diff --git a/.cspell.json b/.cspell.json
index c0d269afca0..5a57f0f0083 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -3,6 +3,7 @@
"version": "0.2",
"language": "en",
"words": [
+ "cacheable",
"Springboot",
"tmproj",
"hgignore",
@@ -399,7 +400,6 @@
"nunito",
"codesee",
"ehlo",
- "logrocket",
"backoff",
"isequal",
"Scriptable",
@@ -501,6 +501,8 @@
"nextjs",
"vanillajs",
"errmsg",
+ "devcontainer",
+ "INITDB",
"springboot",
"errmsg",
"shelljs",
@@ -516,17 +518,36 @@
"Myśliwiec",
"nestframework",
"ryver",
- "idempotency",
- "IDEMPOTENCY",
+ "idempotency",
+ "IDEMPOTENCY",
"Idempotency",
+ "Retryable",
+ "RETRYABLE",
+ "retryable",
"messagebird",
"Datetime",
+ "pubid",
"simpletexting",
"Simpletexting",
"Zulip",
"zulip",
"tspan",
- "maildata"
+ "maildata",
+ "brevo",
+ "Getstream",
+ "getstream",
+ "upstash",
+ "Upstash",
+ "Krakend",
+ "ratelimit",
+ "Ratelimit",
+ "stdev",
+ "Stdev",
+ "openapi",
+ "headerapikey",
+ "INITDB",
+ "isend",
+ "Idand"
],
"flagWords": [],
"patterns": [
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index ed6ad8ce3f0..a7b9f9f3d05 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster
-ARG VARIANT=16-bullseye
+ARG VARIANT=20-bullseye
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# Install MongoDB command line tools if on buster and x86_64 (arm64 not supported)
@@ -23,6 +23,3 @@ RUN . /etc/os-release \
# [Optional] Uncomment if you want to install more global node modules
RUN su node -c "npm install -g pnpm@8.9.0"
-
-
-
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 30c8a74bae2..9a5217e90a2 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -9,7 +9,7 @@ services:
# Update 'VARIANT' to pick an LTS version of Node.js: 16, 14, 12.
# Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon.
- VARIANT: 16-bullseye
+ VARIANT: 20-bullseye
volumes:
- ..:/workspace:cached
@@ -21,7 +21,7 @@ services:
# Uncomment the next line to use a non-root user for all processes.
# user: node
- # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
+ # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
db:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index a5ea37cd2e3..c0b29ba2813 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -64,7 +64,7 @@ body:
attributes:
label: node version
description: In case of self-hosting or local installation mention the node version. If using our cloud-managed solution, mention NA.
- placeholder: 16.0.0
+ placeholder: 20.0.0
- type: textarea
id: additional-context
validations:
diff --git a/.github/actions/docker/build-api/action.yml b/.github/actions/docker/build-api/action.yml
index 4566e4981ba..2c4a51b1b2b 100644
--- a/.github/actions/docker/build-api/action.yml
+++ b/.github/actions/docker/build-api/action.yml
@@ -101,7 +101,7 @@ runs:
docker tag novu-api ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
docker run --network=host --name api -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1337/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1337/v1/health-check | grep 'ok'
echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
diff --git a/.github/actions/docker/build-worker/action.yml b/.github/actions/docker/build-worker/action.yml
index c2cf91bd604..0ee74b07421 100644
--- a/.github/actions/docker/build-worker/action.yml
+++ b/.github/actions/docker/build-worker/action.yml
@@ -104,7 +104,7 @@ runs:
echo "Run image"
docker run --network=host --name worker -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1342/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1342/v1/health-check | grep 'ok'
echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
diff --git a/.github/actions/run-api/action.yml b/.github/actions/run-api/action.yml
index a1e2ecb2f1c..9714366fe32 100644
--- a/.github/actions/run-api/action.yml
+++ b/.github/actions/run-api/action.yml
@@ -24,4 +24,4 @@ runs:
- name: Wait on API
shell: bash
- run: wait-on --timeout=180000 http://localhost:1336/v1/health-check
+ run: wait-on --timeout=180000 http://127.0.0.1:1336/v1/health-check
diff --git a/.github/actions/run-backend/action.yml b/.github/actions/run-backend/action.yml
index b102b92e500..998f96eb84e 100644
--- a/.github/actions/run-backend/action.yml
+++ b/.github/actions/run-backend/action.yml
@@ -26,7 +26,7 @@ runs:
NODE_ENV: "test"
PORT: "1336"
TZ: "UTC"
- GITHUB_OAUTH_REDIRECT: "http://localhost:1336/v1/auth/github/callback"
+ GITHUB_OAUTH_REDIRECT: "http://127.0.0.1:1336/v1/auth/github/callback"
LAUNCH_DARKLY_SDK_KEY: ${{ inputs.launch_darkly_sdk_key }}
run: cd apps/api && pnpm start:build &
@@ -41,4 +41,4 @@ runs:
- name: Wait on API and Worker
shell: bash
- run: wait-on --timeout=180000 http://localhost:1336/v1/health-check http://localhost:1342/v1/health-check
+ run: wait-on --timeout=180000 http://127.0.0.1:1336/v1/health-check http://127.0.0.1:1342/v1/health-check
diff --git a/.github/actions/run-worker/action.yml b/.github/actions/run-worker/action.yml
index 1aa1d2379a9..49df4aa1855 100644
--- a/.github/actions/run-worker/action.yml
+++ b/.github/actions/run-worker/action.yml
@@ -25,4 +25,4 @@ runs:
- name: Wait on worker
shell: bash
- run: wait-on --timeout=180000 http://localhost:1342/v1/health-check
+ run: wait-on --timeout=180000 http://127.0.0.1:1342/v1/health-check
diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml
index f336296f986..b4dd1a89884 100644
--- a/.github/actions/setup-project/action.yml
+++ b/.github/actions/setup-project/action.yml
@@ -11,6 +11,10 @@ inputs:
description: 'Should only install dependencies and checkout code'
required: false
default: 'false'
+ submodules:
+ description: 'Should link submodules'
+ required: false
+ default: 'false'
outputs:
cypress_cache_hit:
description: 'Did cypress use binary cache'
@@ -28,7 +32,7 @@ runs:
- uses: actions/setup-node@v3
name: ⚙️ Setup Node Version
with:
- node-version: '16.15.1'
+ node-version: '20.8.1'
cache: 'pnpm'
- name: 💵 Start Redis
@@ -61,6 +65,12 @@ runs:
shell: bash
run: pnpm install --frozen-lockfile
+
+ - name: Link submodules
+ shell: bash
+ if: ${{ inputs.submodules == 'true' }}
+ run: pnpm symlink:submodules
+
- name: Install wait-on plugin
shell: bash
run: pnpm i -g wait-on
diff --git a/.github/actions/start-localstack/action.yml b/.github/actions/start-localstack/action.yml
index 7f99da25234..e7a2a8a32be 100644
--- a/.github/actions/start-localstack/action.yml
+++ b/.github/actions/start-localstack/action.yml
@@ -24,9 +24,9 @@ runs:
do
sleep 1
[[ counter -eq $max_retry ]] && echo "Failed!" && exit 1
- aws --endpoint-url=http://localhost:4566 s3 ls
+ aws --endpoint-url=http://127.0.0.1:4566 s3 ls
echo "Trying again. Try #$counter"
((counter++))
done
docker-compose -f docker-compose.localstack.yml logs --tail="all"
- aws --endpoint-url=http://localhost:4566 --cli-connect-timeout 600 s3 mb s3://novu-test
+ aws --endpoint-url=http://127.0.0.1:4566 --cli-connect-timeout 600 s3 mb s3://novu-test
diff --git a/.github/actions/validate-openapi/action.yml b/.github/actions/validate-openapi/action.yml
new file mode 100644
index 00000000000..73b3f3444d5
--- /dev/null
+++ b/.github/actions/validate-openapi/action.yml
@@ -0,0 +1,18 @@
+name: Validate OpenAPI
+
+description: Validates the OpenAPI from the API
+
+runs:
+ using: composite
+
+ steps:
+ - uses: mansagroup/nrwl-nx-action@v3
+ env:
+ PORT: '1336'
+ with:
+ targets: lint:openapi
+ projects: '@novu/api'
+
+ - name: Kill port for api 1336 for unit tests
+ shell: bash
+ run: sudo kill -9 $(sudo lsof -t -i:1336)
diff --git a/.github/actions/validate-swagger/action.yml b/.github/actions/validate-swagger/action.yml
index 855874f628d..e69de29bb2d 100644
--- a/.github/actions/validate-swagger/action.yml
+++ b/.github/actions/validate-swagger/action.yml
@@ -1,20 +0,0 @@
-name: Validate Swagger
-
-description: Validates the swagger from the API
-
-runs:
- using: composite
-
- steps:
- - name: Get swagger json as file
- shell: bash
- run: |
- curl -o swagger.json http://localhost:1336/api-json
-
- - uses: char0n/swagger-editor-validate@v1
- with:
- definition-file: swagger.json
-
- - name: Kill port for api 1336 for unit tests
- shell: bash
- run: sudo kill -9 $(sudo lsof -t -i:1336)
diff --git a/.github/workflows/community-label.yml b/.github/workflows/community-label.yml
index c317dfe0c80..bac384db43b 100644
--- a/.github/workflows/community-label.yml
+++ b/.github/workflows/community-label.yml
@@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 20.8.1
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action
diff --git a/.github/workflows/dev-deploy-api.yml b/.github/workflows/dev-deploy-api.yml
index ba86751ca37..8a24b605ccb 100644
--- a/.github/workflows/dev-deploy-api.yml
+++ b/.github/workflows/dev-deploy-api.yml
@@ -24,49 +24,10 @@ jobs:
name: ['novu/api-ee', 'novu/api']
uses: ./.github/workflows/reusable-api-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "next"
+ ee: ${{ contains (matrix.name,'-ee') }}
+ job-name: ${{ matrix.name }}
secrets: inherit
- test_e2e_ee:
- name: Test E2E EE
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
- steps:
- - run: echo ${{ matrix.projectName }}
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
- - uses: ./.github/actions/setup-redis-cluster
- - uses: mansagroup/nrwl-nx-action@v3
- name: Lint and build
- with:
- targets: lint,build
- projects: ${{matrix.projectName}}
-
- - uses: ./.github/actions/start-localstack
-
- - uses: ./.github/actions/run-worker
- if: ${{matrix.projectName == '@novu/api' }}
- with:
- launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
-
- - uses: mansagroup/nrwl-nx-action@v3
- name: Running the E2E tests
- env:
- LAUNCH_DARKLY_SDK_KEY: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
- GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
- GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
- CI_EE_TEST: true
- with:
- targets: test:e2e:ee
- projects: ${{matrix.projectName}}
-
deploy_dev_api:
if: "!contains(github.event.head_commit.message, 'ci skip')"
# The type of runner that the job will run on
@@ -84,7 +45,13 @@ jobs:
name: ['novu/api-ee', 'novu/api']
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+
- uses: ./.github/actions/docker/build-api
id: docker_build
with:
@@ -95,7 +62,7 @@ jobs:
bullmq_secret: ${{ secrets.BULL_MQ_PRO_NPM_TOKEN }}
- name: Checkout cloud infra
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
uses: actions/checkout@master
with:
repository: novuhq/cloud-infra
@@ -103,7 +70,7 @@ jobs:
path: cloud-infra
- name: Configure AWS credentials
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
@@ -111,7 +78,7 @@ jobs:
aws-region: eu-west-2
- name: Terraform setup
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
uses: hashicorp/setup-terraform@v1
with:
cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
@@ -119,13 +86,13 @@ jobs:
terraform_wrapper: false
- name: Terraform Init
- if: ${{ contains (matrix.name,'ee') }}
- working-directory: cloud-infra/terraform/novu
+ if: ${{ contains (matrix.name,'-ee') }}
+ working-directory: cloud-infra/terraform/novu/aws
run: terraform init
- name: Terraform get output
- if: ${{ contains (matrix.name,'ee') }}
- working-directory: cloud-infra/terraform/novu
+ if: ${{ contains (matrix.name,'-ee') }}
+ working-directory: cloud-infra/terraform/novu/aws
id: terraform
run: |
echo "api_ecs_container_name=$(terraform output -json api_ecs_container_name | jq -r .)" >> $GITHUB_ENV
@@ -134,13 +101,13 @@ jobs:
echo "api_task_name=$(terraform output -json api_task_name | jq -r .)" >> $GITHUB_ENV
- name: Download task definition
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
run: |
aws ecs describe-task-definition --task-definition ${{ env.api_task_name }} \
--query taskDefinition > task-definition.json
- name: Render Amazon ECS task definition
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
id: render-web-container
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
@@ -149,7 +116,7 @@ jobs:
image: ${{ steps.docker_build.outputs.image }}
- name: Deploy to Amazon ECS service
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.render-web-container.outputs.task-definition }}
@@ -158,14 +125,14 @@ jobs:
wait-for-service-stability: true
- name: get-npm-version
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: apps/api
- name: Create Sentry release
- if: ${{ contains (matrix.name,'ee') }}
+ if: ${{ contains (matrix.name,'-ee') }}
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
diff --git a/.github/workflows/dev-deploy-embed.yml b/.github/workflows/dev-deploy-embed.yml
index f9c79244aeb..8b01ef9cfae 100644
--- a/.github/workflows/dev-deploy-embed.yml
+++ b/.github/workflows/dev-deploy-embed.yml
@@ -17,54 +17,26 @@ on:
jobs:
# This workflow contains a single job called "build"
deploy_embed:
- environment: Development
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
if: "!contains(github.event.head_commit.message, 'ci skip')"
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' npm run build:embed
-
- - name: Build
- working-directory: libs/embed
- run: CI='' npm run build:dev
-
- - name: Deploy EMBED to DEV
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: libs/embed/dist
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: dev
- production-deploy: true
- alias: dev
- github-deployment-environment: development
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 22682666-3a8d-40be-af26-017bfadf5ae9
- timeout-minutes: 1
-
- - name: Remove build outputs
- working-directory: libs/embed
- run: rm -rf dist
-
- - name: Build, tag, and push image to Amazon ECR
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/embed
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- docker build -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG -f libs/embed/Dockerfile .
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-embed-deploy.yml
+ with:
+ environment: Development
+ widget_url: https://dev.widget.novu.co
+ netlify_deploy_message: Dev deployment
+ netlify_alias: dev
+ netlify_gh_env: development
+ netlify_site_id: 22682666-3a8d-40be-af26-017bfadf5ae9
+ secrets: inherit
+
+ publish_docker_image_embed:
+ needs: deploy_embed
+ if: "!contains(github.event.head_commit.message, 'ci skip')"
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Development
+ package_name: novu/embed
+ project_path: libs/embed
+ local_tag: novu-embed
+ env_tag: dev
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/dev-deploy-inbound-mail.yml b/.github/workflows/dev-deploy-inbound-mail.yml
index af72d5d48b2..d9ec0c04061 100644
--- a/.github/workflows/dev-deploy-inbound-mail.yml
+++ b/.github/workflows/dev-deploy-inbound-mail.yml
@@ -24,9 +24,7 @@ jobs:
name: ['novu/inbound-mail-ee', 'novu/inbound-mail']
uses: ./.github/workflows/reusable-inbound-mail-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "next"
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
dev_deploy_inbound_mail:
@@ -103,11 +101,11 @@ jobs:
terraform_wrapper: false
- name: Terraform Init
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
run: terraform init
- name: Terraform get output
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
id: terraform
run: |
echo "inbound_mail_ecs_container_name=$(terraform output -json inbound_mail_ecs_container_name | jq -r .)" >> $GITHUB_ENV
diff --git a/.github/workflows/dev-deploy-web.yml b/.github/workflows/dev-deploy-web.yml
index de9e8717b23..00357189d9e 100644
--- a/.github/workflows/dev-deploy-web.yml
+++ b/.github/workflows/dev-deploy-web.yml
@@ -19,112 +19,37 @@ jobs:
test_web:
uses: ./.github/workflows/reusable-web-e2e.yml
with:
- submodules: true
- submodule_branch: 'next'
+ ee: true
secrets: inherit
- # This workflow contains a single job called "build"
deploy_web:
needs: test_web
- environment: Development
if: "!contains(github.event.head_commit.message, 'ci skip')"
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- - name: Build
- run: CI='' pnpm build:web
-
- - name: Create env file
- working-directory: apps/web
- run: |
- touch .env
- echo REACT_APP_API_URL="https://dev.api.novu.co" >> .env
- echo REACT_APP_WS_URL="https://dev.ws.novu.co" >> .env
- echo REACT_APP_WEBHOOK_URL="https://dev.webhook.novu.co" >> .env
- echo REACT_APP_WIDGET_EMBED_PATH="https://dev.embed.novu.co/embed.umd.min.js" >> .env
- echo REACT_APP_NOVU_APP_ID=${{ secrets.NOVU_APP_ID }} >> .env
- echo REACT_APP_SEGMENT_KEY=${{ secrets.WEB_SEGMENT_KEY }} >> .env
- echo REACT_APP_SENTRY_DSN="https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907" >> .env
- echo REACT_APP_ENVIRONMENT=dev >> .env
- echo REACT_APP_MAIL_SERVER_DOMAIN="dev.inbound-mail.novu.co" >> .env
- echo REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=${{ secrets.DEV_LAUNCH_DARKLY_CLIENT_SIDE_ID }} >> .env
-
- - name: Envsetup
- working-directory: apps/web
- run: npm run envsetup
-
- # Runs a single command using the runners shell
- - name: Build
- env:
- REACT_APP_SEGMENT_KEY: ${{ secrets.WEB_SEGMENT_KEY }}
- REACT_APP_INTERCOM_APP_ID: ${{ secrets.INTERCOM_APP_ID }}
- REACT_APP_API_URL: https://dev.api.novu.co
- REACT_APP_WS_URL: https://dev.ws.novu.co
- REACT_APP_WEBHOOK_URL: https://dev.webhook.novu.co
- REACT_APP_WIDGET_EMBED_PATH: https://dev.embed.novu.co/embed.umd.min.js
- REACT_APP_NOVU_APP_ID: ${{ secrets.NOVU_APP_ID }}
- REACT_APP_SENTRY_DSN: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
- REACT_APP_ENVIRONMENT: dev
- REACT_APP_MAIL_SERVER_DOMAIN: dev.inbound-mail.novu.co
- REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID: ${{ secrets.DEV_LAUNCH_DARKLY_CLIENT_SIDE_ID }}
- working-directory: apps/web
- run: npm run build
-
- - name: Deploy WEB to DEV
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: apps/web/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: Dev deployment
- production-deploy: true
- alias: dev
- github-deployment-environment: development
- github-deployment-description: Web Deployment
- netlify-config-path: apps/web/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 45396446-dc86-4ad6-81e4-86d3eb78d06f
- timeout-minutes: 1
-
- - name: Setup Depot
- uses: depot/setup-action@v1
- with:
- oidc: true
-
- - name: Remove build outputs
- working-directory: apps/web
- run: rm -rf build
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/web
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- DEPOT_PROJECT_ID: f88777ff6m
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- depot build --push \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev \
- -f apps/web/Dockerfile .
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-web-deploy.yml
+ with:
+ environment: Development
+ react_app_api_url: https://dev.api.novu.co
+ react_app_ws_url: https://dev.ws.novu.co
+ react_app_webhook_url: https://dev.webhook.novu.co
+ react_app_widget_embed_path: https://dev.embed.novu.co/embed.umd.min.js
+ react_app_sentry_dsn: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
+ react_app_environment: dev
+ react_app_mail_server_domain: dev.inbound-mail.novu.co
+ netlify_deploy_message: Dev deployment
+ netlify_alias: dev
+ netlify_gh_env: development
+ netlify_site_id: 45396446-dc86-4ad6-81e4-86d3eb78d06f
+ secrets: inherit
- - uses: actions/upload-artifact@v3
- if: failure()
- with:
- name: cypress-screenshots
- path: apps/web/cypress/screenshots
+ publish_docker_image_web:
+ needs: test_web
+ if: "!contains(github.event.head_commit.message, 'ci skip')"
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Development
+ package_name: novu/web
+ project_path: apps/web
+ local_tag: novu-web
+ env_tag: dev
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/dev-deploy-webhook.yml b/.github/workflows/dev-deploy-webhook.yml
index e51bcca3147..bb643f3c504 100644
--- a/.github/workflows/dev-deploy-webhook.yml
+++ b/.github/workflows/dev-deploy-webhook.yml
@@ -14,100 +14,33 @@ on:
- 'apps/webhook/**'
- 'libs/dal/**'
- 'libs/shared/**'
-env:
- TF_WORKSPACE: novu-dev
jobs:
test_webhook:
uses: ./.github/workflows/reusable-webhook-e2e.yml
- deploy_dev_webhook:
+ publish_docker_image_webhook:
if: "!contains(github.event.head_commit.message, 'ci skip')"
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
needs: test_webhook
- timeout-minutes: 80
- environment: Development
- permissions:
- contents: read
- packages: write
- deployments: write
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- - name: Build, tag, and push image to Amazon ECR
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/webhook
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- cd apps/webhook && DOCKER_BUILDKIT=1 npm run docker:build
- docker tag novu-webhook ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker tag novu-webhook ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
-
- docker run --network=host --name webhook -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1341/v1/health-check | grep 'ok'
-
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
-
- - name: Checkout cloud infra
- uses: actions/checkout@master
- with:
- repository: novuhq/cloud-infra
- token: ${{ secrets.GH_PACKAGES }}
- path: cloud-infra
-
- - name: Configure AWS credentials
- uses: aws-actions/configure-aws-credentials@v1
- with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- aws-region: eu-west-2
-
- - name: Terraform setup
- uses: hashicorp/setup-terraform@v1
- with:
- cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
- terraform_version: 1.5.5
- terraform_wrapper: false
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Development
+ package_name: novu/webhook
+ project_path: apps/webhook
+ test_port: 1341
+ health_check: true
+ local_tag: novu-webhook
+ env_tag: dev
+ depot_project_id: f88777ff6m
+ secrets: inherit
- - name: Terraform Init
- working-directory: cloud-infra/terraform/novu
- run: terraform init
-
- - name: Terraform get output
- working-directory: cloud-infra/terraform/novu
- id: terraform
- run: |
- echo "webhook_ecs_container_name=$(terraform output -json webhook_ecs_container_name | jq -r .)" >> $GITHUB_ENV
- echo "webhook_ecs_service=$(terraform output -json webhook_ecs_service | jq -r .)" >> $GITHUB_ENV
- echo "webhook_ecs_cluster=$(terraform output -json webhook_ecs_cluster | jq -r .)" >> $GITHUB_ENV
- echo "webhook_task_name=$(terraform output -json webhook_task_name | jq -r .)" >> $GITHUB_ENV
-
- - name: Download task definition
- run: |
- aws ecs describe-task-definition --task-definition ${{ env.webhook_task_name }} \
- --query taskDefinition > task-definition.json
-
- - name: Render Amazon ECS task definition
- id: render-web-container
- uses: aws-actions/amazon-ecs-render-task-definition@v1
- with:
- task-definition: task-definition.json
- container-name: ${{ env.webhook_ecs_container_name }}
- image: ${{ steps.build-image.outputs.IMAGE }}
-
- - name: Deploy to Amazon ECS service
- uses: aws-actions/amazon-ecs-deploy-task-definition@v1
- with:
- task-definition: ${{ steps.render-web-container.outputs.task-definition }}
- service: ${{ env.webhook_ecs_service }}
- cluster: ${{ env.webhook_ecs_cluster }}
- wait-for-service-stability: true
+ deploy_dev_webhook:
+ if: "!contains(github.event.head_commit.message, 'ci skip')"
+ needs: publish_docker_image_webhook
+ uses: ./.github/workflows/reusable-app-service-deploy.yml
+ secrets: inherit
+ with:
+ environment: Development
+ service_name: webhook
+ terraform_workspace: novu-dev
+ docker_image: ${{ needs.publish_docker_image_webhook.outputs.docker_image_ee }}
diff --git a/.github/workflows/dev-deploy-widget.yml b/.github/workflows/dev-deploy-widget.yml
index e5c4aed035c..ac70c52d6df 100644
--- a/.github/workflows/dev-deploy-widget.yml
+++ b/.github/workflows/dev-deploy-widget.yml
@@ -20,84 +20,35 @@ jobs:
test_widget:
uses: ./.github/workflows/reusable-widget-e2e.yml
with:
- submodules: true
- submodule_branch: "next"
+ ee: true
secrets: inherit
- # This workflow contains a single job called "build"
deploy_widget:
needs: test_widget
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
if: "!contains(github.event.head_commit.message, 'ci skip')"
- environment: Development
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:widget
-
- - name: Create env file
- working-directory: apps/widget
- run: |
- touch .env
- echo REACT_APP_API_URL="https://dev.api.novu.co" >> .env
- echo REACT_APP_WS_URL="https://dev.ws.novu.co" >> .env
- echo REACT_APP_WEBHOOK_URL="https://dev.webhook.novu.co" >> .env
- echo REACT_APP_SENTRY_DSN="https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116" >> .env
- echo REACT_APP_ENVIRONMENT=dev >> .env
-
- - name: Envsetup
- working-directory: apps/widget
- run: npm run envsetup
-
- - name: Build PROD
- working-directory: apps/widget
- run: npm run build
-
- - name: Deploy WIDGET to DEV
- uses: scopsy/actions-netlify@develop
- with:
- publish-dir: apps/widget/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: dev
- production-deploy: true
- alias: dev
- github-deployment-environment: development
- github-deployment-description: Web Deployment
- netlify-config-path: apps/widget/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: b9147448-b835-4eb1-a2f0-11102f611f5f
- timeout-minutes: 1
-
- - name: Remove build outputs
- working-directory: apps/widget
- run: rm -rf build
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/widget
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- docker build -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG -f apps/widget/Dockerfile .
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-widget-deploy.yml
+ with:
+ environment: Development
+ react_app_api_url: https://dev.api.novu.co
+ react_app_ws_url: https://dev.ws.novu.co
+ react_app_webhook_url: https://dev.webhook.novu.co
+ react_app_sentry_dsn: https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116
+ react_app_environment: dev
+ netlify_deploy_message: Dev deployment
+ netlify_alias: dev
+ netlify_gh_env: development
+ netlify_site_id: b9147448-b835-4eb1-a2f0-11102f611f5f
+ secrets: inherit
- - uses: actions/upload-artifact@v3
- if: failure()
- with:
- name: cypress-screenshots
- path: apps/widget/cypress/screenshots
+ publish_docker_image_widget:
+ needs: test_widget
+ if: "!contains(github.event.head_commit.message, 'ci skip')"
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Development
+ package_name: novu/widget
+ project_path: apps/widget
+ local_tag: novu-widget
+ env_tag: dev
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/dev-deploy-worker.yml b/.github/workflows/dev-deploy-worker.yml
index dabe27c4e7b..cfbfd53d0c9 100644
--- a/.github/workflows/dev-deploy-worker.yml
+++ b/.github/workflows/dev-deploy-worker.yml
@@ -28,12 +28,10 @@ jobs:
name: ['novu/worker', 'novu/worker-ee']
uses: ./.github/workflows/reusable-worker-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "next"
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
- deploy_dev_worker:
+ build_dev_worker:
if: "!contains(github.event.head_commit.message, 'ci skip')"
# The type of runner that the job will run on
runs-on: ubuntu-latest
@@ -50,7 +48,13 @@ jobs:
name: ['novu/worker-ee', 'novu/worker']
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+
- uses: ./.github/actions/docker/build-worker
id: docker_build
with:
@@ -60,96 +64,32 @@ jobs:
docker_name: ${{ matrix.name }}
bullmq_secret: ${{ secrets.BULL_MQ_PRO_NPM_TOKEN }}
- - name: Checkout cloud infra
- if: ${{ contains (matrix.name,'ee') }}
- uses: actions/checkout@master
- with:
- repository: novuhq/cloud-infra
- token: ${{ secrets.GH_PACKAGES }}
- path: cloud-infra
-
- - name: Configure AWS credentials
- if: ${{ contains (matrix.name,'ee') }}
- uses: aws-actions/configure-aws-credentials@v1
- with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- aws-region: eu-west-2
-
- - name: Terraform setup
- uses: hashicorp/setup-terraform@v1
- if: ${{ contains (matrix.name,'ee') }}
- with:
- cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
- terraform_version: 1.5.5
- terraform_wrapper: false
-
- - name: Terraform Init
- if: ${{ contains (matrix.name,'ee') }}
- working-directory: cloud-infra/terraform/novu
- run: terraform init
-
- - name: Terraform get output
- working-directory: cloud-infra/terraform/novu
- if: ${{ contains (matrix.name,'ee') }}
- id: terraform
- run: |
- echo "worker_ecs_container_name=$(terraform output -json worker_ecs_container_name | jq -r .)" >> $GITHUB_ENV
- echo "worker_ecs_service=$(terraform output -json worker_ecs_service | jq -r .)" >> $GITHUB_ENV
- echo "worker_ecs_cluster=$(terraform output -json worker_ecs_cluster | jq -r .)" >> $GITHUB_ENV
- echo "worker_task_name=$(terraform output -json worker_task_name | jq -r .)" >> $GITHUB_ENV
-
- - name: Download task definition
- if: ${{ contains (matrix.name,'ee') }}
- run: |
- aws ecs describe-task-definition --task-definition ${{ env.worker_task_name }} \
- --query taskDefinition > task-definition.json
-
- - name: Render Amazon ECS task definition
- if: ${{ contains (matrix.name,'ee') }}
- id: render-web-container
- uses: aws-actions/amazon-ecs-render-task-definition@v1
- with:
- task-definition: task-definition.json
- container-name: ${{ env.worker_ecs_container_name }}
- image: ${{ steps.docker_build.outputs.image }}
-
- - name: Deploy to Amazon ECS service
- if: ${{ contains (matrix.name,'ee') }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v1
- with:
- task-definition: ${{ steps.render-web-container.outputs.task-definition }}
- service: ${{ env.worker_ecs_service }}
- cluster: ${{ env.worker_ecs_cluster }}
- wait-for-service-stability: true
-
- - name: get-npm-version
- id: package-version
- if: ${{ contains (matrix.name,'ee') }}
- uses: martinbeentjes/npm-get-version-action@main
- with:
- path: apps/worker
+ # Temporary for the migration phase
+ deploy_general_worker:
+ needs: build_dev_worker
+ uses: ./.github/workflows/reusable-app-service-deploy.yml
+ secrets: inherit
+ with:
+ environment: Development
+ service_name: worker
+ terraform_workspace: novu-dev
+ # This is a workaround to an issue with matrix outputs
+ docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
- - name: Create Sentry release
- if: ${{ contains (matrix.name,'ee') }}
- uses: getsentry/action-release@v1
- env:
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- SENTRY_ORG: novu-r9
- SENTRY_PROJECT: worker
- with:
- version: ${{ steps.package-version.outputs.current-version}}
- environment: dev
- version_prefix: v
- sourcemaps: apps/worker/dist
- ignore_empty: true
- ignore_missing: true
- url_prefix: "~"
+ deploy_dev_workers:
+ needs: deploy_general_worker
+ uses: ./.github/workflows/reusable-workers-service-deploy.yml
+ secrets: inherit
+ with:
+ environment: Development
+ terraform_workspace: novu-dev
+ # This is a workaround to an issue with matrix outputs
+ docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
newrelic:
runs-on: ubuntu-latest
name: New Relic Deploy
- needs: deploy_dev_worker
+ needs: deploy_dev_workers
environment: Development
steps:
# This step builds a var with the release tag value to use later
diff --git a/.github/workflows/dev-deploy-ws.yml b/.github/workflows/dev-deploy-ws.yml
index 7acc3a28dbd..fd9f714c534 100644
--- a/.github/workflows/dev-deploy-ws.yml
+++ b/.github/workflows/dev-deploy-ws.yml
@@ -20,9 +20,7 @@ jobs:
name: ['novu/ws-ee', 'novu/ws']
uses: ./.github/workflows/reusable-ws-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: 'next'
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
# This workflow contains a single job called "build"
@@ -40,7 +38,12 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
@@ -63,11 +66,11 @@ jobs:
terraform_version: 1.5.5
terraform_wrapper: false
- name: Terraform Init
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
run: terraform init
- name: Terraform get output
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
id: terraform
run: |
echo "ws_ecs_container_name=$(terraform output -json ws_ecs_container_name | jq -r .)" >> $GITHUB_ENV
@@ -99,7 +102,7 @@ jobs:
echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
docker build -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG --build-arg BULL_MQ_PRO_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} -f apps/ws/Dockerfile .
docker run --network=host --name api -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1340/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1340/v1/health-check | grep 'ok'
docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
diff --git a/.github/workflows/issue-label.yml b/.github/workflows/issue-label.yml
index a5b107db6f3..9c07d7ed6ee 100644
--- a/.github/workflows/issue-label.yml
+++ b/.github/workflows/issue-label.yml
@@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 20.8.1
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action
diff --git a/.github/workflows/milestone-assign.yml b/.github/workflows/milestone-assign.yml
index e1e48065dab..517fef45c76 100644
--- a/.github/workflows/milestone-assign.yml
+++ b/.github/workflows/milestone-assign.yml
@@ -21,7 +21,7 @@ jobs:
- uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 20.8.1
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
index b3f25ad9105..6fca208686f 100644
--- a/.github/workflows/pr-labeler.yml
+++ b/.github/workflows/pr-labeler.yml
@@ -1,20 +1,25 @@
-name: "Pull Request Labeller"
+name: "Pull Request Labeler"
+
on:
- pull_request_target
jobs:
- triage:
+ on_pr:
permissions:
contents: read
pull-requests: write
+ statuses: write
runs-on: ubuntu-latest
steps:
- - uses: actions/labeler@v4
+ - uses: actions/checkout@v4
+
+ - name: PR Labels
+ uses: actions/labeler@v4
with:
- repo-token: "${{ secrets.GITHUB_TOKEN }}"
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
- - uses: microsoft/PR-Metrics@v1.5.7
- name: PR Metrics
+ - name: PR Metrics
+ uses: microsoft/PR-Metrics@v1.5.7
env:
PR_METRICS_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
diff --git a/.github/workflows/prod-deploy-api.yml b/.github/workflows/prod-deploy-api.yml
index 8f886ad620f..528f3f41e13 100644
--- a/.github/workflows/prod-deploy-api.yml
+++ b/.github/workflows/prod-deploy-api.yml
@@ -13,49 +13,10 @@ jobs:
name: [ 'novu/api-ee', 'novu/api' ]
uses: ./.github/workflows/reusable-api-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "main"
+ ee: ${{ contains (matrix.name,'-ee') }}
+ job-name: ${{ matrix.name }}
secrets: inherit
- test_e2e_ee:
- name: Test E2E EE
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
- steps:
- - run: echo ${{ matrix.projectName }}
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
- - uses: ./.github/actions/setup-redis-cluster
- - uses: mansagroup/nrwl-nx-action@v3
- name: Lint and build
- with:
- targets: lint,build
- projects: ${{matrix.projectName}}
-
- - uses: ./.github/actions/start-localstack
-
- - uses: ./.github/actions/run-worker
- if: ${{matrix.projectName == '@novu/api' }}
- with:
- launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
-
- - uses: mansagroup/nrwl-nx-action@v3
- name: Running the E2E tests
- env:
- LAUNCH_DARKLY_SDK_KEY: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
- GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
- GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
- CI_EE_TEST: true
- with:
- targets: test:e2e:ee
- projects: ${{matrix.projectName}}
-
build_prod_image:
if: "!contains(github.event.head_commit.message, 'ci skip')"
# The type of runner that the job will run on
@@ -75,10 +36,15 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
- name: build api
- run: pnpm build:api
+ run: pnpm build:api --skip-nx-cache
- name: Setup Depot
uses: depot/setup-action@v1
@@ -108,7 +74,7 @@ jobs:
docker tag novu-api ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
docker run --network=host --name api -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1337/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1337/v1/health-check | grep 'ok'
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
diff --git a/.github/workflows/prod-deploy-embed.yml b/.github/workflows/prod-deploy-embed.yml
index 07cbb1e0547..e8731355c48 100644
--- a/.github/workflows/prod-deploy-embed.yml
+++ b/.github/workflows/prod-deploy-embed.yml
@@ -10,112 +10,37 @@ on:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
deploy_embed_eu:
- environment: Production
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
- if: "!contains(github.event.head_commit.message, 'ci skip')"
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
+ uses: ./.github/workflows/reusable-embed-deploy.yml
+ with:
+ environment: Production
+ widget_url: https://eu.widget.novu.co
+ netlify_deploy_message: Production deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: 0c830b50-df83-480b-ba36-a7f3176efcc8
+ secrets: inherit
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' npm run build:embed
-
- - name: Build
- working-directory: libs/embed
- env:
- WIDGET_URL: https://eu.widget.novu.co
- run: CI='' npm run build:prod
-
- - name: Deploy EMBED to PROD
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: libs/embed/dist
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: prod
- production-deploy: true
- alias: Prod
- github-deployment-environment: Production
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 0c830b50-df83-480b-ba36-a7f3176efcc8
- timeout-minutes: 1
-
- # This workflow contains a single job called "build"
deploy_embed_us:
- environment: Production
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- needs: deploy_embed_eu
- timeout-minutes: 80
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' npm run build:embed
-
- - name: Build
- working-directory: libs/embed
- env:
- WIDGET_URL: https://widget.novu.co
- run: CI='' npm run build:prod
-
- - name: Deploy EMBED to PROD
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: libs/embed/dist
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: prod
- production-deploy: true
- alias: Prod
- github-deployment-environment: Production
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 0689c015-fca0-4940-a26d-3e33f561bc48
- timeout-minutes: 1
-
- deploy_embed:
- environment: Production
- runs-on: ubuntu-latest
+ uses: ./.github/workflows/reusable-embed-deploy.yml
+ with:
+ environment: Production
+ widget_url: https://widget.novu.co
+ netlify_deploy_message: Production deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: 0689c015-fca0-4940-a26d-3e33f561bc48
+ secrets: inherit
+
+ publish_docker_image_embed:
needs:
- deploy_embed_us
- deploy_embed_eu
- timeout-minutes: 80
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' npm run build:embed
-
- - name: Build
- working-directory: libs/embed
- run: CI='' npm run build:prod
-
- - name: Remove build outputs
- working-directory: libs/embed
- run: rm -rf dist
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/embed
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- docker build -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG -f libs/embed/Dockerfile .
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Production
+ package_name: novu/embed
+ project_path: libs/embed
+ local_tag: novu-embed
+ env_tag: prod
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/prod-deploy-inbound-mail.yml b/.github/workflows/prod-deploy-inbound-mail.yml
index 9ebf2bf2413..af6e3086d67 100644
--- a/.github/workflows/prod-deploy-inbound-mail.yml
+++ b/.github/workflows/prod-deploy-inbound-mail.yml
@@ -12,9 +12,7 @@ jobs:
name: [ 'novu/inbound-mail-ee', 'novu/inbound-mail' ]
uses: ./.github/workflows/reusable-inbound-mail-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "main"
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
build_prod_image:
diff --git a/.github/workflows/prod-deploy-web.yml b/.github/workflows/prod-deploy-web.yml
index b757e13f394..768bd9273bf 100644
--- a/.github/workflows/prod-deploy-web.yml
+++ b/.github/workflows/prod-deploy-web.yml
@@ -12,209 +12,57 @@ jobs:
test_web:
uses: ./.github/workflows/reusable-web-e2e.yml
with:
- submodules: true
- submodule_branch: 'main'
+ ee: true
secrets: inherit
deploy_web_eu:
needs: test_web
- environment: Production
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:web
-
- - name: Create env file
- working-directory: apps/web
- run: |
- touch .env
- echo REACT_APP_INTERCOM_APP_ID=${{ secrets.INTERCOM_APP_ID }} >> .env
- echo REACT_APP_API_URL="https://eu.api.novu.co" >> .env
- echo REACT_APP_WS_URL="https://eu.ws.novu.co" >> .env
- echo REACT_APP_WEBHOOK_URL="https://eu.webhook.novu.co" >> .env
- echo REACT_APP_WIDGET_EMBED_PATH="https://eu.embed.novu.co/embed.umd.min.js" >> .env
- echo REACT_APP_NOVU_APP_ID=${{ secrets.NOVU_APP_ID }} >> .env
- echo REACT_APP_SENTRY_DSN="https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907" >> .env
- echo REACT_APP_ENVIRONMENT=production >> .env
- echo REACT_APP_SEGMENT_KEY=${{ secrets.WEB_SEGMENT_KEY }} >> .env
- echo REACT_APP_LOGROCKET_ID=${{ secrets.LOGROCKET_ID }} >> .env
- echo REACT_APP_MAIL_SERVER_DOMAIN="eu.inbound-mail.novu.co" >> .env
- echo REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=${{ secrets.PROD_LAUNCH_DARKLY_CLIENT_SIDE_ID }} >> .env
-
- - name: Envsetup
- working-directory: apps/web
- run: npm run envsetup
-
- - name: Build
- env:
- REACT_APP_INTERCOM_APP_ID: ${{ secrets.INTERCOM_APP_ID }}
- REACT_APP_SEGMENT_KEY: ${{ secrets.WEB_SEGMENT_KEY }}
- REACT_APP_API_URL: https://eu.api.novu.co
- REACT_APP_WS_URL: https://eu.ws.novu.co
- REACT_APP_WEBHOOK_URL: https://eu.webhook.novu.co
- REACT_APP_WIDGET_EMBED_PATH: https://eu.embed.novu.co/embed.umd.min.js
- REACT_APP_NOVU_APP_ID: ${{ secrets.NOVU_APP_ID }}
- REACT_APP_LOGROCKET_ID: ${{ secrets.LOGROCKET_ID }}
- REACT_APP_SENTRY_DSN: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
- REACT_APP_ENVIRONMENT: production
- REACT_APP_MAIL_SERVER_DOMAIN: eu.inbound-mail.novu.co
- REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID: ${{ secrets.PROD_LAUNCH_DARKLY_CLIENT_SIDE_ID }}
-
- working-directory: apps/web
- run: npm run build
-
- - name: Deploy WEB to PROD
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: apps/web/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: Prod deployment
- production-deploy: true
- alias: prod
- github-deployment-environment: Production
- github-deployment-description: Web Deployment
- netlify-config-path: apps/web/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: d2e8b860-7016-4202-9256-ebca0f13259a
- timeout-minutes: 1
+ uses: ./.github/workflows/reusable-web-deploy.yml
+ with:
+ environment: Production
+ react_app_api_url: https://eu.api.novu.co
+ react_app_ws_url: https://eu.ws.novu.co
+ react_app_webhook_url: https://eu.webhook.novu.co
+ react_app_widget_embed_path: https://eu.embed.novu.co/embed.umd.min.js
+ react_app_sentry_dsn: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
+ react_app_environment: production
+ react_app_mail_server_domain: eu.inbound-mail.novu.co
+ netlify_deploy_message: Prod deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: d2e8b860-7016-4202-9256-ebca0f13259a
+ secrets: inherit
- # This workflow contains a single job called "build"
deploy_web_us:
needs:
- test_web
- deploy_web_eu
- environment: Production
- if: "!contains(github.event.head_commit.message, 'ci skip')"
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:web
-
- - name: Create env file
- working-directory: apps/web
- run: |
- touch .env
- echo REACT_APP_INTERCOM_APP_ID=${{ secrets.INTERCOM_APP_ID }} >> .env
- echo REACT_APP_API_URL="https://api.novu.co" >> .env
- echo REACT_APP_WS_URL="https://ws.novu.co" >> .env
- echo REACT_APP_WEBHOOK_URL="https://webhook.novu.co" >> .env
- echo REACT_APP_WIDGET_EMBED_PATH="https://embed.novu.co/embed.umd.min.js" >> .env
- echo REACT_APP_NOVU_APP_ID=${{ secrets.NOVU_APP_ID }} >> .env
- echo REACT_APP_SENTRY_DSN="https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907" >> .env
- echo REACT_APP_ENVIRONMENT=production >> .env
- echo REACT_APP_SEGMENT_KEY=${{ secrets.WEB_SEGMENT_KEY }} >> .env
- echo REACT_APP_LOGROCKET_ID=${{ secrets.LOGROCKET_ID }} >> .env
- echo REACT_APP_MAIL_SERVER_DOMAIN="inbound-mail.novu.co" >> .env
- echo REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=${{ secrets.PROD_LAUNCH_DARKLY_CLIENT_SIDE_ID }} >> .env
-
- - name: Envsetup
- working-directory: apps/web
- run: npm run envsetup
-
- - name: Build
- env:
- REACT_APP_INTERCOM_APP_ID: ${{ secrets.INTERCOM_APP_ID }}
- REACT_APP_SEGMENT_KEY: ${{ secrets.WEB_SEGMENT_KEY }}
- REACT_APP_API_URL: https://api.novu.co
- REACT_APP_WS_URL: https://ws.novu.co
- REACT_APP_WEBHOOK_URL: https://webhook.novu.co
- REACT_APP_WIDGET_EMBED_PATH: https://embed.novu.co/embed.umd.min.js
- REACT_APP_NOVU_APP_ID: ${{ secrets.NOVU_APP_ID }}
- REACT_APP_LOGROCKET_ID: ${{ secrets.LOGROCKET_ID }}
- REACT_APP_SENTRY_DSN: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
- REACT_APP_ENVIRONMENT: production
- REACT_APP_MAIL_SERVER_DOMAIN: inbound-mail.novu.co
- REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID: ${{ secrets.PROD_LAUNCH_DARKLY_CLIENT_SIDE_ID }}
- working-directory: apps/web
- run: npm run build
-
- - name: Deploy WEB to PROD
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: apps/web/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: Prod deployment
- production-deploy: true
- alias: prod
- github-deployment-environment: Production
- github-deployment-description: Web Deployment
- netlify-config-path: apps/web/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 8639d8b9-81f9-44c3-b885-585a7fd2b5ff
- timeout-minutes: 1
+ uses: ./.github/workflows/reusable-web-deploy.yml
+ with:
+ environment: Production
+ react_app_api_url: https://api.novu.co
+ react_app_ws_url: https://ws.novu.co
+ react_app_webhook_url: https://webhook.novu.co
+ react_app_widget_embed_path: https://embed.novu.co/embed.umd.min.js
+ react_app_sentry_dsn: https://2b5160da86384949be4cc66679c54e79@o1161119.ingest.sentry.io/6250907
+ react_app_environment: production
+ react_app_mail_server_domain: inbound-mail.novu.co
+ netlify_deploy_message: Prod deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: 8639d8b9-81f9-44c3-b885-585a7fd2b5ff
+ secrets: inherit
deploy_docker:
needs:
- deploy_web_us
- deploy_web_eu
- environment: Production
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- - name: Setup Depot
- uses: depot/setup-action@v1
- with:
- oidc: true
-
- - name: Remove build outputs
- working-directory: apps/web
- run: rm -rf build
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:web
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/web
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- DEPOT_PROJECT_ID: f88777ff6m
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- depot build --push \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest \
- -f apps/web/Dockerfile .
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Production
+ package_name: novu/web
+ project_path: apps/web
+ local_tag: novu-web
+ env_tag: prod
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/prod-deploy-webhook.yml b/.github/workflows/prod-deploy-webhook.yml
index 10f34b95b70..e8020051bab 100644
--- a/.github/workflows/prod-deploy-webhook.yml
+++ b/.github/workflows/prod-deploy-webhook.yml
@@ -8,67 +8,40 @@ on:
jobs:
test_webhook:
uses: ./.github/workflows/reusable-webhook-e2e.yml
-
- build_prod_image:
- runs-on: ubuntu-latest
+
+ publish_docker_image_webhook:
needs: test_webhook
- timeout-minutes: 80
- environment: Production
- outputs:
- docker_image: ${{ steps.build-image.outputs.IMAGE }}
- permissions:
- contents: read
- packages: write
- deployments: write
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- - name: build webhook
- run: pnpm build:webhook
-
- - name: Build, tag, and push image to Amazon ECR
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/webhook
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- cd apps/webhook && DOCKER_BUILDKIT=1 npm run docker:build
- docker tag novu-webhook ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker tag novu-webhook ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker tag novu-webhook ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
-
- docker run --network=host --name webhook -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1341/v1/health-check | grep 'ok'
-
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Production
+ package_name: novu/webhook
+ project_path: apps/webhook
+ test_port: 1341
+ health_check: true
+ local_tag: novu-webhook
+ env_tag: prod
+ depot_project_id: f88777ff6m
+ secrets: inherit
deploy_prod_webhook_eu:
needs:
- - build_prod_image
+ - publish_docker_image_webhook
uses: ./.github/workflows/reusable-app-service-deploy.yml
secrets: inherit
with:
environment: Production
service_name: webhook
terraform_workspace: novu-prod-eu
- docker_image: ${{ needs.build_prod_image.outputs.docker_image }}
+ docker_image: ${{ needs.publish_docker_image_webhook.outputs.docker_image_ee }}
deploy_prod_webhook_us:
needs:
- deploy_prod_webhook_eu
- - build_prod_image
+ - publish_docker_image_webhook
uses: ./.github/workflows/reusable-app-service-deploy.yml
secrets: inherit
with:
environment: Production
service_name: webhook
terraform_workspace: novu-prod
- docker_image: ${{ needs.build_prod_image.outputs.docker_image }}
+ docker_image: ${{ needs.publish_docker_image_webhook.outputs.docker_image_ee }}
diff --git a/.github/workflows/prod-deploy-widget.yml b/.github/workflows/prod-deploy-widget.yml
index 33f1a20d6fa..bf6ad2c64ba 100644
--- a/.github/workflows/prod-deploy-widget.yml
+++ b/.github/workflows/prod-deploy-widget.yml
@@ -11,150 +11,53 @@ jobs:
test_widget:
uses: ./.github/workflows/reusable-widget-e2e.yml
with:
- submodules: true
- submodule_branch: "main"
+ ee: true
secrets: inherit
deploy_widget_eu:
needs: test_widget
- runs-on: ubuntu-latest
- timeout-minutes: 80
- environment: Production
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:widget
-
- - name: Create env file
- working-directory: apps/widget
- run: |
- touch .env
- echo REACT_APP_API_URL="https://eu.api.novu.co" >> .env
- echo REACT_APP_SENTRY_DSN="https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116" >> .env
- echo REACT_APP_WS_URL="https://eu.ws.novu.co" >> .env
- echo REACT_APP_ENVIRONMENT=production >> .env
-
- - name: Envsetup
- working-directory: apps/widget
- run: npm run envsetup
-
- - name: Build PROD
- env:
- REACT_APP_API_URL: https://eu.api.novu.co
- REACT_APP_SENTRY_DSN: https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116
- REACT_APP_WS_URL: https://eu.ws.novu.co
- REACT_APP_ENVIRONMENT: production
- working-directory: apps/widget
- run: npm run build
-
- - name: Deploy WIDGET to PROD
- uses: scopsy/actions-netlify@develop
- with:
- publish-dir: apps/widget/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: prod
- production-deploy: true
- alias: prod
- github-deployment-environment: Production
- github-deployment-description: Web Deployment
- netlify-config-path: apps/widget/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 20a64bdd-1934-4284-875f-862410c69a3b
- timeout-minutes: 1
+ uses: ./.github/workflows/reusable-widget-deploy.yml
+ with:
+ environment: Production
+ react_app_api_url: https://eu.api.novu.co
+ react_app_ws_url: https://eu.ws.novu.co
+ react_app_webhook_url: https://eu.webhook.novu.co
+ react_app_sentry_dsn: https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116
+ react_app_environment: production
+ netlify_deploy_message: Prod deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: 20a64bdd-1934-4284-875f-862410c69a3b
+ secrets: inherit
- # This workflow contains a single job called "build"
deploy_widget_us:
needs:
- test_widget
- deploy_widget_eu
- runs-on: ubuntu-latest
- timeout-minutes: 80
- environment: Production
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:widget
-
- - name: Create env file
- working-directory: apps/widget
- run: |
- touch .env
- echo REACT_APP_API_URL="https://api.novu.co" >> .env
- echo REACT_APP_SENTRY_DSN="https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116" >> .env
- echo REACT_APP_WS_URL="https://ws.novu.co" >> .env
- echo REACT_APP_ENVIRONMENT=production >> .env
-
- - name: Envsetup
- working-directory: apps/widget
- run: npm run envsetup
-
- - name: Build PROD
- env:
- REACT_APP_API_URL: https://api.novu.co
- REACT_APP_SENTRY_DSN: https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116
- REACT_APP_WS_URL: https://ws.novu.co
- REACT_APP_ENVIRONMENT: production
- working-directory: apps/widget
- run: npm run build
-
- - name: Deploy WIDGET to PROD
- uses: scopsy/actions-netlify@develop
- with:
- publish-dir: apps/widget/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: prod
- production-deploy: true
- alias: prod
- github-deployment-environment: Production
- github-deployment-description: Web Deployment
- netlify-config-path: apps/widget/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 6f927fd4-dcb0-4cf3-8c0b-8c5539d0d034
- timeout-minutes: 1
+ uses: ./.github/workflows/reusable-widget-deploy.yml
+ with:
+ environment: Production
+ react_app_api_url: https://api.novu.co
+ react_app_ws_url: https://ws.novu.co
+ react_app_webhook_url: https://webhook.novu.co
+ react_app_sentry_dsn: https://02189965b1bb4cf8bb4776f417f80b92@o1161119.ingest.sentry.io/625116
+ react_app_environment: production
+ netlify_deploy_message: Prod deployment
+ netlify_alias: prod
+ netlify_gh_env: Production
+ netlify_site_id: 6f927fd4-dcb0-4cf3-8c0b-8c5539d0d034
+ secrets: inherit
deploy_docker:
needs:
- deploy_widget_us
- deploy_widget_eu
- runs-on: ubuntu-latest
- timeout-minutes: 80
- environment: Production
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- # Runs a single command using the runners shell
- - name: Build
- run: CI='' pnpm build:widget
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/widget
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- docker build -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG -f apps/widget/Dockerfile .
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
- docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Production
+ package_name: novu/widget
+ project_path: apps/widget
+ local_tag: novu-widget
+ env_tag: prod
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/prod-deploy-worker.yml b/.github/workflows/prod-deploy-worker.yml
index 8ce444cf0c9..8770f21f405 100644
--- a/.github/workflows/prod-deploy-worker.yml
+++ b/.github/workflows/prod-deploy-worker.yml
@@ -13,9 +13,7 @@ jobs:
name: [ 'novu/worker-ee', 'novu/worker' ]
uses: ./.github/workflows/reusable-worker-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: "main"
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
build_prod_image:
@@ -38,10 +36,15 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
- name: build worker
- run: pnpm build:worker
+ run: pnpm build:worker --skip-nx-cache
- name: Setup Depot
uses: depot/setup-action@v1
@@ -71,14 +74,15 @@ jobs:
docker tag novu-worker ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
docker run --network=host --name worker -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1342/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1342/v1/health-check | grep 'ok'
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
- deploy_prod_worker_eu:
+ # Temporary for the migration phase
+ deploy_general_worker_eu:
needs: build_prod_image
uses: ./.github/workflows/reusable-app-service-deploy.yml
secrets: inherit
@@ -89,9 +93,21 @@ jobs:
# This is a workaround to an issue with matrix outputs
docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
- deploy_prod_worker_us:
+ deploy_prod_workers_eu:
+ needs: deploy_general_worker_eu
+ uses: ./.github/workflows/reusable-workers-service-deploy.yml
+ secrets: inherit
+ with:
+ environment: Production
+ terraform_workspace: novu-prod-eu
+ # This is a workaround to an issue with matrix outputs
+ docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
+
+
+ # Temporary for the migration phase
+ deploy_general_worker_us:
needs:
- - deploy_prod_worker_eu
+ - deploy_prod_workers_eu
- build_prod_image
uses: ./.github/workflows/reusable-app-service-deploy.yml
secrets: inherit
@@ -101,13 +117,25 @@ jobs:
terraform_workspace: novu-prod
# This is a workaround to an issue with matrix outputs
docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
+
+ deploy_prod_workers_us:
+ needs:
+ - deploy_general_worker_us
+ - build_prod_image
+ uses: ./.github/workflows/reusable-workers-service-deploy.yml
+ secrets: inherit
+ with:
+ environment: Production
+ terraform_workspace: novu-prod
+ # This is a workaround to an issue with matrix outputs
+ docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }}
deploy_sentry_release: true
sentry_project: worker
newrelic:
runs-on: ubuntu-latest
name: New Relic Deploy
- needs: deploy_prod_worker_us
+ needs: deploy_prod_workers_us
environment: Production
steps:
# This step builds a var with the release tag value to use later
diff --git a/.github/workflows/prod-deploy-ws.yml b/.github/workflows/prod-deploy-ws.yml
index 87b98c2b825..c247b18915d 100644
--- a/.github/workflows/prod-deploy-ws.yml
+++ b/.github/workflows/prod-deploy-ws.yml
@@ -12,9 +12,7 @@ jobs:
name: [ 'novu/ws-ee', 'novu/ws' ]
uses: ./.github/workflows/reusable-ws-e2e.yml
with:
- ee: ${{ contains (matrix.name,'ee') }}
- submodules: ${{ contains (matrix.name,'ee') }}
- submodule_branch: 'main'
+ ee: ${{ contains (matrix.name,'-ee') }}
secrets: inherit
# This workflow contains a single job called "build"
@@ -32,7 +30,12 @@ jobs:
docker_image: ${{ steps.build-image.outputs.IMAGE }}
steps:
- uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
- uses: ./.github/actions/setup-project
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
- name: Set Bull MQ Env variable for EE
if: contains(matrix.name, 'ee')
@@ -55,7 +58,7 @@ jobs:
echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
docker build --build-arg BULL_MQ_PRO_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG -f apps/ws/Dockerfile .
docker run --network=host --name api -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
- docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://localhost:1340/v1/health-check | grep 'ok'
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:1340/v1/health-check | grep 'ok'
docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
diff --git a/.github/workflows/reusable-api-e2e.yml b/.github/workflows/reusable-api-e2e.yml
index 62962c2d5e7..f3f63c7c9fa 100644
--- a/.github/workflows/reusable-api-e2e.yml
+++ b/.github/workflows/reusable-api-e2e.yml
@@ -3,65 +3,101 @@ name: E2E API Tests
# Controls when the action will run. Triggers the workflow on push or pull request
on:
workflow_call:
-
inputs:
- ee:
- description: 'use the ee version of api'
+ test-e2e-affected:
+ description: 'detect if we should run e2e tests'
required: false
- default: false
+ default: true
type: boolean
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
+ test-e2e-ee-affected:
+ description: 'detect if we should run e2e-ee tests'
required: false
- default: false
+ default: true
type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
+ ee:
+ description: 'use the ee version of api'
required: false
- default: 'main'
+ default: false
+ type: boolean
+ job-name:
+ description: 'job name [options: novu/api-ee, novu/api]'
+ required: true
type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
+ check_submodule_token:
+ if: ${{ inputs.ee }}
+ name: Check if the secret exists or not.
+ runs-on: ubuntu-latest
+ outputs:
+ has_token: ${{ steps.secret-check.outputs.has_token }}
+ steps:
+ - name: Check if secret exists
+ id: secret-check
+ run: |
+ if [[ -n "${{ secrets.SUBMODULES_TOKEN }}" ]]; then
+ echo "::set-output name=has_token::true"
+ else
+ echo "::set-output name=has_token::false"
+ fi
+
# This workflow contains a single job called "build"
e2e_api:
+ name: Test E2E
# The type of runner that the job will run on
runs-on: ubuntu-latest
timeout-minutes: 80
-
permissions:
contents: read
packages: write
deployments: write
id-token: write
-
+ needs: [check_submodule_token]
+ if: |
+ ${{ contains (inputs.job-name,'-ee') && inputs.test-e2e-ee-affected == 'true' }} ||
+ ${{ !contains (inputs.job-name,'-ee') && inputs.test-e2e-affected == 'true' }}
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- - id: setup
- run: |
- if ! [[ -z "${{ secrets.SUBMODULES_TOKEN }}" ]]; then
- echo "has_token=true" >> $GITHUB_OUTPUT
- else
- echo "has_token=false" >> $GITHUB_OUTPUT
- fi
- - uses: actions/checkout@v3
# checkout with submodules if token is provided
+ - uses: actions/checkout@v3
+ name: Checkout with submodules
+ if: ${{ needs.check_submodule_token.outputs.has_token == 'true' && inputs.ee }}
+ with:
+ submodules: true
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+
+ # else checkout without submodules if the token is not provided
+ - uses: actions/checkout@v3
+ name: Checkout
+ if: ${{ needs.check_submodule_token.outputs.has_token != 'true' || !contains (inputs.job-name,'-ee') }}
+
- uses: ./.github/actions/setup-project
+ name: Setup project
+ with:
+ submodules: ${{ inputs.ee && needs.check_submodule_token.outputs.has_token == 'true' }}
+
- uses: ./.github/actions/setup-redis-cluster
+ name: Setup redis cluster
+
- uses: mansagroup/nrwl-nx-action@v3
+ name: Run Lint
with:
targets: lint
- projects: "@novu/api"
+ projects: '@novu/api'
- uses: ./.github/actions/start-localstack
+ name: Start localstack
+
- uses: ./.github/actions/run-worker
+ name: Run worker
with:
launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
# Runs a single command using the runners shell
- name: Build API
- run: CI='' pnpm build:api
+ run: CI='' pnpm build:api --skip-nx-cache
- name: Run E2E tests
env:
@@ -69,11 +105,19 @@ jobs:
run: |
cd apps/api && pnpm test:e2e
+ - name: Run E2E EE tests
+ if: ${{ needs.check_submodule_token.outputs.has_token == 'true' && inputs.ee }}
+ env:
+ LAUNCH_DARKLY_SDK_KEY: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
+ GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
+ GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
+ CI_EE_TEST: true
+ run: |
+ cd apps/api && pnpm test:e2e:ee
+
- name: Kill port for worker 1342 for unit tests
run: sudo kill -9 $(sudo lsof -t -i:1342)
- name: Run unit tests
run: |
cd apps/api && pnpm test
-
-
diff --git a/.github/workflows/reusable-app-service-deploy.yml b/.github/workflows/reusable-app-service-deploy.yml
index 48f1cfddc4a..771adba4bf5 100644
--- a/.github/workflows/reusable-app-service-deploy.yml
+++ b/.github/workflows/reusable-app-service-deploy.yml
@@ -52,11 +52,11 @@ jobs:
terraform_wrapper: false
- name: Terraform Init
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
run: terraform init
- name: Terraform get output
- working-directory: cloud-infra/terraform/novu
+ working-directory: cloud-infra/terraform/novu/aws
id: terraform
env:
SERVICE_NAME: ${{ inputs.service_name }}
diff --git a/.github/workflows/reusable-docker.yml b/.github/workflows/reusable-docker.yml
new file mode 100644
index 00000000000..b286a3c95f7
--- /dev/null
+++ b/.github/workflows/reusable-docker.yml
@@ -0,0 +1,153 @@
+name: Build, tag and push docker image to ghcr.io
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+on:
+ workflow_call:
+ inputs:
+ # The environment with the GH secrets environment.
+ environment:
+ required: true
+ type: string
+ # The name of the package under which the docker image will be published on GH Packages.
+ package_name:
+ required: true
+ type: string
+ # The path to the project in the monorepo.
+ project_path:
+ required: true
+ type: string
+ # The ID of the depot project.
+ # Note: Currently we build everything under one project (it requires to set up the OIDC for each project and I had some issues with the Depot)
+ depot_project_id:
+ required: true
+ type: string
+ # The port used for testing. This is not a required input, and it defaults to '1341'.
+ # This value is added, so you can test the image after it's built in test mode and by doing a health-check.
+ test_port:
+ required: false
+ default: '1341'
+ type: string
+ # The boolean that helps to determine whether to perform a health check. This is not a required input and defaults to false.
+ health_check:
+ required: false
+ default: false
+ type: boolean
+ # The tag under which the image is built, it should align with the name in the project command docker:build:depot
+ local_tag:
+ required: true
+ type: string
+ # The environment tag. Possible values are dev, stg, and prod. This is not a required input of type string.
+ env_tag:
+ required: false
+ type: string
+ outputs:
+ docker_image:
+ description: 'The image that was built'
+ value: ${{ jobs.reusable_docker.outputs.docker_image }}
+ docker_image_ee:
+ description: 'The enterprise image that was built'
+ value: ${{ jobs.reusable_docker.outputs.docker_image_ee }}
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ reusable_docker:
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ outputs:
+ docker_image: ${{ steps.save-image-to-output.outputs.IMAGE }}
+ docker_image_ee: ${{ steps.save-image-to-output.outputs.IMAGE_EE }}
+ permissions:
+ contents: read
+ packages: write
+ deployments: write
+ id-token: write
+ strategy:
+ matrix:
+ name: [ '${{ inputs.package_name }}-ee', '${{ inputs.package_name }}' ]
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: ${{ contains (matrix.name,'-ee') }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+
+ - uses: ./.github/actions/setup-project
+ with:
+ slim: 'true'
+ submodules: ${{ contains (matrix.name,'-ee') }}
+
+ - name: Setup Depot
+ uses: depot/setup-action@v1
+ with:
+ oidc: true
+
+ - name: Build, tag, and push image to ghcr.io
+ id: build-image
+ if: ${{ inputs.env_tag == 'dev' || inputs.env_tag == 'stg' }}
+ env:
+ REGISTRY_OWNER: novuhq
+ DOCKER_NAME: ${{ matrix.name }}
+ LOCAL_TAG: ${{ inputs.local_tag }}
+ IMAGE_TAG: ${{ github.sha }}
+ ENV_TAG: ${{ inputs.env_tag }}
+ PROJECT_PATH: ${{ inputs.project_path }}
+ GH_ACTOR: ${{ github.actor }}
+ GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
+ DEPOT_PROJECT_ID: ${{ inputs.depot_project_id }}
+ run: |
+ echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
+ cd $PROJECT_PATH && npm run docker:build:depot
+ docker tag $LOCAL_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
+ docker tag $LOCAL_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$ENV_TAG
+ docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
+ docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$ENV_TAG
+
+ - name: Production build, tag, and push image to ghcr.io
+ id: build-prod-image
+ if: ${{ inputs.env_tag == 'prod' }}
+ env:
+ REGISTRY_OWNER: novuhq
+ DOCKER_NAME: ${{ matrix.name }}
+ LOCAL_TAG: ${{ inputs.local_tag }}
+ IMAGE_TAG: ${{ github.sha }}
+ ENV_TAG: ${{ inputs.env_tag }}
+ PROJECT_PATH: ${{ inputs.project_path }}
+ GH_ACTOR: ${{ github.actor }}
+ GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
+ DEPOT_PROJECT_ID: ${{ inputs.depot_project_id }}
+ run: |
+ echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
+ cd $PROJECT_PATH && npm run docker:build:depot
+ docker tag $LOCAL_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
+ docker tag $LOCAL_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$ENV_TAG
+ docker tag $LOCAL_TAG ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
+ docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
+ docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$ENV_TAG
+ docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:latest
+
+ - name: Save image to output
+ id: save-image-to-output
+ env:
+ REGISTRY_OWNER: novuhq
+ DOCKER_NAME: ${{ matrix.name }}
+ IMAGE_TAG: ${{ github.sha }}
+ OUTPUT_NAME: ${{ contains(matrix.name,'-ee') && 'IMAGE_EE' || 'IMAGE' }}
+ run: |
+ echo "$OUTPUT_NAME=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+
+ - name: Health check test
+ id: health-check
+ if: ${{ inputs.health_check == 'true' && (steps.build-image.outcome == 'success' || steps.build-prod-image.outcome == 'success') }}
+ env:
+ REGISTRY_OWNER: novuhq
+ DOCKER_NAME: ${{ matrix.name }}
+ LOCAL_TAG: ${{ inputs.local_tag }}
+ IMAGE_TAG: ${{ github.sha }}
+ TEST_PORT: ${{ inputs.test_port }}
+ GH_ACTOR: ${{ github.actor }}
+ GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
+ run: |
+ echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
+
+ docker run --network=host --name $LOCAL_TAG -dit --env NODE_ENV=test ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG
+ docker run --network=host appropriate/curl --retry 10 --retry-delay 5 --retry-connrefused http://127.0.0.1:$TEST_PORT/v1/health-check | grep 'ok'
diff --git a/.github/workflows/reusable-embed-deploy.yml b/.github/workflows/reusable-embed-deploy.yml
new file mode 100644
index 00000000000..fa6d16744b0
--- /dev/null
+++ b/.github/workflows/reusable-embed-deploy.yml
@@ -0,0 +1,66 @@
+name: Deploy Embed to Netlify
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+on:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
+ widget_url:
+ required: true
+ type: string
+ # Netlify inputs
+ netlify_deploy_message:
+ required: true
+ type: string
+ netlify_alias:
+ required: true
+ type: string
+ netlify_gh_env:
+ required: true
+ type: string
+ netlify_site_id:
+ required: true
+ type: string
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ reusable_embed_deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ permissions:
+ contents: read
+ packages: write
+ deployments: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v3
+ - uses: ./.github/actions/setup-project
+ with:
+ slim: 'true'
+
+ - name: Build
+ run: CI='' npm run build:embed
+
+ - name: Build
+ working-directory: libs/embed
+ env:
+ WIDGET_URL: ${{ inputs.widget_url }}
+ run: CI='' npm run build:prod
+
+ - name: Deploy Embed
+ uses: nwtgck/actions-netlify@v1.2
+ with:
+ publish-dir: libs/embed/dist
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ deploy-message: ${{ inputs.netlify_deploy_message }}
+ production-deploy: true
+ alias: ${{ inputs.netlify_alias }}
+ github-deployment-environment: ${{ inputs.netlify_gh_env }}
+ github-deployment-description: Embed Deployment
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: ${{ inputs.netlify_site_id }}
+ timeout-minutes: 1
diff --git a/.github/workflows/reusable-inbound-mail-e2e.yml b/.github/workflows/reusable-inbound-mail-e2e.yml
index 35d50ef1fa8..3e08d0b1b22 100644
--- a/.github/workflows/reusable-inbound-mail-e2e.yml
+++ b/.github/workflows/reusable-inbound-mail-e2e.yml
@@ -9,16 +9,6 @@ on:
required: false
default: false
type: boolean
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
- required: false
- default: false
- type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
- required: false
- default: 'main'
- type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
@@ -36,8 +26,22 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
+ - id: setup
+ run: |
+ if ! [[ -z "${{ secrets.SUBMODULES_TOKEN }}" ]]; then
+ echo "has_token=true" >> $GITHUB_OUTPUT
+ else
+ echo "has_token=false" >> $GITHUB_OUTPUT
+ fi
# checkout with submodules if token is provided
- uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token == 'true'
+ with:
+ submodules: ${{ inputs.ee }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+ # else checkout without submodules if the token is not provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token != 'true'
- uses: ./.github/actions/setup-project
- uses: ./.github/actions/setup-redis-cluster
- uses: mansagroup/nrwl-nx-action@v3
diff --git a/.github/workflows/reusable-web-deploy.yml b/.github/workflows/reusable-web-deploy.yml
new file mode 100644
index 00000000000..1cf97726dc1
--- /dev/null
+++ b/.github/workflows/reusable-web-deploy.yml
@@ -0,0 +1,106 @@
+name: Deploy Web to Netlify
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+on:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
+ react_app_api_url:
+ required: true
+ type: string
+ react_app_ws_url:
+ required: true
+ type: string
+ react_app_webhook_url:
+ required: true
+ type: string
+ react_app_widget_embed_path:
+ required: true
+ type: string
+ react_app_sentry_dsn:
+ required: true
+ type: string
+ react_app_environment:
+ required: true
+ type: string
+ react_app_mail_server_domain:
+ required: true
+ type: string
+ # Netlify inputs
+ netlify_deploy_message:
+ required: true
+ type: string
+ netlify_alias:
+ required: true
+ type: string
+ netlify_gh_env:
+ required: true
+ type: string
+ netlify_site_id:
+ required: true
+ type: string
+
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ reusable_web_deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ permissions:
+ contents: read
+ packages: write
+ deployments: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v3
+ name: Checkout with submodules
+ with:
+ submodules: true
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+
+ - uses: ./.github/actions/setup-project
+ with:
+ slim: 'true'
+ submodules: true
+
+ - name: Create env file
+ working-directory: apps/web
+ run: |
+ touch .env
+ echo REACT_APP_SEGMENT_KEY=${{ secrets.WEB_SEGMENT_KEY }} >> .env
+ echo REACT_APP_INTERCOM_APP_ID=${{ secrets.INTERCOM_APP_ID }} >> .env
+ echo REACT_APP_API_URL=${{ inputs.react_app_api_url }} >> .env
+ echo REACT_APP_WS_URL=${{ inputs.react_app_ws_url }} >> .env
+ echo REACT_APP_WEBHOOK_URL=${{ inputs.react_app_webhook_url }} >> .env
+ echo REACT_APP_WIDGET_EMBED_PATH=${{ inputs.react_app_widget_embed_path }} >> .env
+ echo REACT_APP_NOVU_APP_ID=${{ secrets.NOVU_APP_ID }} >> .env
+ echo REACT_APP_SENTRY_DSN=${{ inputs.react_app_sentry_dsn }} >> .env
+ echo REACT_APP_ENVIRONMENT=${{ inputs.react_app_environment }} >> .env
+ echo REACT_APP_MAIL_SERVER_DOMAIN=${{ inputs.react_app_mail_server_domain }} >> .env
+ echo REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=${{ secrets.LAUNCH_DARKLY_CLIENT_SIDE_ID }} >> .env
+
+ - name: Envsetup
+ working-directory: apps/web
+ run: npm run envsetup
+
+ - name: Build
+ run: CI='' pnpm build:web
+
+ - name: Deploy WEB
+ uses: nwtgck/actions-netlify@v1.2
+ with:
+ publish-dir: apps/web/build
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ deploy-message: ${{ inputs.netlify_deploy_message }}
+ production-deploy: true
+ alias: ${{ inputs.netlify_alias }}
+ github-deployment-environment: ${{ inputs.netlify_gh_env }}
+ github-deployment-description: Web Deployment
+ netlify-config-path: apps/web/netlify.toml
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: ${{ inputs.netlify_site_id }}
+ timeout-minutes: 1
diff --git a/.github/workflows/reusable-web-e2e.yml b/.github/workflows/reusable-web-e2e.yml
index ddee3f71600..018a6bd2de3 100644
--- a/.github/workflows/reusable-web-e2e.yml
+++ b/.github/workflows/reusable-web-e2e.yml
@@ -6,16 +6,11 @@ name: Test WEB
on:
workflow_call:
inputs:
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
+ ee:
+ description: 'use the ee version of worker'
required: false
default: false
type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
- required: false
- default: 'main'
- type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
@@ -50,8 +45,15 @@ jobs:
echo "has_token=false" >> $GITHUB_OUTPUT
fi
- - uses: actions/checkout@v3
# checkout with submodules if token is provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token == 'true'
+ with:
+ submodules: ${{ inputs.ee }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+ # else checkout without submodules if the token is not provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token != 'true'
- uses: ./.github/actions/setup-project
id: setup-project
with:
@@ -74,9 +76,9 @@ jobs:
- name: Start Client
working-directory: apps/web
env:
- REACT_APP_API_URL: http://localhost:1336
- REACT_APP_WS_URL: http://localhost:1340
- REACT_APP_WEBHOOK_URL: http://localhost:1341
+ REACT_APP_API_URL: http://127.0.0.1:1336
+ REACT_APP_WS_URL: http://127.0.0.1:1340
+ REACT_APP_WEBHOOK_URL: http://127.0.0.1:1341
REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID: ${{ secrets.TEST_LAUNCH_DARKLY_CLIENT_SIDE_ID }}
run: pnpm start:static:build &
@@ -85,7 +87,7 @@ jobs:
cd apps/ws && pnpm start:test &
- name: Wait on Services
- run: wait-on --timeout=180000 http://localhost:1340/v1/health-check http://localhost:4200
+ run: wait-on --timeout=180000 http://127.0.0.1:1340/v1/health-check http://127.0.0.1:4200/
# run cypress install only when cache was not hit
- name: Cypress install
diff --git a/.github/workflows/reusable-widget-deploy.yml b/.github/workflows/reusable-widget-deploy.yml
new file mode 100644
index 00000000000..d3b8425216b
--- /dev/null
+++ b/.github/workflows/reusable-widget-deploy.yml
@@ -0,0 +1,88 @@
+name: Deploy Widget to Netlify
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+on:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
+ react_app_api_url:
+ required: true
+ type: string
+ react_app_ws_url:
+ required: true
+ type: string
+ react_app_webhook_url:
+ required: true
+ type: string
+ react_app_sentry_dsn:
+ required: true
+ type: string
+ react_app_environment:
+ required: true
+ type: string
+ # Netlify inputs
+ netlify_deploy_message:
+ required: true
+ type: string
+ netlify_alias:
+ required: true
+ type: string
+ netlify_gh_env:
+ required: true
+ type: string
+ netlify_site_id:
+ required: true
+ type: string
+
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ reusable_widget_deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ permissions:
+ contents: read
+ packages: write
+ deployments: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v3
+ - uses: ./.github/actions/setup-project
+ with:
+ slim: 'true'
+
+ - name: Create env file
+ working-directory: apps/widget
+ run: |
+ touch .env
+ echo REACT_APP_API_URL=${{ inputs.react_app_api_url }} >> .env
+ echo REACT_APP_WS_URL=${{ inputs.react_app_ws_url }} >> .env
+ echo REACT_APP_WEBHOOK_URL=${{ inputs.react_app_webhook_url }} >> .env
+ echo REACT_APP_SENTRY_DSN=${{ inputs.react_app_sentry_dsn }} >> .env
+ echo REACT_APP_ENVIRONMENT=${{ inputs.react_app_environment }} >> .env
+
+ - name: Envsetup
+ working-directory: apps/widget
+ run: npm run envsetup
+
+ - name: Build
+ run: CI='' pnpm build:widget
+
+ - name: Deploy Widget
+ uses: scopsy/actions-netlify@develop
+ with:
+ publish-dir: apps/widget/build
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ deploy-message: ${{ inputs.netlify_deploy_message }}
+ production-deploy: true
+ alias: ${{ inputs.netlify_alias }}
+ github-deployment-environment: ${{ inputs.netlify_gh_env }}
+ github-deployment-description: Widget Deployment
+ netlify-config-path: apps/widget/netlify.toml
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: ${{ inputs.netlify_site_id }}
+ timeout-minutes: 1
diff --git a/.github/workflows/reusable-widget-e2e.yml b/.github/workflows/reusable-widget-e2e.yml
index 5100fbc0f01..862a8ce0ea8 100644
--- a/.github/workflows/reusable-widget-e2e.yml
+++ b/.github/workflows/reusable-widget-e2e.yml
@@ -5,16 +5,11 @@ name: Test E2E WIDGET
on:
workflow_call:
inputs:
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
+ ee:
+ description: 'use the ee version of worker'
required: false
default: false
type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
- required: false
- default: 'main'
- type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
@@ -49,8 +44,15 @@ jobs:
else
echo "has_token=false" >> $GITHUB_OUTPUT
fi
- - uses: actions/checkout@v3
# checkout with submodules if token is provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token == 'true'
+ with:
+ submodules: ${{ inputs.ee }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+ # else checkout without submodules if the token is not provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token != 'true'
- uses: ./.github/actions/setup-project
id: setup-project
with:
@@ -87,7 +89,7 @@ jobs:
cd apps/ws && pnpm start:prod &
- name: Wait on Widget and WS Services
- run: wait-on --timeout=180000 http://localhost:1340/v1/health-check http://localhost:3500
+ run: wait-on --timeout=180000 http://127.0.0.1:1340/v1/health-check http://127.0.0.1:3500/
- name: Cypress install
if: steps.setup-project.outputs.cypress_cache_hit != 'true'
@@ -102,7 +104,7 @@ jobs:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_WIDGET_KEY }}
with:
working-directory: apps/widget
- wait-on: http://localhost:3500
+ wait-on: http://127.0.0.1:3500/
browser: chrome
install: false
record: true
diff --git a/.github/workflows/reusable-worker-e2e.yml b/.github/workflows/reusable-worker-e2e.yml
index ce4a2bd04c9..ab20f45e47b 100644
--- a/.github/workflows/reusable-worker-e2e.yml
+++ b/.github/workflows/reusable-worker-e2e.yml
@@ -3,23 +3,12 @@ name: E2E worker Tests
# Controls when the action will run. Triggers the workflow on push or pull request
on:
workflow_call:
-
inputs:
ee:
description: 'use the ee version of worker'
required: false
default: false
type: boolean
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
- required: false
- default: false
- type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
- required: false
- default: 'main'
- type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
@@ -44,8 +33,15 @@ jobs:
else
echo "has_token=false" >> $GITHUB_OUTPUT
fi
- - uses: actions/checkout@v3
# checkout with submodules if token is provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token == 'true'
+ with:
+ submodules: ${{ inputs.ee }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+ # else checkout without submodules if the token is not provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token != 'true'
- uses: ./.github/actions/setup-project
diff --git a/.github/workflows/reusable-workers-service-deploy.yml b/.github/workflows/reusable-workers-service-deploy.yml
new file mode 100644
index 00000000000..28e52ef8054
--- /dev/null
+++ b/.github/workflows/reusable-workers-service-deploy.yml
@@ -0,0 +1,134 @@
+name: Deploy Workers Job
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+on:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
+ terraform_workspace:
+ required: true
+ type: string
+ docker_image:
+ required: true
+ type: string
+ sentry_project:
+ required: false
+ type: string
+ deploy_sentry_release:
+ required: false
+ default: false
+ type: boolean
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ infrastructure_data:
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ env:
+ TF_WORKSPACE: ${{ inputs.terraform_workspace }}
+ permissions:
+ contents: read
+ deployments: write
+ outputs:
+ services_to_deploy: ${{ steps.terraform.outputs.queue_workers_services }}
+ ecs_cluster: ${{ steps.terraform.outputs.ecs_cluster }}
+ aws_region: ${{ steps.terraform.outputs.aws_region }}
+ steps:
+ - run: echo "Deploying ${{ inputs.service_name }} to ${{ inputs.terraform_workspace }} And Docker Tag ${{ inputs.docker_image }}"
+ - name: Checkout cloud infra
+ uses: actions/checkout@master
+ with:
+ repository: novuhq/cloud-infra
+ token: ${{ secrets.GH_PACKAGES }}
+ path: cloud-infra
+
+ - name: Terraform setup
+ uses: hashicorp/setup-terraform@v1
+ with:
+ cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
+ terraform_version: 1.5.5
+ terraform_wrapper: false
+
+ - name: Terraform Init
+ working-directory: cloud-infra/terraform/novu/aws
+ run: terraform init
+
+ - name: Terraform get output
+ working-directory: cloud-infra/terraform/novu/aws
+ id: terraform
+ run: |
+ echo "queue_workers_services=$(terraform output -json queue_workers_services)" >> $GITHUB_OUTPUT
+ echo "ecs_cluster=$(terraform output -json worker_ecs_cluster | jq -r .)" >> $GITHUB_OUTPUT
+ echo "aws_region=$(terraform output -json aws_region | jq -r .)" >> $GITHUB_OUTPUT
+
+
+ deploy_worker_queue:
+ needs: infrastructure_data
+ runs-on: ubuntu-latest
+ timeout-minutes: 80
+ environment: ${{ inputs.environment }}
+ env:
+ TF_WORKSPACE: ${{ inputs.terraform_workspace }}
+ permissions:
+ contents: read
+ deployments: write
+ strategy:
+ matrix:
+ worker: ${{fromJson(needs.infrastructure_data.outputs.services_to_deploy)}}
+ steps:
+ - run: echo "Deploying ${{ matrix.name }} to ${{ inputs.terraform_workspace }} And Docker Tag ${{ inputs.docker_image }}"
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v1
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ needs.infrastructure_data.outputs.aws_region }}
+
+ - name: Download task definition
+ run: |
+ aws ecs describe-task-definition --task-definition ${{ matrix.worker.task_name }} \
+ --query taskDefinition > task-definition.json
+
+ - name: Render Amazon ECS task definition
+ id: render-web-container
+ uses: aws-actions/amazon-ecs-render-task-definition@v1
+ with:
+ task-definition: task-definition.json
+ container-name: ${{ matrix.worker.container_name }}
+ image: ${{ inputs.docker_image }}
+
+ - name: Deploy to Amazon ECS service
+ uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+ with:
+ task-definition: ${{ steps.render-web-container.outputs.task-definition }}
+ service: ${{ matrix.worker.service }}
+ cluster: ${{ needs.infrastructure_data.outputs.ecs_cluster }}
+ wait-for-service-stability: true
+
+ - uses: actions/checkout@v3
+ if: ${{ inputs.deploy_sentry_release }}
+
+ - name: get-npm-version
+ if: ${{ inputs.deploy_sentry_release }}
+ id: package-version
+ uses: martinbeentjes/npm-get-version-action@main
+ with:
+ path: apps/${{ inputs.sentry_project }}
+
+ - name: Create Sentry release
+ if: ${{ inputs.deploy_sentry_release }}
+ uses: getsentry/action-release@v1
+ env:
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ SENTRY_ORG: novu-r9
+ SENTRY_PROJECT: ${{ inputs.sentry_project }}
+ with:
+ version: ${{ steps.package-version.outputs.current-version}}
+ version_prefix: v
+ environment: prod
+ ignore_empty: true
+ ignore_missing: true
diff --git a/.github/workflows/reusable-ws-e2e.yml b/.github/workflows/reusable-ws-e2e.yml
index df4dd129fce..51f9fef2bab 100644
--- a/.github/workflows/reusable-ws-e2e.yml
+++ b/.github/workflows/reusable-ws-e2e.yml
@@ -9,16 +9,6 @@ on:
required: false
default: false
type: boolean
- submodules:
- description: 'The flag controlling whether we want submodules to checkout'
- required: false
- default: false
- type: boolean
- submodule_branch:
- description: 'Submodule branch to checkout to'
- required: false
- default: 'main'
- type: string
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
@@ -43,7 +33,15 @@ jobs:
else
echo "has_token=false" >> $GITHUB_OUTPUT
fi
+ # checkout with submodules if token is provided
+ - uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token == 'true'
+ with:
+ submodules: ${{ inputs.ee }}
+ token: ${{ secrets.SUBMODULES_TOKEN }}
+ # else checkout without submodules if the token is not provided
- uses: actions/checkout@v3
+ if: steps.setup.outputs.has_token != 'true'
- uses: ./.github/actions/setup-project
- uses: mansagroup/nrwl-nx-action@v3
with:
diff --git a/.github/workflows/staging-deploy-web.yml b/.github/workflows/staging-deploy-web.yml
index e957e4d7b55..bf04d41f9eb 100644
--- a/.github/workflows/staging-deploy-web.yml
+++ b/.github/workflows/staging-deploy-web.yml
@@ -19,111 +19,37 @@ jobs:
test_web:
uses: ./.github/workflows/reusable-web-e2e.yml
with:
- submodules: true
+ ee: true
secrets: inherit
- # This workflow contains a single job called "build"
deploy_web:
needs: test_web
- environment: Development
if: "!contains(github.event.head_commit.message, 'ci skip')"
- # The type of runner that the job will run on
- runs-on: ubuntu-latest
- timeout-minutes: 80
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
-
- # Steps represent a sequence of tasks that will be executed as part of the job
- steps:
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
-
- - name: Build
- run: CI='' pnpm build:web
-
- - name: Create env file
- working-directory: apps/web
- run: |
- touch .env
- echo REACT_APP_API_URL="https://staging.api.novu.co" >> .env
- echo REACT_APP_WS_URL="https://staging.ws.novu.co" >> .env
- echo REACT_APP_WEBHOOK_URL="https://staging.webhook.novu.co" >> .env
- echo REACT_APP_WIDGET_EMBED_PATH="https://staging.embed.novu.co/embed.umd.min.js" >> .env
- echo REACT_APP_NOVU_APP_ID=${{ secrets.NOVU_APP_ID }} >> .env
- echo REACT_APP_SEGMENT_KEY=${{ secrets.WEB_SEGMENT_KEY }} >> .env
- echo REACT_APP_SENTRY_DSN="https://8054d521cff2e73d32b8edfe4793d05c@o1161119.ingest.sentry.io/4505829158158336" >> .env
- echo REACT_APP_ENVIRONMENT=staging >> .env
- echo REACT_APP_MAIL_SERVER_DOMAIN="staging.inbound-mail.novu.co" >> .env
- echo REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=${{ secrets.DEV_LAUNCH_DARKLY_CLIENT_SIDE_ID }} >> .env
-
- - name: Envsetup
- working-directory: apps/web
- run: npm run envsetup
-
- # Runs a single command using the runners shell
- - name: Build
- env:
- REACT_APP_SEGMENT_KEY: ${{ secrets.WEB_SEGMENT_KEY }}
- REACT_APP_INTERCOM_APP_ID: ${{ secrets.INTERCOM_APP_ID }}
- REACT_APP_API_URL: https://staging.api.novu.co
- REACT_APP_WS_URL: https://staging.ws.novu.co
- REACT_APP_WEBHOOK_URL: https://staging.webhook.novu.co
- REACT_APP_WIDGET_EMBED_PATH: https://staging.embed.novu.co/embed.umd.min.js
- REACT_APP_NOVU_APP_ID: ${{ secrets.NOVU_APP_ID }}
- REACT_APP_SENTRY_DSN: https://8054d521cff2e73d32b8edfe4793d05c@o1161119.ingest.sentry.io/4505829158158336
- REACT_APP_ENVIRONMENT: staging
- REACT_APP_MAIL_SERVER_DOMAIN: staging.inbound-mail.novu.co
- REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID: ${{ secrets.DEV_LAUNCH_DARKLY_CLIENT_SIDE_ID }}
- working-directory: apps/web
- run: npm run build
-
- - name: Deploy WEB to Staging
- uses: nwtgck/actions-netlify@v1.2
- with:
- publish-dir: apps/web/build
- github-token: ${{ secrets.GITHUB_TOKEN }}
- deploy-message: Staging deployment
- production-deploy: true
- alias: dev
- github-deployment-environment: staging
- github-deployment-description: Web Deployment
- netlify-config-path: apps/web/netlify.toml
- env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: 8010b875-9f6e-4bcc-ba67-c090c1cc2e05
- timeout-minutes: 1
-
- - name: Setup Depot
- uses: depot/setup-action@v1
- with:
- oidc: true
-
- - name: Remove build outputs
- working-directory: apps/web
- run: rm -rf build
-
- - name: Build, tag, and push image to ghcr.io
- id: build-image
- env:
- REGISTRY_OWNER: novuhq
- DOCKER_NAME: novu/web
- IMAGE_TAG: ${{ github.sha }}
- GH_ACTOR: ${{ github.actor }}
- GH_PASSWORD: ${{ secrets.GH_PACKAGES }}
- DEPOT_PROJECT_ID: f88777ff6m
- run: |
- echo $GH_PASSWORD | docker login ghcr.io -u $GH_ACTOR --password-stdin
- depot build --push \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG \
- -t ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:dev \
- -f apps/web/Dockerfile .
- echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
+ uses: ./.github/workflows/reusable-web-deploy.yml
+ with:
+ environment: Development
+ react_app_api_url: https://staging.api.novu.co
+ react_app_ws_url: https://staging.ws.novu.co
+ react_app_webhook_url: https://staging.webhook.novu.co
+ react_app_widget_embed_path: https://staging.embed.novu.co/embed.umd.min.js
+ react_app_sentry_dsn: https://8054d521cff2e73d32b8edfe4793d05c@o1161119.ingest.sentry.io/4505829158158336
+ react_app_environment: staging
+ react_app_mail_server_domain: staging.inbound-mail.novu.co
+ netlify_deploy_message: Staging deployment
+ netlify_alias: dev
+ netlify_gh_env: staging
+ netlify_site_id: 8010b875-9f6e-4bcc-ba67-c090c1cc2e05
+ secrets: inherit
- - uses: actions/upload-artifact@v3
- if: failure()
- with:
- name: cypress-screenshots
- path: apps/web/cypress/screenshots
+ publish_docker_image_web:
+ needs: test_web
+ if: "!contains(github.event.head_commit.message, 'ci skip')"
+ uses: ./.github/workflows/reusable-docker.yml
+ with:
+ environment: Development
+ package_name: novu/web
+ project_path: apps/web
+ local_tag: novu-web
+ env_tag: dev
+ depot_project_id: f88777ff6m
+ secrets: inherit
diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml
index 5ff1258b229..9ea6f242c0b 100644
--- a/.github/workflows/tag-images.yml
+++ b/.github/workflows/tag-images.yml
@@ -26,7 +26,7 @@ jobs:
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
- uses: actions/setup-node@v3
with:
- node-version: '16.15.1'
+ node-version: '20.8.1'
- name: Login to docker
env:
@@ -125,4 +125,3 @@ jobs:
docker pull ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod
docker tag ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:prod ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$DOCKER_VERSION
docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$DOCKER_VERSION
-
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 241f4d9ab9b..75aeae7d679 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -43,7 +43,7 @@ jobs:
# Get current branch name
- name: Get branch name
id: branch-name
- uses: tj-actions/branch-names@v5.2
+ uses: tj-actions/branch-names@v7.0.7
# Get base branch name to compare with. Base branch on a PR, "main" branch on pushing.
- name: Get base branch name
id: get-base-branch-name
@@ -92,15 +92,17 @@ jobs:
test_web:
name: Test Web Cypress
- needs: [ get-affected ]
+ needs: [get-affected]
if: ${{ contains(fromJson(needs.get-affected.outputs.test-cypress), '@novu/web') }}
uses: ./.github/workflows/reusable-web-e2e.yml
secrets: inherit
test_widget:
name: Test Widget Cypress
- needs: [ get-affected ]
+ needs: [get-affected]
uses: ./.github/workflows/reusable-widget-e2e.yml
+ with:
+ ee: true
if: ${{ contains(fromJson(needs.get-affected.outputs.test-cypress), '@novu/widget') || contains(fromJson(needs.get-affected.outputs.test-unit), '@novu/notification-center') || contains(fromJson(needs.get-affected.outputs.test-unit), '@novu/ws') }}
secrets: inherit
@@ -108,7 +110,7 @@ jobs:
name: Build Docker API
runs-on: ubuntu-latest
timeout-minutes: 80
- needs: [ get-affected ]
+ needs: [get-affected]
if: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api') }}
permissions:
contents: read
@@ -117,7 +119,7 @@ jobs:
id-token: write
strategy:
matrix:
- name: [ 'novu/api', 'novu/api-ee' ]
+ name: ['novu/api', 'novu/api-ee']
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
@@ -158,13 +160,22 @@ jobs:
deployments: write
id-token: write
steps:
- - run: echo '${{ needs.get-affected.outputs.test-packages }}'
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
+ - name: Display Test Packages
+ run: echo '${{ needs.get-affected.outputs.test-packages }}'
+
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Setup Project
+ uses: ./.github/actions/setup-project
with:
slim: 'true'
- - uses: ./.github/actions/setup-redis-cluster
- - uses: mansagroup/nrwl-nx-action@v3
+
+ - name: Setup Redis Cluster
+ uses: ./.github/actions/setup-redis-cluster
+
+ - name: Run Lint, Build, Test
+ uses: mansagroup/nrwl-nx-action@v3
env:
LOGGING_LEVEL: 'info'
with:
@@ -186,105 +197,20 @@ jobs:
targets: lint,build,test
projects: ${{join(fromJson(needs.get-affected.outputs.test-libs), ',')}}
- test_e2e:
- name: Test E2E
- runs-on: ubuntu-latest
+ test_api:
+ name: Test API
needs: [get-affected]
- if: ${{ fromJson(needs.get-affected.outputs.test-e2e)[0] }}
- timeout-minutes: 80
strategy:
- # One job for each different project and node version
+ # The order is important for ee to be first, otherwise outputs not work correctly
matrix:
- projectName: ${{ fromJson(needs.get-affected.outputs.test-e2e) }}
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
- steps:
- - run: echo ${{ matrix.projectName }}
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
- - uses: ./.github/actions/setup-redis-cluster
- - uses: mansagroup/nrwl-nx-action@v3
- name: Lint and build
- with:
- targets: lint,build
- projects: ${{matrix.projectName}}
-
- - uses: ./.github/actions/start-localstack
-
- - uses: ./.github/actions/run-worker
- if: ${{matrix.projectName != '@novu/worker' }}
- with:
- launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
-
- - uses: mansagroup/nrwl-nx-action@v3
- name: Running the E2E tests
- env:
- LAUNCH_DARKLY_SDK_KEY: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
- with:
- targets: test:e2e
- projects: ${{matrix.projectName}}
-
- check_submodule_token:
- name: Check if the secret exists or not.
- runs-on: ubuntu-latest
- outputs:
- has_token: ${{ steps.secret-check.outputs.has_token }}
- steps:
- - name: Check if secret exists
- id: secret-check
- run: |
- if [[ -n "${{ secrets.SUBMODULES_TOKEN }}" ]]; then
- echo "::set-output name=has_token::true"
- else
- echo "::set-output name=has_token::false"
- fi
-
- test_e2e_ee:
- name: Test E2E EE
- runs-on: ubuntu-latest
- needs: [get-affected, check_submodule_token]
- if: ${{ fromJson(needs.get-affected.outputs.test-e2e-ee)[0] && needs.check_submodule_token.outputs.has_token == 'true' }}
- timeout-minutes: 80
- strategy:
- # One job for each different project and node version
- matrix:
- projectName: ${{ fromJson(needs.get-affected.outputs.test-e2e-ee) }}
- permissions:
- contents: read
- packages: write
- deployments: write
- id-token: write
- steps:
- - run: echo ${{ matrix.projectName }}
- - uses: actions/checkout@v3
- - uses: ./.github/actions/setup-project
- - uses: ./.github/actions/setup-redis-cluster
- - uses: mansagroup/nrwl-nx-action@v3
- name: Lint and build
- with:
- targets: lint,build
- projects: ${{matrix.projectName}}
-
- - uses: ./.github/actions/start-localstack
-
- - uses: ./.github/actions/run-worker
- if: ${{matrix.projectName == '@novu/api' }}
- with:
- launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
-
- - uses: mansagroup/nrwl-nx-action@v3
- name: Running the E2E tests
- env:
- LAUNCH_DARKLY_SDK_KEY: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
- GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
- GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
- CI_EE_TEST: true
- with:
- targets: test:e2e:ee
- projects: ${{matrix.projectName}}
+ name: ['novu/api-ee', 'novu/api']
+ uses: ./.github/workflows/reusable-api-e2e.yml
+ with:
+ ee: ${{ contains (matrix.name,'-ee') }}
+ test-e2e-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api') }}
+ test-e2e-ee-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/api') }}
+ job-name: ${{ matrix.name }}
+ secrets: inherit
test_unit:
name: Unit Test
@@ -315,8 +241,8 @@ jobs:
targets: lint,build,test
projects: ${{matrix.projectName}}
- validate_swagger:
- name: Validate Swagger
+ validate_openapi:
+ name: Validate OpenAPI
runs-on: ubuntu-latest
needs: [get-affected]
if: ${{ fromJson(needs.get-affected.outputs.test-unit)[0] }}
@@ -334,4 +260,4 @@ jobs:
with:
launch_darkly_sdk_key: ${{ secrets.LAUNCH_DARKLY_SDK_KEY }}
- - uses: ./.github/actions/validate-swagger
+ - uses: ./.github/actions/validate-openapi
diff --git a/.gitmodules b/.gitmodules
index a258b6c0cb7..3b24c2a33bc 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,4 +1,3 @@
-[submodule "enterprise/packages"]
- path = enterprise/packages
+[submodule "enterprise"]
+ path = .source
url = git@github.com:novuhq/packages-enterprise.git
- branch = next
diff --git a/.idea/novu.iml b/.idea/novu.iml
index 01f50a9c1d4..15e9eba46ad 100644
--- a/.idea/novu.iml
+++ b/.idea/novu.iml
@@ -42,6 +42,7 @@
+
diff --git a/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml b/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml
index 96502b0b9a3..a1c4872b604 100644
--- a/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml
+++ b/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml
@@ -10,7 +10,7 @@
- --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts
+ --timeout 30000 --require ts-node/register --exit --file e2e/setup.ts
DIRECTORY
false
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index a46828097c7..51ec43051b2 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -18,5 +18,6 @@
+
-
\ No newline at end of file
+
diff --git a/.nvmrc b/.nvmrc
index d9289897d30..6569dfa4f32 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-16.15.1
+20.8.1
diff --git a/.source b/.source
new file mode 160000
index 00000000000..914e787dfcd
--- /dev/null
+++ b/.source
@@ -0,0 +1 @@
+Subproject commit 914e787dfcdc529497a9f8e0ca7eac09c51b2f4e
diff --git a/.vscode/settings.json b/.vscode/settings.json
index f2860a3eb12..59aeb44e3b4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,11 +13,11 @@
"editor.wordWrap": "wordWrapColumn",
"editor.wordWrapColumn": 120,
"editor.codeActionsOnSave": {
- "source.fixAll": true
+ "source.fixAll": "explicit"
},
"cSpell.words": ["mantine"],
"vsicons.presets.nestjs": true,
"[markdown]": {
- "editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4d7aedf8437..ecc07a66ae8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,8 +20,8 @@ You can open a new issue with this [issue form](https://github.com/novuhq/novu/i
### Requirements
-- Node.js v16.14.0
- - To install Node.js v16.14.0 through NVM (Node Version Manager), follow these steps:
+- Node.js v20.8.1 (LTS)
+ - To install Node.js v20.8.1 (LTS) through NVM (Node Version Manager), follow these steps:
1. Open your terminal.
2. Install NVM if you haven't already. You can install NVM by following the instructions at [NVM GitHub](https://github.com/nvm-sh/nvm).
@@ -30,14 +30,14 @@ You can open a new issue with this [issue form](https://github.com/novuhq/novu/i
```bash
- nvm install 16.14.0
+ nvm install 20.8.1
- node -v # output: v16.14.0
+ node -v # output: v20.8.1
```
- 5. You can set Node.js v16.14.0 as your default version with the following command:
+ 5. You can set Node.js v20.8.1 as your default version with the following command:
```bash
- nvm alias default 16
+ nvm alias default 20
```
diff --git a/README.md b/README.md
index 72f0efbbcc7..c02dad07af9 100644
--- a/README.md
+++ b/README.md
@@ -2,17 +2,19 @@
- ![Novu Logo]
+
-
+
+
+
-
-
+
+
-
-
+
+
@@ -22,29 +24,6 @@
The ultimate service for managing multi-channel notifications with a single API.
-
-🎉 We're participating in Hacktoberfest 2023! 🎉
-
-Are you interested in participating in Hacktoberfest? We extend a warm invitation! You also get the opportunity to win some swag 😁
-
-> ⭐️ If you're new to Hacktoberfest, you can learn more and register to participate [here](https://hacktoberfest.com/participation/). Registration is from **September 26th - October 31st**.
-
-- Our Hacktoberfest kickoff event is happening on October 2, 2023. 🚀
-- Check out our website for [hacktoberfest instructions](https://novu.co/hacktoberfest/).
-- Join our [Discord and engage with our community](https://discord.com/invite/novu), get answers to your challenges, and stay updated on events, announcements, and prizes.
-
-In addition to this repository, here are the other Novu repositories you can contribute to for Hacktoberfest:
-- [Novu Docs](https://github.com/novuhq/docs/issues)
-- [Novu PHP SDK](https://github.com/novuhq/novu-php/issues)
-- [Novu Ruby SDK](https://github.com/novuhq/novu-ruby/issues)
-- [Novu Python SDK](https://github.com/novuhq/novu-python/issues)
-- [Novu Go SDK](https://github.com/novuhq/go-novu/issues)
-- [Novu Java SDK](https://github.com/novuhq/novu-java/issues)
-- [Novu Kotlin SDK](https://github.com/novuhq/novu-kotlin/issues)
-- [Novu Elixir SDK](https://github.com/novuhq/novu-elixir/issues)
-- [Novu Rust SDK](https://github.com/novuhq/novu-rust/issues)
-
-Your contribution, no matter its size, holds immense value. We eagerly await to see the impact you'll make in our community! 🚀
diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js
index 04324a0ca4c..7e88bbaefcc 100644
--- a/apps/api/.eslintrc.js
+++ b/apps/api/.eslintrc.js
@@ -2,6 +2,44 @@ module.exports = {
extends: ['../../.eslintrc.js'],
rules: {
'func-names': 'off',
+ "no-restricted-imports": ["error", {
+ "patterns": [{
+ /**
+ * This rule ensures that the overidden Swagger decorators are used,
+ * which apply common responses to all API endpoints.
+ */
+ "group": ["@nestjs/swagger"],
+ "importNames": [
+ 'ApiOkResponse',
+ 'ApiCreatedResponse',
+ 'ApiAcceptedResponse',
+ 'ApiNoContentResponse',
+ 'ApiMovedPermanentlyResponse',
+ 'ApiFoundResponse',
+ 'ApiBadRequestResponse',
+ 'ApiUnauthorizedResponse',
+ 'ApiTooManyRequestsResponse',
+ 'ApiNotFoundResponse',
+ 'ApiInternalServerErrorResponse',
+ 'ApiBadGatewayResponse',
+ 'ApiConflictResponse',
+ 'ApiForbiddenResponse',
+ 'ApiGatewayTimeoutResponse',
+ 'ApiGoneResponse',
+ 'ApiMethodNotAllowedResponse',
+ 'ApiNotAcceptableResponse',
+ 'ApiNotImplementedResponse',
+ 'ApiPreconditionFailedResponse',
+ 'ApiPayloadTooLargeResponse',
+ 'ApiRequestTimeoutResponse',
+ 'ApiServiceUnavailableResponse',
+ 'ApiUnprocessableEntityResponse',
+ 'ApiUnsupportedMediaTypeResponse',
+ 'ApiDefaultResponse',
+ ],
+ "message": "Use 'ApiResponse' from '/shared/framework/response.decorator' instead."
+ }]
+ }]
},
parserOptions: {
project: './tsconfig.json',
diff --git a/apps/api/.gitignore b/apps/api/.gitignore
index f5cb14c442d..246e01248c6 100644
--- a/apps/api/.gitignore
+++ b/apps/api/.gitignore
@@ -119,3 +119,5 @@ fabric.properties
.serverless
newrelic_agent.log
+
+backups/
diff --git a/apps/api/.mocharc.json b/apps/api/.mocharc.json
index 19e868a6e9d..80567ad8842 100644
--- a/apps/api/.mocharc.json
+++ b/apps/api/.mocharc.json
@@ -1,5 +1,5 @@
{
- "timeout": 10000,
+ "timeout": 20000,
"require": "ts-node/register",
"file": ["e2e/setup.ts"],
"exit": true,
diff --git a/apps/api/.spectral.yaml b/apps/api/.spectral.yaml
new file mode 100644
index 00000000000..969d31587b4
--- /dev/null
+++ b/apps/api/.spectral.yaml
@@ -0,0 +1,20 @@
+# cSpell:disable
+
+# Spectral is a flexible JSON/YAML linter and validator, which can be used for OpenAPI, AsyncAPI, or any other purpose.
+# To run Spectral locally:
+# > pnpm lint:openapi
+
+# For information on creating custom rulesets, see:
+# https://meta.stoplight.io/docs/spectral/01baf06bdd05a-create-a-ruleset
+
+# Base rulesets to extend from:
+extends: [[spectral:oas, all]]
+
+# Override rules from base ruleset. Useful to incrementally enable rules on legacy files.
+# https://meta.stoplight.io/docs/spectral/293426e270fac-overrides
+overrides:
+ - files:
+ - "**#/paths/~1v1~1subscribers~1%7BsubscriberId%7D~1preferences~1%7BtemplateId%7D"
+ - "**#/paths/~1v1~1subscribers~1%7BsubscriberId%7D~1preferences~1%7Blevel%7D"
+ rules:
+ path-params: "off"
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index 963994c0166..9ebbc6dbca1 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -1,4 +1,4 @@
-FROM nikolaik/python-nodejs:python3.10-nodejs16-alpine as dev_base
+FROM nikolaik/python-nodejs:python3.10-nodejs20-alpine as dev_base
ARG BULL_MQ_PRO_TOKEN
ENV BULL_MQ_PRO_NPM_TOKEN=$BULL_MQ_PRO_TOKEN
diff --git a/apps/api/README.md b/apps/api/README.md
index f7f931edb5c..cc9d7b27658 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -15,11 +15,19 @@ A RESTful API for accessing the Novu platform, built using [NestJS](https://nest
The Novu API utilizes the [`@nestjs/swagger`](https://github.com/nestjs/swagger) package to generate up-to-date OpenAPI specifications.
-A web interface to browse the available endpoints is available at [api.novu.co/api](https://api.novu.co/api). An OpenAPI specification can be retrieved at [api.novu.co/api-json](https://api.novu.co/api-json).
+A web interface to browse the published endpoints is available during local development at [localhost:3000/openapi](https://localhost:3000/openapi). An OpenAPI specification can be retrieved at [api.novu.co/openapi.yaml](https://api.novu.co/openapi.yaml).
+
+To maintain consistency and quality of OpenAPI documentation, Novu uses [Spectral](https://github.com/stoplightio/spectral) to validate the OpenAPI specification and enforce style. The OpenAPI specification is run through a Github action on pull request, and call also be run locally after starting the API with:
+
+```bash
+$ npm run lint:openapi
+```
+
+The command will return warnings and errors that must be fixed before the Github action will pass. These fixes are created by making changes through the `@nestjs/swagger` decorators.
## Running the API
-See the docs for [Run in Local Machine](https://docs.novu.co/community/run-in-local-machin) to get setup. Then run:
+See the docs for [Run in Local Machine](https://docs.novu.co/community/run-in-local-machine) to get setup. Then run:
```bash
# Run the API in watch mode
@@ -36,3 +44,29 @@ $ npm run test
### E2E tests
See the docs for [Running on Local Machine - API Tests](https://docs.novu.co/community/run-in-local-machine#api).
+
+## Migrations
+Database migrations are included for features that have a hard dependency on specific data being available on database entities. These migrations are run by both Novu Cloud and Novu Self-Hosted users to support new feature releases.
+
+### How to Run
+
+The `npm run migration` script is available in the `package.json` to ensure script changes are DRY and consistent. This script is included in user-facing communications such as our documentation and release notes, and the script naming therefore MUST remain stable.
+
+The path to the migration to run is passed as a positional argument to the script. For example, to run the Add Integration Identifier script, we would enter the following:
+
+```bash
+npm run migration -- ./migrations/integration-scheme-update/add-integration-identifier-migration.ts
+```
+
+### Conventions
+
+These migrations live in the `./migrations` directory, and follow the naming convention of:
+`./migrations//.ts`. Each `` may have 1 or more `.ts` scripts. For example:
+
+```
+.
+└── migrations/
+ └── integration-scheme-update/
+ ├── add-integration-identifier-migration.ts
+ └── add-primary-priority-migration.ts
+```
diff --git a/apps/api/admin/connect-to-dal.ts b/apps/api/admin/connect-to-dal.ts
new file mode 100644
index 00000000000..d2bbad03a03
--- /dev/null
+++ b/apps/api/admin/connect-to-dal.ts
@@ -0,0 +1,17 @@
+import { DalService } from '@novu/dal';
+
+const dalService = new DalService();
+
+export async function connect(databaseQuery: () => Promise) {
+ try {
+ await dalService.connect(process.env.MONGO_URL);
+ await databaseQuery();
+ } catch (e) {
+ console.error(e);
+ } finally {
+ if (dalService.isConnected()) {
+ await dalService.disconnect();
+ }
+ process.exit(0);
+ }
+}
diff --git a/apps/api/admin/make-json-backup.ts b/apps/api/admin/make-json-backup.ts
new file mode 100644
index 00000000000..9ebf9ab3d58
--- /dev/null
+++ b/apps/api/admin/make-json-backup.ts
@@ -0,0 +1,20 @@
+import * as fs from 'fs';
+import { format } from 'date-fns';
+
+const backupFolder = `${__dirname}/backups`;
+
+export async function makeJsonBackup(folder: string, fileName: string, obj: unknown) {
+ try {
+ const fullFolderPath = `${backupFolder}/${folder}`;
+ if (!fs.existsSync(fullFolderPath)) {
+ await fs.promises.mkdir(fullFolderPath, { recursive: true });
+ }
+
+ const dateString = format(new Date(), 'yyyy-MM-dd:HH:mm:ss:SSS');
+ const fullFileName = `${dateString}_${fileName}`;
+ await fs.promises.writeFile(`${fullFolderPath}/${fullFileName}.json`, JSON.stringify(obj));
+ console.log(`The backup JSON was written to file ${fullFileName}`);
+ } catch (e) {
+ console.error('Error writing backup JSON to file:', e);
+ }
+}
diff --git a/apps/api/admin/remove-organization.ts b/apps/api/admin/remove-organization.ts
new file mode 100644
index 00000000000..f77258aac30
--- /dev/null
+++ b/apps/api/admin/remove-organization.ts
@@ -0,0 +1,122 @@
+/* eslint-disable no-console */
+import '../src/config';
+import {
+ OrganizationRepository,
+ EnvironmentRepository,
+ MemberRepository,
+ SubscriberRepository,
+ IntegrationRepository,
+ NotificationTemplateRepository,
+ ChangeRepository,
+ ExecutionDetailsRepository,
+ BaseRepository,
+ EnvironmentId,
+ OrganizationId,
+ EnforceEnvOrOrgIds,
+ FeedRepository,
+ JobRepository,
+ LayoutRepository,
+ LogRepository,
+ MessageRepository,
+ MessageTemplateRepository,
+ NotificationGroupRepository,
+ NotificationRepository,
+ SubscriberPreferenceRepository,
+ TenantRepository,
+ TopicRepository,
+ TopicSubscribersRepository,
+} from '@novu/dal';
+
+import { connect } from './connect-to-dal';
+import { makeJsonBackup } from './make-json-backup';
+
+const args = process.argv.slice(2);
+const ORG_ID = args[0];
+const folder = 'remove-organization';
+
+async function removeData, E extends { _id?: string }>(
+ repository: T,
+ model: string,
+ organizationId: OrganizationId,
+ environmentIds: EnvironmentId[]
+) {
+ const data = await repository.find({
+ _organizationId: organizationId,
+ _environmentId: {
+ $in: environmentIds,
+ },
+ });
+ console.log(`Found ${data.length} ${model} from all environments of the organization`);
+
+ if (data.length > 0) {
+ console.log(`Removing ${data.length} ${model} from all environments of the organization`);
+ await makeJsonBackup(folder, model, data);
+ await repository._model.deleteMany({
+ _id: {
+ $in: data.map((change) => change._id),
+ },
+ });
+ }
+}
+
+connect(async () => {
+ const organizationRepository = new OrganizationRepository();
+ const environmentRepository = new EnvironmentRepository();
+ const memberRepository = new MemberRepository();
+
+ const organization = await organizationRepository.findById(ORG_ID);
+ if (!organization) {
+ throw new Error(`Organization with id ${ORG_ID} is not found`);
+ }
+
+ console.log(`The organization ${organization.name} is found`);
+
+ const membersOfOrganization = await memberRepository._model.find({
+ _organizationId: organization._id,
+ });
+ console.log(`The organization has ${membersOfOrganization.length} members`);
+
+ if (membersOfOrganization.length > 0) {
+ console.log(`Removing members from organization`);
+ await makeJsonBackup(folder, 'members', membersOfOrganization);
+ await memberRepository._model.deleteMany({
+ _organizationId: organization._id,
+ });
+ }
+
+ const environmentsOfOrganization = await environmentRepository.findOrganizationEnvironments(organization._id);
+ const envIds = environmentsOfOrganization.map((env) => env._id);
+
+ await removeData(new ChangeRepository(), 'changes', organization._id, envIds);
+ // await removeData(new ExecutionDetailsRepository(), 'executiondetails', organization._id, envIds);
+ await removeData(new FeedRepository(), 'feeds', organization._id, envIds);
+ await removeData(new IntegrationRepository(), 'integrations', organization._id, envIds);
+ // await removeData(new JobRepository(), 'jobs', organization._id, envIds);
+ await removeData(new LayoutRepository(), 'layouts', organization._id, envIds);
+ await removeData(new LogRepository(), 'logs', organization._id, envIds);
+ await removeData(new MessageRepository(), 'messages', organization._id, envIds);
+ await removeData(new MessageTemplateRepository(), 'messagetemplates', organization._id, envIds);
+ await removeData(new NotificationGroupRepository(), 'notificationgroups', organization._id, envIds);
+ // await removeData(new NotificationRepository(), 'notifications', organization._id, envIds);
+ await removeData(new NotificationTemplateRepository(), 'workflows', organization._id, envIds);
+ await removeData(new SubscriberPreferenceRepository(), 'subscriberpreferences', organization._id, envIds);
+ await removeData(new SubscriberRepository(), 'subscribers', organization._id, envIds);
+ await removeData(new TenantRepository(), 'tenants', organization._id, envIds);
+ await removeData(new TopicRepository(), 'topics', organization._id, envIds);
+ await removeData(new TopicSubscribersRepository(), 'topicsubscribers', organization._id, envIds);
+
+ if (environmentsOfOrganization.length > 0) {
+ console.log(`Removing all environments of the organization ${organization.name}`);
+ await makeJsonBackup(folder, 'environments', environmentsOfOrganization);
+ await environmentRepository._model.deleteMany({
+ _id: {
+ $in: envIds,
+ },
+ _organizationId: organization._id,
+ });
+ }
+
+ console.log(`Removing the organization ${organization.name}`);
+ await makeJsonBackup(folder, 'organization', organization);
+ await organizationRepository.delete({ _id: organization._id });
+});
diff --git a/apps/api/admin/remove-user-account.ts b/apps/api/admin/remove-user-account.ts
new file mode 100644
index 00000000000..db44f10b0e5
--- /dev/null
+++ b/apps/api/admin/remove-user-account.ts
@@ -0,0 +1,41 @@
+/* eslint-disable no-console */
+import '../src/config';
+import { UserRepository, MemberRepository } from '@novu/dal';
+
+import { connect } from './connect-to-dal';
+import { normalizeEmail } from '../src/app/shared/helpers/email-normalization.service';
+import { makeJsonBackup } from './make-json-backup';
+
+const args = process.argv.slice(2);
+const EMAIL = args[0];
+const folder = 'remove-user-account';
+
+connect(async () => {
+ const userRepository = new UserRepository();
+ const memberRepository = new MemberRepository();
+
+ const email = normalizeEmail(EMAIL);
+ const user = await userRepository.findByEmail(email);
+ if (!user) {
+ throw new Error(`User account with email ${email} is not found`);
+ }
+
+ console.log(`The user with email: ${email} is found`);
+
+ const memberOfOrganizations = await memberRepository._model.find({
+ _userId: user._id,
+ });
+ console.log(`User is a member of ${memberOfOrganizations.length} organizations`);
+
+ if (memberOfOrganizations.length > 0) {
+ console.log(`Removing user from all organizations`);
+ await makeJsonBackup(folder, 'members', memberOfOrganizations);
+ await memberRepository._model.deleteMany({
+ _userId: user._id,
+ });
+ }
+
+ console.log(`Removing user account`);
+ await makeJsonBackup(folder, 'user', user);
+ await userRepository.delete({ _id: user._id });
+});
diff --git a/apps/api/e2e/compile-email-template.e2e.ts b/apps/api/e2e/compile-email-template.e2e.ts
index e9d54c4b40d..f65a2aa09ad 100644
--- a/apps/api/e2e/compile-email-template.e2e.ts
+++ b/apps/api/e2e/compile-email-template.e2e.ts
@@ -216,4 +216,160 @@ describe('Compile E-mail Template', function () {
expect(subject).to.equal('A title for Header Test');
});
});
+
+ describe('Escaping', function () {
+ it('should escape editor text in double curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: '{{textUrl}}
',
+ },
+ ],
+ payload: {
+ textUrl: 'https://example.com?email=text+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Text Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=text+testing@example.com
');
+ });
+
+ it('should not escape editor text in triple curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: '{{{textUrl}}}
',
+ },
+ ],
+ payload: {
+ textUrl: 'https://example.com?email=text+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Text No Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=text+testing@example.com
');
+ });
+
+ it('should escape button text in double curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.BUTTON,
+ content: '{{buttonText}}',
+ url: 'https://example.com',
+ },
+ ],
+ payload: {
+ buttonText: 'https://example.com?email=button+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Button Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=button+testing@example.com');
+ });
+
+ it('should not escape button text in triple curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.BUTTON,
+ content: '{{{buttonText}}}',
+ url: 'https://example.com',
+ },
+ ],
+ payload: {
+ buttonText: 'https://example.com?email=button+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Button Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=button+testing@example.com');
+ });
+
+ it('should escape button url in double curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.BUTTON,
+ content: 'Click Here To Go To Link!',
+ url: '{{buttonUrl}}',
+ },
+ ],
+ payload: {
+ buttonUrl: 'https://example.com?email=button+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Button Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=button+testing@example.com');
+ });
+
+ it('should not escape button url in triple curly braces', async function () {
+ const { html } = await useCase.execute(
+ CompileEmailTemplateCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ layoutId: null,
+ preheader: null,
+ content: [
+ {
+ type: EmailBlockTypeEnum.BUTTON,
+ content: 'Click Here To Go To Link!',
+ url: '{{{buttonUrl}}}',
+ },
+ ],
+ payload: {
+ buttonUrl: 'https://example.com?email=button+testing@example.com',
+ },
+ userId: session.user._id,
+ contentType: 'editor',
+ subject: 'Editor Button No Escape Test',
+ })
+ );
+
+ expect(html).to.contain('https://example.com?email=button+testing@example.com');
+ });
+ });
});
diff --git a/apps/api/migrations/changes-migration.ts b/apps/api/migrations/changes-migration.ts
index 21b4a2974a4..781d6fa88de 100644
--- a/apps/api/migrations/changes-migration.ts
+++ b/apps/api/migrations/changes-migration.ts
@@ -12,12 +12,11 @@ import {
OrganizationRepository,
} from '@novu/dal';
import { ChangeEntityTypeEnum, MemberRoleEnum } from '@novu/shared';
-import { CreateChange } from '../src/app/change/usecases/create-change/create-change.usecase';
-import { CreateChangeCommand } from '../src/app/change/usecases/create-change/create-change.command';
import { CreateEnvironment } from '../src/app/environments/usecases/create-environment/create-environment.usecase';
import { CreateEnvironmentCommand } from '../src/app/environments/usecases/create-environment/create-environment.command';
import { ApplyChange } from '../src/app/change/usecases/apply-change/apply-change.usecase';
import { ApplyChangeCommand } from '../src/app/change/usecases/apply-change/apply-change.command';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
export async function run(): Promise {
console.log('Script started');
diff --git a/apps/api/package.json b/apps/api/package.json
index b1caa9f7f7d..26fb408cd52 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@novu/api",
- "version": "0.21.0",
+ "version": "0.22.0",
"description": "description",
"author": "",
"private": "true",
@@ -10,7 +10,7 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"precommit": "lint-staged",
- "docker:build": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | docker buildx build -t novu-api --build-arg PACKAGE_PATH=apps/api -",
+ "docker:build": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | docker buildx build --load -t novu-api --build-arg PACKAGE_PATH=apps/api -",
"docker:build:depot": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | depot build --build-arg PACKAGE_PATH=apps/api - -t novu-api --load",
"start": "pnpm start:dev",
"start:dev": "cross-env TZ=UTC nest start --watch",
@@ -20,36 +20,40 @@
"start:build": "TZ=UTC node dist/main.js",
"lint": "eslint src",
"lint:fix": "pnpm lint -- --fix",
- "test": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/**/*.spec.ts",
- "test:e2e": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e.ts",
- "test:e2e:ee": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e-ee.ts",
- "migration": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly ./migrations/expire-at/expire-at.migration.ts",
- "migration:in-app": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly ./migrations/integration-scheme-update/add-primary-priority-migration.ts",
- "migration:primary-provider": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly ./migrations/integration-scheme-update/add-primary-priority-migration.ts"
+ "lint:openapi": "spectral lint http://127.0.0.1:${PORT:-3000}/openapi.yaml",
+ "test": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --require ts-node/register --exit --file e2e/setup.ts src/**/**/*.spec.ts",
+ "test:e2e": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e.ts",
+ "test:e2e:ee": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e-ee.ts",
+ "migration": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly",
+ "link:submodules": "pnpm link ../../enterprise/packages/auth && pnpm link ../../enterprise/packages/translation",
+ "admin:remove-user-account": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly ./admin/remove-user-account.ts",
+ "admin:remove-organization": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly ./admin/remove-organization.ts"
},
"dependencies": {
"@godaddy/terminus": "^4.12.1",
"@google-cloud/storage": "^6.2.3",
"@nestjs/axios": "~2.0.0",
- "@nestjs/common": "^10.2.2",
+ "@nestjs/common": "10.2.2",
"@nestjs/core": "^10.2.2",
- "@nestjs/jwt": "^10.1.0",
+ "@nestjs/jwt": "10.2.0",
"@nestjs/passport": "^10.0.1",
"@nestjs/platform-express": "^10.2.2",
"@nestjs/swagger": "^7.1.8",
"@nestjs/terminus": "^10.0.1",
- "@novu/application-generic": "^0.21.0",
- "@novu/dal": "^0.21.0",
- "@novu/node": "^0.21.0",
- "@novu/shared": "^0.21.0",
- "@novu/stateless": "^0.21.0",
- "@novu/testing": "^0.21.0",
- "@sendgrid/mail": "^7.6.0",
+ "@nestjs/throttler": "^5.0.1",
+ "@novu/application-generic": "^0.22.0",
+ "@novu/dal": "^0.22.0",
+ "@novu/node": "^0.22.0",
+ "@novu/shared": "^0.22.0",
+ "@novu/stateless": "^0.22.0",
+ "@novu/testing": "^0.22.0",
+ "@sendgrid/mail": "^8.1.0",
"@sentry/hub": "^7.40.0",
"@sentry/node": "^7.40.0",
"@sentry/tracing": "^7.40.0",
"@types/newrelic": "^9.14.0",
- "axios": "^1.3.3",
+ "@upstash/ratelimit": "^0.4.4",
+ "axios": "^1.6.2",
"bcrypt": "^5.0.0",
"body-parser": "^1.20.0",
"bull": "^4.2.1",
@@ -71,6 +75,7 @@
"newrelic": "^9.15.0",
"passport": "0.6.0",
"passport-github2": "^0.1.12",
+ "passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.0",
"passport-oauth2": "^1.6.1",
"recursive-diff": "^1.0.8",
@@ -83,13 +88,15 @@
"slugify": "^1.4.6",
"swagger-ui-express": "^4.4.0",
"twilio": "^4.14.1",
- "uuid": "^8.3.2"
+ "uuid": "^8.3.2",
+ "i18next": "^23.7.6"
},
"devDependencies": {
"@faker-js/faker": "^6.0.0",
"@nestjs/cli": "^10.1.16",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.2",
+ "@stoplight/spectral-cli": "^6.11.0",
"@types/bcrypt": "^3.0.0",
"@types/bull": "^3.15.8",
"@types/chai": "^4.2.11",
@@ -110,7 +117,8 @@
"typescript": "4.9.5"
},
"optionalDependencies": {
- "@novu/ee-auth": "^0.19.0"
+ "@novu/ee-auth": "^0.22.0",
+ "@novu/ee-translation": "^0.22.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development
index 1fa679e99c5..b7e8e3fba18 100644
--- a/apps/api/src/.env.development
+++ b/apps/api/src/.env.development
@@ -25,7 +25,6 @@ REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
REDIS_CACHE_ENABLE_AUTOPIPELINING=true
-IS_REQUEST_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
@@ -49,6 +48,7 @@ VERCEL_BASE_URL=https://api.vercel.com
FF_IS_TOPIC_NOTIFICATION_ENABLED=true
FF_IS_DISTRIBUTED_LOCK_LOGGING_ENABLED=false
+IS_TRANSLATION_MANAGER_ENABLED=false
STORE_NOTIFICATION_CONTENT=
@@ -67,3 +67,19 @@ INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY=
LAUNCH_DARKLY_SDK_KEY=
IS_API_IDEMPOTENCY_ENABLED=false
+AUTO_CREATE_INDEXES=true
+
+IS_API_RATE_LIMITING_ENABLED=false
+API_RATE_LIMIT_COST_SINGLE=
+API_RATE_LIMIT_COST_BULK=
+API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
+API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production
index 6936de8f8f4..4a396481707 100644
--- a/apps/api/src/.env.production
+++ b/apps/api/src/.env.production
@@ -13,7 +13,6 @@ REDIS_PORT=6379
REDIS_PREFIX=
REDIS_DB_INDEX=2
-IS_REQUEST_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
@@ -36,6 +35,7 @@ VERCEL_BASE_URL=https://api.vercel.com
FF_IS_TOPIC_NOTIFICATION_ENABLED=true
FF_IS_DISTRIBUTED_LOCK_LOGGING_ENABLED=false
+IS_TRANSLATION_MANAGER_ENABLED=false
STORE_NOTIFICATION_CONTENT=
@@ -56,3 +56,20 @@ INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY=
LAUNCH_DARKLY_SDK_KEY=
IS_API_IDEMPOTENCY_ENABLED=false
+## This value should be set to true if it is the first time you are running the with the database
+AUTO_CREATE_INDEXES=false
+
+IS_API_RATE_LIMITING_ENABLED=false
+API_RATE_LIMIT_COST_SINGLE=
+API_RATE_LIMIT_COST_BULK=
+API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
+API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test
index 31f183d0f2b..2325de0bfa7 100644
--- a/apps/api/src/.env.test
+++ b/apps/api/src/.env.test
@@ -1,12 +1,12 @@
GOOGLE_OAUTH_CLIENT_ID=11
GOOGLE_OAUTH_CLIENT_SECRET=11
-GOOGLE_OAUTH_REDIRECT=http://localhost:3000/v1/auth/google/callback
+GOOGLE_OAUTH_REDIRECT=http://127.0.0.1:3000/v1/auth/google/callback
STORE_ENCRYPTION_KEY=""
BLUEPRINT_CREATOR=645b648b36dd6d25f8650d37
-CLIENT_SUCCESS_AUTH_REDIRECT=http://localhost:4200/auth/login
+CLIENT_SUCCESS_AUTH_REDIRECT=http://127.0.0.1:4200/auth/login
-MONGO_URL=mongodb://localhost:27017/novu-test
+MONGO_URL=mongodb://127.0.0.1:27017/novu-test
REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_PREFIX=
@@ -21,9 +21,8 @@ REDIS_CACHE_CONNECTION_TIMEOUT=
REDIS_CACHE_KEEP_ALIVE=
REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
-REDIS_CACHE_ENABLE_AUTOPIPELINING=false
+REDIS_CACHE_ENABLE_AUTOPIPELINING=false
-IS_REQUEST_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
@@ -37,8 +36,8 @@ REDIS_CLUSTER_KEEP_ALIVE=
REDIS_CLUSTER_FAMILY=
REDIS_CLUSTER_KEY_PREFIX=
-SYNC_PATH=http://localhost:3001
-API_ROOT_URL=http://localhost:3000
+SYNC_PATH=http://127.0.0.1:3001
+API_ROOT_URL=http://127.0.0.1:3000
DISABLE_USER_REGISTRATION=false
PORT=1337
JWT_SECRET=ASD#asda23DFEFSFHG%fg
@@ -47,14 +46,14 @@ SENDGRID_API_KEY=SG.123123
S3_ACCESS_KEY=
S3_SECRET=
REDIS_ARENA_PORT=4568
-FRONT_BASE_URL=http://localhost:4200
+FRONT_BASE_URL=http://127.0.0.1:4200
RELEASLY_MAIL=support@starter.co
-BACK_OFFICE_URL=http://localhost:5200
+BACK_OFFICE_URL=http://127.0.0.1:5200
INTERCOM_API_KEY=
GLOBAL_CONTEXT_PATH=
API_CONTEXT_PATH=
-S3_LOCAL_STACK=http://localhost:4566
+S3_LOCAL_STACK=http://127.0.0.1:4566
S3_BUCKET_NAME=novu-test
S3_REGION=us-east-1
GCS_BUCKET_NAME=novu-test
@@ -71,7 +70,7 @@ MAIL_SERVER_DOMAIN=
VERCEL_CLIENT_ID=
VERCEL_CLIENT_SECRET=
-VERCEL_REDIRECT_URI=http://localhost:4200/auth/login
+VERCEL_REDIRECT_URI=http://127.0.0.1:4200/auth/login
VERCEL_BASE_URL=https://api.vercel.com
FF_IS_TOPIC_NOTIFICATION_ENABLED=true
@@ -92,3 +91,21 @@ NOVU_SMS_INTEGRATION_TOKEN=test
NOVU_SMS_INTEGRATION_SENDER=1234567890
IS_API_IDEMPOTENCY_ENABLED=true
+AUTO_CREATE_INDEXES=true
+
+IS_API_RATE_LIMITING_ENABLED=false
+API_RATE_LIMIT_COST_SINGLE=
+API_RATE_LIMIT_COST_BULK=
+API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
+API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
+
+IS_USE_MERGED_DIGEST_ID_ENABLED=true
diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env
index 688ddb2bf25..835bdaf59a2 100644
--- a/apps/api/src/.example.env
+++ b/apps/api/src/.example.env
@@ -1,11 +1,11 @@
NODE_ENV=local
PORT=3000
-API_ROOT_URL=http://localhost:3000
-FRONT_BASE_URL=http://localhost:4200
+API_ROOT_URL=http://127.0.0.1:3000
+FRONT_BASE_URL=http://127.0.0.1:4200
STORE_ENCRYPTION_KEY=""
DISABLE_USER_REGISTRATION=false
-MONGO_URL=mongodb://localhost:27017/novu-db
+MONGO_URL=mongodb://127.0.0.1:27017/novu-db
MONGO_MAX_POOL_SIZE=500
REDIS_PORT=6379
REDIS_PREFIX=
@@ -23,7 +23,6 @@ REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
REDIS_CACHE_ENABLE_AUTOPIPELINING=
-IS_REQUEST_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
REDIS_CLUSTER_SERVICE_HOST=
REDIS_CLUSTER_SERVICE_PORT=
@@ -36,9 +35,9 @@ REDIS_CLUSTER_FAMILY=
REDIS_CLUSTER_KEY_PREFIX=
-JWT_SECRET=%LOCAL_TEST&@Ub4&9s
+JWT_SECRET=LOCAL_ONLY_CHANGE_ME
-S3_LOCAL_STACK=http://localhost:4566
+S3_LOCAL_STACK=http://127.0.0.1:4566
S3_BUCKET_NAME=novu-local
S3_REGION=us-east-1
AWS_ACCESS_KEY_ID=test
@@ -52,10 +51,11 @@ GLOBAL_CONTEXT_PATH=
API_CONTEXT_PATH=
VERCEL_CLIENT_ID=
VERCEL_CLIENT_SECRET=
-VERCEL_REDIRECT_URI=http://localhost:4200/auth/login
+VERCEL_REDIRECT_URI=http://127.0.0.1:4200/auth/login
VERCEL_BASE_URL=https://api.vercel.com
FF_IS_TOPIC_NOTIFICATION_ENABLED=true
+IS_TRANSLATION_MANAGER_ENABLED=false
STORE_NOTIFICATION_CONTENT=true
@@ -63,3 +63,18 @@ INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY=
LOGGING_LEVEL=info
LAUNCH_DARKLY_SDK_KEY=
+
+IS_API_RATE_LIMITING_ENABLED=false
+API_RATE_LIMIT_COST_SINGLE=
+API_RATE_LIMIT_COST_BULK=
+API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
+API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
+API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 05373ac753e..b7ef016abd7 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -32,12 +32,20 @@ import { InboundParseModule } from './app/inbound-parse/inbound-parse.module';
import { BlueprintModule } from './app/blueprint/blueprint.module';
import { TenantModule } from './app/tenant/tenant.module';
import { IdempotencyInterceptor } from './app/shared/framework/idempotency.interceptor';
+import { WorkflowOverridesModule } from './app/workflow-overrides/workflow-overrides.module';
+import { ApiRateLimitInterceptor } from './app/rate-limiting/guards';
+import { RateLimitingModule } from './app/rate-limiting/rate-limiting.module';
const enterpriseImports = (): Array | ForwardReference> => {
const modules: Array | ForwardReference> = [];
try {
- if (process.env.NOVU_MANAGED_SERVICE === 'true' || process.env.CI_EE_TEST === 'true') {
- modules.push(require('@novu/ee-auth')?.EEAuthModule);
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (require('@novu/ee-auth')?.EEAuthModule) {
+ modules.push(require('@novu/ee-auth')?.EEAuthModule);
+ }
+ if (require('@novu/ee-translation')?.EnterpriseTranslationModule) {
+ modules.push(require('@novu/ee-translation')?.EnterpriseTranslationModule);
+ }
}
} catch (e) {
Logger.error(e, `Unexpected error while importing enterprise modules`, 'EnterpriseImport');
@@ -73,6 +81,8 @@ const baseModules: Array | Forward
TopicsModule,
BlueprintModule,
TenantModule,
+ WorkflowOverridesModule,
+ RateLimitingModule,
];
const enterpriseModules = enterpriseImports();
@@ -80,6 +90,10 @@ const enterpriseModules = enterpriseImports();
const modules = baseModules.concat(enterpriseModules);
const providers: Provider[] = [
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: ApiRateLimitInterceptor,
+ },
{
provide: APP_INTERCEPTOR,
useClass: IdempotencyInterceptor,
diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts
index e4a5ec4a1af..c43fa83276b 100644
--- a/apps/api/src/app/auth/auth.controller.ts
+++ b/apps/api/src/app/auth/auth.controller.ts
@@ -14,10 +14,9 @@ import {
UseGuards,
UseInterceptors,
Logger,
- ExecutionContext,
+ Header,
} from '@nestjs/common';
import { MemberRepository, OrganizationRepository, UserRepository, MemberEntity } from '@novu/dal';
-import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { IJwtPayload } from '@novu/shared';
import { UserRegistrationBodyDto } from './dtos/user-registration.dto';
@@ -27,7 +26,7 @@ import { Login } from './usecases/login/login.usecase';
import { LoginBodyDto } from './dtos/login.dto';
import { LoginCommand } from './usecases/login/login.command';
import { UserSession } from '../shared/framework/user.decorator';
-import { JwtAuthGuard } from './framework/auth.guard';
+import { UserAuthGuard } from './framework/user.auth.guard';
import { PasswordResetRequestCommand } from './usecases/password-reset-request/password-reset-request.command';
import { PasswordResetRequest } from './usecases/password-reset-request/password-reset-request.usecase';
import { PasswordResetCommand } from './usecases/password-reset/password-reset.command';
@@ -43,7 +42,9 @@ import {
SwitchOrganization,
SwitchOrganizationCommand,
} from '@novu/application-generic';
+import { ApiCommonResponses } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/auth')
@UseInterceptors(ClassSerializerInterceptor)
@ApiTags('Auth')
@@ -51,7 +52,6 @@ import {
export class AuthController {
constructor(
private userRepository: UserRepository,
- private jwtService: JwtService,
private authService: AuthService,
private userRegisterUsecase: UserRegister,
private loginUsecase: Login,
@@ -89,7 +89,8 @@ export class AuthController {
}
@Get('/refresh')
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
+ @Header('Cache-Control', 'no-store')
refreshToken(@UserSession() user: IJwtPayload) {
if (!user || !user._id) throw new BadRequestException();
@@ -97,6 +98,7 @@ export class AuthController {
}
@Post('/register')
+ @Header('Cache-Control', 'no-store')
async userRegistration(@Body() body: UserRegistrationBodyDto) {
return await this.userRegisterUsecase.execute(
UserRegisterCommand.create({
@@ -106,6 +108,9 @@ export class AuthController {
lastName: body.lastName,
organizationName: body.organizationName,
origin: body.origin,
+ jobTitle: body.jobTitle,
+ domain: body.domain,
+ productUseCases: body.productUseCases,
})
);
}
@@ -130,6 +135,7 @@ export class AuthController {
}
@Post('/login')
+ @Header('Cache-Control', 'no-store')
async userLogin(@Body() body: LoginBodyDto) {
return await this.loginUsecase.execute(
LoginCommand.create({
@@ -140,8 +146,9 @@ export class AuthController {
}
@Post('/organizations/:organizationId/switch')
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@HttpCode(200)
+ @Header('Cache-Control', 'no-store')
async organizationSwitch(
@UserSession() user: IJwtPayload,
@Param('organizationId') organizationId: string
@@ -155,7 +162,8 @@ export class AuthController {
}
@Post('/environments/:environmentId/switch')
- @UseGuards(JwtAuthGuard)
+ @Header('Cache-Control', 'no-store')
+ @UseGuards(UserAuthGuard)
@HttpCode(200)
async projectSwitch(
@UserSession() user: IJwtPayload,
diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts
index 4294e22ab64..4200eb6c99c 100644
--- a/apps/api/src/app/auth/auth.module.ts
+++ b/apps/api/src/app/auth/auth.module.ts
@@ -3,7 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import * as passport from 'passport';
-import { AuthProviderEnum } from '@novu/shared';
+import { AuthProviderEnum, PassportStrategyEnum } from '@novu/shared';
import { AuthService } from '@novu/application-generic';
import { RolesGuard } from './framework/roles.guard';
@@ -16,10 +16,11 @@ import { GitHubStrategy } from './services/passport/github.strategy';
import { OrganizationModule } from '../organization/organization.module';
import { EnvironmentsModule } from '../environments/environments.module';
import { JwtSubscriberStrategy } from './services/passport/subscriber-jwt.strategy';
-import { JwtAuthGuard } from './framework/auth.guard';
+import { UserAuthGuard } from './framework/user.auth.guard';
import { RootEnvironmentGuard } from './framework/root-environment-guard.service';
+import { ApiKeyStrategy } from './services/passport/apikey.strategy';
-const AUTH_STRATEGIES: Provider[] = [];
+const AUTH_STRATEGIES: Provider[] = [JwtStrategy, ApiKeyStrategy, JwtSubscriberStrategy];
if (process.env.GITHUB_OAUTH_CLIENT_ID) {
AUTH_STRATEGIES.push(GitHubStrategy);
@@ -31,7 +32,7 @@ if (process.env.GITHUB_OAUTH_CLIENT_ID) {
SharedModule,
UserModule,
PassportModule.register({
- defaultStrategy: 'jwt',
+ defaultStrategy: PassportStrategyEnum.JWT,
}),
JwtModule.register({
secretOrKeyProvider: () => process.env.JWT_SECRET as string,
@@ -42,17 +43,8 @@ if (process.env.GITHUB_OAUTH_CLIENT_ID) {
EnvironmentsModule,
],
controllers: [AuthController],
- providers: [
- JwtAuthGuard,
- ...USE_CASES,
- ...AUTH_STRATEGIES,
- JwtStrategy,
- AuthService,
- RolesGuard,
- JwtSubscriberStrategy,
- RootEnvironmentGuard,
- ],
- exports: [RolesGuard, RootEnvironmentGuard, AuthService, ...USE_CASES, JwtAuthGuard],
+ providers: [UserAuthGuard, ...USE_CASES, ...AUTH_STRATEGIES, AuthService, RolesGuard, RootEnvironmentGuard],
+ exports: [RolesGuard, RootEnvironmentGuard, AuthService, ...USE_CASES, UserAuthGuard],
})
export class AuthModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
diff --git a/apps/api/src/app/auth/dtos/user-registration.dto.ts b/apps/api/src/app/auth/dtos/user-registration.dto.ts
index 0d8e61e5a05..01fdbe93f1a 100644
--- a/apps/api/src/app/auth/dtos/user-registration.dto.ts
+++ b/apps/api/src/app/auth/dtos/user-registration.dto.ts
@@ -1,5 +1,11 @@
import { IsDefined, IsEmail, IsOptional, MinLength, Matches, MaxLength, IsString, IsEnum } from 'class-validator';
-import { passwordConstraints, SignUpOriginEnum } from '@novu/shared';
+import {
+ JobTitleEnum,
+ passwordConstraints,
+ ProductUseCases,
+ ProductUseCasesEnum,
+ SignUpOriginEnum,
+} from '@novu/shared';
export class UserRegistrationBodyDto {
@IsDefined()
@IsEmail()
@@ -25,9 +31,20 @@ export class UserRegistrationBodyDto {
@IsOptional()
@IsString()
- organizationName: string;
+ organizationName?: string;
@IsOptional()
@IsEnum(SignUpOriginEnum)
origin?: SignUpOriginEnum;
+
+ @IsOptional()
+ @IsEnum(JobTitleEnum)
+ jobTitle?: JobTitleEnum;
+
+ @IsString()
+ @IsOptional()
+ domain?: string;
+
+ @IsOptional()
+ productUseCases?: ProductUseCases;
}
diff --git a/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts b/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts
index 6b9306e3131..d3189adc0cf 100644
--- a/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts
+++ b/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts
@@ -1,6 +1,5 @@
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
-import * as sinon from 'sinon';
describe('User login - /auth/google (GET)', async () => {
let session: UserSession;
diff --git a/apps/api/src/app/auth/e2e/user.auth.guard.e2e.ts b/apps/api/src/app/auth/e2e/user.auth.guard.e2e.ts
new file mode 100644
index 00000000000..9d0cc3a1494
--- /dev/null
+++ b/apps/api/src/app/auth/e2e/user.auth.guard.e2e.ts
@@ -0,0 +1,87 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+import { ApiAuthSchemeEnum } from '@novu/shared';
+import { HttpRequestHeaderKeysEnum } from '../../shared/framework/types';
+
+describe('UserAuthGuard', () => {
+ let session: UserSession;
+ const defaultPath = '/v1/test-auth/user-route';
+ const apiInaccessiblePath = '/v1/test-auth/user-api-inaccessible-route';
+
+ let request: (
+ authHeader: string,
+ path?: string
+ ) => Promise>>;
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+
+ request = (authHeader, path = defaultPath) =>
+ session.testAgent.get(path).set(HttpRequestHeaderKeysEnum.AUTHORIZATION, authHeader);
+ });
+
+ describe('Invalid authentication scheme', () => {
+ it('should return 401 when an invalid auth scheme is provided', async () => {
+ const response = await request('Invalid invalid_value');
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('Invalid authentication scheme: "Invalid"');
+ });
+
+ it('should return 401 when no authorization header is provided', async () => {
+ const response = await session.testAgent.get(defaultPath).unset(HttpRequestHeaderKeysEnum.AUTHORIZATION);
+
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('Missing authorization header');
+ });
+ });
+
+ describe('ApiKey authentication scheme', () => {
+ it('should return 401 when ApiKey auth scheme is provided without a value', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.API_KEY} `);
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('Unauthorized');
+ });
+
+ it('should return 401 when ApiKey auth scheme is provided with an invalid value', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.API_KEY} invalid_key`);
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('API Key not found');
+ });
+
+ it('should return 401 when ApiKey auth scheme is used for an externally inaccessible API route', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.API_KEY} ${session.apiKey}`, apiInaccessiblePath);
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('API endpoint not available');
+ });
+
+ it('should return 200 when ApiKey auth scheme is provided with a valid value', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.API_KEY} ${session.apiKey}`);
+ expect(response.statusCode).to.equal(200);
+ });
+ });
+
+ describe('Bearer authentication scheme', () => {
+ it('should return 401 when Bearer auth scheme is provided without a value', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.BEARER} `);
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('Unauthorized');
+ });
+
+ it('should return 401 when Bearer auth scheme is provided with an invalid value', async () => {
+ const response = await request(`${ApiAuthSchemeEnum.BEARER} invalid_token`);
+ expect(response.statusCode).to.equal(401);
+ expect(response.body.message).to.equal('Unauthorized');
+ });
+
+ it('should return 200 when Bearer auth scheme is used for an externally inaccessible API route', async () => {
+ const response = await request(session.token, apiInaccessiblePath);
+ expect(response.statusCode).to.equal(200);
+ });
+
+ it('should return 200 when Bearer auth scheme is provided with a valid value', async () => {
+ const response = await request(session.token);
+ expect(response.statusCode).to.equal(200);
+ });
+ });
+});
diff --git a/apps/api/src/app/auth/framework/auth.guard.ts b/apps/api/src/app/auth/framework/auth.guard.ts
deleted file mode 100644
index bd8ae0e96cf..00000000000
--- a/apps/api/src/app/auth/framework/auth.guard.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { ExecutionContext, forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
-import { AuthGuard } from '@nestjs/passport';
-import { Reflector } from '@nestjs/core';
-import { AuthService, Instrument, PinoLogger } from '@novu/application-generic';
-
-@Injectable()
-export class JwtAuthGuard extends AuthGuard('jwt') {
- constructor(
- @Inject(forwardRef(() => AuthService)) private authService: AuthService,
- private readonly reflector: Reflector,
- private readonly logger: PinoLogger
- ) {
- super();
- }
-
- @Instrument()
- async canActivate(context: ExecutionContext) {
- const request = context.switchToHttp().getRequest();
- const authorizationHeader = request.headers.authorization;
-
- if (authorizationHeader && authorizationHeader.includes('ApiKey')) {
- const apiEnabled = this.reflector.get('external_api_accessible', context.getHandler());
- if (!apiEnabled) throw new UnauthorizedException('API endpoint not available');
-
- const key = authorizationHeader.split(' ')[1];
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return this.authService.apiKeyAuthenticate(key).then((result) => {
- request.headers.authorization = `Bearer ${result}`;
-
- return true;
- });
- }
-
- return super.canActivate(context);
- }
-}
diff --git a/apps/api/src/app/auth/framework/external-api.decorator.ts b/apps/api/src/app/auth/framework/external-api.decorator.ts
index 11ab71f7ec6..e707fa92009 100644
--- a/apps/api/src/app/auth/framework/external-api.decorator.ts
+++ b/apps/api/src/app/auth/framework/external-api.decorator.ts
@@ -1,4 +1,3 @@
-import { SetMetadata } from '@nestjs/common';
+import { ExternalApiAccessible } from '@novu/application-generic';
-// eslint-disable-next-line @typescript-eslint/naming-convention
-export const ExternalApiAccessible = () => SetMetadata('external_api_accessible', true);
+export { ExternalApiAccessible };
diff --git a/apps/api/src/app/auth/framework/roles.guard.ts b/apps/api/src/app/auth/framework/roles.guard.ts
index 4138c5c67f6..b452a5b7874 100644
--- a/apps/api/src/app/auth/framework/roles.guard.ts
+++ b/apps/api/src/app/auth/framework/roles.guard.ts
@@ -13,18 +13,6 @@ export class RolesGuard implements CanActivate {
return true;
}
- const request = context.switchToHttp().getRequest();
- if (!request.headers.authorization) return false;
-
- const token = request.headers.authorization.split(' ')[1];
- if (!token) return false;
-
- const authorizationHeader = request.headers.authorization;
- if (!authorizationHeader?.includes('ApiKey')) {
- const user = jwt.decode(token) as IJwtPayload;
- if (!user) return false;
- }
-
/*
* TODO: The roles check implementation is currently not enabled
* As we are not using roles in the system at this point
diff --git a/apps/api/src/app/auth/framework/root-environment-guard.service.ts b/apps/api/src/app/auth/framework/root-environment-guard.service.ts
index 25f475d9195..79546bd8bcb 100644
--- a/apps/api/src/app/auth/framework/root-environment-guard.service.ts
+++ b/apps/api/src/app/auth/framework/root-environment-guard.service.ts
@@ -13,14 +13,7 @@ export class RootEnvironmentGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
- if (!request.headers.authorization) return false;
-
- const token = request.headers.authorization.split(' ')[1];
- if (!token) return false;
-
- const user = jwt.decode(token) as IJwtPayload;
- if (!user) return false;
- if (!user.environmentId) return false;
+ const user = request.user;
const environment = await this.authService.isRootEnvironment(user);
diff --git a/apps/api/src/app/auth/framework/user.auth.guard.ts b/apps/api/src/app/auth/framework/user.auth.guard.ts
new file mode 100644
index 00000000000..d394d1894fe
--- /dev/null
+++ b/apps/api/src/app/auth/framework/user.auth.guard.ts
@@ -0,0 +1,3 @@
+import { UserAuthGuard } from '@novu/application-generic';
+
+export { UserAuthGuard };
diff --git a/apps/api/src/app/auth/services/passport/apikey.strategy.ts b/apps/api/src/app/auth/services/passport/apikey.strategy.ts
new file mode 100644
index 00000000000..7618fe7b3b1
--- /dev/null
+++ b/apps/api/src/app/auth/services/passport/apikey.strategy.ts
@@ -0,0 +1,28 @@
+import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
+import { PassportStrategy } from '@nestjs/passport';
+import { Injectable } from '@nestjs/common';
+import { AuthService } from '@novu/application-generic';
+import { ApiAuthSchemeEnum, IJwtPayload } from '@novu/shared';
+import { HttpRequestHeaderKeysEnum } from '../../../shared/framework/types';
+
+@Injectable()
+export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
+ constructor(private readonly authService: AuthService) {
+ super(
+ { header: HttpRequestHeaderKeysEnum.AUTHORIZATION, prefix: `${ApiAuthSchemeEnum.API_KEY} ` },
+ true,
+ (apikey: string, verified: (err: Error | null, user?: IJwtPayload | false) => void) => {
+ this.authService
+ .validateApiKey(apikey)
+ .then((user) => {
+ if (!user) {
+ return verified(null, false);
+ }
+
+ return verified(null, user);
+ })
+ .catch((err) => verified(err, false));
+ }
+ );
+ }
+}
diff --git a/apps/api/src/app/auth/services/passport/jwt.strategy.ts b/apps/api/src/app/auth/services/passport/jwt.strategy.ts
index bd912e1f4a9..fa9fbc7786b 100644
--- a/apps/api/src/app/auth/services/passport/jwt.strategy.ts
+++ b/apps/api/src/app/auth/services/passport/jwt.strategy.ts
@@ -2,11 +2,11 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { IJwtPayload } from '@novu/shared';
-import { AuthService, Instrument, PinoLogger } from '@novu/application-generic';
+import { AuthService, Instrument } from '@novu/application-generic';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
- constructor(private readonly authService: AuthService, private logger: PinoLogger) {
+ constructor(private readonly authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
@@ -20,12 +20,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException();
}
- this.logger.assign({
- userId: user._id,
- environmentId: payload.environmentId,
- organizationId: payload.organizationId,
- });
-
return payload;
}
}
diff --git a/apps/api/src/app/auth/usecases/register/user-register.command.ts b/apps/api/src/app/auth/usecases/register/user-register.command.ts
index ce7c1a5cf15..a779b297051 100644
--- a/apps/api/src/app/auth/usecases/register/user-register.command.ts
+++ b/apps/api/src/app/auth/usecases/register/user-register.command.ts
@@ -1,6 +1,8 @@
-import { IsDefined, IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator';
+import { IsDefined, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
+
+import { JobTitleEnum, ProductUseCases, SignUpOriginEnum } from '@novu/shared';
+
import { BaseCommand } from '../../../shared/commands/base.command';
-import { SignUpOriginEnum } from '@novu/shared';
export class UserRegisterCommand extends BaseCommand {
@IsDefined()
@@ -9,18 +11,34 @@ export class UserRegisterCommand extends BaseCommand {
email: string;
@IsDefined()
+ @IsString()
@MinLength(8)
password: string;
@IsDefined()
+ @IsString()
firstName: string;
@IsOptional()
+ @IsString()
lastName?: string;
@IsOptional()
+ @IsString()
organizationName?: string;
@IsOptional()
+ @IsEnum(SignUpOriginEnum)
origin?: SignUpOriginEnum;
+
+ @IsOptional()
+ @IsEnum(JobTitleEnum)
+ jobTitle?: JobTitleEnum;
+
+ @IsString()
+ @IsOptional()
+ domain?: string;
+
+ @IsOptional()
+ productUseCases?: ProductUseCases;
}
diff --git a/apps/api/src/app/auth/usecases/register/user-register.usecase.ts b/apps/api/src/app/auth/usecases/register/user-register.usecase.ts
index b6b5df585c5..115965a3a3d 100644
--- a/apps/api/src/app/auth/usecases/register/user-register.usecase.ts
+++ b/apps/api/src/app/auth/usecases/register/user-register.usecase.ts
@@ -54,6 +54,9 @@ export class UserRegister {
CreateOrganizationCommand.create({
name: command.organizationName,
userId: user._id,
+ jobTitle: command.jobTitle,
+ domain: command.domain,
+ productUseCases: command.productUseCases,
})
);
}
diff --git a/apps/api/src/app/blueprint/blueprint.controller.ts b/apps/api/src/app/blueprint/blueprint.controller.ts
index 1b85868ca82..a17a670e4f5 100644
--- a/apps/api/src/app/blueprint/blueprint.controller.ts
+++ b/apps/api/src/app/blueprint/blueprint.controller.ts
@@ -4,7 +4,9 @@ import { GroupedBlueprintResponse } from './dto/grouped-blueprint.response.dto';
import { GetBlueprint, GetBlueprintCommand } from './usecases/get-blueprint';
import { GetGroupedBlueprints } from './usecases/get-grouped-blueprints';
import { GetBlueprintResponse } from './dto/get-blueprint.response.dto';
+import { ApiCommonResponses } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/blueprints')
@UseInterceptors(ClassSerializerInterceptor)
export class BlueprintController {
diff --git a/apps/api/src/app/change/change.module.ts b/apps/api/src/app/change/change.module.ts
index 827ae5d933a..dd46d1b511f 100644
--- a/apps/api/src/app/change/change.module.ts
+++ b/apps/api/src/app/change/change.module.ts
@@ -1,11 +1,35 @@
-import { forwardRef, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
+import {
+ DynamicModule,
+ forwardRef,
+ ForwardReference,
+ Logger,
+ MiddlewareConsumer,
+ Module,
+ NestModule,
+ Type,
+} from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { ChangesController } from './changes.controller';
import { USE_CASES } from './usecases';
import { AuthModule } from '../auth/auth.module';
+const enterpriseImports = (): Array | ForwardReference> => {
+ const modules: Array | ForwardReference> = [];
+ try {
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (require('@novu/ee-translation')?.EnterpriseTranslationModule) {
+ modules.push(require('@novu/ee-translation')?.EnterpriseTranslationModule);
+ }
+ }
+ } catch (e) {
+ Logger.error(e, `Unexpected error while importing enterprise modules`, 'EnterpriseImport');
+ }
+
+ return modules;
+};
+
@Module({
- imports: [SharedModule, forwardRef(() => AuthModule)],
+ imports: [SharedModule, forwardRef(() => AuthModule), ...enterpriseImports()],
providers: [...USE_CASES],
exports: [...USE_CASES],
controllers: [ChangesController],
diff --git a/apps/api/src/app/change/changes.controller.ts b/apps/api/src/app/change/changes.controller.ts
index 3208a8485e0..f194844ae16 100644
--- a/apps/api/src/app/change/changes.controller.ts
+++ b/apps/api/src/app/change/changes.controller.ts
@@ -9,9 +9,9 @@ import {
UseGuards,
UseInterceptors,
} from '@nestjs/common';
-import { IJwtPayload } from '@novu/shared';
+import { ApiRateLimitCostEnum, IJwtPayload } from '@novu/shared';
import { UserSession } from '../shared/framework/user.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ApplyChange, ApplyChangeCommand } from './usecases';
import { GetChanges } from './usecases/get-changes/get-changes.usecase';
import { GetChangesCommand } from './usecases/get-changes/get-changes.command';
@@ -19,17 +19,19 @@ import { BulkApplyChange } from './usecases/bulk-apply-change/bulk-apply-change.
import { BulkApplyChangeCommand } from './usecases/bulk-apply-change/bulk-apply-change.command';
import { CountChanges } from './usecases/count-changes/count-changes.usecase';
import { CountChangesCommand } from './usecases/count-changes/count-changes.command';
-import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ChangesResponseDto, ChangeResponseDto } from './dtos/change-response.dto';
import { ChangesRequestDto } from './dtos/change-request.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator';
import { DataNumberDto } from '../shared/dtos/data-wrapper-dto';
import { BulkApplyChangeDto } from './dtos/bulk-apply-change.dto';
+import { ThrottlerCost } from '../rate-limiting/guards';
+@ApiCommonResponses()
@Controller('/changes')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Changes')
export class ChangesController {
constructor(
@@ -78,6 +80,7 @@ export class ChangesController {
);
}
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
@Post('/bulk/apply')
@ApiResponse(ChangeResponseDto, 201, true)
@ApiOperation({
diff --git a/apps/api/src/app/change/usecases/create-change/create-change.spec.ts b/apps/api/src/app/change/usecases/create-change/create-change.spec.ts
index 65cdf456219..60f5b8d8e77 100644
--- a/apps/api/src/app/change/usecases/create-change/create-change.spec.ts
+++ b/apps/api/src/app/change/usecases/create-change/create-change.spec.ts
@@ -3,9 +3,9 @@ import { UserSession } from '@novu/testing';
import { ChangeEntityTypeEnum } from '@novu/shared';
import { expect } from 'chai';
-import { CreateChange, CreateChangeCommand } from './index';
import { ChangeModule } from '../../change.module';
import { SharedModule } from '../../../shared/shared.module';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
describe('Create Change', function () {
let useCase: CreateChange;
diff --git a/apps/api/src/app/change/usecases/index.ts b/apps/api/src/app/change/usecases/index.ts
index da7c153df06..fa07f1263b9 100644
--- a/apps/api/src/app/change/usecases/index.ts
+++ b/apps/api/src/app/change/usecases/index.ts
@@ -1,7 +1,6 @@
import { PromoteMessageTemplateChange } from './promote-message-template-change/promote-message-template-change';
import { PromoteNotificationTemplateChange } from './promote-notification-template-change/promote-notification-template-change.usecase';
import { PromoteChangeToEnvironment } from './promote-change-to-environment/promote-change-to-environment.usecase';
-import { CreateChange } from './create-change/create-change.usecase';
import { ApplyChange } from './apply-change/apply-change.usecase';
import { GetChanges } from './get-changes/get-changes.usecase';
import { BulkApplyChange } from './bulk-apply-change/bulk-apply-change.usecase';
@@ -10,9 +9,10 @@ import { PromoteNotificationGroupChange } from './promote-notification-group-cha
import { UpdateChange } from './update-change/update-change';
import { PromoteFeedChange } from './promote-feed-change/promote-feed-change';
import { PromoteLayoutChange } from './promote-layout-change/promote-layout-change.use-case';
+import { CreateChange } from '@novu/application-generic';
+import { PromoteTranslationChange } from './promote-translation-change';
export * from './apply-change';
-export * from './create-change';
export * from './promote-change-to-environment';
export * from './promote-notification-template-change';
@@ -29,4 +29,5 @@ export const USE_CASES = [
BulkApplyChange,
CountChanges,
UpdateChange,
+ PromoteTranslationChange,
];
diff --git a/apps/api/src/app/change/usecases/promote-change-to-environment/promote-change-to-environment.usecase.ts b/apps/api/src/app/change/usecases/promote-change-to-environment/promote-change-to-environment.usecase.ts
index 234c2a169ef..60f69d37b87 100644
--- a/apps/api/src/app/change/usecases/promote-change-to-environment/promote-change-to-environment.usecase.ts
+++ b/apps/api/src/app/change/usecases/promote-change-to-environment/promote-change-to-environment.usecase.ts
@@ -1,4 +1,4 @@
-import { forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ChangeRepository, EnvironmentRepository } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
import { applyDiff } from 'recursive-diff';
@@ -10,6 +10,8 @@ import { PromoteNotificationTemplateChange } from '../promote-notification-templ
import { PromoteMessageTemplateChange } from '../promote-message-template-change/promote-message-template-change';
import { PromoteNotificationGroupChange } from '../promote-notification-group-change/promote-notification-group-change';
import { PromoteFeedChange } from '../promote-feed-change/promote-feed-change';
+import { ModuleRef } from '@nestjs/core';
+import { PromoteTranslationChange } from '../promote-translation-change/promote-translation-change.usecase';
@Injectable()
export class PromoteChangeToEnvironment {
@@ -21,7 +23,9 @@ export class PromoteChangeToEnvironment {
private promoteNotificationTemplateChange: PromoteNotificationTemplateChange,
private promoteMessageTemplateChange: PromoteMessageTemplateChange,
private promoteNotificationGroupChange: PromoteNotificationGroupChange,
- private promoteFeedChange: PromoteFeedChange
+ private promoteFeedChange: PromoteFeedChange,
+ private promoteTranslationChange: PromoteTranslationChange,
+ private moduleRef: ModuleRef
) {}
async execute(command: PromoteChangeToEnvironmentCommand) {
@@ -61,6 +65,24 @@ export class PromoteChangeToEnvironment {
case ChangeEntityTypeEnum.DEFAULT_LAYOUT:
await this.promoteLayoutChange.execute(typeCommand);
break;
+ case ChangeEntityTypeEnum.TRANSLATION:
+ await this.promoteTranslationChange.execute(typeCommand);
+ break;
+ case ChangeEntityTypeEnum.TRANSLATION_GROUP:
+ try {
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (!require('@novu/ee-translation')?.PromoteTranslationGroupChange) {
+ throw new BadRequestException('Translation module is not loaded');
+ }
+ const usecase = this.moduleRef.get(require('@novu/ee-translation')?.PromoteTranslationGroupChange, {
+ strict: false,
+ });
+ await usecase.execute(typeCommand);
+ }
+ } catch (e) {
+ Logger.error(e, `Unexpected error while importing enterprise modules`, 'PromoteChangeToEnvironment');
+ }
+ break;
default:
Logger.error(`Change with type ${command.type} could not be enabled from environment ${command.environmentId}`);
}
diff --git a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts
index f702e2e24f0..915c45c0f63 100644
--- a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts
+++ b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts
@@ -6,6 +6,7 @@ import {
MessageTemplateRepository,
NotificationStepEntity,
NotificationGroupRepository,
+ StepVariantEntity,
} from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
import {
@@ -42,7 +43,10 @@ export class PromoteNotificationTemplateChange {
const messages = await this.messageTemplateRepository.find({
_environmentId: command.environmentId,
_parentId: {
- $in: newItem.steps ? newItem.steps.map((step) => step._templateId) : [],
+ $in: (newItem.steps || []).flatMap((step) => [
+ step._templateId,
+ ...(step.variants || []).flatMap((variant) => variant._templateId),
+ ]),
},
});
@@ -53,6 +57,30 @@ export class PromoteNotificationTemplateChange {
return message._parentId === step._templateId;
});
+ if (step.variants && step.variants.length > 0) {
+ step.variants = step.variants
+ ?.map(mapNewVariantItem)
+ .filter((variant): variant is StepVariantEntity => variant !== undefined);
+ }
+
+ if (!oldMessage) {
+ missingMessages.push(step._templateId);
+
+ return undefined;
+ }
+
+ if (step?._templateId && oldMessage._id) {
+ step._templateId = oldMessage._id;
+ }
+
+ return step;
+ };
+
+ const mapNewVariantItem = (step: StepVariantEntity) => {
+ const oldMessage = messages.find((message) => {
+ return message._parentId === step._templateId;
+ });
+
if (!oldMessage) {
missingMessages.push(step._templateId);
diff --git a/apps/api/src/app/change/usecases/promote-translation-change/index.ts b/apps/api/src/app/change/usecases/promote-translation-change/index.ts
new file mode 100644
index 00000000000..fc56a64fb7b
--- /dev/null
+++ b/apps/api/src/app/change/usecases/promote-translation-change/index.ts
@@ -0,0 +1 @@
+export * from './promote-translation-change.usecase';
diff --git a/apps/api/src/app/change/usecases/promote-translation-change/promote-translation-change.usecase.ts b/apps/api/src/app/change/usecases/promote-translation-change/promote-translation-change.usecase.ts
new file mode 100644
index 00000000000..4ecdb573691
--- /dev/null
+++ b/apps/api/src/app/change/usecases/promote-translation-change/promote-translation-change.usecase.ts
@@ -0,0 +1,55 @@
+import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
+import { ChangeRepository } from '@novu/dal';
+import { ChangeEntityTypeEnum } from '@novu/shared';
+
+import { ApplyChange, ApplyChangeCommand } from '../apply-change';
+import { PromoteTypeChangeCommand } from '../promote-type-change.command';
+import { ModuleRef } from '@nestjs/core';
+
+@Injectable()
+export class PromoteTranslationChange {
+ constructor(
+ private moduleRef: ModuleRef,
+ @Inject(forwardRef(() => ApplyChange)) private applyChange: ApplyChange,
+ private changeRepository: ChangeRepository
+ ) {}
+
+ async execute(command: PromoteTypeChangeCommand) {
+ try {
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (!require('@novu/ee-translation')?.PromoteTranslationChange) {
+ throw new BadRequestException('Translation module is not loaded');
+ }
+ const usecase = this.moduleRef.get(require('@novu/ee-translation')?.PromoteTranslationChange, {
+ strict: false,
+ });
+ await usecase.execute(command, this.applyGroupChange.bind(this));
+ }
+ } catch (e) {
+ Logger.error(e, `Unexpected error while importing enterprise modules`, 'PromoteTranslationChange');
+ }
+ }
+
+ private async applyGroupChange(command: PromoteTypeChangeCommand) {
+ const newItem = command.item as {
+ _groupId: string;
+ };
+
+ const changes = await this.changeRepository.getEntityChanges(
+ command.organizationId,
+ ChangeEntityTypeEnum.TRANSLATION_GROUP,
+ newItem._groupId
+ );
+
+ for (const change of changes) {
+ await this.applyChange.execute(
+ ApplyChangeCommand.create({
+ changeId: change._id,
+ environmentId: change._environmentId,
+ organizationId: change._organizationId,
+ userId: command.userId,
+ })
+ );
+ }
+ }
+}
diff --git a/apps/api/src/app/change/usecases/promote-type-change.command.ts b/apps/api/src/app/change/usecases/promote-type-change.command.ts
index 240b74eb675..320b6099623 100644
--- a/apps/api/src/app/change/usecases/promote-type-change.command.ts
+++ b/apps/api/src/app/change/usecases/promote-type-change.command.ts
@@ -1,9 +1,3 @@
-import { IsDefined } from 'class-validator';
+import { PromoteTypeChangeCommand } from '@novu/application-generic';
-import { EnvironmentWithUserCommand } from '../../shared/commands/project.command';
-import { IItem } from './create-change/create-change.command';
-
-export class PromoteTypeChangeCommand extends EnvironmentWithUserCommand {
- @IsDefined()
- item: IItem;
-}
+export { PromoteTypeChangeCommand };
diff --git a/apps/api/src/app/content-templates/content-templates.controller.ts b/apps/api/src/app/content-templates/content-templates.controller.ts
index e245624faea..5ea5194e68b 100644
--- a/apps/api/src/app/content-templates/content-templates.controller.ts
+++ b/apps/api/src/app/content-templates/content-templates.controller.ts
@@ -1,14 +1,28 @@
-import { Body, Controller, Post } from '@nestjs/common';
+import { Body, Controller, Logger, Post, UseGuards } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
-import { IEmailBlock, IJwtPayload, MessageTemplateContentType } from '@novu/shared';
-import { CompileEmailTemplate, CompileEmailTemplateCommand } from '@novu/application-generic';
+import {
+ ApiException,
+ CompileEmailTemplate,
+ CompileEmailTemplateCommand,
+ CompileInAppTemplate,
+ CompileInAppTemplateCommand,
+ UserAuthGuard,
+} from '@novu/application-generic';
+import * as i18next from 'i18next';
+import { ModuleRef } from '@nestjs/core';
+import { IEmailBlock, IJwtPayload, MessageTemplateContentType, IMessageCTA } from '@novu/shared';
import { UserSession } from '../shared/framework/user.decorator';
@Controller('/content-templates')
+@UseGuards(UserAuthGuard)
@ApiExcludeController()
export class ContentTemplatesController {
- constructor(private compileEmailTemplateUsecase: CompileEmailTemplate) {}
+ constructor(
+ private compileEmailTemplateUsecase: CompileEmailTemplate,
+ private compileInAppTemplate: CompileInAppTemplate,
+ private moduleRef: ModuleRef
+ ) {}
@Post('/preview/email')
public previewEmail(
@@ -29,7 +43,51 @@ export class ContentTemplatesController {
payload,
subject,
layoutId,
- })
+ }),
+ this.initiateTranslations.bind(this)
);
}
+
+ @Post('/preview/in-app')
+ public previewInApp(
+ @UserSession() user: IJwtPayload,
+ @Body('content') content: string,
+ @Body('payload') payload: any,
+ @Body('cta') cta: IMessageCTA
+ ) {
+ return this.compileInAppTemplate.execute(
+ CompileInAppTemplateCommand.create({
+ userId: user._id,
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ content,
+ payload,
+ cta,
+ }),
+ this.initiateTranslations.bind(this)
+ );
+ }
+
+ protected async initiateTranslations(environmentId: string, organizationId: string, locale: string | undefined) {
+ try {
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (!require('@novu/ee-translation')?.TranslationsService) {
+ throw new ApiException('Translation module is not loaded');
+ }
+ const service = this.moduleRef.get(require('@novu/ee-translation')?.TranslationsService, { strict: false });
+ const { namespaces, resources } = await service.getTranslationsList(environmentId, organizationId);
+
+ await i18next.init({
+ resources,
+ ns: namespaces,
+ defaultNS: false,
+ nsSeparator: '.',
+ lng: locale || 'en',
+ compatibilityJSON: 'v2',
+ });
+ }
+ } catch (e) {
+ Logger.error(e, `Unexpected error while importing enterprise modules`, 'TranslationsService');
+ }
+ }
}
diff --git a/apps/api/src/app/content-templates/content-templates.module.ts b/apps/api/src/app/content-templates/content-templates.module.ts
index 56954ec366a..78c11eb1fac 100644
--- a/apps/api/src/app/content-templates/content-templates.module.ts
+++ b/apps/api/src/app/content-templates/content-templates.module.ts
@@ -1,11 +1,28 @@
-import { Module } from '@nestjs/common';
+import { DynamicModule, Logger, Module } from '@nestjs/common';
import { USE_CASES } from './usecases';
import { ContentTemplatesController } from './content-templates.controller';
import { SharedModule } from '../shared/shared.module';
import { LayoutsModule } from '../layouts/layouts.module';
+import { Type } from '@nestjs/common/interfaces/type.interface';
+import { ForwardReference } from '@nestjs/common/interfaces/modules/forward-reference.interface';
+
+const enterpriseImports = (): Array | ForwardReference> => {
+ const modules: Array | ForwardReference> = [];
+ try {
+ if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
+ if (require('@novu/ee-translation')?.EnterpriseTranslationModule) {
+ modules.push(require('@novu/ee-translation')?.EnterpriseTranslationModule);
+ }
+ }
+ } catch (e) {
+ Logger.error(e, `Unexpected error while importing enterprise modules`, 'EnterpriseImport');
+ }
+
+ return modules;
+};
@Module({
- imports: [SharedModule, LayoutsModule],
+ imports: [SharedModule, LayoutsModule, ...enterpriseImports()],
providers: [...USE_CASES],
exports: [...USE_CASES],
controllers: [ContentTemplatesController],
diff --git a/apps/api/src/app/content-templates/usecases/index.ts b/apps/api/src/app/content-templates/usecases/index.ts
index b758715fedd..96733d81fb2 100644
--- a/apps/api/src/app/content-templates/usecases/index.ts
+++ b/apps/api/src/app/content-templates/usecases/index.ts
@@ -1,3 +1,3 @@
-import { CompileTemplate, CompileEmailTemplate } from '@novu/application-generic';
+import { CompileTemplate, CompileEmailTemplate, CompileInAppTemplate } from '@novu/application-generic';
-export const USE_CASES = [CompileTemplate, CompileEmailTemplate];
+export const USE_CASES = [CompileTemplate, CompileEmailTemplate, CompileInAppTemplate];
diff --git a/apps/api/src/app/environments/environments.controller.ts b/apps/api/src/app/environments/environments.controller.ts
index 8c77c127549..39affececaa 100644
--- a/apps/api/src/app/environments/environments.controller.ts
+++ b/apps/api/src/app/environments/environments.controller.ts
@@ -19,7 +19,7 @@ import { GetApiKeys } from './usecases/get-api-keys/get-api-keys.usecase';
import { GetEnvironment, GetEnvironmentCommand } from './usecases/get-environment';
import { GetMyEnvironments } from './usecases/get-my-environments/get-my-environments.usecase';
import { GetMyEnvironmentsCommand } from './usecases/get-my-environments/get-my-environments.command';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiKey } from '../shared/dtos/api-key';
import { EnvironmentResponseDto } from './dtos/environment-response.dto';
@@ -28,11 +28,12 @@ import { RegenerateApiKeys } from './usecases/regenerate-api-keys/regenerate-api
import { UpdateEnvironmentCommand } from './usecases/update-environment/update-environment.command';
import { UpdateEnvironment } from './usecases/update-environment/update-environment.usecase';
import { UpdateEnvironmentRequestDto } from './dtos/update-environment-request.dto';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/environments')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Environments')
export class EnvironmentsController {
constructor(
@@ -64,8 +65,8 @@ export class EnvironmentsController {
@ApiOperation({
summary: 'Create environment',
})
+ @ApiExcludeEndpoint()
@ApiResponse(EnvironmentResponseDto, 201)
- @ExternalApiAccessible()
async createEnvironment(
@UserSession() user: IJwtPayload,
@Body() body: CreateEnvironmentRequestDto
diff --git a/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts b/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts
index 1ea631ac5db..59b349ae046 100644
--- a/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts
+++ b/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts
@@ -50,4 +50,14 @@ describe('Create Environment - /environments (POST)', async () => {
expect(layouts.data[0].isDefault).to.equal(true);
expect(layouts.data[0].content.length).to.be.greaterThan(20);
});
+
+ it('should not set apiRateLimits field on environment by default', async function () {
+ const demoEnvironment = {
+ name: 'Hello App',
+ };
+ const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);
+ const dbEnvironment = await environmentRepository.findOne({ _id: body.data._id });
+
+ expect(dbEnvironment?.apiRateLimits).to.be.undefined;
+ });
});
diff --git a/apps/api/src/app/events/e2e/cancel-event.e2e.ts b/apps/api/src/app/events/e2e/cancel-event.e2e.ts
new file mode 100644
index 00000000000..a0803af0367
--- /dev/null
+++ b/apps/api/src/app/events/e2e/cancel-event.e2e.ts
@@ -0,0 +1,574 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import axios from 'axios';
+import { expect } from 'chai';
+import {
+ MessageRepository,
+ NotificationTemplateEntity,
+ SubscriberEntity,
+ JobRepository,
+ JobStatusEnum,
+} from '@novu/dal';
+import { StepTypeEnum, DigestTypeEnum, DigestUnitEnum, DelayTypeEnum } from '@novu/shared';
+import { UserSession, SubscribersService } from '@novu/testing';
+
+const axiosInstance = axios.create();
+
+describe('Cancel event - /v1/events/trigger/:transactionId (DELETE)', function () {
+ let session: UserSession;
+ let template: NotificationTemplateEntity;
+ let subscriber: SubscriberEntity;
+ let subscriberService: SubscribersService;
+ const jobRepository = new JobRepository();
+
+ const triggerEvent = async (payload, transactionId?: string, overrides = {}, to = [subscriber.subscriberId]) => {
+ return (
+ await axiosInstance.post(
+ `${session.serverUrl}/v1/events/trigger`,
+ {
+ transactionId,
+ name: template.triggers[0].identifier,
+ to,
+ payload,
+ overrides,
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ )
+ ).data.data;
+ };
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ template = await session.createTemplate();
+ subscriberService = new SubscribersService(session.organization._id, session.environment._id);
+ subscriber = await subscriberService.createSubscriber();
+ });
+
+ it('should be able to cancel digest', async function () {
+ const id = MessageRepository.createObjectId();
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{customVar}}' as string,
+ },
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ digestKey: 'id',
+ type: DigestTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ await triggerEvent(
+ {
+ customVar: 'Testing of User Name',
+ },
+ id
+ );
+
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await axiosInstance.delete(`${session.serverUrl}/v1/events/trigger/${id}`, {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ });
+
+ const delayedJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ });
+
+ expect(delayedJobs && delayedJobs.length).to.eql(1);
+
+ const pendingJobs = await jobRepository.count({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ status: JobStatusEnum.PENDING,
+ transactionId: id,
+ });
+
+ expect(pendingJobs).to.equal(0);
+
+ const cancelledDigestJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ status: JobStatusEnum.CANCELED,
+ type: StepTypeEnum.DIGEST,
+ transactionId: id,
+ });
+
+ expect(cancelledDigestJobs && cancelledDigestJobs.length).to.eql(1);
+ });
+
+ it('should be able to cancel delay', async function () {
+ const secondSubscriber = await subscriberService.createSubscriber();
+
+ const id = MessageRepository.createObjectId();
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{customVar}}' as string,
+ },
+ {
+ type: StepTypeEnum.DELAY,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 5,
+ type: DelayTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{customVar}}' as string,
+ },
+ ],
+ });
+
+ await triggerEvent(
+ {
+ customVar: 'Testing of User Name',
+ },
+ id,
+ {},
+ [subscriber.subscriberId, secondSubscriber.subscriberId]
+ );
+
+ await session.awaitRunningJobs(template?._id, true, 2);
+ await axiosInstance.delete(`${session.serverUrl}/v1/events/trigger/${id}`, {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ });
+
+ let delayedJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DELAY,
+ });
+
+ const pendingJobs = await jobRepository.count({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ status: JobStatusEnum.PENDING,
+ transactionId: id,
+ });
+
+ expect(pendingJobs).to.equal(0);
+
+ delayedJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DELAY,
+ transactionId: id,
+ });
+ expect(delayedJobs[0]!.status).to.equal(JobStatusEnum.CANCELED);
+ expect(delayedJobs[1]!.status).to.equal(JobStatusEnum.CANCELED);
+ });
+
+ it('should be able to cancel not 1st digest (e.x 2nd,3rd,etc..)', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 1,
+ digestKey: 'id',
+ type: DigestTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ const trigger1 = await triggerEvent({
+ customVar: 'trigger_1_data',
+ });
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const trigger2 = await triggerEvent({
+ customVar: 'trigger_2_data',
+ });
+
+ // Wait for trigger2 to be merged to trigger1
+ await session.awaitRunningJobs(template?._id, false, 1);
+
+ const trigger3 = await triggerEvent({
+ customVar: 'trigger_3_data',
+ });
+
+ await session.testAgent.delete(`/v1/events/trigger/${trigger2.transactionId}`).send({});
+
+ await session.awaitRunningJobs(template?._id, false, 0);
+
+ const delayedJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ });
+
+ expect(delayedJobs.length).to.eql(3);
+
+ const cancelledDigestJobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ status: JobStatusEnum.CANCELED,
+ type: StepTypeEnum.DIGEST,
+ transactionId: trigger2.transactionId,
+ });
+
+ expect(cancelledDigestJobs.length).to.eql(1);
+
+ const jobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.IN_APP,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ const rootTrigger = jobs[0];
+ expect(rootTrigger.status).to.eql(JobStatusEnum.COMPLETED);
+ expect(rootTrigger.payload.customVar).to.eql('trigger_1_data');
+ expect(rootTrigger.digest?.events?.length).to.eql(2);
+ expect(rootTrigger.digest?.events?.[0].customVar).to.eql('trigger_1_data');
+ expect(rootTrigger.digest?.events?.[1].customVar).to.eql('trigger_3_data');
+
+ const secondCancelledTrigger = jobs[1];
+ expect(secondCancelledTrigger.payload.customVar).to.eql('trigger_2_data');
+ expect(secondCancelledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+
+ const thirdMergedTrigger = jobs[2];
+ expect(thirdMergedTrigger.payload.customVar).to.eql('trigger_3_data');
+ expect(thirdMergedTrigger.status).to.eql(JobStatusEnum.MERGED);
+ });
+
+ it('should be able to cancel 1st main digest', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 1,
+ digestKey: 'id',
+ type: DigestTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ const trigger1 = await triggerEvent({
+ customVar: 'trigger_1_data',
+ });
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const trigger2 = await triggerEvent({
+ customVar: 'trigger_2_data',
+ });
+
+ // Wait for trigger2 to be merged to trigger1
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${trigger1.transactionId}`).send({});
+
+ const trigger3 = await triggerEvent({
+ customVar: 'trigger_3_data',
+ });
+
+ await session.awaitRunningJobs(template?._id, false, 0);
+
+ const delayedJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(delayedJobs.length).to.eql(3);
+
+ const cancelledDigestJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ status: JobStatusEnum.CANCELED,
+ type: StepTypeEnum.DIGEST,
+ transactionId: trigger1.transactionId,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(cancelledDigestJobs.length).to.eql(1);
+
+ const inpAppJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.IN_APP,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ const firstMainCanceledTrigger = inpAppJobs[0];
+ expect(firstMainCanceledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ expect(firstMainCanceledTrigger.payload.customVar).to.eql('trigger_1_data');
+ expect(firstMainCanceledTrigger.digest?.events?.length).to.eql(0);
+
+ const secondTrigger = inpAppJobs[1];
+ expect(secondTrigger.payload.customVar).to.eql('trigger_2_data');
+ expect(secondTrigger.status).to.eql(JobStatusEnum.COMPLETED);
+ expect(secondTrigger.digest?.events?.length).to.eql(2);
+ expect(secondTrigger.digest?.events?.[0].customVar).to.eql('trigger_2_data');
+ expect(secondTrigger.digest?.events?.[1].customVar).to.eql('trigger_3_data');
+
+ const thirdMergedTrigger = inpAppJobs[2];
+ expect(thirdMergedTrigger.payload.customVar).to.eql('trigger_3_data');
+ expect(thirdMergedTrigger.digest?.events?.length).to.eql(0);
+ expect(thirdMergedTrigger.status).to.eql(JobStatusEnum.MERGED);
+ });
+
+ it('should be able to cancel 1st main digest and then its follower', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 1,
+ digestKey: 'id',
+ type: DigestTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ const trigger1 = await triggerEvent({
+ customVar: 'trigger_1_data',
+ });
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const trigger2 = await triggerEvent({
+ customVar: 'trigger_2_data',
+ });
+
+ // Wait for trigger2 to be merged to trigger1
+ const mainDigest = trigger1.transactionId;
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${mainDigest}`).send({});
+
+ const trigger3 = await triggerEvent({
+ customVar: 'trigger_3_data',
+ });
+
+ // Wait for trigger3 to be merged to trigger2
+ const followerDigest = trigger2.transactionId;
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${followerDigest}`).send({});
+
+ const trigger4 = await triggerEvent({
+ customVar: 'trigger_4_data',
+ });
+
+ await session.awaitRunningJobs(template?._id, false, 0);
+
+ const delayedJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(delayedJobs.length).to.eql(4);
+
+ const cancelledDigestJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ transactionId: [trigger1.transactionId, trigger2.transactionId],
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(cancelledDigestJobs.length).to.eql(2);
+
+ const inpAppJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.IN_APP,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ const firstMainCanceledTrigger = inpAppJobs[0];
+ expect(firstMainCanceledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ expect(firstMainCanceledTrigger.payload.customVar).to.eql('trigger_1_data');
+ expect(firstMainCanceledTrigger.digest?.events?.length).to.eql(0);
+
+ const secondFollowerCanceledTrigger = inpAppJobs[1];
+ expect(secondFollowerCanceledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ expect(secondFollowerCanceledTrigger.payload.customVar).to.eql('trigger_2_data');
+ expect(secondFollowerCanceledTrigger.digest?.events?.length).to.eql(0);
+
+ const thirdTriggerLatestFollower = inpAppJobs[2];
+ expect(thirdTriggerLatestFollower.payload.customVar).to.eql('trigger_3_data');
+ expect(thirdTriggerLatestFollower.status).to.eql(JobStatusEnum.COMPLETED);
+ expect(thirdTriggerLatestFollower.digest?.events?.length).to.eql(2);
+ expect(thirdTriggerLatestFollower.digest?.events?.[0].customVar).to.eql('trigger_3_data');
+ expect(thirdTriggerLatestFollower.digest?.events?.[1].customVar).to.eql('trigger_4_data');
+
+ const fourthMergedTrigger = inpAppJobs[3];
+ expect(fourthMergedTrigger.payload.customVar).to.eql('trigger_4_data');
+ expect(fourthMergedTrigger.digest?.events?.length).to.eql(0);
+ expect(fourthMergedTrigger.status).to.eql(JobStatusEnum.MERGED);
+ });
+
+ it('should be able to cancel 1st main digest and then its follower and last merged notification', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 1,
+ digestKey: 'id',
+ type: DigestTypeEnum.REGULAR,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ const trigger1 = await triggerEvent({
+ customVar: 'trigger_1_data',
+ });
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const trigger2 = await triggerEvent({
+ customVar: 'trigger_2_data',
+ });
+
+ // Wait for trigger2 to be merged to trigger1
+ const mainDigest = trigger1.transactionId;
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${mainDigest}`).send({});
+
+ const trigger3 = await triggerEvent({
+ customVar: 'trigger_3_data',
+ });
+
+ // Wait for trigger3 to be merged to trigger2
+ const followerDigest = trigger2.transactionId;
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${followerDigest}`).send({});
+
+ const trigger4 = await triggerEvent({
+ customVar: 'trigger_4_data',
+ });
+
+ // Wait for trigger4 to be merged to trigger3
+ await session.awaitRunningJobs(template?._id, false, 1);
+ await session.testAgent.delete(`/v1/events/trigger/${trigger4.transactionId}`).send({});
+
+ await session.awaitRunningJobs(template?._id, false, 0);
+
+ const delayedJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(delayedJobs.length).to.eql(4);
+
+ const cancelledDigestJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.DIGEST,
+ transactionId: [trigger1.transactionId, trigger2.transactionId, trigger4.transactionId],
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ expect(cancelledDigestJobs.length).to.eql(3);
+
+ const inpAppJobs = await jobRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ type: StepTypeEnum.IN_APP,
+ },
+ undefined,
+ { sort: { createdAt: 1 } }
+ );
+
+ const firstMainCanceledTrigger = inpAppJobs[0];
+ expect(firstMainCanceledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ expect(firstMainCanceledTrigger.payload.customVar).to.eql('trigger_1_data');
+ expect(firstMainCanceledTrigger.digest?.events?.length).to.eql(0);
+
+ const secondFollowerCanceledTrigger = inpAppJobs[1];
+ expect(secondFollowerCanceledTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ expect(secondFollowerCanceledTrigger.payload.customVar).to.eql('trigger_2_data');
+ expect(secondFollowerCanceledTrigger.digest?.events?.length).to.eql(0);
+
+ const thirdTriggerLatestFollower = inpAppJobs[2];
+ expect(thirdTriggerLatestFollower.payload.customVar).to.eql('trigger_3_data');
+ expect(thirdTriggerLatestFollower.status).to.eql(JobStatusEnum.COMPLETED);
+ expect(thirdTriggerLatestFollower.digest?.events?.length).to.eql(1);
+ expect(thirdTriggerLatestFollower.digest?.events?.[0].customVar).to.eql('trigger_3_data');
+
+ const fourthMergedTrigger = inpAppJobs[3];
+ expect(fourthMergedTrigger.payload.customVar).to.eql('trigger_4_data');
+ expect(fourthMergedTrigger.digest?.events?.length).to.eql(0);
+ expect(fourthMergedTrigger.status).to.eql(JobStatusEnum.CANCELED);
+ });
+});
diff --git a/apps/api/src/app/events/e2e/delay-events.e2e.ts b/apps/api/src/app/events/e2e/delay-events.e2e.ts
index bca7d81b517..a49c62e4637 100644
--- a/apps/api/src/app/events/e2e/delay-events.e2e.ts
+++ b/apps/api/src/app/events/e2e/delay-events.e2e.ts
@@ -10,8 +10,7 @@ import {
JobStatusEnum,
} from '@novu/dal';
import { UserSession, SubscribersService } from '@novu/testing';
-import { StepTypeEnum, DelayTypeEnum, DigestUnitEnum, DigestTypeEnum } from '@novu/shared';
-import { StandardQueueService } from '@novu/application-generic';
+import { StepTypeEnum, DelayTypeEnum, DigestUnitEnum, DigestTypeEnum, JobTopicNameEnum } from '@novu/shared';
const axiosInstance = axios.create();
@@ -21,7 +20,6 @@ describe('Trigger event - Delay triggered events - /v1/events/trigger (POST)', f
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;
const jobRepository = new JobRepository();
- let standardQueueService: StandardQueueService;
const messageRepository = new MessageRepository();
const triggerEvent = async (payload, transactionId?: string, overrides = {}, to = [subscriber.subscriberId]) => {
@@ -48,7 +46,6 @@ describe('Trigger event - Delay triggered events - /v1/events/trigger (POST)', f
template = await session.createTemplate();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
- standardQueueService = session?.testServer?.getService(StandardQueueService);
});
it('should delay event for time interval', async function () {
@@ -190,7 +187,8 @@ describe('Trigger event - Delay triggered events - /v1/events/trigger (POST)', f
const updatedAt = delayedJob?.updatedAt as string;
const diff = differenceInMilliseconds(new Date(delayedJob.payload.sendAt), new Date(updatedAt));
- const delay = await standardQueueService.queue.getDelayed();
+ const delay = await session.queueGet(JobTopicNameEnum.STANDARD, 'getDelayed');
+
expect(delay[0].opts.delay).to.approximately(diff, 1000);
});
@@ -328,71 +326,4 @@ describe('Trigger event - Delay triggered events - /v1/events/trigger (POST)', f
expect(e.response.data.message).to.equal('payload is missing required key(s) and type(s): sendAt (ISO Date)');
}
});
-
- it('should be able to cancel delay', async function () {
- const secondSubscriber = await subscriberService.createSubscriber();
-
- const id = MessageRepository.createObjectId();
- template = await session.createTemplate({
- steps: [
- {
- type: StepTypeEnum.IN_APP,
- content: 'Hello world {{customVar}}' as string,
- },
- {
- type: StepTypeEnum.DELAY,
- content: '',
- metadata: {
- unit: DigestUnitEnum.SECONDS,
- amount: 5,
- type: DelayTypeEnum.REGULAR,
- },
- },
- {
- type: StepTypeEnum.IN_APP,
- content: 'Hello world {{customVar}}' as string,
- },
- ],
- });
-
- await triggerEvent(
- {
- customVar: 'Testing of User Name',
- },
- id,
- {},
- [subscriber.subscriberId, secondSubscriber.subscriberId]
- );
-
- await session.awaitRunningJobs(template?._id, true, 2);
- await axiosInstance.delete(`${session.serverUrl}/v1/events/trigger/${id}`, {
- headers: {
- authorization: `ApiKey ${session.apiKey}`,
- },
- });
-
- let delayedJobs = await jobRepository.find({
- _environmentId: session.environment._id,
- _templateId: template._id,
- type: StepTypeEnum.DELAY,
- });
-
- const pendingJobs = await jobRepository.count({
- _environmentId: session.environment._id,
- _templateId: template._id,
- status: JobStatusEnum.PENDING,
- transactionId: id,
- });
-
- expect(pendingJobs).to.equal(2);
-
- delayedJobs = await jobRepository.find({
- _environmentId: session.environment._id,
- _templateId: template._id,
- type: StepTypeEnum.DELAY,
- transactionId: id,
- });
- expect(delayedJobs[0]!.status).to.equal(JobStatusEnum.CANCELED);
- expect(delayedJobs[1]!.status).to.equal(JobStatusEnum.CANCELED);
- });
});
diff --git a/apps/api/src/app/events/e2e/digest-events.e2e.ts b/apps/api/src/app/events/e2e/digest-events.e2e.ts
index 617c0f6424f..67eea2bf746 100644
--- a/apps/api/src/app/events/e2e/digest-events.e2e.ts
+++ b/apps/api/src/app/events/e2e/digest-events.e2e.ts
@@ -91,12 +91,12 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(initialJobs.length).to.eql(2);
+ expect(initialJobs && initialJobs.length).to.eql(2);
const delayedJobs = initialJobs.filter((elem) => elem.status === JobStatusEnum.DELAYED);
- expect(delayedJobs.length).to.eql(1);
+ expect(delayedJobs && delayedJobs.length).to.eql(1);
const mergedJobs = initialJobs.filter((elem) => elem.status !== JobStatusEnum.DELAYED);
- expect(mergedJobs.length).to.eql(1);
+ expect(mergedJobs && mergedJobs.length).to.eql(1);
const delayedJob = delayedJobs[0];
@@ -113,10 +113,10 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
});
const digestJob = jobs.find((job) => job.step?.template?.type === StepTypeEnum.DIGEST);
- expect((digestJob?.digest as IDigestRegularMetadata)?.amount).to.equal(digestAmount);
- expect((digestJob?.digest as IDigestRegularMetadata)?.unit).to.equal(digestUnit);
+ expect((digestJob && (digestJob?.digest as IDigestRegularMetadata))?.amount).to.equal(digestAmount);
+ expect((digestJob && (digestJob?.digest as IDigestRegularMetadata))?.unit).to.equal(digestUnit);
const job = jobs.find((item) => item.digest?.events?.length && item.digest.events.length > 0);
- expect(job?.digest?.events?.length).to.equal(2);
+ expect(job && job?.digest?.events?.length).to.equal(2);
});
it('should not have digest prop when not running a digest', async function () {
@@ -141,8 +141,8 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
channel: StepTypeEnum.SMS,
});
- expect(message[0].content).to.include('NO_DIGEST_PROP');
- expect(message[0].content).to.not.include('HAS_DIGEST_PROP');
+ expect(message && message[0].content).to.include('NO_DIGEST_PROP');
+ expect(message && message[0].content).to.not.include('HAS_DIGEST_PROP');
});
it('should add a digest prop to template compilation', async function () {
@@ -181,7 +181,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(2);
+ expect(jobs && jobs.length).to.eql(2);
const completedJob = jobs.find((elem) => elem.status === JobStatusEnum.COMPLETED);
expect(completedJob).to.ok;
@@ -196,7 +196,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_templateId: template._id,
});
- expect(message?.content).to.include('HAS_DIGEST_PROP');
+ expect(message && message?.content).to.include('HAS_DIGEST_PROP');
});
it('should digest based on digestKey within time interval', async function () {
@@ -246,12 +246,12 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(3);
+ expect(jobs && jobs.length).to.eql(3);
const delayedJobs = jobs.filter((elem) => elem.status === JobStatusEnum.DELAYED);
- expect(delayedJobs.length).to.eql(2);
+ expect(delayedJobs && delayedJobs.length).to.eql(2);
const mergedJobs = jobs.filter((elem) => elem.status !== JobStatusEnum.DELAYED);
- expect(mergedJobs.length).to.eql(1);
+ expect(mergedJobs && mergedJobs.length).to.eql(1);
await session.awaitRunningJobs(template?._id, false, 1);
@@ -261,12 +261,12 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
});
const digestedJobs = finalJobs.filter((job) => (job?.digest as IDigestRegularMetadata)?.digestKey === 'id');
- expect(digestedJobs.length).to.eql(3);
+ expect(digestedJobs && digestedJobs.length).to.eql(3);
const jobsWithEvents = finalJobs.filter(
(item) => item.type === StepTypeEnum.SMS && item?.digest?.events && item.digest.events.length > 0
);
- expect(jobsWithEvents.length).to.equal(2);
+ expect(jobsWithEvents && jobsWithEvents.length).to.equal(2);
});
it('should digest based on same digestKey within time interval', async function () {
@@ -314,12 +314,12 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.equal(3);
+ expect(jobs && jobs.length).to.equal(3);
const completedJobs = jobs.filter((elem) => elem.status === JobStatusEnum.COMPLETED);
- expect(completedJobs.length).to.eql(2);
+ expect(completedJobs && completedJobs.length).to.eql(2);
const mergedJobs = jobs.filter((elem) => elem.status === JobStatusEnum.MERGED);
- expect(mergedJobs.length).to.eql(1);
+ expect(mergedJobs && mergedJobs.length).to.eql(1);
const messages = await messageRepository.find({
_environmentId: session.environment._id,
@@ -334,10 +334,10 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
const firstDigestKeyBatch = messages.filter((message) => (message.content as string).includes('Hello world 2'));
const secondDigestKeyBatch = messages.filter((message) => (message.content as string).includes('Hello world 1'));
- expect(firstDigestKeyBatch.length).to.eql(1);
- expect(secondDigestKeyBatch.length).to.eql(1);
+ expect(firstDigestKeyBatch && firstDigestKeyBatch.length).to.eql(1);
+ expect(secondDigestKeyBatch && secondDigestKeyBatch.length).to.eql(1);
- expect(messages.length).to.equal(2);
+ expect(messages && messages.length).to.equal(2);
});
it('should digest delayed events', async function () {
@@ -377,74 +377,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
},
});
- expect(jobs.length).to.equal(0);
- });
-
- it('should be able to cancel digest', async function () {
- const id = MessageRepository.createObjectId();
- template = await session.createTemplate({
- steps: [
- {
- type: StepTypeEnum.IN_APP,
- content: 'Hello world {{customVar}}' as string,
- },
- {
- type: StepTypeEnum.DIGEST,
- content: '',
- metadata: {
- unit: DigestUnitEnum.SECONDS,
- amount: 2,
- digestKey: 'id',
- type: DigestTypeEnum.REGULAR,
- },
- },
- {
- type: StepTypeEnum.IN_APP,
- content: 'Hello world {{step.events.length}}' as string,
- },
- ],
- });
-
- await triggerEvent(
- {
- customVar: 'Testing of User Name',
- },
- id
- );
-
- await session.awaitRunningJobs(template?._id, false, 1);
- await axiosInstance.delete(`${session.serverUrl}/v1/events/trigger/${id}`, {
- headers: {
- authorization: `ApiKey ${session.apiKey}`,
- },
- });
-
- const delayedJobs = await jobRepository.find({
- _environmentId: session.environment._id,
- _templateId: template._id,
- type: StepTypeEnum.DIGEST,
- });
-
- expect(delayedJobs.length).to.eql(1);
-
- const pendingJobs = await jobRepository.count({
- _environmentId: session.environment._id,
- _templateId: template._id,
- status: JobStatusEnum.PENDING,
- transactionId: id,
- });
-
- expect(pendingJobs).to.equal(1);
-
- const cancelledDigestJobs = await jobRepository.find({
- _environmentId: session.environment._id,
- _templateId: template._id,
- status: JobStatusEnum.CANCELED,
- type: StepTypeEnum.DIGEST,
- transactionId: id,
- });
-
- expect(cancelledDigestJobs.length).to.eql(1);
+ expect(jobs && jobs.length).to.equal(0);
});
it('should digest with backoff strategy', async function () {
@@ -458,7 +391,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
amount: 5,
type: DigestTypeEnum.BACKOFF,
backoffUnit: DigestUnitEnum.SECONDS,
- backoffAmount: 1,
+ backoffAmount: 10,
},
},
{
@@ -489,7 +422,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(7);
+ expect(jobs && jobs.length).to.eql(7);
const completedJob = jobs.find((elem) => elem.status === JobStatusEnum.COMPLETED);
expect(completedJob).to.ok;
@@ -505,16 +438,19 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.IN_APP,
});
- expect(generatedMessageJob.length).to.equal(7);
+ expect(generatedMessageJob && generatedMessageJob.length).to.equal(7);
const mergedInApp = generatedMessageJob.filter((elem) => elem.status === JobStatusEnum.MERGED);
- expect(mergedInApp.length).to.equal(5);
+ expect(mergedInApp && mergedInApp.length).to.equal(5);
const completedInApp = generatedMessageJob.filter((elem) => elem.status === JobStatusEnum.COMPLETED);
- expect(completedInApp.length).to.equal(2);
+ expect(completedInApp && completedInApp.length).to.equal(2);
+
+ const digestEventLength6 = completedInApp.find((i) => i.digest?.events?.length === 6);
+ expect(digestEventLength6).to.be.ok;
- expect(completedInApp.find((i) => i.digest?.events?.length === 6)).to.be.ok;
- expect(completedInApp.find((i) => i.digest?.events?.length === 0)).to.be.ok;
+ const digestEventLength0 = completedInApp.find((i) => i.digest?.events?.length === 0);
+ expect(digestEventLength0).to.be.ok;
});
it('should create multiple digest based on different digestKeys', async function () {
@@ -567,17 +503,17 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(digests.length).to.equal(5);
+ expect(digests && digests.length).to.equal(5);
const noPostIdJobs = digests.filter((job) => !job.payload.postId);
- expect(noPostIdJobs.length).to.equal(2);
+ expect(noPostIdJobs && noPostIdJobs.length).to.equal(2);
const postId1Jobs = digests.filter((job) => job.payload.postId === postId);
const postId2Jobs = digests.filter((job) => job.payload.postId === postId2);
const postId1MergedJobs = postId1Jobs.filter((job) => job.status === JobStatusEnum.MERGED);
- expect(postId1MergedJobs.length).to.equal(1);
- expect(postId1Jobs.length).to.equal(2);
- expect(postId2Jobs.length).to.equal(1);
+ expect(postId1MergedJobs && postId1MergedJobs.length).to.equal(1);
+ expect(postId1Jobs && postId1Jobs.length).to.equal(2);
+ expect(postId2Jobs && postId2Jobs.length).to.equal(1);
await session.awaitRunningJobs(template?._id, false, 0);
@@ -586,7 +522,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_templateId: template._id,
_subscriberId: subscriber._id,
});
- expect(messages.length).to.eql(3);
+ expect(messages && messages.length).to.eql(3);
const postId1Content = messages.find((message) => (message.content as string).includes(postId));
const postId2Content = messages.find((message) => (message.content as string).includes(postId2));
const noDigestKeyContent = messages.find((message) => message.content === 'Hello world ');
@@ -653,18 +589,18 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(digests.length).to.eql(5);
+ expect(digests && digests.length).to.eql(5);
const noPostIdJobs = digests.filter((job) => !job.payload.nested);
- expect(noPostIdJobs.length).to.equal(2);
+ expect(noPostIdJobs && noPostIdJobs.length).to.equal(2);
const postId1Jobs = digests.filter((job) => job.payload.nested?.postId === postId);
const postId2Jobs = digests.filter((job) => job.payload.nested?.postId === postId2);
const postId1MergedJobs = postId1Jobs.filter((job) => job.status === JobStatusEnum.MERGED);
- expect(postId1MergedJobs.length).to.equal(1);
- expect(postId1Jobs.length).to.equal(2);
- expect(postId2Jobs.length).to.equal(1);
+ expect(postId1MergedJobs && postId1MergedJobs.length).to.equal(1);
+ expect(postId1Jobs && postId1Jobs.length).to.equal(2);
+ expect(postId2Jobs && postId2Jobs.length).to.equal(1);
await session.awaitRunningJobs(template?._id, false, 0);
@@ -674,7 +610,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_subscriberId: subscriber._id,
});
- expect(messages.length).to.eql(3);
+ expect(messages && messages.length).to.eql(3);
const postId1Content = messages.find((message) => (message.content as string).includes(postId));
const postId2Content = messages.find((message) => (message.content as string).includes(postId2));
const noDigestKeyContent = messages.find((message) => message.content === 'Hello world ');
@@ -731,22 +667,22 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(digests.length).to.equal(6);
+ expect(digests && digests.length).to.equal(6);
const completedJobs = digests.filter((job) => job.status === JobStatusEnum.COMPLETED);
- expect(completedJobs.length).to.equal(3);
+ expect(completedJobs && completedJobs.length).to.equal(3);
const skippedJobs = digests.filter((job) => job.status === JobStatusEnum.SKIPPED);
- expect(skippedJobs.length).to.equal(3);
+ expect(skippedJobs && skippedJobs.length).to.equal(3);
const postId1Jobs = digests.filter((job) => job.payload.postId === postId);
- expect(postId1Jobs.length).to.equal(2);
+ expect(postId1Jobs && postId1Jobs.length).to.equal(2);
const postId2Jobs = digests.filter((job) => job.payload.postId === postId2);
- expect(postId2Jobs.length).to.equal(2);
+ expect(postId2Jobs && postId2Jobs.length).to.equal(2);
const noPostIdJobs = digests.filter((job) => !job.payload.postId);
- expect(noPostIdJobs.length).to.equal(2);
+ expect(noPostIdJobs && noPostIdJobs.length).to.equal(2);
await session.awaitRunningJobs(template?._id, false, 0);
@@ -756,7 +692,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_subscriberId: subscriber._id,
});
- expect(messages.length).to.equal(6);
+ expect(messages && messages.length).to.equal(6);
const contents: string[] = messages
.map((message) => message.content)
@@ -840,29 +776,29 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(digests.length).to.equal(6);
+ expect(digests && digests.length).to.equal(6);
const completedJobs = digests.filter((job) => job.status === JobStatusEnum.COMPLETED);
- expect(completedJobs.length).to.equal(3);
+ expect(completedJobs && completedJobs.length).to.equal(3);
const skippedJobs = digests.filter((job) => job.status === JobStatusEnum.SKIPPED);
- expect(skippedJobs.length).to.equal(3);
+ expect(skippedJobs && skippedJobs.length).to.equal(3);
const postId1Jobs = digests.filter((job) => job.payload?.nested?.postId === postId);
- expect(postId1Jobs.length).to.equal(2);
+ expect(postId1Jobs && postId1Jobs.length).to.equal(2);
const postId2Jobs = digests.filter((job) => job.payload?.nested?.postId === postId2);
- expect(postId2Jobs.length).to.equal(2);
+ expect(postId2Jobs && postId2Jobs.length).to.equal(2);
const noPostIdJobs = digests.filter((job) => !job.payload?.nested?.postId);
- expect(noPostIdJobs.length).to.equal(2);
+ expect(noPostIdJobs && noPostIdJobs.length).to.equal(2);
const messages = await messageRepository.find({
_environmentId: session.environment._id,
_templateId: template._id,
_subscriberId: subscriber._id,
});
- expect(messages.length).to.equal(6);
+ expect(messages && messages.length).to.equal(6);
const jobCount = await jobRepository.count({
_environmentId: session.environment._id,
@@ -908,7 +844,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(2);
+ expect(jobs && jobs.length).to.eql(2);
const completedJob = jobs.find((elem) => elem.status === JobStatusEnum.COMPLETED);
expect(completedJob).to.ok;
@@ -924,8 +860,8 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_templateId: template._id,
_notificationId: completedJob?._notificationId,
});
- expect(message?.content).to.include('HAS_DIGEST_PROP');
- expect(message?.content).to.include('Total events in digest:2');
+ expect(message && message?.content).to.include('HAS_DIGEST_PROP');
+ expect(message && message?.content).to.include('Total events in digest:2');
});
it('should add a digest prop to push template compilation', async function () {
@@ -965,7 +901,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(2);
+ expect(jobs && jobs.length).to.eql(2);
const completedJob = jobs.find((elem) => elem.status === JobStatusEnum.COMPLETED);
expect(completedJob).to.ok;
@@ -980,7 +916,7 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
_notificationId: completedJob?._notificationId,
});
- expect(message?.content).to.include('HAS_DIGEST_PROP');
+ expect(message && message?.content).to.include('HAS_DIGEST_PROP');
});
it('should merge digest events accordingly when concurrent calls', async () => {
@@ -1043,12 +979,12 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(10);
+ expect(jobs && jobs.length).to.eql(10);
const delayedJobs = jobs.filter((elem) => elem.status === JobStatusEnum.DELAYED);
- expect(delayedJobs.length).to.eql(1);
+ expect(delayedJobs && delayedJobs.length).to.eql(1);
const mergedJobs = jobs.filter((elem) => elem.status !== JobStatusEnum.DELAYED);
- expect(mergedJobs.length).to.eql(9);
+ expect(mergedJobs && mergedJobs.length).to.eql(9);
let delayedJobUpdateTime = delayedJobs[0].updatedAt;
expect(delayedJobUpdateTime).to.be.ok;
@@ -1110,16 +1046,16 @@ describe('Trigger event - Digest triggered events - /v1/events/trigger (POST)',
type: StepTypeEnum.DIGEST,
});
- expect(jobs.length).to.eql(10);
+ expect(jobs && jobs.length).to.eql(10);
const delayedJobs = jobs.filter((elem) => elem.status === JobStatusEnum.DELAYED);
- expect(delayedJobs.length).to.eql(1);
+ expect(delayedJobs && delayedJobs.length).to.eql(1);
const mergedJobs = jobs.filter((elem) => elem.status !== JobStatusEnum.DELAYED);
- expect(mergedJobs.length).to.eql(9);
+ expect(mergedJobs && mergedJobs.length).to.eql(9);
const delayedJob = delayedJobs[0];
const { updatedAt: delayedJobUpdateTime, payload } = delayedJob;
expect(delayedJobUpdateTime).to.be.ok;
- expect(payload.customVar).to.contain('sequential-calls-');
+ expect(payload && payload.customVar).to.contain('sequential-calls-');
});
});
diff --git a/apps/api/src/app/events/e2e/map-trigger-recipients.e2e.ts b/apps/api/src/app/events/e2e/map-trigger-recipients.e2e.ts
index 717e0ae6dd5..037ec8979d0 100644
--- a/apps/api/src/app/events/e2e/map-trigger-recipients.e2e.ts
+++ b/apps/api/src/app/events/e2e/map-trigger-recipients.e2e.ts
@@ -1,6 +1,11 @@
import { Test } from '@nestjs/testing';
import { SubscribersService, UserSession } from '@novu/testing';
-import { FeatureFlagsService, MapTriggerRecipients, MapTriggerRecipientsCommand } from '@novu/application-generic';
+import {
+ FeatureFlagsService,
+ GetTopicSubscribersUseCase,
+ MapTriggerRecipients,
+ MapTriggerRecipientsCommand,
+} from '@novu/application-generic';
import {
SubscriberEntity,
SubscriberRepository,
@@ -12,6 +17,7 @@ import {
import {
ISubscribersDefine,
ITopic,
+ SubscriberSourceEnum,
TopicId,
TopicKey,
TopicName,
@@ -42,7 +48,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const moduleRef = await Test.createTestingModule({
imports: [SharedModule, EventsModule],
- providers: [],
+ providers: [MapTriggerRecipients, GetTopicSubscribersUseCase],
}).compile();
session = new UserSession();
@@ -65,7 +71,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const command = buildCommand(session, transactionId, subscriberId);
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ subscriberId }]);
+ expect(result).to.be.eql([{ subscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE }]);
});
it('should map properly a single subscriber defined payload', async () => {
@@ -83,7 +89,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ ...recipient }]);
+ expect(result).to.be.eql([{ ...recipient, _subscriberSource: SubscriberSourceEnum.SINGLE }]);
});
it('should only process the subscriber id and the subscriber recipients and ignore topics', async () => {
@@ -145,7 +151,10 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ subscriberId: singleSubscriberId }, { ...singleSubscribersDefine }]);
+ expect(result).to.be.eql([
+ { subscriberId: singleSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { ...singleSubscribersDefine, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ ]);
});
it('should map properly multiple duplicated recipients of different types and deduplicate them', async () => {
@@ -177,7 +186,10 @@ describe('MapTriggerRecipientsUseCase', () => {
]);
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ subscriberId: firstSubscriberId }, { subscriberId: secondSubscriberId }]);
+ expect(result).to.be.eql([
+ { subscriberId: firstSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { subscriberId: secondSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ ]);
});
});
@@ -188,7 +200,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const moduleRef = await Test.createTestingModule({
imports: [SharedModule, EventsModule],
- providers: [],
+ providers: [MapTriggerRecipients, GetTopicSubscribersUseCase],
}).compile();
session = new UserSession();
@@ -211,7 +223,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const command = buildCommand(session, transactionId, subscriberId);
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ subscriberId }]);
+ expect(result).to.be.eql([{ subscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE }]);
});
it('should map properly a single subscriber defined payload', async () => {
@@ -229,7 +241,7 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ ...recipient }]);
+ expect(result).to.be.eql([{ ...recipient, _subscriberSource: SubscriberSourceEnum.SINGLE }]);
});
it('should map properly a single topic', async () => {
@@ -256,8 +268,8 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
expect(result).to.include.deep.members([
- { subscriberId: firstSubscriber.subscriberId },
- { subscriberId: secondSubscriber.subscriberId },
+ { subscriberId: firstSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: secondSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
]);
});
@@ -345,11 +357,11 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
expect(result).to.include.deep.members([
- { subscriberId: singleSubscriberId },
- { ...singleSubscribersDefine },
- { subscriberId: firstSubscriber.subscriberId },
- { subscriberId: secondSubscriber.subscriberId },
- { subscriberId: thirdSubscriber.subscriberId },
+ { subscriberId: singleSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { ...singleSubscribersDefine, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { subscriberId: firstSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: secondSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: thirdSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
]);
});
@@ -382,7 +394,10 @@ describe('MapTriggerRecipientsUseCase', () => {
]);
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ subscriberId: firstSubscriberId }, { subscriberId: secondSubscriberId }]);
+ expect(result).to.be.eql([
+ { subscriberId: firstSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { subscriberId: secondSubscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ ]);
});
it('should map properly multiple duplicated recipients of different types and deduplicate them but with different order', async () => {
@@ -416,7 +431,10 @@ describe('MapTriggerRecipientsUseCase', () => {
]);
const result = await useCase.execute(command);
- expect(result).to.be.eql([{ ...firstRecipient }, { ...secondRecipient }]);
+ expect(result).to.be.eql([
+ { ...firstRecipient, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { ...secondRecipient, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ ]);
});
it('should map properly multiple topics and deduplicate them', async () => {
@@ -495,10 +513,10 @@ describe('MapTriggerRecipientsUseCase', () => {
const result = await useCase.execute(command);
expect(result).to.include.deep.members([
- { subscriberId: firstSubscriber.subscriberId },
- { subscriberId: fourthSubscriber.subscriberId },
- { subscriberId: secondSubscriber.subscriberId },
- { subscriberId: thirdSubscriber.subscriberId },
+ { subscriberId: firstSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: fourthSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: secondSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
+ { subscriberId: thirdSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.TOPIC },
]);
});
@@ -514,14 +532,14 @@ describe('MapTriggerRecipientsUseCase', () => {
const thirdSubscriber = await subscribersService.createSubscriber();
const firstRecipient: ISubscribersDefine = {
- subscriberId: firstSubscriber._id,
+ subscriberId: firstSubscriber.subscriberId,
firstName: 'Test Name',
lastName: 'Last of name',
email: 'test@email.novu',
};
const secondRecipient: ISubscribersDefine = {
- subscriberId: secondSubscriber._id,
+ subscriberId: secondSubscriber.subscriberId,
firstName: 'Test Name',
lastName: 'Last of name',
email: 'test@email.novu',
@@ -564,19 +582,21 @@ describe('MapTriggerRecipientsUseCase', () => {
const command = buildCommand(session, transactionId, [
secondTopicRecipient,
firstRecipient,
- firstSubscriber._id,
- secondSubscriber._id,
+ firstSubscriber.subscriberId,
+ secondSubscriber.subscriberId,
firstTopicRecipient,
secondRecipient,
- thirdSubscriber._id,
+ thirdSubscriber.subscriberId,
]);
const result = await useCase.execute(command);
- // We process first recipients that are not topics so they will take precedence when deduplicating
+ expect(result.length).to.equal(3);
+
+ // We process first recipients that are not topics, so they will take precedence when deduplicating
expect(result).to.include.deep.members([
- { ...firstRecipient },
- { subscriberId: secondSubscriber.subscriberId },
- { subscriberId: thirdSubscriber.subscriberId },
+ { ...firstRecipient, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { subscriberId: secondSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
+ { subscriberId: thirdSubscriber.subscriberId, _subscriberSource: SubscriberSourceEnum.SINGLE },
]);
});
});
diff --git a/apps/api/src/app/events/e2e/process-subscriber.e2e.ts b/apps/api/src/app/events/e2e/process-subscriber.e2e.ts
index 0e92775950c..b06e28988b0 100644
--- a/apps/api/src/app/events/e2e/process-subscriber.e2e.ts
+++ b/apps/api/src/app/events/e2e/process-subscriber.e2e.ts
@@ -259,7 +259,7 @@ async function updateSubscriberPreference(
subscriberToken: string,
templateId: string
) {
- return await axios.patch(`http://localhost:${process.env.PORT}/v1/widgets/preferences/${templateId}`, data, {
+ return await axios.patch(`http://127.0.0.1:${process.env.PORT}/v1/widgets/preferences/${templateId}`, data, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
diff --git a/apps/api/src/app/events/e2e/scheduled-digest.e2e.ts b/apps/api/src/app/events/e2e/scheduled-digest.e2e.ts
new file mode 100644
index 00000000000..cdcc51aa228
--- /dev/null
+++ b/apps/api/src/app/events/e2e/scheduled-digest.e2e.ts
@@ -0,0 +1,121 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import axios from 'axios';
+import { expect } from 'chai';
+import {
+ MessageRepository,
+ NotificationTemplateEntity,
+ SubscriberEntity,
+ JobRepository,
+ JobStatusEnum,
+ JobEntity,
+} from '@novu/dal';
+import { StepTypeEnum, DigestTypeEnum, DigestUnitEnum, IDigestRegularMetadata } from '@novu/shared';
+import { UserSession, SubscribersService } from '@novu/testing';
+
+const axiosInstance = axios.create();
+
+const promiseTimeout = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+describe('Trigger event - Scheduled Digest Mode - /v1/events/trigger (POST)', function () {
+ let session: UserSession;
+ let template: NotificationTemplateEntity;
+ let subscriber: SubscriberEntity;
+ let subscriberService: SubscribersService;
+ const jobRepository = new JobRepository();
+ const messageRepository = new MessageRepository();
+
+ const triggerEvent = async (payload, transactionId?: string): Promise => {
+ await axiosInstance.post(
+ `${session.serverUrl}/v1/events/trigger`,
+ {
+ transactionId,
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload,
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+ };
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ template = await session.createTemplate();
+ subscriberService = new SubscribersService(session.organization._id, session.environment._id);
+ subscriber = await subscriberService.createSubscriber();
+ });
+
+ it('should digest events using a scheduled digest', async () => {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.MINUTES,
+ amount: 1,
+ type: DigestTypeEnum.TIMED,
+ },
+ },
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello world {{step.events.length}}' as string,
+ },
+ ],
+ });
+
+ const events = [
+ { customVar: 'Testing of User Name' },
+ { customVar: 'digest' },
+ { customVar: 'merged' },
+ { customVar: 'digest' },
+ { customVar: 'merged' },
+ { customVar: 'digest' },
+ { customVar: 'merged' },
+ ];
+
+ await Promise.all(events.map((event) => triggerEvent(event)));
+
+ const handler = await session.awaitRunningJobs(template?._id, false, 1);
+
+ await handler.runDelayedImmediately();
+ await session.awaitRunningJobs(template?._id);
+
+ const jobs = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ _subscriberId: subscriber._id,
+ type: StepTypeEnum.DIGEST,
+ });
+
+ expect(jobs && jobs.length).to.eql(7);
+
+ const completedJob = jobs.find((elem) => elem.status === JobStatusEnum.COMPLETED);
+ expect(completedJob).to.ok;
+
+ const mergedJob = jobs.find((elem) => elem.status === JobStatusEnum.MERGED);
+ expect(mergedJob).to.ok;
+
+ const generatedMessageJob = await jobRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ _subscriberId: subscriber._id,
+ type: StepTypeEnum.IN_APP,
+ });
+
+ expect(generatedMessageJob && generatedMessageJob.length).to.equal(7);
+
+ const mergedInApp = generatedMessageJob.filter((elem) => elem.status === JobStatusEnum.MERGED);
+ expect(mergedInApp && mergedInApp.length).to.equal(6);
+
+ const completedInApp = generatedMessageJob.filter((elem) => elem.status === JobStatusEnum.COMPLETED);
+ expect(completedInApp && completedInApp.length).to.equal(1);
+
+ const digestEventLength = completedInApp.find((i) => i.digest?.events?.length === 7);
+ expect(digestEventLength).to.be.ok;
+ });
+});
diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts
index 66474841a14..c6c9c79227d 100644
--- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts
+++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts
@@ -14,8 +14,10 @@ import {
IntegrationRepository,
ExecutionDetailsRepository,
EnvironmentRepository,
+ TenantRepository,
+ NotificationTemplateRepository,
} from '@novu/dal';
-import { UserSession, SubscribersService } from '@novu/testing';
+import { UserSession, SubscribersService, WorkflowOverrideService } from '@novu/testing';
import {
ChannelTypeEnum,
EmailBlockTypeEnum,
@@ -52,13 +54,16 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
let template: NotificationTemplateEntity;
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;
+ let workflowOverrideService: WorkflowOverrideService;
const notificationRepository = new NotificationRepository();
+ const notificationTemplateRepository = new NotificationTemplateRepository();
const messageRepository = new MessageRepository();
const subscriberRepository = new SubscriberRepository();
const integrationRepository = new IntegrationRepository();
const jobRepository = new JobRepository();
const executionDetailsRepository = new ExecutionDetailsRepository();
const environmentRepository = new EnvironmentRepository();
+ const tenantRepository = new TenantRepository();
describe(`Trigger Event - ${eventTriggerPath} (POST)`, function () {
beforeEach(async () => {
@@ -68,6 +73,10 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
template = await session.createTemplate();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
+ workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
});
it('should filter delay step', async function () {
@@ -230,6 +239,136 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
expect(executionDetails.length).to.equal(1);
});
+ it('should filter multiple digest steps', async function () {
+ const firstStepUuid = uuid();
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.EMAIL,
+ name: 'Message Name',
+ subject: 'Test email subject',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }],
+ uuid: firstStepUuid,
+ },
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ type: DelayTypeEnum.REGULAR,
+ },
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'digest_type',
+ value: '1',
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ type: DelayTypeEnum.REGULAR,
+ },
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'digest_type',
+ value: '2',
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ type: DelayTypeEnum.REGULAR,
+ },
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'digest_type',
+ value: '3',
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.EMAIL,
+ name: 'Message Name',
+ subject: 'Test email subject',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }],
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload: {
+ customVar: 'Testing of User Name',
+ digest_type: '2',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template?._id, true, 0);
+
+ const messagesAfter = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: template?._id,
+ _subscriberId: subscriber._id,
+ channel: StepTypeEnum.EMAIL,
+ });
+
+ expect(messagesAfter.length).to.equal(2);
+
+ const executionDetails = await executionDetailsRepository.find({
+ _environmentId: session.environment._id,
+ _notificationTemplateId: template?._id,
+ channel: StepTypeEnum.DIGEST,
+ detail: DetailEnum.FILTER_STEPS,
+ });
+
+ expect(executionDetails.length).to.equal(2);
+ });
+
it('should not filter digest step', async function () {
const firstStepUuid = uuid();
template = await session.createTemplate({
@@ -301,14 +440,185 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
expect(messagesAfter.length).to.equal(2);
- const executionDetails = await executionDetailsRepository.findOne({
+ const executionDetails = await executionDetailsRepository.find({
_environmentId: session.environment._id,
_notificationTemplateId: template?._id,
channel: StepTypeEnum.DIGEST,
detail: DetailEnum.FILTER_STEPS,
});
- expect(executionDetails).to.not.be.ok;
+ expect(executionDetails.length).to.equal(0);
+ });
+
+ it('should digest events with filters', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ type: DelayTypeEnum.REGULAR,
+ },
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.PAYLOAD,
+ operator: FieldOperatorEnum.IS_DEFINED,
+ field: 'exclude',
+ value: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.SMS,
+ content: 'total digested: {{step.total_count}}',
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload: {
+ exclude: false,
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload: {
+ exclude: false,
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template?._id, true, 0);
+
+ const messagesAfter = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _subscriberId: subscriber._id,
+ channel: StepTypeEnum.SMS,
+ });
+
+ expect(messagesAfter.length).to.equal(1);
+ expect(messagesAfter && messagesAfter[0].content).to.include('total digested: 2');
+
+ const executionDetails = await executionDetailsRepository.find({
+ _environmentId: session.environment._id,
+ _notificationTemplateId: template?._id,
+ channel: StepTypeEnum.DIGEST,
+ detail: DetailEnum.FILTER_STEPS,
+ });
+
+ expect(executionDetails.length).to.equal(0);
+ });
+
+ it('should not aggregate a filtered digest into a non filtered digest', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.DIGEST,
+ content: '',
+ metadata: {
+ unit: DigestUnitEnum.SECONDS,
+ amount: 2,
+ type: DelayTypeEnum.REGULAR,
+ },
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.PAYLOAD,
+ operator: FieldOperatorEnum.IS_DEFINED,
+ field: 'exclude',
+ value: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.SMS,
+ content: 'total digested: {{step.total_count}}',
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload: {
+ exclude: false,
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: [subscriber.subscriberId],
+ payload: {},
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template?._id, true, 0);
+
+ const messagesAfter = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _subscriberId: subscriber._id,
+ channel: StepTypeEnum.SMS,
+ });
+
+ expect(messagesAfter.length).to.equal(2);
+ expect(messagesAfter && messagesAfter[0].content).to.include('total digested: 1');
+ expect(messagesAfter && messagesAfter[1].content).to.include('total digested: 0');
+
+ const executionDetails = await executionDetailsRepository.find({
+ _environmentId: session.environment._id,
+ _notificationTemplateId: template?._id,
+ channel: StepTypeEnum.DIGEST,
+ detail: DetailEnum.FILTER_STEPS,
+ });
+
+ expect(executionDetails.length).to.equal(1);
});
it('should not filter delay step', async function () {
@@ -382,14 +692,14 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
expect(messagesAfter.length).to.equal(2);
- const executionDetails = await executionDetailsRepository.findOne({
+ const executionDetails = await executionDetailsRepository.find({
_environmentId: session.environment._id,
_notificationTemplateId: template?._id,
channel: StepTypeEnum.DELAY,
detail: DetailEnum.FILTER_STEPS,
});
- expect(executionDetails).to.not.be.ok;
+ expect(executionDetails.length).to.equal(0);
});
it('should use conditions to select integration', async function () {
@@ -1409,14 +1719,18 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
await session.awaitRunningJobs(template._id);
- messages = await messageRepository.find({
- _environmentId: session.environment._id,
- _subscriberId: createdSubscriber?._id,
- channel: channelType,
- });
-
+ messages = await messageRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _subscriberId: createdSubscriber?._id,
+ channel: channelType,
+ },
+ '',
+ { sort: { createdAt: -1 } }
+ );
+
expect(messages.length).to.be.equal(2);
- expect(messages[1].providerId).to.be.equal(EmailProviderIdEnum.Mailgun);
+ expect(messages[0].providerId).to.be.equal(EmailProviderIdEnum.Mailgun);
});
it('should fail to trigger with missing variables', async function () {
@@ -2040,9 +2354,360 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
await axiosInstance.post(
`${session.serverUrl}${eventTriggerPath}`,
{
- name: template.triggers[0].identifier,
+ name: template.triggers[0].identifier,
+ to: subscriber.subscriberId,
+ payload: {},
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template._id);
+
+ messages = await messageRepository.count({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ });
+
+ // expect(messages).to.equal(1);
+ expect(messages).to.equal(2);
+ // axiosPostStub.restore();
+ });
+
+ it('should choose variant by tenant data', async function () {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'one_123',
+ name: 'The one and only tenant',
+ data: { value1: 'Best fighter', value2: 'Ever' },
+ });
+
+ const templateWithVariants = await session.createTemplate({
+ name: 'test email template',
+ description: 'This is a test description',
+ steps: [
+ {
+ name: 'Root Message Name',
+ subject: 'Root Test email subject',
+ preheader: 'Root Test email preheader',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'Root This is a sample text block' }],
+ type: StepTypeEnum.EMAIL,
+ filters: [],
+ variants: [
+ {
+ name: 'Bad Variant Message Template',
+ subject: 'Bad Variant subject',
+ preheader: 'Bad Variant pre header',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample of Bad Variant text block' }],
+ type: StepTypeEnum.EMAIL,
+ active: true,
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'name',
+ value: 'Titans',
+ operator: FieldOperatorEnum.EQUAL,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'Better Variant Message Template',
+ subject: 'Better Variant subject',
+ preheader: 'Better Variant pre header',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample of Better Variant text block' }],
+ type: StepTypeEnum.EMAIL,
+ active: true,
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'name',
+ value: 'The one and only tenant',
+ operator: FieldOperatorEnum.EQUAL,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: templateWithVariants.triggers[0].identifier,
+ to: subscriber.subscriberId,
+ payload: {},
+ tenant: { identifier: tenant.identifier },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(templateWithVariants._id);
+
+ const messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: templateWithVariants._id,
+ });
+
+ expect(messages.length).to.equal(1);
+ expect(messages[0].subject).to.equal('Better Variant subject');
+ });
+ });
+
+ describe('filters logic', () => {
+ beforeEach(async () => {
+ process.env.LAUNCH_DARKLY_SDK_KEY = '';
+ session = new UserSession();
+ await session.initialize();
+ subscriberService = new SubscribersService(session.organization._id, session.environment._id);
+ subscriber = await subscriberService.createSubscriber();
+ });
+
+ it('should filter a message with variables', async function () {
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.EMAIL,
+ subject: 'Password reset',
+ content: [
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'This are the text contents of the template for {{firstName}}',
+ },
+ {
+ type: EmailBlockTypeEnum.BUTTON,
+ content: 'SIGN UP',
+ url: 'https://url-of-app.com/{{urlVariable}}',
+ },
+ ],
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'run',
+ value: '{{payload.var}}',
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: StepTypeEnum.EMAIL,
+ subject: 'Password reset',
+ content: [
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'This are the text contents of the template for {{firstName}}',
+ },
+ ],
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'subscriberId',
+ value: subscriber.subscriberId,
+ operator: FieldOperatorEnum.NOT_EQUAL,
+ on: FilterPartTypeEnum.SUBSCRIBER,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: subscriber.subscriberId,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ run: true,
+ var: true,
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template._id);
+
+ const messages = await messageRepository.count({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ });
+
+ expect(messages).to.equal(1);
+ });
+
+ it('should filter a message with value that includes variables and strings', async function () {
+ const actorSubscriber = await subscriberService.createSubscriber({
+ firstName: 'Actor',
+ });
+
+ template = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.EMAIL,
+ subject: 'Password reset',
+ content: [
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'This are the text contents of the template for {{firstName}}',
+ },
+ ],
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ field: 'name',
+ value: 'Test {{actor.firstName}}',
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: template.triggers[0].identifier,
+ to: subscriber.subscriberId,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ name: 'Test Actor',
+ },
+ actor: actorSubscriber.subscriberId,
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(template._id);
+
+ const messages = await messageRepository.count({
+ _environmentId: session.environment._id,
+ _templateId: template._id,
+ });
+
+ expect(messages).to.equal(1);
+ });
+
+ it('should filter by tenant variables data', async function () {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'one_123',
+ name: 'The one and only tenant',
+ data: { value1: 'Best fighter', value2: 'Ever', count: 4 },
+ });
+
+ const templateWithVariants = await session.createTemplate({
+ name: 'test email template',
+ description: 'This is a test description',
+ steps: [
+ {
+ name: 'Message Name',
+ subject: 'Test email subject',
+ preheader: 'Test email preheader',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }],
+ type: StepTypeEnum.EMAIL,
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'data.count',
+ value: '{{payload.count}}',
+ operator: FieldOperatorEnum.LARGER,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: templateWithVariants.triggers[0].identifier,
+ to: subscriber.subscriberId,
+ payload: { count: 5 },
+ tenant: { identifier: tenant.identifier },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ await session.awaitRunningJobs(templateWithVariants._id);
+
+ let messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: templateWithVariants._id,
+ });
+
+ expect(messages.length).to.equal(0);
+
+ await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: templateWithVariants.triggers[0].identifier,
to: subscriber.subscriberId,
- payload: {},
+ payload: { count: 1 },
+ tenant: { identifier: tenant.identifier },
},
{
headers: {
@@ -2050,17 +2715,76 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
},
}
);
+ await session.awaitRunningJobs(templateWithVariants._id);
+
+ messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: templateWithVariants._id,
+ });
+
+ expect(messages.length).to.equal(1);
+ });
+ it('should trigger message with override integration identifier', async function () {
+ const newSubscriberId = SubscriberRepository.createObjectId();
+ const channelType = ChannelTypeEnum.EMAIL;
+
+ template = await createTemplate(session, channelType);
+
+ await sendTrigger(session, template, newSubscriberId);
await session.awaitRunningJobs(template._id);
- messages = await messageRepository.count({
+ const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, newSubscriberId);
+
+ let messages = await messageRepository.find({
_environmentId: session.environment._id,
- _templateId: template._id,
+ _subscriberId: createdSubscriber?._id,
+ channel: channelType,
});
- // expect(messages).to.equal(1);
- expect(messages).to.equal(2);
- // axiosPostStub.restore();
+ expect(messages.length).to.be.equal(1);
+ expect(messages[0].providerId).to.be.equal(EmailProviderIdEnum.SendGrid);
+
+ const prodEnv = await environmentRepository.findOne({
+ name: 'Production',
+ _organizationId: session.organization._id,
+ });
+
+ const payload = {
+ providerId: EmailProviderIdEnum.Mailgun,
+ channel: 'email',
+ credentials: { apiKey: '123', secretKey: 'abc' },
+ _environmentId: prodEnv?._id,
+ active: true,
+ check: false,
+ };
+
+ const {
+ body: { data: newIntegration },
+ } = await session.testAgent.post('/v1/integrations').send(payload);
+
+ await sendTrigger(
+ session,
+ template,
+ newSubscriberId,
+ {},
+ { email: { integrationIdentifier: newIntegration.identifier } }
+ );
+
+ await session.awaitRunningJobs(template._id);
+
+ messages = await messageRepository.find(
+ {
+ _environmentId: session.environment._id,
+ _subscriberId: createdSubscriber?._id,
+ channel: channelType,
+ },
+ '',
+ { sort: { createdAt: -1 } }
+ );
+
+ expect(messages.length).to.be.equal(2);
+ expect(messages[0].providerId).to.be.equal(EmailProviderIdEnum.Mailgun);
});
describe('in-app avatar', () => {
@@ -2391,63 +3115,286 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () {
});
});
- it('should trigger message with override integration identifier', async function () {
- const newSubscriberId = SubscriberRepository.createObjectId();
- const channelType = ChannelTypeEnum.EMAIL;
+ describe('workflow override', () => {
+ beforeEach(async () => {
+ process.env.LAUNCH_DARKLY_SDK_KEY = '';
+ session = new UserSession();
+ await session.initialize();
- template = await createTemplate(session, channelType);
+ workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+ });
- await sendTrigger(session, template, newSubscriberId);
+ it('should override - active false', async function () {
+ const subscriberOverride = SubscriberRepository.createObjectId();
- await session.awaitRunningJobs(template._id);
+ // Create active workflow
+ const workflow = await createTemplate(session, ChannelTypeEnum.IN_APP);
- const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, newSubscriberId);
+ // Create workflow override with active false
+ const { tenant } = await workflowOverrideService.createWorkflowOverride({
+ workflowId: workflow._id,
+ active: false,
+ });
- let messages = await messageRepository.find({
- _environmentId: session.environment._id,
- _subscriberId: createdSubscriber?._id,
- channel: channelType,
+ if (!tenant) {
+ throw new Error('Tenant not found');
+ }
+
+ const triggerResponse = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ expect(triggerResponse.status).to.equal(201);
+ expect(triggerResponse.data.data.status).to.equal('trigger_not_active');
+
+ await session.awaitRunningJobs();
+
+ const messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages.length).to.equal(0);
+
+ // Disable workflow - should not take effect, test for anomalies
+ await notificationTemplateRepository.update(
+ { _id: workflow._id, _environmentId: session.environment._id },
+ { $set: { active: false } }
+ );
+
+ const triggerResponse2 = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ expect(triggerResponse2.status).to.equal(201);
+ expect(triggerResponse2.data.data.status).to.equal('trigger_not_active');
+
+ await session.awaitRunningJobs();
+
+ const messages2 = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages2.length).to.equal(0);
});
- expect(messages.length).to.be.equal(1);
- expect(messages[0].providerId).to.be.equal(EmailProviderIdEnum.SendGrid);
+ it('should override - active true', async function () {
+ const subscriberOverride = SubscriberRepository.createObjectId();
- const prodEnv = await environmentRepository.findOne({
- name: 'Production',
- _organizationId: session.organization._id,
+ // Create active workflow
+ const workflow = await createTemplate(session, ChannelTypeEnum.IN_APP);
+
+ // Create active workflow override
+ const { tenant } = await workflowOverrideService.createWorkflowOverride({
+ workflowId: workflow._id,
+ active: true,
+ });
+
+ if (!tenant) {
+ throw new Error('Tenant not found');
+ }
+
+ const triggerResponse = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ expect(triggerResponse.status).to.equal(201);
+ expect(triggerResponse.data.data.status).to.equal('processed');
+
+ await session.awaitRunningJobs();
+
+ const messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages.length).to.equal(1);
+
+ // Disable workflow - should not take effect as override is active
+ await notificationTemplateRepository.update(
+ { _id: workflow._id, _environmentId: session.environment._id },
+ { $set: { active: false } }
+ );
+
+ const triggerResponse2 = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ expect(triggerResponse2.status).to.equal(201);
+ expect(triggerResponse2.data.data.status).to.equal('processed');
+
+ await session.awaitRunningJobs();
+
+ const messages2 = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages2.length).to.equal(2);
});
- const payload = {
- providerId: EmailProviderIdEnum.Mailgun,
- channel: 'email',
- credentials: { apiKey: '123', secretKey: 'abc' },
- _environmentId: prodEnv?._id,
- active: true,
- check: false,
- };
+ it('should override - preference - should disable in app channel', async function () {
+ const subscriberOverride = SubscriberRepository.createObjectId();
- const {
- body: { data: newIntegration },
- } = await session.testAgent.post('/v1/integrations').send(payload);
+ // Create a workflow with in app channel enabled
+ const workflow = await createTemplate(session, ChannelTypeEnum.IN_APP);
- await sendTrigger(
- session,
- template,
- newSubscriberId,
- {},
- { email: { integrationIdentifier: newIntegration.identifier } }
- );
+ // Create a workflow with in app channel disabled
+ const { tenant } = await workflowOverrideService.createWorkflowOverride({
+ workflowId: workflow._id,
+ active: true,
+ preferenceSettings: { in_app: false },
+ });
- await session.awaitRunningJobs(template._id);
+ if (!tenant) {
+ throw new Error('Tenant not found');
+ }
+ const triggerResponse = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
- messages = await messageRepository.find({
- _environmentId: session.environment._id,
- _subscriberId: createdSubscriber?._id,
- channel: channelType,
+ expect(triggerResponse.status).to.equal(201);
+ expect(triggerResponse.data.data.status).to.equal('processed');
+
+ await session.awaitRunningJobs();
+
+ const messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages.length).to.equal(0);
});
- expect(messages.length).to.be.equal(2);
- expect(messages[1].providerId).to.be.equal(EmailProviderIdEnum.Mailgun);
+ it('should override - preference - should enable in app channel', async function () {
+ const subscriberOverride = SubscriberRepository.createObjectId();
+
+ // Create a workflow with in-app channel disabled
+ const workflow = await session.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Hello' as string,
+ },
+ ],
+ preferenceSettingsOverride: { in_app: false },
+ });
+
+ // Create workflow override with in app channel enabled
+ const { tenant } = await workflowOverrideService.createWorkflowOverride({
+ workflowId: workflow._id,
+ active: true,
+ preferenceSettings: { in_app: true },
+ });
+
+ if (!tenant) {
+ throw new Error('Tenant not found');
+ }
+
+ const triggerResponse = await axiosInstance.post(
+ `${session.serverUrl}${eventTriggerPath}`,
+ {
+ name: workflow.triggers[0].identifier,
+ to: subscriberOverride,
+ tenant: tenant.identifier,
+ payload: {
+ firstName: 'Testing of User Name',
+ urlVariable: '/test/url/path',
+ },
+ },
+ {
+ headers: {
+ authorization: `ApiKey ${session.apiKey}`,
+ },
+ }
+ );
+
+ expect(triggerResponse.status).to.equal(201);
+ expect(triggerResponse.data.data.status).to.equal('processed');
+
+ await session.awaitRunningJobs();
+
+ const messages = await messageRepository.find({
+ _environmentId: session.environment._id,
+ _templateId: workflow._id,
+ });
+
+ expect(messages.length).to.equal(1);
+ });
});
});
});
diff --git a/apps/api/src/app/events/events.controller.ts b/apps/api/src/app/events/events.controller.ts
index 4389964b7dd..1020ff4bc80 100644
--- a/apps/api/src/app/events/events.controller.ts
+++ b/apps/api/src/app/events/events.controller.ts
@@ -1,14 +1,14 @@
import { Body, Controller, Delete, Param, Post, Scope, UseGuards } from '@nestjs/common';
-import { ApiOkResponse, ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger';
import { v4 as uuidv4 } from 'uuid';
import {
+ AddressingTypeEnum,
+ ApiRateLimitCategoryEnum,
+ ApiRateLimitCostEnum,
IJwtPayload,
- ISubscribersDefine,
- ITenantDefine,
- TriggerRecipientSubscriber,
- TriggerTenantContext,
+ TriggerRequestCategoryEnum,
} from '@novu/shared';
-import { MapTriggerRecipients, SendTestEmail, SendTestEmailCommand } from '@novu/application-generic';
+import { SendTestEmail, SendTestEmailCommand } from '@novu/application-generic';
import {
BulkTriggerEventDto,
@@ -18,16 +18,19 @@ import {
TriggerEventToAllRequestDto,
} from './dtos';
import { CancelDelayed, CancelDelayedCommand } from './usecases/cancel-delayed';
-import { ParseEventRequest, ParseEventRequestCommand } from './usecases/parse-event-request';
+import { ParseEventRequest, ParseEventRequestMulticastCommand } from './usecases/parse-event-request';
import { ProcessBulkTrigger, ProcessBulkTriggerCommand } from './usecases/process-bulk-trigger';
import { TriggerEventToAll, TriggerEventToAllCommand } from './usecases/trigger-event-to-all';
import { UserSession } from '../shared/framework/user.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
+import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framework/response.decorator';
import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto';
+import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards';
+@ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
+@ApiCommonResponses()
@Controller({
path: 'events',
scope: Scope.REQUEST,
@@ -35,7 +38,6 @@ import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto';
@ApiTags('Events')
export class EventsController {
constructor(
- private mapTriggerRecipients: MapTriggerRecipients,
private cancelDelayedUsecase: CancelDelayed,
private triggerEventToAll: TriggerEventToAll,
private sendTestEmail: SendTestEmail,
@@ -44,7 +46,7 @@ export class EventsController {
) {}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/trigger')
@ApiResponse(TriggerEventResponseDto, 201)
@ApiOperation({
@@ -59,11 +61,8 @@ export class EventsController {
@UserSession() user: IJwtPayload,
@Body() body: TriggerEventRequestDto
): Promise {
- const mappedTenant = body.tenant ? this.mapTenant(body.tenant) : null;
- const mappedActor = body.actor ? this.mapActor(body.actor) : null;
-
const result = await this.parseEventRequest.execute(
- ParseEventRequestCommand.create({
+ ParseEventRequestMulticastCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
@@ -71,9 +70,11 @@ export class EventsController {
payload: body.payload || {},
overrides: body.overrides || {},
to: body.to,
- actor: mappedActor,
- tenant: mappedTenant,
+ actor: body.actor,
+ tenant: body.tenant,
transactionId: body.transactionId,
+ addressingType: AddressingTypeEnum.MULTICAST,
+ requestCategory: TriggerRequestCategoryEnum.SINGLE,
})
);
@@ -81,7 +82,8 @@ export class EventsController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
@Post('/trigger/bulk')
@ApiResponse(TriggerEventResponseDto, 201, true)
@ApiOperation({
@@ -106,7 +108,8 @@ export class EventsController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
@Post('/trigger/broadcast')
@ApiResponse(TriggerEventResponseDto)
@ApiOperation({
@@ -119,8 +122,6 @@ export class EventsController {
@Body() body: TriggerEventToAllRequestDto
): Promise {
const transactionId = body.transactionId || uuidv4();
- const mappedActor = body.actor ? this.mapActor(body.actor) : null;
- const mappedTenant = body.tenant ? this.mapTenant(body.tenant) : null;
return this.triggerEventToAll.execute(
TriggerEventToAllCommand.create({
@@ -129,15 +130,15 @@ export class EventsController {
organizationId: user.organizationId,
identifier: body.name,
payload: body.payload,
- tenant: mappedTenant,
+ tenant: body.tenant,
transactionId,
overrides: body.overrides || {},
- actor: mappedActor,
+ actor: body.actor,
})
);
}
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/test/email')
@ApiExcludeEndpoint()
async testEmailMessage(@UserSession() user: IJwtPayload, @Body() body: TestSendEmailRequestDto): Promise {
@@ -158,7 +159,7 @@ export class EventsController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Delete('/trigger/:transactionId')
@ApiOkResponse({
type: DataBooleanDto,
@@ -183,16 +184,4 @@ export class EventsController {
})
);
}
-
- private mapActor(actor?: TriggerRecipientSubscriber | null): ISubscribersDefine | null {
- if (!actor) return null;
-
- return this.mapTriggerRecipients.mapSubscriber(actor);
- }
-
- private mapTenant(tenant?: TriggerTenantContext | null): ITenantDefine | null {
- if (!tenant) return null;
-
- return this.parseEventRequest.mapTenant(tenant);
- }
}
diff --git a/apps/api/src/app/events/events.module.ts b/apps/api/src/app/events/events.module.ts
index 2df63fe6d4f..ab9e07e9982 100644
--- a/apps/api/src/app/events/events.module.ts
+++ b/apps/api/src/app/events/events.module.ts
@@ -2,25 +2,11 @@ import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import {
- AddJob,
- AddDelayJob,
- MergeOrCreateDigest,
CreateExecutionDetails,
- CreateNotificationJobs,
- DigestFilterSteps,
- DigestFilterStepsBackoff,
- DigestFilterStepsRegular,
- DigestFilterStepsTimed,
EventsDistributedLockService,
GetNovuProviderCredentials,
- ProcessSubscriber,
- ProcessTenant,
- QueuesModule,
StorageHelperService,
SendTestEmail,
- StoreSubscriberJobs,
- TriggerEvent,
- MapTriggerRecipients,
} from '@novu/application-generic';
import { EventsController } from './events.controller';
@@ -37,26 +23,14 @@ import { ExecutionDetailsModule } from '../execution-details/execution-details.m
import { TopicsModule } from '../topics/topics.module';
import { LayoutsModule } from '../layouts/layouts.module';
import { TenantModule } from '../tenant/tenant.module';
+import { JobTopicNameEnum } from '@novu/shared';
const PROVIDERS = [
- AddJob,
- AddDelayJob,
- MergeOrCreateDigest,
CreateExecutionDetails,
- CreateNotificationJobs,
- DigestFilterSteps,
- DigestFilterStepsBackoff,
- DigestFilterStepsRegular,
- DigestFilterStepsTimed,
GetNovuProviderCredentials,
StorageHelperService,
EventsDistributedLockService,
- ProcessSubscriber,
- ProcessTenant,
SendTestEmail,
- StoreSubscriberJobs,
- TriggerEvent,
- MapTriggerRecipients,
];
@Module({
@@ -73,7 +47,6 @@ const PROVIDERS = [
TopicsModule,
LayoutsModule,
TenantModule,
- QueuesModule,
],
controllers: [EventsController],
providers: [...PROVIDERS, ...USE_CASES],
diff --git a/apps/api/src/app/events/usecases/cancel-delayed/cancel-delayed.usecase.ts b/apps/api/src/app/events/usecases/cancel-delayed/cancel-delayed.usecase.ts
index 954d652394a..4c65180680c 100644
--- a/apps/api/src/app/events/usecases/cancel-delayed/cancel-delayed.usecase.ts
+++ b/apps/api/src/app/events/usecases/cancel-delayed/cancel-delayed.usecase.ts
@@ -1,30 +1,49 @@
import { Injectable } from '@nestjs/common';
-import { JobStatusEnum, JobRepository } from '@novu/dal';
+
+import { JobStatusEnum, JobRepository, JobEntity } from '@novu/dal';
+import { StepTypeEnum } from '@novu/shared';
+import { isActionStepType, isMainDigest } from '@novu/application-generic';
+
import { CancelDelayedCommand } from './cancel-delayed.command';
+type PartialJob = Pick;
+
@Injectable()
export class CancelDelayed {
constructor(private jobRepository: JobRepository) {}
public async execute(command: CancelDelayedCommand): Promise {
- const jobs = await this.jobRepository.find(
+ let transactionJobs: PartialJob[] = await this.jobRepository.find(
{
_environmentId: command.environmentId,
transactionId: command.transactionId,
- status: JobStatusEnum.DELAYED,
+ status: [JobStatusEnum.DELAYED, JobStatusEnum.MERGED],
},
- '_id'
+ '_id type status _environmentId _subscriberId'
);
- if (!jobs?.length) {
+ if (!transactionJobs?.length) {
return false;
}
+ if (transactionJobs.find((job) => job.type && isActionStepType(job.type))) {
+ const possiblePendingJobs: PartialJob[] = await this.jobRepository.find(
+ {
+ _environmentId: command.environmentId,
+ transactionId: command.transactionId,
+ status: [JobStatusEnum.PENDING],
+ },
+ '_id type status _environmentId _subscriberId'
+ );
+
+ transactionJobs = [...transactionJobs, ...possiblePendingJobs];
+ }
+
await this.jobRepository.update(
{
_environmentId: command.environmentId,
_id: {
- $in: jobs.map((job) => job._id),
+ $in: transactionJobs.map((job) => job._id),
},
},
{
@@ -34,6 +53,71 @@ export class CancelDelayed {
}
);
+ const mainDigestJob = transactionJobs.find((job) => isMainDigest(job.type, job.status));
+
+ if (!mainDigestJob) {
+ return true;
+ }
+
+ return await this.assignNextDigestJob(mainDigestJob);
+ }
+
+ private async assignNextDigestJob(job: PartialJob) {
+ const mainFollowerDigestJob = await this.jobRepository.findOne(
+ {
+ _mergedDigestId: job._id,
+ status: JobStatusEnum.MERGED,
+ type: StepTypeEnum.DIGEST,
+ _environmentId: job._environmentId,
+ _subscriberId: job._subscriberId,
+ },
+ '',
+ {
+ query: { sort: { createdAt: 1 } },
+ }
+ );
+
+ // meaning that only one trigger was send, and it was cancelled in the CancelDelayed.execute
+ if (!mainFollowerDigestJob) {
+ return true;
+ }
+
+ // update new main follower from Merged to Delayed
+ await this.jobRepository.update(
+ {
+ _environmentId: job._environmentId,
+ status: JobStatusEnum.MERGED,
+ _id: mainFollowerDigestJob._id,
+ },
+ {
+ $set: {
+ status: JobStatusEnum.DELAYED,
+ _mergedDigestId: null,
+ },
+ }
+ );
+
+ // update all main follower children jobs to pending status
+ await this.jobRepository.updateAllChildJobStatus(
+ mainFollowerDigestJob,
+ JobStatusEnum.PENDING,
+ mainFollowerDigestJob._id
+ );
+
+ // update all jobs that were merged into the old main digest job to point to the new follower
+ await this.jobRepository.update(
+ {
+ _environmentId: job._environmentId,
+ status: JobStatusEnum.MERGED,
+ _mergedDigestId: job._id,
+ },
+ {
+ $set: {
+ _mergedDigestId: mainFollowerDigestJob._id,
+ },
+ }
+ );
+
return true;
}
}
diff --git a/apps/api/src/app/events/usecases/parse-event-request/index.ts b/apps/api/src/app/events/usecases/parse-event-request/index.ts
index a2e8e723d90..ab9b42c1e21 100644
--- a/apps/api/src/app/events/usecases/parse-event-request/index.ts
+++ b/apps/api/src/app/events/usecases/parse-event-request/index.ts
@@ -1,2 +1,6 @@
export { ParseEventRequest } from './parse-event-request.usecase';
-export { ParseEventRequestCommand } from './parse-event-request.command';
+export {
+ ParseEventRequestMulticastCommand,
+ ParseEventRequestBroadcastCommand,
+ ParseEventRequestCommand,
+} from './parse-event-request.command';
diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.command.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.command.ts
index d3cbb4d2937..3063571d8df 100644
--- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.command.ts
+++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.command.ts
@@ -1,9 +1,15 @@
-import { IsDefined, IsString, IsOptional, ValidateNested } from 'class-validator';
-import { TriggerRecipients, TriggerRecipientSubscriber, TriggerTenantContext } from '@novu/shared';
+import { IsDefined, IsString, IsOptional, ValidateNested, ValidateIf, IsEnum } from 'class-validator';
+import {
+ AddressingTypeEnum,
+ TriggerRecipients,
+ TriggerRecipientSubscriber,
+ TriggerRequestCategoryEnum,
+ TriggerTenantContext,
+} from '@novu/shared';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
-export class ParseEventRequestCommand extends EnvironmentWithUserCommand {
+export class ParseEventRequestBaseCommand extends EnvironmentWithUserCommand {
@IsDefined()
@IsString()
identifier: string;
@@ -14,18 +20,36 @@ export class ParseEventRequestCommand extends EnvironmentWithUserCommand {
@IsDefined()
overrides: Record>;
- @IsDefined()
- to: TriggerRecipients;
-
@IsString()
@IsOptional()
transactionId?: string;
@IsOptional()
+ @ValidateIf((_, value) => typeof value !== 'string')
@ValidateNested()
actor?: TriggerRecipientSubscriber | null;
@IsOptional()
@ValidateNested()
+ @ValidateIf((_, value) => typeof value !== 'string')
tenant?: TriggerTenantContext | null;
+
+ @IsDefined()
+ @IsEnum(TriggerRequestCategoryEnum)
+ requestCategory: TriggerRequestCategoryEnum;
}
+
+export class ParseEventRequestMulticastCommand extends ParseEventRequestBaseCommand {
+ @IsDefined()
+ to: TriggerRecipients;
+
+ @IsEnum(AddressingTypeEnum)
+ addressingType: AddressingTypeEnum.MULTICAST;
+}
+
+export class ParseEventRequestBroadcastCommand extends ParseEventRequestBaseCommand {
+ @IsEnum(AddressingTypeEnum)
+ addressingType: AddressingTypeEnum.BROADCAST;
+}
+
+export type ParseEventRequestCommand = ParseEventRequestMulticastCommand | ParseEventRequestBroadcastCommand;
diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.e2e.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.e2e.ts
index cc38c8cc882..2dfcc1705bb 100644
--- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.e2e.ts
+++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.e2e.ts
@@ -3,12 +3,12 @@ import { expect } from 'chai';
import { v4 as uuid } from 'uuid';
import { SubscribersService, UserSession } from '@novu/testing';
-import { SubscriberRepository, NotificationTemplateEntity } from '@novu/dal';
-import { TriggerRecipients } from '@novu/shared';
+import { NotificationTemplateEntity, SubscriberRepository } from '@novu/dal';
+import { AddressingTypeEnum, TriggerRecipients, TriggerRequestCategoryEnum } from '@novu/shared';
import { SharedModule } from '../../../shared/shared.module';
import { EventsModule } from '../../events.module';
-import { ParseEventRequestCommand } from './parse-event-request.command';
+import { ParseEventRequestCommand, ParseEventRequestMulticastCommand } from './parse-event-request.command';
import { ParseEventRequest } from './parse-event-request.usecase';
describe('ParseEventRequest Usecase', () => {
@@ -58,7 +58,7 @@ const buildCommand = (
to: TriggerRecipients,
identifier: string
): ParseEventRequestCommand => {
- return ParseEventRequestCommand.create({
+ return ParseEventRequestMulticastCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
to,
@@ -67,5 +67,7 @@ const buildCommand = (
identifier,
payload: {},
overrides: {},
+ addressingType: AddressingTypeEnum.MULTICAST,
+ requestCategory: TriggerRequestCategoryEnum.SINGLE,
});
};
diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts
index e3bd8273b68..f9baf6c6c97 100644
--- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts
+++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts
@@ -8,21 +8,22 @@ import {
CachedEntity,
Instrument,
InstrumentUsecase,
+ IWorkflowDataDto,
+ IWorkflowJobDto,
StorageHelperService,
WorkflowQueueService,
} from '@novu/application-generic';
-import { NotificationTemplateRepository, NotificationTemplateEntity, TenantRepository } from '@novu/dal';
+import { ReservedVariablesMap, TriggerContextTypeEnum, TriggerEventStatusEnum } from '@novu/shared';
import {
- ISubscribersDefine,
- ITenantDefine,
- ReservedVariablesMap,
- TriggerContextTypeEnum,
- TriggerEventStatusEnum,
- TriggerTenantContext,
-} from '@novu/shared';
+ WorkflowOverrideRepository,
+ TenantEntity,
+ WorkflowOverrideEntity,
+ NotificationTemplateRepository,
+ NotificationTemplateEntity,
+ TenantRepository,
+} from '@novu/dal';
import { ParseEventRequestCommand } from './parse-event-request.command';
-
import { ApiException } from '../../../shared/exceptions/api.exception';
import { VerifyPayload, VerifyPayloadCommand } from '../verify-payload';
@@ -35,7 +36,8 @@ export class ParseEventRequest {
private verifyPayload: VerifyPayload,
private storageHelperService: StorageHelperService,
private workflowQueueService: WorkflowQueueService,
- private tenantRepository: TenantRepository
+ private tenantRepository: TenantRepository,
+ private workflowOverrideRepository: WorkflowOverrideRepository
) {}
@InstrumentUsecase()
@@ -54,7 +56,38 @@ export class ParseEventRequest {
const reservedVariablesTypes = this.getReservedVariablesTypes(template);
this.validateTriggerContext(command, reservedVariablesTypes);
- if (!template.active) {
+ let tenant: TenantEntity | null = null;
+ if (command.tenant) {
+ tenant = await this.tenantRepository.findOne({
+ _environmentId: command.environmentId,
+ identifier: typeof command.tenant === 'string' ? command.tenant : command.tenant.identifier,
+ });
+
+ if (!tenant) {
+ return {
+ acknowledged: true,
+ status: TriggerEventStatusEnum.TENANT_MISSING,
+ };
+ }
+ }
+
+ let workflowOverride: WorkflowOverrideEntity | null = null;
+ if (tenant) {
+ workflowOverride = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _organizationId: command.organizationId,
+ _workflowId: template._id,
+ _tenantId: tenant._id,
+ });
+ }
+
+ const inactiveWorkflow = !workflowOverride && !template.active;
+ const inactiveWorkflowOverride = workflowOverride && !workflowOverride.active;
+
+ if (inactiveWorkflowOverride || inactiveWorkflow) {
+ const message = workflowOverride ? 'Workflow is not active by workflow override' : 'Workflow is not active';
+ Logger.log(message, LOG_CONTEXT);
+
return {
acknowledged: true,
status: TriggerEventStatusEnum.NOT_ACTIVE,
@@ -75,20 +108,6 @@ export class ParseEventRequest {
};
}
- if (command.tenant) {
- try {
- await this.validateTenant({
- identifier: typeof command.tenant === 'string' ? command.tenant : command.tenant.identifier,
- _environmentId: command.environmentId,
- });
- } catch (e) {
- return {
- acknowledged: true,
- status: TriggerEventStatusEnum.TENANT_MISSING,
- };
- }
- }
-
Sentry.addBreadcrumb({
message: 'Sending trigger',
data: {
@@ -112,13 +131,13 @@ export class ParseEventRequest {
command.payload = merge({}, defaultPayload, command.payload);
- const jobData = {
+ const jobData: IWorkflowDataDto = {
...command,
- to: command.to,
actor: command.actor,
transactionId,
};
- await this.workflowQueueService.add(transactionId, jobData, command.organizationId);
+
+ await this.workflowQueueService.add({ name: transactionId, data: jobData, groupId: command.organizationId });
return {
acknowledged: true,
@@ -145,16 +164,6 @@ export class ParseEventRequest {
);
}
- private async validateTenant({ identifier, _environmentId }: { identifier: string; _environmentId: string }) {
- const found = await this.tenantRepository.findOne({
- _environmentId: _environmentId,
- identifier: identifier,
- });
- if (!found) {
- throw new ApiException(`Tenant with identifier ${identifier} could not be found`);
- }
- }
-
@Instrument()
private validateTriggerContext(
command: ParseEventRequestCommand,
@@ -192,14 +201,6 @@ export class ParseEventRequest {
}));
}
- public mapTenant(tenant: TriggerTenantContext): ITenantDefine {
- if (typeof tenant === 'string') {
- return { identifier: tenant };
- }
-
- return tenant;
- }
-
public getReservedVariablesTypes(template: NotificationTemplateEntity): TriggerContextTypeEnum[] {
const reservedVariables = template.triggers[0].reservedVariables;
diff --git a/apps/api/src/app/events/usecases/process-bulk-trigger/process-bulk-trigger.usecase.ts b/apps/api/src/app/events/usecases/process-bulk-trigger/process-bulk-trigger.usecase.ts
index 71746dcd424..c706abe5ed1 100644
--- a/apps/api/src/app/events/usecases/process-bulk-trigger/process-bulk-trigger.usecase.ts
+++ b/apps/api/src/app/events/usecases/process-bulk-trigger/process-bulk-trigger.usecase.ts
@@ -1,28 +1,25 @@
import { Injectable } from '@nestjs/common';
-import { TriggerEventStatusEnum } from '@novu/shared';
-import { MapTriggerRecipients } from '@novu/application-generic';
+import { AddressingTypeEnum, TriggerEventStatusEnum, TriggerRequestCategoryEnum } from '@novu/shared';
import { ProcessBulkTriggerCommand } from './process-bulk-trigger.command';
import { TriggerEventResponseDto } from '../../dtos';
-import { ParseEventRequestCommand } from '../parse-event-request/parse-event-request.command';
import { ParseEventRequest } from '../parse-event-request/parse-event-request.usecase';
+import { ParseEventRequestMulticastCommand } from '../parse-event-request/parse-event-request.command';
@Injectable()
export class ProcessBulkTrigger {
- constructor(private parseEventRequest: ParseEventRequest, private mapTriggerRecipients: MapTriggerRecipients) {}
+ constructor(private parseEventRequest: ParseEventRequest) {}
async execute(command: ProcessBulkTriggerCommand) {
const results: TriggerEventResponseDto[] = [];
for (const event of command.events) {
let result: TriggerEventResponseDto;
- const mappedTenant = event.tenant ? this.parseEventRequest.mapTenant(event.tenant) : null;
- const mappedActor = event.actor ? this.mapTriggerRecipients.mapSubscriber(event.actor) : null;
try {
result = (await this.parseEventRequest.execute(
- ParseEventRequestCommand.create({
+ ParseEventRequestMulticastCommand.create({
userId: command.userId,
environmentId: command.environmentId,
organizationId: command.organizationId,
@@ -30,9 +27,11 @@ export class ProcessBulkTrigger {
payload: event.payload,
overrides: event.overrides || {},
to: event.to,
- actor: mappedActor,
- tenant: mappedTenant,
+ actor: event.actor,
+ tenant: event.tenant,
transactionId: event.transactionId,
+ addressingType: AddressingTypeEnum.MULTICAST,
+ requestCategory: TriggerRequestCategoryEnum.BULK,
})
)) as unknown as TriggerEventResponseDto;
} catch (e) {
diff --git a/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.command.ts b/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.command.ts
index 4f6127424cf..f3209067843 100644
--- a/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.command.ts
+++ b/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.command.ts
@@ -1,5 +1,5 @@
import { IsDefined, IsObject, IsOptional, IsString } from 'class-validator';
-import { ISubscribersDefine, ITenantDefine } from '@novu/shared';
+import { TriggerRecipientSubscriber, TriggerTenantContext } from '@novu/shared';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
@@ -20,8 +20,8 @@ export class TriggerEventToAllCommand extends EnvironmentWithUserCommand {
overrides: Record>;
@IsOptional()
- actor?: ISubscribersDefine | null;
+ actor?: TriggerRecipientSubscriber | null;
@IsOptional()
- tenant?: ITenantDefine | null;
+ tenant?: TriggerTenantContext | null;
}
diff --git a/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.usecase.ts b/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.usecase.ts
index e0a5ea90276..37049b1a3cb 100644
--- a/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.usecase.ts
+++ b/apps/api/src/app/events/usecases/trigger-event-to-all/trigger-event-to-all.usecase.ts
@@ -1,62 +1,35 @@
import { Injectable } from '@nestjs/common';
-import * as _ from 'lodash';
-import { SubscriberEntity, SubscriberRepository } from '@novu/dal';
-import { TriggerEvent, TriggerEventCommand } from '@novu/application-generic';
-import { TriggerEventStatusEnum } from '@novu/shared';
+import { SubscriberRepository } from '@novu/dal';
+import { AddressingTypeEnum, TriggerEventStatusEnum, TriggerRequestCategoryEnum } from '@novu/shared';
import { TriggerEventToAllCommand } from './trigger-event-to-all.command';
+import { ParseEventRequest, ParseEventRequestBroadcastCommand } from '../parse-event-request';
@Injectable()
export class TriggerEventToAll {
- constructor(private triggerEvent: TriggerEvent, private subscriberRepository: SubscriberRepository) {}
+ constructor(private subscriberRepository: SubscriberRepository, private parseEventRequest: ParseEventRequest) {}
public async execute(command: TriggerEventToAllCommand) {
- const batchSize = 500;
- let list: SubscriberEntity[] = [];
-
- for await (const subscriber of this.subscriberRepository.findBatch(
- {
- _environmentId: command.environmentId,
- _organizationId: command.organizationId,
- },
- 'subscriberId',
- {},
- batchSize
- )) {
- list.push(subscriber);
- if (list.length === batchSize) {
- await this.trigger(command, list);
- list = [];
- }
- }
-
- if (list.length > 0) {
- await this.trigger(command, list);
- }
-
- return {
- acknowledged: true,
- status: TriggerEventStatusEnum.PROCESSED,
- transactionId: command.transactionId,
- };
- }
-
- private async trigger(command: TriggerEventToAllCommand, list: SubscriberEntity[]) {
- await this.triggerEvent.execute(
- TriggerEventCommand.create({
+ await this.parseEventRequest.execute(
+ ParseEventRequestBroadcastCommand.create({
userId: command.userId,
environmentId: command.environmentId,
organizationId: command.organizationId,
identifier: command.identifier,
- payload: command.payload,
- to: list.map((item) => ({
- subscriberId: item.subscriberId,
- })),
+ payload: command.payload || {},
+ addressingType: AddressingTypeEnum.BROADCAST,
transactionId: command.transactionId,
- overrides: command.overrides,
+ overrides: command.overrides || {},
actor: command.actor,
tenant: command.tenant,
+ requestCategory: TriggerRequestCategoryEnum.SINGLE,
})
);
+
+ return {
+ acknowledged: true,
+ status: TriggerEventStatusEnum.PROCESSED,
+ transactionId: command.transactionId,
+ };
}
}
diff --git a/apps/api/src/app/execution-details/execution-details.controller.ts b/apps/api/src/app/execution-details/execution-details.controller.ts
index c805e73cdf3..d30dcc82463 100644
--- a/apps/api/src/app/execution-details/execution-details.controller.ts
+++ b/apps/api/src/app/execution-details/execution-details.controller.ts
@@ -3,15 +3,16 @@ import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IJwtPayload } from '@novu/shared';
import { ExecutionDetailsResponseDto } from '@novu/application-generic';
import { UserSession } from '../shared/framework/user.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { GetExecutionDetails, GetExecutionDetailsCommand } from './usecases/get-execution-details';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { ExecutionDetailsRequestDto } from './dtos/execution-details-request.dto';
+@ApiCommonResponses()
@Controller('/execution-details')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Execution Details')
export class ExecutionDetailsController {
constructor(private getExecutionDetails: GetExecutionDetails) {}
diff --git a/apps/api/src/app/feeds/feeds.controller.ts b/apps/api/src/app/feeds/feeds.controller.ts
index 19642a4d8ac..a0ed0b5206d 100644
--- a/apps/api/src/app/feeds/feeds.controller.ts
+++ b/apps/api/src/app/feeds/feeds.controller.ts
@@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { IJwtPayload } from '@novu/shared';
import { UserSession } from '../shared/framework/user.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { CreateFeed } from './usecases/create-feed/create-feed.usecase';
import { CreateFeedCommand } from './usecases/create-feed/create-feed.command';
import { CreateFeedRequestDto } from './dto/create-feed-request.dto';
@@ -22,11 +22,12 @@ import { DeleteFeedCommand } from './usecases/delete-feed/delete-feed.command';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FeedResponseDto } from './dto/feed-response.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/feeds')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Feeds')
export class FeedsController {
constructor(
diff --git a/apps/api/src/app/feeds/usecases/create-feed/create-feed.usecase.ts b/apps/api/src/app/feeds/usecases/create-feed/create-feed.usecase.ts
index 3c65cba1484..04dbd821c3e 100644
--- a/apps/api/src/app/feeds/usecases/create-feed/create-feed.usecase.ts
+++ b/apps/api/src/app/feeds/usecases/create-feed/create-feed.usecase.ts
@@ -1,8 +1,8 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { FeedRepository, FeedEntity } from '@novu/dal';
import { CreateFeedCommand } from './create-feed.command';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { ChangeEntityTypeEnum } from '@novu/shared';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
@Injectable()
export class CreateFeed {
diff --git a/apps/api/src/app/feeds/usecases/delete-feed/delete-feed.usecase.ts b/apps/api/src/app/feeds/usecases/delete-feed/delete-feed.usecase.ts
index 06cbbbe0aa6..d9e990ae3d9 100644
--- a/apps/api/src/app/feeds/usecases/delete-feed/delete-feed.usecase.ts
+++ b/apps/api/src/app/feeds/usecases/delete-feed/delete-feed.usecase.ts
@@ -4,7 +4,7 @@ import { ChangeEntityTypeEnum } from '@novu/shared';
import { DeleteFeedCommand } from './delete-feed.command';
import { ApiException } from '../../../shared/exceptions/api.exception';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
@Injectable()
export class DeleteFeed {
diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts
index e877c785552..2d20bae16b5 100644
--- a/apps/api/src/app/health/health.controller.ts
+++ b/apps/api/src/app/health/health.controller.ts
@@ -4,7 +4,6 @@ import { HealthCheck, HealthCheckResult, HealthCheckService, HealthIndicatorFunc
import {
CacheServiceHealthIndicator,
DalServiceHealthIndicator,
- StandardQueueServiceHealthIndicator,
WorkflowQueueServiceHealthIndicator,
} from '@novu/application-generic';
@@ -17,7 +16,6 @@ export class HealthController {
private healthCheckService: HealthCheckService,
private cacheHealthIndicator: CacheServiceHealthIndicator,
private dalHealthIndicator: DalServiceHealthIndicator,
- private standardQueueHealthIndicator: StandardQueueServiceHealthIndicator,
private workflowQueueHealthIndicator: WorkflowQueueServiceHealthIndicator
) {}
@@ -26,7 +24,6 @@ export class HealthController {
healthCheck(): Promise {
const checks: HealthIndicatorFunction[] = [
async () => this.dalHealthIndicator.isHealthy(),
- async () => this.standardQueueHealthIndicator.isHealthy(),
async () => this.workflowQueueHealthIndicator.isHealthy(),
async () => {
return {
diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts
index 533bb1810e1..b4a4002be7a 100644
--- a/apps/api/src/app/health/health.module.ts
+++ b/apps/api/src/app/health/health.module.ts
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
-
import { SharedModule } from '../shared/shared.module';
@Module({
diff --git a/apps/api/src/app/inbound-parse/inbound-parse.controller.ts b/apps/api/src/app/inbound-parse/inbound-parse.controller.ts
index b2899a37a8a..4cc552427a8 100644
--- a/apps/api/src/app/inbound-parse/inbound-parse.controller.ts
+++ b/apps/api/src/app/inbound-parse/inbound-parse.controller.ts
@@ -2,17 +2,18 @@ import { ClassSerializerInterceptor, Controller, Get, UseGuards, UseInterceptors
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '@novu/shared';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { GetMxRecord } from './usecases/get-mx-record/get-mx-record.usecase';
import { GetMxRecordCommand } from './usecases/get-mx-record/get-mx-record.command';
import { GetMxRecordResponseDto } from './dtos/get-mx-record.dto';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/inbound-parse')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Inbound Parse')
export class InboundParseController {
constructor(private getMxRecordUsecase: GetMxRecord) {}
diff --git a/apps/api/src/app/inbound-parse/inbound-parse.module.ts b/apps/api/src/app/inbound-parse/inbound-parse.module.ts
index 18954928e0a..e35c36838e1 100644
--- a/apps/api/src/app/inbound-parse/inbound-parse.module.ts
+++ b/apps/api/src/app/inbound-parse/inbound-parse.module.ts
@@ -1,23 +1,37 @@
-import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
-import { CompileTemplate, QueuesModule } from '@novu/application-generic';
+import { MiddlewareConsumer, Module, NestModule, OnApplicationShutdown } from '@nestjs/common';
+import { CompileTemplate, WorkflowInMemoryProviderService } from '@novu/application-generic';
import { USE_CASES } from './usecases';
import { InboundParseController } from './inbound-parse.controller';
-import { InboundParseQueueService } from './services/inbound-parse.queue.service';
import { GetMxRecord } from './usecases/get-mx-record/get-mx-record.usecase';
-import { InboundEmailParse } from './usecases/inbound-email-parse/inbound-email-parse.usecase';
import { SharedModule } from '../shared/shared.module';
import { AuthModule } from '../auth/auth.module';
+import { InboundParseWorkerService } from './services/inbound-parse.worker.service';
-const PROVIDERS = [InboundParseQueueService, GetMxRecord, CompileTemplate];
+const PROVIDERS = [GetMxRecord, CompileTemplate, InboundParseWorkerService];
+const memoryQueueService = {
+ provide: WorkflowInMemoryProviderService,
+ useFactory: async () => {
+ const memoryService = new WorkflowInMemoryProviderService();
+
+ await memoryService.initialize();
+
+ return memoryService;
+ },
+};
@Module({
- imports: [SharedModule, AuthModule, QueuesModule],
+ imports: [SharedModule, AuthModule],
controllers: [InboundParseController],
- providers: [...PROVIDERS, ...USE_CASES],
- exports: [...USE_CASES, QueuesModule],
+ providers: [...PROVIDERS, ...USE_CASES, memoryQueueService],
+ exports: [...USE_CASES],
})
-export class InboundParseModule implements NestModule {
+export class InboundParseModule implements NestModule, OnApplicationShutdown {
+ constructor(private workflowInMemoryProviderService: WorkflowInMemoryProviderService) {}
configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {}
+
+ async onApplicationShutdown() {
+ await this.workflowInMemoryProviderService.shutdown();
+ }
}
diff --git a/apps/api/src/app/inbound-parse/services/inbound-parse.queue.service.ts b/apps/api/src/app/inbound-parse/services/inbound-parse.worker.service.ts
similarity index 62%
rename from apps/api/src/app/inbound-parse/services/inbound-parse.queue.service.ts
rename to apps/api/src/app/inbound-parse/services/inbound-parse.worker.service.ts
index 208ede0aff3..01775895d3b 100644
--- a/apps/api/src/app/inbound-parse/services/inbound-parse.queue.service.ts
+++ b/apps/api/src/app/inbound-parse/services/inbound-parse.worker.service.ts
@@ -1,11 +1,11 @@
import {
+ BullMqService,
getInboundParseMailWorkerOptions,
- InboundParseQueue,
- InboundParseWorker,
- Queue,
- QueueOptions,
- Worker,
+ IInboundParseDataDto,
+ IInboundParseJobDto,
+ WorkerBaseService,
WorkerOptions,
+ WorkflowInMemoryProviderService,
} from '@novu/application-generic';
import { JobTopicNameEnum } from '@novu/shared';
import { Injectable, Logger } from '@nestjs/common';
@@ -16,17 +16,14 @@ import { InboundEmailParseCommand } from '../usecases/inbound-email-parse/inboun
const LOG_CONTEXT = 'InboundParseQueueService';
@Injectable()
-export class InboundParseQueueService {
- public readonly queue: Queue;
- public readonly worker: Worker;
-
+export class InboundParseWorkerService extends WorkerBaseService {
constructor(
private emailParseUsecase: InboundEmailParse,
- public readonly inboundParseQueue: InboundParseQueue,
- public readonly inboundParseWorker: InboundParseWorker
+ public workflowInMemoryProviderService: WorkflowInMemoryProviderService
) {
- this.inboundParseQueue.createQueue();
- this.inboundParseWorker.createWorker(this.getWorkerProcessor(), this.getWorkerOptions());
+ super(JobTopicNameEnum.INBOUND_PARSE_MAIL, new BullMqService(workflowInMemoryProviderService));
+
+ this.createWorker(this.getWorkerProcessor(), this.getWorkerOptions());
}
private getWorkerOptions(): WorkerOptions {
@@ -34,7 +31,7 @@ export class InboundParseQueueService {
}
public getWorkerProcessor() {
- return async ({ data }: { data: InboundEmailParseCommand }) => {
+ return async ({ data }: { data: IInboundParseDataDto }) => {
Logger.verbose({ data }, 'Processing the inbound parsed email', LOG_CONTEXT);
await this.emailParseUsecase.execute(InboundEmailParseCommand.create({ ...data }));
};
diff --git a/apps/api/src/app/inbound-parse/usecases/inbound-email-parse/inbound-email-parse.command.ts b/apps/api/src/app/inbound-parse/usecases/inbound-email-parse/inbound-email-parse.command.ts
index 6514c9eb3cb..eced0917d13 100644
--- a/apps/api/src/app/inbound-parse/usecases/inbound-email-parse/inbound-email-parse.command.ts
+++ b/apps/api/src/app/inbound-parse/usecases/inbound-email-parse/inbound-email-parse.command.ts
@@ -1,7 +1,16 @@
import { IsDefined, IsNumber, IsOptional, IsString } from 'class-validator';
-import { BaseCommand } from '@novu/application-generic';
-
-export class InboundEmailParseCommand extends BaseCommand {
+import {
+ BaseCommand,
+ IConnection,
+ IEnvelopeFrom,
+ IEnvelopeTo,
+ IFrom,
+ IHeaders,
+ IInboundParseDataDto,
+ ITo,
+} from '@novu/application-generic';
+
+export class InboundEmailParseCommand extends BaseCommand implements IInboundParseDataDto {
@IsDefined()
@IsString()
html: string;
@@ -66,70 +75,3 @@ export class InboundEmailParseCommand extends BaseCommand {
@IsDefined()
envelopeTo: IEnvelopeTo[];
}
-
-export interface IHeaders {
- 'content-type': string;
- from: string;
- to: string;
- subject: string;
- 'message-id': string;
- date: string;
- 'mime-version': string;
-}
-
-export interface IFrom {
- address: string;
- name: string;
-}
-
-export interface ITo {
- address: string;
- name: string;
-}
-
-export interface ITlsOptions {
- name: string;
- standardName: string;
- version: string;
-}
-
-export interface IMailFrom {
- address: string;
- args: boolean;
-}
-
-export interface IRcptTo {
- address: string;
- args: boolean;
-}
-
-export interface IEnvelope {
- mailFrom: IMailFrom;
- rcptTo: IRcptTo[];
-}
-
-export interface IConnection {
- id: string;
- remoteAddress: string;
- remotePort: number;
- clientHostname: string;
- openingCommand: string;
- hostNameAppearsAs: string;
- xClient: any;
- xForward: any;
- transmissionType: string;
- tlsOptions: ITlsOptions;
- envelope: IEnvelope;
- transaction: number;
- mailPath: string;
-}
-
-export interface IEnvelopeFrom {
- address: string;
- args: boolean;
-}
-
-export interface IEnvelopeTo {
- address: string;
- args: boolean;
-}
diff --git a/apps/api/src/app/integrations/dtos/credentials.dto.ts b/apps/api/src/app/integrations/dtos/credentials.dto.ts
index eaed4cdc65f..95049a0a749 100644
--- a/apps/api/src/app/integrations/dtos/credentials.dto.ts
+++ b/apps/api/src/app/integrations/dtos/credentials.dto.ts
@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
+import { ICredentials } from '@novu/shared';
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
import { TransformToBoolean } from '../../shared/transformers/to-boolean';
-import { ICredentials } from '@novu/shared';
export class CredentialsDto implements ICredentials {
@ApiPropertyOptional()
@@ -166,4 +166,34 @@ export class CredentialsDto implements ICredentials {
@IsString()
@IsOptional()
authenticationTokenKey?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ instanceId?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ alertUid?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ title?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ imageUrl?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ state?: string;
+
+ @ApiPropertyOptional()
+ @IsString()
+ @IsOptional()
+ externalLink?: string;
}
diff --git a/apps/api/src/app/integrations/e2e/get-webhook-support-status.e2e.ts b/apps/api/src/app/integrations/e2e/get-webhook-support-status.e2e.ts
new file mode 100644
index 00000000000..4c799558bc2
--- /dev/null
+++ b/apps/api/src/app/integrations/e2e/get-webhook-support-status.e2e.ts
@@ -0,0 +1,154 @@
+import { expect } from 'chai';
+import { UserSession } from '@novu/testing';
+import {
+ ChannelTypeEnum,
+ ChatProviderIdEnum,
+ EmailProviderIdEnum,
+ InAppProviderIdEnum,
+ PushProviderIdEnum,
+ SmsProviderIdEnum,
+} from '@novu/shared';
+import { IntegrationEntity, IntegrationRepository } from '@novu/dal';
+
+describe('Get Webhook Support Status - /webhook/provider/:providerOrIntegrationId/status (GET)', function () {
+ let session: UserSession;
+ const integrationRepository = new IntegrationRepository();
+
+ const checkBadRequestIntegration = async (integration: IntegrationEntity, message: string) => {
+ const { body } = await session.testAgent.get(`/v1/integrations/webhook/provider/${integration._id}/status`);
+ expect(body.statusCode).to.equal(400);
+ expect(body.error).to.equal('Bad Request');
+ expect(body.message).to.equal(message);
+ };
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await integrationRepository.deleteMany({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+ });
+
+ it("should throw not found error when integration doesn't exist", async () => {
+ const notExistingIntegrationId = IntegrationRepository.createObjectId();
+
+ const { body } = await session.testAgent.get(
+ `/v1/integrations/webhook/provider/${notExistingIntegrationId}/status`
+ );
+ expect(body.statusCode).to.equal(404);
+ expect(body.error).to.equal('Not Found');
+ expect(body.message).to.equal(`Integration for ${notExistingIntegrationId} was not found`);
+ });
+
+ it("should throw bad request error when integration doesn't have credentials", async () => {
+ const integration = await integrationRepository.create({
+ name: 'Test',
+ identifier: 'sendgrid',
+ providerId: EmailProviderIdEnum.SendGrid,
+ channel: ChannelTypeEnum.EMAIL,
+ active: false,
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+
+ await checkBadRequestIntegration(integration, `Integration ${integration._id} doesn't have credentials set up`);
+ });
+
+ it('should throw bad request error for chat, push, in-app integrations', async () => {
+ const slackIntegration = await integrationRepository.create({
+ name: 'Slack',
+ identifier: 'slack',
+ providerId: ChatProviderIdEnum.Slack,
+ channel: ChannelTypeEnum.CHAT,
+ active: true,
+ credentials: {
+ apiKey: '',
+ },
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+ await checkBadRequestIntegration(
+ slackIntegration,
+ `Webhook for ${slackIntegration.providerId}-${slackIntegration.channel} is not supported yet`
+ );
+
+ const fcmIntegration = await integrationRepository.create({
+ name: 'FCM',
+ identifier: 'push',
+ providerId: PushProviderIdEnum.FCM,
+ channel: ChannelTypeEnum.PUSH,
+ active: true,
+ credentials: {
+ apiKey: '',
+ },
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+ await checkBadRequestIntegration(
+ fcmIntegration,
+ `Webhook for ${fcmIntegration.providerId}-${fcmIntegration.channel} is not supported yet`
+ );
+
+ const novuIntegration = await integrationRepository.create({
+ name: 'Novu',
+ identifier: 'novu',
+ providerId: InAppProviderIdEnum.Novu,
+ channel: ChannelTypeEnum.IN_APP,
+ active: true,
+ credentials: {
+ apiKey: '',
+ },
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+ await checkBadRequestIntegration(
+ novuIntegration,
+ `Webhook for ${novuIntegration.providerId}-${novuIntegration.channel} is not supported yet`
+ );
+ });
+
+ it('should return true if provider supports parsing events', async () => {
+ const integration = await integrationRepository.create({
+ name: 'Test',
+ identifier: 'sendgrid',
+ providerId: EmailProviderIdEnum.SendGrid,
+ channel: ChannelTypeEnum.EMAIL,
+ active: true,
+ credentials: {
+ apiKey: '',
+ },
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+
+ const { body, statusCode } = await session.testAgent.get(
+ `/v1/integrations/webhook/provider/${integration._id}/status`
+ );
+ expect(statusCode).to.equal(200);
+ expect(body.data).to.equal(true);
+ });
+
+ it("should return false if provider doesn't support parsing events", async () => {
+ const integration = await integrationRepository.create({
+ name: 'AfricasTalking',
+ identifier: 'africastalking',
+ providerId: SmsProviderIdEnum.AfricasTalking,
+ channel: ChannelTypeEnum.SMS,
+ active: true,
+ credentials: {
+ apiKey: 'asdf',
+ user: 'asdf',
+ from: 'asdf',
+ },
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ });
+
+ const { body, statusCode } = await session.testAgent.get(
+ `/v1/integrations/webhook/provider/${integration._id}/status`
+ );
+ expect(statusCode).to.equal(200);
+ expect(body.data).to.equal(false);
+ });
+});
diff --git a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts
index 398193e68f1..57464cddec7 100644
--- a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts
+++ b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts
@@ -5,6 +5,7 @@ import {
ChannelTypeEnum,
ChatProviderIdEnum,
EmailProviderIdEnum,
+ FieldOperatorEnum,
InAppProviderIdEnum,
ITenantFilterPart,
PushProviderIdEnum,
@@ -69,7 +70,7 @@ describe('Update Integration - /integrations/:integrationId (PUT)', function ()
check: false,
conditions: [
{
- children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }],
+ children: [{ field: 'identifier', value: 'test', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }],
},
],
};
diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts
index 0b3ecb9043a..21812a2da34 100644
--- a/apps/api/src/app/integrations/integrations.controller.ts
+++ b/apps/api/src/app/integrations/integrations.controller.ts
@@ -13,9 +13,9 @@ import {
} from '@nestjs/common';
import { ChannelTypeEnum, IJwtPayload, MemberRoleEnum } from '@novu/shared';
import { CalculateLimitNovuIntegration, CalculateLimitNovuIntegrationCommand } from '@novu/application-generic';
-import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { UserSession } from '../shared/framework/user.decorator';
import { CreateIntegration } from './usecases/create-integration/create-integration.usecase';
import { CreateIntegrationRequestDto } from './dtos/create-integration-request.dto';
@@ -35,15 +35,21 @@ import { GetWebhookSupportStatus } from './usecases/get-webhook-support-status/g
import { GetWebhookSupportStatusCommand } from './usecases/get-webhook-support-status/get-webhook-support-status.command';
import { GetInAppActivatedCommand } from './usecases/get-in-app-activated/get-in-app-activated.command';
import { GetInAppActivated } from './usecases/get-in-app-activated/get-in-app-activated.usecase';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import {
+ ApiCommonResponses,
+ ApiResponse,
+ ApiNotFoundResponse,
+ ApiOkResponse,
+} from '../shared/framework/response.decorator';
import { ChannelTypeLimitDto } from './dtos/get-channel-type-limit.sto';
import { GetActiveIntegrationsCommand } from './usecases/get-active-integration/get-active-integration.command';
import { SetIntegrationAsPrimary } from './usecases/set-integration-as-primary/set-integration-as-primary.usecase';
import { SetIntegrationAsPrimaryCommand } from './usecases/set-integration-as-primary/set-integration-as-primary.command';
+@ApiCommonResponses()
@Controller('/integrations')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Integrations')
export class IntegrationsController {
constructor(
@@ -60,7 +66,7 @@ export class IntegrationsController {
@Get('/')
@ApiOkResponse({
- type: IntegrationResponseDto,
+ type: [IntegrationResponseDto],
description: 'The list of integrations belonging to the organization that are successfully returned.',
})
@ApiOperation({
@@ -81,7 +87,7 @@ export class IntegrationsController {
@Get('/active')
@ApiOkResponse({
- type: IntegrationResponseDto,
+ type: [IntegrationResponseDto],
description: 'The list of active integrations belonging to the organization that are successfully returned.',
})
@ApiOperation({
@@ -100,7 +106,7 @@ export class IntegrationsController {
);
}
- @Get('/webhook/provider/:providerId/status')
+ @Get('/webhook/provider/:providerOrIntegrationId/status')
@ApiOkResponse({
type: Boolean,
description: 'The status of the webhook for the provider requested',
@@ -113,13 +119,13 @@ export class IntegrationsController {
@ExternalApiAccessible()
async getWebhookSupportStatus(
@UserSession() user: IJwtPayload,
- @Param('providerId') providerId: string
+ @Param('providerOrIntegrationId') providerOrIntegrationId: string
): Promise {
return await this.getWebhookSupportStatusUsecase.execute(
GetWebhookSupportStatusCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
- providerId: providerId,
+ providerOrIntegrationId,
userId: user._id,
})
);
@@ -246,7 +252,7 @@ export class IntegrationsController {
}
@Get('/:channelType/limit')
- @ApiResponse(ChannelTypeLimitDto)
+ @ApiExcludeEndpoint()
async getProviderLimit(
@UserSession() user: IJwtPayload,
@Param('channelType') channelType: ChannelTypeEnum
@@ -267,6 +273,7 @@ export class IntegrationsController {
}
@Get('/in-app/status')
+ @ApiExcludeEndpoint()
async getInAppActivated(@UserSession() user: IJwtPayload) {
return await this.getInAppActivatedUsecase.execute(
GetInAppActivatedCommand.create({
diff --git a/apps/api/src/app/integrations/integrations.module.ts b/apps/api/src/app/integrations/integrations.module.ts
index 7530d6c86db..e1ef63f12f9 100644
--- a/apps/api/src/app/integrations/integrations.module.ts
+++ b/apps/api/src/app/integrations/integrations.module.ts
@@ -4,11 +4,13 @@ import { SharedModule } from '../shared/shared.module';
import { USE_CASES } from './usecases';
import { IntegrationsController } from './integrations.controller';
import { AuthModule } from '../auth/auth.module';
+import { CompileTemplate, CreateExecutionDetails, QueuesModule } from '@novu/application-generic';
+import { JobTopicNameEnum } from '@novu/shared';
@Module({
imports: [SharedModule, forwardRef(() => AuthModule)],
controllers: [IntegrationsController],
- providers: [...USE_CASES],
+ providers: [...USE_CASES, CompileTemplate, CreateExecutionDetails],
exports: [...USE_CASES],
})
export class IntegrationModule {}
diff --git a/apps/api/src/app/integrations/usecases/check-integration/check-integration.usecase.ts b/apps/api/src/app/integrations/usecases/check-integration/check-integration.usecase.ts
index 12e1142346d..b3a6f66e646 100644
--- a/apps/api/src/app/integrations/usecases/check-integration/check-integration.usecase.ts
+++ b/apps/api/src/app/integrations/usecases/check-integration/check-integration.usecase.ts
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CheckIntegrationEMail } from './check-integration-email.usecase';
import { CheckIntegrationCommand } from './check-integration.command';
-import { ChannelTypeEnum, ICredentialsDto } from '@novu/shared';
+import { ChannelTypeEnum } from '@novu/shared';
@Injectable()
export class CheckIntegration {
diff --git a/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.command.ts b/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.command.ts
index 1d4248a57ed..74cf58da2c7 100644
--- a/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.command.ts
+++ b/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.command.ts
@@ -3,5 +3,5 @@ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.com
export class GetWebhookSupportStatusCommand extends EnvironmentWithUserCommand {
@IsString()
- providerId: string;
+ providerOrIntegrationId: string;
}
diff --git a/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.usecase.ts b/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.usecase.ts
index 35b62c53276..cfe00fc3321 100644
--- a/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.usecase.ts
+++ b/apps/api/src/app/integrations/usecases/get-webhook-support-status/get-webhook-support-status.usecase.ts
@@ -1,8 +1,9 @@
import { Injectable, NotFoundException, Scope } from '@nestjs/common';
-import { IntegrationEntity, IntegrationRepository } from '@novu/dal';
+import { IntegrationEntity, IntegrationQuery, IntegrationRepository } from '@novu/dal';
import { IEmailProvider, ISmsProvider } from '@novu/stateless';
import { IMailHandler, ISmsHandler, MailFactory, SmsFactory } from '@novu/application-generic';
-import { ChannelTypeEnum } from '@novu/shared';
+import { ChannelTypeEnum, providers } from '@novu/shared';
+
import { GetWebhookSupportStatusCommand } from './get-webhook-support-status.command';
import { ApiException } from '../../../shared/exceptions/api.exception';
@@ -15,14 +16,17 @@ export class GetWebhookSupportStatus {
constructor(private integrationRepository: IntegrationRepository) {}
async execute(command: GetWebhookSupportStatusCommand): Promise {
- const { providerId } = command;
-
const integration = await this.getIntegration(command);
if (!integration) {
- throw new NotFoundException(`Integration for ${providerId} was not found`);
+ throw new NotFoundException(`Integration for ${command.providerOrIntegrationId} was not found`);
+ }
+
+ const hasNoCredentials = !integration.credentials || Object.keys(integration.credentials).length === 0;
+ if (hasNoCredentials) {
+ throw new ApiException(`Integration ${integration._id} doesn't have credentials set up`);
}
- const { channel } = integration;
+ const { channel, providerId } = integration;
if (![ChannelTypeEnum.EMAIL, ChannelTypeEnum.SMS].includes(channel)) {
throw new ApiException(`Webhook for ${providerId}-${channel} is not supported yet`);
}
@@ -35,12 +39,20 @@ export class GetWebhookSupportStatus {
return true;
}
+
private async getIntegration(command: GetWebhookSupportStatusCommand) {
- return await this.integrationRepository.findOne({
+ const providerOrIntegrationId = command.providerOrIntegrationId;
+ const isProviderId = !!providers.find((el) => el.id === providerOrIntegrationId);
+
+ const query: IntegrationQuery = {
+ ...(isProviderId
+ ? { providerId: providerOrIntegrationId, credentials: { $exists: true } }
+ : { _id: providerOrIntegrationId }),
_environmentId: command.environmentId,
_organizationId: command.organizationId,
- providerId: command.providerId,
- });
+ };
+
+ return await this.integrationRepository.findOne(query);
}
private getHandler(integration: IntegrationEntity): ISmsHandler | IMailHandler | null {
diff --git a/apps/api/src/app/invites/dtos/invite-member.dto.ts b/apps/api/src/app/invites/dtos/invite-member.dto.ts
index af794d931ad..fa69023b90e 100644
--- a/apps/api/src/app/invites/dtos/invite-member.dto.ts
+++ b/apps/api/src/app/invites/dtos/invite-member.dto.ts
@@ -1,11 +1,7 @@
-import { IsEmail, IsEnum, IsNotEmpty } from 'class-validator';
-import { MemberRoleEnum } from '@novu/shared';
+import { IsEmail, IsNotEmpty } from 'class-validator';
export class InviteMemberDto {
@IsEmail()
@IsNotEmpty()
email: string;
-
- @IsEnum(MemberRoleEnum)
- role: MemberRoleEnum;
}
diff --git a/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts b/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts
index 8fb3bb01d63..a8a639ac4ca 100644
--- a/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts
+++ b/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts
@@ -65,38 +65,12 @@ describe('Bulk invite members - /invites/bulk (POST)', async () => {
expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED);
});
- it('should invite member as member', async () => {
- session = new UserSession();
- await session.initialize();
-
- const { body } = await session.testAgent
- .post('/v1/invites/bulk')
- .send({
- invitees: [
- {
- email: 'aaaaa2@asdas.com',
- role: 'member',
- },
- ],
- })
- .expect(201);
-
- const members = await memberRepository.getOrganizationMembers(session.organization._id);
-
- expect(members.length).to.eq(2);
-
- const member = members.find((i) => !i._userId);
-
- expect(member.roles[0]).to.equal(MemberRoleEnum.MEMBER);
- expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED);
- });
-
describe('send valid invites', () => {
let inviteResponse: IBulkInviteResponse[];
const invitee = {
email: 'asdasda@asdas.com',
- role: 'member',
+ role: 'admin',
};
before(async () => {
@@ -129,7 +103,7 @@ describe('Bulk invite members - /invites/bulk (POST)', async () => {
expect(member.invite.email).to.equal(invitee.email);
expect(member.invite._inviterId).to.equal(session.user._id);
expect(member.roles.length).to.equal(1);
- expect(member.roles[0]).to.equal(MemberRoleEnum.MEMBER);
+ expect(member.roles[0]).to.equal(MemberRoleEnum.ADMIN);
expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED);
expect(member._userId).to.be.not.ok;
diff --git a/apps/api/src/app/invites/invites.controller.ts b/apps/api/src/app/invites/invites.controller.ts
index b4f549bc75a..053dc223b92 100644
--- a/apps/api/src/app/invites/invites.controller.ts
+++ b/apps/api/src/app/invites/invites.controller.ts
@@ -9,7 +9,13 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
-import { IBulkInviteResponse, IGetInviteResponseDto, IJwtPayload, MemberRoleEnum } from '@novu/shared';
+import {
+ ApiRateLimitCostEnum,
+ IBulkInviteResponse,
+ IGetInviteResponseDto,
+ IJwtPayload,
+ MemberRoleEnum,
+} from '@novu/shared';
import { UserSession } from '../shared/framework/user.decorator';
import { GetInviteCommand } from './usecases/get-invite/get-invite.command';
import { AcceptInviteCommand } from './usecases/accept-invite/accept-invite.command';
@@ -26,8 +32,12 @@ import { ResendInviteDto } from './dtos/resend-invite.dto';
import { ResendInviteCommand } from './usecases/resend-invite/resend-invite.command';
import { ResendInvite } from './usecases/resend-invite/resend-invite.usecase';
import { ApiExcludeController, ApiTags } from '@nestjs/swagger';
+import { ThrottlerCost } from '../rate-limiting/guards';
+import { ApiCommonResponses } from '../shared/framework/response.decorator';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
@UseInterceptors(ClassSerializerInterceptor)
+@ApiCommonResponses()
@Controller('/invites')
@ApiTags('Invites')
@ApiExcludeController()
@@ -50,7 +60,7 @@ export class InvitesController {
}
@Post('/:inviteToken/accept')
- @UseGuards(AuthGuard('jwt'))
+ @UseGuards(UserAuthGuard)
async acceptInviteToken(
@UserSession() user: IJwtPayload,
@Param('inviteToken') inviteToken: string
@@ -65,13 +75,13 @@ export class InvitesController {
@Post('/')
@Roles(MemberRoleEnum.ADMIN)
- @UseGuards(AuthGuard('jwt'))
+ @UseGuards(UserAuthGuard)
async inviteMember(@UserSession() user: IJwtPayload, @Body() body: InviteMemberDto): Promise<{ success: boolean }> {
const command = InviteMemberCommand.create({
userId: user._id,
organizationId: user.organizationId,
email: body.email,
- role: body.role,
+ role: MemberRoleEnum.ADMIN,
});
await this.inviteMemberUsecase.execute(command);
@@ -83,7 +93,7 @@ export class InvitesController {
@Post('/resend')
@Roles(MemberRoleEnum.ADMIN)
- @UseGuards(AuthGuard('jwt'))
+ @UseGuards(UserAuthGuard)
async resendInviteMember(
@UserSession() user: IJwtPayload,
@Body() body: ResendInviteDto
@@ -101,8 +111,9 @@ export class InvitesController {
};
}
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
@Post('/bulk')
- @UseGuards(AuthGuard('jwt'))
+ @UseGuards(UserAuthGuard)
@Roles(MemberRoleEnum.ADMIN)
async bulkInviteMembers(
@UserSession() user: IJwtPayload,
diff --git a/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts
index 91326aab3b2..4daf94a75d9 100644
--- a/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts
+++ b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts
@@ -25,7 +25,7 @@ export class BulkInvite {
await this.inviteMemberUsecase.execute(
InviteMemberCommand.create({
email: invitee.email,
- role: invitee.role || MemberRoleEnum.MEMBER,
+ role: MemberRoleEnum.ADMIN,
organizationId: command.organizationId,
userId: command.userId,
})
diff --git a/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts b/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts
index a8f05754e9c..bcb9eaecfd7 100644
--- a/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts
+++ b/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts
@@ -7,6 +7,5 @@ export class InviteMemberCommand extends OrganizationCommand {
readonly email: string;
@IsDefined()
- @IsEnum(MemberRoleEnum)
readonly role: MemberRoleEnum;
}
diff --git a/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts
index 75a8e936576..98aaf7f7c02 100644
--- a/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts
+++ b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts
@@ -1,6 +1,6 @@
-import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
+import { Injectable, NotFoundException, Scope } from '@nestjs/common';
import { OrganizationRepository, UserRepository, MemberRepository, IAddMemberData } from '@novu/dal';
-import { MemberStatusEnum } from '@novu/shared';
+import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared';
import { Novu } from '@novu/node';
import { AnalyticsService } from '@novu/application-generic';
@@ -8,8 +8,6 @@ import { ApiException } from '../../../shared/exceptions/api.exception';
import { InviteMemberCommand } from './invite-member.command';
import { capitalize, createGuid } from '../../../shared/services/helper/helper.service';
-import { normalizeEmail } from '../../../shared/helpers/email-normalization.service';
-
@Injectable({
scope: Scope.REQUEST,
})
@@ -54,7 +52,7 @@ export class InviteMember {
}
const memberPayload: IAddMemberData = {
- roles: [command.role],
+ roles: [command.role as MemberRoleEnum],
memberStatus: MemberStatusEnum.INVITED,
invite: {
token,
diff --git a/apps/api/src/app/layouts/layouts.controller.ts b/apps/api/src/app/layouts/layouts.controller.ts
index 55d8b70bbf2..662e0da1a31 100644
--- a/apps/api/src/app/layouts/layouts.controller.ts
+++ b/apps/api/src/app/layouts/layouts.controller.ts
@@ -13,17 +13,16 @@ import {
UseGuards,
Logger,
} from '@nestjs/common';
+import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
+ ApiCommonResponses,
+ ApiResponse,
ApiBadRequestResponse,
ApiConflictResponse,
- ApiCreatedResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
- ApiOperation,
- ApiQuery,
- ApiTags,
-} from '@nestjs/swagger';
+} from '../shared/framework/response.decorator';
import { IJwtPayload } from '@novu/shared';
import { GetLayoutCommand, GetLayoutUseCase } from '@novu/application-generic';
@@ -50,14 +49,14 @@ import {
} from './usecases';
import { LayoutId } from './types';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { UserSession } from '../shared/framework/user.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/layouts')
@ApiTags('Layouts')
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
export class LayoutsController {
constructor(
private createLayoutUseCase: CreateLayoutUseCase,
@@ -210,6 +209,7 @@ export class LayoutsController {
@ApiConflictResponse({
description:
'One default layout is needed. If you are trying to turn a default layout as not default, you should turn a different layout as default first and automatically it will be done by the system.',
+ schema: { example: `One default layout is required` },
})
@ApiOperation({
summary: 'Update a layout',
diff --git a/apps/api/src/app/layouts/usecases/create-default-layout-change/create-default-layout-change.usecase.ts b/apps/api/src/app/layouts/usecases/create-default-layout-change/create-default-layout-change.usecase.ts
index b52876da239..77c0abba350 100644
--- a/apps/api/src/app/layouts/usecases/create-default-layout-change/create-default-layout-change.usecase.ts
+++ b/apps/api/src/app/layouts/usecases/create-default-layout-change/create-default-layout-change.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
import { ChangeRepository, LayoutEntity, LayoutRepository } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { LayoutDto } from '../../dtos';
import { FindDeletedLayoutCommand, FindDeletedLayoutUseCase } from '../find-deleted-layout';
import { CreateDefaultLayoutChangeCommand } from './create-default-layout-change.command';
diff --git a/apps/api/src/app/layouts/usecases/create-layout-change/create-layout-change.use-case.ts b/apps/api/src/app/layouts/usecases/create-layout-change/create-layout-change.use-case.ts
index 5ffc2ff12ec..e0c3922a09e 100644
--- a/apps/api/src/app/layouts/usecases/create-layout-change/create-layout-change.use-case.ts
+++ b/apps/api/src/app/layouts/usecases/create-layout-change/create-layout-change.use-case.ts
@@ -5,8 +5,7 @@ import { Injectable } from '@nestjs/common';
import { CreateLayoutChangeCommand } from './create-layout-change.command';
import { FindDeletedLayoutCommand, FindDeletedLayoutUseCase } from '../find-deleted-layout';
-
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
@Injectable()
export class CreateLayoutChangeUseCase {
diff --git a/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts b/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts
index 1af3d689648..e4b7b9d6a4f 100644
--- a/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts
+++ b/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts
@@ -24,4 +24,40 @@ describe('HTML Sanitizer', function () {
]);
expect(result[0].content).to.equal('hello bold ');
});
+
+ it('should NOT sanitize style tags', function () {
+ const result = sanitizeMessageContent([
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'Red Text
',
+ url: '',
+ },
+ ]);
+
+ expect(result[0].content).to.equal('Red Text
');
+ });
+
+ it('should NOT sanitize style attributes', function () {
+ const result = sanitizeMessageContent([
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'Red Text
',
+ url: '',
+ },
+ ]);
+
+ expect(result[0].content).to.equal('Red Text
');
+ });
+
+ it('should NOT format style attributes', function () {
+ const result = sanitizeMessageContent([
+ {
+ type: EmailBlockTypeEnum.TEXT,
+ content: 'Red Text
',
+ url: '',
+ },
+ ]);
+
+ expect(result[0].content).to.equal('Red Text
');
+ });
});
diff --git a/apps/api/src/app/message-template/shared/sanitizer.service.ts b/apps/api/src/app/message-template/shared/sanitizer.service.ts
index a28b3d4143a..ea2651d5798 100644
--- a/apps/api/src/app/message-template/shared/sanitizer.service.ts
+++ b/apps/api/src/app/message-template/shared/sanitizer.service.ts
@@ -1,10 +1,44 @@
import * as sanitize from 'sanitize-html';
import { IEmailBlock } from '@novu/shared';
+/**
+ * Options for the sanitize-html library.
+ *
+ * @see https://www.npmjs.com/package/sanitize-html#default-options
+ */
+const sanitizeOptions: sanitize.IOptions = {
+ /**
+ * Additional tags to allow.
+ */
+ allowedTags: sanitize.defaults.allowedTags.concat(['style']),
+ allowedAttributes: {
+ ...sanitize.defaults.allowedAttributes,
+ /**
+ * Additional attributes to allow on all tags.
+ */
+ '*': ['style'],
+ },
+ /**
+ * Required to disable console warnings when allowing style tags.
+ *
+ * We are allowing style tags to support the use of styles in the In-App Editor.
+ * This is a known security risk through an XSS attack vector,
+ * but we are accepting this risk by dropping support for IE11.
+ *
+ * @see https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html#remote-style-sheet
+ */
+ allowVulnerableTags: true,
+ /**
+ * Required to disable formatting of style attributes. This is useful to retain
+ * formatting of style attributes in the In-App Editor.
+ */
+ parseStyleAttributes: false,
+};
+
export function sanitizeHTML(html: string) {
if (!html) return html;
- return sanitize(html);
+ return sanitize(html, sanitizeOptions);
}
export function sanitizeMessageContent(content: string | IEmailBlock[]) {
diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts
index 6839e089a18..1fb46fac7af 100644
--- a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts
+++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts
@@ -4,10 +4,10 @@ import { ChangeEntityTypeEnum } from '@novu/shared';
import { CreateMessageTemplateCommand } from './create-message-template.command';
import { sanitizeMessageContent } from '../../shared/sanitizer.service';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { UpdateChange } from '../../../change/usecases/update-change/update-change';
import { UpdateChangeCommand } from '../../../change/usecases/update-change/update-change.command';
import { UpdateMessageTemplate } from '../update-message-template/update-message-template.usecase';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
@Injectable()
export class CreateMessageTemplate {
diff --git a/apps/api/src/app/message-template/usecases/delete-message-template/delete-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/delete-message-template/delete-message-template.usecase.ts
index 8375ba03e97..50a8795077f 100644
--- a/apps/api/src/app/message-template/usecases/delete-message-template/delete-message-template.usecase.ts
+++ b/apps/api/src/app/message-template/usecases/delete-message-template/delete-message-template.usecase.ts
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
-import { ChangeRepository, DalException, MessageTemplateEntity, MessageTemplateRepository } from '@novu/dal';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
+import { ChangeRepository, DalException, MessageTemplateRepository } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { ApiException } from '../../../shared/exceptions/api.exception';
import { DeleteMessageTemplateCommand } from './delete-message-template.command';
diff --git a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts
index 413ce36dc61..bcac216d41f 100644
--- a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts
+++ b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts
@@ -4,9 +4,9 @@ import { ChangeEntityTypeEnum, ITemplateVariable } from '@novu/shared';
import { UpdateMessageTemplateCommand } from './update-message-template.command';
import { sanitizeMessageContent } from '../../shared/sanitizer.service';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { UpdateChangeCommand } from '../../../change/usecases/update-change/update-change.command';
import { UpdateChange } from '../../../change/usecases/update-change/update-change';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
@Injectable()
export class UpdateMessageTemplate {
diff --git a/apps/api/src/app/messages/messages.controller.ts b/apps/api/src/app/messages/messages.controller.ts
index af0e40bdb0b..9d4ba6db83f 100644
--- a/apps/api/src/app/messages/messages.controller.ts
+++ b/apps/api/src/app/messages/messages.controller.ts
@@ -1,21 +1,27 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query, UseGuards } from '@nestjs/common';
import { RemoveMessage, RemoveMessageCommand } from './usecases/remove-message';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { IJwtPayload } from '@novu/shared';
-import { ApiTags, ApiOkResponse, ApiOperation, ApiParam, ApiNoContentResponse } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger';
import { DeleteMessageResponseDto } from './dtos/delete-message-response.dto';
import { ActivitiesResponseDto } from '../notifications/dtos/activities-response.dto';
import { GetMessages, GetMessagesCommand } from './usecases/get-messages';
import { MessagesResponseDto } from '../widgets/dtos/message-response.dto';
import { DeleteMessageParams } from './params/delete-message.param';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import {
+ ApiCommonResponses,
+ ApiResponse,
+ ApiNoContentResponse,
+ ApiOkResponse,
+} from '../shared/framework/response.decorator';
import { GetMessagesRequestDto } from './dtos/get-messages-requests.dto';
import { RemoveMessagesByTransactionId } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.usecase';
import { RemoveMessagesByTransactionIdCommand } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.command';
import { DeleteMessageByTransactionIdRequestDto } from './dtos/remove-messages-by-transactionId-request.dto';
+@ApiCommonResponses()
@Controller('/messages')
@ApiTags('Messages')
export class MessagesController {
@@ -27,7 +33,7 @@ export class MessagesController {
@Get('')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiOkResponse({
type: ActivitiesResponseDto,
})
@@ -59,7 +65,7 @@ export class MessagesController {
@Delete('/:messageId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(DeleteMessageResponseDto)
@ApiOperation({
summary: 'Delete message',
@@ -82,7 +88,7 @@ export class MessagesController {
@Delete('/transaction/:transactionId')
@HttpCode(HttpStatus.NO_CONTENT)
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiNoContentResponse()
@ApiOperation({
summary: 'Delete messages by transactionId',
diff --git a/apps/api/src/app/notification-groups/notification-groups.controller.ts b/apps/api/src/app/notification-groups/notification-groups.controller.ts
index 5793cc8cff6..2882a6e8b36 100644
--- a/apps/api/src/app/notification-groups/notification-groups.controller.ts
+++ b/apps/api/src/app/notification-groups/notification-groups.controller.ts
@@ -15,7 +15,7 @@ import { CreateNotificationGroup } from './usecases/create-notification-group/cr
import { UserSession } from '../shared/framework/user.decorator';
import { CreateNotificationGroupCommand } from './usecases/create-notification-group/create-notification-group.command';
import { CreateNotificationGroupRequestDto } from './dtos/create-notification-group-request.dto';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { GetNotificationGroups } from './usecases/get-notification-groups/get-notification-groups.usecase';
import { GetNotificationGroupsCommand } from './usecases/get-notification-groups/get-notification-groups.command';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@@ -28,11 +28,12 @@ import { DeleteNotificationGroupCommand } from './usecases/delete-notification-g
import { DeleteNotificationGroupResponseDto } from './dtos/delete-notification-group-response.dto';
import { UpdateNotificationGroupCommand } from './usecases/update-notification-group/update-notification-group.command';
import { UpdateNotificationGroup } from './usecases/update-notification-group/update-notification-group.usecase';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/notification-groups')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Workflow groups')
export class NotificationGroupsController {
constructor(
diff --git a/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts
index 899221067fc..c3b4d97ebf0 100644
--- a/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts
+++ b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
+import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
import { NotificationGroupRepository, NotificationGroupEntity } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
import { CreateNotificationGroupCommand } from './create-notification-group.command';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases/create-change';
-
@Injectable()
export class CreateNotificationGroup {
constructor(private notificationGroupRepository: NotificationGroupRepository, private createChange: CreateChange) {}
diff --git a/apps/api/src/app/notifications/notification.controller.ts b/apps/api/src/app/notifications/notification.controller.ts
index 7c9dc26cf36..f0a3bb609ec 100644
--- a/apps/api/src/app/notifications/notification.controller.ts
+++ b/apps/api/src/app/notifications/notification.controller.ts
@@ -1,5 +1,5 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
-import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ChannelTypeEnum, IJwtPayload } from '@novu/shared';
import { GetActivityFeed } from './usecases/get-activity-feed/get-activity-feed.usecase';
@@ -16,9 +16,10 @@ import { GetActivityCommand } from './usecases/get-activity/get-activity.command
import { UserSession } from '../shared/framework/user.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
+import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/notifications')
@ApiTags('Notification')
export class NotificationsController {
@@ -36,7 +37,7 @@ export class NotificationsController {
@ApiOperation({
summary: 'Get notifications',
})
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ExternalApiAccessible()
getNotifications(
@UserSession() user: IJwtPayload,
@@ -84,7 +85,7 @@ export class NotificationsController {
summary: 'Get notification statistics',
})
@Get('/stats')
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ExternalApiAccessible()
getActivityStats(@UserSession() user: IJwtPayload): Promise {
return this.getActivityStatsUsecase.execute(
@@ -96,7 +97,7 @@ export class NotificationsController {
}
@Get('/graph/stats')
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ExternalApiAccessible()
@ApiResponse(ActivityGraphStatesResponse, 200, true)
@ApiOperation({
@@ -126,7 +127,7 @@ export class NotificationsController {
@ApiOperation({
summary: 'Get notification',
})
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ExternalApiAccessible()
getActivity(
@UserSession() user: IJwtPayload,
diff --git a/apps/api/src/app/organization/dtos/create-organization.dto.ts b/apps/api/src/app/organization/dtos/create-organization.dto.ts
index c485674e7cb..8a11687f979 100644
--- a/apps/api/src/app/organization/dtos/create-organization.dto.ts
+++ b/apps/api/src/app/organization/dtos/create-organization.dto.ts
@@ -1,11 +1,23 @@
-import { IsOptional, IsString } from 'class-validator';
-import { ICreateOrganizationDto } from '@novu/shared';
+import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';
+import { ICreateOrganizationDto, JobTitleEnum, ProductUseCases, ProductUseCasesEnum } from '@novu/shared';
export class CreateOrganizationDto implements ICreateOrganizationDto {
@IsString()
+ @IsDefined()
name: string;
@IsString()
@IsOptional()
logo?: string;
+
+ @IsOptional()
+ @IsEnum(JobTitleEnum)
+ jobTitle?: JobTitleEnum;
+
+ @IsString()
+ @IsOptional()
+ domain?: string;
+
+ @IsOptional()
+ productUseCases?: ProductUseCases;
}
diff --git a/apps/api/src/app/organization/dtos/update-branding-details.dto.ts b/apps/api/src/app/organization/dtos/update-branding-details.dto.ts
index 4ff5d8f2935..17ce1e4c733 100644
--- a/apps/api/src/app/organization/dtos/update-branding-details.dto.ts
+++ b/apps/api/src/app/organization/dtos/update-branding-details.dto.ts
@@ -1,7 +1,14 @@
import { IsHexColor, IsOptional, IsString, IsUrl } from 'class-validator';
+import { IsImageUrl } from '../../shared/validators/image.validator';
export class UpdateBrandingDetailsDto {
- @IsUrl({ require_tld: false })
+ @IsUrl({
+ require_protocol: true,
+ protocols: ['https'],
+ })
+ @IsImageUrl({
+ message: 'Logo must be a valid image URL with one of the following extensions: jpg, jpeg, png, gif, svg',
+ })
@IsOptional()
logo: string;
diff --git a/apps/api/src/app/organization/dtos/update-member-roles.dto.ts b/apps/api/src/app/organization/dtos/update-member-roles.dto.ts
index 554344b519a..d04a019c3ec 100644
--- a/apps/api/src/app/organization/dtos/update-member-roles.dto.ts
+++ b/apps/api/src/app/organization/dtos/update-member-roles.dto.ts
@@ -3,5 +3,5 @@ import { MemberRoleEnum } from '@novu/shared';
export class UpdateMemberRolesDto {
@IsEnum(MemberRoleEnum)
- role: MemberRoleEnum;
+ role: MemberRoleEnum.ADMIN;
}
diff --git a/apps/api/src/app/organization/e2e/create-organization.e2e.ts b/apps/api/src/app/organization/e2e/create-organization.e2e.ts
index 61a6f44469f..bbc5e00cc0c 100644
--- a/apps/api/src/app/organization/e2e/create-organization.e2e.ts
+++ b/apps/api/src/app/organization/e2e/create-organization.e2e.ts
@@ -1,11 +1,13 @@
-import { MemberRepository, OrganizationRepository } from '@novu/dal';
-import { UserSession } from '@novu/testing';
-import { MemberRoleEnum } from '@novu/shared';
import { expect } from 'chai';
+import { MemberRepository, OrganizationRepository, UserRepository } from '@novu/dal';
+import { UserSession } from '@novu/testing';
+import { ApiServiceLevelEnum, ICreateOrganizationDto, JobTitleEnum, MemberRoleEnum } from '@novu/shared';
+
describe('Create Organization - /organizations (POST)', async () => {
let session: UserSession;
const organizationRepository = new OrganizationRepository();
+ const userRepository = new UserRepository();
const memberRepository = new MemberRepository();
before(async () => {
@@ -44,5 +46,47 @@ describe('Create Organization - /organizations (POST)', async () => {
it('should not create organization with no name', async () => {
await session.testAgent.post('/v1/organizations').send({}).expect(400);
});
+
+ it('should create organization with apiServiceLevel of free by default', async () => {
+ const testOrganization = {
+ name: 'Free Org',
+ };
+
+ const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
+ const dbOrganization = await organizationRepository.findById(body.data._id);
+
+ expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelEnum.FREE);
+ });
+
+ it('should create organization with questionnaire data', async () => {
+ const testOrganization: ICreateOrganizationDto = {
+ name: 'Org Name',
+ productUseCases: {
+ in_app: true,
+ multi_channel: true,
+ },
+ domain: 'org.com',
+ };
+
+ const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
+ const dbOrganization = await organizationRepository.findById(body.data._id);
+
+ expect(dbOrganization?.name).to.eq(testOrganization.name);
+ expect(dbOrganization?.domain).to.eq(testOrganization.domain);
+ expect(dbOrganization?.productUseCases?.in_app).to.eq(testOrganization.productUseCases?.in_app);
+ expect(dbOrganization?.productUseCases?.multi_channel).to.eq(testOrganization.productUseCases?.multi_channel);
+ });
+
+ it('should update user job title on organization creation', async () => {
+ const testOrganization: ICreateOrganizationDto = {
+ name: 'Org Name',
+ jobTitle: JobTitleEnum.PRODUCT_MANAGER,
+ };
+
+ await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
+ const user = await userRepository.findById(session.user._id);
+
+ expect(user?.jobTitle).to.eq(testOrganization.jobTitle);
+ });
});
});
diff --git a/apps/api/src/app/organization/e2e/get-members.e2e.ts b/apps/api/src/app/organization/e2e/get-members.e2e.ts
index 0d00a51db01..f34537a28a6 100644
--- a/apps/api/src/app/organization/e2e/get-members.e2e.ts
+++ b/apps/api/src/app/organization/e2e/get-members.e2e.ts
@@ -24,7 +24,7 @@ describe('Get members - /organization/members (GET)', async () => {
invitees: [
{
email: 'dddd@asdas.com',
- role: MemberRoleEnum.MEMBER,
+ role: MemberRoleEnum.ADMIN,
},
],
})
@@ -45,11 +45,4 @@ describe('Get members - /organization/members (GET)', async () => {
expect(JSON.stringify(body.data)).to.include('dddd@asdas.com');
expect(JSON.stringify(body.data)).to.include(session.user.firstName);
});
-
- it('should hide emails of all members as member', async () => {
- const { body } = await otherSession.testAgent.get('/v1/organizations/members').expect(200);
-
- expect(JSON.stringify(body.data)).to.not.include('dddd@asdas.com');
- expect(JSON.stringify(body.data)).to.include(session.user.firstName);
- });
});
diff --git a/apps/api/src/app/organization/e2e/set-default-locale.e2e-ee.ts b/apps/api/src/app/organization/e2e/set-default-locale.e2e-ee.ts
new file mode 100644
index 00000000000..42443f08660
--- /dev/null
+++ b/apps/api/src/app/organization/e2e/set-default-locale.e2e-ee.ts
@@ -0,0 +1,32 @@
+import { OrganizationRepository } from '@novu/dal';
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('Set default locale for organization - /organizations (POST)', async () => {
+ let session: UserSession;
+ const organizationRepository = new OrganizationRepository();
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should set default locale for organization', async () => {
+ let org = await organizationRepository.findById(session.organization._id);
+ expect(org?.defaultLocale).to.be.equal(undefined);
+
+ let result = await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ expect(result.body.data.defaultLocale).to.eq('en_US');
+ org = await organizationRepository.findById(session.organization._id);
+ expect(org?.defaultLocale).to.be.equal('en_US');
+
+ result = await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_GB',
+ });
+ expect(result.body.data.defaultLocale).to.eq('en_GB');
+ org = await organizationRepository.findById(session.organization._id);
+ expect(org?.defaultLocale).to.be.equal('en_GB');
+ });
+});
diff --git a/apps/api/src/app/organization/e2e/update-branding-details.e2e.ts b/apps/api/src/app/organization/e2e/update-branding-details.e2e.ts
index ba6b35e7ad6..3e8f76396fa 100644
--- a/apps/api/src/app/organization/e2e/update-branding-details.e2e.ts
+++ b/apps/api/src/app/organization/e2e/update-branding-details.e2e.ts
@@ -11,23 +11,63 @@ describe('Update Branding Details - /organizations/branding (PUT)', function ()
await session.initialize();
});
+ it('should update organization name only', async () => {
+ const payload = {
+ name: 'New Name',
+ };
+
+ await session.testAgent.patch('/v1/organizations').send(payload).expect(200);
+
+ const organization = await organizationRepository.findById(session.organization._id);
+ expect(organization?.name).to.equal(payload.name);
+ expect(organization?.logo).to.equal(session.organization.logo);
+ });
+
it('should update the branding details', async function () {
const payload = {
color: '#fefefe',
fontColor: '#f4f4f4',
contentBackground: '#fefefe',
fontFamily: 'Nunito',
- logo: 'https://st.depositphotos.com/1186248/2404/i/600/depositphotos_24043595-stock-photo-fake-rubber-stamp.jpg',
+ logo: 'https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png',
};
- await session.testAgent.put('/v1/organizations/branding').send(payload);
+ const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(200);
const organization = await organizationRepository.findById(session.organization._id);
- expect(organization.branding.color).to.equal(payload.color);
- expect(organization.branding.logo).to.equal(payload.logo);
- expect(organization.branding.fontColor).to.equal(payload.fontColor);
- expect(organization.branding.fontFamily).to.equal(payload.fontFamily);
- expect(organization.branding.contentBackground).to.equal(payload.contentBackground);
+ expect(organization?.branding.color).to.equal(payload.color);
+ expect(organization?.branding.logo).to.equal(payload.logo);
+ expect(organization?.branding.fontColor).to.equal(payload.fontColor);
+ expect(organization?.branding.fontFamily).to.equal(payload.fontFamily);
+ expect(organization?.branding.contentBackground).to.equal(payload.contentBackground);
+ });
+
+ it('logo should be an https protocol', async () => {
+ const payload = {
+ logo: 'http://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png',
+ };
+
+ const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400);
+ });
+
+ ['png', 'jpg', 'jpeg', 'gif', 'svg'].forEach((extension) => {
+ it(`should update if logo is a valid image URL with ${extension} extension`, async function () {
+ const payload = {
+ logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`,
+ };
+
+ const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(200);
+ });
+ });
+
+ ['exe', 'zip'].forEach((extension) => {
+ it(`should fail to update if logo is a valid image URL with ${extension} extension`, async function () {
+ const payload = {
+ logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`,
+ };
+
+ const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400);
+ });
});
});
diff --git a/apps/api/src/app/organization/organization.controller.ts b/apps/api/src/app/organization/organization.controller.ts
index 9a92742186f..005c1237366 100644
--- a/apps/api/src/app/organization/organization.controller.ts
+++ b/apps/api/src/app/organization/organization.controller.ts
@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { OrganizationEntity } from '@novu/dal';
import { IJwtPayload, MemberRoleEnum } from '@novu/shared';
-import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger';
+import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/framework/roles.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { CreateOrganizationDto } from './dtos/create-organization.dto';
@@ -21,7 +21,7 @@ import { CreateOrganizationCommand } from './usecases/create-organization/create
import { CreateOrganization } from './usecases/create-organization/create-organization.usecase';
import { RemoveMember } from './usecases/membership/remove-member/remove-member.usecase';
import { RemoveMemberCommand } from './usecases/membership/remove-member/remove-member.command';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { GetMembersCommand } from './usecases/membership/get-members/get-members.command';
import { GetMembers } from './usecases/membership/get-members/get-members.usecase';
import { ChangeMemberRoleCommand } from './usecases/membership/change-member-role/change-member-role.command';
@@ -40,13 +40,15 @@ import { RenameOrganizationDto } from './dtos/rename-organization.dto';
import { UpdateBrandingDetailsDto } from './dtos/update-branding-details.dto';
import { UpdateMemberRolesDto } from './dtos/update-member-roles.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { OrganizationBrandingResponseDto, OrganizationResponseDto } from './dtos/organization-response.dto';
import { MemberResponseDto } from './dtos/member-response.dto';
+
@Controller('/organizations')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Organizations')
+@ApiCommonResponses()
export class OrganizationController {
constructor(
private createOrganizationUsecase: CreateOrganization,
@@ -69,13 +71,16 @@ export class OrganizationController {
@UserSession() user: IJwtPayload,
@Body() body: CreateOrganizationDto
): Promise {
- const command = CreateOrganizationCommand.create({
- userId: user._id,
- logo: body.logo,
- name: body.name,
- });
-
- return await this.createOrganizationUsecase.execute(command);
+ return await this.createOrganizationUsecase.execute(
+ CreateOrganizationCommand.create({
+ userId: user._id,
+ logo: body.logo,
+ name: body.name,
+ jobTitle: body.jobTitle,
+ domain: body.domain,
+ productUseCases: body.productUseCases,
+ })
+ );
}
@Get('/')
@@ -127,6 +132,7 @@ export class OrganizationController {
@Put('/members/:memberId/roles')
@ExternalApiAccessible()
+ @ApiExcludeEndpoint()
@Roles(MemberRoleEnum.ADMIN)
@ApiResponse(MemberResponseDto)
@ApiOperation({
@@ -138,10 +144,14 @@ export class OrganizationController {
@Param('memberId') memberId: string,
@Body() body: UpdateMemberRolesDto
) {
+ if (body.role !== MemberRoleEnum.ADMIN) {
+ throw new Error('Only admin role can be assigned to a member');
+ }
+
return await this.changeMemberRoleUsecase.execute(
ChangeMemberRoleCommand.create({
memberId,
- role: body.role,
+ role: MemberRoleEnum.ADMIN,
userId: user._id,
organizationId: user.organizationId,
})
diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts
index 47156555ff9..ca6e9bc08b4 100644
--- a/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts
+++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts
@@ -1,7 +1,26 @@
+import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';
+
+import { JobTitleEnum, ProductUseCases } from '@novu/shared';
+
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class CreateOrganizationCommand extends AuthenticatedCommand {
+ @IsString()
+ @IsDefined()
+ public readonly name: string;
+
+ @IsString()
+ @IsOptional()
public readonly logo?: string;
- public readonly name: string;
+ @IsOptional()
+ @IsEnum(JobTitleEnum)
+ jobTitle?: JobTitleEnum;
+
+ @IsString()
+ @IsOptional()
+ domain?: string;
+
+ @IsOptional()
+ productUseCases?: ProductUseCases;
}
diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts
index 7084e4e1a49..f78b81d41d4 100644
--- a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts
+++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal';
-import { MemberRoleEnum } from '@novu/shared';
+import { ApiServiceLevelEnum, JobTitleEnum, MemberRoleEnum } from '@novu/shared';
import { AnalyticsService } from '@novu/application-generic';
import { CreateEnvironmentCommand } from '../../../environments/usecases/create-environment/create-environment.command';
@@ -30,15 +30,20 @@ export class CreateOrganization {
) {}
async execute(command: CreateOrganizationCommand): Promise {
- const organization = new OrganizationEntity();
-
- organization.logo = command.logo;
- organization.name = command.name;
-
const user = await this.userRepository.findById(command.userId);
if (!user) throw new ApiException('User not found');
- const createdOrganization = await this.organizationRepository.create(organization);
+ const createdOrganization = await this.organizationRepository.create({
+ logo: command.logo,
+ name: command.name,
+ apiServiceLevel: ApiServiceLevelEnum.FREE,
+ domain: command.domain,
+ productUseCases: command.productUseCases,
+ });
+
+ if (command.jobTitle) {
+ await this.updateJobTitle(user, command.jobTitle);
+ }
await this.addMemberUsecase.execute(
AddMemberCommand.create({
@@ -96,4 +101,19 @@ export class CreateOrganization {
return organizationAfterChanges as OrganizationEntity;
}
+
+ private async updateJobTitle(user, jobTitle: JobTitleEnum) {
+ await this.userRepository.update(
+ {
+ _id: user._id,
+ },
+ {
+ $set: {
+ jobTitle: jobTitle,
+ },
+ }
+ );
+
+ this.analyticsService.setValue(user._id, 'jobTitle', jobTitle);
+ }
}
diff --git a/apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts b/apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts
index 5009f43f83d..28805b67556 100644
--- a/apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts
+++ b/apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts
@@ -3,9 +3,8 @@ import { IsDefined, IsEnum, IsMongoId } from 'class-validator';
import { OrganizationCommand } from '../../../../shared/commands/organization.command';
export class ChangeMemberRoleCommand extends OrganizationCommand {
- @IsEnum(MemberRoleEnum)
@IsDefined()
- role: MemberRoleEnum;
+ role: MemberRoleEnum.ADMIN;
@IsDefined()
@IsMongoId()
diff --git a/apps/api/src/app/partner-integrations/partner-integrations.controller.ts b/apps/api/src/app/partner-integrations/partner-integrations.controller.ts
index 9a4a9974195..8954948b87c 100644
--- a/apps/api/src/app/partner-integrations/partner-integrations.controller.ts
+++ b/apps/api/src/app/partner-integrations/partner-integrations.controller.ts
@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { IJwtPayload } from '@novu/shared';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { UserSession } from '../shared/framework/user.decorator';
import { CompleteAndUpdateVercelIntegrationRequestDto } from './dtos/complete-and-update-vercel-integration-request.dto';
import { SetVercelConfigurationRequestDto } from './dtos/setup-vercel-integration-request.dto';
@@ -31,7 +31,7 @@ import { UpdateVercelConfiguration } from './usecases/update-vercel-configuratio
@Controller('/partner-integrations')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Partner Integrations')
@ApiExcludeController()
export class PartnerIntegrationsController {
diff --git a/apps/api/src/app/rate-limiting/guards/index.ts b/apps/api/src/app/rate-limiting/guards/index.ts
new file mode 100644
index 00000000000..ba0fa5f023a
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/guards/index.ts
@@ -0,0 +1,2 @@
+export * from './throttler.decorator';
+export * from './throttler.guard';
diff --git a/apps/api/src/app/rate-limiting/guards/throttler.decorator.ts b/apps/api/src/app/rate-limiting/guards/throttler.decorator.ts
new file mode 100644
index 00000000000..b6d0442b8e1
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/guards/throttler.decorator.ts
@@ -0,0 +1,8 @@
+import { Reflector } from '@nestjs/core';
+import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const ThrottlerCategory = Reflector.createDecorator();
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const ThrottlerCost = Reflector.createDecorator();
diff --git a/apps/api/src/app/rate-limiting/guards/throttler.guard.e2e.ts b/apps/api/src/app/rate-limiting/guards/throttler.guard.e2e.ts
new file mode 100644
index 00000000000..2ae7ad114dd
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/guards/throttler.guard.e2e.ts
@@ -0,0 +1,383 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiServiceLevelEnum } from '@novu/shared';
+import { HttpResponseHeaderKeysEnum } from '../../shared/framework/types';
+
+const mockSingleCost = 1;
+const mockBulkCost = 5;
+const mockWindowDuration = 5;
+const mockBurstAllowance = 1;
+const mockMaximumFreeTrigger = 5;
+const mockMaximumFreeGlobal = 3;
+const mockMaximumUnlimitedTrigger = 10;
+const mockMaximumUnlimitedGlobal = 5;
+
+process.env.API_RATE_LIMIT_COST_SINGLE = `${mockSingleCost}`;
+process.env.API_RATE_LIMIT_COST_BULK = `${mockBulkCost}`;
+process.env.API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION = `${mockWindowDuration}`;
+process.env.API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE = `${mockBurstAllowance}`;
+process.env.API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER = `${mockMaximumFreeTrigger}`;
+process.env.API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL = `${mockMaximumFreeGlobal}`;
+process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER = `${mockMaximumUnlimitedTrigger}`;
+process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL = `${mockMaximumUnlimitedGlobal}`;
+
+process.env.LAUNCH_DARKLY_SDK_KEY = ''; // disable Launch Darkly to allow test to define FF state
+
+describe('API Rate Limiting', () => {
+ let session: UserSession;
+ const pathPrefix = '/v1/rate-limiting';
+ let request: (
+ path: string,
+ authHeader?: string
+ ) => Promise>>;
+
+ describe('Guard logic', () => {
+ beforeEach(async () => {
+ process.env.IS_API_RATE_LIMITING_ENABLED = 'true';
+
+ session = new UserSession();
+ await session.initialize();
+
+ request = (path: string, authHeader = `ApiKey ${session.apiKey}`) =>
+ session.testAgent.get(path).set('authorization', authHeader);
+ });
+
+ describe('Feature Flag', () => {
+ it('should set rate limit headers when the Feature Flag is enabled', async () => {
+ process.env.IS_API_RATE_LIMITING_ENABLED = 'true';
+ const response = await request(pathPrefix + '/no-category-no-cost');
+
+ expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist;
+ });
+
+ it('should NOT set rate limit headers when the Feature Flag is disabled', async () => {
+ process.env.IS_API_RATE_LIMITING_ENABLED = 'false';
+ const response = await request(pathPrefix + '/no-category-no-cost');
+
+ expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
+ });
+ });
+
+ describe('Allowed Authentication Security Schemes', () => {
+ it('should set rate limit headers when ApiKey security scheme is used to authenticate', async () => {
+ const response = await request(pathPrefix + '/no-category-no-cost', `ApiKey ${session.apiKey}`);
+
+ expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist;
+ });
+
+ it('should NOT set rate limit headers when a Bearer security scheme is used to authenticate', async () => {
+ const response = await request(pathPrefix + '/no-category-no-cost', session.token);
+
+ expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
+ });
+
+ it('should NOT set rate limit headers when NO authorization header is present', async () => {
+ const response = await request(pathPrefix + '/no-category-no-cost', '');
+
+ expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
+ });
+ });
+
+ describe('RateLimit-Policy', () => {
+ const testParams: Array<{ name: string; expectedRegex: string }> = [
+ { name: 'limit', expectedRegex: `${mockMaximumUnlimitedGlobal * mockWindowDuration}` },
+ { name: 'w', expectedRegex: `w=${mockWindowDuration}` },
+ {
+ name: 'burst',
+ expectedRegex: `burst=${mockMaximumUnlimitedGlobal * (1 + mockBurstAllowance) * mockWindowDuration}`,
+ },
+ { name: 'comment', expectedRegex: `comment="[a-zA-Z ]*"` },
+ { name: 'category', expectedRegex: `category="(${Object.values(ApiRateLimitCategoryEnum).join('|')})"` },
+ { name: 'cost', expectedRegex: `cost="(${Object.values(ApiRateLimitCostEnum).join('|')})"` },
+ {
+ name: 'serviceLevel',
+ expectedRegex: `serviceLevel="[a-zA-Z]*"`,
+ },
+ ];
+
+ testParams.forEach(({ name, expectedRegex }) => {
+ it(`should include the ${name} parameter`, async () => {
+ const response = await request(pathPrefix + '/no-category-no-cost');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.match(new RegExp(expectedRegex));
+ });
+ });
+
+ it('should separate the params with a semicolon', async () => {
+ const response = await request(pathPrefix + '/no-category-no-cost');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader.split(';')).to.have.lengthOf(testParams.length);
+ });
+ });
+
+ describe('Rate Limit Decorators', () => {
+ describe('Controller WITHOUT Decorators', () => {
+ const controllerPathPrefix = '/v1/rate-limiting';
+
+ it('should use the global category for an endpoint WITHOUT category decorator', async () => {
+ const response = await request(controllerPathPrefix + '/no-category-no-cost');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`);
+ });
+
+ it('should use the single cost for an endpoint WITHOUT cost decorator', async () => {
+ const response = await request(controllerPathPrefix + '/no-category-no-cost');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`);
+ });
+ });
+
+ describe('Controller WITH Decorators', () => {
+ const controllerPathPrefix = '/v1/rate-limiting-trigger-bulk';
+
+ it('should use the category decorator defined on the controller for an endpoint WITHOUT category decorator', async () => {
+ const response = await request(controllerPathPrefix + '/no-category-no-cost-override');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.TRIGGER}"`);
+ });
+
+ it('should use the cost decorator defined on the controller for an endpoint WITHOUT cost decorator', async () => {
+ const response = await request(controllerPathPrefix + '/no-category-no-cost-override');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.BULK}"`);
+ });
+
+ it('should override the cost decorator defined on the controller for an endpoint WITH cost decorator', async () => {
+ const response = await request(controllerPathPrefix + '/no-category-single-cost-override');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`);
+ });
+
+ it('should override the category decorator defined on the controller for an endpoint WITH category decorator', async () => {
+ const response = await request(controllerPathPrefix + '/global-category-no-cost-override');
+ const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
+
+ expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`);
+ });
+ });
+ });
+ });
+
+ describe('Scenarios', () => {
+ type TestCase = {
+ name: string;
+ requests: { path: string; count: number }[];
+ expectedStatus: number;
+ expectedLimit: number;
+ expectedCost: number;
+ expectedReset: number;
+ expectedRetryAfter?: number;
+ expectedThrottledRequests: number;
+ setupTest?: (userSession: UserSession) => Promise;
+ };
+
+ const testCases: TestCase[] = [
+ {
+ name: 'single trigger endpoint request',
+ requests: [{ path: '/trigger-category-single-cost', count: 1 }],
+ expectedStatus: 200,
+ expectedLimit: mockMaximumUnlimitedTrigger,
+ expectedCost: mockSingleCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ },
+ {
+ name: 'no category no cost endpoint request',
+ requests: [{ path: '/no-category-no-cost', count: 1 }],
+ expectedStatus: 200,
+ expectedLimit: mockMaximumUnlimitedGlobal,
+ expectedCost: mockSingleCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ },
+ {
+ name: 'single trigger request with service level specified on organization ',
+ requests: [{ path: '/trigger-category-single-cost', count: 1 }],
+ expectedStatus: 200,
+ expectedLimit: mockMaximumFreeTrigger,
+ expectedCost: mockSingleCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ async setupTest(userSession) {
+ await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE);
+ },
+ },
+ {
+ name: 'single trigger request with maximum rate limit specified on environment',
+ requests: [{ path: '/trigger-category-single-cost', count: 1 }],
+ expectedStatus: 200,
+ expectedLimit: 60,
+ expectedCost: mockSingleCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ async setupTest(userSession) {
+ await userSession.updateEnvironmentApiRateLimits({ [ApiRateLimitCategoryEnum.TRIGGER]: 60 });
+ },
+ },
+ {
+ name: 'combination of single trigger and single global endpoint request',
+ requests: [
+ { path: '/trigger-category-single-cost', count: 20 },
+ { path: '/global-category-single-cost', count: 100 },
+ ],
+ expectedStatus: 429,
+ expectedLimit: mockMaximumUnlimitedGlobal,
+ expectedCost: mockSingleCost * 100,
+ expectedReset: 1,
+ expectedRetryAfter: 1,
+ expectedThrottledRequests: 50,
+ },
+ {
+ name: 'bulk trigger endpoint request',
+ requests: [{ path: '/trigger-category-bulk-cost', count: 1 }],
+ expectedStatus: 200,
+ expectedLimit: mockMaximumUnlimitedTrigger,
+ expectedCost: mockBulkCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ },
+ {
+ name: 'bulk global endpoint request',
+ requests: [{ path: '/global-category-bulk-cost', count: 20 }],
+ expectedStatus: 429,
+ expectedLimit: mockMaximumUnlimitedGlobal,
+ expectedCost: mockBulkCost * 20,
+ expectedReset: 1,
+ expectedRetryAfter: 1,
+ expectedThrottledRequests: 10,
+ },
+ {
+ name: 'combination of single trigger and bulk trigger endpoint request',
+ requests: [
+ { path: '/trigger-category-single-cost', count: 2 },
+ { path: '/trigger-category-bulk-cost', count: 1 },
+ ],
+ expectedStatus: 200,
+ expectedLimit: mockMaximumUnlimitedTrigger,
+ expectedCost: mockSingleCost * 2 + mockBulkCost * 1,
+ expectedReset: 1,
+ expectedThrottledRequests: 0,
+ },
+ {
+ name: 'bulk trigger request with service level specified on organization and maximum rate limit specified on environment',
+ requests: [{ path: '/trigger-category-bulk-cost', count: 5 }],
+ expectedStatus: 429,
+ expectedLimit: 1,
+ expectedCost: mockBulkCost * 5,
+ expectedReset: 5,
+ expectedRetryAfter: 5,
+ expectedThrottledRequests: 3,
+ async setupTest(userSession) {
+ await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE);
+ await userSession.updateEnvironmentApiRateLimits({ [ApiRateLimitCategoryEnum.TRIGGER]: 1 });
+ },
+ },
+ {
+ name: 'combination of bulk trigger and bulk global endpoint request',
+ requests: [
+ { path: '/trigger-category-bulk-cost', count: 40 },
+ { path: '/global-category-bulk-cost', count: 40 },
+ ],
+ expectedStatus: 429,
+ expectedLimit: mockMaximumUnlimitedGlobal,
+ expectedCost: mockBulkCost * 40,
+ expectedReset: 1,
+ expectedRetryAfter: 1,
+ expectedThrottledRequests: 50,
+ },
+ ];
+
+ testCases
+ .map(
+ ({
+ name,
+ requests,
+ expectedStatus,
+ expectedLimit,
+ expectedCost,
+ expectedReset,
+ expectedRetryAfter,
+ expectedThrottledRequests,
+ setupTest,
+ }) => {
+ return () => {
+ describe(`${expectedStatus === 429 ? 'Throttled' : 'Allowed'} ${name}`, () => {
+ let lastResponse: ReturnType;
+ let throttledResponseCount = 0;
+ const throttledResponseCountTolerance = 0.5;
+ const expectedWindowLimit = expectedLimit * mockWindowDuration;
+ const expectedBurstLimit = expectedWindowLimit * (1 + mockBurstAllowance);
+ const expectedRemaining = Math.max(0, expectedBurstLimit - expectedCost);
+
+ before(async () => {
+ process.env.IS_API_RATE_LIMITING_ENABLED = 'true';
+
+ session = new UserSession();
+ await session.initialize();
+
+ request = (path: string, authHeader = `ApiKey ${session.apiKey}`) =>
+ session.testAgent.get(path).set('authorization', authHeader);
+
+ setupTest && (await setupTest(session));
+ for (const { path, count } of requests) {
+ for (let index = 0; index < count; index++) {
+ const response = await request(pathPrefix + path);
+ lastResponse = response;
+
+ if (response.statusCode === 429) {
+ throttledResponseCount++;
+ }
+ }
+ }
+ });
+
+ it(`should return a ${expectedStatus} status code`, async () => {
+ expect(lastResponse.statusCode).to.equal(expectedStatus);
+ });
+
+ it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT} header of ${expectedWindowLimit}`, async () => {
+ expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.equal(
+ `${expectedWindowLimit}`
+ );
+ });
+
+ it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING} header of ${expectedRemaining}`, async () => {
+ expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING.toLowerCase()]).to.equal(
+ `${expectedRemaining}`
+ );
+ });
+
+ it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_RESET} header of ${expectedReset}`, async () => {
+ expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_RESET.toLowerCase()]).to.equal(
+ `${expectedReset}`
+ );
+ });
+
+ it(`should return a ${HttpResponseHeaderKeysEnum.RETRY_AFTER} header of ${expectedRetryAfter}`, async () => {
+ expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase()]).to.equal(
+ expectedRetryAfter && `${expectedRetryAfter}`
+ );
+ });
+
+ const expectedMinThrottled = Math.floor(
+ expectedThrottledRequests * (1 - throttledResponseCountTolerance)
+ );
+ const expectedMaxThrottled = Math.ceil(expectedThrottledRequests * (1 + throttledResponseCountTolerance));
+ it(`should have between ${expectedMinThrottled} and ${expectedMaxThrottled} requests throttled`, async () => {
+ expect(throttledResponseCount).to.be.greaterThanOrEqual(expectedMinThrottled);
+ expect(throttledResponseCount).to.be.lessThanOrEqual(expectedMaxThrottled);
+ });
+ });
+ };
+ }
+ )
+ .forEach((testCase) => testCase());
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/guards/throttler.guard.ts b/apps/api/src/app/rate-limiting/guards/throttler.guard.ts
new file mode 100644
index 00000000000..672e9d67553
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/guards/throttler.guard.ts
@@ -0,0 +1,193 @@
+import {
+ InjectThrottlerOptions,
+ InjectThrottlerStorage,
+ ThrottlerException,
+ ThrottlerGuard,
+ ThrottlerModuleOptions,
+ ThrottlerOptions,
+ ThrottlerStorage,
+} from '@nestjs/throttler';
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from '../usecases/evaluate-api-rate-limit';
+import { Reflector } from '@nestjs/core';
+import { FeatureFlagCommand, GetIsApiRateLimitingEnabled, Instrument } from '@novu/application-generic';
+import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiAuthSchemeEnum, IJwtPayload } from '@novu/shared';
+import { ThrottlerCost, ThrottlerCategory } from './throttler.decorator';
+import { HttpRequestHeaderKeysEnum, HttpResponseHeaderKeysEnum } from '../../shared/framework/types';
+
+export const THROTTLED_EXCEPTION_MESSAGE = 'API rate limit exceeded';
+export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY];
+
+const defaultApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
+const defaultApiRateLimitCost = ApiRateLimitCostEnum.SINGLE;
+
+/**
+ * An interceptor is used instead of a guard to ensure that Auth context is available.
+ * This is currently necessary because we do not currently have a global guard configured for Auth,
+ * therefore the Auth context is not guaranteed to be available in the guard.
+ */
+@Injectable()
+export class ApiRateLimitInterceptor extends ThrottlerGuard implements NestInterceptor {
+ constructor(
+ @InjectThrottlerOptions() protected readonly options: ThrottlerModuleOptions,
+ @InjectThrottlerStorage() protected readonly storageService: ThrottlerStorage,
+ reflector: Reflector,
+ private evaluateApiRateLimit: EvaluateApiRateLimit,
+ private getIsApiRateLimitingEnabled: GetIsApiRateLimitingEnabled
+ ) {
+ super(options, storageService, reflector);
+ }
+
+ /**
+ * Thin wrapper around the ThrottlerGuard's canActivate method.
+ */
+ async intercept(context: ExecutionContext, next: CallHandler) {
+ try {
+ await this.canActivate(context);
+
+ return next.handle();
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ @Instrument()
+ canActivate(context: ExecutionContext): Promise {
+ return super.canActivate(context);
+ }
+
+ protected async shouldSkip(context: ExecutionContext): Promise {
+ const isAllowedAuthScheme = this.isAllowedAuthScheme(context);
+ if (!isAllowedAuthScheme) {
+ return true;
+ }
+
+ const user = this.getReqUser(context);
+ const { organizationId, environmentId, _id } = user;
+
+ const isEnabled = await this.getIsApiRateLimitingEnabled.execute(
+ FeatureFlagCommand.create({
+ environmentId,
+ organizationId,
+ userId: _id,
+ })
+ );
+
+ return !isEnabled;
+ }
+
+ /**
+ * Throttles incoming HTTP requests.
+ * All the outgoing requests will contain RFC-compatible RateLimit headers.
+ * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
+ * @throws {ThrottlerException}
+ */
+ protected async handleRequest(
+ context: ExecutionContext,
+ _limit: number,
+ _ttl: number,
+ throttler: ThrottlerOptions
+ ): Promise {
+ const { req, res } = this.getRequestResponse(context);
+ const ignoreUserAgents = throttler.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents;
+ // Return early if the current user agent should be ignored.
+ if (Array.isArray(ignoreUserAgents)) {
+ for (const pattern of ignoreUserAgents) {
+ if (pattern.test(req.headers[HttpRequestHeaderKeysEnum.USER_AGENT.toLowerCase()])) {
+ return true;
+ }
+ }
+ }
+
+ const handler = context.getHandler();
+ const classRef = context.getClass();
+ const apiRateLimitCategory =
+ this.reflector.getAllAndOverride(ThrottlerCategory, [handler, classRef]) || defaultApiRateLimitCategory;
+ const apiRateLimitCost =
+ this.reflector.getAllAndOverride(ThrottlerCost, [handler, classRef]) || defaultApiRateLimitCost;
+
+ const { organizationId, environmentId } = this.getReqUser(context);
+
+ const { success, limit, remaining, reset, windowDuration, burstLimit, algorithm, apiServiceLevel } =
+ await this.evaluateApiRateLimit.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId,
+ environmentId,
+ apiRateLimitCategory,
+ apiRateLimitCost,
+ })
+ );
+
+ const secondsToReset = Math.max(Math.ceil((reset - Date.now()) / 1e3), 0);
+
+ res.header(HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING, remaining);
+ res.header(HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT, limit);
+ res.header(HttpResponseHeaderKeysEnum.RATELIMIT_RESET, secondsToReset);
+ res.header(
+ HttpResponseHeaderKeysEnum.RATELIMIT_POLICY,
+ this.createPolicyHeader(
+ limit,
+ windowDuration,
+ burstLimit,
+ algorithm,
+ apiRateLimitCategory,
+ apiRateLimitCost,
+ apiServiceLevel
+ )
+ );
+ res.rateLimitPolicy = {
+ limit,
+ windowDuration,
+ burstLimit,
+ algorithm,
+ apiRateLimitCategory,
+ apiRateLimitCost,
+ apiServiceLevel,
+ };
+
+ if (success) {
+ return true;
+ } else {
+ res.header(HttpResponseHeaderKeysEnum.RETRY_AFTER, secondsToReset);
+ throw new ThrottlerException(THROTTLED_EXCEPTION_MESSAGE);
+ }
+ }
+
+ private createPolicyHeader(
+ limit: number,
+ windowDuration: number,
+ burstLimit: number,
+ algorithm: string,
+ apiRateLimitCategory: ApiRateLimitCategoryEnum,
+ apiRateLimitCost: ApiRateLimitCostEnum,
+ apiServiceLevel: string
+ ): string {
+ const policyMap = {
+ w: windowDuration,
+ burst: burstLimit,
+ comment: `"${algorithm}"`,
+ category: `"${apiRateLimitCategory}"`,
+ cost: `"${apiRateLimitCost}"`,
+ serviceLevel: `"${apiServiceLevel}"`,
+ };
+ const policy = Object.entries(policyMap).reduce((acc, [key, value]) => {
+ return `${acc};${key}=${value}`;
+ }, `${limit}`);
+
+ return policy;
+ }
+
+ private isAllowedAuthScheme(context: ExecutionContext): boolean {
+ const req = context.switchToHttp().getRequest();
+ const authScheme = req.authScheme;
+
+ return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme);
+ }
+
+ private getReqUser(context: ExecutionContext): IJwtPayload {
+ const req = context.switchToHttp().getRequest();
+
+ return req.user;
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/rate-limiting.module.ts b/apps/api/src/app/rate-limiting/rate-limiting.module.ts
new file mode 100644
index 00000000000..298a8368ccd
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/rate-limiting.module.ts
@@ -0,0 +1,21 @@
+import { Module } from '@nestjs/common';
+import { USE_CASES } from './usecases';
+import { SharedModule } from '../shared/shared.module';
+import { ThrottlerModule } from '@nestjs/throttler';
+import { ApiRateLimitInterceptor } from './guards';
+
+@Module({
+ imports: [
+ SharedModule,
+ ThrottlerModule.forRoot([
+ // The following configuration is required for the NestJS ThrottlerModule to work. It has no effect.
+ {
+ ttl: 60000,
+ limit: 10,
+ },
+ ]),
+ ],
+ providers: [...USE_CASES, ApiRateLimitInterceptor],
+ exports: [...USE_CASES, ApiRateLimitInterceptor],
+})
+export class RateLimitingModule {}
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts
new file mode 100644
index 00000000000..3479e065409
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts
@@ -0,0 +1,13 @@
+import { IsDefined, IsEnum } from 'class-validator';
+import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared';
+import { EnvironmentCommand } from '../../../shared/commands/project.command';
+
+export class EvaluateApiRateLimitCommand extends EnvironmentCommand {
+ @IsDefined()
+ @IsEnum(ApiRateLimitCategoryEnum)
+ apiRateLimitCategory: ApiRateLimitCategoryEnum;
+
+ @IsDefined()
+ @IsEnum(ApiRateLimitCostEnum)
+ apiRateLimitCost: ApiRateLimitCostEnum;
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts
new file mode 100644
index 00000000000..2aa6d1539dc
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts
@@ -0,0 +1,213 @@
+import { Test } from '@nestjs/testing';
+import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from './index';
+import { UserSession } from '@novu/testing';
+import {
+ ApiRateLimitAlgorithmEnum,
+ ApiRateLimitCategoryEnum,
+ ApiRateLimitCostEnum,
+ ApiServiceLevelEnum,
+ IApiRateLimitAlgorithm,
+ IApiRateLimitCost,
+} from '@novu/shared';
+import { expect } from 'chai';
+import * as sinon from 'sinon';
+import { GetApiRateLimitMaximum } from '../get-api-rate-limit-maximum';
+import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config';
+import { SharedModule } from '../../../shared/shared.module';
+import { RateLimitingModule } from '../../rate-limiting.module';
+import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config';
+import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit';
+
+const mockApiRateLimitAlgorithm: IApiRateLimitAlgorithm = {
+ [ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.2,
+ [ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 2,
+};
+const mockApiRateLimitCost = ApiRateLimitCostEnum.SINGLE;
+const mockApiServiceLevel = ApiServiceLevelEnum.FREE;
+const mockCost = 1;
+const mockApiRateLimitCostConfig: Partial = {
+ [mockApiRateLimitCost]: mockCost,
+};
+
+const mockMaxLimit = 10;
+const mockRemaining = 9;
+const mockReset = 1;
+const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
+
+describe('EvaluateApiRateLimit', async () => {
+ let useCase: EvaluateApiRateLimit;
+ let session: UserSession;
+ let getApiRateLimitMaximum: GetApiRateLimitMaximum;
+ let getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig;
+ let getApiRateLimitCostConfig: GetApiRateLimitCostConfig;
+ let evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit;
+
+ let getApiRateLimitMaximumStub: sinon.SinonStub;
+ let getApiRateLimitAlgorithmConfigStub: sinon.SinonStub;
+ let getApiRateLimitCostConfigStub: sinon.SinonStub;
+ let evaluateTokenBucketRateLimitStub: sinon.SinonStub;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [SharedModule, RateLimitingModule],
+ }).compile();
+
+ session = new UserSession();
+ await session.initialize();
+
+ useCase = moduleRef.get(EvaluateApiRateLimit);
+ getApiRateLimitMaximum = moduleRef.get(GetApiRateLimitMaximum);
+ getApiRateLimitAlgorithmConfig = moduleRef.get(GetApiRateLimitAlgorithmConfig);
+ getApiRateLimitCostConfig = moduleRef.get(GetApiRateLimitCostConfig);
+ evaluateTokenBucketRateLimit = moduleRef.get(EvaluateTokenBucketRateLimit);
+
+ getApiRateLimitMaximumStub = sinon
+ .stub(getApiRateLimitMaximum, 'execute')
+ .resolves([mockMaxLimit, mockApiServiceLevel]);
+ getApiRateLimitAlgorithmConfigStub = sinon
+ .stub(getApiRateLimitAlgorithmConfig, 'default')
+ .value(mockApiRateLimitAlgorithm);
+ getApiRateLimitCostConfigStub = sinon.stub(getApiRateLimitCostConfig, 'default').value(mockApiRateLimitCostConfig);
+ evaluateTokenBucketRateLimitStub = sinon.stub(evaluateTokenBucketRateLimit, 'execute').resolves({
+ success: true,
+ limit: mockMaxLimit,
+ remaining: mockRemaining,
+ reset: mockReset,
+ });
+ });
+
+ afterEach(() => {
+ getApiRateLimitMaximumStub.restore();
+ getApiRateLimitAlgorithmConfigStub.restore();
+ getApiRateLimitCostConfigStub.restore();
+ });
+
+ describe('Evaluation Values', () => {
+ it('should return a boolean success value', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(typeof result.success).to.equal('boolean');
+ });
+
+ it('should return a positive limit', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.limit).to.be.greaterThan(0);
+ });
+
+ it('should return a positive remaining tokens ', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.remaining).to.be.greaterThan(0);
+ });
+
+ it('should return a positive reset', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.reset).to.be.greaterThan(0);
+ });
+ });
+
+ describe('Static Values', () => {
+ it('should return a string type algorithm value', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(typeof result.algorithm).to.equal('string');
+ });
+
+ it('should return the correct window duration', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.windowDuration).to.equal(mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]);
+ });
+ });
+
+ describe('Computed Values', () => {
+ it('should return the correct cost', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.cost).to.equal(mockApiRateLimitCostConfig[mockApiRateLimitCost]);
+ });
+
+ it('should return the correct refill rate', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.refillRate).to.equal(
+ mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]
+ );
+ });
+
+ it('should return the correct burst limit', async () => {
+ const result = await useCase.execute(
+ EvaluateApiRateLimitCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ apiRateLimitCost: mockApiRateLimitCost,
+ })
+ );
+
+ expect(result.burstLimit).to.equal(
+ mockMaxLimit *
+ mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] *
+ (1 + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE])
+ );
+ });
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts
new file mode 100644
index 00000000000..8fee719fd16
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts
@@ -0,0 +1,42 @@
+export type EvaluateApiRateLimitResponseDto = {
+ /**
+ * Whether the request may pass(true) or exceeded the limit(false)
+ */
+ success: boolean;
+ /**
+ * Maximum number of requests allowed within a window.
+ */
+ limit: number;
+ /**
+ * How many requests the client has left within the current window.
+ */
+ remaining: number;
+ /**
+ * Unix timestamp in milliseconds when the limits are reset.
+ */
+ reset: number;
+ /**
+ * The duration of the window in seconds.
+ */
+ windowDuration: number;
+ /**
+ * The maximum number of requests allowed within a window, including the burst allowance.
+ */
+ burstLimit: number;
+ /**
+ * The number of requests that will be refilled per window.
+ */
+ refillRate: number;
+ /**
+ * The name of the algorithm used to calculate the rate limit.
+ */
+ algorithm: string;
+ /**
+ * The cost of the request.
+ */
+ cost: number;
+ /**
+ * The API service level used to evaluate the request.
+ */
+ apiServiceLevel: string;
+};
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts
new file mode 100644
index 00000000000..92671651905
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts
@@ -0,0 +1,82 @@
+import { Injectable } from '@nestjs/common';
+import { ApiRateLimitAlgorithmEnum } from '@novu/shared';
+import { EvaluateApiRateLimitCommand } from './evaluate-api-rate-limit.command';
+import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from '../get-api-rate-limit-maximum';
+import { InstrumentUsecase, buildEvaluateApiRateLimitKey } from '@novu/application-generic';
+import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config';
+import { EvaluateApiRateLimitResponseDto } from './evaluate-api-rate-limit.types';
+import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase';
+import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config';
+import { EvaluateTokenBucketRateLimitCommand } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command';
+
+@Injectable()
+export class EvaluateApiRateLimit {
+ constructor(
+ private getApiRateLimitMaximum: GetApiRateLimitMaximum,
+ private getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig,
+ private getApiRateLimitCostConfig: GetApiRateLimitCostConfig,
+ private evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit
+ ) {}
+
+ @InstrumentUsecase()
+ async execute(command: EvaluateApiRateLimitCommand): Promise {
+ const [maxLimitPerSecond, apiServiceLevel] = await this.getApiRateLimitMaximum.execute(
+ GetApiRateLimitMaximumCommand.create({
+ apiRateLimitCategory: command.apiRateLimitCategory,
+ environmentId: command.environmentId,
+ organizationId: command.organizationId,
+ })
+ );
+
+ const windowDuration = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.WINDOW_DURATION];
+ const burstAllowance = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE];
+ const cost = this.getApiRateLimitCostConfig.default[command.apiRateLimitCost];
+ const maxTokensPerWindow = this.getMaxTokensPerWindow(maxLimitPerSecond, windowDuration);
+ const refillRate = this.getRefillRate(maxLimitPerSecond, windowDuration);
+ const burstLimit = this.getBurstLimit(maxTokensPerWindow, burstAllowance);
+
+ const identifier = buildEvaluateApiRateLimitKey({
+ _environmentId: command.environmentId,
+ apiRateLimitCategory: command.apiRateLimitCategory,
+ });
+
+ const { success, remaining, reset } = await this.evaluateTokenBucketRateLimit.execute(
+ EvaluateTokenBucketRateLimitCommand.create({
+ identifier,
+ maxTokens: burstLimit,
+ windowDuration,
+ cost,
+ refillRate,
+ })
+ );
+
+ return {
+ success,
+ limit: maxTokensPerWindow,
+ remaining,
+ reset,
+ windowDuration,
+ burstLimit,
+ refillRate,
+ algorithm: this.evaluateTokenBucketRateLimit.algorithm,
+ cost,
+ apiServiceLevel,
+ };
+ }
+
+ private getMaxTokensPerWindow(maxLimit: number, windowDuration: number): number {
+ return maxLimit * windowDuration;
+ }
+
+ private getRefillRate(maxLimit: number, windowDuration: number): number {
+ /*
+ * Refill rate is currently set to the max tokens per window.
+ * This can be changed to a different value to implement adaptive rate limiting.
+ */
+ return this.getMaxTokensPerWindow(maxLimit, windowDuration);
+ }
+
+ private getBurstLimit(maxTokensPerWindow: number, burstAllowance: number): number {
+ return Math.floor(maxTokensPerWindow * (1 + burstAllowance));
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts
new file mode 100644
index 00000000000..a7e80f96d03
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts
@@ -0,0 +1,3 @@
+export * from './evaluate-api-rate-limit.command';
+export * from './evaluate-api-rate-limit.usecase';
+export * from './evaluate-api-rate-limit.types';
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts
new file mode 100644
index 00000000000..c88170e42c0
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts
@@ -0,0 +1,24 @@
+import { IsDefined, IsNumber, IsString } from 'class-validator';
+import { BaseCommand } from '../../../shared/commands/base.command';
+
+export class EvaluateTokenBucketRateLimitCommand extends BaseCommand {
+ @IsDefined()
+ @IsString()
+ identifier: string;
+
+ @IsDefined()
+ @IsNumber()
+ maxTokens: number;
+
+ @IsDefined()
+ @IsNumber()
+ windowDuration: number;
+
+ @IsDefined()
+ @IsNumber()
+ cost: number;
+
+ @IsDefined()
+ @IsNumber()
+ refillRate: number;
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts
new file mode 100644
index 00000000000..9b5fbbf9501
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts
@@ -0,0 +1,495 @@
+import { expect } from 'chai';
+import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit.usecase';
+import { CacheService, cacheService as inMemoryCacheService } from '@novu/application-generic';
+import { SharedModule } from '../../../shared/shared.module';
+import { RateLimitingModule } from '../../rate-limiting.module';
+import { Test } from '@nestjs/testing';
+import * as sinon from 'sinon';
+import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command';
+import { v4 as uuid } from 'uuid';
+
+describe('EvaluateTokenBucketRateLimit', () => {
+ let useCase: EvaluateTokenBucketRateLimit;
+ let cacheService: CacheService;
+
+ const mockCommand = EvaluateTokenBucketRateLimitCommand.create({
+ identifier: 'test',
+ maxTokens: 10,
+ windowDuration: 1,
+ cost: 1,
+ refillRate: 1,
+ });
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [SharedModule, RateLimitingModule],
+ }).compile();
+
+ useCase = moduleRef.get(EvaluateTokenBucketRateLimit);
+ cacheService = moduleRef.get(CacheService);
+ });
+
+ describe('Static values', () => {
+ it('should have a static algorithm value', () => {
+ expect(useCase.algorithm).to.equal('token bucket');
+ });
+ });
+
+ describe('Cache invocation', () => {
+ let cacheServiceEvalStub: sinon.SinonStub;
+ let cacheServiceSaddStub: sinon.SinonStub;
+ let cacheServiceIsEnabledStub: sinon.SinonStub;
+
+ beforeEach(async () => {
+ cacheServiceEvalStub = sinon.stub(cacheService, 'eval');
+ cacheServiceSaddStub = sinon.stub(cacheService, 'sadd');
+ cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true);
+ });
+
+ afterEach(() => {
+ cacheServiceEvalStub.restore();
+ cacheServiceSaddStub.restore();
+ cacheServiceIsEnabledStub.restore();
+ });
+
+ describe('Cache Errors', () => {
+ it('should throw error when a cache operation fails', async () => {
+ cacheServiceEvalStub.resolves(new Error());
+
+ try {
+ await useCase.execute(mockCommand);
+ throw new Error('Should not reach here');
+ } catch (e) {
+ expect(e.message).to.equal('Failed to evaluate rate limit');
+ }
+ });
+
+ it('should throw error when cache is not enabled', async () => {
+ cacheServiceIsEnabledStub.returns(false);
+
+ try {
+ await useCase.execute(mockCommand);
+ throw new Error('Should not reach here');
+ } catch (e) {
+ expect(e.message).to.equal('Rate limiting cache service is not available');
+ }
+ });
+ });
+
+ describe('Cache Service Adapter', () => {
+ it('should invoke the SADD method with members casted to string', async () => {
+ const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService);
+ const key = 'testKey';
+ const members = [1, 2];
+
+ await cacheClient.sadd(key, ...members);
+
+ expect(cacheServiceSaddStub.calledWith(key, ...['1', '2'])).to.equal(true);
+ });
+
+ it('should invoke the EVAL function with args casted to string', async () => {
+ const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService);
+ const script = 'return 1';
+ const keys = ['key1', 'key2'];
+ const args = [1, 2];
+
+ await cacheClient.eval(script, keys, args);
+
+ expect(cacheServiceEvalStub.calledWith(script, keys, ['1', '2'])).to.equal(true);
+ });
+ });
+
+ describe.skip('Redis EVAL script benchmarks', () => {
+ type TestCase = {
+ /**
+ * Test scenario description
+ */
+ description: string;
+ /**
+ * Total number of requests to simulate
+ */
+ totalRequests: number;
+ /**
+ * Proportion of requests that have a unique identifier
+ */
+ proportionUniqueIds: number;
+ /**
+ * Proportion of requests that are throttled
+ */
+ proportionThrottled: number;
+ /**
+ * Proportion of requests that are high cost
+ */
+ proportionHighCost: number;
+ /**
+ * The proportion of the window duration to jitter the request duration by.
+ * Low value to simulate burst request patterns.
+ * High value to simulate sustained request patterns.
+ */
+ proportionJitter: number;
+ /**
+ * Expected maximum total evaluation duration in milliseconds
+ */
+ expectedTotalTimeMs: number;
+ /**
+ * Expected average evaluation duration in milliseconds
+ */
+ expectedAverageTimeMs: number;
+ /**
+ * Expected nth percentile evaluation duration in milliseconds
+ */
+ expectedNthPercentileTimeMs: number;
+ };
+
+ const testCases: TestCase[] = [
+ {
+ description: 'Low Load - 0% Throttled - Sustained Single Window',
+ totalRequests: 5000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.8,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 10,
+ expectedNthPercentileTimeMs: 30,
+ },
+ {
+ description: 'Medium Load - 0% Throttled - Sustained Single Window',
+ totalRequests: 10000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.8,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 20,
+ expectedNthPercentileTimeMs: 50,
+ },
+ {
+ description: 'High Load - 0% Throttled - Sustained Single Window',
+ totalRequests: 20000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.8,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 200,
+ expectedNthPercentileTimeMs: 500,
+ },
+ {
+ description: 'Extreme Load - 0% Throttled - Sustained Single Window',
+ totalRequests: 40000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.8,
+ expectedTotalTimeMs: 2000,
+ expectedAverageTimeMs: 500,
+ expectedNthPercentileTimeMs: 2000,
+ },
+ {
+ description: 'High Load - 0% Throttled - Burst Single Window',
+ totalRequests: 20000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.2,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 500,
+ expectedNthPercentileTimeMs: 1000,
+ },
+ {
+ description: 'Extreme Load - 0% Throttled - Burst Single Window',
+ totalRequests: 40000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0,
+ proportionHighCost: 0,
+ proportionJitter: 0.2,
+ expectedTotalTimeMs: 3000,
+ expectedAverageTimeMs: 1500,
+ expectedNthPercentileTimeMs: 2000,
+ },
+ {
+ description: 'High Load - 50% Throttled - Burst Single Window',
+ totalRequests: 20000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0,
+ proportionJitter: 0.2,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 500,
+ expectedNthPercentileTimeMs: 1000,
+ },
+ {
+ description: 'High Load - 50% Throttled - Sustained Single Window',
+ totalRequests: 20000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0,
+ proportionJitter: 0.8,
+ expectedTotalTimeMs: 1000,
+ expectedAverageTimeMs: 500,
+ expectedNthPercentileTimeMs: 500,
+ },
+ {
+ description: 'High Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows',
+ totalRequests: 40000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0.5,
+ proportionJitter: 2.2,
+ expectedTotalTimeMs: 3000,
+ expectedAverageTimeMs: 30,
+ expectedNthPercentileTimeMs: 100,
+ },
+ {
+ description: 'Extreme Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows',
+ totalRequests: 80000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0.5,
+ proportionJitter: 2.2,
+ expectedTotalTimeMs: 4000,
+ expectedAverageTimeMs: 1000,
+ expectedNthPercentileTimeMs: 1500,
+ },
+ {
+ description: 'High Load - 50% Throttled & 90% High-Cost - Sustained Multiple Windows',
+ totalRequests: 40000,
+ proportionUniqueIds: 0.5,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0.9,
+ proportionJitter: 2.2,
+ expectedTotalTimeMs: 3000,
+ expectedAverageTimeMs: 50,
+ expectedNthPercentileTimeMs: 200,
+ },
+ {
+ description: 'High Load - 50% Throttled & 0% Unique - Sustained Multiple Windows',
+ totalRequests: 40000,
+ proportionUniqueIds: 0,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0,
+ proportionJitter: 2.2,
+ expectedTotalTimeMs: 3000,
+ expectedAverageTimeMs: 30,
+ expectedNthPercentileTimeMs: 200,
+ },
+ {
+ description: 'High Load - 50% Throttled & 100% Unique - Sustained Multiple Windows',
+ totalRequests: 40000,
+ proportionUniqueIds: 1,
+ proportionThrottled: 0.5,
+ proportionHighCost: 0,
+ proportionJitter: 2.2,
+ expectedTotalTimeMs: 3000,
+ expectedAverageTimeMs: 30,
+ expectedNthPercentileTimeMs: 100,
+ },
+ ];
+ const mockLowCost = 1;
+ const mockHighCost = 10;
+ const mockWindowDuration = 1;
+ const mockWindowDurationMs = mockWindowDuration * 1000;
+ const mockProportionRefill = 0.5;
+
+ const testThrottledCountErrorTolerance = 0.2;
+ const testPercentile = 0.95;
+
+ function printHistogram(results) {
+ // Define the number of bins for the histogram
+ const bins = 10;
+
+ // Find the maximum duration to scale the histogram
+ const maxDuration = Math.max(...results.map((r) => r.duration));
+
+ // Initialize an array for the histogram bins
+ const histogram = Array(bins).fill(0);
+
+ // Populate the histogram bins
+ results.forEach((result) => {
+ const index = Math.floor((result.duration / maxDuration) * bins);
+ histogram[index < bins ? index : bins - 1]++;
+ });
+
+ // Find the maximum bin count to scale the histogram height
+ const maxCount = Math.max(...histogram);
+
+ // Print the histogram
+ console.log(`\t Request Time (ms)`);
+ histogram.forEach((count, i) => {
+ const bar = '*'.repeat((count / maxCount) * 50); // Scale to a max width of 50 "*"
+ console.log(`\t ${(((i + 1) / bins) * maxDuration).toFixed(2).padStart(7)}: ${bar}`);
+ });
+ }
+
+ testCases
+ .map(
+ ({
+ description,
+ totalRequests,
+ proportionUniqueIds,
+ proportionThrottled,
+ proportionHighCost,
+ proportionJitter,
+ expectedAverageTimeMs,
+ expectedNthPercentileTimeMs,
+ expectedTotalTimeMs,
+ }) => {
+ return () => {
+ describe(description, () => {
+ let testContext;
+ let results: Array<{ duration: number; success: boolean }>;
+ let totalTime: number;
+ let averageTime: number;
+ let successCount: number;
+ let throttledCount: number;
+ let variance: number;
+ let stdev: number;
+ let nthPercentile: number;
+
+ const maxTokens = Math.ceil(totalRequests * (1 - proportionThrottled));
+ const uniqueIdRequests = Math.max(1, Math.floor(totalRequests * proportionUniqueIds));
+ const uniqueIds = Array.from({ length: uniqueIdRequests }).map(() => uuid());
+ const mockRepeatId = uuid();
+ const maxJitterMs = mockWindowDurationMs * proportionJitter;
+
+ const refillPerWindow = (maxTokens * mockProportionRefill) / mockWindowDuration;
+
+ before(async () => {
+ const cacheService = await inMemoryCacheService.useFactory();
+ testContext = {
+ redis: EvaluateTokenBucketRateLimit.getCacheClient(cacheService),
+ };
+
+ const proms = Array.from({ length: totalRequests }).map(async (_val, index) => {
+ const cost = Math.random() < proportionHighCost ? mockHighCost : mockLowCost;
+ /**
+ * Distribute unique ids with request allocation skewed left.
+ * matching an expected distribution of requests per unique API client, where:
+ * - the majority of clients make a small number of requests
+ * - a small number of clients make a large number of requests
+ *
+ * Number of Requests per Unique Id
+ * ID Requests
+ * 1 *
+ * 2 **
+ * 3 ****
+ * 4 ******
+ * 5 *********
+ * 6 *************
+ * 7 *****************
+ * 8 ***********************
+ * 9 ********************************
+ * 10 *******************************************
+ */
+ const id =
+ Math.random() < proportionUniqueIds
+ ? uniqueIds[Math.floor((index / totalRequests) * uniqueIds.length)]
+ : mockRepeatId;
+
+ const jitter = Math.floor(Math.random() * maxJitterMs);
+ await new Promise((resolve) => setTimeout(resolve, jitter));
+ const start = Date.now();
+ const limit = EvaluateTokenBucketRateLimit.tokenBucketLimiter(
+ refillPerWindow,
+ mockWindowDuration,
+ maxTokens,
+ cost
+ );
+ const { success } = await limit(testContext, id);
+ const end = Date.now();
+ const duration = end - start;
+
+ return {
+ duration,
+ success,
+ };
+ });
+
+ const startAll = Date.now();
+ results = await Promise.all(proms);
+ const endAll = Date.now();
+
+ totalTime = endAll - startAll;
+ averageTime = results.reduce((acc, val) => acc + val.duration, 0) / results.length;
+ variance =
+ results.reduce((acc, val) => acc + Math.pow(val.duration - averageTime, 2), 0) / results.length;
+ stdev = Math.sqrt(variance);
+ nthPercentile = results.sort((a, b) => a.duration - b.duration)[
+ Math.floor(results.length * testPercentile)
+ ].duration;
+ successCount = results.filter(({ success }) => success).length;
+ throttledCount = totalRequests - successCount;
+
+ console.log(
+ `\t Params: Total Req: ${totalRequests.toLocaleString()}\tUsers: ${uniqueIdRequests.toLocaleString()}\tThrottled: ${
+ proportionThrottled * 100
+ }%\tHigh Cost: ${proportionHighCost * 100}%\tJitter: ${maxJitterMs}ms`
+ );
+ console.log(
+ `\t Stats: Total Time: ${totalTime.toLocaleString()}ms\tAvg: ${averageTime.toFixed(
+ 1
+ )}ms\tStdev: ${stdev.toFixed(1)}\tp(${
+ testPercentile * 100
+ }): ${nthPercentile}\tThrottled: ${throttledCount.toLocaleString()}`
+ );
+ printHistogram(results);
+ });
+
+ describe('Script Performance', () => {
+ it(`should be able to process ${totalRequests.toLocaleString()} evaluations in less than ${expectedTotalTimeMs}ms`, async () => {
+ expect(totalTime).to.be.lessThan(expectedTotalTimeMs);
+ });
+
+ it(`should have average evaluation duration less than ${expectedAverageTimeMs}ms`, async () => {
+ expect(averageTime).to.be.lessThan(expectedAverageTimeMs);
+ });
+
+ it(`should have ${
+ testPercentile * 100
+ }th percentile evaluation duration less than ${expectedNthPercentileTimeMs}ms`, async () => {
+ expect(nthPercentile).to.be.lessThan(expectedNthPercentileTimeMs);
+ });
+ });
+
+ describe('Script Throttle Evaluation', () => {
+ const proportionRequestsPerWindow =
+ maxJitterMs > mockWindowDurationMs ? mockWindowDurationMs / maxJitterMs : 1;
+ const totalRequestsPerWindow = Math.floor(totalRequests * proportionRequestsPerWindow);
+ const uniqueRequestsPerWindow = Math.floor(totalRequestsPerWindow * (1 - proportionThrottled));
+ const expectedPerRequestCost =
+ (1 - proportionHighCost) * mockLowCost + proportionHighCost * mockHighCost;
+
+ const expectedWindowCost = uniqueRequestsPerWindow * expectedPerRequestCost;
+ const firstWindowThrottledRequests =
+ expectedWindowCost > maxTokens ? (expectedWindowCost - maxTokens) / expectedPerRequestCost : 0;
+ const secondWindowMaxTokens = Math.max(
+ maxTokens,
+ maxTokens - firstWindowThrottledRequests + refillPerWindow
+ );
+ const secondWindowThrottledRequests =
+ expectedWindowCost > secondWindowMaxTokens
+ ? (expectedWindowCost - secondWindowMaxTokens) / expectedPerRequestCost
+ : 0;
+
+ const expectedThrottledCount = firstWindowThrottledRequests + secondWindowThrottledRequests;
+ const expectedThrottledCountMin = Math.floor(
+ expectedThrottledCount * (1 - testThrottledCountErrorTolerance)
+ );
+ const expectedThrottledCountMax = Math.floor(
+ expectedThrottledCount * (1 + testThrottledCountErrorTolerance)
+ );
+
+ it(`should throttle between ${expectedThrottledCountMin} and ${expectedThrottledCountMax} requests`, async () => {
+ expect(throttledCount).to.be.greaterThanOrEqual(expectedThrottledCountMin);
+ expect(throttledCount).to.be.lessThanOrEqual(expectedThrottledCountMax);
+ });
+ });
+ });
+ };
+ }
+ )
+ .forEach((testCase) => testCase());
+ });
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts
new file mode 100644
index 00000000000..14b0b8e2eb4
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts
@@ -0,0 +1,60 @@
+import { Ratelimit } from '@upstash/ratelimit';
+
+export type UpstashRedisClient = ConstructorParameters[0]['redis'];
+
+export type EvaluateTokenBucketRateLimitResponseDto = {
+ /**
+ * Whether the request may pass(true) or exceeded the limit(false)
+ */
+ success: boolean;
+ /**
+ * Maximum number of requests allowed within a window.
+ */
+ limit: number;
+ /**
+ * How many requests the client has left within the current window.
+ */
+ remaining: number;
+ /**
+ * Unix timestamp in milliseconds when the limits are reset.
+ */
+ reset: number;
+};
+
+export type RegionLimiter = ReturnType;
+
+/**
+ * You have a bucket filled with `{maxTokens}` tokens that refills constantly
+ * at `{refillRate}` per `{interval}`.
+ * Every request will remove `{cost}` token(s) from the bucket and if there is no
+ * token to take, the request is rejected.
+ *
+ * **Pro:**
+ *
+ * - Bursts of requests are smoothed out and you can process them at a constant
+ * rate.
+ * - Allows to set a higher initial burst limit by setting `maxTokens` higher
+ * than `refillRate`
+ */
+export type CostLimiter = (
+ /**
+ * How many tokens are refilled per `interval`
+ *
+ * An interval of `10s` and refillRate of 5 will cause a new token to be added every 2 seconds.
+ */
+ refillRate: number,
+ /**
+ * The interval in seconds for the `refillRate`
+ */
+ interval: number,
+ /**
+ * Maximum number of tokens.
+ * A newly created bucket starts with this many tokens.
+ * Useful to allow higher burst limits.
+ */
+ maxTokens: number,
+ /**
+ * The number of tokens used in the request.
+ */
+ cost: number
+) => RegionLimiter;
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts
new file mode 100644
index 00000000000..b95f62363de
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts
@@ -0,0 +1,180 @@
+import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
+import { Ratelimit } from '@upstash/ratelimit';
+import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command';
+import { CacheService, InstrumentUsecase } from '@novu/application-generic';
+import {
+ EvaluateTokenBucketRateLimitResponseDto,
+ RegionLimiter,
+ UpstashRedisClient,
+} from './evaluate-token-bucket-rate-limit.types';
+
+const LOG_CONTEXT = 'EvaluateTokenBucketRateLimit';
+
+@Injectable()
+export class EvaluateTokenBucketRateLimit {
+ private ephemeralCache = new Map();
+ public algorithm = 'token bucket';
+
+ constructor(private cacheService: CacheService) {}
+
+ @InstrumentUsecase()
+ async execute(command: EvaluateTokenBucketRateLimitCommand): Promise {
+ if (!this.cacheService.cacheEnabled()) {
+ const message = 'Rate limiting cache service is not available';
+ Logger.error(message, LOG_CONTEXT);
+ throw new ServiceUnavailableException(message);
+ }
+
+ const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(this.cacheService);
+
+ const ratelimit = new Ratelimit({
+ redis: cacheClient,
+ limiter: EvaluateTokenBucketRateLimit.tokenBucketLimiter(
+ command.refillRate,
+ command.windowDuration,
+ command.maxTokens,
+ command.cost
+ ),
+ prefix: '', // Empty cache key prefix to give us full control over the key format
+ ephemeralCache: this.ephemeralCache,
+ });
+ try {
+ const { success, limit, remaining, reset } = await ratelimit.limit(command.identifier);
+
+ return {
+ success,
+ limit,
+ remaining,
+ reset,
+ };
+ } catch (error) {
+ const apiMessage = 'Failed to evaluate rate limit';
+ const logMessage = `${apiMessage} for identifier: "${command.identifier}". Error: "${error}"`;
+ Logger.error(logMessage, LOG_CONTEXT);
+ throw new ServiceUnavailableException(apiMessage);
+ }
+ }
+
+ public static getCacheClient(cacheService: CacheService): UpstashRedisClient {
+ // Adapter for the @upstash/redis client -> cache client
+ return {
+ sadd: async (key, ...members) => cacheService.sadd(key, ...members.map((member) => String(member))),
+ eval: async (script, keys, args) =>
+ cacheService.eval(
+ script,
+ keys,
+ args.map((arg) => String(arg))
+ ),
+ };
+ }
+
+ /**
+ * Token Bucket algorithm with variable cost. Adapted from @upstash/ratelimit and modified to support variable cost.
+ * Also influenced by Krakend's token bucket implementation to delay refills until bucket is empty.
+ *
+ * @see https://github.com/upstash/ratelimit/blob/3a8cfb00e827188734ac347965cb743a75fcb98a/src/single.ts#L292
+ * @see https://github.com/krakend/krakend-ratelimit/blob/369f0be9b51a4fb8ab7d43e4833d076b461a4374/rate.go#L85
+ */
+ public static tokenBucketLimiter(
+ refillRate: number,
+ interval: number,
+ maxTokens: number,
+ cost: number
+ ): RegionLimiter {
+ const script = /* Lua */ `
+ local key = KEYS[1] -- current interval identifier including prefixes
+ local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
+ local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
+ local fillInterval = tonumber(ARGV[3]) -- time between refills in milliseconds
+ local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
+ local cost = tonumber(ARGV[5]) -- cost of request
+ local remaining = 0 -- remaining number of tokens
+ local reset = 0 -- timestamp when next request of {cost} token(s) can be accepted
+ local resetCost = 0 -- multiplier for the next reset time
+ local lastRefill = 0 -- timestamp of last refill
+
+ local bucket = redis.call("HMGET", key, "lastRefill", "tokens")
+
+ if bucket[1] == false then
+ -- The bucket does not exist yet, so we create it and add a ttl.
+ lastRefill = now
+ remaining = maxTokens - cost
+ resetCost = (remaining < cost) and (cost - remaining) or cost
+ redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining)
+ redis.call("PEXPIRE", key, interval * 2)
+ else
+ -- The current bucket does exist
+ lastRefill = tonumber(bucket[1])
+ local tokens = tonumber(bucket[2])
+
+ if tokens >= cost then
+ -- Delay refill until bucket is empty
+ remaining = tokens - cost
+ resetCost = (remaining < cost) and (cost - remaining) or cost
+ redis.call("HMSET", key, "tokens", remaining)
+ else
+ local elapsed = now - lastRefill
+ local tokensToAdd = math.floor(elapsed / fillInterval)
+ local newTokens = math.min(maxTokens, tokens + tokensToAdd)
+ remaining = newTokens - cost
+
+ if remaining >= 0 then
+ -- Update the time of the last refill depending on how many tokens we added
+ lastRefill = lastRefill + tokensToAdd * fillInterval
+ resetCost = (remaining < cost) and (cost - remaining) or cost
+ redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining)
+ redis.call("PEXPIRE", key, interval * 2)
+ else
+ resetCost = cost - tokens
+ end
+ end
+ end
+
+ reset = lastRefill + resetCost * fillInterval
+ return {remaining, reset}
+`;
+
+ const intervalDurationMs = interval * 1e3;
+ const fillInterval = intervalDurationMs / refillRate;
+
+ return async function (ctx, identifier) {
+ // Cost needs to be included in local cache identifier to ensure lower cost requests are not blocked
+ const localCacheIdentifier = `${identifier}:${cost}`;
+
+ if (ctx.cache) {
+ const { blocked, reset } = ctx.cache.isBlocked(localCacheIdentifier);
+ if (blocked) {
+ return {
+ success: false,
+ limit: refillRate,
+ remaining: 0,
+ reset: reset,
+ pending: Promise.resolve(),
+ };
+ }
+ }
+
+ const now = Date.now();
+
+ const [remaining, reset] = (await ctx.redis.eval(
+ script,
+ [identifier],
+ [maxTokens, intervalDurationMs, fillInterval, now, cost]
+ )) as [number, number];
+
+ const success = remaining >= 0;
+ const nonNegativeRemaining = Math.max(0, remaining);
+ if (ctx.cache && !success) {
+ ctx.cache.blockUntil(localCacheIdentifier, reset);
+ }
+
+ return {
+ success,
+ limit: refillRate,
+ remaining: nonNegativeRemaining,
+ reset,
+ pending: Promise.resolve(),
+ };
+ };
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts
new file mode 100644
index 00000000000..a4c20ef693e
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts
@@ -0,0 +1,3 @@
+export * from './evaluate-token-bucket-rate-limit.command';
+export * from './evaluate-token-bucket-rate-limit.usecase';
+export * from './evaluate-token-bucket-rate-limit.types';
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts
new file mode 100644
index 00000000000..ccbf77fb37a
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts
@@ -0,0 +1,41 @@
+import { Test } from '@nestjs/testing';
+import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config.usecase';
+import {
+ ApiRateLimitAlgorithmEnum,
+ ApiRateLimitAlgorithmEnvVarFormat,
+ DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG,
+} from '@novu/shared';
+import { expect } from 'chai';
+
+describe('GetApiRateLimitAlgorithmConfig', () => {
+ let useCase: GetApiRateLimitAlgorithmConfig;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [GetApiRateLimitAlgorithmConfig],
+ }).compile();
+
+ useCase = moduleRef.get(GetApiRateLimitAlgorithmConfig);
+ });
+
+ it('should use the default rate limit algorithm config when no environment variables are set', () => {
+ expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG);
+ });
+
+ it('should override default rate limit algorithm config with environment variables', () => {
+ const mockOverrideBurstAllowance = 0.2;
+ const mockApiRateLimitConfigurationKey = ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE;
+
+ const envVarName: ApiRateLimitAlgorithmEnvVarFormat = `API_RATE_LIMIT_ALGORITHM_${
+ mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase
+ }`;
+ process.env[envVarName] = `${mockOverrideBurstAllowance}`;
+
+ // Re-initialize the defaultApiRateLimits after setting the environment variable
+ useCase.loadDefault();
+ const result = useCase.default;
+
+ expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBurstAllowance);
+ delete process.env[envVarName]; // cleanup
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts
new file mode 100644
index 00000000000..24b84247405
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts
@@ -0,0 +1,42 @@
+import { Injectable } from '@nestjs/common';
+import {
+ ApiRateLimitAlgorithmEnum,
+ ApiRateLimitAlgorithmEnvVarFormat,
+ DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG,
+ IApiRateLimitAlgorithm,
+} from '@novu/shared';
+
+@Injectable()
+export class GetApiRateLimitAlgorithmConfig {
+ public default: IApiRateLimitAlgorithm;
+
+ constructor() {
+ this.loadDefault();
+ }
+
+ public loadDefault(): void {
+ this.default = this.createDefault();
+ }
+
+ private createDefault(): IApiRateLimitAlgorithm {
+ const mergedConfig: IApiRateLimitAlgorithm = { ...DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG };
+
+ // Read process environment only once for performance
+ const processEnv = process.env;
+
+ Object.values(ApiRateLimitAlgorithmEnum).forEach((algorithmOption) => {
+ const envVarName = this.getEnvVarName(algorithmOption);
+ const envVarValue = processEnv[envVarName];
+
+ if (envVarValue) {
+ mergedConfig[algorithmOption] = Number(envVarValue);
+ }
+ });
+
+ return mergedConfig;
+ }
+
+ private getEnvVarName(algorithmOption: ApiRateLimitAlgorithmEnum): ApiRateLimitAlgorithmEnvVarFormat {
+ return `API_RATE_LIMIT_ALGORITHM_${algorithmOption.toUpperCase() as Uppercase}`;
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts
new file mode 100644
index 00000000000..89843dfaced
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts
@@ -0,0 +1 @@
+export * from './get-api-rate-limit-algorithm-config.usecase';
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts
new file mode 100644
index 00000000000..7bdfc359b59
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts
@@ -0,0 +1,37 @@
+import { Test } from '@nestjs/testing';
+import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config.usecase';
+import { ApiRateLimitCostEnum, ApiRateLimitCostEnvVarFormat, DEFAULT_API_RATE_LIMIT_COST_CONFIG } from '@novu/shared';
+import { expect } from 'chai';
+
+describe('GetApiRateLimitCostConfig', () => {
+ let useCase: GetApiRateLimitCostConfig;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [GetApiRateLimitCostConfig],
+ }).compile();
+
+ useCase = moduleRef.get(GetApiRateLimitCostConfig);
+ });
+
+ it('should use the default rate limit cost configuration when no environment variables are set', () => {
+ expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_COST_CONFIG);
+ });
+
+ it('should override default rate limit cost configuration with environment variables', () => {
+ const mockOverrideBulkCost = 15;
+ const mockApiRateLimitConfigurationKey = ApiRateLimitCostEnum.BULK;
+
+ const envVarName: ApiRateLimitCostEnvVarFormat = `API_RATE_LIMIT_COST_${
+ mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase
+ }`;
+ process.env[envVarName] = `${mockOverrideBulkCost}`;
+
+ // Re-initialize the defaultApiRateLimits after setting the environment variable
+ useCase.loadDefault();
+ const result = useCase.default;
+
+ expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBulkCost);
+ delete process.env[envVarName]; // cleanup
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts
new file mode 100644
index 00000000000..a3f525263a8
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts
@@ -0,0 +1,42 @@
+import { Injectable } from '@nestjs/common';
+import {
+ ApiRateLimitCostEnum,
+ ApiRateLimitCostEnvVarFormat,
+ DEFAULT_API_RATE_LIMIT_COST_CONFIG,
+ IApiRateLimitCost,
+} from '@novu/shared';
+
+@Injectable()
+export class GetApiRateLimitCostConfig {
+ public default: IApiRateLimitCost;
+
+ constructor() {
+ this.loadDefault();
+ }
+
+ public loadDefault(): void {
+ this.default = this.createDefault();
+ }
+
+ private createDefault(): IApiRateLimitCost {
+ const mergedConfig: IApiRateLimitCost = { ...DEFAULT_API_RATE_LIMIT_COST_CONFIG };
+
+ // Read process environment only once for performance
+ const processEnv = process.env;
+
+ Object.values(ApiRateLimitCostEnum).forEach((costOption) => {
+ const envVarName = this.getEnvVarName(costOption);
+ const envVarValue = processEnv[envVarName];
+
+ if (envVarValue) {
+ mergedConfig[costOption] = Number(envVarValue);
+ }
+ });
+
+ return mergedConfig;
+ }
+
+ private getEnvVarName(costOption: ApiRateLimitCostEnum): ApiRateLimitCostEnvVarFormat {
+ return `API_RATE_LIMIT_COST_${costOption.toUpperCase() as Uppercase}`;
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts
new file mode 100644
index 00000000000..43a8fddce66
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts
@@ -0,0 +1 @@
+export * from './get-api-rate-limit-cost-config.usecase';
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts
new file mode 100644
index 00000000000..a16b36ef59e
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts
@@ -0,0 +1,9 @@
+import { IsDefined, IsEnum } from 'class-validator';
+import { ApiRateLimitCategoryEnum } from '@novu/shared';
+import { EnvironmentCommand } from '../../../shared/commands/project.command';
+
+export class GetApiRateLimitMaximumCommand extends EnvironmentCommand {
+ @IsDefined()
+ @IsEnum(ApiRateLimitCategoryEnum)
+ apiRateLimitCategory: ApiRateLimitCategoryEnum;
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto.ts
new file mode 100644
index 00000000000..08e734d8888
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto.ts
@@ -0,0 +1,8 @@
+import { ApiServiceLevelEnum } from '@novu/shared';
+
+export const CUSTOM_API_SERVICE_LEVEL = 'custom';
+
+export type ApiServiceLevel = ApiServiceLevelEnum | typeof CUSTOM_API_SERVICE_LEVEL;
+
+// Array type to keep the cached entity as small as possible for more performant caching
+export type GetApiRateLimitMaximumDto = [apiRateLimitMaximum: number, apiServiceLevel: ApiServiceLevel];
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts
new file mode 100644
index 00000000000..21b8fee5a11
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts
@@ -0,0 +1,219 @@
+import { EnvironmentRepository, OrganizationRepository } from '@novu/dal';
+import { UserSession } from '@novu/testing';
+import { ApiRateLimitCategoryEnum, ApiServiceLevelEnum } from '@novu/shared';
+import { expect } from 'chai';
+import * as sinon from 'sinon';
+import { Test } from '@nestjs/testing';
+import { CacheService, MockCacheService } from '@novu/application-generic';
+import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from './index';
+import { SharedModule } from '../../../shared/shared.module';
+import { GetApiRateLimitServiceMaximumConfig } from '../get-api-rate-limit-service-maximum-config';
+import { RateLimitingModule } from '../../rate-limiting.module';
+import { CUSTOM_API_SERVICE_LEVEL } from './get-api-rate-limit-maximum.dto';
+
+const mockDefaultApiRateLimits = {
+ [ApiServiceLevelEnum.FREE]: {
+ [ApiRateLimitCategoryEnum.GLOBAL]: 60,
+ [ApiRateLimitCategoryEnum.TRIGGER]: 60,
+ [ApiRateLimitCategoryEnum.CONFIGURATION]: 60,
+ },
+ [ApiServiceLevelEnum.UNLIMITED]: {
+ [ApiRateLimitCategoryEnum.GLOBAL]: 600,
+ [ApiRateLimitCategoryEnum.TRIGGER]: 600,
+ [ApiRateLimitCategoryEnum.CONFIGURATION]: 600,
+ },
+};
+
+describe('GetApiRateLimitMaximum', async () => {
+ let useCase: GetApiRateLimitMaximum;
+ let session: UserSession;
+ let organizationRepository: OrganizationRepository;
+ let environmentRepository: EnvironmentRepository;
+ let getDefaultApiRateLimits: GetApiRateLimitServiceMaximumConfig;
+
+ let findOneEnvironmentStub: sinon.SinonStub;
+ let findOneOrganizationStub: sinon.SinonStub;
+ let defaultApiRateLimits: sinon.SinonStub;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [SharedModule, RateLimitingModule],
+ providers: [],
+ })
+ .overrideProvider(CacheService)
+ .useValue(MockCacheService.createClient())
+ .compile();
+
+ session = new UserSession();
+ await session.initialize();
+
+ useCase = moduleRef.get(GetApiRateLimitMaximum);
+ organizationRepository = moduleRef.get(OrganizationRepository);
+ environmentRepository = moduleRef.get(EnvironmentRepository);
+ getDefaultApiRateLimits = moduleRef.get(GetApiRateLimitServiceMaximumConfig);
+
+ findOneEnvironmentStub = sinon.stub(environmentRepository, 'findOne');
+ findOneOrganizationStub = sinon.stub(organizationRepository, 'findOne');
+ defaultApiRateLimits = sinon.stub(getDefaultApiRateLimits, 'default').value(mockDefaultApiRateLimits);
+ });
+
+ afterEach(() => {
+ findOneEnvironmentStub.restore();
+ findOneOrganizationStub.restore();
+ });
+
+ it('should throw error when environment is not found', async () => {
+ findOneEnvironmentStub.resolves(undefined);
+
+ try {
+ await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: ApiRateLimitCategoryEnum.GLOBAL,
+ })
+ );
+ throw new Error('Should not reach here');
+ } catch (e) {
+ expect(e.message).to.equal(`Environment id: ${session.environment._id} not found`);
+ }
+ });
+
+ describe('Environment DOES have rate limits specified', () => {
+ const mockGlobalLimit = 65;
+ const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
+
+ beforeEach(() => {
+ findOneEnvironmentStub.resolves({
+ apiRateLimits: {
+ [mockApiRateLimitCategory]: mockGlobalLimit,
+ },
+ });
+ });
+
+ it('should return api rate limit for the category set on environment', async () => {
+ const [rateLimit] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(rateLimit).to.equal(mockGlobalLimit);
+ });
+
+ it('should return api service level of CUSTOM', async () => {
+ const [, apiServiceLevel] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(apiServiceLevel).to.equal(CUSTOM_API_SERVICE_LEVEL);
+ });
+ });
+
+ describe('Environment DOES NOT have rate limits specified', () => {
+ const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
+
+ beforeEach(() => {
+ findOneEnvironmentStub.resolves({
+ apiRateLimits: undefined,
+ });
+ });
+
+ describe('Organization DOES have api service level specified', () => {
+ const mockApiServiceLevel = ApiServiceLevelEnum.FREE;
+
+ beforeEach(() => {
+ findOneOrganizationStub.resolves({
+ apiServiceLevel: mockApiServiceLevel,
+ });
+ });
+
+ it('should return default api rate limit for the organizations apiServiceLevel when apiServiceLevel IS set on organization', async () => {
+ const defaultApiRateLimit = mockDefaultApiRateLimits[mockApiServiceLevel][mockApiRateLimitCategory];
+
+ const [rateLimit] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(rateLimit).to.equal(defaultApiRateLimit);
+ });
+
+ it('should return the api service level set on organization when apiServiceLevel IS set on organization', async () => {
+ const mockApiServiceLevel = ApiServiceLevelEnum.FREE;
+
+ const [, apiServiceLevel] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(apiServiceLevel).to.equal(mockApiServiceLevel);
+ });
+ });
+
+ describe('Organization DOES NOT have api service level specified', () => {
+ beforeEach(() => {
+ findOneOrganizationStub.resolves({
+ apiServiceLevel: undefined,
+ });
+ });
+
+ it('should return default api rate limit for the UNLIMITED service level when apiServiceLevel IS NOT set on organization', async () => {
+ const defaultApiRateLimit = mockDefaultApiRateLimits[ApiServiceLevelEnum.UNLIMITED][mockApiRateLimitCategory];
+
+ const [rateLimit] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(rateLimit).to.equal(defaultApiRateLimit);
+ });
+
+ it('should return the default api service level of UNLIMITED when apiServiceLevel IS NOT set on organization', async () => {
+ const defaultApiServiceLevel = ApiServiceLevelEnum.UNLIMITED;
+
+ const [, apiServiceLevel] = await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+
+ expect(apiServiceLevel).to.equal(defaultApiServiceLevel);
+ });
+ });
+
+ it('should throw an error when the organization is not found', async () => {
+ findOneOrganizationStub.resolves(undefined);
+
+ try {
+ await useCase.execute(
+ GetApiRateLimitMaximumCommand.create({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ apiRateLimitCategory: mockApiRateLimitCategory,
+ })
+ );
+ throw new Error('Should not reach here');
+ } catch (e) {
+ expect(e.message).to.equal(`Organization id: ${session.organization._id} not found`);
+ }
+ });
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts
new file mode 100644
index 00000000000..a907666194c
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts
@@ -0,0 +1,79 @@
+import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
+import { EnvironmentRepository, OrganizationRepository } from '@novu/dal';
+import { buildMaximumApiRateLimitKey, CachedEntity, InstrumentUsecase } from '@novu/application-generic';
+import { ApiRateLimitCategoryEnum, ApiServiceLevelEnum, IApiRateLimitMaximum } from '@novu/shared';
+import { GetApiRateLimitMaximumCommand } from './get-api-rate-limit-maximum.command';
+import { GetApiRateLimitServiceMaximumConfig } from '../get-api-rate-limit-service-maximum-config';
+import { ApiServiceLevel, CUSTOM_API_SERVICE_LEVEL, GetApiRateLimitMaximumDto } from './get-api-rate-limit-maximum.dto';
+
+const LOG_CONTEXT = 'GetApiRateLimit';
+
+@Injectable()
+export class GetApiRateLimitMaximum {
+ constructor(
+ private environmentRepository: EnvironmentRepository,
+ private organizationRepository: OrganizationRepository,
+ private getDefaultApiRateLimits: GetApiRateLimitServiceMaximumConfig
+ ) {}
+
+ @InstrumentUsecase()
+ async execute(command: GetApiRateLimitMaximumCommand): Promise {
+ return await this.getApiRateLimit({
+ apiRateLimitCategory: command.apiRateLimitCategory,
+ _environmentId: command.environmentId,
+ _organizationId: command.organizationId,
+ });
+ }
+
+ @CachedEntity({
+ builder: (command: { apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string }) =>
+ buildMaximumApiRateLimitKey({
+ _environmentId: command._environmentId,
+ apiRateLimitCategory: command.apiRateLimitCategory,
+ }),
+ })
+ private async getApiRateLimit({
+ apiRateLimitCategory,
+ _environmentId,
+ _organizationId,
+ }: {
+ apiRateLimitCategory: ApiRateLimitCategoryEnum;
+ _environmentId: string;
+ _organizationId: string;
+ }): Promise {
+ const environment = await this.environmentRepository.findOne({ _id: _environmentId });
+
+ if (!environment) {
+ const message = `Environment id: ${_environmentId} not found`;
+ Logger.error(message, LOG_CONTEXT);
+ throw new InternalServerErrorException(message);
+ }
+
+ let apiRateLimits: IApiRateLimitMaximum;
+ let apiServiceLevel: ApiServiceLevel;
+ if (environment.apiRateLimits) {
+ apiServiceLevel = CUSTOM_API_SERVICE_LEVEL;
+ apiRateLimits = environment.apiRateLimits;
+ } else {
+ const organization = await this.organizationRepository.findOne({ _id: _organizationId });
+
+ if (!organization) {
+ const message = `Organization id: ${_organizationId} not found`;
+ Logger.error(message, LOG_CONTEXT);
+ throw new InternalServerErrorException(message);
+ }
+
+ if (organization.apiServiceLevel) {
+ apiServiceLevel = organization.apiServiceLevel;
+ } else {
+ // TODO: NV-3067 - Remove this once all organizations have a service level
+ apiServiceLevel = ApiServiceLevelEnum.UNLIMITED;
+ }
+ apiRateLimits = this.getDefaultApiRateLimits.default[apiServiceLevel];
+ }
+
+ const apiRateLimit = apiRateLimits[apiRateLimitCategory];
+
+ return [apiRateLimit, apiServiceLevel];
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts
new file mode 100644
index 00000000000..f71bcd4c7f9
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts
@@ -0,0 +1,2 @@
+export * from './get-api-rate-limit-maximum.command';
+export * from './get-api-rate-limit-maximum.usecase';
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts
new file mode 100644
index 00000000000..f59c0ed443f
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts
@@ -0,0 +1,87 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { GetApiRateLimitServiceMaximumConfig } from './get-api-rate-limit-service-maximum-config.usecase';
+import {
+ ApiRateLimitCategoryEnum,
+ ApiRateLimitServiceMaximumEnvVarFormat,
+ ApiServiceLevelEnum,
+ DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG,
+} from '@novu/shared';
+import { expect } from 'chai';
+import * as sinon from 'sinon';
+import { CacheService, InvalidateCacheService, cacheService as cacheServiceProvider } from '@novu/application-generic';
+
+const mockRateLimitServiceLevel = ApiServiceLevelEnum.FREE;
+const mockRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
+const mockEnvVarName: ApiRateLimitServiceMaximumEnvVarFormat = `API_RATE_LIMIT_MAXIMUM_${
+ mockRateLimitServiceLevel.toUpperCase() as Uppercase
+}_${mockRateLimitCategory.toUpperCase() as Uppercase}`;
+const mockOverrideRateLimit = 65;
+
+describe('GetApiRateLimitServiceMaximumConfig', () => {
+ let useCase: GetApiRateLimitServiceMaximumConfig;
+ let invalidateCacheService: InvalidateCacheService;
+ let cacheService: CacheService;
+
+ let invalidateByKeyStub: sinon.SinonStub;
+ let cacheServiceIsEnabledStub: sinon.SinonStub;
+ let moduleRef: TestingModule;
+
+ beforeEach(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [cacheServiceProvider, InvalidateCacheService, GetApiRateLimitServiceMaximumConfig],
+ }).compile();
+
+ useCase = moduleRef.get(GetApiRateLimitServiceMaximumConfig);
+ invalidateCacheService = moduleRef.get(InvalidateCacheService);
+ cacheService = moduleRef.get(CacheService);
+
+ invalidateByKeyStub = sinon.stub(invalidateCacheService, 'invalidateByKey').resolves();
+ cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true);
+
+ await moduleRef.init();
+ });
+
+ afterEach(() => {
+ invalidateByKeyStub.reset();
+ });
+
+ it('should load the default API rate limits on module init', () => {
+ expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG);
+ });
+
+ it('should override default API rate limits with environment variables', async () => {
+ process.env[mockEnvVarName] = `${mockOverrideRateLimit}`;
+ // Re-initialize the defaults after setting the environment variable
+ await useCase.loadDefault();
+ delete process.env[mockEnvVarName]; // cleanup
+
+ expect(useCase.default[mockRateLimitServiceLevel][mockRateLimitCategory]).to.equal(mockOverrideRateLimit);
+ });
+
+ it('should NOT invalidate the cache when loading defaults and the cache IS disabled', async () => {
+ cacheServiceIsEnabledStub.returns(false);
+ await useCase.loadDefault();
+
+ expect(invalidateByKeyStub.callCount).to.equal(0);
+ });
+
+ it('should NOT invalidate the cache when loading defaults and the config HAS NOT changed between loads', async () => {
+ cacheServiceIsEnabledStub.returns(true);
+ await useCase.loadDefault();
+ await useCase.loadDefault();
+
+ expect(invalidateByKeyStub.callCount).to.equal(0);
+ });
+
+ it('should invalidate the cache when loading defaults and the config HAS changed between loads', async () => {
+ cacheServiceIsEnabledStub.returns(true);
+ await useCase.loadDefault();
+
+ process.env[mockEnvVarName] = `${mockOverrideRateLimit + 1}`;
+ // Re-initialize the defaults after setting the environment variable
+ await useCase.loadDefault();
+ delete process.env[mockEnvVarName]; // cleanup
+
+ expect(invalidateByKeyStub.callCount).to.equal(1);
+ });
+});
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts
new file mode 100644
index 00000000000..df219183fe0
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts
@@ -0,0 +1,87 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import {
+ ApiRateLimitCategoryEnum,
+ ApiRateLimitServiceMaximumEnvVarFormat,
+ ApiServiceLevelEnum,
+ DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG,
+ IApiRateLimitServiceMaximum,
+} from '@novu/shared';
+import { createHash } from 'crypto';
+import {
+ buildMaximumApiRateLimitKey,
+ buildServiceConfigApiRateLimitMaximumKey,
+ CacheService,
+ InvalidateCacheService,
+} from '@novu/application-generic';
+
+@Injectable()
+export class GetApiRateLimitServiceMaximumConfig implements OnModuleInit {
+ public default: IApiRateLimitServiceMaximum = DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG;
+
+ constructor(private invalidateCache: InvalidateCacheService, private cacheService: CacheService) {}
+
+ async onModuleInit() {
+ await this.loadDefault();
+ }
+
+ public async loadDefault(): Promise {
+ const newDefault = this.createDefault();
+ this.default = newDefault;
+
+ if (!this.cacheService.cacheEnabled()) {
+ return;
+ }
+
+ const cacheKey = buildServiceConfigApiRateLimitMaximumKey();
+ const previousHash = await this.cacheService.get(cacheKey);
+ const newHash = this.getConfigHash(newDefault);
+
+ if (previousHash !== newHash) {
+ Logger.log(`Updating API Rate Limit Maximum config cache`, GetApiRateLimitServiceMaximumConfig.name);
+ await this.cacheService.set(cacheKey, newHash);
+
+ this.invalidateCache.invalidateByKey({
+ key: buildMaximumApiRateLimitKey({
+ _environmentId: '*',
+ apiRateLimitCategory: '*',
+ }),
+ });
+ }
+ }
+
+ private getConfigHash(config: IApiRateLimitServiceMaximum): string {
+ const hash = createHash('sha256');
+ hash.update(JSON.stringify(config));
+
+ return hash.digest('hex');
+ }
+
+ private createDefault(): IApiRateLimitServiceMaximum {
+ const mergedConfig: IApiRateLimitServiceMaximum = { ...DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG };
+
+ // Read process environment only once for performance
+ const processEnv = process.env;
+
+ Object.values(ApiServiceLevelEnum).forEach((apiServiceLevel) => {
+ Object.values(ApiRateLimitCategoryEnum).forEach((apiRateLimitCategory) => {
+ const envVarName = this.getEnvVarName(apiServiceLevel, apiRateLimitCategory);
+ const envVarValue = processEnv[envVarName];
+
+ if (envVarValue) {
+ mergedConfig[apiServiceLevel][apiRateLimitCategory] = Number(envVarValue);
+ }
+ });
+ });
+
+ return mergedConfig;
+ }
+
+ private getEnvVarName(
+ apiServiceLevel: ApiServiceLevelEnum,
+ apiRateLimitCategory: ApiRateLimitCategoryEnum
+ ): ApiRateLimitServiceMaximumEnvVarFormat {
+ return `API_RATE_LIMIT_MAXIMUM_${apiServiceLevel.toUpperCase() as Uppercase}_${
+ apiRateLimitCategory.toUpperCase() as Uppercase
+ }`;
+ }
+}
diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts
new file mode 100644
index 00000000000..0a9a8b5431f
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts
@@ -0,0 +1 @@
+export * from './get-api-rate-limit-service-maximum-config.usecase';
diff --git a/apps/api/src/app/rate-limiting/usecases/index.ts b/apps/api/src/app/rate-limiting/usecases/index.ts
new file mode 100644
index 00000000000..7e98991017b
--- /dev/null
+++ b/apps/api/src/app/rate-limiting/usecases/index.ts
@@ -0,0 +1,16 @@
+import { GetApiRateLimitMaximum } from './get-api-rate-limit-maximum';
+import { GetApiRateLimitServiceMaximumConfig } from './get-api-rate-limit-service-maximum-config';
+import { EvaluateApiRateLimit } from './evaluate-api-rate-limit';
+import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config';
+import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config';
+import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit';
+
+export const USE_CASES = [
+ //
+ GetApiRateLimitServiceMaximumConfig,
+ GetApiRateLimitMaximum,
+ GetApiRateLimitAlgorithmConfig,
+ GetApiRateLimitCostConfig,
+ EvaluateApiRateLimit,
+ EvaluateTokenBucketRateLimit,
+];
diff --git a/apps/api/src/app/shared/dtos/message-template.ts b/apps/api/src/app/shared/dtos/message-template.ts
index 578705e489d..bd32aaf2496 100644
--- a/apps/api/src/app/shared/dtos/message-template.ts
+++ b/apps/api/src/app/shared/dtos/message-template.ts
@@ -58,4 +58,7 @@ export class MessageTemplate {
type: ActorTypeEnum;
data: string | null;
};
+
+ @IsOptional()
+ _creatorId?: string;
}
diff --git a/apps/api/src/app/shared/dtos/notification-step.ts b/apps/api/src/app/shared/dtos/notification-step.ts
index 379980ddbca..c789cc3f51d 100644
--- a/apps/api/src/app/shared/dtos/notification-step.ts
+++ b/apps/api/src/app/shared/dtos/notification-step.ts
@@ -14,6 +14,7 @@ import {
MonthlyTypeEnum,
OrdinalEnum,
OrdinalValueEnum,
+ StepVariantDto,
} from '@novu/shared';
import { IsBoolean, ValidateNested } from 'class-validator';
@@ -104,7 +105,7 @@ class DelayScheduledMetadata implements IDelayScheduledMetadata {
}
@ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata)
-export class NotificationStep {
+export class NotificationStepVariant implements StepVariantDto {
@ApiPropertyOptional()
_id?: string;
@@ -156,3 +157,12 @@ export class NotificationStep {
url: string;
};
}
+
+@ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata)
+export class NotificationStep extends NotificationStepVariant {
+ @ApiPropertyOptional({
+ type: NotificationStepVariant,
+ })
+ @ValidateNested()
+ variants?: NotificationStepVariant[];
+}
diff --git a/apps/api/src/app/shared/dtos/preference-channels.ts b/apps/api/src/app/shared/dtos/preference-channels.ts
index 4df2e58f7a5..2da42a689f9 100644
--- a/apps/api/src/app/shared/dtos/preference-channels.ts
+++ b/apps/api/src/app/shared/dtos/preference-channels.ts
@@ -1,14 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IPreferenceChannels } from '@novu/shared';
+import { IsBoolean, IsOptional } from 'class-validator';
-export class PreferenceChannels {
+export class PreferenceChannels implements IPreferenceChannels {
@ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
email?: boolean;
+
@ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
sms?: boolean;
+
@ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
in_app?: boolean;
+
@ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
chat?: boolean;
+
@ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
push?: boolean;
}
diff --git a/apps/api/src/app/shared/dtos/step-filter.ts b/apps/api/src/app/shared/dtos/step-filter.ts
index cd06682db44..f7a98fb827e 100644
--- a/apps/api/src/app/shared/dtos/step-filter.ts
+++ b/apps/api/src/app/shared/dtos/step-filter.ts
@@ -115,12 +115,12 @@ type FilterParts =
export class StepFilter {
@ApiProperty()
- isNegated: boolean;
+ isNegated?: boolean;
@ApiProperty({
enum: ['BOOLEAN', 'TEXT', 'DATE', 'NUMBER', 'STATEMENT', 'LIST', 'MULTI_LIST', 'GROUP'],
})
- type: BuilderFieldType;
+ type?: BuilderFieldType;
@ApiProperty({
enum: ['AND', 'OR'],
diff --git a/apps/api/src/app/shared/dtos/subscriber-channel.ts b/apps/api/src/app/shared/dtos/subscriber-channel.ts
index cb223671a7b..2c14d71907f 100644
--- a/apps/api/src/app/shared/dtos/subscriber-channel.ts
+++ b/apps/api/src/app/shared/dtos/subscriber-channel.ts
@@ -15,6 +15,31 @@ export class ChannelCredentials {
description: 'Contains an array of the subscriber device tokens for a given provider. Used on Push integrations',
})
deviceTokens?: string[];
+
+ @ApiPropertyOptional({
+ description: 'alert_uid for grafana on-call webhook payload',
+ })
+ alertUid?: string;
+
+ @ApiPropertyOptional({
+ description: 'title to be used with grafana on call webhook',
+ })
+ title?: string;
+
+ @ApiPropertyOptional({
+ description: 'image_url property fo grafana on call webhook',
+ })
+ imageUrl?: string;
+
+ @ApiPropertyOptional({
+ description: 'state property fo grafana on call webhook',
+ })
+ state?: string;
+
+ @ApiPropertyOptional({
+ description: 'link_to_upstream_details property fo grafana on call webhook',
+ })
+ externalUrl?: string;
}
export class SubscriberChannel {
diff --git a/apps/api/src/app/shared/framework/constants/headers.schema.ts b/apps/api/src/app/shared/framework/constants/headers.schema.ts
new file mode 100644
index 00000000000..2c46ed0219c
--- /dev/null
+++ b/apps/api/src/app/shared/framework/constants/headers.schema.ts
@@ -0,0 +1,69 @@
+import { HeaderObject, HttpResponseHeaderKeysEnum } from '../types/headers.types';
+
+export const COMMON_RESPONSE_HEADERS: Array = [
+ HttpResponseHeaderKeysEnum.CONTENT_TYPE,
+ HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT,
+ HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING,
+ HttpResponseHeaderKeysEnum.RATELIMIT_RESET,
+ HttpResponseHeaderKeysEnum.RATELIMIT_POLICY,
+ HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY,
+ HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY,
+];
+
+export const RESPONSE_HEADER_CONFIG: Record = {
+ [HttpResponseHeaderKeysEnum.CONTENT_TYPE]: {
+ required: true,
+ description: 'The MIME type of the response body.',
+ schema: { type: 'string' },
+ example: 'application/json',
+ },
+ [HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT]: {
+ required: false,
+ description:
+ 'The number of requests that the client is permitted to make per second. The actual maximum may differ when burst is enabled.',
+ schema: { type: 'string' },
+ example: '100',
+ },
+ [HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING]: {
+ required: false,
+ description: 'The number of requests remaining until the next window.',
+ schema: { type: 'string' },
+ example: '93',
+ },
+ [HttpResponseHeaderKeysEnum.RATELIMIT_RESET]: {
+ required: false,
+ description: 'The remaining seconds until a request of the same cost will be refreshed.',
+ schema: { type: 'string' },
+ example: '8',
+ },
+ [HttpResponseHeaderKeysEnum.RATELIMIT_POLICY]: {
+ required: false,
+ description: 'The rate limit policy that was used to evaluate the request.',
+ schema: { type: 'string' },
+ example: '100;w=1;burst=110;comment="token bucket";category="trigger";cost="single"',
+ },
+ [HttpResponseHeaderKeysEnum.RETRY_AFTER]: {
+ required: false,
+ description: 'The number of seconds after which the client may retry the request that was previously rejected.',
+ schema: { type: 'string' },
+ example: '8',
+ },
+ [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: {
+ required: false,
+ description: 'The idempotency key used to evaluate the request.',
+ schema: { type: 'string' },
+ example: '8',
+ },
+ [HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: {
+ required: false,
+ description: 'Whether the request was a replay of a previous request.',
+ schema: { type: 'string' },
+ example: 'true',
+ },
+ [HttpResponseHeaderKeysEnum.LINK]: {
+ required: false,
+ description: 'A link to the documentation.',
+ schema: { type: 'string' },
+ example: 'https://docs.novu.co/',
+ },
+};
diff --git a/apps/api/src/app/shared/framework/constants/index.ts b/apps/api/src/app/shared/framework/constants/index.ts
new file mode 100644
index 00000000000..090eb4afc64
--- /dev/null
+++ b/apps/api/src/app/shared/framework/constants/index.ts
@@ -0,0 +1,2 @@
+export * from './headers.schema';
+export * from './responses.schema';
diff --git a/apps/api/src/app/shared/framework/constants/responses.schema.ts b/apps/api/src/app/shared/framework/constants/responses.schema.ts
new file mode 100644
index 00000000000..a9d1a3a134b
--- /dev/null
+++ b/apps/api/src/app/shared/framework/constants/responses.schema.ts
@@ -0,0 +1,27 @@
+import { ApiResponseOptions } from '@nestjs/swagger';
+import { THROTTLED_EXCEPTION_MESSAGE } from '../../../rate-limiting/guards';
+import { createReusableHeaders } from '../swagger';
+import { ApiResponseDecoratorName, HttpResponseHeaderKeysEnum } from '../types';
+
+export const COMMON_RESPONSES: Partial> = {
+ ApiConflictResponse: {
+ description: 'The request could not be completed due to a conflict with the current state of the target resource.',
+ schema: {
+ type: 'string',
+ example:
+ 'Request with key 3909d656-d4fe-4e80-ba86-90d3861afcd7 is currently being processed. Please retry after 1 second',
+ },
+ headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER, HttpResponseHeaderKeysEnum.LINK]),
+ },
+ ApiTooManyRequestsResponse: {
+ description: 'The client has sent too many requests in a given amount of time. ',
+ schema: { type: 'string', example: THROTTLED_EXCEPTION_MESSAGE },
+ headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]),
+ },
+ ApiServiceUnavailableResponse: {
+ description:
+ 'The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.',
+ schema: { type: 'string', example: 'Please wait some time, then try again.' },
+ headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]),
+ },
+};
diff --git a/apps/api/e2e/idempotency.e2e.ts b/apps/api/src/app/shared/framework/idempotency.e2e.ts
similarity index 63%
rename from apps/api/e2e/idempotency.e2e.ts
rename to apps/api/src/app/shared/framework/idempotency.e2e.ts
index f4a77ef252d..6b562fe61d6 100644
--- a/apps/api/e2e/idempotency.e2e.ts
+++ b/apps/api/src/app/shared/framework/idempotency.e2e.ts
@@ -1,17 +1,14 @@
import { UserSession } from '@novu/testing';
import { CacheService } from '@novu/application-generic';
import { expect } from 'chai';
+import { HttpResponseHeaderKeysEnum } from './types';
+import { DOCS_LINK } from './idempotency.interceptor';
+
+process.env.LAUNCH_DARKLY_SDK_KEY = ''; // disable Launch Darkly to allow test to define FF state
+
describe('Idempotency Test', async () => {
let session: UserSession;
const path = '/v1/testing/idempotency';
- const HEADER_KEYS = {
- IDEMPOTENCY_KEY: 'idempotency-key',
- RETRY_AFTER: 'retry-after',
- IDEMPOTENCY_REPLAY: 'idempotency-replay',
- LINK: 'link',
- };
- const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency';
-
let cacheService: CacheService | null = null;
describe('when enabled', () => {
@@ -26,27 +23,27 @@ describe('Idempotency Test', async () => {
const key = `1`;
const { body, headers } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
const { body: bodyDupe, headers: headerDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
expect(typeof body.data.number === 'number').to.be.true;
expect(body.data.number).to.equal(bodyDupe.data.number);
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true');
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY.toLowerCase()]).to.eq('true');
});
it('should return cached and use correct cache key when apiKey is used', async () => {
const key = `2`;
const { body, headers } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
@@ -57,21 +54,21 @@ describe('Idempotency Test', async () => {
expect(JSON.stringify(body)).to.eq(cacheVal);
const { body: bodyDupe, headers: headerDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
expect(typeof body.data.number === 'number').to.be.true;
expect(body.data.number).to.equal(bodyDupe.data.number);
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true');
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY.toLowerCase()]).to.eq('true');
});
it('should return cached and use correct cache key when authToken and apiKey combination is used', async () => {
const key = `3`;
const { body, headers } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', session.token)
.send({ data: 201 })
.expect(201);
@@ -82,32 +79,34 @@ describe('Idempotency Test', async () => {
expect(JSON.stringify(body)).to.eq(cacheVal);
const { body: bodyDupe, headers: headerDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
expect(typeof body.data.number === 'number').to.be.true;
expect(body.data.number).to.equal(bodyDupe.data.number);
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true');
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY.toLowerCase()]).to.eq('true');
});
it('should return conflict when concurrent requests are made', async () => {
const key = `4`;
const [{ headers, body, status }, { headers: headerDupe, body: bodyDupe, status: statusDupe }] =
await Promise.all([
- session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 250 }),
- session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 250 }),
+ session.testAgent.post(path).set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key).send({ data: 250 }),
+ session.testAgent.post(path).set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key).send({ data: 250 }),
]);
const oneSuccess = status === 201 || statusDupe === 201;
const oneConflict = status === 409 || statusDupe === 409;
const conflictBody = status === 201 ? bodyDupe : body;
- const retryHeader = headers[HEADER_KEYS.RETRY_AFTER] || headerDupe[HEADER_KEYS.RETRY_AFTER];
+ const retryHeader =
+ headers[HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase()] ||
+ headerDupe[HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase()];
expect(oneSuccess).to.be.true;
expect(oneConflict).to.be.true;
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.LINK]).to.eq(DOCS_LINK);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.LINK.toLowerCase()]).to.eq(DOCS_LINK);
expect(retryHeader).to.eq(`1`);
expect(JSON.stringify(conflictBody)).to.eq(
JSON.stringify({
@@ -121,23 +120,23 @@ describe('Idempotency Test', async () => {
const key = '5';
const { headers, body, status } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 250 });
const {
headers: headerDupe,
body: bodyDupe,
status: statusDupe,
- } = await session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 251 });
+ } = await session.testAgent.post(path).set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key).send({ data: 251 });
const oneSuccess = status === 201 || statusDupe === 201;
const oneConflict = status === 422 || statusDupe === 422;
const conflictBody = status === 201 ? bodyDupe : body;
expect(oneSuccess).to.be.true;
expect(oneConflict).to.be.true;
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.LINK]).to.eq(DOCS_LINK);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.LINK.toLowerCase()]).to.eq(DOCS_LINK);
expect(JSON.stringify(conflictBody)).to.eq(
JSON.stringify({
message: `Request with key "${key}" is being reused for a different body`,
@@ -151,61 +150,61 @@ describe('Idempotency Test', async () => {
const key1 = '7';
const { body, headers } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
const { body: bodyDupe, headers: headerDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key1)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key1)
.send({ data: 201 })
.expect(201);
expect(typeof body.data.number === 'number').to.be.true;
expect(typeof bodyDupe.data.number === 'number').to.be.true;
expect(body.data.number).not.to.equal(bodyDupe.data.number);
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key1);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key1);
});
it('should return non cached response for GET requests', async () => {
const key = '8';
const { body, headers } = await session.testAgent
.get(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({})
.expect(200);
const { body: bodyDupe } = await session.testAgent
.get(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({})
.expect(200);
expect(typeof body.data.number === 'number').to.be.true;
expect(typeof bodyDupe.data.number === 'number').to.be.true;
expect(body.data.number).not.to.equal(bodyDupe.data.number);
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(undefined);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(undefined);
});
it('should return cached error response for duplicate requests', async () => {
const key = '9';
const { body, headers } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 422 })
.expect(422);
const { body: bodyDupe, headers: headerDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 422 })
.expect(422);
expect(JSON.stringify(body)).to.equal(JSON.stringify(bodyDupe));
- expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
- expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
+ expect(headerDupe[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.eq(key);
});
it('should return 400 when key bigger than allowed limit', async () => {
const key = Array.from({ length: 256 })
@@ -214,7 +213,7 @@ describe('Idempotency Test', async () => {
.join('');
const { body } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 250 })
.expect(400);
@@ -226,6 +225,41 @@ describe('Idempotency Test', async () => {
})
);
});
+
+ describe('Allowed Authentication Security Schemes', () => {
+ it('should set Idempotency-Key header when ApiKey security scheme is used to authenticate', async () => {
+ const key = '10';
+ const { headers } = await session.testAgent
+ .post(path)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
+ .set('authorization', `ApiKey ${session.apiKey}`)
+ .send({ data: 201 })
+ .expect(201);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.exist;
+ });
+
+ it('should set rate limit headers when a Bearer security scheme is used to authenticate', async () => {
+ const key = '10';
+ const { headers } = await session.testAgent
+ .post(path)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
+ .set('authorization', session.token)
+ .send({ data: 201 })
+ .expect(201);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).to.exist;
+ });
+
+ it('should NOT set rate limit headers when NO authorization header is present', async () => {
+ const key = '10';
+ const { headers } = await session.testAgent
+ .post(path)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
+ .set('authorization', '')
+ .send({ data: 201 })
+ .expect(401);
+ expect(headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase()]).not.to.exist;
+ });
+ });
});
describe('when disabled', () => {
@@ -239,14 +273,14 @@ describe('Idempotency Test', async () => {
const key = '10';
const { body } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
const { body: bodyDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
@@ -258,14 +292,14 @@ describe('Idempotency Test', async () => {
const key1 = '12';
const { body } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: 201 })
.expect(201);
const { body: bodyDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key1)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key1)
.send({ data: 201 })
.expect(201);
expect(typeof body.data.number === 'number').to.be.true;
@@ -274,11 +308,15 @@ describe('Idempotency Test', async () => {
});
it('should return non cached response for GET requests', async () => {
const key = '13';
- const { body } = await session.testAgent.get(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({}).expect(200);
+ const { body } = await session.testAgent
+ .get(path)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
+ .send({})
+ .expect(200);
const { body: bodyDupe } = await session.testAgent
.get(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({})
.expect(200);
@@ -290,14 +328,14 @@ describe('Idempotency Test', async () => {
const key = '14';
const { body } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: '500' })
.expect(500);
const { body: bodyDupe } = await session.testAgent
.post(path)
- .set(HEADER_KEYS.IDEMPOTENCY_KEY, key)
+ .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({ data: '500' })
.expect(500);
diff --git a/apps/api/src/app/shared/framework/idempotency.interceptor.ts b/apps/api/src/app/shared/framework/idempotency.interceptor.ts
index 9557bee2e09..10f7e8aad46 100644
--- a/apps/api/src/app/shared/framework/idempotency.interceptor.ts
+++ b/apps/api/src/app/shared/framework/idempotency.interceptor.ts
@@ -11,43 +11,64 @@ import {
BadRequestException,
ConflictException,
} from '@nestjs/common';
-import { CacheService } from '@novu/application-generic';
+import { CacheService, FeatureFlagCommand, GetIsApiIdempotencyEnabled, Instrument } from '@novu/application-generic';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { createHash } from 'crypto';
-import * as jwt from 'jsonwebtoken';
-import { IJwtPayload } from '@novu/shared';
+import { ApiAuthSchemeEnum, IJwtPayload } from '@novu/shared';
+import { HttpResponseHeaderKeysEnum } from './types';
const LOG_CONTEXT = 'IdempotencyInterceptor';
const IDEMPOTENCY_CACHE_TTL = 60 * 60 * 24; //24h
const IDEMPOTENCY_PROGRESS_TTL = 60 * 5; //5min
-const HEADER_KEYS = {
- IDEMPOTENCY_KEY: 'Idempotency-Key',
- RETRY_AFTER: 'Retry-After',
- IDEMPOTENCY_REPLAY: 'Idempotency-Replay',
- LINK: 'Link',
-};
-
-const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency';
-
enum ReqStatusEnum {
PROGRESS = 'in-progress',
SUCCESS = 'success',
ERROR = 'error',
}
+export const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency';
+export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY];
+const ALLOWED_METHODS = ['post', 'patch'];
+
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
- constructor(private readonly cacheService: CacheService) {}
+ constructor(
+ private readonly cacheService: CacheService,
+ private getIsApiIdempotencyEnabled: GetIsApiIdempotencyEnabled
+ ) {}
+
+ protected async isEnabled(context: ExecutionContext): Promise {
+ const isAllowedAuthScheme = this.isAllowedAuthScheme(context);
+ if (!isAllowedAuthScheme) {
+ return true;
+ }
+
+ const user = this.getReqUser(context);
+ const { organizationId, environmentId, _id } = user;
+ const isEnabled = await this.getIsApiIdempotencyEnabled.execute(
+ FeatureFlagCommand.create({
+ environmentId,
+ organizationId,
+ userId: _id,
+ })
+ );
+
+ return isEnabled;
+ }
+
+ @Instrument()
async intercept(context: ExecutionContext, next: CallHandler): Promise> {
const request = context.switchToHttp().getRequest();
+ const isAllowedMethod = ALLOWED_METHODS.includes(request.method.toLowerCase());
const idempotencyKey = this.getIdempotencyKey(context);
- const isEnabled = process.env.IS_API_IDEMPOTENCY_ENABLED == 'true';
- if (!isEnabled || !idempotencyKey || !['post', 'patch'].includes(request.method.toLowerCase())) {
+ const isEnabled = await this.isEnabled(context);
+ if (!idempotencyKey || !isAllowedMethod || !isEnabled) {
return next.handle();
}
+
if (idempotencyKey?.length > 255) {
return throwError(
() =>
@@ -90,29 +111,32 @@ export class IdempotencyInterceptor implements NestInterceptor {
private getIdempotencyKey(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
- return request.headers[HEADER_KEYS.IDEMPOTENCY_KEY.toLocaleLowerCase()];
+ return request.headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLocaleLowerCase()];
}
- private getReqUser(context: ExecutionContext): IJwtPayload | null {
+ private getReqUser(context: ExecutionContext): IJwtPayload {
const req = context.switchToHttp().getRequest();
- if (req?.user?.organizationId) {
- return req.user;
- }
- if (req.headers?.authorization?.length) {
- const token = req.headers.authorization.split(' ')[1];
- if (token) {
- return jwt.decode(token);
- }
- }
- return null;
+ return req.user;
+ }
+
+ private isAllowedAuthScheme(context: ExecutionContext): boolean {
+ const req = context.switchToHttp().getRequest();
+ const authScheme = req.authScheme;
+
+ return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme);
}
private getCacheKey(context: ExecutionContext): string {
- const { organizationId } = this.getReqUser(context) || {};
+ const user = this.getReqUser(context);
+ if (user === undefined) {
+ const message = 'Cannot build idempotency cache key without user';
+ Logger.error(message, LOG_CONTEXT);
+ throw new InternalServerErrorException(message);
+ }
const env = process.env.NODE_ENV;
- return `${env}-${organizationId}-${this.getIdempotencyKey(context)}`;
+ return `${env}-${user.organizationId}-${this.getIdempotencyKey(context)}`;
}
async setCache(
@@ -162,14 +186,16 @@ export class IdempotencyInterceptor implements NestInterceptor {
const cacheKey = this.getCacheKey(context);
const idempotencyKey = this.getIdempotencyKey(context)!;
const data = await this.cacheService.get(cacheKey);
- this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey });
+ this.setHeaders(context.switchToHttp().getResponse(), {
+ [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey,
+ });
const parsed = JSON.parse(data);
if (parsed.status === ReqStatusEnum.PROGRESS) {
// api call is in progress, so client need to handle this case
- Logger.error(`previous api call in progress rejecting the request. key:${idempotencyKey}`, LOG_CONTEXT);
+ Logger.verbose(`previous api call in progress rejecting the request. key: "${idempotencyKey}"`, LOG_CONTEXT);
this.setHeaders(context.switchToHttp().getResponse(), {
- [HEADER_KEYS.RETRY_AFTER]: `1`,
- [HEADER_KEYS.LINK]: DOCS_LINK,
+ [HttpResponseHeaderKeysEnum.RETRY_AFTER]: `1`,
+ [HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK,
});
throw new ConflictException(
@@ -178,20 +204,20 @@ export class IdempotencyInterceptor implements NestInterceptor {
}
if (bodyHash !== parsed.bodyHash) {
//different body sent than before
- Logger.error(`idempotency key is being reused for different bodies. key:${idempotencyKey}`, LOG_CONTEXT);
+ Logger.verbose(`idempotency key is being reused for different bodies. key: "${idempotencyKey}"`, LOG_CONTEXT);
this.setHeaders(context.switchToHttp().getResponse(), {
- [HEADER_KEYS.LINK]: DOCS_LINK,
+ [HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK,
});
throw new UnprocessableEntityException(
`Request with key "${idempotencyKey}" is being reused for a different body`
);
}
- this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_REPLAY]: 'true' });
+ this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: 'true' });
//already seen the request return cached response
if (parsed.status === ReqStatusEnum.ERROR) {
- Logger.error(`returning cached error response. key:${idempotencyKey}`, LOG_CONTEXT);
+ Logger.verbose(`returning cached error response. key: "${idempotencyKey}"`, LOG_CONTEXT);
throw this.buildError(parsed.data);
}
@@ -218,8 +244,8 @@ export class IdempotencyInterceptor implements NestInterceptor {
{ status: ReqStatusEnum.SUCCESS, bodyHash, statusCode: statusCode, data: response },
IDEMPOTENCY_CACHE_TTL
);
- Logger.verbose(`cached the success response for idempotency key:${idempotencyKey}`, LOG_CONTEXT);
- this.setHeaders(httpResponse, { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey });
+ Logger.verbose(`cached the success response for idempotency key: "${idempotencyKey}"`, LOG_CONTEXT);
+ this.setHeaders(httpResponse, { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey });
return response;
}),
@@ -237,8 +263,10 @@ export class IdempotencyInterceptor implements NestInterceptor {
},
IDEMPOTENCY_CACHE_TTL
).catch(() => {});
- Logger.verbose(`cached the error response for idempotency key:${idempotencyKey}`, LOG_CONTEXT);
- this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey });
+ Logger.verbose(`cached the error response for idempotency key: "${idempotencyKey}"`, LOG_CONTEXT);
+ this.setHeaders(context.switchToHttp().getResponse(), {
+ [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey,
+ });
throw err;
})
diff --git a/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts b/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts
index 077274a0267..b15eabcf943 100644
--- a/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts
+++ b/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts
@@ -1,6 +1,7 @@
-import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
+import { ApiExtraModels, getSchemaPath } from '@nestjs/swagger';
import { PaginatedResponseDto } from '../dtos/pagination-response';
import { Type, applyDecorators } from '@nestjs/common';
+import { ApiOkResponse } from './response.decorator';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ApiOkPaginatedResponse = >(dataDto: DataDto) =>
diff --git a/apps/api/src/app/shared/framework/response.decorator.ts b/apps/api/src/app/shared/framework/response.decorator.ts
index b6344c90913..8fb1b7cf77d 100644
--- a/apps/api/src/app/shared/framework/response.decorator.ts
+++ b/apps/api/src/app/shared/framework/response.decorator.ts
@@ -1,7 +1,37 @@
/* eslint-disable @typescript-eslint/naming-convention */
-import { ApiExtraModels, ApiOkResponse, ApiCreatedResponse, getSchemaPath } from '@nestjs/swagger';
+
+import { ApiExtraModels, getSchemaPath } from '@nestjs/swagger';
import { Type, applyDecorators } from '@nestjs/common';
import { DataWrapperDto } from '../dtos/data-wrapper-dto';
+import { customResponseDecorators } from './swagger/responses.decorator';
+import { COMMON_RESPONSES } from './constants/responses.schema';
+
+export const ApiOkResponse = customResponseDecorators.ApiOkResponse;
+export const ApiCreatedResponse = customResponseDecorators.ApiCreatedResponse;
+export const ApiAcceptedResponse = customResponseDecorators.ApiAcceptedResponse;
+export const ApiNoContentResponse = customResponseDecorators.ApiNoContentResponse;
+export const ApiMovedPermanentlyResponse = customResponseDecorators.ApiMovedPermanentlyResponse;
+export const ApiFoundResponse = customResponseDecorators.ApiFoundResponse;
+export const ApiBadRequestResponse = customResponseDecorators.ApiBadRequestResponse;
+export const ApiUnauthorizedResponse = customResponseDecorators.ApiUnauthorizedResponse;
+export const ApiTooManyRequestsResponse = customResponseDecorators.ApiTooManyRequestsResponse;
+export const ApiNotFoundResponse = customResponseDecorators.ApiNotFoundResponse;
+export const ApiInternalServerErrorResponse = customResponseDecorators.ApiInternalServerErrorResponse;
+export const ApiBadGatewayResponse = customResponseDecorators.ApiBadGatewayResponse;
+export const ApiConflictResponse = customResponseDecorators.ApiConflictResponse;
+export const ApiForbiddenResponse = customResponseDecorators.ApiForbiddenResponse;
+export const ApiGatewayTimeoutResponse = customResponseDecorators.ApiGatewayTimeoutResponse;
+export const ApiGoneResponse = customResponseDecorators.ApiGoneResponse;
+export const ApiMethodNotAllowedResponse = customResponseDecorators.ApiMethodNotAllowedResponse;
+export const ApiNotAcceptableResponse = customResponseDecorators.ApiNotAcceptableResponse;
+export const ApiNotImplementedResponse = customResponseDecorators.ApiNotImplementedResponse;
+export const ApiPreconditionFailedResponse = customResponseDecorators.ApiPreconditionFailedResponse;
+export const ApiPayloadTooLargeResponse = customResponseDecorators.ApiPayloadTooLargeResponse;
+export const ApiRequestTimeoutResponse = customResponseDecorators.ApiRequestTimeoutResponse;
+export const ApiServiceUnavailableResponse = customResponseDecorators.ApiServiceUnavailableResponse;
+export const ApiUnprocessableEntityResponse = customResponseDecorators.ApiUnprocessableEntityResponse;
+export const ApiUnsupportedMediaTypeResponse = customResponseDecorators.ApiUnsupportedMediaTypeResponse;
+export const ApiDefaultResponse = customResponseDecorators.ApiDefaultResponse;
export const ApiResponse = >(
dataDto: DataDto,
@@ -13,6 +43,7 @@ export const ApiResponse = >(
return applyDecorators(
ApiExtraModels(DataWrapperDto, dataDto),
Response({
+ description: statusCode === 201 ? 'Created' : 'Ok',
schema: {
properties: isResponseArray
? { data: { type: 'array', items: { $ref: getSchemaPath(dataDto) } } }
@@ -21,3 +52,11 @@ export const ApiResponse = >(
})
);
};
+
+export const ApiCommonResponses = () => {
+ return applyDecorators(
+ ...Object.entries(COMMON_RESPONSES).map(([decoratorName, responseOptions]) =>
+ customResponseDecorators[decoratorName](responseOptions)
+ )
+ );
+};
diff --git a/apps/api/src/app/shared/framework/swagger/headers.decorator.ts b/apps/api/src/app/shared/framework/swagger/headers.decorator.ts
new file mode 100644
index 00000000000..ad47b56744a
--- /dev/null
+++ b/apps/api/src/app/shared/framework/swagger/headers.decorator.ts
@@ -0,0 +1,29 @@
+import { HeaderObjects, HttpResponseHeaderKeysEnum } from '../types';
+import { RESPONSE_HEADER_CONFIG } from '../constants/headers.schema';
+import { OpenAPIObject } from '@nestjs/swagger';
+
+export const injectReusableHeaders = (document: OpenAPIObject): OpenAPIObject => {
+ const newDocument = { ...document };
+ newDocument.components = {
+ ...document.components,
+ headers: Object.entries(RESPONSE_HEADER_CONFIG).reduce((acc, [name, header]) => {
+ return {
+ ...acc,
+ [name]: header,
+ };
+ }, {} as HeaderObjects),
+ };
+
+ return newDocument;
+};
+
+export const createReusableHeaders = (headers: Array) => {
+ return headers.reduce((acc, header) => {
+ return {
+ ...acc,
+ [header]: {
+ $ref: `#/components/headers/${header}`,
+ },
+ };
+ }, {} as HeaderObjects);
+};
diff --git a/apps/api/src/app/shared/framework/swagger/index.ts b/apps/api/src/app/shared/framework/swagger/index.ts
new file mode 100644
index 00000000000..0d718e67302
--- /dev/null
+++ b/apps/api/src/app/shared/framework/swagger/index.ts
@@ -0,0 +1,3 @@
+export * from './injection';
+export * from './responses.decorator';
+export * from './headers.decorator';
diff --git a/apps/api/src/app/shared/framework/swagger/injection.ts b/apps/api/src/app/shared/framework/swagger/injection.ts
new file mode 100644
index 00000000000..c3077239458
--- /dev/null
+++ b/apps/api/src/app/shared/framework/swagger/injection.ts
@@ -0,0 +1,8 @@
+import { OpenAPIObject } from '@nestjs/swagger';
+import { injectReusableHeaders } from './headers.decorator';
+
+export const injectDocumentComponents = (document: OpenAPIObject): OpenAPIObject => {
+ const injectedResponseHeadersDocument = injectReusableHeaders(document);
+
+ return injectedResponseHeadersDocument;
+};
diff --git a/apps/api/src/app/shared/framework/swagger/responses.decorator.ts b/apps/api/src/app/shared/framework/swagger/responses.decorator.ts
new file mode 100644
index 00000000000..ce6f801f407
--- /dev/null
+++ b/apps/api/src/app/shared/framework/swagger/responses.decorator.ts
@@ -0,0 +1,35 @@
+/* eslint-disable no-restricted-imports */
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { applyDecorators } from '@nestjs/common';
+import * as nestSwagger from '@nestjs/swagger';
+import { ApiResponseOptions } from '@nestjs/swagger';
+import { COMMON_RESPONSE_HEADERS, COMMON_RESPONSES } from '../constants';
+import { ApiResponseDecoratorName } from '../types';
+import { createReusableHeaders } from './headers.decorator';
+
+const createCustomResponseDecorator = (decoratorName: ApiResponseDecoratorName) => {
+ return (options?: ApiResponseOptions) => {
+ return applyDecorators(
+ nestSwagger[decoratorName]({
+ ...COMMON_RESPONSES[decoratorName],
+ ...options,
+ headers: {
+ ...createReusableHeaders(COMMON_RESPONSE_HEADERS),
+ ...options?.headers,
+ },
+ })
+ );
+ };
+};
+
+const nestSwaggerResponseExports = Object.keys(nestSwagger).filter(
+ (key) => key.match(/^Api([a-zA-Z]+)Response$/) !== null
+) as Array;
+
+export const customResponseDecorators = nestSwaggerResponseExports.reduce((acc, decoratorName) => {
+ return {
+ ...acc,
+ [decoratorName]: createCustomResponseDecorator(decoratorName),
+ };
+}, {} as Record ReturnType>);
diff --git a/apps/api/src/app/shared/framework/swagger/swagger.controller.ts b/apps/api/src/app/shared/framework/swagger/swagger.controller.ts
new file mode 100644
index 00000000000..f9a710ab3da
--- /dev/null
+++ b/apps/api/src/app/shared/framework/swagger/swagger.controller.ts
@@ -0,0 +1,118 @@
+/* eslint-disable max-len */
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { INestApplication } from '@nestjs/common';
+import { injectDocumentComponents } from './injection';
+
+const options = new DocumentBuilder()
+ .setTitle('Novu API')
+ .setDescription('Novu REST API. Please see https://docs.novu.co/api-reference for more details.')
+ .setVersion('1.0')
+ .setContact('Novu Support', 'https://discord.gg/novu', 'support@novu.co')
+ .setExternalDoc('Novu Documentation', 'https://docs.novu.co')
+ .setTermsOfService('https://novu.co/terms')
+ .setLicense('MIT', 'https://opensource.org/license/mit')
+ .addServer(process.env.API_ROOT_URL)
+ .addApiKey({
+ type: 'apiKey',
+ name: 'Authorization',
+ in: 'header',
+ description: 'API key authentication. Allowed headers-- "Authorization: ApiKey ".',
+ })
+ .addTag(
+ 'Events',
+ `Events represent a change in state of a subscriber. They are used to trigger workflows, and enable you to send notifications to subscribers based on their actions.`,
+ { url: 'https://docs.novu.co/workflows' }
+ )
+ .addTag(
+ 'Subscribers',
+ `A subscriber in Novu represents someone who should receive a message. A subscriber’s profile information contains important attributes about the subscriber that will be used in messages (name, email). The subscriber object can contain other key-value pairs that can be used to further personalize your messages.`,
+ { url: 'https://docs.novu.co/subscribers/subscribers' }
+ )
+ .addTag(
+ 'Topics',
+ `Topics are a way to group subscribers together so that they can be notified of events at once. A topic is identified by a custom key. This can be helpful for things like sending out marketing emails or notifying users of new features. Topics can also be used to send notifications to the subscribers who have been grouped together based on their interests, location, activities and much more.`,
+ { url: 'https://docs.novu.co/subscribers/topics' }
+ )
+ .addTag(
+ 'Notification',
+ 'A notification conveys information from source to recipient, triggered by a workflow acting as a message blueprint. Notifications can be individual or bundled as digest for user-friendliness.',
+ { url: 'https://docs.novu.co/getting-started/introduction' }
+ )
+ .addTag(
+ 'Integrations',
+ `With the help of the Integration Store, you can easily integrate your favorite delivery provider. During the runtime of the API, the Integrations Store is responsible for storing the configurations of all the providers.`,
+ { url: 'https://docs.novu.co/channels-and-providers/integration-store' }
+ )
+ .addTag(
+ 'Layouts',
+ `Novu allows the creation of layouts - a specific HTML design or structure to wrap content of email notifications. Layouts can be manipulated and assigned to new or existing workflows within the Novu platform, allowing users to create, manage, and assign these layouts to workflows, so they can be reused to structure the appearance of notifications sent through the platform.`,
+ { url: 'https://docs.novu.co/content-creation-design/layouts' }
+ )
+ .addTag(
+ 'Workflows',
+ `All notifications are sent via a workflow. Each workflow acts as a container for the logic and blueprint that are associated with a type of notification in your system.`,
+ { url: 'https://docs.novu.co/workflows' }
+ )
+ .addTag(
+ 'Notification Templates',
+ `Deprecated. Use Workflows (/workflows) instead, which provide the same functionality under a new name.`
+ )
+ .addTag('Workflow groups', `Workflow groups are used to organize workflows into logical groups.`)
+ .addTag(
+ 'Changes',
+ `Changes represent a change in state of an environment. They are analagous to a pending pull request in git, enabling you to test changes before they are applied to your environment and atomically apply them when you are ready.`,
+ { url: 'https://docs.novu.co/platform/environments#promoting-pending-changes-to-production' }
+ )
+ .addTag(
+ 'Environments',
+ `Novu uses the concept of environments to ensure logical separation of your data and configuration. This means that subscribers, and preferences created in one environment are never accessible to another.`,
+ { url: 'https://docs.novu.co/platform/environments' }
+ )
+ .addTag(
+ 'Inbound Parse',
+ `Inbound Webhook is a feature that allows processing of incoming emails for a domain or subdomain. The feature parses the contents of the email and POSTs the information to a specified URL in a multipart/form-data format.`,
+ { url: 'https://docs.novu.co/platform/inbound-parse-webhook' }
+ )
+ .addTag(
+ 'Feeds',
+ `Novu provides a notification activity feed that monitors every outgoing message associated with its relevant metadata. This can be used to monitor activity and discover potential issues with a specific provider or a channel type.`,
+ { url: 'https://docs.novu.co/activity-feed' }
+ )
+ .addTag(
+ 'Tenants',
+ `A tenant represents a group of users. As a developer, when your apps have organizations, they are referred to as tenants. Tenants in Novu provides the ability to tailor specific notification experiences to users of different groups or organizations.`,
+ { url: 'https://docs.novu.co/tenants' }
+ )
+ .addTag(
+ 'Messages',
+ `A message in Novu represents a notification delivered to a recipient on a particular channel. Messages contain information about the request that triggered its delivery, a view of the data sent to the recipient, and a timeline of its lifecycle events. Learn more about messages.`,
+ { url: 'https://docs.novu.co/workflows/messages' }
+ )
+ .addTag(
+ 'Organizations',
+ `An organization serves as a separate entity within your Novu account. Each organization you create has its own separate integration store, workflows, subscribers, and API keys. This separation of resources allows you to manage multi-tenant environments and separate domains within a single account.`,
+ { url: 'https://docs.novu.co/platform/organizations' }
+ )
+ .addTag(
+ 'Execution Details',
+ `Execution details are used to track the execution of a workflow. They provided detailed information on the execution of a workflow, including the status of each step, the input and output of each step, and the overall status of the execution.`,
+ { url: 'https://docs.novu.co/activity-feed' }
+ )
+ .build();
+
+export const setupSwagger = (app: INestApplication) => {
+ const document = injectDocumentComponents(SwaggerModule.createDocument(app, options));
+
+ SwaggerModule.setup('api', app, {
+ ...document,
+ info: {
+ ...document.info,
+ title: `DEPRECATED: ${document.info.title}. Use /openapi.{json,yaml} instead.`,
+ },
+ });
+ SwaggerModule.setup('openapi', app, document, {
+ jsonDocumentUrl: 'openapi.json',
+ yamlDocumentUrl: 'openapi.yaml',
+ explorer: process.env.NODE_ENV !== 'production',
+ });
+};
diff --git a/apps/api/src/app/shared/framework/types/headers.types.ts b/apps/api/src/app/shared/framework/types/headers.types.ts
new file mode 100644
index 00000000000..9758a46531a
--- /dev/null
+++ b/apps/api/src/app/shared/framework/types/headers.types.ts
@@ -0,0 +1,29 @@
+import { ApiHeaderOptions } from '@nestjs/swagger';
+import { WithRequired, testHttpHeaderEnumValidity } from './utils.types';
+
+export enum HttpRequestHeaderKeysEnum {
+ AUTHORIZATION = 'Authorization',
+ USER_AGENT = 'User-Agent',
+ CONTENT_TYPE = 'Content-Type',
+ SENTRY_TRACE = 'Sentry-Trace',
+}
+testHttpHeaderEnumValidity(HttpRequestHeaderKeysEnum);
+
+export enum HttpResponseHeaderKeysEnum {
+ CONTENT_TYPE = 'Content-Type',
+ RATELIMIT_REMAINING = 'RateLimit-Remaining',
+ RATELIMIT_LIMIT = 'RateLimit-Limit',
+ RATELIMIT_RESET = 'RateLimit-Reset',
+ RATELIMIT_POLICY = 'RateLimit-Policy',
+ RETRY_AFTER = 'Retry-After',
+ IDEMPOTENCY_KEY = 'Idempotency-Key',
+ IDEMPOTENCY_REPLAY = 'Idempotency-Replay',
+ LINK = 'Link',
+}
+testHttpHeaderEnumValidity(HttpResponseHeaderKeysEnum);
+
+export type HeaderObject = WithRequired<
+ Omit,
+ 'required' | 'description' | 'schema' | 'example'
+>;
+export type HeaderObjects = Record;
diff --git a/apps/api/src/app/shared/framework/types/index.ts b/apps/api/src/app/shared/framework/types/index.ts
new file mode 100644
index 00000000000..a6855939cfc
--- /dev/null
+++ b/apps/api/src/app/shared/framework/types/index.ts
@@ -0,0 +1,2 @@
+export * from './headers.types';
+export * from './responses.types';
diff --git a/apps/api/src/app/shared/framework/types/responses.types.ts b/apps/api/src/app/shared/framework/types/responses.types.ts
new file mode 100644
index 00000000000..c2b3c1b964c
--- /dev/null
+++ b/apps/api/src/app/shared/framework/types/responses.types.ts
@@ -0,0 +1,5 @@
+// eslint-disable-next-line no-restricted-imports
+import * as nestSwagger from '@nestjs/swagger';
+
+type NestJsExport = keyof typeof nestSwagger;
+export type ApiResponseDecoratorName = NestJsExport & `Api${string}Response`;
diff --git a/apps/api/src/app/shared/framework/types/utils.types.spec.ts b/apps/api/src/app/shared/framework/types/utils.types.spec.ts
new file mode 100644
index 00000000000..7171a0d5c3f
--- /dev/null
+++ b/apps/api/src/app/shared/framework/types/utils.types.spec.ts
@@ -0,0 +1,87 @@
+/* cSpell:enableCompoundWords */
+import { WithRequired, ConvertToConstantCase, testHttpHeaderEnumValidity, ValidateHttpHeaderCase } from './utils.types';
+
+/**
+ * WithRequired tests
+ */
+type TestWithRequired = {
+ optional?: string;
+ required: number;
+};
+// Valid
+export const validTestType: WithRequired = {
+ optional: 'test',
+ required: 1,
+};
+
+// Invalid
+// @ts-expect-error - Missing 'optional' property
+export const invalidTestType: WithRequired = {
+ required: 1,
+};
+
+/**
+ * ConvertToConstantCase tests
+ */
+// Valid
+export const validConstantSingleString: ConvertToConstantCase<'Single'> = 'SINGLE';
+export const validConstantSingleSingleString: ConvertToConstantCase<'Double-String'> = 'DOUBLE_STRING';
+export const validConstantDoubleSingleString: ConvertToConstantCase<'DoubleWord-String'> = 'DOUBLEWORD_STRING';
+
+// @ts-expect-error - Incorrect case - should be 'SINGLE'
+export const invalidConstantSingleString: ConvertToConstantCase<'Single'> = 'single';
+
+/**
+ * ValidateHttpHeaderCase tests
+ */
+// Valid
+export const validHttpHeaderSingleString: ValidateHttpHeaderCase<'Single'> = 'Single';
+export const validHttpHeaderSingleSingleString: ValidateHttpHeaderCase<'Double-String'> = 'Double-String';
+export const validHttpHeaderDoubleSingleString: ValidateHttpHeaderCase<'DoubleWord-String'> = 'DoubleWord-String';
+export const validHttpHeaderUnion1String: ValidateHttpHeaderCase<'First-String' | 'Second-String'> = 'First-String';
+export const validHttpHeaderUnion2String: ValidateHttpHeaderCase<'First-String' | 'Second-String'> = 'Second-String';
+enum TestCapitalHeaderEnum {
+ SINGLE = 'Single',
+ INVALID = 'invalid-string',
+ DOUBLE_STRING = 'Double-String',
+ DOUBLEWORD_STRING = 'DoubleWord-String',
+}
+export const validHttpHeaderSingleEnum: ValidateHttpHeaderCase = 'Single';
+export const validHttpHeaderSingleSingleEnum: ValidateHttpHeaderCase =
+ 'Double-String';
+export const validHttpHeaderDoubleSingleEnum: ValidateHttpHeaderCase =
+ 'DoubleWord-String';
+
+// Invalid
+// @ts-expect-error - Incorrect case - 'invalid-string' literal type is not Capital-Case
+export const invalidHttpHeaderSingleString: ValidateHttpHeaderCase<'invalid-string'> = 'Invalid';
+// @ts-expect-error - Incorrect case - 'invalid-string' union type is not Capital-Case
+export const invalidHttpHeaderUnionString: ValidateHttpHeaderCase<'First-String' | 'invalid-string'> = 'invalid-string';
+// @ts-expect-error - Incorrect case - 'invalid-string' enum is not Capital-Case
+export const invalidHttpHeaderEnumString: ValidateHttpHeaderCase = 'invalid';
+
+/**
+ * testHeaderEnumValidity Tests
+ */
+// Valid
+enum ValidHeaderEnum {
+ SINGLE = 'Single',
+ DOUBLE_STRING = 'Double-String',
+ DOUBLEWORD_STRING = 'DoubleWord-String',
+}
+testHttpHeaderEnumValidity(ValidHeaderEnum);
+
+// Invalid
+enum InvalidKeyHeaderEnum {
+ SINGLE = 'Single',
+ Invalid_Key = 'Invalid-Key',
+}
+// @ts-expect-error - Invalid key - Invalid_Key should be 'INVALID_KEY'
+testHttpHeaderEnumValidity(InvalidKeyHeaderEnum);
+
+enum InvalidValueHeaderEnum {
+ SINGLE = 'Single',
+ INVALID_VALUE = 'invalid-key',
+}
+// @ts-expect-error - Invalid value - 'another-test-header' should be 'Another-Test-Header'
+testHttpHeaderEnumValidity(InvalidValueHeaderEnum);
diff --git a/apps/api/src/app/shared/framework/types/utils.types.ts b/apps/api/src/app/shared/framework/types/utils.types.ts
new file mode 100644
index 00000000000..e6ae9c128c0
--- /dev/null
+++ b/apps/api/src/app/shared/framework/types/utils.types.ts
@@ -0,0 +1,63 @@
+/* cSpell:enableCompoundWords */
+/**
+ * Make properties K in T required.
+ */
+export type WithRequired = T & { [P in K]-?: T[P] };
+
+/**
+ * Transform S to CONSTANT_CASE.
+ */
+export type ConvertToConstantCase = S extends `${infer T}-${infer U}`
+ ? `${Uppercase}_${ConvertToConstantCase}`
+ : Uppercase;
+
+/**
+ * Validate that S is in Http-Header-Case, and return S if valid, otherwise never.
+ */
+export type ValidateHttpHeaderCase = S extends `${infer U}-${infer V}`
+ ? U extends Capitalize
+ ? `${U}-${ValidateHttpHeaderCase}`
+ : never
+ : S extends Capitalize
+ ? `${S}` // necessary to cast to string literal type for non-hyphenated enum validation
+ : never;
+
+/**
+ * Helper function to test that Header enum keys and values match correct format.
+ *
+ * - The enum keys must be in CONSTANT_CASE
+ * - The enum values must be in Http-Header-Case.
+ * - The enum values must be the CONSTANT_CASED version of the Http-Header-Cased value.
+ *
+ * If the test fails, you should review your `enum` to verify that the conditions above are met.
+ *
+ * @example
+ * // Correct format:
+ * enum TestEnum {
+ * HEADER = 'Header',
+ * HYPHENATED_HEADER = 'Hyphenated-Header',
+ * DOUBLEWORD_Header = 'DoubleWord-Header',
+ * }
+ *
+ * @example
+ * // Incorrect format:
+ * enum TestEnum {
+ * Single = 'Single', // incorrect key case (Single should be SINGLE)
+ * SINGLE = 'single', // incorrect value case ('single' should be 'Single')
+ * // extra underscore in key (DOUBLE_WORD_HEADER should be DOUBLEWORD_HEADER)
+ * DOUBLE_WORD_HEADER = 'DoubleWord-Header',
+ * }
+ *
+ * @param testEnum - the Enum to type check
+ */
+export function testHttpHeaderEnumValidity<
+ TEnum extends IConstants,
+ TValue extends TEnum[keyof TEnum] & string,
+ IConstants = Record, ValidateHttpHeaderCase>
+>(
+ testEnum: TEnum &
+ Record<
+ Exclude,
+ ['Key must be the CONSTANT_CASED version of the Capital-Cased value']
+ >
+) {}
diff --git a/apps/api/src/app/shared/framework/user.decorator.ts b/apps/api/src/app/shared/framework/user.decorator.ts
index 66484b3791a..be300a204a0 100644
--- a/apps/api/src/app/shared/framework/user.decorator.ts
+++ b/apps/api/src/app/shared/framework/user.decorator.ts
@@ -1,40 +1,8 @@
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
+import { UserSession } from '@novu/application-generic';
-// eslint-disable-next-line @typescript-eslint/naming-convention
-export const UserSession = createParamDecorator((data, ctx) => {
- let req;
- if (ctx.getType() === 'graphql') {
- req = ctx.getArgs()[2].req;
- } else {
- req = ctx.switchToHttp().getRequest();
- }
-
- if (req.user) {
- /**
- * This helps with sentry and other tools that need to know who the user is based on `id` property.
- */
- req.user.id = req.user._id;
- req.user.username = (req.user.firstName || '').trim();
- req.user.domain = req.user.email?.split('@')[1];
-
- return req.user;
- }
-
- if (req.headers) {
- if (req.headers.authorization) {
- const tokenParts = req.headers.authorization.split(' ');
- if (tokenParts[0] !== 'Bearer') throw new UnauthorizedException('bad_token');
- if (!tokenParts[1]) throw new UnauthorizedException('bad_token');
-
- const user = jwt.decode(tokenParts[1]);
-
- return user;
- }
- }
-
- return null;
-});
+export { UserSession };
// eslint-disable-next-line @typescript-eslint/naming-convention
export const SubscriberSession = createParamDecorator((data, ctx) => {
diff --git a/apps/api/src/app/shared/helpers/content.service.spec.ts b/apps/api/src/app/shared/helpers/content.service.spec.ts
index 32984c527b1..4482495d79e 100644
--- a/apps/api/src/app/shared/helpers/content.service.spec.ts
+++ b/apps/api/src/app/shared/helpers/content.service.spec.ts
@@ -422,7 +422,7 @@ describe('ContentService', function () {
{
isNegated: false,
type: 'GROUP',
- value: 'AND',
+ value: FieldLogicalOperatorEnum.AND,
},
],
},
diff --git a/apps/api/src/app/shared/helpers/content.service.ts b/apps/api/src/app/shared/helpers/content.service.ts
index 4e5c14d9730..38aeb82ccb2 100644
--- a/apps/api/src/app/shared/helpers/content.service.ts
+++ b/apps/api/src/app/shared/helpers/content.service.ts
@@ -15,6 +15,7 @@ import {
} from '@novu/shared';
import Handlebars from 'handlebars';
import { ApiException } from '../exceptions/api.exception';
+import { NotificationStep } from '../../workflows/usecases/create-notification-template';
export class ContentService {
replaceVariables(content: string, variables: { [key: string]: string }) {
@@ -41,7 +42,7 @@ export class ContentService {
}
}
- extractMessageVariables(messages: INotificationTemplateStep[]): {
+ extractMessageVariables(messages: NotificationStep[]): {
variables: IMustacheVariable[];
reservedVariables: ITriggerReservedVariable[];
} {
@@ -68,7 +69,7 @@ export class ContentService {
};
}
- extractStepVariables(messages: INotificationTemplateStep[]): IMustacheVariable[] {
+ extractStepVariables(messages: NotificationStep[]): IMustacheVariable[] {
const variables: IMustacheVariable[] = [];
for (const message of messages) {
@@ -118,7 +119,7 @@ export class ContentService {
return reservedVariables;
}
- extractSubscriberMessageVariables(messages: INotificationTemplateStep[]): string[] {
+ extractSubscriberMessageVariables(messages: NotificationStep[]): string[] {
const variables: string[] = [];
const hasSmsMessage = !!messages.find((i) => i.template?.type === StepTypeEnum.SMS);
@@ -134,7 +135,7 @@ export class ContentService {
return Array.from(new Set(variables));
}
- private *messagesTextIterator(messages: INotificationTemplateStep[]): Generator {
+ private *messagesTextIterator(messages: NotificationStep[]): Generator {
for (const message of messages) {
if (!message.template) continue;
diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts
index ebd6c710093..55b81103b9c 100644
--- a/apps/api/src/app/shared/shared.module.ts
+++ b/apps/api/src/app/shared/shared.module.ts
@@ -1,27 +1,28 @@
import { Module } from '@nestjs/common';
import {
+ ChangeRepository,
DalService,
- UserRepository,
- OrganizationRepository,
EnvironmentRepository,
ExecutionDetailsRepository,
- NotificationTemplateRepository,
- SubscriberRepository,
- NotificationRepository,
- MessageRepository,
- NotificationGroupRepository,
- MessageTemplateRepository,
- MemberRepository,
- LayoutRepository,
- LogRepository,
+ FeedRepository,
IntegrationRepository,
- ChangeRepository,
JobRepository,
- FeedRepository,
+ LayoutRepository,
+ LogRepository,
+ MemberRepository,
+ MessageRepository,
+ MessageTemplateRepository,
+ NotificationGroupRepository,
+ NotificationRepository,
+ NotificationTemplateRepository,
+ OrganizationRepository,
SubscriberPreferenceRepository,
+ SubscriberRepository,
+ TenantRepository,
TopicRepository,
TopicSubscribersRepository,
- TenantRepository,
+ UserRepository,
+ WorkflowOverrideRepository,
} from '@novu/dal';
import {
analyticsService,
@@ -33,13 +34,16 @@ import {
distributedLockService,
featureFlagsService,
getIsTopicNotificationEnabled,
+ getIsApiRateLimitingEnabled,
InvalidateCacheService,
LoggerModule,
QueuesModule,
storageService,
+ getIsApiIdempotencyEnabled,
} from '@novu/application-generic';
import * as packageJson from '../../../package.json';
+import { JobTopicNameEnum } from '@novu/shared';
const DAL_MODELS = [
UserRepository,
@@ -63,6 +67,7 @@ const DAL_MODELS = [
TopicRepository,
TopicSubscribersRepository,
TenantRepository,
+ WorkflowOverrideRepository,
];
const dalService = {
@@ -85,6 +90,8 @@ const PROVIDERS = [
distributedLockService,
featureFlagsService,
getIsTopicNotificationEnabled,
+ getIsApiRateLimitingEnabled,
+ getIsApiIdempotencyEnabled,
InvalidateCacheService,
storageService,
...DAL_MODELS,
@@ -92,13 +99,18 @@ const PROVIDERS = [
@Module({
imports: [
+ QueuesModule.forRoot([
+ JobTopicNameEnum.EXECUTION_LOG,
+ JobTopicNameEnum.WEB_SOCKETS,
+ JobTopicNameEnum.WORKFLOW,
+ JobTopicNameEnum.INBOUND_PARSE_MAIL,
+ ]),
LoggerModule.forRoot(
createNestLoggingModuleOptions({
serviceName: packageJson.name,
version: packageJson.version,
})
),
- QueuesModule,
],
providers: [...PROVIDERS],
exports: [...PROVIDERS, LoggerModule, QueuesModule],
diff --git a/apps/api/src/app/shared/validators/image.validator.ts b/apps/api/src/app/shared/validators/image.validator.ts
new file mode 100644
index 00000000000..c4bb945f558
--- /dev/null
+++ b/apps/api/src/app/shared/validators/image.validator.ts
@@ -0,0 +1,22 @@
+import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export function IsImageUrl(validationOptions?: ValidationOptions) {
+ return function (object: object, propertyName: string) {
+ registerDecorator({
+ name: 'isImageUrl',
+ target: object.constructor,
+ propertyName: propertyName,
+ options: validationOptions,
+ validator: {
+ validate(value: any, args: ValidationArguments) {
+ const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg'];
+ const extension = value.split('.').pop();
+ if (!extension) return false;
+
+ return validExtensions.includes(extension);
+ },
+ },
+ });
+ };
+}
diff --git a/apps/api/src/app/storage/storage.controller.ts b/apps/api/src/app/storage/storage.controller.ts
index cc4ceb24812..3a2b67d6c6f 100644
--- a/apps/api/src/app/storage/storage.controller.ts
+++ b/apps/api/src/app/storage/storage.controller.ts
@@ -3,16 +3,17 @@ import { IJwtPayload } from '@novu/shared';
import { GetSignedUrl } from './usecases/get-signed-url/get-signed-url.usecase';
import { GetSignedUrlCommand } from './usecases/get-signed-url/get-signed-url.command';
import { UserSession } from '../shared/framework/user.decorator';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UploadUrlResponse } from './dtos/upload-url-response.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/storage')
@ApiTags('Storage')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiExcludeController()
export class StorageController {
constructor(private getSignedUrlUsecase: GetSignedUrl) {}
diff --git a/apps/api/src/app/subscribers/e2e/get-notifications-feed.e2e.ts b/apps/api/src/app/subscribers/e2e/get-notifications-feed.e2e.ts
index 540ff7e0cec..4824c24e3eb 100644
--- a/apps/api/src/app/subscribers/e2e/get-notifications-feed.e2e.ts
+++ b/apps/api/src/app/subscribers/e2e/get-notifications-feed.e2e.ts
@@ -93,7 +93,7 @@ describe('Get Notifications feed - /:subscriberId/notifications/feed (GET)', fun
async function getNotificationsFeed(subscriberId: string, apiKey: string, query = {}) {
const response = await axios.get(
- `http://localhost:${process.env.PORT}/v1/subscribers/${subscriberId}/notifications/feed`,
+ `http://127.0.0.1:${process.env.PORT}/v1/subscribers/${subscriberId}/notifications/feed`,
{
params: {
...query,
diff --git a/apps/api/src/app/subscribers/e2e/get-unseen-count.e2e.ts b/apps/api/src/app/subscribers/e2e/get-unseen-count.e2e.ts
index 0acd8f47940..fe2dc079bf3 100644
--- a/apps/api/src/app/subscribers/e2e/get-unseen-count.e2e.ts
+++ b/apps/api/src/app/subscribers/e2e/get-unseen-count.e2e.ts
@@ -38,7 +38,7 @@ describe('Get Unseen Count - /:subscriberId/notifications/unseen (GET)', functio
async function getUnSeenCount(subscriberId: string, apiKey: string, query = {}) {
const response = await axios.get(
- `http://localhost:${process.env.PORT}/v1/subscribers/${subscriberId}/notifications/unseen`,
+ `http://127.0.0.1:${process.env.PORT}/v1/subscribers/${subscriberId}/notifications/unseen`,
{
params: {
...query,
diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts
index efc5eb97242..7bd20ae00da 100644
--- a/apps/api/src/app/subscribers/subscribers.controller.ts
+++ b/apps/api/src/app/subscribers/subscribers.controller.ts
@@ -21,12 +21,18 @@ import {
UpdateSubscriber,
UpdateSubscriberCommand,
} from '@novu/application-generic';
-import { ApiOperation, ApiTags, ApiNoContentResponse, ApiParam } from '@nestjs/swagger';
-import { ButtonTypeEnum, ChatProviderIdEnum, IJwtPayload } from '@novu/shared';
+import { ApiOperation, ApiTags, ApiParam } from '@nestjs/swagger';
+import {
+ ApiRateLimitCategoryEnum,
+ ApiRateLimitCostEnum,
+ ButtonTypeEnum,
+ ChatProviderIdEnum,
+ IJwtPayload,
+} from '@novu/shared';
import { MessageEntity, PreferenceLevelEnum } from '@novu/dal';
import { RemoveSubscriber, RemoveSubscriberCommand } from './usecases/remove-subscriber';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import {
@@ -69,7 +75,7 @@ import { ApiOkPaginatedResponse } from '../shared/framework/paginated-ok-respons
import { PaginatedResponseDto } from '../shared/dtos/pagination-response';
import { GetSubscribersDto } from './dtos/get-subscribers.dto';
import { GetInAppNotificationsFeedForSubscriberDto } from './dtos/get-in-app-notification-feed-for-subscriber.dto';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse, ApiNoContentResponse } from '../shared/framework/response.decorator';
import { ChatOauthCallbackRequestDto, ChatOauthRequestDto } from './dtos/chat-oauth-request.dto';
import { OAuthHandlerEnum } from './types';
import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase';
@@ -90,7 +96,10 @@ import {
UpdateSubscriberGlobalPreferencesCommand,
} from './usecases/update-subscriber-global-preferences';
import { GetSubscriberPreferencesByLevelParams } from './params';
+import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards';
+@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
+@ApiCommonResponses()
@Controller('/subscribers')
@ApiTags('Subscribers')
export class SubscribersController {
@@ -118,7 +127,7 @@ export class SubscribersController {
@Get('')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiOkPaginatedResponse(SubscriberResponseDto)
@ApiOperation({
summary: 'Get subscribers',
@@ -140,7 +149,7 @@ export class SubscribersController {
@Get('/:subscriberId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(SubscriberResponseDto)
@ApiOperation({
summary: 'Get subscriber',
@@ -161,7 +170,7 @@ export class SubscribersController {
@Post('/')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(SubscriberResponseDto, 201)
@ApiOperation({
summary: 'Create subscriber',
@@ -190,9 +199,10 @@ export class SubscribersController {
);
}
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
@Post('/bulk')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiOperation({
summary: 'Bulk create subscribers',
description: `
@@ -212,7 +222,7 @@ export class SubscribersController {
@Put('/:subscriberId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(SubscriberResponseDto)
@ApiOperation({
summary: 'Update subscriber',
@@ -241,7 +251,7 @@ export class SubscribersController {
@Put('/:subscriberId/credentials')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(SubscriberResponseDto)
@ApiOperation({
summary: 'Update subscriber credentials',
@@ -267,7 +277,7 @@ export class SubscribersController {
@Delete('/:subscriberId/credentials/:providerId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiNoContentResponse()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
@@ -291,7 +301,7 @@ export class SubscribersController {
@Patch('/:subscriberId/online-status')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(SubscriberResponseDto)
@ApiOperation({
summary: 'Update subscriber online status',
@@ -314,7 +324,7 @@ export class SubscribersController {
@Delete('/:subscriberId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(DeleteSubscriberResponseDto)
@ApiOperation({
summary: 'Delete subscriber',
@@ -335,7 +345,7 @@ export class SubscribersController {
@Get('/:subscriberId/preferences')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(UpdateSubscriberPreferenceResponseDto, 200, true)
@ApiOperation({
summary: 'Get subscriber preferences',
@@ -356,7 +366,7 @@ export class SubscribersController {
@Get('/:subscriberId/preferences/:level')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(GetSubscriberPreferencesResponseDto, 200, true)
@ApiOperation({
summary: 'Get subscriber preferences by level',
@@ -379,7 +389,7 @@ export class SubscribersController {
@Patch('/:subscriberId/preferences/:templateId')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(UpdateSubscriberPreferenceResponseDto)
@ApiOperation({
summary: 'Update subscriber preference',
@@ -404,7 +414,7 @@ export class SubscribersController {
@Patch('/:subscriberId/preferences')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiResponse(UpdateSubscriberPreferenceResponseDto)
@ApiOperation({
summary: 'Update subscriber global preferences',
@@ -426,7 +436,7 @@ export class SubscribersController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Get('/:subscriberId/notifications/feed')
@ApiOperation({
summary: 'Get in-app notification feed for a particular subscriber',
@@ -457,7 +467,7 @@ export class SubscribersController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Get('/:subscriberId/notifications/unseen')
@ApiResponse(UnseenCountResponse)
@ApiOperation({
@@ -493,7 +503,7 @@ export class SubscribersController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/:subscriberId/messages/markAs')
@ApiOperation({
summary: 'Mark a subscriber feed message as seen',
@@ -521,7 +531,7 @@ export class SubscribersController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/:subscriberId/messages/mark-all')
@ApiOperation({
summary:
@@ -546,7 +556,7 @@ export class SubscribersController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/:subscriberId/messages/:messageId/actions/:type')
@ApiOperation({
summary: 'Mark message action as seen',
diff --git a/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscriber.command.ts b/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.command.ts
similarity index 100%
rename from apps/api/src/app/subscribers/usecases/get-subscribers/get-subscriber.command.ts
rename to apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.command.ts
diff --git a/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.usecase.ts
similarity index 83%
rename from apps/api/src/app/subscribers/usecases/get-subscribers/get-subscriber.usecase.ts
rename to apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.usecase.ts
index 2cc8b669ed6..70d013ba11c 100644
--- a/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscriber.usecase.ts
+++ b/apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { SubscriberRepository } from '@novu/dal';
-import { GetSubscribersCommand } from './get-subscriber.command';
+import { GetSubscribersCommand } from './get-subscribers.command';
@Injectable()
export class GetSubscribers {
@@ -12,8 +12,6 @@ export class GetSubscribers {
_organizationId: command.organizationId,
};
- const totalCount = await this.subscriberRepository.count(query);
-
const data = await this.subscriberRepository.find(query, '', {
limit: command.limit,
skip: command.page * command.limit,
diff --git a/apps/api/src/app/subscribers/usecases/get-subscribers/index.ts b/apps/api/src/app/subscribers/usecases/get-subscribers/index.ts
index b5f798a2635..ede667b7078 100644
--- a/apps/api/src/app/subscribers/usecases/get-subscribers/index.ts
+++ b/apps/api/src/app/subscribers/usecases/get-subscribers/index.ts
@@ -1,2 +1,2 @@
-export * from './get-subscriber.command';
-export * from './get-subscriber.usecase';
+export * from './get-subscribers.command';
+export * from './get-subscribers.usecase';
diff --git a/apps/api/src/app/tenant/tenant.controller.ts b/apps/api/src/app/tenant/tenant.controller.ts
index 787971f4345..4126aa86f8f 100644
--- a/apps/api/src/app/tenant/tenant.controller.ts
+++ b/apps/api/src/app/tenant/tenant.controller.ts
@@ -13,16 +13,9 @@ import {
UseGuards,
UseInterceptors,
} from '@nestjs/common';
-import {
- ApiConflictResponse,
- ApiExcludeController,
- ApiNoContentResponse,
- ApiNotFoundResponse,
- ApiOperation,
- ApiTags,
-} from '@nestjs/swagger';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
-import { IJwtPayload } from '@novu/shared';
+import { ApiRateLimitCategoryEnum, IJwtPayload } from '@novu/shared';
import {
UpdateTenant,
UpdateTenantCommand,
@@ -32,10 +25,16 @@ import {
CreateTenantCommand,
} from '@novu/application-generic';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { UserSession } from '../shared/framework/user.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import {
+ ApiCommonResponses,
+ ApiResponse,
+ ApiNoContentResponse,
+ ApiNotFoundResponse,
+ ApiConflictResponse,
+} from '../shared/framework/response.decorator';
import { DeleteTenantCommand } from './usecases/delete-tenant/delete-tenant.command';
import { DeleteTenant } from './usecases/delete-tenant/delete-tenant.usecase';
import { ApiOkPaginatedResponse } from '../shared/framework/paginated-ok-response.decorator';
@@ -50,11 +49,14 @@ import {
CreateTenantResponseDto,
CreateTenantRequestDto,
} from './dtos';
+import { ThrottlerCategory } from '../rate-limiting/guards';
+@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
+@ApiCommonResponses()
@Controller('/tenants')
@ApiTags('Tenants')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
export class TenantController {
constructor(
private createTenantUsecase: CreateTenant,
@@ -66,7 +68,7 @@ export class TenantController {
@Get('')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiOkPaginatedResponse(GetTenantResponseDto)
@ApiOperation({
summary: 'Get tenants',
@@ -165,7 +167,7 @@ export class TenantController {
@Delete('/:identifier')
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@ApiOperation({
summary: 'Delete tenant',
description: 'Deletes a tenant entity from the Novu platform',
diff --git a/apps/api/src/app/testing/auth.controller.ts b/apps/api/src/app/testing/auth.controller.ts
new file mode 100644
index 00000000000..775d0fb0da8
--- /dev/null
+++ b/apps/api/src/app/testing/auth.controller.ts
@@ -0,0 +1,19 @@
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
+import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
+
+@Controller('/test-auth')
+export class TestApiAuthController {
+ @ExternalApiAccessible()
+ @UseGuards(UserAuthGuard)
+ @Get('/user-route')
+ userRoute() {
+ return true;
+ }
+
+ @UseGuards(UserAuthGuard)
+ @Get('/user-api-inaccessible-route')
+ userInaccessibleRoute() {
+ return true;
+ }
+}
diff --git a/apps/api/src/app/testing/rate-limiting.controller.ts b/apps/api/src/app/testing/rate-limiting.controller.ts
new file mode 100644
index 00000000000..47c8d2444b0
--- /dev/null
+++ b/apps/api/src/app/testing/rate-limiting.controller.ts
@@ -0,0 +1,94 @@
+import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared';
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
+import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
+
+@Controller('/rate-limiting')
+@UseGuards(UserAuthGuard)
+export class TestApiRateLimitController {
+ @ExternalApiAccessible()
+ @Get('/no-category-no-cost')
+ noCategoryNoCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCost(ApiRateLimitCostEnum.SINGLE)
+ @Get('/no-category-single-cost')
+ noCategorySingleCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL)
+ @Get('/global-category-no-cost')
+ globalCategoryNoCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL)
+ @ThrottlerCost(ApiRateLimitCostEnum.SINGLE)
+ @Get('/global-category-single-cost')
+ globalCategorySingleCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL)
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
+ @Get('/global-category-bulk-cost')
+ global() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
+ @Get('/trigger-category-no-cost')
+ triggerCategoryNoCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
+ @ThrottlerCost(ApiRateLimitCostEnum.SINGLE)
+ @Get('/trigger-category-single-cost')
+ triggerCategorySingleCost() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
+ @ThrottlerCost(ApiRateLimitCostEnum.BULK)
+ @Get('/trigger-category-bulk-cost')
+ triggerCategoryBulkCost() {
+ return true;
+ }
+}
+
+@Controller('/rate-limiting-trigger-bulk')
+@UseGuards(UserAuthGuard)
+@ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
+@ThrottlerCost(ApiRateLimitCostEnum.BULK)
+export class TestApiRateLimitBulkController {
+ @ExternalApiAccessible()
+ @Get('/no-category-no-cost-override')
+ noCategoryNoCostOverride() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @ThrottlerCost(ApiRateLimitCostEnum.SINGLE)
+ @Get('/no-category-single-cost-override')
+ noCategorySingleCostOverride() {
+ return true;
+ }
+
+ @ExternalApiAccessible()
+ @Get('/global-category-no-cost-override')
+ @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL)
+ globalCategoryNoCostOverride() {
+ return true;
+ }
+}
diff --git a/apps/api/src/app/testing/testing.controller.ts b/apps/api/src/app/testing/testing.controller.ts
index 1939ca49148..d46e4b06851 100644
--- a/apps/api/src/app/testing/testing.controller.ts
+++ b/apps/api/src/app/testing/testing.controller.ts
@@ -9,7 +9,7 @@ import { SeedDataCommand } from './usecases/seed-data/seed-data.command';
import { CreateSession } from './usecases/create-session/create-session.usecase';
import { CreateSessionCommand } from './usecases/create-session/create-session.command';
import { ApiExcludeController } from '@nestjs/swagger';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
@Controller('/testing')
@@ -53,7 +53,7 @@ export class TestingController {
}
@ExternalApiAccessible()
- @UseGuards(JwtAuthGuard)
+ @UseGuards(UserAuthGuard)
@Post('/idempotency')
async idempotency(@Body() body: IdempotencyBodyDto): Promise<{ number: number }> {
if (process.env.NODE_ENV !== 'test') throw new NotFoundException();
diff --git a/apps/api/src/app/testing/testing.module.ts b/apps/api/src/app/testing/testing.module.ts
index 5b6b34a0ac1..f1d7791620c 100644
--- a/apps/api/src/app/testing/testing.module.ts
+++ b/apps/api/src/app/testing/testing.module.ts
@@ -3,10 +3,13 @@ import { USE_CASES } from './usecases';
import { TestingController } from './testing.controller';
import { SharedModule } from '../shared/shared.module';
import { AuthModule } from '../auth/auth.module';
+import { TestApiRateLimitBulkController, TestApiRateLimitController } from './rate-limiting.controller';
+import { RateLimitingModule } from '../rate-limiting/rate-limiting.module';
+import { TestApiAuthController } from './auth.controller';
@Module({
- imports: [SharedModule, AuthModule],
+ imports: [SharedModule, AuthModule, RateLimitingModule],
providers: [...USE_CASES],
- controllers: [TestingController],
+ controllers: [TestingController, TestApiRateLimitController, TestApiRateLimitBulkController, TestApiAuthController],
})
export class TestingModule {}
diff --git a/apps/api/src/app/testing/translations/create-translation.e2e-ee.ts b/apps/api/src/app/testing/translations/create-translation.e2e-ee.ts
new file mode 100644
index 00000000000..3153719eaf5
--- /dev/null
+++ b/apps/api/src/app/testing/translations/create-translation.e2e-ee.ts
@@ -0,0 +1,72 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('Create translation group - /translations/groups (POST)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ });
+
+ it('should create translation group', async () => {
+ const result = await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US'],
+ });
+
+ let group = result.body.data;
+ const id = group.id;
+
+ expect(group.name).to.eq('test');
+ expect(group.identifier).to.eq('test');
+
+ let data = await session.testAgent.get(`/v1/translations/groups/test`).send();
+ group = data.body.data;
+ let locales = group.translations.map((t) => t.isoLanguage);
+
+ expect(group.name).to.eq('test');
+ expect(group.identifier).to.eq('test');
+ expect(locales).to.deep.eq(['en_US']);
+ expect(id).to.equal(group.id);
+ await session.applyChanges({
+ enabled: false,
+ });
+ await session.switchToProdEnvironment();
+
+ data = await session.testAgent.get(`/v1/translations/groups/test`).send();
+ group = data.body.data;
+ locales = group.translations.map((t) => t.isoLanguage);
+ expect(group.name).to.eq('test');
+ expect(group.identifier).to.eq('test');
+ expect(locales).to.deep.eq(['en_US']);
+ });
+
+ it('should check that default locale is included in group', async () => {
+ const result = await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_GB'],
+ });
+
+ expect(result.body.message).to.be.eq('Default language needs to be in all translation groups');
+ expect(result.body.statusCode).to.be.eq(400);
+ expect(result.body.error).to.be.eq('Bad Request');
+ });
+
+ it('should check that locale is allowed', async () => {
+ const result = await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US', 'test'],
+ });
+
+ expect(result.body.message).to.be.eq('Locale could not be found');
+ expect(result.body.statusCode).to.be.eq(404);
+ expect(result.body.error).to.be.eq('Not Found');
+ });
+});
diff --git a/apps/api/src/app/testing/translations/get-locales.e2e-ee.ts b/apps/api/src/app/testing/translations/get-locales.e2e-ee.ts
new file mode 100644
index 00000000000..1cd7134caed
--- /dev/null
+++ b/apps/api/src/app/testing/translations/get-locales.e2e-ee.ts
@@ -0,0 +1,29 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('get locales - /translations/locales (GET)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should get locales', async () => {
+ const data = await session.testAgent.get(`/v1/translations/locales`).send();
+ const locales: any[] = data.body.data;
+
+ expect(locales.length).to.equal(482);
+ expect(Object.keys(locales[0])).to.deep.equal([
+ 'name',
+ 'officialName',
+ 'numeric',
+ 'alpha2',
+ 'alpha3',
+ 'currencyName',
+ 'currencyAlphabeticCode',
+ 'langName',
+ 'langIso',
+ ]);
+ });
+});
diff --git a/apps/api/src/app/testing/translations/get-translation-group.e2e-ee.ts b/apps/api/src/app/testing/translations/get-translation-group.e2e-ee.ts
new file mode 100644
index 00000000000..7b120ae3267
--- /dev/null
+++ b/apps/api/src/app/testing/translations/get-translation-group.e2e-ee.ts
@@ -0,0 +1,38 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('get translation group - /translations/groups/:identifier (GET)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US'],
+ });
+ });
+
+ it('should get translation group', async () => {
+ const data = await session.testAgent.get(`/v1/translations/groups/test`).send();
+ const group = data.body.data;
+ const locales = group.translations.map((t) => t.isoLanguage);
+
+ expect(group.name).to.eq('test');
+ expect(group.identifier).to.eq('test');
+ expect(locales).to.deep.eq(['en_US']);
+ });
+
+ it('should return 404 on trying getting a translation group that does not exist', async () => {
+ const data = await session.testAgent.get(`/v1/translations/groups/hej`).send();
+ const result = data.body;
+
+ expect(result.message).to.equal('Group could not be found');
+ expect(result.statusCode).to.be.eq(404);
+ expect(result.error).to.be.eq('Not Found');
+ });
+});
diff --git a/apps/api/src/app/testing/translations/get-translation-groups.e2e-ee.ts b/apps/api/src/app/testing/translations/get-translation-groups.e2e-ee.ts
new file mode 100644
index 00000000000..e1d3f0e3951
--- /dev/null
+++ b/apps/api/src/app/testing/translations/get-translation-groups.e2e-ee.ts
@@ -0,0 +1,52 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('get translation groups - /translations/groups (GET)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US'],
+ });
+ await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test1',
+ identifier: 'test1',
+ locales: ['en_US', 'en_GB'],
+ });
+ await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test2',
+ identifier: 'test2',
+ locales: ['en_US', 'sv_SE'],
+ });
+ });
+
+ it('should get translation groups', async () => {
+ const data = await session.testAgent.get(`/v1/translations/groups`).send();
+ const groups = data.body.data;
+
+ const testGroup = groups[0];
+ expect(testGroup.identifier).to.equal('test');
+ expect(testGroup.name).to.equal('test');
+ expect(testGroup.uiConfig.locales).to.deep.equal(['en_US']);
+ expect(testGroup.uiConfig.localesMissingTranslations).to.deep.equal(['en_US']);
+
+ const test1Group = groups[1];
+ expect(test1Group.identifier).to.equal('test1');
+ expect(test1Group.name).to.equal('test1');
+ expect(test1Group.uiConfig.locales).to.deep.equal(['en_US', 'en_GB']);
+ expect(test1Group.uiConfig.localesMissingTranslations).to.deep.equal(['en_US', 'en_GB']);
+
+ const test2Group = groups[2];
+ expect(test2Group.identifier).to.equal('test2');
+ expect(test2Group.name).to.equal('test2');
+ expect(test2Group.uiConfig.locales).to.deep.equal(['en_US', 'sv_SE']);
+ expect(test2Group.uiConfig.localesMissingTranslations).to.deep.equal(['en_US', 'sv_SE']);
+ });
+});
diff --git a/apps/api/src/app/testing/translations/get-translation.e2e-ee.ts b/apps/api/src/app/testing/translations/get-translation.e2e-ee.ts
new file mode 100644
index 00000000000..dae51cd0c0b
--- /dev/null
+++ b/apps/api/src/app/testing/translations/get-translation.e2e-ee.ts
@@ -0,0 +1,31 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('GET translation - /translations/groups/:identifier/locales/:locale (GET)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ });
+
+ it('should get translation', async () => {
+ let result = await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US', 'sv_SE'],
+ });
+
+ const group = result.body.data;
+
+ result = await session.testAgent.get(`/v1/translations/groups/test/locales/sv_SE`).send();
+
+ const translation = result.body.data;
+
+ expect(translation.isoLanguage).to.equal('sv_SE');
+ expect(translation._groupId).to.equal(group.id);
+ });
+});
diff --git a/apps/api/src/app/testing/translations/update-translation.e2e-ee.ts b/apps/api/src/app/testing/translations/update-translation.e2e-ee.ts
new file mode 100644
index 00000000000..c606976c5a8
--- /dev/null
+++ b/apps/api/src/app/testing/translations/update-translation.e2e-ee.ts
@@ -0,0 +1,58 @@
+import { UserSession } from '@novu/testing';
+import { expect } from 'chai';
+
+describe('Update translation - /translations/groups (PATCH)', async () => {
+ let session: UserSession;
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ await session.testAgent.put(`/v1/organizations/language`).send({
+ locale: 'en_US',
+ });
+ });
+
+ it('should update translation', async () => {
+ await session.testAgent.post(`/v1/translations/groups`).send({
+ name: 'test',
+ identifier: 'test',
+ locales: ['en_US', 'sv_SE'],
+ });
+
+ await session.applyChanges({
+ enabled: false,
+ });
+
+ let result = await session.testAgent.patch(`/v1/translations/groups/test`).send({
+ name: 'test1',
+ identifier: 'test1',
+ locales: ['en_US', 'en_GB'],
+ });
+
+ let group = result.body.data;
+
+ let locales = group.translations.map((t) => t.isoLanguage);
+
+ expect(group.identifier).to.equal('test1');
+ expect(group.name).to.equal('test1');
+ expect(locales).to.deep.equal(['en_US', 'en_GB']);
+
+ result = await session.testAgent.get(`/v1/translations/groups/test1/locales/sv_SE`).send();
+
+ expect(result.body.message).to.equal('Translation could not be found');
+ expect(result.body.error).to.equal('Not Found');
+ expect(result.body.statusCode).to.equal(404);
+
+ await session.applyChanges({
+ enabled: false,
+ });
+ await session.switchToProdEnvironment();
+
+ const data = await session.testAgent.get(`/v1/translations/groups/test1`).send();
+ group = data.body.data;
+ locales = group.translations.map((t) => t.isoLanguage);
+ expect(group.identifier).to.equal('test1');
+ expect(group.name).to.equal('test1');
+ expect(locales).to.deep.equal(['en_US', 'en_GB']);
+ });
+});
diff --git a/apps/api/src/app/topics/topics.controller.ts b/apps/api/src/app/topics/topics.controller.ts
index 668c9fd1206..7fd1cb56daa 100644
--- a/apps/api/src/app/topics/topics.controller.ts
+++ b/apps/api/src/app/topics/topics.controller.ts
@@ -11,16 +11,8 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
-import {
- ApiConflictResponse,
- ApiNoContentResponse,
- ApiNotFoundResponse,
- ApiOkResponse,
- ApiOperation,
- ApiQuery,
- ApiTags,
-} from '@nestjs/swagger';
-import { ExternalSubscriberId, IJwtPayload, TopicKey } from '@novu/shared';
+import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { ApiRateLimitCategoryEnum, ExternalSubscriberId, IJwtPayload, TopicKey } from '@novu/shared';
import {
AddSubscribersRequestDto,
@@ -52,14 +44,24 @@ import {
RenameTopicCommand,
RenameTopicUseCase,
} from './use-cases';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { UserSession } from '../shared/framework/user.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import {
+ ApiCommonResponses,
+ ApiResponse,
+ ApiConflictResponse,
+ ApiNoContentResponse,
+ ApiNotFoundResponse,
+ ApiOkResponse,
+} from '../shared/framework/response.decorator';
+import { ThrottlerCategory } from '../rate-limiting/guards';
+@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
+@ApiCommonResponses()
@Controller('/topics')
@ApiTags('Topics')
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
export class TopicsController {
constructor(
private addSubscribersUseCase: AddSubscribersUseCase,
diff --git a/apps/api/src/app/user/dtos/user-response.dto.ts b/apps/api/src/app/user/dtos/user-response.dto.ts
index d29024d9383..7550d987b4d 100644
--- a/apps/api/src/app/user/dtos/user-response.dto.ts
+++ b/apps/api/src/app/user/dtos/user-response.dto.ts
@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { JobTitleEnum } from '@novu/shared';
+
export class ServicesHashesDto {
@ApiProperty()
intercom?: string;
@@ -35,4 +37,7 @@ export class UserResponseDto {
@ApiProperty()
servicesHashes?: ServicesHashesDto;
+
+ @ApiPropertyOptional()
+ jobTitle?: JobTitleEnum;
}
diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts
index 2ac037be922..07a4cd924ec 100644
--- a/apps/api/src/app/user/user.controller.ts
+++ b/apps/api/src/app/user/user.controller.ts
@@ -13,24 +13,25 @@ import { UserSession } from '../shared/framework/user.decorator';
import { GetMyProfileUsecase } from './usecases/get-my-profile/get-my-profile.usecase';
import { GetMyProfileCommand } from './usecases/get-my-profile/get-my-profile.dto';
import { UserResponseDto } from './dtos/user-response.dto';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { UpdateOnBoardingCommand } from './usecases/update-on-boarding/update-on-boarding.command';
import { UpdateOnBoardingUsecase } from './usecases/update-on-boarding/update-on-boarding.usecase';
-import { ApiExcludeController, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserOnboardingRequestDto } from './dtos/user-onboarding-request.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ChangeProfileEmailDto } from './dtos/change-profile-email.dto';
import { UpdateProfileEmail } from './usecases/update-profile-email/update-profile-email.usecase';
import { UpdateProfileEmailCommand } from './usecases/update-profile-email/update-profile-email.command';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framework/response.decorator';
import { UserOnboardingTourRequestDto } from './dtos/user-onboarding-tour-request.dto';
import { UpdateOnBoardingTourUsecase } from './usecases/update-on-boarding-tour/update-on-boarding-tour.usecase';
import { UpdateOnBoardingTourCommand } from './usecases/update-on-boarding-tour/update-on-boarding-tour.command';
+@ApiCommonResponses()
@Controller('/users')
@ApiTags('Users')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiExcludeController()
export class UsersController {
constructor(
diff --git a/apps/api/src/app/widgets/dtos/organization-response.dto.ts b/apps/api/src/app/widgets/dtos/organization-response.dto.ts
index 0a241e345e5..ad8447ba667 100644
--- a/apps/api/src/app/widgets/dtos/organization-response.dto.ts
+++ b/apps/api/src/app/widgets/dtos/organization-response.dto.ts
@@ -22,6 +22,6 @@ export class OrganizationResponseDto {
_id: string;
@ApiProperty()
name: string;
- @ApiProperty()
- branding: Branding;
+ @ApiPropertyOptional()
+ branding?: Branding;
}
diff --git a/apps/api/src/app/widgets/e2e/get-count.e2e.ts b/apps/api/src/app/widgets/e2e/get-count.e2e.ts
index b6fa214b411..07bd85d2f0c 100644
--- a/apps/api/src/app/widgets/e2e/get-count.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/get-count.e2e.ts
@@ -262,7 +262,7 @@ describe('Count - GET /widget/notifications/count', function () {
expect(messages[0].seen).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -293,7 +293,7 @@ describe('Count - GET /widget/notifications/count', function () {
expect(messages[0].read).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true, read: true } },
{
headers: {
@@ -341,7 +341,7 @@ describe('Count - GET /widget/notifications/count', function () {
});
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -355,7 +355,7 @@ describe('Count - GET /widget/notifications/count', function () {
});
async function getFeedCount(query = {}) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/count`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/count`, {
params: {
...query,
},
diff --git a/apps/api/src/app/widgets/e2e/get-notification-feed.e2e.ts b/apps/api/src/app/widgets/e2e/get-notification-feed.e2e.ts
index 36064ec2361..41ea07dfeba 100644
--- a/apps/api/src/app/widgets/e2e/get-notification-feed.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/get-notification-feed.e2e.ts
@@ -209,7 +209,7 @@ describe('GET /widget/notifications/feed', function () {
});
async function getSubscriberFeed(query = {}) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/feed`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/feed`, {
params: {
page: 0,
...query,
@@ -224,7 +224,7 @@ describe('GET /widget/notifications/feed', function () {
async function markMessageAsSeen(messageId: string) {
return await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
diff --git a/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts b/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts
index 9d04e5344c9..834e0779008 100644
--- a/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts
@@ -88,7 +88,7 @@ describe('GET /widget/preferences', function () {
});
export async function getSubscriberPreference(subscriberToken: string) {
- return await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/preferences`, {
+ return await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/preferences`, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
diff --git a/apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts b/apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts
index c0c464a0457..bc7512da204 100644
--- a/apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts
@@ -56,7 +56,7 @@ describe('Unread Count - GET /widget/notifications/unread', function () {
expect(messages[0].read).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { read: true } },
{
headers: {
@@ -85,7 +85,7 @@ describe('Unread Count - GET /widget/notifications/unread', function () {
expect(messages[0].read).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { read: true } },
{
headers: {
@@ -114,7 +114,7 @@ describe('Unread Count - GET /widget/notifications/unread', function () {
expect(messages[0].read).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { read: true } },
{
headers: {
@@ -128,7 +128,7 @@ describe('Unread Count - GET /widget/notifications/unread', function () {
});
async function getUnreadCount(query = {}) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/unread`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unread`, {
params: {
...query,
},
diff --git a/apps/api/src/app/widgets/e2e/get-unseen-count.e2e.ts b/apps/api/src/app/widgets/e2e/get-unseen-count.e2e.ts
index e2325e59b1e..7dffe1f9e36 100644
--- a/apps/api/src/app/widgets/e2e/get-unseen-count.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/get-unseen-count.e2e.ts
@@ -74,7 +74,7 @@ describe('Unseen Count - GET /widget/notifications/unseen', function () {
expect(messages[0].seen).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -103,7 +103,7 @@ describe('Unseen Count - GET /widget/notifications/unseen', function () {
expect(messages[0].seen).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -132,7 +132,7 @@ describe('Unseen Count - GET /widget/notifications/unseen', function () {
expect(messages[0].seen).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -177,7 +177,7 @@ describe('Unseen Count - GET /widget/notifications/unseen', function () {
});
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
@@ -191,7 +191,7 @@ describe('Unseen Count - GET /widget/notifications/unseen', function () {
});
async function getUnseenCount(query = {}) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/unseen`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unseen`, {
params: {
...query,
},
diff --git a/apps/api/src/app/widgets/e2e/mark-all-as-read.e2e.ts b/apps/api/src/app/widgets/e2e/mark-all-as-read.e2e.ts
index 838777e511d..935ba5a993b 100644
--- a/apps/api/src/app/widgets/e2e/mark-all-as-read.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/mark-all-as-read.e2e.ts
@@ -51,7 +51,7 @@ describe('Mark all as read - /widgets/messages/seen (POST)', function () {
expect(unseenMessagesBefore.data.count).to.equal(3);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/seen`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/seen`,
{},
{
headers: {
@@ -76,7 +76,7 @@ describe('Mark all as read - /widgets/messages/seen (POST)', function () {
expect(unseenMessagesBefore.data.count).to.equal(3);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/read`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/read`,
{},
{
headers: {
@@ -91,7 +91,7 @@ describe('Mark all as read - /widgets/messages/seen (POST)', function () {
});
async function getFeedCount(query = {}) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/unseen`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unseen`, {
params: {
...query,
},
@@ -104,7 +104,7 @@ describe('Mark all as read - /widgets/messages/seen (POST)', function () {
}
async function getNotificationCount(query: string) {
- const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/count?${query}`, {
+ const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/count?${query}`, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
diff --git a/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts b/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts
index bd00c8cd97e..13116d560e3 100644
--- a/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts
@@ -43,7 +43,7 @@ describe('Mark as Seen - /widgets/messages/:messageId/seen (POST)', async () =>
expect(messages[0].seen).to.equal(false);
await axios.post(
- `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`,
+ `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`,
{ messageId, mark: { seen: true } },
{
headers: {
diff --git a/apps/api/src/app/widgets/e2e/remove-all-messages.e2e.ts b/apps/api/src/app/widgets/e2e/remove-all-messages.e2e.ts
index 7c5bc3cd33b..3c984801c5b 100644
--- a/apps/api/src/app/widgets/e2e/remove-all-messages.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/remove-all-messages.e2e.ts
@@ -54,7 +54,7 @@ describe('Remove all messages - /widgets/messages (DELETE)', function () {
});
expect(messagesBefore.length).to.equal(3);
- await axios.delete(`http://localhost:${process.env.PORT}/v1/widgets/messages`, {
+ await axios.delete(`http://127.0.0.1:${process.env.PORT}/v1/widgets/messages`, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
@@ -91,7 +91,7 @@ describe('Remove all messages - /widgets/messages (DELETE)', function () {
expect(messagesBefore.length).to.equal(5);
- await axios.delete(`http://localhost:${process.env.PORT}/v1/widgets/messages?feedId=${_feedId}`, {
+ await axios.delete(`http://127.0.0.1:${process.env.PORT}/v1/widgets/messages?feedId=${_feedId}`, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
diff --git a/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts b/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts
index a798cc37714..4890cdfb94f 100644
--- a/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts
+++ b/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts
@@ -115,7 +115,7 @@ export async function updateSubscriberPreference(
subscriberToken: string,
templateId: string
) {
- return await axios.patch(`http://localhost:${process.env.PORT}/v1/widgets/preferences/${templateId}`, data, {
+ return await axios.patch(`http://127.0.0.1:${process.env.PORT}/v1/widgets/preferences/${templateId}`, data, {
headers: {
Authorization: `Bearer ${subscriberToken}`,
},
diff --git a/apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase.ts b/apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase.ts
index 767ea7b7d57..e1a5e65ebfe 100644
--- a/apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase.ts
+++ b/apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase.ts
@@ -65,15 +65,15 @@ export class MarkAllMessagesAs {
countQuery
);
- this.webSocketsQueueService.add(
- 'sendMessage',
- {
+ this.webSocketsQueueService.add({
+ name: 'sendMessage',
+ data: {
event: isUnreadCountChanged ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN,
userId: subscriber._id,
_environmentId: command.environmentId,
},
- subscriber._organizationId
- );
+ groupId: subscriber._organizationId,
+ });
this.analyticsService.track(
`Mark all messages as ${command.markAs}- [Notification Center]`,
diff --git a/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts b/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts
index 35bb46f4f83..2bdc8d3b710 100644
--- a/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts
+++ b/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts
@@ -85,15 +85,15 @@ export class MarkMessageAs {
private updateSocketCount(subscriber: SubscriberEntity, mark: MarkEnum) {
const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN;
- this.webSocketsQueueService.add(
- 'sendMessage',
- {
+ this.webSocketsQueueService.add({
+ name: 'sendMessage',
+ data: {
event: eventMessage,
userId: subscriber._id,
_environmentId: subscriber._environmentId,
},
- subscriber._organizationId
- );
+ groupId: subscriber._organizationId,
+ });
}
@CachedEntity({
builder: (command: { subscriberId: string; _environmentId: string }) =>
diff --git a/apps/api/src/app/widgets/usecases/remove-message/remove-message.usecase.ts b/apps/api/src/app/widgets/usecases/remove-message/remove-message.usecase.ts
index eff7b3eac25..b38e5e08d4d 100644
--- a/apps/api/src/app/widgets/usecases/remove-message/remove-message.usecase.ts
+++ b/apps/api/src/app/widgets/usecases/remove-message/remove-message.usecase.ts
@@ -98,14 +98,14 @@ export class RemoveMessage {
private updateSocketCount(subscriber: SubscriberEntity, mark: MarkEnum) {
const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN;
- this.webSocketsQueueService.add(
- 'sendMessage',
- {
+ this.webSocketsQueueService.add({
+ name: 'sendMessage',
+ data: {
event: eventMessage,
userId: subscriber._id,
_environmentId: subscriber._environmentId,
},
- subscriber._organizationId
- );
+ groupId: subscriber._organizationId,
+ });
}
}
diff --git a/apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.usecase.ts b/apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.usecase.ts
index 8025a66b4e9..4c25d6af4ed 100644
--- a/apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.usecase.ts
+++ b/apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.usecase.ts
@@ -100,14 +100,14 @@ export class RemoveAllMessages {
private updateSocketCount(subscriber: SubscriberEntity, mark: string) {
const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN;
- this.webSocketsQueueService.add(
- 'sendMessage',
- {
+ this.webSocketsQueueService.add({
+ name: 'sendMessage',
+ data: {
event: eventMessage,
userId: subscriber._id,
_environmentId: subscriber._environmentId,
},
- subscriber._organizationId
- );
+ groupId: subscriber._organizationId,
+ });
}
}
diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts
index cdff461f9b3..9cc13b37de9 100644
--- a/apps/api/src/app/widgets/widgets.controller.ts
+++ b/apps/api/src/app/widgets/widgets.controller.ts
@@ -14,7 +14,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
-import { ApiExcludeController, ApiNoContentResponse, ApiOperation, ApiQuery } from '@nestjs/swagger';
+import { ApiExcludeController, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { AnalyticsService, GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic';
import { MessageEntity, PreferenceLevelEnum, SubscriberEntity } from '@novu/dal';
import { MarkMessagesAsEnum, ButtonTypeEnum, MessageActionStatusEnum } from '@novu/shared';
@@ -61,7 +61,9 @@ import {
import { UpdateSubscriberGlobalPreferencesRequestDto } from '../subscribers/dtos/update-subscriber-global-preferences-request.dto';
import { GetPreferencesByLevel } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase';
import { GetPreferencesByLevelCommand } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command';
+import { ApiCommonResponses, ApiNoContentResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/widgets')
@ApiExcludeController()
export class WidgetsController {
@@ -431,7 +433,7 @@ export class WidgetsController {
let paramArray: string[] | undefined = undefined;
if (param) {
- paramArray = Array.isArray(param) ? param : param.split(',');
+ paramArray = Array.isArray(param) ? param : String(param).split(',');
}
return paramArray as string[];
diff --git a/apps/api/src/app/workflow-overrides/dto/create-workflow-override-request.dto.ts b/apps/api/src/app/workflow-overrides/dto/create-workflow-override-request.dto.ts
new file mode 100644
index 00000000000..531ac47379a
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/create-workflow-override-request.dto.ts
@@ -0,0 +1,32 @@
+import { IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+
+import { ICreateWorkflowOverrideRequestDto } from '@novu/shared';
+
+import { PreferenceChannels } from '../../shared/dtos/preference-channels';
+
+export class CreateWorkflowOverrideRequestDto implements ICreateWorkflowOverrideRequestDto {
+ @ApiProperty()
+ @IsString()
+ @IsDefined()
+ workflowId: string;
+
+ @ApiProperty()
+ @IsString()
+ @IsDefined()
+ tenantId: string;
+
+ @ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
+ active?: boolean;
+
+ @ApiPropertyOptional({
+ type: PreferenceChannels,
+ })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PreferenceChannels)
+ preferenceSettings?: PreferenceChannels;
+}
diff --git a/apps/api/src/app/workflow-overrides/dto/create-workflow-override-response.dto.ts b/apps/api/src/app/workflow-overrides/dto/create-workflow-override-response.dto.ts
new file mode 100644
index 00000000000..da93330d97d
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/create-workflow-override-response.dto.ts
@@ -0,0 +1,6 @@
+import { OverrideResponseDto } from './shared';
+import { ICreateWorkflowOverrideResponseDto } from '@novu/shared';
+
+export class CreateWorkflowOverrideResponseDto
+ extends OverrideResponseDto
+ implements ICreateWorkflowOverrideResponseDto {}
diff --git a/apps/api/src/app/workflow-overrides/dto/get-workflow-override-response.dto.ts b/apps/api/src/app/workflow-overrides/dto/get-workflow-override-response.dto.ts
new file mode 100644
index 00000000000..e3bbf4fa63b
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/get-workflow-override-response.dto.ts
@@ -0,0 +1,4 @@
+import { OverrideResponseDto } from './shared';
+import { IGetWorkflowOverrideResponseDto } from '@novu/shared';
+
+export class GetWorkflowOverrideResponseDto extends OverrideResponseDto implements IGetWorkflowOverrideResponseDto {}
diff --git a/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-request.dto.ts b/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-request.dto.ts
new file mode 100644
index 00000000000..a8fd6d99d6c
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-request.dto.ts
@@ -0,0 +1,3 @@
+import { PaginationRequestDto } from '../../shared/dtos/pagination-request';
+
+export class GetWorkflowOverridesRequestDto extends PaginationRequestDto(10, 100) {}
diff --git a/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-response.dto.ts b/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-response.dto.ts
new file mode 100644
index 00000000000..4e22f2a19f1
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/get-workflow-overrides-response.dto.ts
@@ -0,0 +1,17 @@
+import { OverrideResponseDto } from './shared';
+import { IGetWorkflowOverridesResponseDto } from '@novu/shared';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class GetWorkflowOverridesResponseDto implements IGetWorkflowOverridesResponseDto {
+ @ApiProperty()
+ hasMore: boolean;
+
+ @ApiProperty()
+ data: OverrideResponseDto[];
+
+ @ApiProperty()
+ pageSize: number;
+
+ @ApiProperty()
+ page: number;
+}
diff --git a/apps/api/src/app/workflow-overrides/dto/index.ts b/apps/api/src/app/workflow-overrides/dto/index.ts
new file mode 100644
index 00000000000..a972be98002
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/index.ts
@@ -0,0 +1,7 @@
+export * from './create-workflow-override-response.dto';
+export * from './create-workflow-override-request.dto';
+export * from './get-workflow-override-response.dto';
+export * from './get-workflow-overrides-request.dto';
+export * from './get-workflow-overrides-response.dto';
+export * from './update-workflow-override-request.dto';
+export * from './update-workflow-override-response.dto';
diff --git a/apps/api/src/app/workflow-overrides/dto/shared.ts b/apps/api/src/app/workflow-overrides/dto/shared.ts
new file mode 100644
index 00000000000..7e368a31af0
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/shared.ts
@@ -0,0 +1,49 @@
+import {
+ EnvironmentId,
+ IPreferenceChannels,
+ IWorkflowOverride,
+ OrganizationId,
+ WorkflowOverrideId,
+} from '@novu/shared';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { PreferenceChannels } from '../../shared/dtos/preference-channels';
+
+export class OverrideResponseDto implements IWorkflowOverride {
+ @ApiProperty()
+ _id: WorkflowOverrideId;
+
+ @ApiProperty()
+ _organizationId: OrganizationId;
+
+ @ApiProperty()
+ _environmentId: EnvironmentId;
+
+ @ApiProperty()
+ _workflowId: string;
+
+ @ApiProperty()
+ _tenantId: string;
+
+ @ApiProperty()
+ active: boolean;
+
+ @ApiProperty({
+ type: PreferenceChannels,
+ })
+ preferenceSettings: IPreferenceChannels;
+
+ @ApiProperty()
+ deleted: boolean;
+
+ @ApiPropertyOptional()
+ deletedAt?: string;
+
+ @ApiPropertyOptional()
+ deletedBy?: string;
+
+ @ApiProperty()
+ createdAt: string;
+
+ @ApiProperty()
+ updatedAt?: string;
+}
diff --git a/apps/api/src/app/workflow-overrides/dto/update-workflow-override-request.dto.ts b/apps/api/src/app/workflow-overrides/dto/update-workflow-override-request.dto.ts
new file mode 100644
index 00000000000..660d9b49cce
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/update-workflow-override-request.dto.ts
@@ -0,0 +1,22 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator';
+
+import { IUpdateWorkflowOverrideRequestDto } from '@novu/shared';
+
+import { PreferenceChannels } from '../../shared/dtos/preference-channels';
+
+export class UpdateWorkflowOverrideRequestDto implements IUpdateWorkflowOverrideRequestDto {
+ @ApiPropertyOptional()
+ @IsBoolean()
+ @IsOptional()
+ active?: boolean;
+
+ @ApiPropertyOptional({
+ type: PreferenceChannels,
+ })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PreferenceChannels)
+ preferenceSettings?: PreferenceChannels;
+}
diff --git a/apps/api/src/app/workflow-overrides/dto/update-workflow-override-response.dto.ts b/apps/api/src/app/workflow-overrides/dto/update-workflow-override-response.dto.ts
new file mode 100644
index 00000000000..8a46b449a56
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/dto/update-workflow-override-response.dto.ts
@@ -0,0 +1,6 @@
+import { OverrideResponseDto } from './shared';
+import { IUpdateWorkflowOverrideResponseDto } from '@novu/shared';
+
+export class UpdateWorkflowOverrideResponseDto
+ extends OverrideResponseDto
+ implements IUpdateWorkflowOverrideResponseDto {}
diff --git a/apps/api/src/app/workflow-overrides/e2e/create-workflow-override.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/create-workflow-override.e2e.ts
new file mode 100644
index 00000000000..d7dd7a42683
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/create-workflow-override.e2e.ts
@@ -0,0 +1,110 @@
+import { expect } from 'chai';
+
+import { UserSession } from '@novu/testing';
+import { ICreateWorkflowOverrideRequestDto } from '@novu/shared';
+import { NotificationTemplateRepository, TenantRepository } from '@novu/dal';
+
+describe('Create Integration - /workflow-overrides (POST)', function () {
+ let session: UserSession;
+ const tenantRepository = new TenantRepository();
+ const notificationTemplateRepository = new NotificationTemplateRepository();
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should successfully create new workflow override', async function () {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'identifier_123',
+ name: 'name_123',
+ data: { test1: 'test value1', test2: 'test value2' },
+ });
+
+ const workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: session.notificationGroups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api' }],
+ });
+
+ const payload: ICreateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: false },
+ active: false,
+ workflowId: workflow._id,
+ tenantId: tenant._id,
+ };
+
+ const res = await session.testAgent.post('/v1/workflow-overrides').send(payload);
+
+ expect(res.status).to.equal(201);
+
+ expect(res.body.data.active).to.equal(false);
+ expect(res.body.data._workflowId).to.equal(workflow._id);
+ expect(res.body.data._tenantId).to.equal(tenant._id);
+ expect(res.body.data.preferenceSettings).to.deep.equal({
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(res.body.data.deleted).to.equal(false);
+ expect(res.body.data._environmentId).to.equal(session.environment._id);
+ expect(res.body.data._organizationId).to.equal(session.organization._id);
+ });
+
+ it('should fail on creation of new workflow override with missing workflow id', async function () {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'identifier_123',
+ name: 'name_123',
+ data: { test1: 'test value1', test2: 'test value2' },
+ });
+
+ const payload: ICreateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: false },
+ active: false,
+ tenantId: tenant._id,
+ workflowId: undefined as any,
+ };
+
+ const res = await session.testAgent.post('/v1/workflow-overrides').send(payload);
+
+ expect(res.body.statusCode).to.equal(400);
+ expect(res.body.message[0]).to.equal('workflowId should not be null or undefined');
+ expect(res.body.message[1]).to.equal('workflowId must be a string');
+ });
+
+ it('should fail on creation of new workflow override with missing tenant id', async function () {
+ const workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: session.notificationGroups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api' }],
+ });
+
+ const payload: ICreateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: false },
+ active: false,
+ workflowId: workflow._id,
+ tenantId: 'fake-tenant-identifier',
+ };
+
+ const res = await session.testAgent.post('/v1/workflow-overrides').send(payload);
+
+ expect(res.body.statusCode).to.equal(400);
+ expect(res.body.message[0]).to.equal('_tenantId must be a mongodb id');
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/delete-workflow-override.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/delete-workflow-override.e2e.ts
new file mode 100644
index 00000000000..4123d2ceeff
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/delete-workflow-override.e2e.ts
@@ -0,0 +1,58 @@
+import { expect } from 'chai';
+import { UserSession } from '@novu/testing';
+import { TenantRepository, WorkflowOverrideRepository } from '@novu/dal';
+import { WorkflowOverrideService } from '@novu/testing';
+
+describe('Delete workflow override - /workflow-overrides/:overrideId (Delete)', async () => {
+ let session: UserSession;
+ const tenantRepository = new TenantRepository();
+ const workflowOverrideRepository = new WorkflowOverrideRepository();
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should delete the workflow override', async function () {
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+
+ const { tenant, workflowOverride } = await workflowOverrideService.createWorkflowOverride();
+
+ if (!tenant) throw new Error('Tenant not found');
+
+ const validatedCreationWorkflowOverride = await workflowOverrideRepository.findOne({
+ _environmentId: session.environment._id,
+ _id: workflowOverride._id,
+ });
+
+ if (!validatedCreationWorkflowOverride) throw new Error('WorkflowOverride not found');
+
+ expect(validatedCreationWorkflowOverride._id).to.be.ok;
+
+ const deleteRes = await session.testAgent.delete(`/v1/workflow-overrides/${validatedCreationWorkflowOverride._id}`);
+
+ const foundWorkflowOverride: boolean = deleteRes.body.data;
+
+ expect(foundWorkflowOverride).to.equal(true);
+
+ const findDeleted = await workflowOverrideRepository.findOne({
+ _environmentId: session.environment._id,
+ _id: workflowOverride._id,
+ });
+
+ expect(findDeleted).to.be.null;
+ });
+
+ it('should fail to delete non-existing workflow override', async function () {
+ const fakeWorkflowOverrideId = session.user._id;
+ const deleteRes = await session.testAgent.delete(`/v1/workflow-overrides/${fakeWorkflowOverrideId}`);
+
+ const foundWorkflowOverride = deleteRes.body;
+
+ expect(foundWorkflowOverride.statusCode).to.equal(404);
+ expect(foundWorkflowOverride.message).to.equal(`Workflow Override with id ${fakeWorkflowOverrideId} not found`);
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/get-workflow-override-by-id.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/get-workflow-override-by-id.e2e.ts
new file mode 100644
index 00000000000..6f329c7c5eb
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/get-workflow-override-by-id.e2e.ts
@@ -0,0 +1,43 @@
+import { expect } from 'chai';
+import { UserSession } from '@novu/testing';
+import { IWorkflowOverride } from '@novu/shared';
+import { TenantRepository } from '@novu/dal';
+import { WorkflowOverrideService } from '@novu/testing';
+
+describe('Get workflow override by ID - /workflow-overrides/:overrideId (GET)', async () => {
+ let session: UserSession;
+ const tenantRepository = new TenantRepository();
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should return the workflow override by ID', async function () {
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+ const { workflowOverride } = await workflowOverrideService.createWorkflowOverride();
+
+ const tenant = await tenantRepository.findOne({
+ _environmentId: session.environment._id,
+ _id: workflowOverride._tenantId,
+ });
+
+ if (!tenant) throw new Error('Tenant not found');
+
+ const res = await session.testAgent.get(`/v1/workflow-overrides/${workflowOverride._id}`);
+
+ const foundWorkflowOverride: IWorkflowOverride = res.body.data;
+
+ expect(foundWorkflowOverride._workflowId).to.equal(workflowOverride._workflowId);
+ expect(foundWorkflowOverride._tenantId).to.equal(workflowOverride._tenantId);
+ expect(foundWorkflowOverride.active).to.equal(workflowOverride.active);
+ expect(foundWorkflowOverride.preferenceSettings.chat).to.equal(workflowOverride.preferenceSettings.chat);
+ expect(foundWorkflowOverride.preferenceSettings.sms).to.equal(workflowOverride.preferenceSettings.sms);
+ expect(foundWorkflowOverride.preferenceSettings.in_app).to.equal(workflowOverride.preferenceSettings.in_app);
+ expect(foundWorkflowOverride.preferenceSettings.email).to.equal(workflowOverride.preferenceSettings.email);
+ expect(foundWorkflowOverride.preferenceSettings.push).to.equal(workflowOverride.preferenceSettings.push);
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/get-workflow-override.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/get-workflow-override.e2e.ts
new file mode 100644
index 00000000000..a1304b4d8f2
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/get-workflow-override.e2e.ts
@@ -0,0 +1,45 @@
+import { expect } from 'chai';
+import { UserSession } from '@novu/testing';
+import { IWorkflowOverride } from '@novu/shared';
+import { TenantRepository } from '@novu/dal';
+import { WorkflowOverrideService } from '@novu/testing';
+
+describe('Get workflow override - /workflow-overrides/workflows/:workflowId/tenants/:tenantIdentifier (GET)', async () => {
+ let session: UserSession;
+ const tenantRepository = new TenantRepository();
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should return the workflow override', async function () {
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+ const { workflowOverride } = await workflowOverrideService.createWorkflowOverride();
+
+ const tenant = await tenantRepository.findOne({
+ _environmentId: session.environment._id,
+ _id: workflowOverride._tenantId,
+ });
+
+ if (!tenant) throw new Error('Tenant not found');
+
+ const res = await session.testAgent.get(
+ `/v1/workflow-overrides/workflows/${workflowOverride._workflowId}/tenants/${tenant._id}`
+ );
+
+ const foundWorkflowOverride: IWorkflowOverride = res.body.data;
+
+ expect(foundWorkflowOverride._workflowId).to.equal(workflowOverride._workflowId);
+ expect(foundWorkflowOverride._tenantId).to.equal(workflowOverride._tenantId);
+ expect(foundWorkflowOverride.active).to.equal(workflowOverride.active);
+ expect(foundWorkflowOverride.preferenceSettings.chat).to.equal(workflowOverride.preferenceSettings.chat);
+ expect(foundWorkflowOverride.preferenceSettings.sms).to.equal(workflowOverride.preferenceSettings.sms);
+ expect(foundWorkflowOverride.preferenceSettings.in_app).to.equal(workflowOverride.preferenceSettings.in_app);
+ expect(foundWorkflowOverride.preferenceSettings.email).to.equal(workflowOverride.preferenceSettings.email);
+ expect(foundWorkflowOverride.preferenceSettings.push).to.equal(workflowOverride.preferenceSettings.push);
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/get-workflow-overrides.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/get-workflow-overrides.e2e.ts
new file mode 100644
index 00000000000..149757d0425
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/get-workflow-overrides.e2e.ts
@@ -0,0 +1,130 @@
+import { expect } from 'chai';
+import { UserSession } from '@novu/testing';
+import { WorkflowOverrideService } from '@novu/testing';
+import { NotificationGroupRepository, NotificationTemplateRepository, WorkflowOverrideRepository } from '@novu/dal';
+
+describe('Get workflows overrides - /workflow-overrides (GET)', async () => {
+ let session: UserSession;
+ const notificationTemplateRepository = new NotificationTemplateRepository();
+ const notificationGroupRepository = new NotificationGroupRepository();
+ const workflowOverrideRepository = new WorkflowOverrideRepository();
+
+ before(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should return all workflows override by workflow id', async () => {
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+
+ const groups = await notificationGroupRepository.find({
+ _environmentId: session.environment._id,
+ });
+
+ const noOverrides = (await session.testAgent.get(`/v1/workflow-overrides`)).body.data;
+
+ expect(noOverrides.length).to.equal(0);
+
+ let workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_1' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+ workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_2' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+ workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_3' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+
+ const data = (await session.testAgent.get(`/v1/workflow-overrides`)).body.data;
+
+ expect(data.length).to.equal(3);
+
+ const paginatedData = (await session.testAgent.get(`/v1/workflow-overrides?page=1&limit=2`)).body.data;
+
+ expect(paginatedData.length).to.equal(1);
+ });
+
+ it('should return all workflows override by workflow id with pagination', async () => {
+ await workflowOverrideRepository.delete({} as any);
+
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+
+ const groups = await notificationGroupRepository.find({
+ _environmentId: session.environment._id,
+ });
+
+ let workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_1' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+ workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_2' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+ workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: groups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api_2' }],
+ });
+ await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id });
+
+ const page1 = (await session.testAgent.get(`/v1/workflow-overrides?limit=2`)).body;
+
+ expect(page1.data.length).to.equal(2);
+ expect(page1.hasMore).to.equal(true);
+
+ const page2 = (await session.testAgent.get(`/v1/workflow-overrides?page=1&limit=2`)).body;
+
+ expect(page2.data.length).to.equal(1);
+ expect(page2.hasMore).to.equal(false);
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/update-workflow-override-by-id.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/update-workflow-override-by-id.e2e.ts
new file mode 100644
index 00000000000..293271f8f04
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/update-workflow-override-by-id.e2e.ts
@@ -0,0 +1,57 @@
+import { expect } from 'chai';
+
+import { UserSession, WorkflowOverrideService } from '@novu/testing';
+import { IUpdateWorkflowOverrideRequestDto } from '@novu/shared';
+
+describe('Update Workflow Override By ID - /workflow-overrides/:overrideId (PUT)', function () {
+ let session: UserSession;
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should successfully update workflow override by ID', async function () {
+ const workflowOverrideService = new WorkflowOverrideService({
+ organizationId: session.organization._id,
+ environmentId: session.environment._id,
+ });
+
+ const { workflowOverride } = await workflowOverrideService.createWorkflowOverride({
+ preferenceSettings: {
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ },
+ });
+
+ expect(workflowOverride.preferenceSettings).to.deep.equal({
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(workflowOverride.active).to.equal(false);
+
+ const updatePayload: IUpdateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: true, sms: false },
+ active: true,
+ };
+
+ const updatedOverrides = (
+ await session.testAgent.put(`/v1/workflow-overrides/${workflowOverride._id}`).send(updatePayload)
+ ).body.data;
+
+ expect(updatedOverrides.preferenceSettings).to.deep.equal({
+ email: true,
+ sms: false,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(updatedOverrides.active).to.equal(true);
+ });
+});
diff --git a/apps/api/src/app/workflow-overrides/e2e/update-workflow-override.e2e.ts b/apps/api/src/app/workflow-overrides/e2e/update-workflow-override.e2e.ts
new file mode 100644
index 00000000000..8b7d8ef5952
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/e2e/update-workflow-override.e2e.ts
@@ -0,0 +1,172 @@
+import { expect } from 'chai';
+
+import { UserSession } from '@novu/testing';
+import { ICreateWorkflowOverrideRequestDto, IUpdateWorkflowOverrideRequestDto } from '@novu/shared';
+import { NotificationTemplateRepository, TenantRepository } from '@novu/dal';
+
+describe('Update Workflow Override - /workflow-overrides/workflows/:workflowId/tenants/:tenantIdentifier (PUT)', function () {
+ let session: UserSession;
+ const tenantRepository = new TenantRepository();
+ const notificationTemplateRepository = new NotificationTemplateRepository();
+
+ beforeEach(async () => {
+ session = new UserSession();
+ await session.initialize();
+ });
+
+ it('should successfully update workflow override', async function () {
+ const { tenant, workflow, overrides } = await initializeOverrides();
+
+ expect(overrides.preferenceSettings).to.deep.equal({
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(overrides.active).to.equal(false);
+
+ const updatePayload: IUpdateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: true, sms: false },
+ active: true,
+ };
+
+ const updatedOverrides = (
+ await session.testAgent
+ .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${tenant._id}`)
+ .send(updatePayload)
+ ).body.data;
+
+ expect(updatedOverrides.preferenceSettings).to.deep.equal({
+ email: true,
+ sms: false,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(updatedOverrides.active).to.equal(true);
+ });
+
+ it('should fail update workflow override with invalid tenant identifier', async function () {
+ const { tenant, workflow, overrides } = await initializeOverrides();
+
+ expect(overrides.preferenceSettings).to.deep.equal({
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(overrides.active).to.equal(false);
+
+ const updatePayload: IUpdateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: true, sms: false },
+ active: true,
+ };
+
+ const invalidTenantIdentifier = 'invalid-tenant-identifier';
+ const updatedOverrides = (
+ await session.testAgent
+ .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${invalidTenantIdentifier}`)
+ .send(updatePayload)
+ ).body;
+
+ expect(updatedOverrides.statusCode).to.equal(400);
+ expect(updatedOverrides.message[0]).to.equal('_tenantId must be a mongodb id');
+ });
+
+ it('should fail update workflow override with invalid workflow id', async function () {
+ const { tenant, workflow, overrides } = await initializeOverrides();
+
+ expect(overrides.preferenceSettings).to.deep.equal({
+ email: false,
+ sms: true,
+ in_app: true,
+ chat: true,
+ push: true,
+ });
+ expect(overrides.active).to.equal(false);
+
+ const updatePayload: IUpdateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: true, sms: false },
+ active: true,
+ };
+
+ const invalidWorkflowId = tenant._id;
+ const updatedOverrides = (
+ await session.testAgent
+ .put(`/v1/workflow-overrides/workflows/${invalidWorkflowId}/tenants/${tenant.identifier}`)
+ .send(updatePayload)
+ ).body;
+
+ expect(updatedOverrides.statusCode).to.equal(400);
+ expect(updatedOverrides.message[0]).to.equal(`_tenantId must be a mongodb id`);
+ });
+
+ it('should fail update workflow override with now existing workflow override', async function () {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'identifier_123',
+ name: 'name_123',
+ data: { test1: 'test value1', test2: 'test value2' },
+ });
+
+ const workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: session.notificationGroups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api' }],
+ });
+
+ const updatePayload: IUpdateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: true, sms: false },
+ active: true,
+ };
+
+ const updatedOverrides = (
+ await session.testAgent
+ .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${tenant.identifier}`)
+ .send(updatePayload)
+ ).body;
+
+ expect(updatedOverrides.statusCode).to.equal(400);
+ expect(updatedOverrides.message[0]).to.equal(`_tenantId must be a mongodb id`);
+ });
+
+ async function initializeOverrides() {
+ const tenant = await tenantRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ identifier: 'identifier_123',
+ name: 'name_123',
+ data: { test1: 'test value1', test2: 'test value2' },
+ });
+
+ const workflow = await notificationTemplateRepository.create({
+ _organizationId: session.organization._id,
+ _environmentId: session.environment._id,
+ name: 'test api template',
+ description: 'This is a test description',
+ tags: ['test-tag-api'],
+ notificationGroupId: session.notificationGroups[0]._id,
+ steps: [],
+ triggers: [{ identifier: 'test-trigger-api' }],
+ });
+
+ const payload: ICreateWorkflowOverrideRequestDto = {
+ preferenceSettings: { email: false },
+ active: false,
+ workflowId: workflow._id,
+ tenantId: tenant._id,
+ };
+
+ const overrides = (await session.testAgent.post('/v1/workflow-overrides').send(payload)).body.data;
+
+ return { tenant, workflow, overrides };
+ }
+});
diff --git a/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.command.ts b/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.command.ts
new file mode 100644
index 00000000000..97a8d97817c
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.command.ts
@@ -0,0 +1,24 @@
+import { Type } from 'class-transformer';
+import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator';
+
+import { PreferenceChannels } from '../../../shared/dtos/preference-channels';
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class CreateWorkflowOverrideCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ _workflowId: string;
+
+ @IsMongoId()
+ @IsDefined()
+ _tenantId: string;
+
+ @IsBoolean()
+ @IsOptional()
+ active?: boolean;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PreferenceChannels)
+ preferenceSettings?: PreferenceChannels;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.usecase.ts
new file mode 100644
index 00000000000..420fb6c0467
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.usecase.ts
@@ -0,0 +1,58 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+
+import {
+ NotificationTemplateEntity,
+ NotificationTemplateRepository,
+ TenantEntity,
+ TenantRepository,
+ WorkflowOverrideRepository,
+} from '@novu/dal';
+
+import { CreateWorkflowOverrideCommand } from './create-workflow-override.command';
+import { CreateWorkflowOverrideResponseDto } from '../../dto';
+
+@Injectable()
+export class CreateWorkflowOverride {
+ constructor(
+ private tenantRepository: TenantRepository,
+ private notificationTemplateRepository: NotificationTemplateRepository,
+ private workflowOverrideRepository: WorkflowOverrideRepository
+ ) {}
+
+ async execute(command: CreateWorkflowOverrideCommand): Promise {
+ const { tenant, workflow } = await this.extractEntities(command);
+
+ return await this.workflowOverrideRepository.create({
+ _organizationId: command.organizationId,
+ _environmentId: command.environmentId,
+ _tenantId: tenant._id,
+ _workflowId: workflow._id,
+ active: command.active,
+ preferenceSettings: command.preferenceSettings,
+ });
+ }
+
+ private async extractEntities(
+ command: CreateWorkflowOverrideCommand
+ ): Promise<{ tenant: TenantEntity; workflow: NotificationTemplateEntity }> {
+ const tenant = await this.tenantRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command._tenantId,
+ });
+
+ if (!tenant) {
+ throw new NotFoundException(`Tenant with id ${command._tenantId} is not found`);
+ }
+
+ const workflow = await this.notificationTemplateRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command._workflowId,
+ });
+
+ if (!workflow) {
+ throw new NotFoundException(`Workflow with id ${command._workflowId} is not found`);
+ }
+
+ return { tenant, workflow };
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.command.ts b/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.command.ts
new file mode 100644
index 00000000000..3c0236d88ec
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.command.ts
@@ -0,0 +1,9 @@
+import { IsDefined, IsMongoId } from 'class-validator';
+
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class DeleteWorkflowOverrideCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ _id: string;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.usecase.ts
new file mode 100644
index 00000000000..1401f05ac3b
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.usecase.ts
@@ -0,0 +1,30 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { WorkflowOverrideRepository } from '@novu/dal';
+import { DeleteWorkflowOverrideCommand } from './delete-workflow-override.command';
+
+@Injectable()
+export class DeleteWorkflowOverride {
+ constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {}
+
+ async execute(command: DeleteWorkflowOverrideCommand): Promise {
+ const workflowOverride = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command._id,
+ });
+
+ if (!workflowOverride) {
+ throw new NotFoundException(`Workflow Override with id ${command._id} not found`);
+ }
+
+ const deletedWorkflowOverride = await this.workflowOverrideRepository.delete({
+ _environmentId: command.environmentId,
+ _id: command._id,
+ });
+
+ if (!deletedWorkflowOverride?.acknowledged) {
+ throw new Error(`Unexpected error: failed to delete workflow override with id ${command._id}`);
+ }
+
+ return true;
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.command.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.command.ts
new file mode 100644
index 00000000000..bd732239f8f
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.command.ts
@@ -0,0 +1,9 @@
+import { IsDefined, IsMongoId } from 'class-validator';
+
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class GetWorkflowOverrideByIdCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ overrideId: string;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase.ts
new file mode 100644
index 00000000000..76fe374bb0d
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase.ts
@@ -0,0 +1,24 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { WorkflowOverrideRepository } from '@novu/dal';
+
+import { GetWorkflowOverrideResponseDto } from '../../dto';
+import { GetWorkflowOverrideByIdCommand } from './get-workflow-override-by-id.command';
+
+@Injectable()
+export class GetWorkflowOverrideById {
+ constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {}
+
+ async execute(command: GetWorkflowOverrideByIdCommand): Promise {
+ const workflowOverride = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command.overrideId,
+ });
+
+ if (!workflowOverride) {
+ throw new NotFoundException(`Workflow Override with id ${command.overrideId} not found`);
+ }
+
+ return workflowOverride;
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.command.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.command.ts
new file mode 100644
index 00000000000..b6a40dea49f
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.command.ts
@@ -0,0 +1,13 @@
+import { IsDefined, IsMongoId, IsString } from 'class-validator';
+
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class GetWorkflowOverrideCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ _workflowId: string;
+
+ @IsMongoId()
+ @IsDefined()
+ _tenantId: string;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.usecase.ts
new file mode 100644
index 00000000000..63248e6aeb9
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.usecase.ts
@@ -0,0 +1,29 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { TenantRepository, WorkflowOverrideRepository } from '@novu/dal';
+import { GetWorkflowOverrideResponseDto } from '../../dto/get-workflow-override-response.dto';
+import { GetWorkflowOverrideCommand } from './get-workflow-override.command';
+
+@Injectable()
+export class GetWorkflowOverride {
+ constructor(
+ private tenantRepository: TenantRepository,
+ private workflowOverrideRepository: WorkflowOverrideRepository
+ ) {}
+
+ async execute(command: GetWorkflowOverrideCommand): Promise {
+ const workflowOverride = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _workflowId: command._workflowId,
+ _tenantId: command._tenantId,
+ });
+
+ if (!workflowOverride) {
+ throw new NotFoundException(
+ `Workflow Override with workflow id ${command._workflowId}, tenant id ${command._tenantId} not found`
+ );
+ }
+
+ return workflowOverride;
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.command.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.command.ts
new file mode 100644
index 00000000000..52aa8bc7ab5
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.command.ts
@@ -0,0 +1,12 @@
+import { IsDefined, IsMongoId, IsNumber } from 'class-validator';
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class GetWorkflowOverridesCommand extends EnvironmentWithUserCommand {
+ @IsNumber()
+ @IsDefined()
+ page: number;
+
+ @IsNumber()
+ @IsDefined()
+ limit: number;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.usecase.ts
new file mode 100644
index 00000000000..c7c08d15546
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.usecase.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+
+import { WorkflowOverrideRepository } from '@novu/dal';
+import { GetWorkflowOverridesCommand } from './get-workflow-overrides.command';
+import { GetWorkflowOverridesResponseDto } from '../../dto/get-workflow-overrides-response.dto';
+
+@Injectable()
+export class GetWorkflowOverrides {
+ constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {}
+
+ async execute(command: GetWorkflowOverridesCommand): Promise {
+ const { data } = await this.workflowOverrideRepository.getList(
+ {
+ skip: command.page * command.limit,
+ limit: command.limit,
+ },
+ {
+ environmentId: command.environmentId,
+ }
+ );
+
+ return {
+ data: data,
+ page: command.page,
+ pageSize: command.limit,
+ hasMore: data?.length === command.limit,
+ };
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/index.ts b/apps/api/src/app/workflow-overrides/usecases/index.ts
new file mode 100644
index 00000000000..031c498f23c
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/index.ts
@@ -0,0 +1,17 @@
+import { CreateWorkflowOverride } from './create-workflow-override/create-workflow-override.usecase';
+import { UpdateWorkflowOverride } from './update-workflow-override/update-workflow-override.usecase';
+import { GetWorkflowOverride } from './get-workflow-override/get-workflow-override.usecase';
+import { DeleteWorkflowOverride } from './delete-workflow-override/delete-workflow-override.usecase';
+import { GetWorkflowOverrides } from './get-workflow-overrides/get-workflow-overrides.usecase';
+import { GetWorkflowOverrideById } from './get-workflow-override-by-id/get-workflow-override-by-id.usecase';
+import { UpdateWorkflowOverrideById } from './update-workflow-override-by-id/update-workflow-override-by-id.usecase';
+
+export const USE_CASES = [
+ CreateWorkflowOverride,
+ UpdateWorkflowOverride,
+ GetWorkflowOverride,
+ DeleteWorkflowOverride,
+ GetWorkflowOverrides,
+ GetWorkflowOverrideById,
+ UpdateWorkflowOverrideById,
+];
diff --git a/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.command.ts b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.command.ts
new file mode 100644
index 00000000000..0d2f4a1baa5
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.command.ts
@@ -0,0 +1,20 @@
+import { Type } from 'class-transformer';
+import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator';
+
+import { PreferenceChannels } from '../../../shared/dtos/preference-channels';
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class UpdateWorkflowOverrideByIdCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ overrideId: string;
+
+ @IsBoolean()
+ @IsOptional()
+ active?: boolean;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PreferenceChannels)
+ preferenceSettings?: PreferenceChannels;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase.ts
new file mode 100644
index 00000000000..9400feaa84b
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase.ts
@@ -0,0 +1,56 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import {
+ NotificationTemplateRepository,
+ TenantRepository,
+ WorkflowOverrideRepository,
+ WorkflowOverrideEntity,
+} from '@novu/dal';
+
+import { UpdateWorkflowOverrideByIdCommand } from './update-workflow-override-by-id.command';
+import { UpdateWorkflowOverrideResponseDto } from '../../dto';
+
+@Injectable()
+export class UpdateWorkflowOverrideById {
+ constructor(
+ private tenantRepository: TenantRepository,
+ private notificationTemplateRepository: NotificationTemplateRepository,
+ private workflowOverrideRepository: WorkflowOverrideRepository
+ ) {}
+
+ async execute(command: UpdateWorkflowOverrideByIdCommand): Promise {
+ const currentOverrideEntity = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command.overrideId,
+ });
+
+ if (!currentOverrideEntity) {
+ throw new NotFoundException(`Workflow override with id ${command.overrideId} not found`);
+ }
+
+ const updatePayload: Partial = {};
+
+ if (command.active != null) {
+ updatePayload.active = command.active;
+ }
+
+ if (command.preferenceSettings != null) {
+ updatePayload.preferenceSettings = {
+ ...currentOverrideEntity.preferenceSettings,
+ ...command.preferenceSettings,
+ };
+ }
+
+ await this.workflowOverrideRepository.update(
+ {
+ _environmentId: command.environmentId,
+ _id: currentOverrideEntity._id,
+ },
+ {
+ $set: updatePayload,
+ }
+ );
+
+ return { ...currentOverrideEntity, ...updatePayload };
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.command.ts b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.command.ts
new file mode 100644
index 00000000000..f7f8799b087
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.command.ts
@@ -0,0 +1,24 @@
+import { Type } from 'class-transformer';
+import { IsBoolean, IsDefined, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator';
+
+import { PreferenceChannels } from '../../../shared/dtos/preference-channels';
+import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
+
+export class UpdateWorkflowOverrideCommand extends EnvironmentWithUserCommand {
+ @IsMongoId()
+ @IsDefined()
+ _workflowId: string;
+
+ @IsMongoId()
+ @IsDefined()
+ _tenantId: string;
+
+ @IsBoolean()
+ @IsOptional()
+ active?: boolean;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PreferenceChannels)
+ preferenceSettings?: PreferenceChannels;
+}
diff --git a/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.usecase.ts b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.usecase.ts
new file mode 100644
index 00000000000..827f8c1d7a6
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.usecase.ts
@@ -0,0 +1,86 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import {
+ NotificationTemplateEntity,
+ NotificationTemplateRepository,
+ TenantEntity,
+ TenantRepository,
+ WorkflowOverrideRepository,
+ WorkflowOverrideEntity,
+} from '@novu/dal';
+import { UpdateWorkflowOverrideCommand } from './update-workflow-override.command';
+import { UpdateWorkflowOverrideResponseDto } from '../../dto/update-workflow-override-response.dto';
+
+@Injectable()
+export class UpdateWorkflowOverride {
+ constructor(
+ private tenantRepository: TenantRepository,
+ private notificationTemplateRepository: NotificationTemplateRepository,
+ private workflowOverrideRepository: WorkflowOverrideRepository
+ ) {}
+
+ async execute(command: UpdateWorkflowOverrideCommand): Promise {
+ const { tenant, workflow } = await this.extractEntities(command);
+
+ const currentOverrideEntity = await this.workflowOverrideRepository.findOne({
+ _environmentId: command.environmentId,
+ _workflowId: workflow._id,
+ _tenantId: tenant._id,
+ });
+
+ if (!currentOverrideEntity) {
+ throw new NotFoundException(
+ `Workflow override with workflow id ${command._workflowId} and tenant id ${command._tenantId} was not found`
+ );
+ }
+
+ const updatePayload: Partial = {};
+
+ if (command.active != null) {
+ updatePayload.active = command.active;
+ }
+
+ if (command.preferenceSettings != null) {
+ updatePayload.preferenceSettings = {
+ ...currentOverrideEntity.preferenceSettings,
+ ...command.preferenceSettings,
+ };
+ }
+
+ await this.workflowOverrideRepository.update(
+ {
+ _environmentId: command.environmentId,
+ _id: currentOverrideEntity._id,
+ },
+ {
+ $set: updatePayload,
+ }
+ );
+
+ return { ...currentOverrideEntity, ...updatePayload };
+ }
+
+ private async extractEntities(
+ command: UpdateWorkflowOverrideCommand
+ ): Promise<{ tenant: TenantEntity; workflow: NotificationTemplateEntity }> {
+ const tenant = await this.tenantRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command._tenantId,
+ });
+
+ if (!tenant) {
+ throw new NotFoundException(`Tenant with id ${command._tenantId} is not found`);
+ }
+
+ const workflow = await this.notificationTemplateRepository.findOne({
+ _environmentId: command.environmentId,
+ _id: command._workflowId,
+ });
+
+ if (!workflow) {
+ throw new NotFoundException(`Workflow with id ${command._workflowId} is not found`);
+ }
+
+ return { tenant, workflow };
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/workflow-overrides.controller.ts b/apps/api/src/app/workflow-overrides/workflow-overrides.controller.ts
new file mode 100644
index 00000000000..4c429bb2dd9
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/workflow-overrides.controller.ts
@@ -0,0 +1,223 @@
+import {
+ Body,
+ ClassSerializerInterceptor,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Put,
+ Query,
+ UseGuards,
+ UseInterceptors,
+} from '@nestjs/common';
+import { IJwtPayload, MemberRoleEnum } from '@novu/shared';
+import { UserSession } from '../shared/framework/user.decorator';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
+import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
+import { Roles } from '../auth/framework/roles.decorator';
+import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framework/response.decorator';
+import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto';
+import { CreateWorkflowOverride } from './usecases/create-workflow-override/create-workflow-override.usecase';
+import { CreateWorkflowOverrideCommand } from './usecases/create-workflow-override/create-workflow-override.command';
+import { UpdateWorkflowOverrideCommand } from './usecases/update-workflow-override/update-workflow-override.command';
+import { UpdateWorkflowOverride } from './usecases/update-workflow-override/update-workflow-override.usecase';
+import { GetWorkflowOverride } from './usecases/get-workflow-override/get-workflow-override.usecase';
+import { GetWorkflowOverrideCommand } from './usecases/get-workflow-override/get-workflow-override.command';
+import { DeleteWorkflowOverride } from './usecases/delete-workflow-override/delete-workflow-override.usecase';
+import { DeleteWorkflowOverrideCommand } from './usecases/delete-workflow-override/delete-workflow-override.command';
+import { GetWorkflowOverridesCommand } from './usecases/get-workflow-overrides/get-workflow-overrides.command';
+import { GetWorkflowOverrides } from './usecases/get-workflow-overrides/get-workflow-overrides.usecase';
+import {
+ CreateWorkflowOverrideRequestDto,
+ CreateWorkflowOverrideResponseDto,
+ GetWorkflowOverrideResponseDto,
+ GetWorkflowOverridesRequestDto,
+ GetWorkflowOverridesResponseDto,
+ UpdateWorkflowOverrideRequestDto,
+ UpdateWorkflowOverrideResponseDto,
+} from './dto';
+import { GetWorkflowOverrideById } from './usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase';
+import { GetWorkflowOverrideByIdCommand } from './usecases/get-workflow-override-by-id/get-workflow-override-by-id.command';
+import { UpdateWorkflowOverrideByIdCommand } from './usecases/update-workflow-override-by-id/update-workflow-override-by-id.command';
+import { UpdateWorkflowOverrideById } from './usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase';
+
+@ApiCommonResponses()
+@Controller('/workflow-overrides')
+@UseInterceptors(ClassSerializerInterceptor)
+@UseGuards(UserAuthGuard)
+@ApiTags('Workflows-Overrides')
+export class WorkflowOverridesController {
+ constructor(
+ private createWorkflowOverrideUsecase: CreateWorkflowOverride,
+ private updateWorkflowOverrideUsecase: UpdateWorkflowOverride,
+ private updateWorkflowOverrideByIdUsecase: UpdateWorkflowOverrideById,
+ private getWorkflowOverrideUsecase: GetWorkflowOverride,
+ private getWorkflowOverrideByIdUsecase: GetWorkflowOverrideById,
+ private deleteWorkflowOverrideUsecase: DeleteWorkflowOverride,
+ private getWorkflowOverridesUsecase: GetWorkflowOverrides
+ ) {}
+
+ @Post('/')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(CreateWorkflowOverrideResponseDto)
+ @ApiOperation({
+ summary: 'Create workflow override',
+ })
+ @ExternalApiAccessible()
+ createWorkflowOverride(
+ @UserSession() user: IJwtPayload,
+ @Body() body: CreateWorkflowOverrideRequestDto
+ ): Promise {
+ return this.createWorkflowOverrideUsecase.execute(
+ CreateWorkflowOverrideCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ active: body.active,
+ preferenceSettings: body.preferenceSettings,
+ _tenantId: body.tenantId,
+ _workflowId: body.workflowId,
+ })
+ );
+ }
+
+ @Put('/:overrideId')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(UpdateWorkflowOverrideResponseDto)
+ @ApiOperation({
+ summary: 'Update workflow override by id',
+ })
+ @ExternalApiAccessible()
+ updateWorkflowOverrideById(
+ @UserSession() user: IJwtPayload,
+ @Body() body: UpdateWorkflowOverrideRequestDto,
+ @Param('overrideId') overrideId: string
+ ): Promise {
+ return this.updateWorkflowOverrideByIdUsecase.execute(
+ UpdateWorkflowOverrideByIdCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ active: body.active,
+ preferenceSettings: body.preferenceSettings,
+ overrideId: overrideId,
+ })
+ );
+ }
+
+ @Put('/workflows/:workflowId/tenants/:tenantId')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(UpdateWorkflowOverrideResponseDto)
+ @ApiOperation({
+ summary: 'Update workflow override',
+ })
+ @ExternalApiAccessible()
+ updateWorkflowOverride(
+ @UserSession() user: IJwtPayload,
+ @Body() body: UpdateWorkflowOverrideRequestDto,
+ @Param('workflowId') workflowId: string,
+ @Param('tenantId') tenantId: string
+ ): Promise {
+ return this.updateWorkflowOverrideUsecase.execute(
+ UpdateWorkflowOverrideCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ active: body.active,
+ preferenceSettings: body.preferenceSettings,
+ _tenantId: tenantId,
+ _workflowId: workflowId,
+ })
+ );
+ }
+
+ @Get('/:overrideId')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(GetWorkflowOverrideResponseDto)
+ @ApiOperation({
+ summary: 'Get workflow override by id',
+ })
+ @ExternalApiAccessible()
+ getWorkflowOverrideById(
+ @UserSession() user: IJwtPayload,
+ @Param('overrideId') overrideId: string
+ ): Promise {
+ return this.getWorkflowOverrideByIdUsecase.execute(
+ GetWorkflowOverrideByIdCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ overrideId: overrideId,
+ })
+ );
+ }
+
+ @Get('/workflows/:workflowId/tenants/:tenantId')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(GetWorkflowOverrideResponseDto)
+ @ApiOperation({
+ summary: 'Get workflow override',
+ })
+ @ExternalApiAccessible()
+ getWorkflowOverride(
+ @UserSession() user: IJwtPayload,
+ @Param('workflowId') workflowId: string,
+ @Param('tenantId') tenantId: string
+ ): Promise {
+ return this.getWorkflowOverrideUsecase.execute(
+ GetWorkflowOverrideCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ _tenantId: tenantId,
+ _workflowId: workflowId,
+ })
+ );
+ }
+
+ @Delete('/:overrideId')
+ @UseGuards(RootEnvironmentGuard)
+ @Roles(MemberRoleEnum.ADMIN)
+ @ApiOkResponse({
+ type: DataBooleanDto,
+ })
+ @ApiOperation({
+ summary: 'Delete workflow override',
+ })
+ @ExternalApiAccessible()
+ deleteWorkflowOverride(@UserSession() user: IJwtPayload, @Param('overrideId') overrideId: string): Promise {
+ return this.deleteWorkflowOverrideUsecase.execute(
+ DeleteWorkflowOverrideCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ _id: overrideId,
+ })
+ );
+ }
+
+ @Get('/')
+ @UseGuards(RootEnvironmentGuard)
+ @ApiResponse(GetWorkflowOverridesResponseDto)
+ @ApiOperation({
+ summary: 'Get workflow overrides',
+ })
+ @ExternalApiAccessible()
+ getWorkflowOverrides(
+ @UserSession() user: IJwtPayload,
+ @Query() query: GetWorkflowOverridesRequestDto
+ ): Promise {
+ return this.getWorkflowOverridesUsecase.execute(
+ GetWorkflowOverridesCommand.create({
+ organizationId: user.organizationId,
+ environmentId: user.environmentId,
+ userId: user._id,
+ page: query.page ? query.page : 0,
+ limit: query.limit ? query.limit : 10,
+ })
+ );
+ }
+}
diff --git a/apps/api/src/app/workflow-overrides/workflow-overrides.module.ts b/apps/api/src/app/workflow-overrides/workflow-overrides.module.ts
new file mode 100644
index 00000000000..89ec2c22898
--- /dev/null
+++ b/apps/api/src/app/workflow-overrides/workflow-overrides.module.ts
@@ -0,0 +1,15 @@
+import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
+import { USE_CASES } from './usecases';
+import { SharedModule } from '../shared/shared.module';
+import { WorkflowOverridesController } from './workflow-overrides.controller';
+import { AuthModule } from '../auth/auth.module';
+
+@Module({
+ imports: [SharedModule, AuthModule],
+ controllers: [WorkflowOverridesController],
+ providers: [...USE_CASES],
+ exports: [...USE_CASES],
+})
+export class WorkflowOverridesModule implements NestModule {
+ configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {}
+}
diff --git a/apps/api/src/app/workflows/dto/workflow-response.dto.ts b/apps/api/src/app/workflows/dto/workflow-response.dto.ts
index 81e48b6174b..93728294d88 100644
--- a/apps/api/src/app/workflows/dto/workflow-response.dto.ts
+++ b/apps/api/src/app/workflows/dto/workflow-response.dto.ts
@@ -1,7 +1,7 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
-import { NotificationTemplateCustomData } from '@novu/shared';
+import { INotificationTemplate, NotificationTemplateCustomData, TriggerTypeEnum } from '@novu/shared';
import { NotificationStep } from '../../shared/dtos/notification-step';
import { PreferenceChannels } from '../../shared/dtos/preference-channels';
@@ -30,7 +30,7 @@ class NotificationTriggerVariable {
class NotificationTrigger {
@ApiProperty()
- type: 'event';
+ type: TriggerTypeEnum;
@ApiProperty()
identifier: string;
@@ -47,7 +47,7 @@ class NotificationTrigger {
}
@ApiExtraModels(NotificationGroup)
-export class WorkflowResponse {
+export class WorkflowResponse implements INotificationTemplate {
@ApiPropertyOptional()
_id?: string;
diff --git a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts
index 68cb6190a25..60159fcac03 100644
--- a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts
+++ b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts
@@ -12,6 +12,7 @@ import {
IFieldFilterPart,
FilterPartTypeEnum,
EmailProviderIdEnum,
+ ChangeEntityTypeEnum,
} from '@novu/shared';
import {
ChangeRepository,
@@ -19,6 +20,8 @@ import {
MessageTemplateRepository,
EnvironmentRepository,
SubscriberEntity,
+ NotificationGroupRepository,
+ OrganizationRepository,
} from '@novu/dal';
import { isSameDay } from 'date-fns';
import { CreateWorkflowRequestDto } from '../dto';
@@ -64,7 +67,7 @@ describe('Create Workflow - /workflows (POST)', async () => {
it('should create email template', async function () {
const defaultMessageIsActive = true;
- const testTemplate: Partial = {
+ const templateRequestPayload: Partial = {
name: 'test email template',
description: 'This is a test description',
tags: ['test-tag'],
@@ -93,36 +96,72 @@ describe('Create Workflow - /workflows (POST)', async () => {
],
},
],
+ variants: [
+ {
+ template: {
+ name: 'Better Message Template',
+ subject: 'Better subject',
+ preheader: 'Better pre header',
+ content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample of Better text block' }],
+ type: StepTypeEnum.EMAIL,
+ },
+ active: defaultMessageIsActive,
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'name',
+ value: 'Titans',
+ operator: FieldOperatorEnum.EQUAL,
+ },
+ ],
+ },
+ ],
+ },
+ ],
},
],
};
- const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate);
+ const { body } = await session.testAgent.post(`/v1/workflows`).send(templateRequestPayload);
expect(body.data).to.be.ok;
- const template: INotificationTemplate = body.data;
+ const templateRequestResult: INotificationTemplate = body.data;
- expect(template._notificationGroupId).to.equal(testTemplate.notificationGroupId);
- const message = template.steps[0];
- const filters = message?.filters ? message?.filters[0] : null;
+ expect(templateRequestResult._notificationGroupId).to.equal(templateRequestPayload.notificationGroupId);
+ const message = templateRequestResult.steps[0];
- const messageTest = testTemplate?.steps ? testTemplate?.steps[0] : null;
- const filtersTest = messageTest?.filters ? messageTest.filters[0] : null;
+ const messageRequest = templateRequestPayload?.steps ? templateRequestPayload?.steps[0] : null;
+ const filtersTest = messageRequest?.filters ? messageRequest.filters[0] : null;
const children: IFieldFilterPart = filtersTest?.children[0] as IFieldFilterPart;
- expect(message?.template?.name).to.equal(`${messageTest?.template?.name}`);
+ expect(message?.template?.name).to.equal(`${messageRequest?.template?.name}`);
expect(message?.template?.active).to.equal(defaultMessageIsActive);
- expect(message?.template?.subject).to.equal(`${messageTest?.template?.subject}`);
- expect(message?.template?.preheader).to.equal(`${messageTest?.template?.preheader}`);
+ expect(message?.template?.subject).to.equal(`${messageRequest?.template?.subject}`);
+ expect(message?.template?.preheader).to.equal(`${messageRequest?.template?.preheader}`);
+
+ const filters = message?.filters ? message?.filters[0] : null;
expect(filters?.type).to.equal(filtersTest?.type);
expect(filters?.children.length).to.equal(filtersTest?.children?.length);
+
expect(children.value).to.equal(children.value);
expect(children.operator).to.equal(children.operator);
- expect(template.tags[0]).to.equal('test-tag');
+ expect(templateRequestResult.tags[0]).to.equal('test-tag');
+
+ const variantRequest = messageRequest?.variants ? messageRequest?.variants[0] : null;
+ const variantResult = templateRequestResult.steps[0]?.variants ? templateRequestResult.steps[0]?.variants[0] : null;
+ expect(variantResult?.template?.name).to.equal(variantRequest?.template?.name);
+ expect(variantResult?.template?.active).to.equal(variantRequest?.active);
+ expect(variantResult?.template?.subject).to.equal(variantRequest?.template?.subject);
+ expect(variantResult?.template?.preheader).to.equal(variantRequest?.template?.preheader);
- if (Array.isArray(message?.template?.content) && Array.isArray(messageTest?.template?.content)) {
- expect(message?.template?.content[0].type).to.equal(messageTest?.template?.content[0].type);
+ if (Array.isArray(message?.template?.content) && Array.isArray(messageRequest?.template?.content)) {
+ expect(message?.template?.content[0].type).to.equal(messageRequest?.template?.content[0].type);
} else {
throw new Error('content must be an array');
}
@@ -133,7 +172,10 @@ describe('Create Workflow - /workflows (POST)', async () => {
});
await session.testAgent.post(`/v1/changes/${change?._id}/apply`);
- change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: template._id });
+ change = await changeRepository.findOne({
+ _environmentId: session.environment._id,
+ _entityId: templateRequestResult._id,
+ });
await session.testAgent.post(`/v1/changes/${change?._id}/apply`);
const prodEnv = await getProductionEnvironment();
@@ -142,17 +184,17 @@ describe('Create Workflow - /workflows (POST)', async () => {
const prodVersionNotification = await notificationTemplateRepository.findOne({
_environmentId: prodEnv._id,
- _parentId: template._id,
+ _parentId: templateRequestResult._id,
});
- expect(prodVersionNotification?.tags[0]).to.equal(template.tags[0]);
- expect(prodVersionNotification?.steps.length).to.equal(template.steps.length);
- expect(prodVersionNotification?.triggers[0].type).to.equal(template.triggers[0].type);
- expect(prodVersionNotification?.triggers[0].identifier).to.equal(template.triggers[0].identifier);
- expect(prodVersionNotification?.active).to.equal(template.active);
- expect(prodVersionNotification?.draft).to.equal(template.draft);
- expect(prodVersionNotification?.name).to.equal(template.name);
- expect(prodVersionNotification?.description).to.equal(template.description);
+ expect(prodVersionNotification?.tags[0]).to.equal(templateRequestResult.tags[0]);
+ expect(prodVersionNotification?.steps.length).to.equal(templateRequestResult.steps.length);
+ expect(prodVersionNotification?.triggers[0].type).to.equal(templateRequestResult.triggers[0].type);
+ expect(prodVersionNotification?.triggers[0].identifier).to.equal(templateRequestResult.triggers[0].identifier);
+ expect(prodVersionNotification?.active).to.equal(templateRequestResult.active);
+ expect(prodVersionNotification?.draft).to.equal(templateRequestResult.draft);
+ expect(prodVersionNotification?.name).to.equal(templateRequestResult.name);
+ expect(prodVersionNotification?.description).to.equal(templateRequestResult.description);
const prodVersionMessage = await messageTemplateRepository.findOne({
_environmentId: prodEnv._id,
@@ -164,6 +206,17 @@ describe('Create Workflow - /workflows (POST)', async () => {
expect(message?.template?.type).to.equal(prodVersionMessage?.type);
expect(message?.template?.content).to.deep.equal(prodVersionMessage?.content);
expect(message?.template?.active).to.equal(prodVersionMessage?.active);
+
+ const prodVersionVariant = await messageTemplateRepository.findOne({
+ _environmentId: prodEnv._id,
+ _parentId: variantResult._templateId,
+ });
+
+ expect(variantResult?.template?.name).to.equal(prodVersionVariant?.name);
+ expect(variantResult?.template?.subject).to.equal(prodVersionVariant?.subject);
+ expect(variantResult?.template?.type).to.equal(prodVersionVariant?.type);
+ expect(variantResult?.template?.content).to.deep.equal(prodVersionVariant?.content);
+ expect(variantResult?.template?.active).to.equal(prodVersionVariant?.active);
});
it('should create a valid notification', async () => {
@@ -443,6 +496,8 @@ describe('Create Notification template from blueprint - /notification-templates
let session: UserSession;
const notificationTemplateRepository: NotificationTemplateRepository = new NotificationTemplateRepository();
const environmentRepository: EnvironmentRepository = new EnvironmentRepository();
+ const notificationGroupRepository: NotificationGroupRepository = new NotificationGroupRepository();
+ const organizationRepository: OrganizationRepository = new OrganizationRepository();
before(async () => {
session = new UserSession();
@@ -472,6 +527,30 @@ describe('Create Notification template from blueprint - /notification-templates
expect(response.body.statusCode).to.equal(404);
});
+ it('should create notification group change from blueprint creation', async function () {
+ const prodEnv = await getProductionEnvironment();
+
+ const { blueprintId } = await buildBlueprint(session, prodEnv, notificationTemplateRepository);
+
+ const blueprint = (await session.testAgent.get(`/v1/blueprints/${blueprintId}`).send()).body.data;
+ const blueprintOrg = await organizationRepository.create({ name: 'Blueprint Org' });
+ process.env.BLUEPRINT_CREATOR = blueprintOrg._id;
+ const group = await notificationGroupRepository.create({
+ _organizationId: blueprintOrg._id,
+ name: 'Test name',
+ });
+ blueprint.notificationGroupId = group._id;
+ blueprint.blueprintId = blueprint._id;
+
+ const noChanges = (await session.testAgent.get(`/v1/changes?promoted=false`)).body.data;
+ expect(noChanges.length).to.equal(0);
+ await session.testAgent.post(`/v1/workflows`).send({ ...blueprint });
+ const newWorkflowChanges = (await session.testAgent.get(`/v1/changes?promoted=false`)).body.data;
+ expect(newWorkflowChanges.length).to.equal(2);
+ expect(newWorkflowChanges[0].type).to.equal(ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE);
+ expect(newWorkflowChanges[1].type).to.equal(ChangeEntityTypeEnum.NOTIFICATION_GROUP);
+ });
+
async function getProductionEnvironment() {
return await environmentRepository.findOne({
_parentId: session.environment._id,
@@ -479,15 +558,7 @@ describe('Create Notification template from blueprint - /notification-templates
}
});
-export async function createTemplateFromBlueprint({
- session,
- notificationTemplateRepository,
- prodEnv,
-}: {
- session: UserSession;
- notificationTemplateRepository: NotificationTemplateRepository;
- prodEnv;
-}) {
+async function buildBlueprint(session, prodEnv, notificationTemplateRepository) {
const testTemplateRequestDto: Partial = {
name: 'test email template',
description: 'This is a test description',
@@ -545,6 +616,26 @@ export async function createTemplateFromBlueprint({
if (!blueprintId) throw new Error('blueprintId was not found');
+ return { testTemplateRequestDto, testTemplate, blueprintId };
+}
+
+export async function createTemplateFromBlueprint({
+ session,
+ notificationTemplateRepository,
+ prodEnv,
+ overrides = {},
+}: {
+ session: UserSession;
+ notificationTemplateRepository: NotificationTemplateRepository;
+ prodEnv;
+ overrides?: Partial;
+}) {
+ const { testTemplateRequestDto, testTemplate, blueprintId } = await buildBlueprint(
+ session,
+ prodEnv,
+ notificationTemplateRepository
+ );
+
const blueprint = (await session.testAgent.get(`/v1/blueprints/${blueprintId}`).send()).body.data;
blueprint.notificationGroupId = blueprint._notificationGroupId;
diff --git a/apps/api/src/app/workflows/e2e/get-notification-template.e2e.ts b/apps/api/src/app/workflows/e2e/get-notification-template.e2e.ts
index db0ec5d43c5..c833c2c0937 100644
--- a/apps/api/src/app/workflows/e2e/get-notification-template.e2e.ts
+++ b/apps/api/src/app/workflows/e2e/get-notification-template.e2e.ts
@@ -25,7 +25,7 @@ describe('Get workflow by id - /workflows/:workflowId (GET)', async () => {
expect(foundTemplate.name).to.equal(template.name);
expect(foundTemplate.steps.length).to.equal(template.steps.length);
expect(foundTemplate.steps[0].template).to.be.ok;
- expect(foundTemplate.steps[0].template.content).to.equal(template.steps[0].template.content);
+ expect(foundTemplate.steps[0].template?.content).to.equal(template.steps[0].template?.content);
expect(foundTemplate.steps[0]._templateId).to.be.ok;
expect(foundTemplate.triggers.length).to.equal(template.triggers.length);
});
diff --git a/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts b/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts
index f04ff05c561..e40b00f0c89 100644
--- a/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts
+++ b/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts
@@ -1,6 +1,14 @@
import { expect } from 'chai';
import { NotificationTemplateEntity } from '@novu/dal';
import { UserSession, NotificationTemplateService } from '@novu/testing';
+import {
+ ChannelCTATypeEnum,
+ FieldLogicalOperatorEnum,
+ FieldOperatorEnum,
+ FilterPartTypeEnum,
+ StepTypeEnum,
+ TemplateVariableTypeEnum,
+} from '@novu/shared';
describe('Get workflows - /workflows (GET)', async () => {
let session: UserSession;
@@ -17,7 +25,54 @@ describe('Get workflows - /workflows (GET)', async () => {
session.environment._id
);
- templates.push(await notificationTemplateService.createTemplate());
+ templates.push(
+ await notificationTemplateService.createTemplate({
+ steps: [
+ {
+ type: StepTypeEnum.IN_APP,
+ content: 'Test content for {{firstName}} ',
+ cta: {
+ type: ChannelCTATypeEnum.REDIRECT,
+ data: {
+ url: '/cypress/test-shell/example/test?test-param=true',
+ },
+ },
+ variables: [
+ {
+ defaultValue: '',
+ name: 'firstName',
+ required: false,
+ type: TemplateVariableTypeEnum.STRING,
+ },
+ ],
+ variants: [
+ {
+ name: 'In-App',
+ subject: 'test',
+ type: StepTypeEnum.IN_APP,
+ content: '',
+ contentType: 'editor',
+ variables: [],
+ active: true,
+ filters: [
+ {
+ value: FieldLogicalOperatorEnum.OR,
+ children: [
+ {
+ operator: FieldOperatorEnum.EQUAL,
+ on: FilterPartTypeEnum.PAYLOAD,
+ field: 'ef',
+ value: 'dsf',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ })
+ );
templates.push(await notificationTemplateService.createTemplate());
templates.push(await notificationTemplateService.createTemplate());
});
@@ -34,6 +89,19 @@ describe('Get workflows - /workflows (GET)', async () => {
expect(found.notificationGroup.name).to.equal('General');
});
+ it('should not include variants data in the response', async () => {
+ const { body } = await session.testAgent.get(`/v1/workflows`);
+
+ expect(body.data.length).to.equal(3);
+
+ const found = body.data.find((i) => templates[0]._id === i._id);
+
+ expect(found).to.be.ok;
+ expect(found.name).to.equal(templates[0].name);
+ expect(found.notificationGroup.name).to.equal('General');
+ expect(found.steps[0].variants).to.be.undefined;
+ });
+
it('should return all workflows as per pagination', async () => {
templates.push(await notificationTemplateService.createTemplate());
templates.push(await notificationTemplateService.createTemplate());
diff --git a/apps/api/src/app/workflows/e2e/update-notification-template.e2e.ts b/apps/api/src/app/workflows/e2e/update-notification-template.e2e.ts
index c1e81f4a3df..03b9cb12a9b 100644
--- a/apps/api/src/app/workflows/e2e/update-notification-template.e2e.ts
+++ b/apps/api/src/app/workflows/e2e/update-notification-template.e2e.ts
@@ -1,6 +1,13 @@
import { expect } from 'chai';
import { UserSession, NotificationTemplateService } from '@novu/testing';
-import { StepTypeEnum, INotificationTemplate, IUpdateNotificationTemplateDto } from '@novu/shared';
+import {
+ StepTypeEnum,
+ INotificationTemplate,
+ IUpdateNotificationTemplateDto,
+ FilterPartTypeEnum,
+ FieldLogicalOperatorEnum,
+ FieldOperatorEnum,
+} from '@novu/shared';
import { ChangeRepository } from '@novu/dal';
import { CreateWorkflowRequestDto, UpdateWorkflowRequestDto } from '../dto';
import { WorkflowResponse } from '../dto/workflow-response.dto';
@@ -29,6 +36,50 @@ describe('Update workflow by id - /workflows/:workflowId (PUT)', async () => {
type: StepTypeEnum.IN_APP,
content: 'This is new content for notification',
},
+ variants: [
+ {
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'name',
+ value: 'Titans',
+ operator: FieldOperatorEnum.EQUAL,
+ },
+ ],
+ },
+ ],
+ template: {
+ type: StepTypeEnum.IN_APP,
+ content: 'first content',
+ },
+ },
+ {
+ filters: [
+ {
+ isNegated: false,
+ type: 'GROUP',
+ value: FieldLogicalOperatorEnum.AND,
+ children: [
+ {
+ on: FilterPartTypeEnum.TENANT,
+ field: 'name',
+ value: 'Titans',
+ operator: FieldOperatorEnum.EQUAL,
+ },
+ ],
+ },
+ ],
+ template: {
+ type: StepTypeEnum.IN_APP,
+ content: 'second content',
+ },
+ },
+ ],
},
],
};
@@ -39,7 +90,19 @@ describe('Update workflow by id - /workflows/:workflowId (PUT)', async () => {
expect(foundTemplate.name).to.equal('new name for notification');
expect(foundTemplate.description).to.equal(template.description);
expect(foundTemplate.steps.length).to.equal(1);
- expect(foundTemplate.steps[0].template.content).to.equal(update.steps[0].template.content);
+
+ const updateRequestStep = update.steps ? update.steps[0] : undefined;
+ expect(foundTemplate.steps[0].template?.content).to.equal(updateRequestStep?.template?.content);
+
+ const fountVariant = foundTemplate.steps[0].variants ? foundTemplate.steps[0].variants[0] : undefined;
+ const updateRequestStepVariant = updateRequestStep?.variants ? updateRequestStep?.variants[0] : undefined;
+ expect(fountVariant?.template?.content).to.equal(updateRequestStepVariant?.template?.content);
+
+ // test variant parent id
+ const firstVariant = foundTemplate.steps[0].variants ? foundTemplate.steps[0].variants[0] : undefined;
+ expect(firstVariant?._parentId).to.equal(null);
+ const secondVariant = foundTemplate.steps[0].variants ? foundTemplate.steps[0].variants[1] : undefined;
+ expect(secondVariant?._parentId).to.equal(firstVariant?._id);
const change = await changeRepository.findOne({
_environmentId: session.environment._id,
diff --git a/apps/api/src/app/workflows/notification-template.controller.ts b/apps/api/src/app/workflows/notification-template.controller.ts
index 6be984fee72..bced6a3da94 100644
--- a/apps/api/src/app/workflows/notification-template.controller.ts
+++ b/apps/api/src/app/workflows/notification-template.controller.ts
@@ -24,21 +24,22 @@ import { DeleteNotificationTemplate } from './usecases/delete-notification-templ
import { UpdateNotificationTemplateCommand } from './usecases/update-notification-template/update-notification-template.command';
import { ChangeTemplateActiveStatus } from './usecases/change-template-active-status/change-template-active-status.usecase';
import { ChangeTemplateActiveStatusCommand } from './usecases/change-template-active-status/change-template-active-status.command';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service';
-import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { WorkflowResponse } from './dto/workflow-response.dto';
import { WorkflowsResponseDto } from './dto/workflows.response.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { WorkflowsRequestDto } from './dto/workflows-request.dto';
import { Roles } from '../auth/framework/roles.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framework/response.decorator';
import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto';
import { CreateWorkflowQuery } from './queries';
+@ApiCommonResponses()
@Controller('/notification-templates')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Notification Templates')
export class NotificationTemplateController {
constructor(
diff --git a/apps/api/src/app/workflows/usecases/change-template-active-status/change-template-active-status.usecase.ts b/apps/api/src/app/workflows/usecases/change-template-active-status/change-template-active-status.usecase.ts
index 716905571e4..0bbe8c57961 100644
--- a/apps/api/src/app/workflows/usecases/change-template-active-status/change-template-active-status.usecase.ts
+++ b/apps/api/src/app/workflows/usecases/change-template-active-status/change-template-active-status.usecase.ts
@@ -4,11 +4,12 @@ import { ChangeEntityTypeEnum } from '@novu/shared';
import {
buildNotificationTemplateIdentifierKey,
buildNotificationTemplateKey,
+ CreateChange,
+ CreateChangeCommand,
InvalidateCacheService,
} from '@novu/application-generic';
import { ChangeTemplateActiveStatusCommand } from './change-template-active-status.command';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
/**
* DEPRECATED:
diff --git a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts
index 9c665d5e6a9..a264390d2b6 100644
--- a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts
+++ b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts
@@ -87,7 +87,11 @@ export class ChannelCTACommand {
action?: IMessageAction[];
}
-export class NotificationStep {
+export class NotificationStepVariant {
+ @IsString()
+ @IsOptional()
+ _templateId?: string;
+
@ValidateNested()
@IsOptional()
template?: MessageTemplate;
@@ -124,11 +128,18 @@ export class NotificationStep {
metadata?: IWorkflowStepMetadata;
}
+export class NotificationStep extends NotificationStepVariant {
+ @IsOptional()
+ @IsArray()
+ @ValidateNested()
+ variants?: NotificationStepVariant[];
+}
+
export class MessageFilter {
- isNegated: boolean;
+ isNegated?: boolean;
@IsString()
- type: BuilderFieldType;
+ type?: BuilderFieldType;
@IsString()
value: BuilderGroupValues;
diff --git a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts
index 682e11bf6d1..bd9d8d830ce 100644
--- a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts
+++ b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts
@@ -9,13 +9,18 @@ import {
FeedRepository,
NotificationGroupRepository,
} from '@novu/dal';
-import { ChangeEntityTypeEnum, INotificationTemplateStep, INotificationTrigger, TriggerTypeEnum } from '@novu/shared';
-import { AnalyticsService } from '@novu/application-generic';
-
-import { CreateNotificationTemplateCommand } from './create-notification-template.command';
+import {
+ ChangeEntityTypeEnum,
+ INotificationTemplateStep,
+ INotificationTrigger,
+ TriggerTypeEnum,
+ IStepVariant,
+} from '@novu/shared';
+import { AnalyticsService, CreateChange, CreateChangeCommand } from '@novu/application-generic';
+
+import { CreateNotificationTemplateCommand, NotificationStepVariant } from './create-notification-template.command';
import { ContentService } from '../../../shared/helpers/content.service';
import { CreateMessageTemplate, CreateMessageTemplateCommand } from '../../../message-template/usecases';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { ApiException } from '../../../shared/exceptions/api.exception';
/**
@@ -38,14 +43,43 @@ export class CreateNotificationTemplate {
const blueprintCommand = await this.processBlueprint(usecaseCommand);
const command = blueprintCommand ?? usecaseCommand;
- const contentService = new ContentService();
- const { variables, reservedVariables } = contentService.extractMessageVariables(command.steps);
- const subscriberVariables = contentService.extractSubscriberMessageVariables(command.steps);
+ this.validatePayload(command);
const triggerIdentifier = `${slugify(command.name, {
lower: true,
strict: true,
})}`;
+ const parentChangeId: string = NotificationTemplateRepository.createObjectId();
+
+ const [templateSteps, trigger] = await Promise.all([
+ this.storeTemplateSteps(command, parentChangeId),
+ this.createNotificationTrigger(command, triggerIdentifier),
+ ]);
+
+ const storedWorkflow = await this.storeWorkflow(command, templateSteps, trigger, triggerIdentifier);
+
+ await this.createWorkflowChange(command, storedWorkflow, parentChangeId);
+
+ return storedWorkflow;
+ }
+
+ private validatePayload(command: CreateNotificationTemplateCommand) {
+ const variants = command.steps ? command.steps?.flatMap((step) => step.variants || []) : [];
+
+ for (const variant of variants) {
+ if (!variant.filters?.length) {
+ throw new ApiException(`Variant conditions are required, variant name ${variant.name} id ${variant._id}`);
+ }
+ }
+ }
+
+ private async createNotificationTrigger(
+ command: CreateNotificationTemplateCommand,
+ triggerIdentifier: string
+ ): Promise {
+ const contentService = new ContentService();
+ const { variables, reservedVariables } = contentService.extractMessageVariables(command.steps);
+ const subscriberVariables = contentService.extractSubscriberMessageVariables(command.steps);
const templateCheckIdentifier = await this.notificationTemplateRepository.findByTriggerIdentifier(
command.environmentId,
@@ -79,97 +113,197 @@ export class CreateNotificationTemplate {
}),
};
- const parentChangeId: string = NotificationTemplateRepository.createObjectId();
- const templateSteps: INotificationTemplateStep[] = [];
+ return trigger;
+ }
+
+ private sendTemplateCreationEvent(command: CreateNotificationTemplateCommand, triggerIdentifier: string) {
+ if (command.name !== 'On-boarding notification' && !command.__source?.startsWith('onboarding_')) {
+ this.analyticsService.track('Create Notification Template - [Platform]', command.userId, {
+ _organization: command.organizationId,
+ steps: command.steps?.length,
+ channels: command.steps?.map((i) => i.template?.type),
+ __source: command.__source,
+ triggerIdentifier,
+ });
+ }
+ }
+
+ private async createWorkflowChange(command: CreateNotificationTemplateCommand, item, parentChangeId: string) {
+ await this.createChange.execute(
+ CreateChangeCommand.create({
+ organizationId: command.organizationId,
+ environmentId: command.environmentId,
+ userId: command.userId,
+ type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE,
+ item,
+ changeId: parentChangeId,
+ })
+ );
+ }
+
+ private async storeWorkflow(
+ command: CreateNotificationTemplateCommand,
+ templateSteps: INotificationTemplateStep[],
+ trigger: INotificationTrigger,
+ triggerIdentifier: string
+ ) {
+ const savedWorkflow = await this.notificationTemplateRepository.create({
+ _organizationId: command.organizationId,
+ _creatorId: command.userId,
+ _environmentId: command.environmentId,
+ name: command.name,
+ active: command.active,
+ draft: command.draft,
+ critical: command.critical,
+ preferenceSettings: command.preferenceSettings,
+ tags: command.tags,
+ description: command.description,
+ steps: templateSteps,
+ triggers: [trigger],
+ _notificationGroupId: command.notificationGroupId,
+ blueprintId: command.blueprintId,
+ ...(command.data ? { data: command.data } : {}),
+ });
+
+ const item = await this.notificationTemplateRepository.findById(savedWorkflow._id, command.environmentId);
+ if (!item) throw new NotFoundException(`Workflow ${savedWorkflow._id} is not found`);
+
+ this.sendTemplateCreationEvent(command, triggerIdentifier);
+
+ return item;
+ }
+
+ private async storeTemplateSteps(
+ command: CreateNotificationTemplateCommand,
+ parentChangeId: string
+ ): Promise {
let parentStepId: string | null = null;
+ const templateSteps: INotificationTemplateStep[] = [];
for (const message of command.steps) {
if (!message.template) throw new ApiException(`Unexpected error: message template is missing`);
- const template = await this.createMessageTemplate.execute(
- CreateMessageTemplateCommand.create({
- type: message.template.type,
- name: message.template.name,
- content: message.template.content,
- variables: message.template.variables,
- contentType: message.template.contentType,
+ const [template, storedVariants] = await Promise.all([
+ await this.createMessageTemplate.execute(
+ CreateMessageTemplateCommand.create({
+ organizationId: command.organizationId,
+ environmentId: command.environmentId,
+ userId: command.userId,
+ type: message.template.type,
+ name: message.template.name,
+ content: message.template.content,
+ variables: message.template.variables,
+ contentType: message.template.contentType,
+ cta: message.template.cta,
+ subject: message.template.subject,
+ title: message.template.title,
+ feedId: message.template.feedId,
+ layoutId: message.template.layoutId,
+ preheader: message.template.preheader,
+ senderName: message.template.senderName,
+ actor: message.template.actor,
+ parentChangeId,
+ })
+ ),
+ await this.storeVariantSteps({
+ variants: message.variants,
+ parentChangeId: parentChangeId,
organizationId: command.organizationId,
environmentId: command.environmentId,
userId: command.userId,
- cta: message.template.cta,
- subject: message.template.subject,
- title: message.template.title,
- feedId: message.template.feedId,
- layoutId: message.template.layoutId,
- preheader: message.template.preheader,
- senderName: message.template.senderName,
- parentChangeId,
- actor: message.template.actor,
- })
- );
+ }),
+ ]);
const stepId = template._id;
- templateSteps.push({
+ const templateStep: Partial = {
_id: stepId,
_templateId: template._id,
filters: message.filters,
_parentId: parentStepId,
- metadata: message.metadata,
active: message.active,
shouldStopOnFail: message.shouldStopOnFail,
replyCallback: message.replyCallback,
uuid: message.uuid,
name: message.name,
- });
+ metadata: message.metadata,
+ };
+
+ if (storedVariants.length) {
+ templateStep.variants = storedVariants;
+ }
+
+ templateSteps.push(templateStep);
if (stepId) {
parentStepId = stepId;
}
}
- const savedTemplate = await this.notificationTemplateRepository.create({
- _organizationId: command.organizationId,
- _creatorId: command.userId,
- _environmentId: command.environmentId,
- name: command.name,
- active: command.active,
- draft: command.draft,
- critical: command.critical,
- preferenceSettings: command.preferenceSettings,
- tags: command.tags,
- description: command.description,
- steps: templateSteps,
- triggers: [trigger],
- _notificationGroupId: command.notificationGroupId,
- blueprintId: command.blueprintId,
- ...(command.data ? { data: command.data } : {}),
- });
-
- const item = await this.notificationTemplateRepository.findById(savedTemplate._id, command.environmentId);
- if (!item) throw new NotFoundException(`Notification template ${savedTemplate._id} is not found`);
+ return templateSteps;
+ }
- await this.createChange.execute(
- CreateChangeCommand.create({
- organizationId: command.organizationId,
- environmentId: command.environmentId,
- userId: command.userId,
- type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE,
- item,
- changeId: parentChangeId,
- })
- );
+ private async storeVariantSteps({
+ variants,
+ parentChangeId,
+ organizationId,
+ environmentId,
+ userId,
+ }: {
+ variants: NotificationStepVariant[] | undefined;
+ parentChangeId: string;
+ organizationId: string;
+ environmentId: string;
+ userId: string;
+ }): Promise {
+ if (!variants?.length) return [];
+
+ const variantsList: IStepVariant[] = [];
+ let parentVariantId: string | null = null;
+
+ for (const variant of variants) {
+ if (!variant.template) throw new ApiException(`Unexpected error: variants message template is missing`);
+
+ const variantTemplate = await this.createMessageTemplate.execute(
+ CreateMessageTemplateCommand.create({
+ organizationId: organizationId,
+ environmentId: environmentId,
+ userId: userId,
+ type: variant.template.type,
+ name: variant.template.name,
+ content: variant.template.content,
+ variables: variant.template.variables,
+ contentType: variant.template.contentType,
+ cta: variant.template.cta,
+ subject: variant.template.subject,
+ title: variant.template.title,
+ feedId: variant.template.feedId,
+ layoutId: variant.template.layoutId,
+ preheader: variant.template.preheader,
+ senderName: variant.template.senderName,
+ actor: variant.template.actor,
+ parentChangeId,
+ })
+ );
- if (command.name !== 'On-boarding notification' && !command.__source?.startsWith('onboarding_')) {
- this.analyticsService.track('Create Notification Template - [Platform]', command.userId, {
- _organization: command.organizationId,
- steps: command.steps?.length,
- channels: command.steps?.map((i) => i.template?.type),
- __source: command.__source,
- triggerIdentifier,
+ variantsList.push({
+ _id: variantTemplate._id,
+ _templateId: variantTemplate._id,
+ filters: variant.filters,
+ _parentId: parentVariantId,
+ active: variant.active,
+ shouldStopOnFail: variant.shouldStopOnFail,
+ replyCallback: variant.replyCallback,
+ uuid: variant.uuid,
+ name: variant.name,
+ metadata: variant.metadata,
});
+
+ if (variantTemplate._id) {
+ parentVariantId = variantTemplate._id;
+ }
}
- return item;
+ return variantsList;
}
private async processBlueprint(command: CreateNotificationTemplateCommand) {
@@ -218,21 +352,32 @@ export class CreateNotificationTemplate {
continue;
}
- let foundFeed = await this.feedRepository.findOne({
+ let feedItem = await this.feedRepository.findOne({
_organizationId: command.organizationId,
identifier: blueprintFeed.identifier,
});
- if (!foundFeed) {
- foundFeed = await this.feedRepository.create({
+ if (!feedItem) {
+ feedItem = await this.feedRepository.create({
name: blueprintFeed.name,
identifier: blueprintFeed.identifier,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
});
+
+ await this.createChange.execute(
+ CreateChangeCommand.create({
+ item: feedItem,
+ type: ChangeEntityTypeEnum.FEED,
+ environmentId: command.environmentId,
+ organizationId: command.organizationId,
+ userId: command.userId,
+ changeId: FeedRepository.createObjectId(),
+ })
+ );
}
- step.template._feedId = foundFeed._id;
+ step.template._feedId = feedItem._id;
steps[i] = step;
}
@@ -263,6 +408,17 @@ export class CreateNotificationTemplate {
_organizationId: command.organizationId,
name: blueprintNotificationGroup.name,
});
+
+ await this.createChange.execute(
+ CreateChangeCommand.create({
+ item: group,
+ environmentId: command.environmentId,
+ organizationId: command.organizationId,
+ userId: command.userId,
+ type: ChangeEntityTypeEnum.NOTIFICATION_GROUP,
+ changeId: NotificationGroupRepository.createObjectId(),
+ })
+ );
}
return group;
diff --git a/apps/api/src/app/workflows/usecases/delete-notification-template/delete-notification-template.usecase.ts b/apps/api/src/app/workflows/usecases/delete-notification-template/delete-notification-template.usecase.ts
index 7626969d533..ecf20448eb8 100644
--- a/apps/api/src/app/workflows/usecases/delete-notification-template/delete-notification-template.usecase.ts
+++ b/apps/api/src/app/workflows/usecases/delete-notification-template/delete-notification-template.usecase.ts
@@ -3,13 +3,14 @@
import { Injectable } from '@nestjs/common';
import { ChangeRepository, DalException, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { ApiException } from '../../../shared/exceptions/api.exception';
import {
AnalyticsService,
buildNotificationTemplateIdentifierKey,
buildNotificationTemplateKey,
+ CreateChange,
+ CreateChangeCommand,
InvalidateCacheService,
} from '@novu/application-generic';
import { DeleteMessageTemplateCommand } from '../../../message-template/usecases/delete-message-template/delete-message-template.command';
diff --git a/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.command.ts b/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.command.ts
index 6111efe17b5..9228c0ecab0 100644
--- a/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.command.ts
+++ b/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.command.ts
@@ -47,7 +47,7 @@ export class UpdateNotificationTemplateCommand extends EnvironmentWithUserComman
@IsArray()
@ValidateNested()
@IsOptional()
- steps?: Array;
+ steps?: NotificationStep[];
@ValidateNested()
@IsOptional()
diff --git a/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.usecase.ts b/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.usecase.ts
index 0569d38d91e..b1a01c934e1 100644
--- a/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.usecase.ts
+++ b/apps/api/src/app/workflows/usecases/update-notification-template/update-notification-template.usecase.ts
@@ -2,18 +2,21 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
ChangeRepository,
+ NotificationGroupRepository,
NotificationStepEntity,
NotificationTemplateEntity,
NotificationTemplateRepository,
- NotificationGroupRepository,
+ StepVariantEntity,
} from '@novu/dal';
-import { ChangeEntityTypeEnum, INotificationTemplateStep } from '@novu/shared';
+import { ChangeEntityTypeEnum } from '@novu/shared';
import {
AnalyticsService,
- InvalidateCacheService,
- CacheService,
buildNotificationTemplateIdentifierKey,
buildNotificationTemplateKey,
+ CreateChange,
+ CreateChangeCommand,
+ CacheService,
+ InvalidateCacheService,
} from '@novu/application-generic';
import { UpdateNotificationTemplateCommand } from './update-notification-template.command';
@@ -21,12 +24,11 @@ import { ContentService } from '../../../shared/helpers/content.service';
import {
CreateMessageTemplate,
CreateMessageTemplateCommand,
- UpdateMessageTemplateCommand,
UpdateMessageTemplate,
+ UpdateMessageTemplateCommand,
} from '../../../message-template/usecases';
-import { CreateChange, CreateChangeCommand } from '../../../change/usecases';
import { ApiException } from '../../../shared/exceptions/api.exception';
-import { NotificationStep } from '../create-notification-template';
+import { NotificationStep, NotificationStepVariant } from '../create-notification-template';
import { DeleteMessageTemplate } from '../../../message-template/usecases/delete-message-template/delete-message-template.usecase';
import { DeleteMessageTemplateCommand } from '../../../message-template/usecases/delete-message-template/delete-message-template.command';
@@ -51,10 +53,12 @@ export class UpdateNotificationTemplate {
) {}
async execute(command: UpdateNotificationTemplateCommand): Promise {
+ this.validatePayload(command);
+
const existingTemplate = await this.notificationTemplateRepository.findById(command.id, command.environmentId);
if (!existingTemplate) throw new NotFoundException(`Notification template with id ${command.id} not found`);
- const updatePayload: Partial = {};
+ let updatePayload: Partial = {};
if (command.name) {
updatePayload.name = command.name;
}
@@ -122,103 +126,9 @@ export class UpdateNotificationTemplate {
);
if (command.steps) {
- const contentService = new ContentService();
- const { steps } = command;
-
- const { variables, reservedVariables } = contentService.extractMessageVariables(command.steps);
- updatePayload['triggers.0.variables'] = variables.map((i) => {
- return {
- name: i.name,
- type: i.type,
- };
- });
+ updatePayload = this.updateTriggers(updatePayload, command.steps);
- updatePayload['triggers.0.reservedVariables'] = reservedVariables.map((i) => {
- return {
- type: i.type,
- variables: i.variables.map((variable) => {
- return {
- name: variable.name,
- type: variable.type,
- };
- }),
- };
- });
-
- const subscribersVariables = contentService.extractSubscriberMessageVariables(command.steps);
-
- updatePayload['triggers.0.subscriberVariables'] = subscribersVariables.map((i) => {
- return {
- name: i,
- };
- });
-
- const templateMessages: NotificationStepEntity[] = [];
- let parentStepId: string | null = null;
-
- for (const message of steps) {
- let stepId = message._id;
- if (message._templateId) {
- if (!message.template) throw new ApiException(`Something un-expected happened, template couldn't be found`);
-
- const template = await this.updateMessageTemplate.execute(
- UpdateMessageTemplateCommand.create({
- templateId: message._templateId,
- type: message.template.type,
- name: message.template.name,
- content: message.template.content,
- variables: message.template.variables,
- organizationId: command.organizationId,
- environmentId: command.environmentId,
- userId: command.userId,
- contentType: message.template.contentType,
- cta: message.template.cta,
- feedId: message.template.feedId ? message.template.feedId : null,
- layoutId: message.template.layoutId || null,
- subject: message.template.subject,
- title: message.template.title,
- preheader: message.template.preheader,
- senderName: message.template.senderName,
- actor: message.template.actor,
- parentChangeId,
- })
- );
- stepId = template._id;
- } else {
- if (!message.template) throw new ApiException("Something un-expected happened, template couldn't be found");
-
- const template = await this.createMessageTemplate.execute(
- CreateMessageTemplateCommand.create({
- type: message.template.type,
- name: message.template.name,
- content: message.template.content,
- variables: message.template.variables,
- organizationId: command.organizationId,
- environmentId: command.environmentId,
- contentType: message.template.contentType,
- userId: command.userId,
- cta: message.template.cta,
- feedId: message.template.feedId,
- layoutId: message.template.layoutId,
- subject: message.template.subject,
- title: message.template.title,
- preheader: message.template.preheader,
- senderName: message.template.senderName,
- actor: message.template.actor,
- parentChangeId,
- })
- );
-
- stepId = template._id;
- }
-
- const partialNotificationStep = this.getPartialTemplateStep(stepId, parentStepId, message);
-
- templateMessages.push(partialNotificationStep as NotificationStepEntity);
-
- parentStepId = stepId || null;
- }
- updatePayload.steps = templateMessages;
+ updatePayload.steps = await this.updateMessageTemplates(command.steps, command, parentChangeId);
await this.deleteRemovedSteps(existingTemplate.steps, command, parentChangeId);
}
@@ -290,7 +200,119 @@ export class UpdateNotificationTemplate {
return notificationTemplateWithStepTemplate;
}
- private getPartialTemplateStep(stepId: string | undefined, parentStepId: string | null, message: NotificationStep) {
+ private validatePayload(command: UpdateNotificationTemplateCommand) {
+ const variants = command.steps ? command.steps?.flatMap((step) => step.variants || []) : [];
+
+ for (const variant of variants) {
+ if (!variant.filters?.length) {
+ throw new ApiException(`Variant filters are required, variant name ${variant.name} id ${variant._id}`);
+ }
+ }
+ }
+
+ private async updateMessageTemplates(
+ steps: NotificationStep[],
+ command: UpdateNotificationTemplateCommand,
+ parentChangeId: string
+ ) {
+ let parentStepId: string | null = null;
+ const templateMessages: NotificationStepEntity[] = [];
+
+ for (const message of steps) {
+ let stepId = message._id;
+ if (!message.template) throw new ApiException(`Something un-expected happened, template couldn't be found`);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const updatedVariants = await this.updateVariants(message.variants, command, parentChangeId!);
+
+ const messageTemplatePayload: CreateMessageTemplateCommand | UpdateMessageTemplateCommand = {
+ type: message.template.type,
+ name: message.template.name,
+ content: message.template.content,
+ variables: message.template.variables,
+ organizationId: command.organizationId,
+ environmentId: command.environmentId,
+ userId: command.userId,
+ contentType: message.template.contentType,
+ cta: message.template.cta,
+ feedId: message.template.feedId ? message.template.feedId : undefined,
+ layoutId: message.template.layoutId || null,
+ subject: message.template.subject,
+ title: message.template.title,
+ preheader: message.template.preheader,
+ senderName: message.template.senderName,
+ actor: message.template.actor,
+ parentChangeId,
+ };
+
+ const messageTemplateExist = message._templateId;
+ const updatedTemplate = messageTemplateExist
+ ? await this.updateMessageTemplate.execute(
+ UpdateMessageTemplateCommand.create({
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ templateId: message._templateId!,
+ ...messageTemplatePayload,
+ })
+ )
+ : await this.createMessageTemplate.execute(CreateMessageTemplateCommand.create(messageTemplatePayload));
+
+ stepId = updatedTemplate._id;
+
+ const partialNotificationStep = this.getPartialTemplateStep(stepId, parentStepId, message, updatedVariants);
+
+ templateMessages.push(partialNotificationStep as NotificationStepEntity);
+
+ parentStepId = stepId || null;
+ }
+
+ return templateMessages;
+ }
+
+ private updateTriggers(
+ updatePayload: Partial,
+ steps: NotificationStep[]
+ ): Partial {
+ const updatePayloadResult: Partial = { ...updatePayload };
+
+ const contentService = new ContentService();
+ const { variables, reservedVariables } = contentService.extractMessageVariables(steps);
+
+ updatePayloadResult['triggers.0.variables'] = variables.map((i) => {
+ return {
+ name: i.name,
+ type: i.type,
+ };
+ });
+
+ updatePayloadResult['triggers.0.reservedVariables'] = reservedVariables.map((i) => {
+ return {
+ type: i.type,
+ variables: i.variables.map((variable) => {
+ return {
+ name: variable.name,
+ type: variable.type,
+ };
+ }),
+ };
+ });
+
+ const subscribersVariables = contentService.extractSubscriberMessageVariables(steps);
+
+ updatePayloadResult['triggers.0.subscriberVariables'] = subscribersVariables.map((i) => {
+ return {
+ name: i,
+ };
+ });
+
+ return updatePayloadResult;
+ }
+
+ private getPartialTemplateStep(
+ stepId: string | undefined,
+ parentStepId: string | null,
+ message: NotificationStep,
+ updatedVariants: StepVariantEntity[]
+ ) {
const partialNotificationStep: Partial = {
_id: stepId,
_templateId: stepId,
@@ -325,6 +347,10 @@ export class UpdateNotificationTemplate {
partialNotificationStep.name = message.name;
}
+ if (updatedVariants.length) {
+ partialNotificationStep.variants = updatedVariants;
+ }
+
return partialNotificationStep;
}
@@ -340,17 +366,91 @@ export class UpdateNotificationTemplate {
return notificationTemplate;
}
- private getRemovedSteps(existingSteps: NotificationStepEntity[], newSteps: INotificationTemplateStep[]) {
- const existingStepsIds = existingSteps.map((i) => i._templateId);
- const newStepsIds = newSteps.map((i) => i._templateId);
+ private getRemovedSteps(existingSteps: NotificationStepEntity[], newSteps: NotificationStep[]) {
+ const existingStepsIds = (existingSteps || []).flatMap((step) => [
+ step._templateId,
+ ...(step.variants || []).flatMap((variant) => variant._templateId),
+ ]);
+
+ const newStepsIds = (newSteps || []).flatMap((step) => [
+ step._templateId,
+ ...(step.variants || []).flatMap((variant) => variant._templateId),
+ ]);
const removedStepsIds = existingStepsIds.filter((id) => !newStepsIds.includes(id));
return removedStepsIds;
}
+ private async updateVariants(
+ variants: NotificationStepVariant[] | undefined,
+ command: UpdateNotificationTemplateCommand,
+ parentChangeId: string
+ ): Promise {
+ if (!variants?.length) return [];
+
+ const variantsList: StepVariantEntity[] = [];
+ let parentVariantId: string | null = null;
+
+ for (const variant of variants) {
+ if (!variant.template) throw new ApiException(`Unexpected error: variants message template is missing`);
+
+ const messageTemplatePayload: CreateMessageTemplateCommand | UpdateMessageTemplateCommand = {
+ organizationId: command.organizationId,
+ environmentId: command.environmentId,
+ userId: command.userId,
+ type: variant.template.type,
+ name: variant.template.name,
+ content: variant.template.content,
+ variables: variant.template.variables,
+ contentType: variant.template.contentType,
+ cta: variant.template.cta,
+ subject: variant.template.subject,
+ title: variant.template.title,
+ feedId: variant.template.feedId ? variant.template.feedId : undefined,
+ layoutId: variant.template.layoutId || null,
+ preheader: variant.template.preheader,
+ senderName: variant.template.senderName,
+ actor: variant.template.actor,
+ parentChangeId,
+ };
+
+ const messageTemplateExist = variant._templateId;
+ const updatedVariant = messageTemplateExist
+ ? await this.updateMessageTemplate.execute(
+ UpdateMessageTemplateCommand.create({
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ templateId: variant._templateId!,
+ ...messageTemplatePayload,
+ })
+ )
+ : await this.createMessageTemplate.execute(CreateMessageTemplateCommand.create(messageTemplatePayload));
+
+ if (!updatedVariant._id) throw new ApiException(`Unexpected error: variants message template was not created`);
+
+ variantsList.push({
+ _id: updatedVariant._id,
+ _templateId: updatedVariant._id,
+ filters: variant.filters,
+ _parentId: parentVariantId,
+ active: variant.active,
+ shouldStopOnFail: variant.shouldStopOnFail,
+ replyCallback: variant.replyCallback,
+ uuid: variant.uuid,
+ name: variant.name,
+ metadata: variant.metadata,
+ });
+
+ if (updatedVariant._id) {
+ parentVariantId = updatedVariant._id;
+ }
+ }
+
+ return variantsList;
+ }
+
private async deleteRemovedSteps(
- existingSteps: NotificationStepEntity[],
+ existingSteps: NotificationStepEntity[] | StepVariantEntity[] | undefined,
command: UpdateNotificationTemplateCommand,
parentChangeId: string
) {
diff --git a/apps/api/src/app/workflows/workflow.controller.ts b/apps/api/src/app/workflows/workflow.controller.ts
index ab64a50da24..6764173dc72 100644
--- a/apps/api/src/app/workflows/workflow.controller.ts
+++ b/apps/api/src/app/workflows/workflow.controller.ts
@@ -2,21 +2,21 @@ import {
Body,
ClassSerializerInterceptor,
Controller,
- Get,
Delete,
+ Get,
Param,
Post,
Put,
+ Query,
UseGuards,
UseInterceptors,
- Query,
} from '@nestjs/common';
import { IJwtPayload, MemberRoleEnum } from '@novu/shared';
import { UserSession } from '../shared/framework/user.decorator';
import { GetNotificationTemplates } from './usecases/get-notification-templates/get-notification-templates.usecase';
import { GetNotificationTemplatesCommand } from './usecases/get-notification-templates/get-notification-templates.command';
import { CreateNotificationTemplate, CreateNotificationTemplateCommand } from './usecases/create-notification-template';
-import { CreateWorkflowRequestDto, UpdateWorkflowRequestDto, ChangeWorkflowStatusRequestDto } from './dto';
+import { ChangeWorkflowStatusRequestDto, CreateWorkflowRequestDto, UpdateWorkflowRequestDto } from './dto';
import { GetNotificationTemplate } from './usecases/get-notification-template/get-notification-template.usecase';
import { GetNotificationTemplateCommand } from './usecases/get-notification-template/get-notification-template.command';
import { UpdateNotificationTemplate } from './usecases/update-notification-template/update-notification-template.usecase';
@@ -24,21 +24,23 @@ import { DeleteNotificationTemplate } from './usecases/delete-notification-templ
import { UpdateNotificationTemplateCommand } from './usecases/update-notification-template/update-notification-template.command';
import { ChangeTemplateActiveStatus } from './usecases/change-template-active-status/change-template-active-status.usecase';
import { ChangeTemplateActiveStatusCommand } from './usecases/change-template-active-status/change-template-active-status.command';
-import { JwtAuthGuard } from '../auth/framework/auth.guard';
+import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service';
-import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { WorkflowResponse } from './dto/workflow-response.dto';
import { WorkflowsResponseDto } from './dto/workflows.response.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { WorkflowsRequestDto } from './dto/workflows-request.dto';
import { Roles } from '../auth/framework/roles.decorator';
-import { ApiResponse } from '../shared/framework/response.decorator';
+import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto';
import { CreateWorkflowQuery } from './queries';
+import { ApiOkResponse } from '../shared/framework/response.decorator';
+@ApiCommonResponses()
@Controller('/workflows')
@UseInterceptors(ClassSerializerInterceptor)
-@UseGuards(JwtAuthGuard)
+@UseGuards(UserAuthGuard)
@ApiTags('Workflows')
export class WorkflowController {
constructor(
diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts
index 7cf551fb7b0..68bc007c52c 100644
--- a/apps/api/src/bootstrap.ts
+++ b/apps/api/src/bootstrap.ts
@@ -9,7 +9,6 @@ import * as compression from 'compression';
import { NestFactory, Reflector } from '@nestjs/core';
import * as bodyParser from 'body-parser';
import * as Sentry from '@sentry/node';
-import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { BullMqService, getErrorInterceptor, Logger as PinoLogger } from '@novu/application-generic';
import { ExpressAdapter } from '@nestjs/platform-express';
@@ -20,6 +19,8 @@ import { SubscriberRouteGuard } from './app/auth/framework/subscriber-route.guar
import { validateEnv } from './config/env-validator';
import * as packageJson from '../package.json';
+import { setupSwagger } from './app/shared/framework/swagger/swagger.controller';
+import { HttpRequestHeaderKeysEnum } from './app/shared/framework/types';
const extendedBodySizeRoutes = ['/v1/events', '/v1/notification-templates', '/v1/workflows', '/v1/layouts'];
@@ -92,31 +93,7 @@ export async function bootstrap(expressApp?): Promise {
app.use(compression());
- const options = new DocumentBuilder()
- .setTitle('Novu API')
- .setDescription('Open API Specification for Novu API')
- .setVersion('1.0')
- .addTag('Events')
- .addTag('Subscribers')
- .addTag('Topics')
- .addTag('Notification')
- .addTag('Integrations')
- .addTag('Layouts')
- .addTag('Workflows')
- .addTag('Notification Templates')
- .addTag('Workflow groups')
- .addTag('Changes')
- .addTag('Environments')
- .addTag('Inbound Parse')
- .addTag('Feeds')
- .addTag('Tenants')
- .addTag('Messages')
- .addTag('Organizations')
- .addTag('Execution Details')
- .build();
- const document = SwaggerModule.createDocument(app, options);
-
- SwaggerModule.setup('api', app, document);
+ setupSwagger(app);
Logger.log('BOOTSTRAPPED SUCCESSFULLY');
@@ -126,7 +103,6 @@ export async function bootstrap(expressApp?): Promise {
await app.listen(process.env.PORT);
}
- // Starts listening for shutdown hooks
app.enableShutdownHooks();
Logger.log(`Started application in NODE_ENV=${process.env.NODE_ENV} on port ${process.env.PORT}`);
@@ -134,16 +110,16 @@ export async function bootstrap(expressApp?): Promise {
return app;
}
-const corsOptionsDelegate = function (req, callback) {
- const corsOptions = {
+const corsOptionsDelegate: Parameters[0] = function (req, callback) {
+ const corsOptions: Parameters[1] = {
origin: false as boolean | string | string[],
preflightContinue: false,
maxAge: 86400,
- allowedHeaders: ['Content-Type', 'Authorization', 'sentry-trace'],
+ allowedHeaders: Object.values(HttpRequestHeaderKeysEnum),
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
};
- if (['dev', 'test', 'local'].includes(process.env.NODE_ENV) || isWidgetRoute(req.url) || isBlueprintRoute(req.url)) {
+ if (['test', 'local'].includes(process.env.NODE_ENV) || isWidgetRoute(req.url) || isBlueprintRoute(req.url)) {
corsOptions.origin = '*';
} else {
corsOptions.origin = [process.env.FRONT_BASE_URL];
@@ -151,7 +127,7 @@ const corsOptionsDelegate = function (req, callback) {
corsOptions.origin.push(process.env.WIDGET_BASE_URL);
}
}
- callback(null, corsOptions);
+ callback(null as unknown as Error, corsOptions);
};
function isWidgetRoute(url: string) {
diff --git a/apps/api/src/types/env.d.ts b/apps/api/src/types/env.d.ts
index 7f30bd31b6a..ffe5e3b5484 100644
--- a/apps/api/src/types/env.d.ts
+++ b/apps/api/src/types/env.d.ts
@@ -1,18 +1,28 @@
-declare namespace NodeJS {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- export interface ProcessEnv {
- MONGO_URL: string;
- MONGO_MIN_POOL_SIZE: number;
- MONGO_MAX_POOL_SIZE: number;
- REDIS_URL: string;
- SYNC_PATH: string;
- GOOGLE_OAUTH_CLIENT_SECRET: string;
- GOOGLE_OAUTH_CLIENT_ID: string;
- NODE_ENV: 'test' | 'production' | 'dev' | 'ci' | 'local';
- PORT: string;
- DISABLE_USER_REGISTRATION: 'true' | 'false';
- IS_API_IDEMPOTENCY_ENABLED: 'true' | 'false';
- FRONT_BASE_URL: string;
- SENTRY_DSN: string;
+import type { FeatureFlagsKeysEnum, ApiRateLimitEnvVarFormat } from '@novu/shared';
+
+type FeatureFlagsEnvVars = Record;
+type ApiRateLimitEnvVars = Record;
+
+type TypedEnvVars = FeatureFlagsEnvVars & ApiRateLimitEnvVars;
+
+declare global {
+ namespace NodeJS {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ interface ProcessEnv extends TypedEnvVars {
+ MONGO_URL: string;
+ MONGO_MIN_POOL_SIZE: `${number}`;
+ MONGO_MAX_POOL_SIZE: `${number}`;
+ REDIS_URL: string;
+ SYNC_PATH: string;
+ GOOGLE_OAUTH_CLIENT_SECRET: string;
+ GOOGLE_OAUTH_CLIENT_ID: string;
+ NODE_ENV: 'test' | 'production' | 'dev' | 'ci' | 'local';
+ PORT: `${number}`;
+ DISABLE_USER_REGISTRATION: `${boolean}`;
+ IS_API_IDEMPOTENCY_ENABLED: `${boolean}`;
+ FRONT_BASE_URL: string;
+ API_ROOT_URL: string;
+ SENTRY_DSN: string;
+ }
}
}
diff --git a/apps/inbound-mail/.example.env b/apps/inbound-mail/.example.env
index cb33a758e93..3a2dd632f23 100644
--- a/apps/inbound-mail/.example.env
+++ b/apps/inbound-mail/.example.env
@@ -1,5 +1,5 @@
POST="25"
-HOST="0.0.0.0"
+HOST="127.0.0.1"
REDIS_DB_INDEX="2"
@@ -7,3 +7,5 @@ REDIS_HOST="localhost"
REDIS_PORT="6379"
LOGGING_LEVEL=info
+## This value should be set to true if it is the first time you are running the with the database
+AUTO_CREATE_INDEXES=false
diff --git a/apps/inbound-mail/Dockerfile b/apps/inbound-mail/Dockerfile
index fbe4ec0ebf6..6c28ed00808 100644
--- a/apps/inbound-mail/Dockerfile
+++ b/apps/inbound-mail/Dockerfile
@@ -1,4 +1,4 @@
-FROM nikolaik/python-nodejs:python3.10-nodejs16-alpine as dev_base
+FROM nikolaik/python-nodejs:python3.10-nodejs20-alpine as dev_base
ARG BULL_MQ_PRO_TOKEN
ENV BULL_MQ_PRO_NPM_TOKEN=$BULL_MQ_PRO_TOKEN
diff --git a/apps/inbound-mail/package.json b/apps/inbound-mail/package.json
index 787391a1d27..07e1b359db1 100644
--- a/apps/inbound-mail/package.json
+++ b/apps/inbound-mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@novu/inbound-mail",
- "version": "0.21.0",
+ "version": "0.22.0",
"description": "",
"author": "",
"private": true,
@@ -19,8 +19,8 @@
"test": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --trace-warnings --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/**/*.spec.ts"
},
"dependencies": {
- "@novu/application-generic": "^0.21.0",
- "@novu/shared": "^0.21.0",
+ "@novu/application-generic": "^0.22.0",
+ "@novu/shared": "^0.22.0",
"@sentry/node": "^7.12.1",
"bluebird": "^2.9.30",
"dotenv": "^8.6.0",
@@ -39,7 +39,7 @@
"winston": "^3.9.0"
},
"devDependencies": {
- "@novu/testing": "^0.21.0",
+ "@novu/testing": "^0.22.0",
"@types/chai": "^4.2.11",
"@types/express": "^4.17.8",
"@types/html-to-text": "^9.0.1",
diff --git a/apps/inbound-mail/src/.env.development b/apps/inbound-mail/src/.env.development
index 5ed27ba166f..47afdea9fe5 100644
--- a/apps/inbound-mail/src/.env.development
+++ b/apps/inbound-mail/src/.env.development
@@ -1,7 +1,7 @@
NODE_ENV=dev
POST="25"
-HOST="0.0.0.0"
+HOST="127.0.0.1"
REDIS_PORT="6379"
diff --git a/apps/inbound-mail/src/.env.production b/apps/inbound-mail/src/.env.production
index eab410478bc..87217b06b4a 100644
--- a/apps/inbound-mail/src/.env.production
+++ b/apps/inbound-mail/src/.env.production
@@ -1,7 +1,7 @@
NODE_ENV=production
POST="25"
-HOST="0.0.0.0"
+HOST="127.0.0.1"
REDIS_HOST="localhost"
REDIS_PORT=6379
diff --git a/apps/inbound-mail/src/.env.test b/apps/inbound-mail/src/.env.test
index 8f58f5f941d..0f03ba716dd 100644
--- a/apps/inbound-mail/src/.env.test
+++ b/apps/inbound-mail/src/.env.test
@@ -1,7 +1,7 @@
NODE_ENV=test
PORT="2500"
-HOST="0.0.0.0"
+HOST="127.0.0.1"
REDIS_DB_INDEX=2
diff --git a/apps/inbound-mail/src/main.ts b/apps/inbound-mail/src/main.ts
index 7609617b97c..1a011d8822f 100644
--- a/apps/inbound-mail/src/main.ts
+++ b/apps/inbound-mail/src/main.ts
@@ -22,7 +22,7 @@ if (process.env.SENTRY_DSN) {
export default mailin.start(
{
port: env.PORT || 25,
- host: env.HOST || '0.0.0.0',
+ host: env.HOST || '127.0.0.1',
disableDkim: env.disableDkim,
disableSpf: env.disableSpf,
disableSpamScore: env.disableSpamScore,
diff --git a/apps/inbound-mail/src/server/inbound-mail.service.spec.ts b/apps/inbound-mail/src/server/inbound-mail.service.spec.ts
index f0e1a674848..ddd163f1e69 100644
--- a/apps/inbound-mail/src/server/inbound-mail.service.spec.ts
+++ b/apps/inbound-mail/src/server/inbound-mail.service.spec.ts
@@ -1,5 +1,7 @@
import { expect } from 'chai';
+import { IInboundParseDataDto } from '@novu/application-generic';
+
import { InboundMailService } from './inbound-mail.service';
let inboundMailService: InboundMailService;
@@ -24,10 +26,9 @@ describe('Inbound Mail Service', () => {
it('should be initialised properly', async () => {
expect(inboundMailService).to.be.ok;
- expect(inboundMailService).to.have.all.keys('inboundParseQueue');
expect(inboundMailService.inboundParseQueueService.DEFAULT_ATTEMPTS).to.equal(3);
expect(inboundMailService.inboundParseQueueService.topic).to.equal('inbound-parse-mail');
- expect(await inboundMailService.inboundParseQueueService.bullMqService.getStatus()).to.deep.equal({
+ expect(await inboundMailService.inboundParseQueueService.getStatus()).to.deep.equal({
queueIsPaused: false,
queueName: 'inbound-parse-mail',
workerName: undefined,
@@ -55,45 +56,19 @@ describe('Inbound Mail Service', () => {
it('should add a job in the queue', async () => {
const jobId = 'inbound-mail-parse-job-id';
- const _environmentId = 'inbound-mail-parse-environment-id';
+ const html = '<>Hello World>';
+ const text = 'text';
const _organizationId = 'inbound-mail-parse-organization-id';
- const _userId = 'inbound-mail-parse-user-id';
const jobData = {
- _id: jobId,
- test: 'inbound-mail-parse-job-data',
- _environmentId,
- _organizationId,
- _userId,
+ html: html,
+ text: text,
};
- await inboundMailService.inboundParseQueueService.add(jobId, jobData, _organizationId);
- expect(await inboundMailService.inboundParseQueueService.queue.getActiveCount()).to.equal(0);
- expect(await inboundMailService.inboundParseQueueService.queue.getWaitingCount()).to.equal(1);
-
- const inboundParseQueueServiceQueueJobs = await inboundMailService.inboundParseQueueService.queue.getJobs();
- expect(inboundParseQueueServiceQueueJobs.length).to.equal(1);
- const [inboundParseQueueServiceQueueJob] = inboundParseQueueServiceQueueJobs;
- expect(inboundParseQueueServiceQueueJob).to.deep.include({
- id: '1',
+ await inboundMailService.inboundParseQueueService.add({
name: jobId,
- data: jobData,
- attemptsMade: 0,
+ data: jobData as IInboundParseDataDto,
+ groupId: _organizationId,
});
- });
-
- it('should add a minimal job in the queue', async () => {
- const jobId = 'inbound-parse-mail-job-id-2';
- const _environmentId = 'inbound-parse-mail-environment-id';
- const _organizationId = 'inbound-parse-mail-organization-id';
- const _userId = 'inbound-parse-mail-user-id';
- const jobData = {
- _id: jobId,
- test: 'inbound-parse-mail-job-data-2',
- _environmentId,
- _organizationId,
- _userId,
- };
- await inboundMailService.inboundParseQueueService.addMinimalJob(jobId, jobData, _organizationId);
expect(await inboundMailService.inboundParseQueueService.queue.getActiveCount()).to.equal(0);
expect(await inboundMailService.inboundParseQueueService.queue.getWaitingCount()).to.equal(1);
@@ -102,14 +77,9 @@ describe('Inbound Mail Service', () => {
expect(inboundParseQueueServiceQueueJobs.length).to.equal(1);
const [inboundParseQueueServiceQueueJob] = inboundParseQueueServiceQueueJobs;
expect(inboundParseQueueServiceQueueJob).to.deep.include({
- id: '2',
+ id: '1',
name: jobId,
- data: {
- _id: jobId,
- _environmentId,
- _organizationId,
- _userId,
- },
+ data: jobData,
attemptsMade: 0,
});
});
diff --git a/apps/inbound-mail/src/server/inbound-mail.service.ts b/apps/inbound-mail/src/server/inbound-mail.service.ts
index c9c6911b96a..2495d45153c 100644
--- a/apps/inbound-mail/src/server/inbound-mail.service.ts
+++ b/apps/inbound-mail/src/server/inbound-mail.service.ts
@@ -1,14 +1,14 @@
-import { BullMqService, InboundParseQueue, QueueBaseOptions } from '@novu/application-generic';
-import { JobTopicNameEnum } from '@novu/shared';
+import { InboundParseQueueService, WorkflowInMemoryProviderService } from '@novu/application-generic';
export class InboundMailService {
- private inboundParseQueue: InboundParseQueue;
-
+ public inboundParseQueueService: InboundParseQueueService;
+ private workflowInMemoryProviderService: WorkflowInMemoryProviderService;
constructor() {
- this.inboundParseQueue = new InboundParseQueue();
+ this.workflowInMemoryProviderService = new WorkflowInMemoryProviderService();
+ this.inboundParseQueueService = new InboundParseQueueService(this.workflowInMemoryProviderService);
}
- public get inboundParseQueueService() {
- return this.inboundParseQueue;
+ async start() {
+ await this.workflowInMemoryProviderService.initialize();
}
}
diff --git a/apps/inbound-mail/src/server/index.ts b/apps/inbound-mail/src/server/index.ts
index d8ec1afc28d..a5e0ef6af8d 100644
--- a/apps/inbound-mail/src/server/index.ts
+++ b/apps/inbound-mail/src/server/index.ts
@@ -33,7 +33,7 @@ class Mailin extends events.EventEmitter {
super();
this.configuration = {
- host: '0.0.0.0',
+ host: '127.0.0.1',
port: 2500,
tmp: '.tmp',
disableWebhook: true,
@@ -54,7 +54,7 @@ class Mailin extends events.EventEmitter {
this._smtp = null;
}
- public start(options: object, callback: (err?) => void) {
+ public async start(options: object, callback: (err?) => void) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this;
@@ -364,7 +364,11 @@ class Mailin extends events.EventEmitter {
const username: string = parts[0];
const environmentId = username.split('-nv-e=').at(-1);
- inboundMailService.inboundParseQueueService.add(finalizedMessage.messageId, finalizedMessage, environmentId);
+ inboundMailService.inboundParseQueueService.add({
+ name: finalizedMessage.messageId,
+ data: finalizedMessage,
+ groupId: environmentId,
+ });
return resolve();
});
@@ -447,6 +451,8 @@ class Mailin extends events.EventEmitter {
onRcptTo: onRcptTo,
});
+ await inboundMailService.start();
+
const server = new SMTPServer(smtpOptions);
this._smtp = server;
diff --git a/apps/web/.babelrc b/apps/web/.babelrc
index a4a5252063a..6e04cdcac9c 100644
--- a/apps/web/.babelrc
+++ b/apps/web/.babelrc
@@ -1,23 +1,18 @@
{
"presets": [
"@babel/preset-typescript",
+ "@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
- ],
- "@babel/preset-env"
+ ]
],
"plugins": [
"@emotion",
"@babel/plugin-transform-react-display-name",
"@babel/plugin-proposal-optional-chaining",
- [
- "@babel/plugin-transform-runtime",
- {
- "regenerator": true
- }
- ]
+ "@babel/plugin-transform-runtime"
]
}
diff --git a/apps/web/.env b/apps/web/.env
index 23c860488a3..21c9c6651ff 100644
--- a/apps/web/.env
+++ b/apps/web/.env
@@ -13,7 +13,6 @@ REACT_APP_SENTRY_DSN=
REACT_APP_BLUEPRINTS_API_URL=
REACT_APP_MAIL_SERVER_DOMAIN=
REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID=
-REACT_APP_LOGROCKET_ID=
IS_TEMPLATE_STORE_ENABLED=
IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=
IS_MULTI_TENANCY_ENABLED=
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 7572dff8472..1cb2719e21f 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -1,5 +1,5 @@
# start build stage
-FROM nikolaik/python-nodejs:python3.10-nodejs16-alpine as builder
+FROM nikolaik/python-nodejs:python3.10-nodejs20-alpine as builder
ENV NX_DAEMON=false
WORKDIR /usr/src/app
@@ -16,6 +16,8 @@ COPY libs/testing ./libs/testing
COPY packages/client ./packages/client
COPY libs/shared ./libs/shared
COPY libs/design-system ./libs/design-system
+COPY libs/shared-web ./libs/shared-web
+
COPY packages/notification-center ./packages/notification-center
COPY packages/stateless ./packages/stateless
COPY packages/node ./packages/node
@@ -38,7 +40,7 @@ RUN NX_DAEMON=false pnpm build:web
# end build stage
# start production stage
-FROM node:16-alpine
+FROM node:20-alpine
WORKDIR /app
diff --git a/apps/web/README.md b/apps/web/README.md
index 2a86d53b46f..27499c1c736 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -9,7 +9,7 @@ In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+Open [http://127.0.0.1:3000](http://127.0.0.1:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
diff --git a/apps/web/config-overrides.js b/apps/web/config-overrides.js
index 404a228034a..eb26446476e 100644
--- a/apps/web/config-overrides.js
+++ b/apps/web/config-overrides.js
@@ -2,9 +2,17 @@ const { useBabelRc, override } = require('customize-cra');
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
function overrideConfig(config, env) {
- const plugins = [...config.plugins, /* new BundleAnalyzerPlugin() */];
-
- return { ...config, plugins };
+ const plugins = [...config.plugins /* new BundleAnalyzerPlugin() */];
+
+ return {
+ ...config,
+ plugins,
+ ignoreWarnings: [
+ {
+ message: /Module not found: Error: Can't resolve \'@novu\/ee-translation-web\' .*/,
+ },
+ ],
+ };
}
module.exports = override(useBabelRc(), overrideConfig);
diff --git a/apps/web/cypress.config.ts b/apps/web/cypress.config.ts
index ba218888be9..534714d6bb3 100644
--- a/apps/web/cypress.config.ts
+++ b/apps/web/cypress.config.ts
@@ -34,13 +34,13 @@ export default defineConfig({
// eslint-disable-next-line import/extensions
return require('./cypress/plugins/index.ts')(on, config);
},
- baseUrl: 'http://localhost:4200',
+ baseUrl: 'http://127.0.0.1:4200',
specPattern: 'cypress/tests/**/*.{js,jsx,ts,tsx}',
},
env: {
NODE_ENV: 'test',
- apiUrl: 'http://localhost:1336',
+ apiUrl: 'http://127.0.0.1:1336',
GITHUB_USER_EMAIL: '',
GITHUB_USER_PASSWORD: '',
BLUEPRINT_CREATOR: '645b648b36dd6d25f8650d37',
diff --git a/apps/web/cypress/plugins/index.ts b/apps/web/cypress/plugins/index.ts
index 168fa8d6eb5..16cbbed4ac4 100644
--- a/apps/web/cypress/plugins/index.ts
+++ b/apps/web/cypress/plugins/index.ts
@@ -60,13 +60,13 @@ module.exports = (on, config) => {
},
async clearDatabase() {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.destroy();
return true;
},
async seedDatabase() {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
const userService = new UserService();
await userService.createCypressTestUser();
@@ -75,7 +75,7 @@ module.exports = (on, config) => {
},
async passwordResetToken(id: string) {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
const userService = new UserService();
const user = await userService.getUser(id);
@@ -84,7 +84,7 @@ module.exports = (on, config) => {
},
async addOrganization(userId: string) {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
const organizationService = new OrganizationService();
const organization = await organizationService.createOrganization();
@@ -99,7 +99,7 @@ module.exports = (on, config) => {
organizationId: string;
}) {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
const repository = new IntegrationRepository();
@@ -119,9 +119,9 @@ module.exports = (on, config) => {
} = {}
) {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
- const session = new UserSession('http://localhost:1336');
+ const session = new UserSession('http://127.0.0.1:1336');
await session.initialize({
noEnvironment: settings?.noEnvironment,
showOnBoardingTour: settings?.showOnBoardingTour,
@@ -160,7 +160,7 @@ module.exports = (on, config) => {
},
async makeBlueprints() {
const dal = new DalService();
- await dal.connect('mongodb://localhost:27017/novu-test');
+ await dal.connect('mongodb://127.0.0.1:27017/novu-test');
const userService = new UserService();
const user = await userService.createUser();
diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts
index a476d5db895..766c9f46d4f 100644
--- a/apps/web/cypress/support/commands.ts
+++ b/apps/web/cypress/support/commands.ts
@@ -13,8 +13,8 @@ Cypress.Commands.add('getBySelectorLike', (selector, ...args) => {
});
Cypress.Commands.add('waitLoadEnv', (beforeWait: () => void): void => {
- cy.intercept('GET', 'http://localhost:1336/v1/environments').as('environments');
- cy.intercept('GET', 'http://localhost:1336/v1/environments/me').as('environments-me');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments').as('environments');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments/me').as('environments-me');
beforeWait && beforeWait();
@@ -22,17 +22,19 @@ Cypress.Commands.add('waitLoadEnv', (beforeWait: () => void): void => {
});
Cypress.Commands.add('waitLoadTemplatePage', (beforeWait: () => void): void => {
- cy.intercept('GET', 'http://localhost:1336/v1/environments').as('environments');
- cy.intercept('GET', 'http://localhost:1336/v1/environments/me').as('environments-me');
- cy.intercept('GET', 'http://localhost:1336/v1/notification-groups').as('notification-groups');
- cy.intercept('GET', 'http://localhost:1336/v1/changes/count').as('changes-count');
- cy.intercept('GET', 'http://localhost:1336/v1/integrations/active').as('active-integrations');
- cy.intercept('GET', 'http://localhost:1336/v1/users/me').as('me');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments').as('environments');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/organizations').as('organizations');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments/me').as('environments-me');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/notification-groups').as('notification-groups');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/changes/count').as('changes-count');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/integrations/active').as('active-integrations');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/users/me').as('me');
beforeWait && beforeWait();
cy.wait([
'@environments',
+ '@organizations',
'@environments-me',
'@notification-groups',
'@changes-count',
diff --git a/apps/web/cypress/tests/auth.spec.ts b/apps/web/cypress/tests/auth.spec.ts
index c8bc7b1f1ba..2fd50d3207b 100644
--- a/apps/web/cypress/tests/auth.spec.ts
+++ b/apps/web/cypress/tests/auth.spec.ts
@@ -1,4 +1,5 @@
import * as capitalize from 'lodash.capitalize';
+import { JobTitleEnum, jobTitleToLabelMapper } from '@novu/shared';
describe('User Sign-up and Login', function () {
describe('Sign up', function () {
@@ -14,10 +15,17 @@ describe('User Sign-up and Login', function () {
cy.getByTestId('email').type('example@example.com');
cy.getByTestId('password').type('usEr_password_123!');
cy.getByTestId('accept-cb').click({ force: true });
+
cy.getByTestId('submitButton').click();
+
cy.location('pathname').should('equal', '/auth/application');
- cy.getByTestId('app-creation').type('Organization Name');
+ cy.getByTestId('questionnaire-job-title').click();
+ cy.get('.mantine-Select-item').contains(jobTitleToLabelMapper[JobTitleEnum.PRODUCT_MANAGER]).click();
+ cy.getByTestId('questionnaire-company-name').type('Company Name');
+ cy.getByTestId('check-box-container-multi_channel').trigger('mouseover').click();
+
cy.getByTestId('submit-btn').click();
+
cy.location('pathname').should('equal', '/get-started');
});
@@ -51,7 +59,7 @@ describe('User Sign-up and Login', function () {
cy.loginWithGitHub();
cy.location('pathname').should('equal', '/auth/application');
- cy.getByTestId('app-creation').type('Organization Name');
+ cy.getByTestId('questionnaire-company-name').type('Organization Name');
cy.getByTestId('submit-btn').click();
cy.location('pathname').should('equal', '/quickstart');
@@ -82,7 +90,7 @@ describe('User Sign-up and Login', function () {
cy.getByTestId('submitButton').click();
cy.location('pathname').should('equal', '/auth/application');
- cy.getByTestId('app-creation').type('Organization Name');
+ cy.getByTestId('questionnaire-company-name').type('Organization Name');
cy.getByTestId('submit-btn').click();
cy.location('pathname').should('equal', '/quickstart');
diff --git a/apps/web/cypress/tests/changes.spec.ts b/apps/web/cypress/tests/changes.spec.ts
index 942a2e754e4..8287f2c8e95 100644
--- a/apps/web/cypress/tests/changes.spec.ts
+++ b/apps/web/cypress/tests/changes.spec.ts
@@ -53,10 +53,12 @@ describe('Changes Screen', function () {
cy.waitForNetworkIdle(500);
cy.getByTestId('title').first().clear().type('Updated Title');
+ cy.getByTestId('sidebar-close').click();
cy.getByTestId('notification-template-submit-btn').click();
cy.getByTestId('side-nav-changes-count').contains('1');
+ cy.getByTestId('settings-page').click();
cy.getByTestId('active-toggle-switch').click({ force: true });
cy.getByTestId('side-nav-changes-count').contains('1');
diff --git a/apps/web/cypress/tests/digest-playground.spec.ts b/apps/web/cypress/tests/digest-playground.spec.ts
index f9932e3bb9a..f7b8b329cc6 100644
--- a/apps/web/cypress/tests/digest-playground.spec.ts
+++ b/apps/web/cypress/tests/digest-playground.spec.ts
@@ -73,8 +73,8 @@ describe('Digest Playground Workflow Page', function () {
// check if has digest step
cy.getByTestId('node-digestSelector').should('be.visible');
// check if digest step settings opened
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').contains('All events');
+ cy.getByTestId('step-editor-sidebar').should('exist');
+ cy.getByTestId('step-editor-sidebar').contains('All events');
// click next on hint
cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Next').click();
@@ -89,8 +89,8 @@ describe('Digest Playground Workflow Page', function () {
cy.getByTestId('digest-workflow-tooltip-dots-navigation').should('be.visible');
// check if email step settings opened
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').contains('Email');
+ cy.getByTestId('step-editor-sidebar').should('exist');
+ cy.getByTestId('step-editor-sidebar').contains('Email');
// click next on hint
cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Next').click();
@@ -105,8 +105,8 @@ describe('Digest Playground Workflow Page', function () {
cy.getByTestId('digest-workflow-tooltip-dots-navigation').should('be.visible');
// the step settings should be hidden
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').contains('Trigger');
+ cy.getByTestId('workflow-sidebar').should('exist');
+ cy.getByTestId('workflow-sidebar').contains('Trigger');
// click got it should hide the hint
cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Got it').click();
diff --git a/apps/web/cypress/tests/integrations-list-page.spec.ts b/apps/web/cypress/tests/integrations-list-page.spec.ts
index 23684be8dc5..1adb8f2c79f 100644
--- a/apps/web/cypress/tests/integrations-list-page.spec.ts
+++ b/apps/web/cypress/tests/integrations-list-page.spec.ts
@@ -150,11 +150,11 @@ describe('Integrations List Page', function () {
cy.visit('/integrations');
cy.location('pathname').should('equal', '/integrations');
+ checkTableLoading();
+
cy.wait('@getIntegrations');
cy.wait('@getEnvironments');
- checkTableLoading();
-
cy.getByTestId('no-integrations-placeholder').should('be.visible');
cy.contains('Choose a channel you want to start sending notifications');
@@ -606,7 +606,7 @@ describe('Integrations List Page', function () {
cy.getByTestId('conditions-form-title').contains('Conditions for Mailjet Integration provider instance');
cy.getByTestId('add-new-condition').click();
cy.getByTestId('conditions-form-on').should('have.value', 'Tenant');
- cy.getByTestId('conditions-form-key').should('have.value', 'Identifier');
+ cy.getByTestId('conditions-form-key').type('identifier');
cy.getByTestId('conditions-form-operator').should('have.value', 'Equal');
cy.getByTestId('conditions-form-value').type('tenant123');
cy.getByTestId('apply-conditions-btn').click();
@@ -620,6 +620,7 @@ describe('Integrations List Page', function () {
cy.getByTestId('update-provider-sidebar').should('be.visible');
cy.getByTestId('header-add-conditions-btn').contains('1').click();
cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-key').last().type('identifier');
cy.getByTestId('conditions-form-value').last().type('tenant456');
cy.getByTestId('apply-conditions-btn').click();
cy.getByTestId('header-add-conditions-btn').contains('2');
@@ -661,7 +662,7 @@ describe('Integrations List Page', function () {
cy.getByTestId('conditions-form-title').contains('Conditions for SendGrid provider instance');
cy.getByTestId('add-new-condition').click();
cy.getByTestId('conditions-form-on').should('have.value', 'Tenant');
- cy.getByTestId('conditions-form-key').should('have.value', 'Identifier');
+ cy.getByTestId('conditions-form-key').type('identifier');
cy.getByTestId('conditions-form-operator').should('have.value', 'Equal');
cy.getByTestId('conditions-form-value').type('tenant123');
@@ -691,9 +692,12 @@ describe('Integrations List Page', function () {
cy.getByTestId('provider-instance-name').clear().type('Mailjet Integration');
cy.getByTestId('add-conditions-btn').click();
cy.getByTestId('conditions-form-title').contains('Conditions for Mailjet Integration provider instance');
+
cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-key').type('identifier');
cy.getByTestId('conditions-form-value').type('tenant123');
+
cy.getByTestId('apply-conditions-btn').click();
cy.getByTestId('create-provider-instance-sidebar-create').should('not.be.disabled').contains('Create').click();
@@ -1160,4 +1164,64 @@ describe('Integrations List Page', function () {
cy.getByTestId('novu-provider-error').contains('You can only create one Novu Email per environment.');
cy.getByTestId('create-provider-instance-sidebar-create').should('be.disabled');
});
+
+ it('should show the Webhook URL for the Email integration', () => {
+ interceptIntegrationRequests();
+ cy.intercept(
+ {
+ method: 'GET',
+ url: '*/integrations/webhook/provider/*/status',
+ },
+ { data: true }
+ ).as('getWebhookStatus');
+
+ cy.getByTestId('integrations-list-table')
+ .getByTestId('integration-name-cell')
+ .contains('SendGrid')
+ .getByTestId('integration-name-cell-primary')
+ .should('be.visible');
+
+ clickOnListRow('SendGrid');
+
+ cy.wait('@getWebhookStatus');
+
+ cy.getByTestId('update-provider-sidebar')
+ .getByTestId('provider-webhook-url')
+ .invoke('val')
+ .then((val) => {
+ expect(val).match(
+ /^http:\/\/(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost):\d{4}\/webhooks\/organizations\/\w{1,}\/environments\/\w{1,}\/email\/\w{1,}/
+ );
+ });
+ });
+
+ it('should show the Webhook URL for the SMS integration', () => {
+ interceptIntegrationRequests();
+ cy.intercept(
+ {
+ method: 'GET',
+ url: '*/integrations/webhook/provider/*/status',
+ },
+ { data: true }
+ ).as('getWebhookStatus');
+
+ cy.getByTestId('integrations-list-table')
+ .getByTestId('integration-name-cell')
+ .contains('SendGrid')
+ .getByTestId('integration-name-cell-primary')
+ .should('be.visible');
+
+ clickOnListRow('Twilio');
+
+ cy.wait('@getWebhookStatus');
+
+ cy.getByTestId('update-provider-sidebar')
+ .getByTestId('provider-webhook-url')
+ .invoke('val')
+ .then((val) => {
+ expect(val).match(
+ /^http:\/\/(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost):\d{4}\/webhooks\/organizations\/\w{1,}\/environments\/\w{1,}\/sms\/\w{1,}/
+ );
+ });
+ });
});
diff --git a/apps/web/cypress/tests/invites.spec.ts b/apps/web/cypress/tests/invites.spec.ts
index d7babbc00a3..54733a8c16c 100644
--- a/apps/web/cypress/tests/invites.spec.ts
+++ b/apps/web/cypress/tests/invites.spec.ts
@@ -48,7 +48,7 @@ describe('Invites module', function () {
cy.loginWithGitHub();
cy.location('pathname').should('equal', '/auth/application');
- cy.getByTestId('app-creation').type('Organization Name');
+ cy.getByTestId('questionnaire-company-name').type('Organization Name');
cy.getByTestId('submit-btn').click();
cy.url().should('include', '/quickstart');
diff --git a/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts b/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts
index d7ac81d3e19..df50630a3eb 100644
--- a/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts
+++ b/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts
@@ -7,7 +7,7 @@ describe('Debugging - test trigger', function () {
const template = this.session.templates[0];
const userId = this.session.user.id;
- cy.intercept('GET', 'http://localhost:1336/v1/notification-templates/*').as('notification-templates');
+ cy.intercept('GET', 'http://127.0.0.1:1336/v1/notification-templates/*').as('notification-templates');
cy.waitLoadTemplatePage(() => {
cy.visit('/workflows/edit/' + template._id);
@@ -16,8 +16,8 @@ describe('Debugging - test trigger', function () {
cy.wait('@notification-templates');
cy.getByTestId('node-triggerSelector').click({ force: true });
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-to-param').contains(`"subscriberId": "${userId}"`);
+ cy.getByTestId('workflow-sidebar').should('be.visible');
+ cy.getByTestId('workflow-sidebar').getByTestId('test-trigger-to-param').contains(`"subscriberId": "${userId}"`);
});
it('should not test trigger on error ', function () {
@@ -26,12 +26,12 @@ describe('Debugging - test trigger', function () {
cy.waitForNetworkIdle(500);
cy.getByTestId('node-triggerSelector').click({ force: true });
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-to-param').type('{backspace}');
- cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-payload-param').click();
- cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-btn').should('be.disabled');
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper')
+ cy.getByTestId('workflow-sidebar').should('be.visible');
+ cy.getByTestId('workflow-sidebar').getByTestId('test-trigger-to-param').type('{backspace}');
+ cy.getByTestId('workflow-sidebar').getByTestId('test-trigger-payload-param').click();
+ cy.getByTestId('workflow-sidebar').getByTestId('test-trigger-btn').should('be.disabled');
+ cy.getByTestId('workflow-sidebar').should('be.visible');
+ cy.getByTestId('workflow-sidebar')
.getByTestId('test-trigger-to-param')
.should('have.class', 'mantine-JsonInput-invalid');
});
diff --git a/apps/web/cypress/tests/notification-editor/index.ts b/apps/web/cypress/tests/notification-editor/index.ts
index c43d5f49bdb..dc963f5569b 100644
--- a/apps/web/cypress/tests/notification-editor/index.ts
+++ b/apps/web/cypress/tests/notification-editor/index.ts
@@ -1,4 +1,4 @@
-type Channel = 'inApp' | 'email' | 'sms' | 'digest' | 'delay';
+export type Channel = 'inApp' | 'email' | 'sms' | 'chat' | 'push' | 'digest' | 'delay';
export function addAndEditChannel(channel: Channel) {
cy.waitForNetworkIdle(500);
@@ -21,7 +21,7 @@ export function editChannel(channel: Channel, last = false) {
}
export function goBack() {
- cy.getByTestId('close-step-page').click();
+ cy.getByTestId('sidebar-close').click();
cy.waitForNetworkIdle(500);
}
diff --git a/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts b/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts
index 47b52b6b264..11a190ff684 100644
--- a/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts
+++ b/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts
@@ -310,15 +310,15 @@ describe('Workflow Editor - Main Functionality', function () {
cy.getByTestId('notification-template-submit-btn').click();
cy.getByTestId('get-snippet-btn').click();
- cy.getByTestId('step-page-wrapper').should('be.visible');
- cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('test-sms-notification-title');
- cy.getByTestId('step-page-wrapper')
+ cy.getByTestId('workflow-sidebar').should('be.visible');
+ cy.getByTestId('workflow-sidebar').getByTestId('trigger-code-snippet').contains('test-sms-notification-title');
+ cy.getByTestId('workflow-sidebar')
.getByTestId('trigger-code-snippet')
.contains("import { Novu } from '@novu/node'");
- cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('taskName');
+ cy.getByTestId('workflow-sidebar').getByTestId('trigger-code-snippet').contains('taskName');
- cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('firstName');
+ cy.getByTestId('workflow-sidebar').getByTestId('trigger-code-snippet').contains('firstName');
});
it('should save HTML template email', function () {
diff --git a/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts b/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts
index 3eef625c7f5..a6d53a05b8c 100644
--- a/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts
+++ b/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts
@@ -25,7 +25,7 @@ describe('Workflow Editor - Steps Actions', function () {
cy.get('.react-flow__node').should('have.length', 4);
cy.clickWorkflowNode(`node-inAppSelector`);
cy.waitForNetworkIdle(500);
- cy.getByTestId('delete-step-button').click();
+ cy.getByTestId('editor-sidebar-delete').click();
cy.get('.mantine-Modal-modal button').contains('Delete step').click();
cy.getByTestId(`node-inAppSelector`).should('not.exist');
cy.get('.react-flow__node').should('have.length', 3);
@@ -50,8 +50,9 @@ describe('Workflow Editor - Steps Actions', function () {
.getByTestId('channel-node')
.first()
.trigger('mouseover', { force: true })
- .getByTestId('delete-step-action')
+ .getByTestId('step-actions-menu')
.click();
+ cy.getByTestId('node-inAppSelector').getByTestId('channel-node').first().getByTestId('delete-step-action').click();
cy.get('.mantine-Modal-modal button').contains('Delete step').click();
cy.getByTestId(`node-inAppSelector`).should('not.exist');
cy.get('.react-flow__node').should('have.length', 3);
@@ -66,14 +67,10 @@ describe('Workflow Editor - Steps Actions', function () {
waitForEditTemplateRequests();
dragAndDrop('digest');
cy.get('.react-flow__node').should('have.length', 5);
+
cy.clickWorkflowNode('node-digestSelector');
+ cy.getByTestId('editor-sidebar-delete').click();
- cy.getByTestId('node-digestSelector')
- .getByTestId('channel-node')
- .last()
- .trigger('mouseover', { force: true })
- .getByTestId('delete-step-action')
- .click();
cy.get('.mantine-Modal-modal button').contains('Delete step').click();
cy.getByTestId(`node-digestSelector`).should('not.exist');
cy.get('.react-flow__node').should('have.length', 4);
@@ -150,29 +147,28 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-digestSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
+ cy.getByTestId('add-new-condition').click();
+
+ cy.getByTestId('conditions-form-on').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
cy.get('.mantine-Select-item').contains('Subscriber').click();
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
cy.getByTestId('notification-template-submit-btn').click();
cy.waitForNetworkIdle(500);
cy.visit('/workflows/edit/' + template._id);
cy.waitForNetworkIdle(500);
cy.clickWorkflowNode(`node-digestSelector`);
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add filters to a delay step', function () {
@@ -185,29 +181,28 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-delaySelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
+ cy.getByTestId('add-new-condition').click();
+
+ cy.getByTestId('conditions-form-on').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
cy.get('.mantine-Select-item').contains('Subscriber').click();
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
cy.getByTestId('notification-template-submit-btn').click();
cy.waitForNetworkIdle(500);
cy.visit('/workflows/edit/' + template._id);
cy.waitForNetworkIdle(500);
cy.clickWorkflowNode(`node-delaySelector`);
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add filters to a particular step', function () {
@@ -219,22 +214,20 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
+ cy.getByTestId('conditions-form-on').click();
cy.get('.mantine-Select-item').contains('Subscriber').click();
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add read/seen filters to a particular step', function () {
@@ -246,22 +239,20 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-emailSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
+ cy.getByTestId('conditions-form-on').click();
cy.get('.mantine-Select-item').contains('Previous step').click();
cy.getByTestId('previous-step-dropdown').click();
cy.get('.mantine-Select-item').contains('In-App').click();
cy.getByTestId('previous-step-type-dropdown').click();
- cy.get('.mantine-Select-item').contains('Read').click();
+ cy.get('.mantine-Select-item').contains('Seen').click();
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to not add read/seen filters to first step', function () {
@@ -273,12 +264,10 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
+ cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-on').click();
cy.get('.mantine-Select-item').contains('Previous step').should('not.exist');
});
@@ -291,26 +280,26 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
+
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').click();
+ cy.getByTestId('conditions-row-btn').click();
+ cy.getByTestId('conditions-row-delete').click();
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('filter-remove-btn').click();
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('Add filter');
+ cy.getByTestId('editor-sidebar-add-conditions').should('not.contain', '1');
});
it('should be able to add webhook filter for a particular step', function () {
@@ -322,24 +311,22 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('Or').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('filter-on-dropdown').click();
+ cy.getByTestId('conditions-form-on').click();
cy.get('.mantine-Select-item').contains('Webhook').click();
cy.getByTestId('webhook-filter-url-input').type('www.example.com');
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add online right now filter for a particular step', function () {
@@ -351,20 +338,18 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('filter-on-dropdown').click();
- cy.get('.mantine-Select-item').contains('Online right now').click();
+ cy.getByTestId('conditions-form-on').click();
+ cy.get('.mantine-Select-item').contains('Is online').click();
cy.getByTestId('online-now-value-dropdown').click();
cy.get('.mantine-Select-item').contains('Yes').click();
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add online in the last X time period filter for a particular step', function () {
@@ -376,21 +361,19 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
+ cy.getByTestId('add-new-condition').click();
- cy.getByTestId('filter-on-dropdown').click();
- cy.get('.mantine-Select-item').contains("Online in the last 'X' time period").click();
+ cy.getByTestId('conditions-form-on').click();
+ cy.get('.mantine-Select-item').contains('Last time was online').click();
cy.getByTestId('online-in-last-operator-dropdown').click();
cy.get('.mantine-Select-item').contains('Hours').click();
cy.getByTestId('online-in-last-value-input').type('1');
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('1 filter');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
});
it('should be able to add multiple filters to a particular step', function () {
@@ -402,28 +385,26 @@ describe('Workflow Editor - Steps Actions', function () {
cy.clickWorkflowNode(`node-inAppSelector`);
- cy.getByTestId('add-filter-btn').click();
- cy.getByTestId('group-rules-dropdown').click();
- cy.get('.mantine-Select-item').contains('And').click();
+ cy.getByTestId('editor-sidebar-add-conditions').click();
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').click();
+ cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-on').click();
cy.get('.mantine-Select-item').contains('Subscriber').click();
- cy.getByTestId('filter-key-input').type('filter-key');
- cy.getByTestId('filter-operator-dropdown').click();
+ cy.getByTestId('conditions-form-key').type('filter-key');
+ cy.getByTestId('conditions-form-operator').click();
cy.get('.mantine-Select-item').contains('Equal').click();
- cy.getByTestId('filter-value-input').type('filter-value');
+ cy.getByTestId('conditions-form-value').type('filter-value');
- cy.getByTestId('create-rule-btn').click();
- cy.getByTestId('filter-on-dropdown').eq(1).click();
- cy.get('.mantine-Select-item').contains('Online right now').click();
+ cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-on').eq(1).click();
+ cy.get('.mantine-Select-item').contains('Is online').click();
cy.getByTestId('online-now-value-dropdown').click();
cy.get('.mantine-Select-item').contains('Yes').click();
- cy.getByTestId('filter-confirm-btn').click();
+ cy.getByTestId('apply-conditions-btn').click();
- cy.getByTestId('add-filter-btn').contains('2 filters');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('2');
});
it('should re-render content on between step click', function () {
diff --git a/apps/web/cypress/tests/notification-editor/variants.spec.ts b/apps/web/cypress/tests/notification-editor/variants.spec.ts
new file mode 100644
index 00000000000..408eaecf7d2
--- /dev/null
+++ b/apps/web/cypress/tests/notification-editor/variants.spec.ts
@@ -0,0 +1,1003 @@
+import { Channel, dragAndDrop, editChannel, goBack } from '.';
+
+const EDITOR_TEXT = 'Hello, world!';
+const VARIANT_EDITOR_TEXT = 'Hello, world from Variant!';
+const SUBJECT_LINE = 'Novu test';
+const PUSH_TITLE = 'Push test';
+
+describe('Workflow Editor - Variants', function () {
+ beforeEach(function () {
+ cy.initializeSession().as('session');
+ });
+
+ const createWorkflow = (title: string) => {
+ cy.intercept('GET', '**/notification-templates/**').as('getWorkflow');
+ cy.intercept('PUT', '**/notification-templates/**').as('updateWorkflow');
+ cy.waitLoadTemplatePage(() => {
+ cy.visit('/workflows/create');
+ });
+ cy.wait('@getWorkflow');
+ cy.getByTestId('title').first().clear().type(title).blur();
+ };
+
+ const fillInAppEditorContentWith = (text: string) => {
+ cy.get('.ace_text-input').first().clear({ force: true }).type(text, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ };
+
+ const fillEmailEditorContentWith = (subjectLine: string, content: string) => {
+ cy.getByTestId('emailSubject').clear().type(subjectLine, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ cy.getByTestId('editable-text-content').clear().type(content, {
+ parseSpecialCharSequences: false,
+ });
+ };
+
+ const fillSmsEditorContentWith = (content: string) => {
+ cy.getByTestId('smsNotificationContent').clear().type(content, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ };
+
+ const fillChatEditorContentWith = (content: string) => {
+ cy.getByTestId('chatNotificationContent').clear().type(content, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ };
+
+ const fillPushEditorContentWith = (title: string, content: string) => {
+ cy.getByTestId('pushNotificationTitle').clear().type(title, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ cy.getByTestId('pushNotificationContent').clear().type(content, {
+ parseSpecialCharSequences: false,
+ force: true,
+ });
+ };
+
+ const showStepActions = (channel: Channel) => {
+ cy.getByTestId(`node-${channel}Selector`).parent().trigger('mouseover');
+ };
+
+ const addVariantActionClick = (channel: Channel) => {
+ cy.getByTestId(`node-${channel}Selector`)
+ .getByTestId('step-actions-menu')
+ .click()
+ .getByTestId('add-variant-action')
+ .click();
+ };
+
+ const addConditions = () => {
+ cy.getByTestId('add-new-condition').click();
+ cy.getByTestId('conditions-form-key').last().type('test');
+ cy.getByTestId('conditions-form-value').last().type('test');
+ cy.getByTestId('apply-conditions-btn').click();
+ };
+
+ const checkTheVariantsList = (title: string, content: string) => {
+ cy.getByTestId('variants-list-sidebar').should('be.visible');
+ cy.getByTestId(`variant-item-card-0`).contains(title);
+ cy.getByTestId(`variant-item-card-0`).contains(content);
+ cy.getByTestId(`variant-item-card-0`).getByTestId('conditions-action').contains('1');
+ cy.getByTestId(`variant-root-card`).should('be.visible');
+ };
+
+ const fillEditorContent = (channel: Channel, isVariant = false) => {
+ switch (channel) {
+ case 'inApp':
+ fillInAppEditorContentWith(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'email':
+ fillEmailEditorContentWith(SUBJECT_LINE, isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'sms':
+ fillSmsEditorContentWith(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'chat':
+ fillChatEditorContentWith(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'push':
+ fillPushEditorContentWith(PUSH_TITLE, isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ }
+ };
+
+ const checkEditorContent = (channel: Channel, isVariant = false) => {
+ switch (channel) {
+ case 'inApp':
+ cy.get('#codeEditor')
+ .first()
+ .contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'email':
+ cy.getByTestId('emailSubject').should('have.value', SUBJECT_LINE);
+ cy.getByTestId('email-editor').contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'sms':
+ cy.getByTestId('smsNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'chat':
+ cy.getByTestId('chatNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ case 'push':
+ cy.getByTestId('pushNotificationTitle').should('have.value', PUSH_TITLE);
+ cy.getByTestId('pushNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT);
+ break;
+ }
+ };
+
+ const clearEditorContent = (channel: Channel) => {
+ switch (channel) {
+ case 'inApp':
+ cy.get('#codeEditor').first().clear();
+ break;
+ case 'email':
+ cy.getByTestId('emailSubject').clear();
+ cy.getByTestId('email-editor').clear();
+ break;
+ case 'sms':
+ cy.getByTestId('smsNotificationContent').clear();
+ break;
+ case 'chat':
+ cy.getByTestId('chatNotificationContent').clear();
+ break;
+ case 'push':
+ cy.getByTestId('pushNotificationTitle').clear();
+ cy.getByTestId('pushNotificationContent').clear();
+ break;
+ }
+ };
+
+ const addVariantForChannel = (channel: Channel, variantName: string) => {
+ createWorkflow(`Test Add Variant Flow for ${channel}`);
+
+ // drag and edit the channel
+ dragAndDrop(channel);
+ editChannel(channel);
+
+ // fill the editor content
+ fillEditorContent(channel);
+ goBack();
+
+ // add the variant
+ showStepActions(channel);
+ addVariantActionClick(channel);
+
+ // add conditions for the variant
+ addConditions();
+
+ // should land in the editor and has the root step content shown
+ checkEditorContent(channel);
+ fillEditorContent(channel, true);
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+ goBack();
+
+ // should have the variant shown in the list
+ checkTheVariantsList(variantName, VARIANT_EDITOR_TEXT);
+
+ cy.reload();
+
+ // should successfully save the variants
+ checkTheVariantsList(variantName, VARIANT_EDITOR_TEXT);
+ goBack();
+
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('variants-count').contains('1 variant');
+ };
+
+ const checkStepActions = (channel: Channel) => {
+ showStepActions(channel);
+ cy.getByTestId('step-actions').should('be.visible');
+ cy.getByTestId('add-conditions-action').should('be.visible');
+ cy.getByTestId('edit-action').should('be.visible');
+ cy.getByTestId('step-actions-menu').should('be.visible').click();
+ cy.getByTestId('step-actions-menu').getByTestId('add-variant-action').should('be.visible');
+ cy.getByTestId('step-actions-menu').getByTestId('delete-step-action').should('be.visible');
+ };
+
+ const checkEditorActions = (isVariant = false) => {
+ if (!isVariant) {
+ cy.getByTestId('editor-sidebar-add-variant').should('be.visible');
+ cy.getByTestId('editor-sidebar-add-conditions').should('be.visible');
+ } else {
+ cy.getByTestId('editor-sidebar-edit-conditions').should('be.visible');
+ }
+ cy.getByTestId('editor-sidebar-delete').should('be.visible');
+ };
+
+ const navigateAndPromoteAllChanges = () => {
+ cy.getByTestId('side-nav-changes-link').click();
+ cy.waitForNetworkIdle(500);
+ cy.awaitAttachedGetByTestId('promote-all-btn').click();
+ };
+
+ const navigateAndOpenFirstWorkflow = () => {
+ cy.getByTestId('side-nav-templates-link').click();
+ cy.waitForNetworkIdle(500);
+ cy.getByTestId('notifications-template').find('tbody tr').first().click();
+ cy.wait('@getWorkflow');
+ };
+
+ const switchEnvironment = (environment: 'Production' | 'Development') => {
+ cy.getByTestId('environment-switch').find(`input[value="${environment}"]`).click({ force: true });
+ cy.waitForNetworkIdle(500);
+ };
+
+ const checkVariantListCard = ({
+ selector,
+ message,
+ hasBorder = false,
+ }: {
+ message: string;
+ selector: string;
+ hasBorder?: boolean;
+ }) => {
+ cy.getByTestId(selector).contains(message);
+ if (hasBorder) {
+ cy.getByTestId(selector)
+ .find('div[role="button"]')
+ .first()
+ .should('have.css', 'border-style', 'solid')
+ .and('have.css', 'border-width', '1px');
+ }
+ };
+
+ const checkVariantConditions = ({ selector, contains }: { selector: string; contains: string }) => {
+ cy.getByTestId(selector).getByTestId('conditions-action').should('be.visible').contains(contains);
+ };
+
+ describe('Add variant flow', function () {
+ it('should allow creating the variants for the in-app channel', function () {
+ addVariantForChannel('inApp', 'V1 In-App');
+ });
+
+ it('should allow creating the variants for the email channel', function () {
+ addVariantForChannel('email', 'V1 Email');
+ });
+
+ it('should allow creating the variants for the sms channel', function () {
+ addVariantForChannel('sms', 'V1 SMS');
+ });
+
+ it('should allow creating the variants for the chat channel', function () {
+ addVariantForChannel('chat', 'V1 Chat');
+ });
+
+ it('should allow creating the variants for the push channel', function () {
+ addVariantForChannel('push', 'V1 Push');
+ });
+
+ it('should allow creating variant from the step editor', function () {
+ createWorkflow('Add variant flow from variant editor');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+
+ cy.getByTestId('editor-sidebar-add-variant').should('be.visible').click();
+ addConditions();
+ fillEditorContent('inApp', true);
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ checkEditorContent('inApp', true);
+ });
+
+ it('should allow creating multiple variants', function () {
+ const channel = 'inApp';
+ createWorkflow('Add multiple variants');
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+
+ cy.getByTestId('editor-sidebar-add-variant').should('be.visible').click();
+ addConditions();
+ fillEditorContent(channel, true);
+ goBack();
+
+ cy.getByTestId('editor-sidebar-add-variant').should('be.visible').click();
+ addConditions();
+ fillEditorContent(channel, true);
+ goBack();
+ goBack();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('variants-count').contains('2 variants');
+ editChannel(channel);
+
+ checkVariantListCard({ selector: 'variant-item-card-1', message: VARIANT_EDITOR_TEXT });
+ checkVariantConditions({ selector: 'variant-item-card-1', contains: '1' });
+
+ checkVariantListCard({ selector: 'variant-item-card-0', message: VARIANT_EDITOR_TEXT });
+ checkVariantConditions({ selector: 'variant-item-card-0', contains: '1' });
+
+ checkVariantListCard({ selector: 'variant-root-card', message: EDITOR_TEXT });
+ checkVariantConditions({ selector: 'variant-item-card-0', contains: 'No' });
+ });
+
+ it('should not allow creating variant for digest step', function () {
+ const channel = 'digest';
+ createWorkflow('Add variant not available');
+
+ dragAndDrop(channel);
+ showStepActions(channel);
+
+ cy.getByTestId(`node-${channel}Selector`)
+ .getByTestId('step-actions-menu')
+ .click()
+ .getByTestId('add-variant-action')
+ .should('not.exist');
+
+ editChannel(channel);
+ cy.getByTestId('editor-sidebar-add-variant').should('not.exist');
+ });
+
+ it('should not allow creating variant for delay step', function () {
+ const channel = 'delay';
+ createWorkflow('Add variant not available');
+
+ dragAndDrop(channel);
+ showStepActions(channel);
+
+ cy.getByTestId(`node-${channel}Selector`)
+ .getByTestId('step-actions-menu')
+ .click()
+ .getByTestId('add-variant-action')
+ .should('not.exist');
+
+ editChannel(channel);
+ cy.getByTestId('editor-sidebar-add-variant').should('not.exist');
+ });
+ });
+
+ describe('Step actions', function () {
+ it('should show the step actions', function () {
+ createWorkflow(`Test Step Actions`);
+
+ dragAndDrop('inApp');
+
+ checkStepActions('inApp');
+ });
+
+ it('should show the root step actions', function () {
+ createWorkflow(`Test Root Step Actions`);
+
+ // create in-app channel and add variant
+ dragAndDrop('inApp');
+ showStepActions('inApp');
+ addVariantActionClick('inApp');
+ addConditions();
+ goBack();
+ goBack();
+
+ // show the root step actions and check available actions
+ checkStepActions('inApp');
+ });
+
+ it('in production should show only the edit step action', function () {
+ const channel = 'sms';
+ createWorkflow(`Production Test Step Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('conditions-action').should('not.exist');
+ showStepActions(channel);
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('edit-action').should('be.visible');
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('add-conditions-action').should('not.exist');
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('step-actions-menu').should('not.exist');
+ });
+
+ it('in production should show the step actions: edit and conditions', function () {
+ const channel = 'sms';
+ createWorkflow(`Production Test Step Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+ goBack();
+
+ showStepActions(channel);
+ cy.getByTestId('add-conditions-action').should('be.visible').click();
+ addConditions();
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('conditions-action').should('be.visible').contains('1');
+ showStepActions(channel);
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('edit-action').should('be.visible');
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('add-conditions-action').should('be.visible');
+ cy.getByTestId(`node-${channel}Selector`).getByTestId('step-actions-menu').should('not.exist');
+ });
+ });
+
+ describe('Editor actions', function () {
+ it('check the step editor actions', function () {
+ createWorkflow('Test Editor Actions');
+
+ dragAndDrop('email');
+ editChannel('email');
+
+ checkEditorActions();
+ });
+
+ it('check the variant editor actions', function () {
+ createWorkflow('Test Editor Actions');
+
+ dragAndDrop('email');
+ showStepActions('email');
+ addVariantActionClick('email');
+ addConditions();
+
+ checkEditorActions(true);
+ });
+
+ it('in production should show only the close action', function () {
+ const channel = 'sms';
+ createWorkflow(`Production Test Editor Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ editChannel(channel);
+ cy.getByTestId('editor-sidebar-add-variant').should('not.exist');
+ cy.getByTestId('editor-sidebar-add-conditions').should('not.exist');
+ cy.getByTestId('editor-sidebar-edit-conditions').should('not.exist');
+ cy.getByTestId('editor-sidebar-delete').should('not.exist');
+ });
+
+ it('in production should only show the view conditions action', function () {
+ const channel = 'sms';
+ createWorkflow(`Production Test Editor Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+ goBack();
+
+ showStepActions(channel);
+ cy.getByTestId('add-conditions-action').should('be.visible').click();
+ addConditions();
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ editChannel(channel);
+ cy.getByTestId('editor-sidebar-add-variant').should('not.exist');
+ cy.getByTestId('editor-sidebar-add-conditions').should('not.exist');
+ cy.getByTestId('editor-sidebar-edit-conditions').should('be.visible');
+ cy.getByTestId('editor-sidebar-delete').should('not.exist');
+ });
+ });
+
+ describe('Add conditions action', function () {
+ it('should allow adding the conditions on the step', function () {
+ createWorkflow('Test Conditions Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ cy.getByTestId('add-conditions-action').should('be.visible').click();
+ addConditions();
+
+ cy.getByTestId(`node-inAppSelector`).getByTestId('conditions-action').should('be.visible').contains('1');
+ showStepActions('inApp');
+ cy.getByTestId(`node-inAppSelector`).getByTestId('add-conditions-action').should('be.visible').contains('1');
+ });
+
+ it('should allow adding the conditions from the variants list sidebar header', function () {
+ createWorkflow('Test Conditions Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ addVariantActionClick('inApp');
+ addConditions();
+ goBack();
+
+ cy.getByTestId('variants-list-sidebar').getByTestId('editor-sidebar-add-conditions').should('be.visible').click();
+ addConditions();
+
+ cy.getByTestId('variants-list-sidebar')
+ .getByTestId('editor-sidebar-edit-conditions')
+ .should('be.visible')
+ .contains('1');
+ });
+
+ it('should allow adding the conditions on the variant in the variants list', function () {
+ createWorkflow('Test Conditions Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ addVariantActionClick('inApp');
+ addConditions();
+ goBack();
+
+ cy.getByTestId('variant-item-card-0').getByTestId('conditions-action').should('be.visible').contains('1');
+ cy.getByTestId('variant-item-card-0').should('be.visible').trigger('mouseover');
+ cy.getByTestId('variant-item-card-0').getByTestId('add-conditions-action').should('be.visible').click();
+ addConditions();
+
+ cy.getByTestId('variant-item-card-0').getByTestId('conditions-action').should('be.visible').contains('2');
+ });
+
+ it('should allow adding the conditions in the step editor', function () {
+ createWorkflow('Test Conditions Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ cy.getByTestId('editor-sidebar-add-conditions').click();
+ addConditions();
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ checkEditorContent('inApp');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
+ });
+
+ it('should allow adding the conditions in the variant editor', function () {
+ createWorkflow('Test Conditions Action');
+
+ dragAndDrop('email');
+ editChannel('email');
+ fillEditorContent('email');
+ goBack();
+
+ showStepActions('email');
+ addVariantActionClick('email');
+ addConditions();
+
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('1');
+ cy.getByTestId('editor-sidebar-edit-conditions').click();
+ addConditions();
+
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('2');
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+ cy.getByTestId('editor-sidebar-edit-conditions').contains('2');
+ });
+
+ it('in production should only allow edit and view conditions on variant list item', function () {
+ const channel = 'chat';
+ createWorkflow(`Production Variant Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+ goBack();
+
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ editChannel(channel);
+
+ cy.getByTestId('variant-item-card-0').getByTestId('conditions-action').should('be.visible').contains('1');
+ cy.getByTestId('variant-item-card-0').trigger('mouseover');
+ cy.getByTestId('variant-item-card-0').getByTestId('edit-action').should('be.visible');
+ cy.getByTestId('variant-item-card-0').getByTestId('add-conditions-action').should('be.visible');
+ cy.getByTestId('variant-item-card-0').getByTestId('step-actions-menu').should('not.exist');
+ });
+
+ it('in production should only allow edit the variant root list item', function () {
+ const channel = 'chat';
+ createWorkflow(`Production Variant Actions`);
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+ goBack();
+
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ navigateAndPromoteAllChanges();
+
+ switchEnvironment('Production');
+
+ navigateAndOpenFirstWorkflow();
+
+ editChannel(channel);
+
+ cy.getByTestId('variant-root-card').getByTestId('conditions-action').should('be.visible').contains('No');
+ cy.getByTestId('variant-root-card').trigger('mouseover');
+ cy.getByTestId('variant-root-card').getByTestId('edit-step-action').should('be.visible');
+ cy.getByTestId('variant-root-card').getByTestId('add-conditions-action').should('not.exist');
+ cy.getByTestId('variant-root-card').getByTestId('step-actions-menu').should('not.exist');
+ });
+ });
+
+ describe('Edit action', function () {
+ it('should allow editing step', function () {
+ createWorkflow('Test Edit Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ cy.getByTestId('edit-action').should('be.visible').click();
+
+ checkEditorContent('inApp');
+ });
+
+ it('should allow editing root step from the variants list', function () {
+ createWorkflow('Test Edit Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ addVariantActionClick('inApp');
+ addConditions();
+ checkEditorContent('inApp');
+ goBack();
+
+ cy.getByTestId('variant-root-card').getByTestId('conditions-action').should('be.visible').contains('No');
+ cy.getByTestId('variant-root-card').should('be.visible').trigger('mouseover');
+ cy.getByTestId('variant-root-card').getByTestId('edit-step-action').should('be.visible').click();
+
+ checkEditorContent('inApp');
+ });
+
+ it('should allow editing variant from the variants list', function () {
+ createWorkflow('Test Edit Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ goBack();
+
+ showStepActions('inApp');
+ addVariantActionClick('inApp');
+ addConditions();
+ checkEditorContent('inApp');
+ goBack();
+
+ cy.getByTestId('variant-item-card-0').getByTestId('conditions-action').should('be.visible').contains('1');
+ cy.getByTestId('variant-item-card-0').should('be.visible').trigger('mouseover');
+ cy.getByTestId('variant-item-card-0').getByTestId('edit-action').should('be.visible').click();
+
+ checkEditorContent('inApp');
+ });
+ });
+
+ describe('Delete action', function () {
+ it('should allow deleting step', function () {
+ createWorkflow('Test Delete Action');
+
+ dragAndDrop('inApp');
+ editChannel('inApp');
+ fillEditorContent('inApp');
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ goBack();
+
+ showStepActions('inApp');
+ cy.getByTestId('step-actions-menu').should('be.visible').click();
+ cy.getByTestId('step-actions-menu').getByTestId('delete-step-action').should('be.visible').click();
+
+ cy.get('.mantine-Modal-modal').contains('Delete step?');
+ cy.get('.mantine-Modal-modal button').contains('Delete step').click();
+ cy.getByTestId(`node-inAppSelector`).should('not.exist');
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ cy.getByTestId(`node-inAppSelector`).should('not.exist');
+ });
+
+ it('should allow deleting step from the variants list', function () {
+ createWorkflow('Test Delete Action');
+
+ dragAndDrop('email');
+ editChannel('email');
+ fillEditorContent('email');
+ goBack();
+
+ showStepActions('email');
+ addVariantActionClick('email');
+ addConditions();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+ goBack();
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ cy.getByTestId('editor-sidebar-delete').click();
+
+ cy.get('.mantine-Modal-modal').contains('Delete step?');
+ cy.get('.mantine-Modal-modal button').contains('Delete step').click();
+ cy.getByTestId(`node-emailSelector`).should('not.exist');
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+
+ cy.getByTestId(`node-emailSelector`).should('not.exist');
+ });
+
+ it('should allow deleting variant', function () {
+ createWorkflow('Test Delete Action');
+
+ dragAndDrop('email');
+ editChannel('email');
+ fillEditorContent('email');
+ goBack();
+
+ showStepActions('email');
+ addVariantActionClick('email');
+ addConditions();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+ goBack();
+
+ cy.getByTestId('variant-item-card-0').should('be.visible').trigger('mouseover');
+ cy.getByTestId('variant-item-card-0').getByTestId('step-actions-menu').should('be.visible').click();
+ cy.getByTestId('variant-item-card-0').getByTestId('delete-step-action').click();
+
+ cy.get('.mantine-Modal-modal').contains('Delete variant?');
+ cy.get('.mantine-Modal-modal button').contains('Delete variant').click();
+ cy.getByTestId('variant-item-card-0').should('not.exist');
+ goBack();
+
+ cy.getByTestId('notification-template-submit-btn').click();
+ cy.wait('@updateWorkflow');
+
+ cy.reload();
+ cy.wait('@getWorkflow');
+ cy.getByTestId('variants-count').should('not.exist');
+ });
+ });
+
+ describe('Variants List Errors', function () {
+ const checkCurrentError = ({ message, count }: { message: string; count: string }) => {
+ cy.getByTestId('variants-list-current-error').contains(message);
+ cy.getByTestId('variants-list-errors-count').contains(count);
+ };
+
+ it('should show the push variant errors', function () {
+ const channel = 'push';
+ const messageTitleMissing = 'Message title is missing!';
+ const messageContentMissing = 'Message content is missing!';
+ createWorkflow('Variants List Errors');
+
+ dragAndDrop(channel);
+ editChannel(channel);
+ fillEditorContent(channel);
+ goBack();
+
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ clearEditorContent(channel);
+ goBack();
+
+ checkCurrentError({ message: messageTitleMissing, count: '1/2' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing, hasBorder: true });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageContentMissing, count: '2/2' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageContentMissing, hasBorder: true });
+
+ cy.getByTestId('variants-list-errors-up').click();
+
+ checkCurrentError({ message: messageTitleMissing, count: '1/2' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing, hasBorder: true });
+ });
+
+ it('should show the push variant errors and root errors', function () {
+ const channel = 'push';
+ const messageTitleMissing = 'Message title is missing!';
+ const messageContentMissing = 'Message content is missing!';
+ createWorkflow('Variants List Errors');
+
+ dragAndDrop(channel);
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ goBack();
+
+ checkCurrentError({ message: messageTitleMissing, count: '1/4' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing, hasBorder: true });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageTitleMissing });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageContentMissing, count: '2/4' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageContentMissing, hasBorder: true });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageTitleMissing });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageTitleMissing, count: '3/4' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageTitleMissing, hasBorder: true });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageContentMissing, count: '4/4' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageContentMissing, hasBorder: true });
+ });
+
+ it('should show the email variant and root errors', function () {
+ const channel = 'email';
+ const messageSubjectMissing = 'Email subject is missing!';
+ createWorkflow('Variants List Errors');
+
+ dragAndDrop(channel);
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ goBack();
+ goBack();
+
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ fillEditorContent(channel, true);
+ goBack();
+ goBack();
+
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ goBack();
+
+ checkCurrentError({ message: messageSubjectMissing, count: '1/3' });
+ checkVariantListCard({ selector: 'variant-item-card-2', message: messageSubjectMissing, hasBorder: true });
+ checkVariantListCard({ selector: 'variant-item-card-1', message: VARIANT_EDITOR_TEXT });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageSubjectMissing });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageSubjectMissing, count: '2/3' });
+ checkVariantListCard({ selector: 'variant-item-card-2', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-item-card-1', message: VARIANT_EDITOR_TEXT });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing, hasBorder: true });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageSubjectMissing });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageSubjectMissing, count: '3/3' });
+ checkVariantListCard({ selector: 'variant-item-card-2', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-item-card-1', message: VARIANT_EDITOR_TEXT });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageSubjectMissing, hasBorder: true });
+ });
+
+ it('should show the provider missing error', function () {
+ cy.intercept('*/integrations', {
+ data: [],
+ delay: 0,
+ }).as('getIntegrations');
+ cy.intercept('*/integrations/active', {
+ data: [],
+ delay: 0,
+ }).as('getActiveIntegrations');
+
+ const channel = 'email';
+ const messageSubjectMissing = 'Email subject is missing!';
+ const messageProviderMissing = 'Provider is missing!';
+ createWorkflow('Variants List Errors');
+
+ dragAndDrop(channel);
+ showStepActions(channel);
+ addVariantActionClick(channel);
+ addConditions();
+ goBack();
+
+ checkCurrentError({ message: messageSubjectMissing, count: '1/3' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing, hasBorder: true });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageProviderMissing });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageProviderMissing, count: '2/3' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageProviderMissing, hasBorder: true });
+
+ cy.getByTestId('variants-list-errors-down').click();
+
+ checkCurrentError({ message: messageSubjectMissing, count: '3/3' });
+ checkVariantListCard({ selector: 'variant-item-card-0', message: messageSubjectMissing });
+ checkVariantListCard({ selector: 'variant-root-card', message: messageSubjectMissing, hasBorder: true });
+ });
+ });
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index c777753e62f..e7bed8de693 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,16 +1,17 @@
{
"name": "@novu/web",
- "version": "0.21.0",
+ "version": "0.22.0",
"private": true,
"scripts": {
"start": "cross-env PORT=4200 react-app-rewired start",
"prebuild": "rimraf build",
- "build": "cross-env NODE_OPTIONS=--max_old_space_size=4096 react-app-rewired --max_old_space_size=4096 build",
+ "build": "cross-env NODE_OPTIONS=--max_old_space_size=4096 GENERATE_SOURCEMAP=false react-app-rewired --max_old_space_size=4096 build",
"precommit": "lint-staged",
"docker:build": "docker buildx build -f ./Dockerfile -t novu-web ./../..",
+ "docker:build:depot": "depot build -f ./Dockerfile -t novu-web ./../.. --load",
"envsetup:docker": "chmod +x ./env.sh && ./env.sh && mv ./env-config.js ./build/env-config.js",
"envsetup": "chmod +x ./env.sh && ./env.sh && mv env-config.js ./public/env-config.js",
- "start:static:build": "pnpm envsetup:docker && http-server build -p 4200 --proxy http://localhost:4200?",
+ "start:static:build": "pnpm envsetup:docker && http-server build -p 4200 --proxy http://127.0.0.1:4200?",
"start:docker": "pnpm build && pnpm start:static:build",
"start:dev": "pnpm start",
"cypress:run": "cross-env NODE_ENV=test cypress run",
@@ -21,10 +22,11 @@
"storybook": "storybook dev -p 6006 -s public",
"build-storybook": "storybook build -s public",
"lint": "eslint src",
- "lint:fix": "pnpm lint -- --fix"
+ "lint:fix": "pnpm lint -- --fix",
+ "link:submodules": "pnpm link ../../enterprise/packages/translation-web"
},
"dependencies": {
- "@ant-design/icons": "^4.6.2",
+ "@ant-design/icons": "4.6.2",
"@babel/plugin-proposal-optional-chaining": "^7.20.7",
"@babel/plugin-transform-react-display-name": "^7.18.6",
"@babel/plugin-transform-runtime": "^7.23.2",
@@ -49,10 +51,11 @@
"@mantine/notifications": "^5.7.1",
"@mantine/prism": "^5.7.1",
"@mantine/spotlight": "^5.7.1",
- "@novu/design-system": "^0.21.0",
- "@novu/notification-center": "^0.21.0",
- "@novu/shared": "^0.21.0",
+ "@novu/design-system": "^0.22.0",
+ "@novu/notification-center": "^0.22.0",
+ "@novu/shared": "^0.22.0",
"@segment/analytics-next": "^1.48.0",
+ "@novu/shared-web": "^0.22.0",
"@sentry/react": "^7.40.0",
"@sentry/tracing": "^7.40.0",
"@storybook/addon-docs": "^7.4.2",
@@ -69,7 +72,7 @@
"ace-builds": "^1.4.12",
"antd": "^4.10.0",
"autoprefixer": "^9.8.6",
- "axios": "^1.3.3",
+ "axios": "^1.6.2",
"babel-plugin-import": "^1.13.3",
"chart.js": "^3.7.1",
"customize-cra": "^1.0.0",
@@ -87,8 +90,6 @@
"lodash.get": "^4.3.2",
"lodash.isequal": "^4.5.0",
"lodash.set": "^4.3.2",
- "logrocket": "^3.0.1",
- "logrocket-react": "^5.0.1",
"polished": "^4.1.3",
"react": "^17.0.1",
"react-ace": "^9.4.3",
@@ -109,6 +110,7 @@
"react-syntax-highlighter": "^15.4.3",
"react-table": "^7.8.0",
"react-use-intercom": "^2.0.0",
+ "react-joyride": "^2.5.3",
"rimraf": "^3.0.2",
"slugify": "^1.4.6",
"storybook-dark-mode": "^3.0.1",
@@ -119,14 +121,17 @@
"webpack-dev-server": "4.11.1",
"zod": "^3.22.4"
},
+ "optionalDependencies": {
+ "@novu/ee-translation-web": "^0.22.0"
+ },
"devDependencies": {
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.23.2",
"@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.13.0",
"@babel/runtime": "^7.20.13",
- "@novu/dal": "^0.21.0",
- "@novu/testing": "^0.21.0",
+ "@novu/dal": "^0.22.0",
+ "@novu/testing": "^0.22.0",
"@storybook/addon-actions": "^7.4.2",
"@storybook/addon-essentials": "^7.4.2",
"@storybook/addon-links": "^7.4.2",
@@ -149,7 +154,6 @@
"nodemon": "^3.0.1",
"react-app-rewired": "^2.2.1",
"react-error-overlay": "6.0.11",
- "react-joyride": "^2.5.3",
"react-scripts": "^5.0.1",
"storybook": "^7.4.2",
"typescript": "4.9.5",
diff --git a/apps/web/public/static/images/providers/dark/getstream.svg b/apps/web/public/static/images/providers/dark/getstream.svg
new file mode 100644
index 00000000000..ff52f350046
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/getstream.svg
@@ -0,0 +1 @@
+Stream logo
diff --git a/apps/web/public/static/images/providers/dark/grafana-on-call.png b/apps/web/public/static/images/providers/dark/grafana-on-call.png
new file mode 100644
index 00000000000..caccf8b4aaf
Binary files /dev/null and b/apps/web/public/static/images/providers/dark/grafana-on-call.png differ
diff --git a/apps/web/public/static/images/providers/dark/isend-sms.png b/apps/web/public/static/images/providers/dark/isend-sms.png
new file mode 100644
index 00000000000..165dabfa67b
Binary files /dev/null and b/apps/web/public/static/images/providers/dark/isend-sms.png differ
diff --git a/apps/web/public/static/images/providers/dark/pusher-beams.svg b/apps/web/public/static/images/providers/dark/pusher-beams.svg
new file mode 100644
index 00000000000..59e45f51036
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/pusher-beams.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/rocket-chat.svg b/apps/web/public/static/images/providers/dark/rocket-chat.svg
new file mode 100644
index 00000000000..8c1fd745bb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/rocket-chat.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/square/azure-sms.svg b/apps/web/public/static/images/providers/dark/square/azure-sms.svg
index 78ba6e955d2..976ad632043 100644
--- a/apps/web/public/static/images/providers/dark/square/azure-sms.svg
+++ b/apps/web/public/static/images/providers/dark/square/azure-sms.svg
@@ -1,23 +1,28 @@
-
-
-
-
+
+
+
+
+
+
-
+
-
+
-
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/square/braze.svg b/apps/web/public/static/images/providers/dark/square/braze.svg
new file mode 100644
index 00000000000..9dab7c476b7
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/square/braze.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/square/bulk-sms.svg b/apps/web/public/static/images/providers/dark/square/bulk-sms.svg
new file mode 100644
index 00000000000..6e542b09cb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/square/bulk-sms.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg b/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg
new file mode 100644
index 00000000000..09579340d1d
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/dark/square/isend-sms.png b/apps/web/public/static/images/providers/dark/square/isend-sms.png
new file mode 100644
index 00000000000..5e5dc242526
Binary files /dev/null and b/apps/web/public/static/images/providers/dark/square/isend-sms.png differ
diff --git a/apps/web/public/static/images/providers/dark/square/pusher-beams.svg b/apps/web/public/static/images/providers/dark/square/pusher-beams.svg
new file mode 100644
index 00000000000..f683384bf9f
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/square/pusher-beams.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Beams
+
diff --git a/apps/web/public/static/images/providers/dark/square/rocket-chat.svg b/apps/web/public/static/images/providers/dark/square/rocket-chat.svg
new file mode 100644
index 00000000000..8c1fd745bb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/dark/square/rocket-chat.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/getstream.svg b/apps/web/public/static/images/providers/light/getstream.svg
new file mode 100644
index 00000000000..3053746d32f
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/getstream.svg
@@ -0,0 +1 @@
+Stream logo
diff --git a/apps/web/public/static/images/providers/light/grafana-on-call.png b/apps/web/public/static/images/providers/light/grafana-on-call.png
new file mode 100644
index 00000000000..caccf8b4aaf
Binary files /dev/null and b/apps/web/public/static/images/providers/light/grafana-on-call.png differ
diff --git a/apps/web/public/static/images/providers/light/isend-sms.png b/apps/web/public/static/images/providers/light/isend-sms.png
new file mode 100644
index 00000000000..165dabfa67b
Binary files /dev/null and b/apps/web/public/static/images/providers/light/isend-sms.png differ
diff --git a/apps/web/public/static/images/providers/light/pusher-beams.svg b/apps/web/public/static/images/providers/light/pusher-beams.svg
new file mode 100644
index 00000000000..5d97af0e62e
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/pusher-beams.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/rocket-chat.svg b/apps/web/public/static/images/providers/light/rocket-chat.svg
new file mode 100644
index 00000000000..8c1fd745bb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/rocket-chat.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/square/azure-sms.svg b/apps/web/public/static/images/providers/light/square/azure-sms.svg
index 78ba6e955d2..976ad632043 100644
--- a/apps/web/public/static/images/providers/light/square/azure-sms.svg
+++ b/apps/web/public/static/images/providers/light/square/azure-sms.svg
@@ -1,23 +1,28 @@
-
-
-
-
+
+
+
+
+
+
-
+
-
+
-
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/square/braze.svg b/apps/web/public/static/images/providers/light/square/braze.svg
new file mode 100644
index 00000000000..41a5dc4ebf4
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/square/braze.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/square/bulk-sms.svg b/apps/web/public/static/images/providers/light/square/bulk-sms.svg
new file mode 100644
index 00000000000..6e542b09cb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/square/bulk-sms.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/square/grafana-on-call.svg b/apps/web/public/static/images/providers/light/square/grafana-on-call.svg
new file mode 100644
index 00000000000..09579340d1d
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/square/grafana-on-call.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/images/providers/light/square/isend-sms.png b/apps/web/public/static/images/providers/light/square/isend-sms.png
new file mode 100644
index 00000000000..5e5dc242526
Binary files /dev/null and b/apps/web/public/static/images/providers/light/square/isend-sms.png differ
diff --git a/apps/web/public/static/images/providers/light/square/pusher-beams.svg b/apps/web/public/static/images/providers/light/square/pusher-beams.svg
new file mode 100644
index 00000000000..f683384bf9f
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/square/pusher-beams.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Beams
+
diff --git a/apps/web/public/static/images/providers/light/square/rocket-chat.svg b/apps/web/public/static/images/providers/light/square/rocket-chat.svg
new file mode 100644
index 00000000000..8c1fd745bb9
--- /dev/null
+++ b/apps/web/public/static/images/providers/light/square/rocket-chat.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 811793820cd..fe6199f36fe 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -3,14 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { HelmetProvider } from 'react-helmet-async';
import { Route, Routes, BrowserRouter } from 'react-router-dom';
import { withLDProvider } from 'launchdarkly-react-client-sdk';
-import LogRocket from 'logrocket';
-import setupLogRocketReact from 'logrocket-react';
import { Integrations } from '@sentry/tracing';
import { library } from '@fortawesome/fontawesome-svg-core';
import { far } from '@fortawesome/free-regular-svg-icons';
import { fas } from '@fortawesome/free-solid-svg-icons';
-import packageJson from '../package.json';
import { AuthProvider } from './components/providers/AuthProvider';
import { applyToken, getToken } from './hooks';
import { ActivitiesPage } from './pages/activities/ActivitiesPage';
@@ -26,8 +23,8 @@ import { api } from './api/api.client';
import { PasswordResetPage } from './pages/auth/PasswordResetPage';
import { AppLayout } from './components/layout/AppLayout';
import { MembersInvitePage } from './pages/invites/MembersInvitePage';
-import CreateOrganizationPage from './pages/auth/CreateOrganizationPage';
-import { ENV, LAUNCH_DARKLY_CLIENT_SIDE_ID, SENTRY_DSN, CONTEXT_PATH, LOGROCKET_ID } from './config';
+import QuestionnairePage from './pages/auth/QuestionnairePage';
+import { ENV, LAUNCH_DARKLY_CLIENT_SIDE_ID, SENTRY_DSN, CONTEXT_PATH } from './config';
import { PromoteChangesPage } from './pages/changes/PromoteChangesPage';
import { LinkVercelProjectPage } from './pages/partner-integrations/LinkVercelProjectPage';
import { ROUTES } from './constants/routes.enum';
@@ -60,35 +57,11 @@ import { EmailSettings } from './pages/settings/tabs/EmailSettings';
import { ProductLead } from './components/utils/ProductLead';
import { SSO, UserAccess, Cloud } from '@novu/design-system';
import { BrandingForm, LayoutsListPage } from './pages/brand/tabs';
+import { TranslationRoutes } from './pages/TranslationPages';
+import { VariantsPage } from './pages/templates/components/VariantsPage';
library.add(far, fas);
-if (LOGROCKET_ID && window !== undefined) {
- LogRocket.init(LOGROCKET_ID, {
- release: packageJson.version,
- rootHostname: 'novu.co',
- console: {
- shouldAggregateConsoleErrors: true,
- },
- network: {
- requestSanitizer: (request) => {
- // if the url contains token 'ignore' it
- if (request.url.toLowerCase().indexOf('token') !== -1) {
- // ignore the request response pair
- return null;
- }
-
- // remove Authorization header from logrocket
- request.headers.Authorization = undefined;
-
- // otherwise log the request normally
- return request;
- },
- },
- });
- setupLogRocketReact(LogRocket);
-}
-
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
@@ -121,29 +94,9 @@ if (SENTRY_DSN) {
*/
tracesSampleRate: 1.0,
beforeSend(event: Sentry.Event) {
- const logRocketSession = LogRocket.sessionURL;
-
- if (logRocketSession !== null || (event as string) !== '' || event !== undefined) {
- /*
- * Must ignore the next line as this variable could be null but
- * can not be null because of the check in the if statement above.
- */
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- event.extra.LogRocket = logRocketSession;
-
- return event;
- } else {
- return event;
- } //else
+ return event;
},
});
-
- LogRocket.getSessionURL((sessionURL) => {
- Sentry.configureScope((scope) => {
- scope.setExtra('sessionURL', sessionURL);
- });
- });
}
const defaultQueryFn = async ({ queryKey }: { queryKey: string }) => {
@@ -177,7 +130,7 @@ function App() {
} />
} />
} />
- } />
+ } />
} />
} />
} />
+ } />
+ } />
+ } />
} />
}>
@@ -271,6 +227,7 @@ function App() {
} />
} />
+ } />
diff --git a/apps/web/src/api/api.client.ts b/apps/web/src/api/api.client.ts
index 21b50e44b60..5e4449fd36e 100644
--- a/apps/web/src/api/api.client.ts
+++ b/apps/web/src/api/api.client.ts
@@ -1,71 +1,3 @@
-import axios from 'axios';
-import { IParamObject } from '@novu/shared';
-import { API_ROOT } from '../config';
+import { api } from '@novu/shared-web';
-interface IOptions {
- absoluteUrl: boolean;
-}
-export const api = {
- get(url: string, options: IOptions = { absoluteUrl: false }) {
- return axios
- .get(buildUrl(url, options.absoluteUrl))
- .then((response) => {
- return response.data?.data;
- })
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
- getFullResponse(url: string, params?: { [key: string]: string | string[] | number }) {
- return axios
- .get(`${API_ROOT}${url}`, {
- params,
- })
- .then((response) => response.data)
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
- put(url: string, payload) {
- return axios
- .put(`${API_ROOT}${url}`, payload)
- .then((response) => response.data?.data)
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
- post(url: string, payload, params?: IParamObject) {
- return axios
- .post(`${API_ROOT}${url}`, payload, { params })
- .then((response) => response.data?.data)
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
- patch(url: string, payload) {
- return axios
- .patch(`${API_ROOT}${url}`, payload)
- .then((response) => response.data?.data)
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
- delete(url: string, payload = {}) {
- return axios
- .delete(`${API_ROOT}${url}`, payload)
- .then((response) => response.data?.data)
- .catch((error) => {
- // eslint-disable-next-line promise/no-return-wrap
- return Promise.reject(error?.response?.data || error?.response || error);
- });
- },
-};
-
-function buildUrl(url: string, absoluteUrl: boolean) {
- return absoluteUrl ? url : `${API_ROOT}${url}`;
-}
+export { api };
diff --git a/apps/web/src/api/content-templates.ts b/apps/web/src/api/content-templates.ts
index 888f548923a..4aebf00c119 100644
--- a/apps/web/src/api/content-templates.ts
+++ b/apps/web/src/api/content-templates.ts
@@ -8,11 +8,15 @@ export async function previewEmail({
subject,
layoutId,
}: {
- content: string | IEmailBlock[];
- contentType: MessageTemplateContentType;
+ content?: string | IEmailBlock[];
+ contentType?: MessageTemplateContentType;
payload: string;
- subject: string;
+ subject?: string;
layoutId?: string;
}) {
return api.post('/v1/content-templates/preview/email', { content, contentType, payload, subject, layoutId });
}
+
+export async function previewInApp({ content, cta, payload }: { content?: string; cta: any; payload: string }) {
+ return api.post('/v1/content-templates/preview/in-app', { content, payload, cta });
+}
diff --git a/apps/web/src/api/hooks/index.ts b/apps/web/src/api/hooks/index.ts
index 47ffaf88faf..80c02c219db 100644
--- a/apps/web/src/api/hooks/index.ts
+++ b/apps/web/src/api/hooks/index.ts
@@ -1,3 +1,4 @@
export * from './notification-templates';
export * from './useInAppActivated';
export * from './useDeleteIntegration';
+export * from './useWebhookSupportStatus';
diff --git a/apps/web/src/api/hooks/useVercelIntegration.ts b/apps/web/src/api/hooks/useVercelIntegration.ts
deleted file mode 100644
index c0acb23a690..00000000000
--- a/apps/web/src/api/hooks/useVercelIntegration.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import axios from 'axios';
-import { useCallback } from 'react';
-import { useMutation } from '@tanstack/react-query';
-import { useNavigate } from 'react-router-dom';
-
-import { useVercelParams } from '../../hooks';
-import { useAuthContext } from '../../components/providers/AuthProvider';
-import { errorMessage } from '../../utils/notifications';
-import { vercelIntegrationSetup } from '../vercel-integration';
-
-export function useVercelIntegration() {
- const { token } = useAuthContext();
- const isLoggedIn = !!token;
- const isAxiosAuthorized = axios.defaults.headers.common.Authorization;
-
- const { code, next, configurationId } = useVercelParams();
-
- const canStartSetup = Boolean(code && next && isLoggedIn && isAxiosAuthorized);
-
- const navigate = useNavigate();
-
- const { mutate, isLoading } = useMutation(vercelIntegrationSetup, {
- onSuccess: () => {
- if (next && configurationId) {
- navigate(`/partner-integrations/vercel/link-projects?configurationId=${configurationId}&next=${next}`);
- }
- },
- onError: (err: any) => {
- if (err?.message) {
- errorMessage(err?.message);
- }
- },
- });
-
- const startVercelSetup = useCallback(() => {
- if (!canStartSetup || !code || !configurationId) {
- return;
- }
- mutate({ vercelIntegrationCode: code, configurationId });
- }, [canStartSetup, code, mutate, configurationId]);
-
- return {
- isLoading,
- startVercelSetup,
- };
-}
diff --git a/apps/web/src/api/hooks/useWebhookSupportStatus.ts b/apps/web/src/api/hooks/useWebhookSupportStatus.ts
new file mode 100644
index 00000000000..681f0ec7ad7
--- /dev/null
+++ b/apps/web/src/api/hooks/useWebhookSupportStatus.ts
@@ -0,0 +1,49 @@
+import { useQuery } from '@tanstack/react-query';
+import { ChannelTypeEnum } from '@novu/shared';
+
+import { getWebhookSupportStatus } from '../integration';
+import { IS_DOCKER_HOSTED, WEBHOOK_URL } from '../../config';
+import { useAuthController, useEnvController } from '../../hooks';
+
+export const useWebhookSupportStatus = ({
+ hasCredentials,
+ integrationId,
+ channel,
+}: {
+ hasCredentials?: boolean;
+ integrationId?: string;
+ channel?: ChannelTypeEnum;
+}) => {
+ const { environment } = useEnvController();
+ const { organization } = useAuthController();
+
+ const { data: webhookSupportStatus, ...rest } = useQuery(
+ ['webhookSupportStatus', integrationId],
+ () => getWebhookSupportStatus(integrationId as string),
+ {
+ enabled: Boolean(
+ integrationId && channel && hasCredentials && [ChannelTypeEnum.EMAIL, ChannelTypeEnum.SMS].includes(channel)
+ ),
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ }
+ );
+
+ const isWebhookEnabled = !!(
+ !IS_DOCKER_HOSTED &&
+ webhookSupportStatus &&
+ channel &&
+ [ChannelTypeEnum.EMAIL, ChannelTypeEnum.SMS].includes(channel)
+ );
+
+ const webhookUrl =
+ `${WEBHOOK_URL}/webhooks/organizations/${organization?._id}` +
+ `/environments/${environment?._id}/${channel}/${integrationId}`;
+
+ return {
+ webhookSupportStatus,
+ isWebhookEnabled,
+ webhookUrl,
+ ...rest,
+ };
+};
diff --git a/apps/web/src/api/integration.ts b/apps/web/src/api/integration.ts
index 668d1b8897b..87075a68b28 100644
--- a/apps/web/src/api/integration.ts
+++ b/apps/web/src/api/integration.ts
@@ -29,8 +29,8 @@ export function deleteIntegration(integrationId: string) {
return api.delete(`/v1/integrations/${integrationId}`);
}
-export function getWebhookSupportStatus(providerId: string) {
- return api.get(`/v1/integrations/webhook/provider/${providerId}/status`);
+export function getWebhookSupportStatus(integrationId: string) {
+ return api.get(`/v1/integrations/webhook/provider/${integrationId}/status`);
}
export function getInAppActivated() {
diff --git a/apps/web/src/components/conditions/Conditions.tsx b/apps/web/src/components/conditions/Conditions.tsx
index cc1e3167cb8..7d88fe7fc51 100644
--- a/apps/web/src/components/conditions/Conditions.tsx
+++ b/apps/web/src/components/conditions/Conditions.tsx
@@ -1,10 +1,7 @@
import { Grid, Group, ActionIcon, Center, useMantineTheme } from '@mantine/core';
import styled from '@emotion/styled';
-import { useMemo } from 'react';
import { Control, Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
-
-import { FILTER_TO_LABEL, FilterPartTypeEnum, FieldLogicalOperatorEnum, FieldOperatorEnum } from '@novu/shared';
-
+import { FilterPartTypeEnum, IFieldFilterPart, FieldLogicalOperatorEnum, FieldOperatorEnum } from '@novu/shared';
import {
Button,
colors,
@@ -23,26 +20,32 @@ import {
ErrorIcon,
When,
} from '@novu/design-system';
-import { ConditionsContextEnum, ConditionsContextFields, IConditions } from './types';
-import { HEADER_HEIGHT } from '../layout/constants';
-interface IConditionsForm {
- conditions: IConditions[];
-}
+import { DataSelect, IConditions, IConditionsForm, IConditionsProps, IFilterTypeList } from './types';
+import { OnlineConditionRow } from './OnlineConditionRow';
+import { DefaultGroupOperatorData, DefaultOperatorData } from './constants';
+import { PreviousStepsConditionRow } from './PreviousStepsConditionRow';
+
export function Conditions({
isOpened,
+ isReadonly = false,
conditions,
onClose,
- setConditions,
+ updateConditions,
name,
- context = ConditionsContextEnum.INTEGRATIONS,
+ label = '',
+ filterPartsList,
+ defaultFilter,
}: {
isOpened: boolean;
+ isReadonly?: boolean;
onClose: () => void;
- setConditions: (data: IConditions[]) => void;
+ updateConditions: (data: IConditions[]) => void;
conditions?: IConditions[];
name: string;
- context?: ConditionsContextEnum;
+ label?: string;
+ filterPartsList: IFilterTypeList[];
+ defaultFilter?: FilterPartTypeEnum;
}) {
const { colorScheme } = useMantineTheme();
@@ -56,21 +59,33 @@ export function Conditions({
mode: 'onChange',
});
- const { fields, append, remove, insert } = useFieldArray({
+ const defaultOnFilter = defaultFilter ?? filterPartsList[0].value;
+
+ const { fields, append, remove, insert, update } = useFieldArray({
control,
- name: `conditions.0.children`,
+ name: `conditions.0.children` as 'conditions.0.children',
+ });
+
+ const filterPartTypeList = filterPartsList?.map(({ value, label: filterLabel }) => {
+ return {
+ value,
+ label: filterLabel,
+ };
});
- const { label, filterPartsList } = ConditionsContextFields[context];
+ function handleOnChildOnChange(index: number) {
+ return (data) => {
+ const { id: _, ...rest } = fields[index];
- const FilterPartTypeList = useMemo(() => {
- return filterPartsList.map((filterType) => {
- return {
- value: filterType,
- label: FILTER_TO_LABEL[filterType],
- };
- });
- }, [filterPartsList]);
+ if (data === FilterPartTypeEnum.IS_ONLINE) {
+ update(index, { ...rest, on: data, value: true });
+
+ return;
+ }
+
+ update(index, { ...rest, on: data });
+ };
+ }
function handleDuplicate(index: number) {
insert(index + 1, getValues(`conditions.0.children.${index}`));
@@ -84,17 +99,12 @@ export function Conditions({
await trigger('conditions');
if (!errors.conditions) {
updateConditions(getValues('conditions'));
+ onClose();
}
};
- function updateConditions(data) {
- setConditions(data);
- onClose();
- }
-
return (
@@ -124,96 +134,120 @@ export function Conditions({
}
+ styles={{ body: { '.sidebar-body-holder': { height: '100%' } }, root: { zIndex: 300 } }}
>
- {fields.map((item, index) => {
- return (
-
-
-
- {index > 0 ? (
-
- {
- return (
-
+ {fields.map((item, index) => {
+ const filterFieldOn = item.on;
+
+ const customData = filterPartsList?.find((filter) => filter.value === filterFieldOn)?.data;
+
+ return (
+
+
+
+
+
+ {index > 0 ? (
+
+ {
+ return (
+
+ );
+ }}
/>
- );
- }}
- />
-
- ) : (
-
- When
-
- )}
-
-
- {
- return (
-
- );
- }}
+
+ ) : (
+
+ When
+
+ )}
+
+
+ {
+ return (
+
+ );
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleDuplicate(index)}
+ onDelete={() => handleDelete(index)}
+ isReadonly={isReadonly}
/>
-
-
-
-
-
-
- }
- middlewares={{ flip: false, shift: false }}
- position="bottom-end"
- >
- handleDuplicate(index)}
- icon={ }
- >
- Duplicate
-
- handleDelete(index)}
- icon={ }
- >
- Delete
-
-
-
-
-
- );
- })}
+
+
+ );
+ })}
+
-
+
{
append({
operator: FieldOperatorEnum.EQUAL,
- on: FilterPartTypeEnum.TENANT,
- field: 'identifier',
- value: '',
- });
+ on: defaultOnFilter,
+ } as IFieldFilterPart);
}}
- icon={ }
+ icon={ }
+ disabled={isReadonly}
data-test-id="add-new-condition"
>
Add condition
@@ -223,7 +257,19 @@ export function Conditions({
);
}
-function EqualityForm({ control, index }: { control: Control; index: number }) {
+function EqualityForm({
+ control,
+ index,
+ webhook = false,
+ isReadonly = false,
+ customData,
+}: {
+ control: Control;
+ index: number;
+ webhook?: boolean;
+ isReadonly?: boolean;
+ customData?: DataSelect[];
+}) {
const operator = useWatch({
control,
name: `conditions.0.children.${index}.operator`,
@@ -231,20 +277,20 @@ function EqualityForm({ control, index }: { control: Control; i
return (
<>
-
+
{
+ rules={{ required: true }}
+ defaultValue=""
+ render={({ field, fieldState }) => {
return (
- }
+ error={!!fieldState.error}
+ disabled={isReadonly}
data-test-id="conditions-form-key"
/>
);
@@ -260,14 +306,9 @@ function EqualityForm({ control, index }: { control: Control; i
return (
);
@@ -275,7 +316,7 @@ function EqualityForm({ control, index }: { control: Control; i
/>
-
+
{operator !== FieldOperatorEnum.IS_DEFINED && (
; i
-
-
-
-
-
-
- }
+ rightSection={ }
error={!!fieldState.error}
placeholder="Value"
+ disabled={isReadonly}
data-test-id="conditions-form-value"
/>
);
@@ -316,6 +343,79 @@ function EqualityForm({ control, index }: { control: Control; i
);
}
+function WebHookUrlForm({ control, index, isReadonly = false }: IConditionsProps) {
+ return (
+ <>
+
+ {
+ return (
+ }
+ error={!!fieldState.error}
+ placeholder="Url"
+ disabled={isReadonly}
+ data-test-id="webhook-filter-url-input"
+ />
+ );
+ }}
+ />
+
+ >
+ );
+}
+
+export function RightSectionError({ showError, label }: { showError: boolean; label: string }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function ConditionRowMenu({
+ isReadonly,
+ onDuplicate,
+ onDelete,
+}: {
+ onDuplicate: () => void;
+ onDelete: () => void;
+ isReadonly?: boolean;
+}) {
+ return (
+
+
+
+
+ }
+ middlewares={{ flip: false, shift: false }}
+ position="bottom-end"
+ disabled={isReadonly}
+ >
+ }>
+ Duplicate
+
+ }>
+ Delete
+
+
+
+ );
+}
+
const Wrapper = styled.div`
.mantine-Select-wrapper:not(:hover) {
.mantine-Select-input {
diff --git a/apps/web/src/components/conditions/OnlineConditionRow.tsx b/apps/web/src/components/conditions/OnlineConditionRow.tsx
new file mode 100644
index 00000000000..bada69dfa33
--- /dev/null
+++ b/apps/web/src/components/conditions/OnlineConditionRow.tsx
@@ -0,0 +1,108 @@
+import { Grid } from '@mantine/core';
+import { Control, Controller } from 'react-hook-form';
+import { FilterPartTypeEnum, TimeOperatorEnum } from '@novu/shared';
+import { Input, Select } from '@novu/design-system';
+
+import { RightSectionError } from './Conditions';
+import { IConditionsForm, IConditionsProps } from './types';
+import { DefaultTimeOperatorData } from './constants';
+
+export function OnlineConditionRow({
+ fieldOn,
+ control,
+ index,
+ isReadonly = false,
+}: {
+ fieldOn: string;
+ control: Control;
+ index: number;
+ isReadonly?: boolean;
+}) {
+ return (
+ <>
+ {fieldOn === FilterPartTypeEnum.IS_ONLINE ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
+
+function OnlineRightNowForm({ control, index, isReadonly }: IConditionsProps) {
+ return (
+ <>
+
+ {
+ const value = typeof field.value === 'boolean' ? `${field.value}` : `${field.value === 'true'}`;
+
+ return (
+ field.onChange(val === 'true')}
+ value={value}
+ disabled={isReadonly}
+ data-test-id="online-now-value-dropdown"
+ />
+ );
+ }}
+ />
+
+ >
+ );
+}
+
+function OnlineInTheLastForm({ control, index, isReadonly }: IConditionsProps) {
+ return (
+ <>
+
+ {
+ return (
+ }
+ error={!!fieldState.error}
+ placeholder="Value"
+ type="number"
+ disabled={isReadonly}
+ data-test-id="online-in-last-value-input"
+ />
+ );
+ }}
+ />
+
+
+ {
+ return (
+
+ );
+ }}
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/components/conditions/PreviousStepsConditionRow.tsx b/apps/web/src/components/conditions/PreviousStepsConditionRow.tsx
new file mode 100644
index 00000000000..aba1665c873
--- /dev/null
+++ b/apps/web/src/components/conditions/PreviousStepsConditionRow.tsx
@@ -0,0 +1,62 @@
+import { Control, Controller } from 'react-hook-form';
+import { Grid } from '@mantine/core';
+import { PreviousStepTypeEnum } from '@novu/shared';
+import { Select } from '@novu/design-system';
+
+import { DataSelect, IConditionsForm } from './types';
+import { DefaultPreviousStepTypeData } from './constants';
+
+export function PreviousStepsConditionRow({
+ control,
+ index,
+ customData,
+ isReadonly = false,
+}: {
+ control: Control;
+ index: number;
+ customData?: DataSelect[];
+ isReadonly?: boolean;
+}) {
+ const defaultValue = customData?.[0].value;
+
+ return (
+ <>
+
+ {
+ return (
+
+ );
+ }}
+ />
+
+
+ {
+ return (
+
+ );
+ }}
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/components/conditions/constants.ts b/apps/web/src/components/conditions/constants.ts
new file mode 100644
index 00000000000..664d931b35a
--- /dev/null
+++ b/apps/web/src/components/conditions/constants.ts
@@ -0,0 +1,43 @@
+import { FieldLogicalOperatorEnum, PreviousStepTypeEnum, TimeOperatorEnum, FieldOperatorEnum } from '@novu/shared';
+
+export const DefaultTimeOperatorData = [
+ { value: TimeOperatorEnum.MINUTES, label: 'Minutes' },
+ { value: TimeOperatorEnum.HOURS, label: 'Hours' },
+ { value: TimeOperatorEnum.DAYS, label: 'Days' },
+];
+
+export const DefaultOperatorData = [
+ { value: FieldOperatorEnum.EQUAL, label: 'Equal' },
+ { value: FieldOperatorEnum.NOT_EQUAL, label: 'Not equal' },
+ { value: FieldOperatorEnum.IN, label: 'Contains' },
+ { value: FieldOperatorEnum.NOT_IN, label: 'Does not contain' },
+ { value: FieldOperatorEnum.IS_DEFINED, label: 'Is defined' },
+ { value: FieldOperatorEnum.LARGER, label: 'Greater than' },
+ { value: FieldOperatorEnum.SMALLER, label: 'Less than' },
+ { value: FieldOperatorEnum.LARGER_EQUAL, label: 'Greater or Equal' },
+ { value: FieldOperatorEnum.SMALLER_EQUAL, label: 'Less or Equal' },
+];
+
+export const DefaultGroupOperatorData = [
+ { value: FieldLogicalOperatorEnum.AND, label: 'And' },
+ { value: FieldLogicalOperatorEnum.OR, label: 'Or' },
+];
+
+export const DefaultPreviousStepTypeData = [
+ {
+ label: 'Read',
+ value: PreviousStepTypeEnum.READ,
+ },
+ {
+ label: 'Unread',
+ value: PreviousStepTypeEnum.UNREAD,
+ },
+ {
+ label: 'Seen',
+ value: PreviousStepTypeEnum.SEEN,
+ },
+ {
+ label: 'Unseen',
+ value: PreviousStepTypeEnum.UNSEEN,
+ },
+];
diff --git a/apps/web/src/components/conditions/types.ts b/apps/web/src/components/conditions/types.ts
index fc8556e2ed5..bd1cafdd1c6 100644
--- a/apps/web/src/components/conditions/types.ts
+++ b/apps/web/src/components/conditions/types.ts
@@ -1,3 +1,4 @@
+import { Control } from 'react-hook-form';
import { BuilderFieldType, BuilderGroupValues, FilterParts, FilterPartTypeEnum } from '@novu/shared';
export interface IConditions {
@@ -6,14 +7,19 @@ export interface IConditions {
value?: BuilderGroupValues;
children?: FilterParts[];
}
+export interface IConditionsForm {
+ conditions: IConditions[];
+}
-export enum ConditionsContextEnum {
- INTEGRATIONS = 'INTEGRATIONS',
+export interface IConditionsProps {
+ control: Control;
+ isReadonly?: boolean;
+ index: number;
}
+export type DataSelect = { value: string; label: string };
-export const ConditionsContextFields = {
- [ConditionsContextEnum.INTEGRATIONS]: {
- label: 'provider instance',
- filterPartsList: [FilterPartTypeEnum.TENANT],
- },
-};
+export interface IFilterTypeList {
+ value: FilterPartTypeEnum;
+ label: string;
+ data?: DataSelect[];
+}
diff --git a/apps/web/src/components/execution-detail/ExecutionDetailsStepHeader.tsx b/apps/web/src/components/execution-detail/ExecutionDetailsStepHeader.tsx
index 7767962e585..e9a13dd3405 100644
--- a/apps/web/src/components/execution-detail/ExecutionDetailsStepHeader.tsx
+++ b/apps/web/src/components/execution-detail/ExecutionDetailsStepHeader.tsx
@@ -86,6 +86,9 @@ const generateDetailByStepAndStatus = (status, step) => {
}
if (step.type === StepTypeEnum.DIGEST) {
+ if (status === JobStatusEnum.SKIPPED) {
+ return step.executionDetails?.at(-1)?.detail;
+ }
const { digest } = step;
return `Digesting events for ${digest.amount} ${digest.unit}`;
diff --git a/apps/web/src/components/layout/components/HeaderNav.tsx b/apps/web/src/components/layout/components/HeaderNav.tsx
index 2706d08d591..5b201a18614 100644
--- a/apps/web/src/components/layout/components/HeaderNav.tsx
+++ b/apps/web/src/components/layout/components/HeaderNav.tsx
@@ -1,11 +1,10 @@
-import { ActionIcon, Avatar, Container, Group, Header, useMantineColorScheme } from '@mantine/core';
+import { ActionIcon, Avatar, ColorScheme, Container, Group, Header, useMantineColorScheme } from '@mantine/core';
import * as capitalize from 'lodash.capitalize';
import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useIntercom } from 'react-use-intercom';
-import LogRocket from 'logrocket';
-import { CONTEXT_PATH, INTERCOM_APP_ID, IS_DOCKER_HOSTED, LOGROCKET_ID, REACT_APP_VERSION } from '../../../config';
+import { CONTEXT_PATH, INTERCOM_APP_ID, IS_DOCKER_HOSTED, REACT_APP_VERSION } from '../../../config';
import { ROUTES } from '../../../constants/routes.enum';
import {
colors,
@@ -14,25 +13,25 @@ import {
Text,
Tooltip,
Ellipse,
- Mail,
Moon,
Question,
Sun,
Logout,
+ InviteMembers,
} from '@novu/design-system';
-
-import { useLocalThemePreference } from '../../../hooks';
+import { useLocalThemePreference, useDebounce } from '../../../hooks';
import { discordInviteUrl } from '../../../pages/quick-start/consts';
import { useAuthContext } from '../../providers/AuthProvider';
import { useSpotlightContext } from '../../providers/SpotlightProvider';
import { HEADER_HEIGHT } from '../constants';
import { NotificationCenterWidget } from './NotificationCenterWidget';
+import { useSegment } from '../../providers/SegmentProvider';
type Props = { isIntercomOpened: boolean };
const menuItem = [
{
title: 'Invite Members',
- icon: ,
+ icon: ,
path: ROUTES.TEAM,
},
];
@@ -55,11 +54,19 @@ export function HeaderNav({ isIntercomOpened }: Props) {
const { currentOrganization, currentUser, logout } = useAuthContext();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { themeStatus } = useLocalThemePreference();
- const dark = colorScheme === 'dark';
const { addItem, removeItems } = useSpotlightContext();
const { boot } = useIntercom();
+ const segment = useSegment();
const isSelfHosted = IS_DOCKER_HOSTED;
+ const debounceThemeChange = useDebounce((args: { colorScheme: ColorScheme; themeStatus: string }) => {
+ segment.track('Theme is set - [Theme]', args);
+ }, 500);
+
+ useEffect(() => {
+ debounceThemeChange({ colorScheme, themeStatus });
+ }, [colorScheme, themeStatus, debounceThemeChange]);
+
useEffect(() => {
const shouldBootIntercom = !!INTERCOM_APP_ID && currentUser && currentOrganization;
if (shouldBootIntercom) {
@@ -79,30 +86,6 @@ export function HeaderNav({ isIntercomOpened }: Props) {
}
}, [boot, currentUser, currentOrganization]);
- useEffect(() => {
- if (!LOGROCKET_ID) return;
- if (currentUser && currentOrganization) {
- let logrocketTraits;
-
- if (currentUser?.email !== undefined) {
- logrocketTraits = {
- name: currentUser?.firstName + ' ' + currentUser?.lastName,
- organizationId: currentOrganization._id,
- organization: currentOrganization.name,
- email: currentUser?.email ? currentUser?.email : ' ',
- };
- } else {
- logrocketTraits = {
- name: currentUser?.firstName + ' ' + currentUser?.lastName,
- organizationId: currentOrganization._id,
- organization: currentOrganization.name,
- };
- }
-
- LogRocket.identify(currentUser?._id, logrocketTraits);
- }
- }, [currentUser, currentOrganization]);
-
let themeTitle = 'Match System Appearance';
if (themeStatus === 'dark') {
themeTitle = 'Dark Theme';
diff --git a/apps/web/src/components/layout/components/NotificationCenterWidget.tsx b/apps/web/src/components/layout/components/NotificationCenterWidget.tsx
index 8d763486a53..2b3205d041c 100644
--- a/apps/web/src/components/layout/components/NotificationCenterWidget.tsx
+++ b/apps/web/src/components/layout/components/NotificationCenterWidget.tsx
@@ -2,17 +2,20 @@ import { useMantineColorScheme } from '@mantine/core';
import { IUserEntity, IMessage, MessageActionStatusEnum, ButtonTypeEnum } from '@novu/shared';
import { NotificationBell, NovuProvider, PopoverNotificationCenter, useUpdateAction } from '@novu/notification-center';
-import { API_ROOT, APP_ID, WS_URL } from '../../../config';
+import { API_ROOT, APP_ID, WS_URL, IS_EU_ENV } from '../../../config';
import { useEnvController } from '../../../hooks';
+const BACKEND_URL = IS_EU_ENV ? 'https://api.novu.co' : API_ROOT;
+const SOCKET_URL = IS_EU_ENV ? 'https://ws.novu.co' : WS_URL;
+
export function NotificationCenterWidget({ user }: { user: IUserEntity | undefined }) {
const { environment } = useEnvController();
return (
<>
diff --git a/apps/web/src/components/layout/components/PageContainer.tsx b/apps/web/src/components/layout/components/PageContainer.tsx
index ddc17a77987..b6ea7c50e8e 100644
--- a/apps/web/src/components/layout/components/PageContainer.tsx
+++ b/apps/web/src/components/layout/components/PageContainer.tsx
@@ -1,36 +1,3 @@
-import styled from '@emotion/styled';
-import React, { CSSProperties } from 'react';
-import { Container } from '@novu/design-system';
-import PageMeta from './PageMeta';
-
-function PageContainer({
- children,
- title,
- style,
-}: {
- children: React.ReactNode;
- title?: string;
- style?: CSSProperties;
-}) {
- const containerStyle = {
- borderRadius: 0,
- ...style,
- };
-
- return (
-
-
- {children}
-
- );
-}
+import { PageContainer } from '@novu/design-system';
export default PageContainer;
-
-const StyledContainer = styled(Container)`
- overflow-y: auto !important;
- border-radius: 0;
- padding-left: 0;
- padding-right: 0;
- margin: 0;
-`;
diff --git a/apps/web/src/components/layout/components/PageMeta.tsx b/apps/web/src/components/layout/components/PageMeta.tsx
index 0c4d601cac6..02dbf2fc767 100644
--- a/apps/web/src/components/layout/components/PageMeta.tsx
+++ b/apps/web/src/components/layout/components/PageMeta.tsx
@@ -1,15 +1,3 @@
-import { Helmet } from 'react-helmet-async';
-
-type Props = {
- title?: string;
-};
-
-function PageMeta({ title }: Props) {
- return (
-
- {title ? `${title} | ` : ``}Novu Manage Platform
-
- );
-}
+import { PageMeta } from '@novu/design-system';
export default PageMeta;
diff --git a/apps/web/src/components/layout/components/SideNav.tsx b/apps/web/src/components/layout/components/SideNav.tsx
index 4b72b0a7e7e..fa1d74187cc 100644
--- a/apps/web/src/components/layout/components/SideNav.tsx
+++ b/apps/web/src/components/layout/components/SideNav.tsx
@@ -12,7 +12,7 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ROUTES } from '../../../constants/routes.enum';
-import { colors, NavMenu, SegmentedControl, shadows } from '@novu/design-system';
+import { colors, NavMenu, SegmentedControl, shadows, Translation } from '@novu/design-system';
import {
Activity,
Bolt,
@@ -25,7 +25,7 @@ import {
Settings,
Team,
} from '@novu/design-system';
-import { useEnvController, useIsMultiTenancyEnabled } from '../../../hooks';
+import { useEnvController, useIsMultiTenancyEnabled, useIsTranslationManagerEnabled } from '../../../hooks';
import { currentOnboardingStep } from '../../../pages/quick-start/components/route/store';
import { useSpotlightContext } from '../../providers/SpotlightProvider';
import { ChangesCountBadge } from './ChangesCountBadge';
@@ -63,6 +63,7 @@ export function SideNav({}: Props) {
const { addItem, removeItems } = useSpotlightContext();
const { classes } = usePopoverStyles();
const isMultiTenancyEnabled = useIsMultiTenancyEnabled();
+ const isTranslationManagerEnabled = useIsTranslationManagerEnabled();
useEffect(() => {
removeItems(['toggle-environment']);
@@ -103,6 +104,13 @@ export function SideNav({}: Props) {
label: 'Subscribers',
testId: 'side-nav-subscribers-link',
},
+ {
+ condition: isTranslationManagerEnabled,
+ icon: ,
+ link: ROUTES.TRANSLATIONS,
+ label: 'Translations',
+ testId: 'side-nav-translations-link',
+ },
{
icon: ,
link: '/brand',
diff --git a/apps/web/src/components/providers/AuthProvider.tsx b/apps/web/src/components/providers/AuthProvider.tsx
index 5c30d4b565e..15a12565083 100644
--- a/apps/web/src/components/providers/AuthProvider.tsx
+++ b/apps/web/src/components/providers/AuthProvider.tsx
@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import { IOrganizationEntity, IUserEntity, IJwtPayload } from '@novu/shared';
-import { useAuthController } from '../../hooks';
+import { useAuthController, useFeatureFlags } from '../../hooks';
type UserContext = {
token: string | null;
@@ -26,6 +26,7 @@ export const useAuthContext = (): UserContext => useContext(AuthContext);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const { token, setToken, user, organization, logout, jwtPayload, organizations } = useAuthController();
+ useFeatureFlags(organization);
return (
(undefined as any);
-
-export const SegmentProvider = ({ children }: Props) => {
- const segment = React.useMemo(() => new SegmentService(), []);
-
- return {children} ;
-};
-
-export const useSegment = () => {
- const result = React.useContext(SegmentContext);
- if (!result) {
- throw new Error('Context used outside of its Provider!');
- }
-
- return result;
-};
+export { SegmentProvider, useSegment };
diff --git a/apps/web/src/components/workflow/FlowEditor.tsx b/apps/web/src/components/workflow/FlowEditor.tsx
index 7509ece23d2..a940a895102 100644
--- a/apps/web/src/components/workflow/FlowEditor.tsx
+++ b/apps/web/src/components/workflow/FlowEditor.tsx
@@ -1,4 +1,12 @@
-import { ComponentType, MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ComponentType,
+ MouseEvent,
+ MouseEvent as ReactMouseEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import ReactFlow, {
addEdge,
Background,
@@ -23,7 +31,7 @@ import { StepTypeEnum } from '@novu/shared';
import { colors } from '@novu/design-system';
import { getChannel } from '../../utils/channels';
import { useEnvController } from '../../hooks';
-import type { IEdge, IFlowStep } from './types';
+import type { IEdge, IFlowStep, INode } from './types';
const triggerNode: Node = {
id: '1',
@@ -40,25 +48,25 @@ const DEFAULT_WRAPPER_STYLES = {
minHeight: '600px',
};
-interface IFlowEditorProps extends ReactFlowProps {
+export interface IFlowEditorProps extends ReactFlowProps {
+ isReadonly?: boolean;
steps: IFlowStep[];
dragging?: boolean;
errors?: any;
- nodeTypes: {
- triggerNode: ComponentType;
- channelNode: ComponentType;
- addNode?: ComponentType;
- };
+ nodeTypes: Record>;
edgeTypes?: { special: ComponentType };
withControls?: boolean;
wrapperStyles?: React.CSSProperties;
+ onEdit?: (e: MouseEvent, node: INode) => void;
onDelete?: (id: string) => void;
+ onAddVariant?: (id: string) => void;
onStepInit?: (step: IFlowStep) => Promise;
onGetStepError?: (i: number, errors: any) => string;
addStep?: (channelType: StepTypeEnum, id: string, index?: number) => void;
}
export function FlowEditor({
+ isReadonly = false,
steps,
dragging,
errors,
@@ -79,7 +87,9 @@ export function FlowEditor({
onStepInit,
onGetStepError,
addStep,
+ onEdit,
onDelete,
+ onAddVariant,
...restProps
}: IFlowEditorProps) {
const { colorScheme } = useMantineColorScheme();
@@ -155,17 +165,21 @@ export function FlowEditor({
let parentId = '1';
const finalNodes = [cloneDeep(triggerNode)];
let finalEdges: Edge[] = [];
+ let isParentVariantNode = false;
if (steps.length) {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const oldNode = nodes[i + 1];
- const position = oldNode && oldNode.type !== 'addNode' ? oldNode.position : { x: 0, y: 120 };
+ const position =
+ oldNode && oldNode.type !== 'addNode' ? oldNode.position : { x: 0, y: isParentVariantNode ? 160 : 120 };
const newId = (step._id || step.id) as string;
await onStepInit?.(step);
const newNode = buildNewNode(newId, position, parentId, step, i);
+ isParentVariantNode = newNode.data.step.variants && newNode.data.step.variants?.length > 0;
+
finalNodes.push(newNode);
const edgeType = edgeTypes ? 'special' : 'default';
@@ -176,7 +190,7 @@ export function FlowEditor({
}
}
if (!readonly && nodeTypes.addNode) {
- const addNodeButton = buildAddNodeButton(parentId);
+ const addNodeButton = buildAddNodeButton(parentId, isParentVariantNode);
finalNodes.push(addNodeButton);
}
@@ -192,6 +206,7 @@ export function FlowEditor({
i: number
): Node {
const channel = getChannel(step.template?.type);
+ const hasVariants = step.variants && step.variants?.length > 0;
return {
id: newId,
@@ -200,17 +215,16 @@ export function FlowEditor({
parentNode: parentId,
data: {
...channel,
- active: step.active,
+ isReadonly,
index: i,
error: onGetStepError?.(i, errors) ?? '',
onDelete,
- uuid: step.uuid,
- name: step.name,
- content: step.template?.content,
- htmlContent: step.template?.htmlContent,
- delayMetadata: step.delayMetadata,
- digestMetadata: step.digestMetadata,
+ onAddVariant,
+ onEdit,
+ step,
},
+ // this class is needed to update the node height for nodes with variants
+ ...(hasVariants && { className: 'variantNode' }),
};
}
@@ -226,7 +240,7 @@ export function FlowEditor({
};
}
- function buildAddNodeButton(parentId: string): Node {
+ function buildAddNodeButton(parentId: string, isParentVariantNode: boolean): Node {
return {
id: '2',
type: 'addNode',
@@ -240,7 +254,7 @@ export function FlowEditor({
className: 'nodrag',
connectable: false,
parentNode: parentId,
- position: { x: 0, y: 90 },
+ position: { x: 0, y: isParentVariantNode ? 130 : 90 },
};
}
@@ -346,6 +360,9 @@ const Wrapper = styled.div<{ dark: boolean }>`
}
}
}
+ .react-flow__node.variantNode {
+ height: 120px;
+ }
.react-flow__node.react-flow__node-addNode {
cursor: default;
@@ -361,6 +378,7 @@ const Wrapper = styled.div<{ dark: boolean }>`
.react-flow__attribution {
background: transparent;
opacity: 0.5;
+ z-index: 1;
}
.react-flow__edge-path {
stroke: ${colors.B60};
diff --git a/apps/web/src/components/workflow/index.ts b/apps/web/src/components/workflow/index.ts
index 87b611cfd14..0c547c3e8b9 100644
--- a/apps/web/src/components/workflow/index.ts
+++ b/apps/web/src/components/workflow/index.ts
@@ -1,2 +1,3 @@
+export type { IFlowEditorProps } from './FlowEditor';
export { FlowEditor } from './FlowEditor';
export { NodeStep } from './NodeStep';
diff --git a/apps/web/src/components/workflow/types.ts b/apps/web/src/components/workflow/types.ts
index b24ca04431a..66ce3c89b17 100644
--- a/apps/web/src/components/workflow/types.ts
+++ b/apps/web/src/components/workflow/types.ts
@@ -1,6 +1,8 @@
-import { EdgeProps } from 'react-flow-renderer';
-import { IEmailBlock, StepTypeEnum } from '@novu/shared';
-import { IFormStep } from '../../pages/templates/components/formTypes';
+import type { MouseEvent } from 'react';
+import { EdgeProps, NodeProps } from 'react-flow-renderer';
+import { ChannelTypeEnum, IEmailBlock, StepTypeEnum } from '@novu/shared';
+
+import type { IFormStep } from '../../pages/templates/components/formTypes';
export interface IEdge extends EdgeProps {
parentId: string;
@@ -19,6 +21,31 @@ export interface IFlowStep {
content?: string | IEmailBlock[];
htmlContent?: string;
};
+ variants?: Omit[];
+ filters?: IFormStep['filters'];
digestMetadata?: IFormStep['digestMetadata'];
delayMetadata?: IFormStep['delayMetadata'];
}
+
+export interface NodeData {
+ isReadonly: boolean;
+ Icon: React.FC;
+ label: string;
+ tabKey: ChannelTypeEnum;
+ index: number;
+ testId: string;
+ onDelete: (uuid: string) => void;
+ onAddVariant: (uuid: string) => void;
+ onEdit: (e: MouseEvent, node: INode) => void;
+ error: string;
+ channelType: StepTypeEnum;
+ step?: IFlowStep;
+}
+
+export enum NodeType {
+ CHANNEL = 'channelNode',
+ TRIGGER = 'triggerNode',
+ ADD_NODE = 'addNode',
+}
+
+export type INode = NodeProps;
diff --git a/apps/web/src/config/index.ts b/apps/web/src/config/index.ts
index b7273864741..dabe3a69149 100644
--- a/apps/web/src/config/index.ts
+++ b/apps/web/src/config/index.ts
@@ -1,67 +1,41 @@
-import { isBrowser } from '../utils';
-import { getContextPath, NovuComponentEnum } from '@novu/shared';
-
-declare global {
- interface Window {
- _env_: any;
- _cypress: any;
- }
-}
-
-const isCypress = (isBrowser() && (window as any).Cypress) || (isBrowser() && (window as any).parent.Cypress);
-
-export const API_ROOT =
- window._env_.REACT_APP_API_URL || isCypress
- ? window._env_.REACT_APP_API_URL || process.env.REACT_APP_API_URL || 'http://localhost:1336'
- : window._env_.REACT_APP_API_URL || process.env.REACT_APP_API_URL || 'http://localhost:3000';
-
-export const WS_URL = isCypress
- ? window._env_.REACT_APP_WS_URL || process.env.REACT_APP_WS_URL || 'http://localhost:1340'
- : window._env_.REACT_APP_WS_URL || process.env.REACT_APP_WS_URL || 'http://localhost:3002';
-
-export const SENTRY_DSN = window._env_.REACT_APP_SENTRY_DSN || process.env.REACT_APP_SENTRY_DSN;
-
-export const ENV = window._env_.REACT_APP_ENVIRONMENT || process.env.REACT_APP_ENVIRONMENT;
-
-const blueprintApiUrlByEnv = ENV === 'production' || ENV === 'prod' ? 'https://api.novu.co' : 'https://dev.api.novu.co';
-
-export const BLUEPRINTS_API_URL =
- window._env_.REACT_APP_BLUEPRINTS_API_URL || isCypress
- ? window._env_.REACT_APP_BLUEPRINTS_API_URL || process.env.REACT_APP_BLUEPRINTS_API_URL || 'http://localhost:1336'
- : blueprintApiUrlByEnv;
-
-export const APP_ID = window._env_.REACT_APP_NOVU_APP_ID || process.env.REACT_APP_NOVU_APP_ID;
-
-export const WIDGET_EMBED_PATH =
- window._env_.REACT_APP_WIDGET_EMBED_PATH ||
- process.env.REACT_APP_WIDGET_EMBED_PATH ||
- 'http://localhost:4701/embed.umd.min.js';
-
-export const IS_DOCKER_HOSTED =
- window._env_.REACT_APP_DOCKER_HOSTED_ENV === 'true' || process.env.REACT_APP_DOCKER_HOSTED_ENV === 'true';
-
-export const REACT_APP_VERSION = window._env_.REACT_APP_VERSION || process.env.REACT_APP_VERSION;
-
-export const INTERCOM_APP_ID = window._env_.REACT_APP_INTERCOM_APP_ID || process.env.REACT_APP_INTERCOM_APP_ID;
-
-export const CONTEXT_PATH = getContextPath(NovuComponentEnum.WEB);
-
-export const LOGROCKET_ID = (window._env_.REACT_APP_LOGROCKET_ID || process.env.REACT_APP_LOGROCKET_ID) ?? '';
-
-export const WEBHOOK_URL = isCypress
- ? window._env_.REACT_APP_WEBHOOK_URL || process.env.REACT_APP_WEBHOOK_URL || 'http://localhost:1341'
- : window._env_.REACT_APP_WEBHOOK_URL || process.env.REACT_APP_WEBHOOK_URL || 'http://localhost:3003';
-
-export const MAIL_SERVER_DOMAIN =
- window._env_.REACT_APP_MAIL_SERVER_DOMAIN || process.env.REACT_APP_MAIL_SERVER_DOMAIN || 'dev.inbound-mail.novu.co';
-
-export const LAUNCH_DARKLY_CLIENT_SIDE_ID =
- window._env_.REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID || process.env.REACT_APP_LAUNCH_DARKLY_CLIENT_SIDE_ID;
-
-export const IS_TEMPLATE_STORE_ENABLED = isCypress
- ? window._env_.IS_TEMPLATE_STORE_ENABLED || process.env.IS_TEMPLATE_STORE_ENABLED || 'true'
- : window._env_.IS_TEMPLATE_STORE_ENABLED || process.env.IS_TEMPLATE_STORE_ENABLED || 'false';
-
-export const IS_MULTI_TENANCY_ENABLED = isCypress
- ? window._env_.IS_MULTI_TENANCY_ENABLED || process.env.IS_MULTI_TENANCY_ENABLED || 'true'
- : window._env_.IS_MULTI_TENANCY_ENABLED || process.env.IS_MULTI_TENANCY_ENABLED || 'false';
+import {
+ API_ROOT,
+ WS_URL,
+ SENTRY_DSN,
+ ENV,
+ BLUEPRINTS_API_URL,
+ APP_ID,
+ WIDGET_EMBED_PATH,
+ IS_DOCKER_HOSTED,
+ REACT_APP_VERSION,
+ INTERCOM_APP_ID,
+ CONTEXT_PATH,
+ WEBHOOK_URL,
+ MAIL_SERVER_DOMAIN,
+ LAUNCH_DARKLY_CLIENT_SIDE_ID,
+ IS_TEMPLATE_STORE_ENABLED,
+ IS_MULTI_TENANCY_ENABLED,
+ IS_TRANSLATION_MANAGER_ENABLED,
+} from '@novu/shared-web';
+
+export {
+ API_ROOT,
+ WS_URL,
+ SENTRY_DSN,
+ ENV,
+ BLUEPRINTS_API_URL,
+ APP_ID,
+ WIDGET_EMBED_PATH,
+ IS_DOCKER_HOSTED,
+ REACT_APP_VERSION,
+ INTERCOM_APP_ID,
+ CONTEXT_PATH,
+ WEBHOOK_URL,
+ MAIL_SERVER_DOMAIN,
+ LAUNCH_DARKLY_CLIENT_SIDE_ID,
+ IS_TEMPLATE_STORE_ENABLED,
+ IS_MULTI_TENANCY_ENABLED,
+ IS_TRANSLATION_MANAGER_ENABLED,
+};
+
+export const IS_EU_ENV = (ENV === 'production' || ENV === 'prod') && API_ROOT.includes('eu.api.novu.co');
diff --git a/apps/web/src/constants/routes.enum.ts b/apps/web/src/constants/routes.enum.ts
index 6682e0d47bb..f6eab1b3139 100644
--- a/apps/web/src/constants/routes.enum.ts
+++ b/apps/web/src/constants/routes.enum.ts
@@ -33,4 +33,5 @@ export enum ROUTES {
ABOUT = '/about',
CONTACT = '/contact',
BRAND = '/brand',
+ TRANSLATIONS = '/translations',
}
diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts
index c49c6ca05ca..18550efc484 100644
--- a/apps/web/src/hooks/index.ts
+++ b/apps/web/src/hooks/index.ts
@@ -20,4 +20,4 @@ export * from './useVercelParams';
export * from './useEffectOnce';
export * from './useInlineComponent';
export * from './useHoverOverItem';
-export { useDataRef, useKeyDown, useLocalThemePreference } from '@novu/design-system';
+export { useDataRef, useKeyDown, useLocalThemePreference } from '@novu/shared-web';
diff --git a/apps/web/src/hooks/integrations/useGetPrimaryIntegration.ts b/apps/web/src/hooks/integrations/useGetPrimaryIntegration.ts
index 5de44162cb6..dbd7ffaada9 100644
--- a/apps/web/src/hooks/integrations/useGetPrimaryIntegration.ts
+++ b/apps/web/src/hooks/integrations/useGetPrimaryIntegration.ts
@@ -4,7 +4,7 @@ import { useHasActiveIntegrations } from './useHasActiveIntegrations';
type UseHasPrimaryIntegrationProps = {
filterByEnv?: boolean;
- channelType: ChannelTypeEnum;
+ channelType?: ChannelTypeEnum;
};
export function useGetPrimaryIntegration({ filterByEnv = true, channelType }: UseHasPrimaryIntegrationProps) {
diff --git a/apps/web/src/hooks/integrations/useHasActiveIntegrations.ts b/apps/web/src/hooks/integrations/useHasActiveIntegrations.ts
index 0bbeb4d2477..71ad313c4e8 100644
--- a/apps/web/src/hooks/integrations/useHasActiveIntegrations.ts
+++ b/apps/web/src/hooks/integrations/useHasActiveIntegrations.ts
@@ -5,7 +5,7 @@ import { useActiveIntegrations } from './useActiveIntegrations';
import { useIntegrationLimit } from './useIntegrationLimit';
type UseHasActiveIntegrationsProps = {
filterByEnv?: boolean;
- channelType: ChannelTypeEnum;
+ channelType?: ChannelTypeEnum;
};
export function useHasActiveIntegrations({ filterByEnv = true, channelType }: UseHasActiveIntegrationsProps) {
@@ -15,6 +15,10 @@ export function useHasActiveIntegrations({ filterByEnv = true, channelType }: Us
const { isLimitReached: isSmsLimitReached } = useIntegrationLimit(ChannelTypeEnum.SMS);
const isChannelStep = useMemo(() => {
+ if (!channelType) {
+ return false;
+ }
+
return [
ChannelTypeEnum.IN_APP,
ChannelTypeEnum.EMAIL,
diff --git a/apps/web/src/hooks/useAuthController.ts b/apps/web/src/hooks/useAuthController.ts
index 7a12b2d3dd7..04da0f0d1eb 100644
--- a/apps/web/src/hooks/useAuthController.ts
+++ b/apps/web/src/hooks/useAuthController.ts
@@ -1,132 +1,3 @@
-import { useEffect, useCallback, useState } from 'react';
-import axios from 'axios';
-import jwtDecode from 'jwt-decode';
-import { useNavigate } from 'react-router-dom';
-import { useQuery, useQueryClient } from '@tanstack/react-query';
-import * as Sentry from '@sentry/react';
-import type { IJwtPayload, IOrganizationEntity, IUserEntity } from '@novu/shared';
+import { useAuthController, applyToken, getTokenPayload, getToken } from '@novu/shared-web';
-import { getUser } from '../api/user';
-import { getOrganizations } from '../api/organization';
-import { useSegment } from '../components/providers/SegmentProvider';
-
-export function applyToken(token: string | null) {
- if (token) {
- localStorage.setItem('auth_token', token);
- axios.defaults.headers.common.Authorization = `Bearer ${token}`;
- } else {
- localStorage.removeItem('auth_token');
- delete axios.defaults.headers.common.Authorization;
- }
-}
-
-export function getTokenPayload() {
- const token = getToken();
- if (!token) return null;
-
- return jwtDecode(token);
-}
-
-export function getToken(): string {
- return localStorage.getItem('auth_token') as string;
-}
-
-export function useAuthController() {
- const segment = useSegment();
- const queryClient = useQueryClient();
- const navigate = useNavigate();
- const [token, setToken] = useState(() => {
- const initialToken = getToken();
- applyToken(initialToken);
-
- return initialToken;
- });
- const [jwtPayload, setJwtPayload] = useState(() => {
- const initialToken = getToken();
- if (initialToken) {
- return jwtDecode(initialToken);
- }
- });
- const [organization, setOrganization] = useState();
- const isLoggedIn = !!token;
-
- const { data: user } = useQuery(['/v1/users/me'], getUser, {
- enabled: Boolean(isLoggedIn && axios.defaults.headers.common.Authorization),
- });
-
- const authorization = axios.defaults.headers.common.Authorization as string;
- const { data: organizations } = useQuery(['/v1/organizations'], getOrganizations, {
- enabled: Boolean(
- isLoggedIn &&
- axios.defaults.headers.common.Authorization &&
- jwtDecode(authorization?.split(' ')[1])?.organizationId
- ),
- });
-
- useEffect(() => {
- const organizationId = jwtPayload?.organizationId;
- if (!organizationId || !organizations || organizations.length === 0) return;
-
- setOrganization(organizations.find((org) => org._id === organizationId));
- }, [jwtPayload, organizations]);
-
- useEffect(() => {
- if (user && organization) {
- segment.identify(user);
-
- Sentry.setUser({
- email: user.email ?? '',
- username: `${user.firstName} ${user.lastName}`,
- id: user._id,
- organizationId: organization._id,
- organizationName: organization.name,
- });
- } else {
- Sentry.configureScope((scope) => scope.setUser(null));
- }
- }, [user, organization, segment]);
-
- const setTokenCallback = useCallback(
- (newToken: string | null, refetch = true) => {
- /**
- * applyToken needs to be called first to avoid a race condition
- */
- applyToken(newToken);
- setToken(newToken);
-
- if (newToken) {
- if (refetch) {
- queryClient.refetchQueries({
- predicate: (query) =>
- // !query.isFetching &&
- !query.queryKey.includes('/v1/users/me') &&
- !query.queryKey.includes('/v1/environments') &&
- !query.queryKey.includes('/v1/organizations') &&
- !query.queryKey.includes('getInviteTokenData'),
- });
- }
- const payload = jwtDecode(newToken);
- setJwtPayload(payload);
- }
- },
- [queryClient, setToken, setJwtPayload]
- );
-
- const logout = () => {
- setTokenCallback(null);
- queryClient.clear();
- navigate('/auth/login');
- segment.reset();
- };
-
- return {
- isLoggedIn,
- user,
- organizations,
- organization,
- token,
- logout,
- jwtPayload,
- setToken: setTokenCallback,
- };
-}
+export { useAuthController, applyToken, getTokenPayload, getToken };
diff --git a/apps/web/src/hooks/useDebounce.ts b/apps/web/src/hooks/useDebounce.ts
index d37519bf12d..d910484b913 100644
--- a/apps/web/src/hooks/useDebounce.ts
+++ b/apps/web/src/hooks/useDebounce.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react';
import debounce from 'lodash.debounce';
-import { useDataRef } from '@novu/design-system';
+import { useDataRef } from '@novu/shared-web';
export const useDebounce = (callback: (args: Arguments) => void, ms = 0) => {
const callbackRef = useDataRef(callback);
diff --git a/apps/web/src/hooks/useFeatureFlags.ts b/apps/web/src/hooks/useFeatureFlags.ts
index 71c6e909cc1..3deb08a4b8b 100644
--- a/apps/web/src/hooks/useFeatureFlags.ts
+++ b/apps/web/src/hooks/useFeatureFlags.ts
@@ -1,7 +1,27 @@
-import { FeatureFlagsKeysEnum } from '@novu/shared';
+import { FeatureFlagsKeysEnum, IOrganizationEntity } from '@novu/shared';
import { useFlags } from 'launchdarkly-react-client-sdk';
+import { useLDClient } from 'launchdarkly-react-client-sdk';
+import { useEffect } from 'react';
-import { IS_TEMPLATE_STORE_ENABLED, IS_MULTI_TENANCY_ENABLED } from '../config';
+import { IS_TEMPLATE_STORE_ENABLED, IS_MULTI_TENANCY_ENABLED, IS_TRANSLATION_MANAGER_ENABLED } from '../config';
+
+export const useFeatureFlags = (organization: IOrganizationEntity) => {
+ const ldClient = useLDClient();
+
+ useEffect(() => {
+ if (!organization?._id) {
+ return;
+ }
+
+ ldClient?.identify({
+ kind: 'organization',
+ key: organization._id,
+ name: organization.name,
+ });
+ }, [organization?._id, ldClient, organization?.name]);
+
+ return ldClient;
+};
const prepareBooleanStringFeatureFlag = (value: string | undefined, defaultValue: boolean): boolean => {
const preparedValue = value === 'true';
@@ -34,3 +54,13 @@ export const useIsMultiTenancyEnabled = (): boolean => {
return isMultiTenancyEnabled ?? defaultValue;
};
+
+export const useIsTranslationManagerEnabled = (): boolean => {
+ const value = IS_TRANSLATION_MANAGER_ENABLED;
+ const fallbackValue = false;
+ const defaultValue = prepareBooleanStringFeatureFlag(value, fallbackValue);
+
+ const isTranslationManagerEnabled = useGetFlagByKey(FeatureFlagsKeysEnum.IS_TRANSLATION_MANAGER_ENABLED);
+
+ return isTranslationManagerEnabled ?? defaultValue;
+};
diff --git a/apps/web/src/hooks/useInlineComponent.tsx b/apps/web/src/hooks/useInlineComponent.tsx
index 82a63fb5a35..62865caf6a2 100644
--- a/apps/web/src/hooks/useInlineComponent.tsx
+++ b/apps/web/src/hooks/useInlineComponent.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import type { ComponentType, ReactNode } from 'react';
-import { useDataRef } from '@novu/design-system';
+import { useDataRef } from '@novu/shared-web';
export const useInlineComponent: (
Component: ComponentType,
diff --git a/apps/web/src/hooks/useVariablesManager.ts b/apps/web/src/hooks/useVariablesManager.ts
index 64a52ba6fa5..ea9181e914b 100644
--- a/apps/web/src/hooks/useVariablesManager.ts
+++ b/apps/web/src/hooks/useVariablesManager.ts
@@ -1,42 +1,47 @@
-import { useCallback, useLayoutEffect, useState } from 'react';
+import { useLayoutEffect, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { parse } from '@handlebars/parser';
import isEqual from 'lodash.isequal';
import { getTemplateVariables } from '@novu/shared';
+
import { IForm, ITemplates } from '../pages/templates/components/formTypes';
+import { useStepFormPath } from '../pages/templates/hooks/useStepFormPath';
+import { useStepIndex } from '../pages/templates/hooks/useStepIndex';
-export const useVariablesManager = (index: number, contents: string[]) => {
- const { watch, control, getValues } = useFormContext();
- const variablesArray = useFieldArray({ control, name: `steps.${index}.template.variables` });
- const variableArray = watch(`steps.${index}.template.variables`);
+const getTextContent = ({ templateToParse, fields }: { templateToParse?: ITemplates; fields: string[] }): string => {
+ return fields
+ .map((con) => con.split('.').reduce((a, b) => a && a[b], templateToParse ?? {}))
+ .map((con) => (Array.isArray(con) ? con.map((innerCon) => `${innerCon.content} ${innerCon?.url}`).join(' ') : con))
+ .join(' ');
+};
- const getTextContent = useCallback(
- ({ templateToParse, fields }: { templateToParse?: ITemplates; fields: string[] }): string => {
- return fields
- .map((con) => con.split('.').reduce((a, b) => a && a[b], templateToParse ?? {}))
- .map((con) =>
- Array.isArray(con) ? con.map((innerCon) => `${innerCon.content} ${innerCon?.url}`).join(' ') : con
- )
- .join(' ');
- },
- []
- );
+export const useVariablesManager = (contents: string[]) => {
+ const { stepIndex, variantIndex } = useStepIndex();
+ const { watch, control, getValues } = useFormContext();
+ const stepFormPath = useStepFormPath();
+ const variablesArray = useFieldArray({ control, name: `${stepFormPath}.template.variables` });
+ const variableArray = watch(`${stepFormPath}.template.variables`);
const [textContent, setTextContent] = useState(() =>
- getTextContent({ templateToParse: getValues(`steps.${index}.template`), fields: contents })
+ getTextContent({ templateToParse: getValues(`${stepFormPath}.template`), fields: contents })
);
useLayoutEffect(() => {
const subscription = watch((values) => {
const steps = values.steps ?? [];
- if (!steps.length || !steps[index]) return;
+ if (!steps.length || !steps[stepIndex]) return;
+
+ const step = steps[stepIndex];
+ let template = step?.template;
+ if (step && typeof variantIndex !== 'undefined' && variantIndex > -1) {
+ template = step.variants?.[variantIndex]?.template;
+ }
- const step = steps[index];
- setTextContent(getTextContent({ templateToParse: step?.template as ITemplates, fields: contents }));
+ setTextContent(getTextContent({ templateToParse: template as ITemplates, fields: contents }));
});
return () => subscription.unsubscribe();
- }, [index, watch, setTextContent, getTextContent, contents]);
+ }, [stepIndex, variantIndex, watch, setTextContent, contents]);
useLayoutEffect(() => {
try {
diff --git a/apps/web/src/pages/TranslationPages.tsx b/apps/web/src/pages/TranslationPages.tsx
new file mode 100644
index 00000000000..bc59767a4c7
--- /dev/null
+++ b/apps/web/src/pages/TranslationPages.tsx
@@ -0,0 +1,16 @@
+import { IS_DOCKER_HOSTED } from '../config';
+
+export const TranslationRoutes = () => {
+ if (IS_DOCKER_HOSTED) {
+ return null;
+ }
+
+ try {
+ const module = require('@novu/ee-translation-web');
+ const Routes = module.Routes;
+
+ return ;
+ } catch (e) {}
+
+ return null;
+};
diff --git a/apps/web/src/pages/activities/components/ActivityItem.tsx b/apps/web/src/pages/activities/components/ActivityItem.tsx
index 582e5663be5..3c9c3c651cb 100644
--- a/apps/web/src/pages/activities/components/ActivityItem.tsx
+++ b/apps/web/src/pages/activities/components/ActivityItem.tsx
@@ -3,13 +3,15 @@ import { createStyles, CSSObject, Grid, MantineTheme, Text, UnstyledButton, useM
import { JobStatusEnum } from '@novu/shared';
import { format } from 'date-fns';
import styled from '@emotion/styled';
+import { useClipboard } from '@mantine/hooks';
import { ActivityStep } from './ActivityStep';
import { DigestedStep } from './DigestedStep';
import { When } from '../../../components/utils/When';
-import { colors, CheckCircle, ErrorIcon, Timer } from '@novu/design-system';
+import { CheckCircle, colors, ErrorIcon, Timer } from '@novu/design-system';
import { useNotificationStatus } from '../hooks/useNotificationStatus';
+import { CopyButton } from './CopyButton';
const JOB_LENGTH_UPPER_THRESHOLD = 3;
@@ -35,6 +37,18 @@ const useStyles = createStyles(
unstyledButton: {
width: '100%',
cursor: isOld ? 'default' : 'pointer',
+ '&:hover': {
+ '[data-copy]': {
+ visibility: 'visible',
+ },
+ },
+ },
+ copyButton: {
+ display: 'inline',
+ visibility: 'hidden',
+ position: 'relative',
+ top: '2px',
+ marginLeft: '8px',
},
})
);
@@ -45,6 +59,7 @@ export const ActivityItem = ({ item, onClick }) => {
const [isOld, setIsOld] = useState(false);
const [digestedNode, setDigestedNode] = useState('');
const { classes } = useStyles({ isOld });
+ const { copy } = useClipboard();
useEffect(() => {
const details = item.jobs.reduce((items: any[], job) => [...items, ...job.executionDetails], []);
@@ -122,11 +137,15 @@ export const ActivityItem = ({ item, onClick }) => {
Subscriber id:
{item?.subscriber?.subscriberId ? item.subscriber.subscriberId : 'Deleted Subscriber'}
+ {item?.subscriber?.subscriberId && (
+ copy(item.subscriber.subscriberId)} />
+ )}
Transaction id: {item.transactionId}
+ copy(item.transactionId)} />
diff --git a/apps/web/src/pages/activities/components/CopyButton.tsx b/apps/web/src/pages/activities/components/CopyButton.tsx
new file mode 100644
index 00000000000..54b52e67b00
--- /dev/null
+++ b/apps/web/src/pages/activities/components/CopyButton.tsx
@@ -0,0 +1,54 @@
+import { SVGProps, useState } from 'react';
+import { createStyles, Tooltip, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
+import { useTimeout } from '@mantine/hooks';
+
+export const CopyIcon = (props: SVGProps) => {
+ return (
+
+
+
+ );
+};
+
+export type CopyProps = UnstyledButtonProps & {
+ onCopy: () => void;
+};
+
+const useCopyButtonStyles = createStyles({
+ root: {
+ svg: {
+ width: '12px',
+ height: '12px',
+ },
+ },
+});
+
+export const CopyButton = ({ onCopy, className, ...props }: CopyProps) => {
+ const [copied, setCopied] = useState(false);
+ const { start: closeTooltip } = useTimeout(() => setCopied(false), 1000);
+ const { classes } = useCopyButtonStyles();
+
+ return (
+
+ {
+ event.preventDefault();
+ event.stopPropagation();
+
+ onCopy();
+ setCopied(true);
+ closeTooltip();
+ }}
+ >
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/auth/CreateOrganizationPage.tsx b/apps/web/src/pages/auth/QuestionnairePage.tsx
similarity index 61%
rename from apps/web/src/pages/auth/CreateOrganizationPage.tsx
rename to apps/web/src/pages/auth/QuestionnairePage.tsx
index ab5e5ce51ef..acbbd4c242f 100644
--- a/apps/web/src/pages/auth/CreateOrganizationPage.tsx
+++ b/apps/web/src/pages/auth/QuestionnairePage.tsx
@@ -1,10 +1,10 @@
import AuthLayout from '../../components/layout/components/AuthLayout';
import AuthContainer from '../../components/layout/components/AuthContainer';
-import { CreateOrganization } from './components/CreateOrganizationForm';
+import { QuestionnaireForm } from './components/QuestionnaireForm';
import { useVercelIntegration } from '../../hooks';
import SetupLoader from './components/SetupLoader';
-export default function CreateOrganizationPage() {
+export default function QuestionnairePage() {
const { isLoading } = useVercelIntegration();
return (
@@ -12,8 +12,11 @@ export default function CreateOrganizationPage() {
{isLoading ? (
) : (
-
-
+
+
)}
diff --git a/apps/web/src/pages/auth/components/CreateOrganizationForm.tsx b/apps/web/src/pages/auth/components/CreateOrganizationForm.tsx
deleted file mode 100644
index 7d2be323c7b..00000000000
--- a/apps/web/src/pages/auth/components/CreateOrganizationForm.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useForm } from 'react-hook-form';
-import { useEffect, useState } from 'react';
-import { useMutation } from '@tanstack/react-query';
-import { useNavigate } from 'react-router-dom';
-import decode from 'jwt-decode';
-import { IJwtPayload } from '@novu/shared';
-
-import { Button, Input } from '@novu/design-system';
-import { api } from '../../../api/api.client';
-import { useAuthContext } from '../../../components/providers/AuthProvider';
-import { useVercelIntegration, useVercelParams } from '../../../hooks';
-import { ROUTES } from '../../../constants/routes.enum';
-
-type Props = {};
-
-export function CreateOrganization({}: Props) {
- const {
- register,
- handleSubmit,
- formState: { errors },
- } = useForm({});
-
- const navigate = useNavigate();
- const { setToken, token } = useAuthContext();
- const [loading, setLoading] = useState();
- const { startVercelSetup } = useVercelIntegration();
- const { isFromVercel } = useVercelParams();
-
- const { mutateAsync: createOrganizationMutation } = useMutation<
- { _id: string },
- { error: string; message: string; statusCode: number },
- {
- name: string;
- }
- >((data) => api.post(`/v1/organizations`, data));
-
- const { mutateAsync } = useMutation<
- { _id: string },
- { error: string; message: string; statusCode: number },
- {
- name: string;
- }
- >((data) => api.post(`/v1/environments`, data));
-
- useEffect(() => {
- if (token) {
- const userData = decode(token);
-
- if (userData.environmentId) {
- if (isFromVercel) {
- startVercelSetup();
-
- return;
- }
- navigate(ROUTES.HOME);
- }
- }
- }, [token, navigate, isFromVercel, startVercelSetup]);
-
- async function createEnvironment(name: string) {
- const environmentResponse = await mutateAsync({ name });
- const tokenResponse = await api.post(`/v1/auth/environments/${environmentResponse._id}/switch`, {});
-
- setToken(tokenResponse.token);
- }
-
- async function createOrganization(name: string) {
- const organization = await createOrganizationMutation({ name });
- const organizationResponseToken = await api.post(`/v1/auth/organizations/${organization._id}/switch`, {});
-
- setToken(organizationResponseToken);
- }
-
- function jwtHasKey(key: string) {
- if (!token) return false;
- const jwt = decode(token);
-
- return jwt && jwt[key];
- }
-
- const onCreateEnvironment = async (data: { organizationName?: string }) => {
- if (!data?.organizationName) return;
-
- setLoading(true);
-
- if (!jwtHasKey('organizationId')) {
- await createOrganization(data.organizationName);
- }
-
- setLoading(false);
- if (isFromVercel) {
- startVercelSetup();
-
- return;
- }
- navigate(ROUTES.GET_STARTED);
- };
-
- return (
-
- );
-}
diff --git a/apps/web/src/pages/auth/components/LoginForm.tsx b/apps/web/src/pages/auth/components/LoginForm.tsx
index e5b0518cfa9..f33c0010149 100644
--- a/apps/web/src/pages/auth/components/LoginForm.tsx
+++ b/apps/web/src/pages/auth/components/LoginForm.tsx
@@ -1,20 +1,18 @@
import { useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
-import styled from '@emotion/styled';
import { useForm } from 'react-hook-form';
import * as Sentry from '@sentry/react';
-import { Divider, Button as MantineButton, Center } from '@mantine/core';
+import { Center } from '@mantine/core';
+
+import { PasswordInput, Button, colors, Input, Text } from '@novu/design-system';
import { useAuthContext } from '../../../components/providers/AuthProvider';
import { api } from '../../../api/api.client';
-import { PasswordInput, Button, colors, Input, Text, GitHub } from '@novu/design-system';
-import { IS_DOCKER_HOSTED } from '../../../config';
import { useVercelParams } from '../../../hooks';
import { useAcceptInvite } from './useAcceptInvite';
-import { buildGithubLink, buildGoogleLink, buildVercelGithubLink } from './gitHubUtils';
import { ROUTES } from '../../../constants/routes.enum';
-import { When } from '../../../components/utils/When';
+import { OAuth } from './OAuth';
type LoginFormProps = {
invitationToken?: string;
@@ -38,10 +36,6 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) {
const vercelQueryParams = `code=${code}&next=${next}&configurationId=${configurationId}`;
const signupLink = isFromVercel ? `/auth/signup?${vercelQueryParams}` : ROUTES.AUTH_SIGNUP;
const resetPasswordLink = isFromVercel ? `/auth/reset/request?${vercelQueryParams}` : ROUTES.AUTH_RESET_REQUEST;
- const githubLink = isFromVercel
- ? buildVercelGithubLink({ code, next, configurationId })
- : buildGithubLink({ invitationToken });
- const googleLink = buildGoogleLink({ invitationToken });
const {
register,
@@ -96,39 +90,7 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) {
return (
<>
-
- <>
-
- }
- sx={{ color: colors.B40, fontSize: '16px', fontWeight: 700, height: '50px', marginRight: 10 }}
- data-test-id="github-button"
- >
- Sign In with GitHub
-
- {/* }
- data-test-id="google-button"
- sx={{ color: colors.B40, fontSize: '16px', fontWeight: 700, height: '50px', marginLeft: 10 }}
- >
- Sign In with Google
- */}
-
- Or} color={colors.B30} labelPosition="center" my="md" />
- >
-
+