diff --git a/.cspell.json b/.cspell.json index e5eb242e1ae..0b7084b1e9d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -3,6 +3,7 @@ "version": "0.2", "language": "en", "words": [ + "Springboot", "tmproj", "hgignore", "bzrignore", @@ -498,8 +499,10 @@ "reactjs", "nextjs", "vanillajs", - "quckstart", - "errmsg" + "errmsg", + "springboot", + "errmsg", + "shelljs" ], "flagWords": [], "patterns": [ diff --git a/.github/actions/checkout-submodules/action.yml b/.github/actions/checkout-submodules/action.yml new file mode 100644 index 00000000000..6f6cd013dbe --- /dev/null +++ b/.github/actions/checkout-submodules/action.yml @@ -0,0 +1,28 @@ +name: Checkout Submodules + +description: Checkout private enterprise submodule + +inputs: + enabled: + description: 'Run the action' + required: false + default: 'true' + submodule_token: + description: 'Submodule token to use for checkout' + required: true + submodule_branch: + description: 'Submodule branch to checkout to' + required: true + +runs: + using: composite + + steps: + - name: Checkout submodule + if: ${{ inputs.run == 'true' }} + uses: actions/checkout@v3 + with: + token: ${{ inputs.submodule_token }} + repository: novuhq/packages-enterprise + path: enterprise/packages + ref: ${{ inputs.submodule_branch }} diff --git a/.github/workflows/dev-deploy-api.yml b/.github/workflows/dev-deploy-api.yml index 904a653a678..1d9ab7f6baa 100644 --- a/.github/workflows/dev-deploy-api.yml +++ b/.github/workflows/dev-deploy-api.yml @@ -26,6 +26,7 @@ jobs: with: ee: ${{ contains (matrix.name,'ee') }} submodules: ${{ contains (matrix.name,'ee') }} + submodule_branch: "next" secrets: inherit deploy_dev_api: @@ -45,9 +46,11 @@ jobs: name: ['novu/api-ee', 'novu/api'] steps: - uses: actions/checkout@v3 + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ contains (matrix.name,'ee') }} - token: ${{ secrets.SUBMODULES_TOKEN }} + enabled: ${{ contains (matrix.name,'ee') }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: "next" - uses: ./.github/actions/setup-project - uses: ./.github/actions/docker/build-api id: docker_build diff --git a/.github/workflows/dev-deploy-inbound-mail.yml b/.github/workflows/dev-deploy-inbound-mail.yml index f3e23cea4df..f6abc3d0146 100644 --- a/.github/workflows/dev-deploy-inbound-mail.yml +++ b/.github/workflows/dev-deploy-inbound-mail.yml @@ -12,31 +12,35 @@ on: - 'package.json' - 'pnpm-lock.yaml' - 'apps/inbound-mail/**' - - 'libs/dal/**' - 'libs/shared/**' - 'libs/testing/**' env: TF_WORKSPACE: novu-dev jobs: - deploy_dev_inbound_mail: - if: "!contains(github.event.head_commit.message, 'ci skip')" + test_inbound_mail: + strategy: + matrix: + 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" + secrets: inherit + + dev_deploy_inbound_mail: # The type of runner that the job will run on runs-on: ubuntu-latest + needs: test_inbound_mail timeout-minutes: 80 environment: Development - permissions: - contents: read - packages: write - deployments: write - id-token: write + if: "!contains(github.event.head_commit.message, 'ci skip')" strategy: matrix: - # Only should be deploying inbound-mail-ee to dev - #name: ['novu/inbound-mail', 'novu/inbound-mail-ee'] name: ['novu/inbound-mail-ee'] + steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 - uses: ./.github/actions/setup-project @@ -45,7 +49,6 @@ jobs: with: oidc: true - - name: Set Bull MQ Env variable for EE shell: bash run: | diff --git a/.github/workflows/dev-deploy-web.yml b/.github/workflows/dev-deploy-web.yml index 383a638860b..37a0aed554e 100644 --- a/.github/workflows/dev-deploy-web.yml +++ b/.github/workflows/dev-deploy-web.yml @@ -20,6 +20,7 @@ jobs: uses: ./.github/workflows/reusable-web-e2e.yml with: submodules: true + submodule_branch: 'next' secrets: inherit # This workflow contains a single job called "build" diff --git a/.github/workflows/dev-deploy-widget.yml b/.github/workflows/dev-deploy-widget.yml index ef82d5f2d43..fa01ddc89b2 100644 --- a/.github/workflows/dev-deploy-widget.yml +++ b/.github/workflows/dev-deploy-widget.yml @@ -21,6 +21,7 @@ jobs: uses: ./.github/workflows/reusable-widget-e2e.yml with: submodules: true + submodule_branch: "next" secrets: inherit # This workflow contains a single job called "build" diff --git a/.github/workflows/dev-deploy-worker.yml b/.github/workflows/dev-deploy-worker.yml index 18cfb7d2946..0180cf43a2b 100644 --- a/.github/workflows/dev-deploy-worker.yml +++ b/.github/workflows/dev-deploy-worker.yml @@ -30,6 +30,7 @@ jobs: with: ee: ${{ contains (matrix.name,'ee') }} submodules: ${{ contains (matrix.name,'ee') }} + submodule_branch: "next" secrets: inherit deploy_dev_worker: @@ -49,9 +50,11 @@ jobs: name: ['novu/worker-ee', 'novu/worker'] steps: - uses: actions/checkout@v3 + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ contains (matrix.name,'ee') }} - token: ${{ secrets.SUBMODULES_TOKEN }} + enabled: ${{ contains (matrix.name,'ee') }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: "next" - uses: ./.github/actions/setup-project - uses: ./.github/actions/docker/build-worker id: docker_build diff --git a/.github/workflows/dev-deploy-ws.yml b/.github/workflows/dev-deploy-ws.yml index f9652da8e22..87e5e54a89e 100644 --- a/.github/workflows/dev-deploy-ws.yml +++ b/.github/workflows/dev-deploy-ws.yml @@ -22,6 +22,7 @@ jobs: with: ee: ${{ contains (matrix.name,'ee') }} submodules: ${{ contains (matrix.name,'ee') }} + submodule_branch: 'next' secrets: inherit # This workflow contains a single job called "build" diff --git a/.github/workflows/prod-deploy-api.yml b/.github/workflows/prod-deploy-api.yml index aba9f243f93..a88b9b0c849 100644 --- a/.github/workflows/prod-deploy-api.yml +++ b/.github/workflows/prod-deploy-api.yml @@ -15,6 +15,7 @@ jobs: with: ee: ${{ contains (matrix.name,'ee') }} submodules: ${{ contains (matrix.name,'ee') }} + submodule_branch: "main" secrets: inherit build_prod_image: @@ -36,9 +37,11 @@ jobs: id-token: write steps: - uses: actions/checkout@v3 + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ contains (matrix.name,'ee') }} - token: ${{ secrets.SUBMODULES_TOKEN }} + enabled: ${{ contains (matrix.name,'ee') }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: "main" - uses: ./.github/actions/setup-project - name: build api diff --git a/.github/workflows/prod-deploy-inbound-mail.yml b/.github/workflows/prod-deploy-inbound-mail.yml index cd262794f52..eda7f51d819 100644 --- a/.github/workflows/prod-deploy-inbound-mail.yml +++ b/.github/workflows/prod-deploy-inbound-mail.yml @@ -6,6 +6,17 @@ on: workflow_dispatch: jobs: + test_inbound_mail: + strategy: + matrix: + 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" + secrets: inherit + build_prod_image: runs-on: ubuntu-latest timeout-minutes: 80 diff --git a/.github/workflows/prod-deploy-web.yml b/.github/workflows/prod-deploy-web.yml index b944157dbab..1b5a2268ea3 100644 --- a/.github/workflows/prod-deploy-web.yml +++ b/.github/workflows/prod-deploy-web.yml @@ -13,6 +13,7 @@ jobs: uses: ./.github/workflows/reusable-web-e2e.yml with: submodules: true + submodule_branch: 'main' secrets: inherit deploy_web_eu: diff --git a/.github/workflows/prod-deploy-widget.yml b/.github/workflows/prod-deploy-widget.yml index 2778e3d258f..4d1d5d6933e 100644 --- a/.github/workflows/prod-deploy-widget.yml +++ b/.github/workflows/prod-deploy-widget.yml @@ -12,6 +12,7 @@ jobs: uses: ./.github/workflows/reusable-widget-e2e.yml with: submodules: true + submodule_branch: "main" secrets: inherit deploy_widget_eu: diff --git a/.github/workflows/prod-deploy-worker.yml b/.github/workflows/prod-deploy-worker.yml index 048ae1386d1..49fbb7eeb96 100644 --- a/.github/workflows/prod-deploy-worker.yml +++ b/.github/workflows/prod-deploy-worker.yml @@ -15,6 +15,7 @@ jobs: with: ee: ${{ contains (matrix.name,'ee') }} submodules: ${{ contains (matrix.name,'ee') }} + submodule_branch: "main" secrets: inherit build_prod_image: @@ -37,9 +38,11 @@ jobs: id-token: write steps: - uses: actions/checkout@v3 + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ contains (matrix.name,'ee') }} - token: ${{ secrets.SUBMODULES_TOKEN }} + enabled: ${{ contains (matrix.name,'ee') }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: "main" - uses: ./.github/actions/setup-project - name: build worker diff --git a/.github/workflows/prod-deploy-ws.yml b/.github/workflows/prod-deploy-ws.yml index db746e4d5c7..6c2a2d4e5f7 100644 --- a/.github/workflows/prod-deploy-ws.yml +++ b/.github/workflows/prod-deploy-ws.yml @@ -6,6 +6,17 @@ on: workflow_dispatch: jobs: + test_ws: + strategy: + matrix: + 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' + secrets: inherit + # This workflow contains a single job called "build" build_prod_image: # The type of runner that the job will run on diff --git a/.github/workflows/reusable-api-e2e.yml b/.github/workflows/reusable-api-e2e.yml index 75387096764..47b0a501d68 100644 --- a/.github/workflows/reusable-api-e2e.yml +++ b/.github/workflows/reusable-api-e2e.yml @@ -15,6 +15,11 @@ on: 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: @@ -39,15 +44,13 @@ jobs: else echo ::set-output has_token=false fi - # checkout with submodules if token is provided - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token == 'true' + # checkout with submodules if token is provided + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ inputs.submodules }} - token: ${{ secrets.SUBMODULES_TOKEN }} - # checkout without submodules - - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token != 'true' + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} - uses: ./.github/actions/setup-project - uses: ./.github/actions/setup-redis-cluster - uses: mansagroup/nrwl-nx-action@v3 diff --git a/.github/workflows/reusable-inbound-mail-e2e.yml b/.github/workflows/reusable-inbound-mail-e2e.yml new file mode 100644 index 00000000000..efb72234c81 --- /dev/null +++ b/.github/workflows/reusable-inbound-mail-e2e.yml @@ -0,0 +1,59 @@ +name: E2E Inbound Mail 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: + # This workflow contains a single job called "build" + e2e_inbound_mail: + # 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: + # checkout with submodules if token is provided + - uses: actions/checkout@v3 + - uses: ./.github/actions/checkout-submodules + with: + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} + - uses: ./.github/actions/setup-project + - uses: ./.github/actions/setup-redis-cluster + - uses: mansagroup/nrwl-nx-action@v3 + with: + targets: lint + projects: "@novu/inbound-mail" + + # Runs a single command using the runners shell + - name: Build Inbound Mail + run: CI='' pnpm build:inbound-mail + + - name: Run unit tests + run: | + cd apps/inbound-mail && pnpm test diff --git a/.github/workflows/reusable-web-e2e.yml b/.github/workflows/reusable-web-e2e.yml index 66a4ec29f99..0e0a39c0654 100644 --- a/.github/workflows/reusable-web-e2e.yml +++ b/.github/workflows/reusable-web-e2e.yml @@ -11,6 +11,11 @@ on: 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: @@ -45,15 +50,13 @@ jobs: echo ::set-output has_token=false fi - # checkout with submodules if token is provided - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token == 'true' + # checkout with submodules if token is provided + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ inputs.submodules }} - token: ${{ secrets.SUBMODULES_TOKEN }} - # checkout without submodules - - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token != 'true' + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} - uses: ./.github/actions/setup-project id: setup-project with: diff --git a/.github/workflows/reusable-widget-e2e.yml b/.github/workflows/reusable-widget-e2e.yml index 1ba8572ea9a..3c62fe5954e 100644 --- a/.github/workflows/reusable-widget-e2e.yml +++ b/.github/workflows/reusable-widget-e2e.yml @@ -10,6 +10,11 @@ on: 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,15 +49,13 @@ jobs: else echo ::set-output has_token=false fi - # checkout with submodules if token is provided - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token == 'true' + # checkout with submodules if token is provided + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ inputs.submodules }} - token: ${{ secrets.SUBMODULES_TOKEN }} - # checkout without submodules - - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token != 'true' + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} - uses: ./.github/actions/setup-project id: setup-project diff --git a/.github/workflows/reusable-worker-e2e.yml b/.github/workflows/reusable-worker-e2e.yml index fe8c4063b59..a2ee98573d7 100644 --- a/.github/workflows/reusable-worker-e2e.yml +++ b/.github/workflows/reusable-worker-e2e.yml @@ -15,6 +15,11 @@ on: 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: @@ -39,16 +44,15 @@ jobs: else echo ::set-output has_token=false fi - # checkout with submodules if token is provided - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token == 'true' + # checkout with submodules if token is provided + - uses: ./.github/actions/checkout-submodules with: - submodules: ${{ inputs.submodules }} - token: ${{ secrets.SUBMODULES_TOKEN }} - # checkout without submodules - - uses: actions/checkout@v3 - if: steps.setup.outputs.has_token != 'true' + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} - uses: ./.github/actions/setup-project + - uses: ./.github/actions/setup-redis-cluster - uses: mansagroup/nrwl-nx-action@v3 with: diff --git a/.github/workflows/reusable-ws-e2e.yml b/.github/workflows/reusable-ws-e2e.yml index 823621c819a..33d81b4fc10 100644 --- a/.github/workflows/reusable-ws-e2e.yml +++ b/.github/workflows/reusable-ws-e2e.yml @@ -14,6 +14,11 @@ on: 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: @@ -31,10 +36,20 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - # checkout with submodules if token is provided - - uses: actions/checkout@v3 - # checkout without submodules + - id: setup + run: | + if ! [[ -z "${{ secrets.SUBMODULES_TOKEN }}" ]]; then + echo ::set-output has_token=true + else + echo ::set-output has_token=false + fi - uses: actions/checkout@v3 + # checkout with submodules if token is provided + - uses: ./.github/actions/checkout-submodules + with: + enabled: ${{ steps.setup.outputs.has_token == 'true' }} + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: ${{ inputs.submodule_branch }} - uses: ./.github/actions/setup-project - uses: mansagroup/nrwl-nx-action@v3 with: @@ -46,7 +61,5 @@ jobs: run: CI='' pnpm build:ws - name: Run unit tests - env: - IN_MEMORY_CLUSTER_MODE_ENABLED: false run: | cd apps/ws && pnpm test diff --git a/.github/workflows/staging-deploy-web.yml b/.github/workflows/staging-deploy-web.yml new file mode 100644 index 00000000000..310ea5f06f5 --- /dev/null +++ b/.github/workflows/staging-deploy-web.yml @@ -0,0 +1,129 @@ +# This is a basic workflow to help you get started with Actions + +name: Deploy Staging WEB + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + workflow_dispatch: + push: + branches: + - next + - main + paths: + - 'apps/web/**' + - 'libs/shared/**' + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + test_web: + uses: ./.github/workflows/reusable-web-e2e.yml + with: + submodules: 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 "::set-output name=IMAGE::ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" + + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: cypress-screenshots + path: apps/web/cypress/screenshots diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b62c80d5d14..713ede9b88a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ jobs: outputs: test-unit: ${{ steps.get-projects-arrays.outputs.test-unit }} test-e2e: ${{ steps.get-projects-arrays.outputs.test-e2e }} + test-e2e-ee: ${{ steps.get-projects-arrays.outputs.test-e2e-ee }} test-cypress: ${{ steps.get-projects-arrays.outputs.test-cypress }} test-providers: ${{ steps.get-projects-arrays.outputs.test-providers }} test-packages: ${{ steps.get-projects-arrays.outputs.test-packages }} @@ -73,6 +74,7 @@ jobs: echo "Running ALL" echo "::set-output name=test-unit::$(pnpm run get-affected test:unit --all | tail -n +5)" echo "::set-output name=test-e2e::$(pnpm run get-affected test:e2e --all | tail -n +5)" + echo "::set-output name=test-e2e-ee::$(pnpm run get-affected test:e2e:ee --all | tail -n +5)" echo "::set-output name=test-cypress::$(pnpm run get-affected cypress:run --all | tail -n +5)" echo "::set-output name=test-providers::$(pnpm run get-affected test --all providers | tail -n +5)" echo "::set-output name=test-packages::$(pnpm run get-affected test --all packages | tail -n +5)" @@ -81,6 +83,7 @@ jobs: echo "Running PR origin/${{steps.get-base-branch-name.outputs.branch}}" echo "::set-output name=test-unit::$(pnpm run get-affected test origin/${{steps.get-base-branch-name.outputs.branch}} | tail -n +5)" echo "::set-output name=test-e2e::$(pnpm run get-affected test:e2e origin/${{steps.get-base-branch-name.outputs.branch}} | tail -n +5)" + echo "::set-output name=test-e2e-ee::$(pnpm run get-affected test:e2e:ee origin/${{steps.get-base-branch-name.outputs.branch}} | tail -n +5)" echo "::set-output name=test-cypress::$(pnpm run get-affected cypress:run origin/${{steps.get-base-branch-name.outputs.branch}} | tail -n +5)" echo "::set-output name=test-providers::$(pnpm run get-affected test origin/${{steps.get-base-branch-name.outputs.branch}} providers | tail -n +5)" echo "::set-output name=test-packages::$(pnpm run get-affected test origin/${{steps.get-base-branch-name.outputs.branch}} packages | tail -n +5)" @@ -224,6 +227,54 @@ jobs: targets: test:e2e projects: ${{matrix.projectName}} + test_e2e_ee: + name: Test E2E EE + runs-on: ubuntu-latest + needs: [get-affected] + if: ${{ fromJson(needs.get-affected.outputs.test-e2e-ee)[0] }} + 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/checkout-submodules + with: + submodule_token: ${{ secrets.SUBMODULES_TOKEN }} + submodule_branch: "next" + - 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}} + test_unit: name: Unit Test runs-on: ubuntu-latest @@ -245,7 +296,7 @@ jobs: - uses: ./.github/actions/setup-project with: # Don't run redis and etc... for other unit tests - slim: ${{ !contains(matrix.projectName, '@novu/api') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') }} + slim: ${{ !contains(matrix.projectName, '@novu/api') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') && !contains(matrix.projectName, '@novu/inbound-mail')}} - uses: ./.github/actions/setup-redis-cluster - uses: mansagroup/nrwl-nx-action@v3 name: Lint and build and test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..a258b6c0cb7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "enterprise/packages"] + path = enterprise/packages + url = git@github.com:novuhq/packages-enterprise.git + branch = next diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 458e1e42dcb..314f5cd7912 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -18,5 +18,6 @@ + diff --git a/apps/api/package.json b/apps/api/package.json index 9d63926080c..cd2aa83225a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "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" @@ -110,6 +111,12 @@ "tsconfig-paths": "~4.1.0", "typescript": "4.9.5" }, + "optionalDependencies": { + "@novu/ee-auth": "^0.19.0" + }, + "nx": { + "implicitDependencies": ["@novu/ee-auth"] + }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint" diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 94d54b57fcf..00418026038 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -32,7 +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'; -const modules: Array | ForwardReference> = [ +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); + } + } catch (e) { + Logger.error(e, `Unexpected error while importing enterprise modules`, 'EnterpriseImport'); + } + + return modules; +}; + +const baseModules: Array | ForwardReference> = [ InboundParseModule, OrganizationModule, SharedModule, @@ -61,6 +74,10 @@ const modules: Array | ForwardRefe TenantModule, ]; +const enterpriseModules = enterpriseImports(); + +const modules = baseModules.concat(enterpriseModules); + const providers: Provider[] = []; if (process.env.SENTRY_DSN) { diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index e0bb9f0c768..e4a5ec4a1af 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -20,7 +20,6 @@ import { MemberRepository, OrganizationRepository, UserRepository, MemberEntity import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; import { IJwtPayload } from '@novu/shared'; -import { AuthService } from './services/auth.service'; import { UserRegistrationBodyDto } from './dtos/user-registration.dto'; import { UserRegister } from './usecases/register/user-register.usecase'; import { UserRegisterCommand } from './usecases/register/user-register.command'; @@ -28,10 +27,6 @@ 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 { SwitchEnvironment } from './usecases/switch-environment/switch-environment.usecase'; -import { SwitchEnvironmentCommand } from './usecases/switch-environment/switch-environment.command'; -import { SwitchOrganization } from './usecases/switch-organization/switch-organization.usecase'; -import { SwitchOrganizationCommand } from './usecases/switch-organization/switch-organization.command'; import { JwtAuthGuard } from './framework/auth.guard'; import { PasswordResetRequestCommand } from './usecases/password-reset-request/password-reset-request.command'; import { PasswordResetRequest } from './usecases/password-reset-request/password-reset-request.usecase'; @@ -40,6 +35,14 @@ import { PasswordReset } from './usecases/password-reset/password-reset.usecase' import { ApiException } from '../shared/exceptions/api.exception'; import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; import { PasswordResetBodyDto } from './dtos/password-reset.dto'; +import { + AuthService, + buildOauthRedirectUrl, + SwitchEnvironment, + SwitchEnvironmentCommand, + SwitchOrganization, + SwitchOrganizationCommand, +} from '@novu/application-generic'; @Controller('/auth') @UseInterceptors(ClassSerializerInterceptor) @@ -80,50 +83,7 @@ export class AuthController { @Get('/github/callback') @UseGuards(AuthGuard('github')) async githubCallback(@Req() request, @Res() response) { - if (!request.user || !request.user.token) { - return response.redirect(`${process.env.FRONT_BASE_URL + '/auth/login'}?error=AuthenticationError`); - } - - let url = process.env.FRONT_BASE_URL + '/auth/login'; - const redirectUrl = JSON.parse(request.query.state).redirectUrl; - - /** - * Make sure we only allow localhost redirects for CLI use and our own success route - * https://github.com/novuhq/novu/security/code-scanning/3 - */ - if (redirectUrl && redirectUrl.startsWith('http://localhost:') && !redirectUrl.includes('@')) { - url = redirectUrl; - } - - url += `?token=${request.user.token}`; - - if (request.user.newUser) { - url += '&newUser=true'; - } - - /** - * partnerCode, next and configurationId are required during external partners integration - * such as vercel integration etc - */ - const partnerCode = JSON.parse(request.query.state).partnerCode; - if (partnerCode) { - url += `&code=${partnerCode}`; - } - - const next = JSON.parse(request.query.state).next; - if (next) { - url += `&next=${next}`; - } - - const configurationId = JSON.parse(request.query.state).configurationId; - if (configurationId) { - url += `&configurationId=${configurationId}`; - } - - const invitationToken = JSON.parse(request.query.state).invitationToken; - if (invitationToken) { - url += `&invitationToken=${invitationToken}`; - } + const url = buildOauthRedirectUrl(request); return response.redirect(url); } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index a971cfe1811..4294e22ab64 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,12 +1,15 @@ import { MiddlewareConsumer, Module, NestModule, Provider, RequestMethod } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; -import { PassportModule, PassportStrategy } from '@nestjs/passport'; +import { PassportModule } from '@nestjs/passport'; import * as passport from 'passport'; + +import { AuthProviderEnum } from '@novu/shared'; +import { AuthService } from '@novu/application-generic'; + import { RolesGuard } from './framework/roles.guard'; import { JwtStrategy } from './services/passport/jwt.strategy'; import { AuthController } from './auth.controller'; import { UserModule } from '../user/user.module'; -import { AuthService } from './services/auth.service'; import { USE_CASES } from './usecases'; import { SharedModule } from '../shared/shared.module'; import { GitHubStrategy } from './services/passport/github.strategy'; @@ -56,7 +59,7 @@ export class AuthModule implements NestModule { if (process.env.GITHUB_OAUTH_CLIENT_ID) { consumer .apply( - passport.authenticate('github', { + passport.authenticate(AuthProviderEnum.GITHUB, { session: false, scope: ['user:email'], }) diff --git a/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts b/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts new file mode 100644 index 00000000000..6b9306e3131 --- /dev/null +++ b/apps/api/src/app/auth/e2e/oauth.e2e-ee.ts @@ -0,0 +1,19 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +describe('User login - /auth/google (GET)', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should redirect to google oauth', async () => { + const res = await session.testAgent.get('/v1/auth/google').send(); + + expect(res.statusCode).to.equal(302); + expect(res.headers.location).to.contain('accounts.google.com/o/oauth2/v2/auth'); + }); +}); diff --git a/apps/api/src/app/auth/usecases/switch-environment/switch-environment.e2e.ts b/apps/api/src/app/auth/e2e/switch-environment.e2e.ts similarity index 100% rename from apps/api/src/app/auth/usecases/switch-environment/switch-environment.e2e.ts rename to apps/api/src/app/auth/e2e/switch-environment.e2e.ts diff --git a/apps/api/src/app/auth/framework/auth.guard.ts b/apps/api/src/app/auth/framework/auth.guard.ts index c20ec07b2af..bd8ae0e96cf 100644 --- a/apps/api/src/app/auth/framework/auth.guard.ts +++ b/apps/api/src/app/auth/framework/auth.guard.ts @@ -1,8 +1,7 @@ import { ExecutionContext, forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Reflector } from '@nestjs/core'; -import { AuthService } from '../services/auth.service'; -import { Instrument, PinoLogger } from '@novu/application-generic'; +import { AuthService, Instrument, PinoLogger } from '@novu/application-generic'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { 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 7ba934d1cee..25f475d9195 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 @@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext, forwardRef, Inject, Injectable, Unauthor import { Reflector } from '@nestjs/core'; import { IJwtPayload } from '@novu/shared'; import * as jwt from 'jsonwebtoken'; -import { AuthService } from '../services/auth.service'; +import { AuthService } from '@novu/application-generic'; @Injectable() export class RootEnvironmentGuard implements CanActivate { diff --git a/apps/api/src/app/auth/services/passport/github.strategy.ts b/apps/api/src/app/auth/services/passport/github.strategy.ts index c0ee2e01097..25cc0ff1ede 100644 --- a/apps/api/src/app/auth/services/passport/github.strategy.ts +++ b/apps/api/src/app/auth/services/passport/github.strategy.ts @@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport'; import * as githubPassport from 'passport-github2'; import { Metadata, StateStoreStoreCallback, StateStoreVerifyCallback } from 'passport-oauth2'; import { AuthProviderEnum } from '@novu/shared'; -import { AuthService } from '../auth.service'; +import { AuthService } from '@novu/application-generic'; @Injectable() export class GitHubStrategy extends PassportStrategy(githubPassport.Strategy, 'github') { 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 d5e1776921a..bd912e1f4a9 100644 --- a/apps/api/src/app/auth/services/passport/jwt.strategy.ts +++ b/apps/api/src/app/auth/services/passport/jwt.strategy.ts @@ -2,8 +2,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { IJwtPayload } from '@novu/shared'; -import { AuthService } from '../auth.service'; -import { Instrument, PinoLogger } from '@novu/application-generic'; +import { AuthService, Instrument, PinoLogger } from '@novu/application-generic'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { diff --git a/apps/api/src/app/auth/services/passport/subscriber-jwt.strategy.ts b/apps/api/src/app/auth/services/passport/subscriber-jwt.strategy.ts index 46b4ddfe894..81cec4af243 100644 --- a/apps/api/src/app/auth/services/passport/subscriber-jwt.strategy.ts +++ b/apps/api/src/app/auth/services/passport/subscriber-jwt.strategy.ts @@ -2,7 +2,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ISubscriberJwt } from '@novu/shared'; -import { AuthService } from '../auth.service'; +import { AuthService } from '@novu/application-generic'; @Injectable() export class JwtSubscriberStrategy extends PassportStrategy(Strategy, 'subscriberJwt') { diff --git a/apps/api/src/app/auth/usecases/index.ts b/apps/api/src/app/auth/usecases/index.ts index d214c8bd217..0eee9a99b7e 100644 --- a/apps/api/src/app/auth/usecases/index.ts +++ b/apps/api/src/app/auth/usecases/index.ts @@ -1,8 +1,8 @@ +import { SwitchEnvironment, SwitchOrganization } from '@novu/application-generic'; + import { PasswordResetRequest } from './password-reset-request/password-reset-request.usecase'; import { UserRegister } from './register/user-register.usecase'; import { Login } from './login/login.usecase'; -import { SwitchEnvironment } from './switch-environment/switch-environment.usecase'; -import { SwitchOrganization } from './switch-organization/switch-organization.usecase'; import { PasswordReset } from './password-reset/password-reset.usecase'; export const USE_CASES = [ diff --git a/apps/api/src/app/auth/usecases/login/login.usecase.ts b/apps/api/src/app/auth/usecases/login/login.usecase.ts index 43e96198612..13c472cfb10 100644 --- a/apps/api/src/app/auth/usecases/login/login.usecase.ts +++ b/apps/api/src/app/auth/usecases/login/login.usecase.ts @@ -2,12 +2,11 @@ import * as bcrypt from 'bcrypt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { differenceInMinutes, parseISO } from 'date-fns'; import { UserRepository, UserEntity, OrganizationRepository } from '@novu/dal'; -import { AnalyticsService } from '@novu/application-generic'; +import { AnalyticsService, AuthService } from '@novu/application-generic'; import { LoginCommand } from './login.command'; import { ApiException } from '../../../shared/exceptions/api.exception'; import { normalizeEmail } from '../../../shared/helpers/email-normalization.service'; -import { AuthService } from '../../services/auth.service'; import { createHash } from '../../../shared/helpers/hmac.service'; @Injectable() diff --git a/apps/api/src/app/auth/usecases/password-reset/password-reset.usecase.ts b/apps/api/src/app/auth/usecases/password-reset/password-reset.usecase.ts index b62073106a8..c580912fd9b 100644 --- a/apps/api/src/app/auth/usecases/password-reset/password-reset.usecase.ts +++ b/apps/api/src/app/auth/usecases/password-reset/password-reset.usecase.ts @@ -2,11 +2,10 @@ import { Injectable } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { isBefore, subDays } from 'date-fns'; import { UserRepository } from '@novu/dal'; -import { buildUserKey, InvalidateCacheService } from '@novu/application-generic'; +import { AuthService, buildUserKey, InvalidateCacheService } from '@novu/application-generic'; import { PasswordResetCommand } from './password-reset.command'; import { ApiException } from '../../../shared/exceptions/api.exception'; -import { AuthService } from '../../services/auth.service'; @Injectable() export class PasswordReset { 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 544086fc188..b6b5df585c5 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 @@ -2,9 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { OrganizationEntity, UserRepository } from '@novu/dal'; import * as bcrypt from 'bcrypt'; import { SignUpOriginEnum } from '@novu/shared'; -import { AnalyticsService } from '@novu/application-generic'; +import { AnalyticsService, AuthService } from '@novu/application-generic'; -import { AuthService } from '../../services/auth.service'; import { UserRegisterCommand } from './user-register.command'; import { normalizeEmail } from '../../../shared/helpers/email-normalization.service'; import { ApiException } from '../../../shared/exceptions/api.exception'; diff --git a/apps/api/src/app/invites/usecases/accept-invite/accept-invite.usecase.ts b/apps/api/src/app/invites/usecases/accept-invite/accept-invite.usecase.ts index aca16ab5142..f474fb43ce3 100644 --- a/apps/api/src/app/invites/usecases/accept-invite/accept-invite.usecase.ts +++ b/apps/api/src/app/invites/usecases/accept-invite/accept-invite.usecase.ts @@ -1,10 +1,12 @@ import { Injectable, Logger, NotFoundException, Scope } from '@nestjs/common'; + import { MemberEntity, OrganizationRepository, UserEntity, MemberRepository, UserRepository } from '@novu/dal'; import { MemberStatusEnum } from '@novu/shared'; import { Novu } from '@novu/node'; +import { AuthService } from '@novu/application-generic'; + import { ApiException } from '../../../shared/exceptions/api.exception'; import { AcceptInviteCommand } from './accept-invite.command'; -import { AuthService } from '../../../auth/services/auth.service'; import { capitalize } from '../../../shared/services/helper/helper.service'; @Injectable({ diff --git a/apps/api/src/app/tenant/tenant.controller.ts b/apps/api/src/app/tenant/tenant.controller.ts index 444e78a129f..787971f4345 100644 --- a/apps/api/src/app/tenant/tenant.controller.ts +++ b/apps/api/src/app/tenant/tenant.controller.ts @@ -55,7 +55,6 @@ import { @ApiTags('Tenants') @UseInterceptors(ClassSerializerInterceptor) @UseGuards(JwtAuthGuard) -@ApiExcludeController() export class TenantController { constructor( private createTenantUsecase: CreateTenant, diff --git a/apps/api/src/app/testing/usecases/seed-data/seed-data.usecase.ts b/apps/api/src/app/testing/usecases/seed-data/seed-data.usecase.ts index 944ae09052a..891ffe7f277 100644 --- a/apps/api/src/app/testing/usecases/seed-data/seed-data.usecase.ts +++ b/apps/api/src/app/testing/usecases/seed-data/seed-data.usecase.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { faker } from '@faker-js/faker'; + +import { AuthService } from '@novu/application-generic'; + import { SeedDataCommand } from './seed-data.command'; -import { AuthService } from '../../../auth/services/auth.service'; import { UserRegister } from '../../../auth/usecases/register/user-register.usecase'; import { UserRegisterCommand } from '../../../auth/usecases/register/user-register.command'; import { ApiException } from '../../../shared/exceptions/api.exception'; diff --git a/apps/api/src/app/user/usecases/index.ts b/apps/api/src/app/user/usecases/index.ts index 96df2695704..54000735076 100644 --- a/apps/api/src/app/user/usecases/index.ts +++ b/apps/api/src/app/user/usecases/index.ts @@ -1,4 +1,5 @@ -import { CreateUser } from './create-user/create-user.usecase'; +import { CreateUser } from '@novu/application-generic'; + import { GetMyProfileUsecase } from './get-my-profile/get-my-profile.usecase'; import { UpdateOnBoardingUsecase } from './update-on-boarding/update-on-boarding.usecase'; import { UpdateOnBoardingTourUsecase } from './update-on-boarding-tour/update-on-boarding-tour.usecase'; 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 new file mode 100644 index 00000000000..c0c464a0457 --- /dev/null +++ b/apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts @@ -0,0 +1,142 @@ +import axios from 'axios'; +import { expect } from 'chai'; +import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; +import { UserSession } from '@novu/testing'; +import { ChannelTypeEnum } from '@novu/shared'; + +describe('Unread Count - GET /widget/notifications/unread', function () { + const messageRepository = new MessageRepository(); + let session: UserSession; + let template: NotificationTemplateEntity; + let subscriberId: string; + let subscriberToken: string; + let subscriberProfile: { + _id: string; + } | null = null; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + subscriberId = SubscriberRepository.createObjectId(); + + template = await session.createTemplate({ + noFeedId: true, + }); + + const { body } = await session.testAgent + .post('/v1/widgets/session/initialize') + .send({ + applicationIdentifier: session.environment.identifier, + subscriberId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + }) + .expect(201); + + const { token, profile } = body.data; + + subscriberToken = token; + subscriberProfile = profile; + }); + + it('should return unread count with no query', async function () { + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + + await session.awaitRunningJobs(template._id); + + const messages = await messageRepository.findBySubscriberChannel( + session.environment._id, + subscriberProfile!._id, + ChannelTypeEnum.IN_APP + ); + const messageId = messages[0]._id; + expect(messages[0].read).to.equal(false); + + await axios.post( + `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`, + { messageId, mark: { read: true } }, + { + headers: { + Authorization: `Bearer ${subscriberToken}`, + }, + } + ); + + const unreadFeed = await getUnreadCount(); + expect(unreadFeed.data.count).to.equal(2); + }); + + it('should return unread count with query read false', async function () { + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + + await session.awaitRunningJobs(template._id); + + const messages = await messageRepository.findBySubscriberChannel( + session.environment._id, + subscriberProfile!._id, + ChannelTypeEnum.IN_APP + ); + const messageId = messages[0]._id; + expect(messages[0].read).to.equal(false); + + await axios.post( + `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`, + { messageId, mark: { read: true } }, + { + headers: { + Authorization: `Bearer ${subscriberToken}`, + }, + } + ); + + const unreadFeed = await getUnreadCount({ read: false }); + expect(unreadFeed.data.count).to.equal(2); + }); + + it('should return unread count with query read true', async function () { + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + await session.triggerEvent(template.triggers[0].identifier, subscriberId); + + await session.awaitRunningJobs(template._id); + + const messages = await messageRepository.findBySubscriberChannel( + session.environment._id, + subscriberProfile!._id, + ChannelTypeEnum.IN_APP + ); + const messageId = messages[0]._id; + expect(messages[0].read).to.equal(false); + + await axios.post( + `http://localhost:${process.env.PORT}/v1/widgets/messages/markAs`, + { messageId, mark: { read: true } }, + { + headers: { + Authorization: `Bearer ${subscriberToken}`, + }, + } + ); + + const readFeed = await getUnreadCount({ read: true }); + expect(readFeed.data.count).to.equal(1); + }); + + async function getUnreadCount(query = {}) { + const response = await axios.get(`http://localhost:${process.env.PORT}/v1/widgets/notifications/unread`, { + params: { + ...query, + }, + headers: { + Authorization: `Bearer ${subscriberToken}`, + }, + }); + + return response.data; + } +}); diff --git a/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts index 9682d8a53da..80027dabef8 100644 --- a/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts +++ b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts @@ -8,9 +8,9 @@ import { CreateSubscriberCommand, SelectIntegrationCommand, SelectIntegration, + AuthService, } from '@novu/application-generic'; -import { AuthService } from '../../../auth/services/auth.service'; import { ApiException } from '../../../shared/exceptions/api.exception'; import { InitializeSessionCommand } from './initialize-session.command'; diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts index f1dff06ba80..decc1d30260 100644 --- a/apps/api/src/app/widgets/widgets.controller.ts +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -154,6 +154,10 @@ export class WidgetsController { ): Promise { const feedsQuery = this.toArray(feedId); + if (read === undefined) { + read = false; + } + const command = GetFeedCountCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, diff --git a/apps/inbound-mail/e2e/setup.ts b/apps/inbound-mail/e2e/setup.ts new file mode 100644 index 00000000000..678749598e1 --- /dev/null +++ b/apps/inbound-mail/e2e/setup.ts @@ -0,0 +1,16 @@ +import * as sinon from 'sinon'; +import { testServer } from '@novu/testing'; + +import mailin from '../src/main'; + +before(async () => { + await testServer.create(mailin); +}); + +after(async () => { + await testServer.teardown(); +}); + +afterEach(() => { + sinon.restore(); +}); diff --git a/apps/inbound-mail/package.json b/apps/inbound-mail/package.json index 534c641c30e..c6b33eddec1 100644 --- a/apps/inbound-mail/package.json +++ b/apps/inbound-mail/package.json @@ -16,7 +16,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "echo \"No e2e tests, only build tests\"" + "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.19.0", @@ -31,21 +31,27 @@ "lodash": "^4.17.15", "mailparser": "^0.6.0", "newrelic": "^9.15.0", - "node-uuid": "^1.4.3", "rimraf": "^3.0.2", "shelljs": "^0.8.5", "smtp-server": "^1.4.0", "spamc": "0.0.5", - "winston": "^1.0.0" + "uuid": "^9.0.0", + "winston": "^3.9.0" }, "devDependencies": { + "@novu/testing": "^0.19.0", + "@types/chai": "^4.2.11", "@types/express": "^4.17.8", "@types/html-to-text": "^9.0.1", + "@types/mocha": "^8.2.3", "@types/node": "^14.14.6", + "@types/sinon": "^9.0.0", "@types/smtp-server": "^3.5.7", "cross-env": "^7.0.3", + "mocha": "^8.4.0", "nodemon": "^2.0.7", "prettier": "~2.8.0", + "sinon": "^9.2.4", "ts-jest": "^27.0.7", "ts-loader": "~9.4.0", "ts-node": "~10.9.1", diff --git a/apps/inbound-mail/src/.env.development b/apps/inbound-mail/src/.env.development index 23c944394cf..5ed27ba166f 100644 --- a/apps/inbound-mail/src/.env.development +++ b/apps/inbound-mail/src/.env.development @@ -4,9 +4,22 @@ POST="25" HOST="0.0.0.0" -REDIS_DB_INDEX="2" -REDIS_HOST="localhost" REDIS_PORT="6379" +REDIS_HOST="localhost" +REDIS_PREFIX= +REDIS_DB_INDEX="2" + + +IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false +REDIS_CLUSTER_SERVICE_HOST=localhost +REDIS_CLUSTER_SERVICE_PORTS=[7000,7001,7002,7003,7004,7005] +REDIS_CLUSTER_DB_INDEX= +REDIS_CLUSTER_TTL= +REDIS_CLUSTER_PASSWORD= +REDIS_CLUSTER_CONNECTION_TIMEOUT= +REDIS_CLUSTER_KEEP_ALIVE= +REDIS_CLUSTER_FAMILY= +REDIS_CLUSTER_KEY_PREFIX= NEW_RELIC_ENABLED=true NEW_RELIC_APP_NAME="[DEV] - INBOUND-MAIL" diff --git a/apps/inbound-mail/src/.env.production b/apps/inbound-mail/src/.env.production index afaa20521b5..eab410478bc 100644 --- a/apps/inbound-mail/src/.env.production +++ b/apps/inbound-mail/src/.env.production @@ -3,9 +3,21 @@ NODE_ENV=production POST="25" HOST="0.0.0.0" -REDIS_DB_INDEX="2" REDIS_HOST="localhost" -REDIS_PORT="6379" +REDIS_PORT=6379 +REDIS_PREFIX= +REDIS_DB_INDEX=2 + +IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false +REDIS_CLUSTER_SERVICE_HOST= +REDIS_CLUSTER_SERVICE_PORT= +REDIS_CLUSTER_DB_INDEX= +REDIS_CLUSTER_TTL= +REDIS_CLUSTER_PASSWORD= +REDIS_CLUSTER_CONNECTION_TIMEOUT= +REDIS_CLUSTER_KEEP_ALIVE= +REDIS_CLUSTER_FAMILY= +REDIS_CLUSTER_KEY_PREFIX= NEW_RELIC_ENABLED=true NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED=true diff --git a/apps/inbound-mail/src/.env.test b/apps/inbound-mail/src/.env.test index a73677622fc..8f58f5f941d 100644 --- a/apps/inbound-mail/src/.env.test +++ b/apps/inbound-mail/src/.env.test @@ -1,11 +1,23 @@ NODE_ENV=test -POST="25" +PORT="2500" HOST="0.0.0.0" -REDIS_DB_INDEX="2" -REDIS_HOST="localhost" -REDIS_PORT="6379" +REDIS_DB_INDEX=2 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PREFIX= -LOGGING_LEVEL=info +IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false +REDIS_CLUSTER_SERVICE_HOST=localhost +REDIS_CLUSTER_SERVICE_PORTS=[7000,7001,7002,7003,7004,7005] +REDIS_CLUSTER_DB_INDEX= +REDIS_CLUSTER_TTL= +REDIS_CLUSTER_PASSWORD= +REDIS_CLUSTER_CONNECTION_TIMEOUT= +REDIS_CLUSTER_KEEP_ALIVE= +REDIS_CLUSTER_FAMILY= +REDIS_CLUSTER_KEY_PREFIX= + +LOGGING_LEVEL=error diff --git a/apps/inbound-mail/src/main.ts b/apps/inbound-mail/src/main.ts index b2cb5365c29..7609617b97c 100644 --- a/apps/inbound-mail/src/main.ts +++ b/apps/inbound-mail/src/main.ts @@ -7,6 +7,8 @@ import logger from './server/logger'; import * as Sentry from '@sentry/node'; import { version } from '../package.json'; +const LOG_CONTEXT = 'Main'; + const env = process.env; if (process.env.SENTRY_DSN) { @@ -17,7 +19,7 @@ if (process.env.SENTRY_DSN) { }); } -mailin.start( +export default mailin.start( { port: env.PORT || 25, host: env.HOST || '0.0.0.0', diff --git a/apps/inbound-mail/src/server/inbound-mail-queue.service.ts b/apps/inbound-mail/src/server/inbound-mail-queue.service.ts deleted file mode 100644 index 6501e479c03..00000000000 --- a/apps/inbound-mail/src/server/inbound-mail-queue.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BullMqService, QueueBaseOptions } from '@novu/application-generic'; -import { JobTopicNameEnum } from '@novu/shared'; - -export class InboundMailQueueService { - public readonly DEFAULT_ATTEMPTS = 5; - public readonly name = JobTopicNameEnum.INBOUND_PARSE_MAIL; - public readonly bullMqService: BullMqService; - - private bullConfig: QueueBaseOptions = { - connection: { - db: Number(process.env.REDIS_DB_INDEX) ?? 2, - port: Number(process.env.REDIS_PORT) ?? 6379, - keyPrefix: process.env.REDIS_PREFIX ?? '', - }, - }; - - constructor() { - this.bullMqService = new BullMqService(); - this.bullMqService.createQueue(this.name, { - ...this.bullConfig, - defaultJobOptions: { - removeOnComplete: true, - attempts: this.DEFAULT_ATTEMPTS, - backoff: { - type: 'exponential', - delay: 4000, - }, - }, - }); - } -} diff --git a/apps/inbound-mail/src/server/inbound-mail.service.spec.ts b/apps/inbound-mail/src/server/inbound-mail.service.spec.ts new file mode 100644 index 00000000000..f0e1a674848 --- /dev/null +++ b/apps/inbound-mail/src/server/inbound-mail.service.spec.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai'; + +import { InboundMailService } from './inbound-mail.service'; + +let inboundMailService: InboundMailService; + +describe('Inbound Mail Service', () => { + describe('Non Cluster mode', () => { + before(async () => { + process.env.IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; + + inboundMailService = new InboundMailService(); + await inboundMailService.inboundParseQueueService.queue.obliterate(); + }); + + beforeEach(async () => { + await inboundMailService.inboundParseQueueService.queue.drain(); + }); + + after(async () => { + await inboundMailService.inboundParseQueueService.gracefulShutdown(); + }); + + 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({ + queueIsPaused: false, + queueName: 'inbound-parse-mail', + workerName: undefined, + workerIsPaused: undefined, + workerIsRunning: undefined, + }); + expect(await inboundMailService.inboundParseQueueService.queue.isPaused()).to.equal(false); + expect(inboundMailService.inboundParseQueueService.queue).to.deep.include({ + _events: {}, + _eventsCount: 0, + _maxListeners: undefined, + name: 'inbound-parse-mail', + jobsOpts: { + attempts: 5, + backoff: { + delay: 4000, + type: 'exponential', + }, + removeOnComplete: true, + removeOnFail: true, + }, + }); + expect(inboundMailService.inboundParseQueueService.queue.opts.prefix).to.equal('bull'); + }); + + it('should add a job in the queue', async () => { + const jobId = 'inbound-mail-parse-job-id'; + const _environmentId = 'inbound-mail-parse-environment-id'; + 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, + }; + 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', + name: jobId, + data: jobData, + attemptsMade: 0, + }); + }); + + 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); + + const inboundParseQueueServiceQueueJobs = await inboundMailService.inboundParseQueueService.queue.getJobs(); + expect(inboundParseQueueServiceQueueJobs.length).to.equal(1); + const [inboundParseQueueServiceQueueJob] = inboundParseQueueServiceQueueJobs; + expect(inboundParseQueueServiceQueueJob).to.deep.include({ + id: '2', + name: jobId, + data: { + _id: jobId, + _environmentId, + _organizationId, + _userId, + }, + attemptsMade: 0, + }); + }); + }); + + describe('Cluster mode', () => { + beforeEach(async () => { + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + + inboundMailService = new InboundMailService(); + await inboundMailService.inboundParseQueueService.queue.obliterate(); + }); + + afterEach(async () => { + await inboundMailService.inboundParseQueueService.gracefulShutdown(); + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; + }); + + it('should have prefix in cluster mode', async () => { + expect(inboundMailService.inboundParseQueueService.queue.opts.prefix).to.equal('{inbound-parse-mail}'); + }); + }); +}); diff --git a/apps/inbound-mail/src/server/inbound-mail.service.ts b/apps/inbound-mail/src/server/inbound-mail.service.ts new file mode 100644 index 00000000000..c9c6911b96a --- /dev/null +++ b/apps/inbound-mail/src/server/inbound-mail.service.ts @@ -0,0 +1,14 @@ +import { BullMqService, InboundParseQueue, QueueBaseOptions } from '@novu/application-generic'; +import { JobTopicNameEnum } from '@novu/shared'; + +export class InboundMailService { + private inboundParseQueue: InboundParseQueue; + + constructor() { + this.inboundParseQueue = new InboundParseQueue(); + } + + public get inboundParseQueueService() { + return this.inboundParseQueue; + } +} diff --git a/apps/inbound-mail/src/server/index.ts b/apps/inbound-mail/src/server/index.ts index d34f20d4357..d8ec1afc28d 100644 --- a/apps/inbound-mail/src/server/index.ts +++ b/apps/inbound-mail/src/server/index.ts @@ -8,18 +8,20 @@ import * as path from 'path'; import * as shell from 'shelljs'; import * as util from 'util'; import { SMTPServer } from 'smtp-server'; -import * as uuid from 'node-uuid'; +import * as uuid from 'uuid'; import * as dns from 'dns'; import * as extend from 'extend'; import { BullMqService } from '@novu/application-generic'; -import { InboundMailQueueService } from './inbound-mail-queue.service'; +import { InboundMailService } from './inbound-mail.service'; import logger from './logger'; +const LOG_CONTEXT = 'Mailin'; + // eslint-disable-next-line @typescript-eslint/naming-convention const LanguageDetect = require('languagedetect'); const mailUtilities = Promise.promisifyAll(require('./mailUtilities')); -const inboundMailQueueService = new InboundMailQueueService(); +const inboundMailService = new InboundMailService(); BullMqService.haveProInstalled(); class Mailin extends events.EventEmitter { @@ -29,6 +31,7 @@ class Mailin extends events.EventEmitter { constructor() { super(); + this.configuration = { host: '0.0.0.0', port: 2500, @@ -51,16 +54,10 @@ class Mailin extends events.EventEmitter { this._smtp = null; } - public start(options: any, callback: (err?) => void) { + public start(options: object, callback: (err?) => void) { // eslint-disable-next-line @typescript-eslint/no-this-alias const _this = this; - options = options || {}; - if (_.isFunction(options)) { - callback = options; - options = {}; - } - const configuration = this.configuration; extend(true, configuration, options); @@ -79,26 +76,13 @@ class Mailin extends events.EventEmitter { shell.mkdir('-p', configuration.tmp); } - /* Set log level if necessary. */ - if (configuration.logLevel) { - logger.setLevel(configuration.logLevel); - } - - if (configuration.verbose) { - logger.setLevel('verbose'); - logger.info('Log level set to verbose.'); - } - if (configuration.debug) { - logger.info('Debug option activated.'); - logger.setLevel('debug'); - configuration.smtpOptions.debug = true; } /* Basic memory profiling. */ if (configuration.profile) { - logger.info('Enable memory profiling'); + logger.info('Enable memory profiling', LOG_CONTEXT); setInterval(function () { const memoryUsage = process.memoryUsage(); const ram = memoryUsage.rss + memoryUsage.heapUsed; @@ -111,7 +95,8 @@ class Mailin extends events.EventEmitter { 'mb | heapTotal: ' + memoryUsage.heapTotal / million + 'mb | heapUsed: ' + - memoryUsage.heapUsed / million + memoryUsage.heapUsed / million, + LOG_CONTEXT ); }, 500); } @@ -190,7 +175,7 @@ class Mailin extends events.EventEmitter { } function dataReady(connection) { - logger.info(connection.id + ' Processing message from ' + connection.envelope.mailFrom.address); + logger.info(connection.id + ' Processing message from ' + connection.envelope.mailFrom.address, LOG_CONTEXT); return retrieveRawEmail(connection) .then(function (rawEmail) { @@ -221,7 +206,7 @@ class Mailin extends events.EventEmitter { .then(postQueue.bind(null, connection)) .then(unlinkFile.bind(null, connection)) .catch(function (error) { - logger.error(error, connection.id + ' Unable to finish processing message!!'); + logger.error(connection.id + ' Unable to finish processing message!!', LOG_CONTEXT); logger.error(error); throw error; }); @@ -238,10 +223,10 @@ class Mailin extends events.EventEmitter { return Promise.resolve(false); } - logger.verbose(connection.id + ' Validating dkim.'); + logger.verbose(connection.id + ' Validating dkim.', LOG_CONTEXT); return mailUtilities.validateDkimAsync(rawEmail).catch(function (err) { - logger.error(connection.id + ' Unable to validate dkim. Consider dkim as failed.'); + logger.error(connection.id + ' Unable to validate dkim. Consider dkim as failed.', LOG_CONTEXT); logger.error(err); return false; @@ -253,13 +238,13 @@ class Mailin extends events.EventEmitter { return Promise.resolve(false); } - logger.verbose(connection.id + ' Validating spf.'); + logger.verbose(connection.id + ' Validating spf.', LOG_CONTEXT); /* Get ip and host. */ return mailUtilities .validateSpfAsync(connection.remoteAddress, connection.from, connection.clientHostname) .catch(function (err) { - logger.error(connection.id + ' Unable to validate spf. Consider spf as failed.'); + logger.error(connection.id + ' Unable to validate spf. Consider spf as failed.', LOG_CONTEXT); logger.error(err); return false; @@ -272,7 +257,7 @@ class Mailin extends events.EventEmitter { } return mailUtilities.computeSpamScoreAsync(rawEmail).catch(function (err) { - logger.error(connection.id + ' Unable to compute spam score. Set spam score to 0.'); + logger.error(connection.id + ' Unable to compute spam score. Set spam score to 0.', LOG_CONTEXT); logger.error(err); return 0.0; @@ -281,7 +266,7 @@ class Mailin extends events.EventEmitter { function parseEmail(connection) { return new Promise(function (resolve) { - logger.verbose(connection.id + ' Parsing email.'); + logger.verbose(connection.id + ' Parsing email.', LOG_CONTEXT); /* Prepare the mail parser. */ const mailParser = new MailParser(); @@ -315,7 +300,7 @@ class Mailin extends events.EventEmitter { } function detectLanguage(connection, text) { - logger.verbose(connection.id + ' Detecting language.'); + logger.verbose(connection.id + ' Detecting language.', LOG_CONTEXT); let language = ''; @@ -326,7 +311,8 @@ class Mailin extends events.EventEmitter { 'Potential languages: ' + util.inspect(potentialLanguages, { depth: 5, - }) + }), + LOG_CONTEXT ); /* @@ -335,7 +321,7 @@ class Mailin extends events.EventEmitter { */ language = potentialLanguages[0][0]; } else { - logger.info(connection.id + ' Unable to detect language for the current message.'); + logger.info(connection.id + ' Unable to detect language for the current message.', LOG_CONTEXT); } return language; @@ -369,24 +355,16 @@ class Mailin extends events.EventEmitter { function postQueue(connection, finalizedMessage) { return new Promise(function (resolve) { - logger.debug(connection.id + ' finalized message is: ' + finalizedMessage); + logger.debug(connection.id + ' finalized message is: ' + finalizedMessage, LOG_CONTEXT); - logger.info(connection.id + ' Adding mail to queue '); + logger.info(connection.id + ' Adding mail to queue ', LOG_CONTEXT); const toAddress = getAddressTo(finalizedMessage); const parts: string[] = toAddress.split('@'); const username: string = parts[0]; const environmentId = username.split('-nv-e=').at(-1); - inboundMailQueueService.bullMqService.add( - finalizedMessage.messageId, - finalizedMessage, - { - removeOnComplete: true, - removeOnFail: true, - }, - environmentId - ); + inboundMailService.inboundParseQueueService.add(finalizedMessage.messageId, finalizedMessage, environmentId); return resolve(); }); @@ -394,7 +372,7 @@ class Mailin extends events.EventEmitter { function unlinkFile(connection) { /* Don't forget to unlink the tmp file. */ return fs.promises.unlink(connection.mailPath).then(function () { - logger.info(connection.id + ' End processing message, deleted ' + connection.mailPath); + logger.info(connection.id + ' End processing message, deleted ' + connection.mailPath, LOG_CONTEXT); return; }); @@ -411,8 +389,8 @@ class Mailin extends events.EventEmitter { connection.mailPath = mailPath; _this.emit('startData', connection); - logger.verbose('Connection id ' + connection.id); - logger.info(connection.id + ' Receiving message from ' + connection.envelope.mailFrom.address); + logger.verbose('Connection id ' + connection.id, LOG_CONTEXT); + logger.info(connection.id + ' Receiving message from ' + connection.envelope.mailFrom.address, LOG_CONTEXT); _this.emit('startMessage', connection); @@ -434,8 +412,9 @@ class Mailin extends events.EventEmitter { stream.on('error', function (error) { _this.emit('error', connection, error); }); - } catch (e) { - logger.error('Exception occurred while performing onData callback', e); + } catch (error) { + logger.error('Exception occurred while performing onData callback', LOG_CONTEXT); + logger.error(error); } } @@ -473,20 +452,21 @@ class Mailin extends events.EventEmitter { this._smtp = server; server.listen(configuration.port, configuration.host, function () { - logger.info('Mailin Smtp server listening on port ' + configuration.port); + logger.info('Mailin Smtp server listening on port ' + configuration.port, LOG_CONTEXT); }); server.on('close', function () { - logger.info('Closing smtp server'); + logger.info('Closing smtp server', LOG_CONTEXT); _this.emit('close', _session); }); server.on('error', function (error) { callback(error); if (configuration.port < 1000) { - logger.error('Ports under 1000 require root privileges.'); + logger.error('Ports under 1000 require root privileges.', LOG_CONTEXT); } + logger.error('Server errored', LOG_CONTEXT); logger.error(error); _this.emit('error', _session, error); }); @@ -496,7 +476,7 @@ class Mailin extends events.EventEmitter { public stop(callback: () => void) { callback = callback || function () {}; - logger.info('Stopping mailin.'); + logger.info('Stopping mailin.', LOG_CONTEXT); /* * FIXME A bug in the RAI module prevents the callback to be called, so diff --git a/apps/inbound-mail/src/server/logger.ts b/apps/inbound-mail/src/server/logger.ts index 883a2920613..1e4fe203209 100644 --- a/apps/inbound-mail/src/server/logger.ts +++ b/apps/inbound-mail/src/server/logger.ts @@ -1,53 +1,11 @@ import * as _ from 'lodash'; import util from 'util'; -import * as winston from 'winston'; +import { createLogger, format, transports } from 'winston'; -const logger = new winston.Logger({ - transports: [ - new winston.transports.Console({ - colorize: true, - prettyPrint: true, - }), - ], +const logger = createLogger({ + exitOnError: false, + level: 'debug', + transports: [new transports.Console({ format: format.combine(format.prettyPrint()), handleExceptions: true })], }); -/* - * Parameter level is one of 'silly', 'verbose', 'debug', 'info', 'warn', - * 'error'. - * - */ -logger.setLevel = function (level) { - if (['silly', 'verbose', 'debug', 'info', 'warn', 'error'].indexOf(level) === -1) { - logger.error('Unable to set logging level to unknown level "' + level + '".'); - } else { - /* - * Verbose and debug have not exactly the same semantic in Mailin and - * Winston, so handle that. - */ - if (logger.transports.console.level === 'verbose' && level === 'debug') { - return; - } - - logger.transports.console.level = level; - - if (logger.transports.file) logger.transports.file.level = level; - } -}; - -logger._error = logger.error; -logger.error = function (err) { - if (err.stack) { - this._error(err.stack); - } else if (!_.isString(err)) { - this._error( - util.inspect(err, { - depth: 5, - }) - ); - } else { - // eslint-disable-next-line prefer-spread,prefer-rest-params - this._error.apply(this, arguments); - } -}; - export default logger; diff --git a/apps/inbound-mail/tsconfig.json b/apps/inbound-mail/tsconfig.json index ff2e9e3c2a0..91760cfdaf8 100644 --- a/apps/inbound-mail/tsconfig.json +++ b/apps/inbound-mail/tsconfig.json @@ -5,7 +5,7 @@ "strictNullChecks": false, "module": "commonjs", "allowSyntheticDefaultImports": true, - "types": ["node"], + "types": ["node", "mocha", "chai", "sinon"], "target": "es2017", "allowJs": false, "esModuleInterop": false, diff --git a/apps/web/src/design-system/icons/index.ts b/apps/web/src/design-system/icons/index.ts index beaf524bec2..d781a96e70b 100644 --- a/apps/web/src/design-system/icons/index.ts +++ b/apps/web/src/design-system/icons/index.ts @@ -139,3 +139,4 @@ export { DisconnectGradient } from './gradient/DisconnectGradient'; export { BoltOutlinedGradient } from './gradient/BoltOutlinedGradient'; export { GitHub } from './social/GitHub'; +export { Google } from './social/Google'; diff --git a/apps/web/src/design-system/icons/social/Google.tsx b/apps/web/src/design-system/icons/social/Google.tsx new file mode 100644 index 00000000000..6f1269abe49 --- /dev/null +++ b/apps/web/src/design-system/icons/social/Google.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +/* eslint-disable */ +export function Google(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + ); +} diff --git a/apps/web/src/pages/auth/components/LoginForm.tsx b/apps/web/src/pages/auth/components/LoginForm.tsx index b9901dea8cc..77499e5fa53 100644 --- a/apps/web/src/pages/auth/components/LoginForm.tsx +++ b/apps/web/src/pages/auth/components/LoginForm.tsx @@ -9,12 +9,13 @@ import { Divider, Button as MantineButton, Center } from '@mantine/core'; import { useAuthContext } from '../../../components/providers/AuthProvider'; import { api } from '../../../api/api.client'; import { PasswordInput, Button, colors, Input, Text } from '../../../design-system'; -import { GitHub } from '../../../design-system/icons'; +import { GitHub, Google } from '../../../design-system/icons'; import { IS_DOCKER_HOSTED } from '../../../config'; import { useVercelParams } from '../../../hooks'; import { useAcceptInvite } from './useAcceptInvite'; -import { buildGithubLink, buildVercelGithubLink } from './gitHubUtils'; +import { buildGithubLink, buildGoogleLink, buildVercelGithubLink } from './gitHubUtils'; import { ROUTES } from '../../../constants/routes.enum'; +import { When } from '../../../components/utils/When'; type LoginFormProps = { invitationToken?: string; @@ -41,6 +42,7 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) { const githubLink = isFromVercel ? buildVercelGithubLink({ code, next, configurationId }) : buildGithubLink({ invitationToken }); + const googleLink = buildGoogleLink({ invitationToken }); const { register, @@ -95,24 +97,39 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) { return ( <> - {!IS_DOCKER_HOSTED && ( + <> - } - sx={{ color: colors.B40, fontSize: '16px', fontWeight: 700, height: '50px' }} - data-test-id="github-button" - > - Sign In with GitHub - + + } + 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" /> - )} +
{ + const queryParams = new URLSearchParams(); + queryParams.append('source', SignUpOriginEnum.WEB); + if (invitationToken) { + queryParams.append('invitationToken', invitationToken); + } + + return `${API_ROOT}/v1/auth/google?${queryParams.toString()}`; +}; + export const buildVercelGithubLink = ({ code, next, diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index f87d400bd9b..8b6ea72d913 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -84,7 +84,7 @@ export class SendMessageChat extends SendMessageBase { events: command.events, total_count: command.events?.length, }, - ...(tenant ? { tenant: { name: tenant.name, ...tenant.data } } : {}), + ...(tenant && { tenant }), ...command.payload, }; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts index c1993fb3621..d79b2516ac3 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts @@ -164,7 +164,7 @@ export class SendMessageEmail extends SendMessageBase { events: command.events, total_count: command.events?.length, }, - ...(tenant ? { tenant: { name: tenant.name, ...tenant.data } } : {}), + ...(tenant && { tenant }), subscriber, }, }; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts index a3345217855..7b8f949b7b8 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts @@ -236,19 +236,6 @@ export class SendMessageInApp extends SendMessageBase { if (!message) throw new PlatformException('Message not found'); - await this.webSocketsQueueService.bullMqService.add( - 'sendMessage', - { - event: WebSocketEventEnum.RECEIVED, - userId: command._subscriberId, - payload: { - message, - }, - }, - {}, - command.organizationId - ); - await this.createExecutionDetails.execute( CreateExecutionDetailsCommand.create({ ...CreateExecutionDetailsCommand.getDetailsFromJob(command.job), @@ -263,7 +250,20 @@ export class SendMessageInApp extends SendMessageBase { ); await this.webSocketsQueueService.bullMqService.add( - 'sendMessage', + 'sendMessage-received-' + message._id, + { + event: WebSocketEventEnum.RECEIVED, + userId: command._subscriberId, + payload: { + message, + }, + }, + {}, + command.organizationId + ); + + await this.webSocketsQueueService.bullMqService.add( + 'sendMessage-unseen-' + message._id, { event: WebSocketEventEnum.UNSEEN, userId: command._subscriberId, @@ -274,7 +274,7 @@ export class SendMessageInApp extends SendMessageBase { ); await this.webSocketsQueueService.bullMqService.add( - 'sendMessage', + 'sendMessage-unread-' + message._id, { event: WebSocketEventEnum.UNREAD, userId: command._subscriberId, @@ -320,7 +320,7 @@ export class SendMessageInApp extends SendMessageBase { logo: organization?.branding?.logo, color: organization?.branding?.color || '#f47373', }, - ...(tenant ? { tenant: { name: tenant.name, ...tenant.data } } : {}), + ...(tenant && { tenant }), ...payload, }, }) diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts index 69185daa7fd..9a1759873c3 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts @@ -82,7 +82,7 @@ export class SendMessagePush extends SendMessageBase { const data = { subscriber: subscriber, step: stepData, - ...(tenant ? { tenant: { name: tenant.name, ...tenant.data } } : {}), + ...(tenant && { tenant }), ...command.payload, }; let content = ''; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts index ae77c9fdf94..c606a5dbc09 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts @@ -88,7 +88,7 @@ export class SendMessageSms extends SendMessageBase { events: command.events, total_count: command.events?.length, }, - ...(tenant ? { tenant: { name: tenant.name, ...tenant.data } } : {}), + ...(tenant && { tenant }), ...command.payload, }; diff --git a/apps/ws/src/socket/services/web-socket.worker.spec.ts b/apps/ws/src/socket/services/web-socket.worker.spec.ts index 0df77f3e4e9..b649d29e83d 100644 --- a/apps/ws/src/socket/services/web-socket.worker.spec.ts +++ b/apps/ws/src/socket/services/web-socket.worker.spec.ts @@ -44,7 +44,7 @@ describe('WebSocket Worker', () => { workerIsRunning: true, }); expect(webSocketWorker.worker.opts).to.deep.include({ - concurrency: 5, + concurrency: 100, lockDuration: 90000, }); }); diff --git a/apps/ws/src/socket/services/web-socket.worker.ts b/apps/ws/src/socket/services/web-socket.worker.ts index 27f83e43153..06bc7597074 100644 --- a/apps/ws/src/socket/services/web-socket.worker.ts +++ b/apps/ws/src/socket/services/web-socket.worker.ts @@ -1,8 +1,10 @@ +const nr = require('newrelic'); import { Injectable, Logger } from '@nestjs/common'; import { INovuWorker, WebSocketsWorkerService } from '@novu/application-generic'; import { ExternalServicesRoute, ExternalServicesRouteCommand } from '../usecases/external-services-route'; +import { ObservabilityBackgroundTransactionEnum } from '@novu/shared'; const LOG_CONTEXT = 'WebSocketWorker'; @@ -16,27 +18,48 @@ export class WebSocketWorker extends WebSocketsWorkerService implements INovuWor private getWorkerProcessor() { return async (job) => { - try { - await this.externalServicesRoute.execute( - ExternalServicesRouteCommand.create({ - userId: job.data.userId, - event: job.data.event, - payload: job.data.payload, - _environmentId: job.data._environmentId, - }) - ); - } catch (e) { - Logger.error('Unexpected exception occurred while handling external services route ', e, LOG_CONTEXT); + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const _this = this; + + nr.startBackgroundTransaction( + ObservabilityBackgroundTransactionEnum.WS_SOCKET_QUEUE, + 'WS Service', + function () { + const transaction = nr.getTransaction(); - throw e; - } + _this.externalServicesRoute + .execute( + ExternalServicesRouteCommand.create({ + userId: job.data.userId, + event: job.data.event, + payload: job.data.payload, + _environmentId: job.data._environmentId, + }) + ) + .then(resolve) + .catch((error) => { + Logger.error( + 'Unexpected exception occurred while handling external services route ', + error, + LOG_CONTEXT + ); + + reject(error); + }) + .finally(() => { + transaction.end(); + }); + } + ); + }); }; } private getWorkerOpts() { return { lockDuration: 90000, - concurrency: 5, + concurrency: 100, }; } } diff --git a/docker/kubernetes/kustomize/kustomization.yaml b/docker/kubernetes/kustomize/kustomization.yaml index 434cdaf9433..9b066f33ff4 100644 --- a/docker/kubernetes/kustomize/kustomization.yaml +++ b/docker/kubernetes/kustomize/kustomization.yaml @@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - mongodb-volume.yaml - mongodb-deployment.yaml - mongodb-service.yaml - api-deployment.yaml @@ -18,6 +19,7 @@ resources: - ws-service.yaml - worker-deployment.yaml - worker-service.yaml + # namespace # Adds namespace to all resources. diff --git a/docker/kubernetes/kustomize/mongodb-deployment.yaml b/docker/kubernetes/kustomize/mongodb-deployment.yaml index 14c149bbcc6..d36e7666557 100644 --- a/docker/kubernetes/kustomize/mongodb-deployment.yaml +++ b/docker/kubernetes/kustomize/mongodb-deployment.yaml @@ -28,3 +28,10 @@ spec: name: mongodb ports: - containerPort: 27017 + volumeMounts: + - name: "mongodb-persistent-storage" + mountPath: "/data/db" + volumes: + - name: "mongodb-persistent-storage" + persistentVolumeClaim: + claimName: "novu-mongodb-pvc" diff --git a/docker/kubernetes/kustomize/mongodb-volume.yaml b/docker/kubernetes/kustomize/mongodb-volume.yaml new file mode 100644 index 00000000000..079449ffffa --- /dev/null +++ b/docker/kubernetes/kustomize/mongodb-volume.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: novu-mongodb-pvc +spec: + resources: + requests: + storage: 5Gi + volumeMode: Filesystem + storageClassName: local-storage + accessModes: + - ReadWriteOnce diff --git a/docs/docs/notification-center/angular-component.md b/docs/docs/notification-center/angular-component.md index 19167578e4f..87687919e3c 100644 --- a/docs/docs/notification-center/angular-component.md +++ b/docs/docs/notification-center/angular-component.md @@ -13,7 +13,7 @@ npm install @novu/notification-center-angular ``` :::note -Novu supports Angular version >0.15.0. +Novu supports Angular version 15 ::: ## Example usage diff --git a/docs/docs/overview/quickstart/get-started-with-angular.md b/docs/docs/overview/quickstart/get-started-with-angular.md index e4300e02c3b..1b4011b3c96 100644 --- a/docs/docs/overview/quickstart/get-started-with-angular.md +++ b/docs/docs/overview/quickstart/get-started-with-angular.md @@ -16,7 +16,7 @@ To follow the steps in this quickstart, you'll need: - A Novu account. [Sign up for free](http://web.novu.co) if you don’t have one yet. - Angular CLI (Command Line Interface) installed on your machine -- Angular version > 0.15.0 +- Angular version 15 You can also [view the completed code](https://github.com/novuhq/angular-quickstart) of this quick start in a GitHub repo. diff --git a/docs/src/components/Quickstarts.tsx b/docs/src/components/Quickstarts.tsx index 2640ed8a061..a1c697fa787 100644 --- a/docs/src/components/Quickstarts.tsx +++ b/docs/src/components/Quickstarts.tsx @@ -15,7 +15,7 @@ import DotnetLogo from '/static/img/quickstarts/dotnet.svg'; import NovuLogo from '/static/img/quickstarts/novu.svg'; import ArrowIcon from '/static/img/arrow-md.svg'; -const quckstartItems = [ +const quickstartItems = [ { title: 'ReactJS', description: 'Connect a ReactJS application to Novu', @@ -87,7 +87,7 @@ const quckstartItems = [ export default function Quickstarts() { const [isExpanded, setIsExpanded] = useState(false); - const filteredItems = quckstartItems.filter((_, index) => isExpanded || index < 4); + const filteredItems = quickstartItems.filter((_, index) => isExpanded || index < 4); return (
diff --git a/enterprise/packages b/enterprise/packages new file mode 160000 index 00000000000..8f0b31fcf98 --- /dev/null +++ b/enterprise/packages @@ -0,0 +1 @@ +Subproject commit 8f0b31fcf987bd0626b80224d73349fcbf71ceea diff --git a/libs/dal/src/dal.service.ts b/libs/dal/src/dal.service.ts index 93340133b8e..428bb765341 100644 --- a/libs/dal/src/dal.service.ts +++ b/libs/dal/src/dal.service.ts @@ -7,8 +7,9 @@ export class DalService { async connect(url: string, config: ConnectOptions = {}) { const baseConfig: ConnectOptions = { maxPoolSize: process.env.MONGO_MAX_POOL_SIZE || 500, - minPoolSize: process.env.NODE_ENV === 'production' ? 200 : 10, + minPoolSize: process.env.NODE_ENV === 'production' ? 50 : 10, autoIndex: process.env.AUTO_CREATE_INDEXES === 'true', + maxIdleTimeMS: 1000 * 60 * 10, }; const instance = await mongoose.connect(url, { diff --git a/libs/shared/src/config/job-queue.ts b/libs/shared/src/config/job-queue.ts index 50ec627998f..826b82f5f0f 100644 --- a/libs/shared/src/config/job-queue.ts +++ b/libs/shared/src/config/job-queue.ts @@ -9,4 +9,5 @@ export enum JobTopicNameEnum { export enum ObservabilityBackgroundTransactionEnum { JOB_PROCESSING_QUEUE = 'job-processing-queue', TRIGGER_HANDLER_QUEUE = 'trigger-handler-queue', + WS_SOCKET_QUEUE = 'ws_socket_queue', } diff --git a/packages/application-generic/package.json b/packages/application-generic/package.json index 60f9a14847a..7629b5c35d8 100644 --- a/packages/application-generic/package.json +++ b/packages/application-generic/package.json @@ -38,6 +38,7 @@ "@nestjs/swagger": ">=6", "@nestjs/terminus": ">=10", "@nestjs/testing": ">=10", + "@nestjs/jwt": "^10.1.0", "newrelic": "^9", "reflect-metadata": "^0.1.13" }, diff --git a/packages/application-generic/src/index.ts b/packages/application-generic/src/index.ts index cb1c85709ae..86e982129dd 100644 --- a/packages/application-generic/src/index.ts +++ b/packages/application-generic/src/index.ts @@ -12,3 +12,5 @@ export * from './utils/subscriber'; export * from './utils/filter'; export * from './utils/filter-processing-details'; export * from './resilience'; +export * from './utils/exceptions'; +export * from './utils/email-normalization'; diff --git a/apps/api/src/app/auth/services/auth.service.ts b/packages/application-generic/src/services/auth/auth.service.ts similarity index 65% rename from apps/api/src/app/auth/services/auth.service.ts rename to packages/application-generic/src/services/auth/auth.service.ts index e33cd6d0fb5..4f8c9391d17 100644 --- a/apps/api/src/app/auth/services/auth.service.ts +++ b/packages/application-generic/src/services/auth/auth.service.ts @@ -1,5 +1,12 @@ -import { forwardRef, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + forwardRef, + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; + import { EnvironmentRepository, MemberEntity, @@ -12,26 +19,35 @@ import { EnvironmentEntity, IApiKey, } from '@novu/dal'; -import { AuthProviderEnum, IJwtPayload, ISubscriberJwt, MemberRoleEnum, SignUpOriginEnum } from '@novu/shared'; import { - AnalyticsService, - Instrument, - PinoLogger, - CachedEntity, + AuthProviderEnum, + IJwtPayload, + ISubscriberJwt, + MemberRoleEnum, + SignUpOriginEnum, +} from '@novu/shared'; + +import { PinoLogger } from '../../logging'; +import { AnalyticsService } from '../analytics.service'; +import { ApiException } from '../../utils/exceptions'; +import { Instrument } from '../../instrumentation'; +import { CreateUser, CreateUserCommand } from '../../usecases/create-user'; +import { + SwitchEnvironment, + SwitchEnvironmentCommand, +} from '../../usecases/switch-environment'; +import { + SwitchOrganization, + SwitchOrganizationCommand, +} from '../../usecases/switch-organization'; +import { + buildAuthServiceKey, buildEnvironmentByApiKey, buildSubscriberKey, buildUserKey, - buildAuthServiceKey, -} from '@novu/application-generic'; - -import { CreateUserCommand } from '../../user/usecases/create-user/create-user.dto'; -import { CreateUser } from '../../user/usecases/create-user/create-user.usecase'; -import { SwitchEnvironmentCommand } from '../usecases/switch-environment/switch-environment.command'; -import { SwitchEnvironment } from '../usecases/switch-environment/switch-environment.usecase'; -import { SwitchOrganization } from '../usecases/switch-organization/switch-organization.usecase'; -import { SwitchOrganizationCommand } from '../usecases/switch-organization/switch-organization.command'; -import { normalizeEmail } from '../../shared/helpers/email-normalization.service'; -import { ApiException } from '../../shared/exceptions/api.exception'; + CachedEntity, +} from '../cache'; +import { normalizeEmail } from '../../utils/email-normalization'; @Injectable() export class AuthService { @@ -45,15 +61,23 @@ export class AuthService { private organizationRepository: OrganizationRepository, private environmentRepository: EnvironmentRepository, private memberRepository: MemberRepository, - @Inject(forwardRef(() => SwitchOrganization)) private switchOrganizationUsecase: SwitchOrganization, - @Inject(forwardRef(() => SwitchEnvironment)) private switchEnvironmentUsecase: SwitchEnvironment + @Inject(forwardRef(() => SwitchOrganization)) + private switchOrganizationUsecase: SwitchOrganization, + @Inject(forwardRef(() => SwitchEnvironment)) + private switchEnvironmentUsecase: SwitchEnvironment ) {} async authenticate( authProvider: AuthProviderEnum, accessToken: string, refreshToken: string, - profile: { name: string; login: string; email: string; avatar_url: string; id: string }, + profile: { + name: string; + login: string; + email: string; + avatar_url: string; + id: string; + }, distinctId: string, origin?: SignUpOriginEnum ) { @@ -62,12 +86,19 @@ export class AuthService { let newUser = false; if (!user) { + const firstName = profile.name + ? profile.name.split(' ').slice(0, -1).join(' ') + : profile.login; + const lastName = profile.name + ? profile.name.split(' ').slice(-1).join(' ') + : null; + user = await this.createUserUsecase.execute( CreateUserCommand.create({ picture: profile.avatar_url, email, - lastName: profile.name ? profile.name.split(' ').slice(-1).join(' ') : null, - firstName: profile.name ? profile.name.split(' ').slice(0, -1).join(' ') : profile.login, + firstName, + lastName, auth: { username: profile.login, profileId: profile.id, @@ -88,27 +119,11 @@ export class AuthService { origin: origin, }); } else { - if (authProvider === AuthProviderEnum.GITHUB) { - const withoutUsername = user.tokens.find( - (i) => i.provider === AuthProviderEnum.GITHUB && !i.username && String(i.providerId) === String(profile.id) - ); - - if (withoutUsername) { - await this.userRepository.update( - { - _id: user._id, - 'tokens.providerId': profile.id, - }, - { - $set: { - 'tokens.$.username': profile.login, - }, - } - ); - - user = await this.userRepository.findById(user._id); - if (!user) throw new ApiException('User not found'); - } + if ( + authProvider === AuthProviderEnum.GITHUB || + authProvider === AuthProviderEnum.GOOGLE + ) { + user = await this.updateUserUsername(user, profile, authProvider); } this.analyticsService.track('[Authentication] - Login', user._id, { @@ -124,6 +139,44 @@ export class AuthService { }; } + private async updateUserUsername( + user: UserEntity, + profile: { + name: string; + login: string; + email: string; + avatar_url: string; + id: string; + }, + authProvider: AuthProviderEnum + ) { + const withoutUsername = user.tokens.find( + (token) => + token.provider === authProvider && + !token.username && + String(token.providerId) === String(profile.id) + ); + + if (withoutUsername) { + await this.userRepository.update( + { + _id: user._id, + 'tokens.providerId': profile.id, + }, + { + $set: { + 'tokens.$.username': profile.login, + }, + } + ); + + user = await this.userRepository.findById(user._id); + if (!user) throw new ApiException('User not found'); + } + + return user; + } + async refreshToken(userId: string) { const user = await this.getUser({ _id: userId }); if (!user) throw new UnauthorizedException('User not found'); @@ -132,13 +185,21 @@ export class AuthService { } @Instrument() - async isAuthenticatedForOrganization(userId: string, organizationId: string): Promise { - return !!(await this.memberRepository.isMemberOfOrganization(organizationId, userId)); + async isAuthenticatedForOrganization( + userId: string, + organizationId: string + ): Promise { + return !!(await this.memberRepository.isMemberOfOrganization( + organizationId, + userId + )); } @Instrument() async apiKeyAuthenticate(apiKey: string) { - const { environment, user, key, error } = await this.getUserData({ apiKey }); + const { environment, user, key, error } = await this.getUserData({ + apiKey, + }); if (error) throw new UnauthorizedException(error); if (!environment) throw new UnauthorizedException('API Key not found'); @@ -152,10 +213,17 @@ export class AuthService { if (!key) throw new UnauthorizedException('API Key not found'); - return await this.getApiSignedToken(user, environment._organizationId, environment._id, key.key); + return await this.getApiSignedToken( + user, + environment._organizationId, + environment._id, + key.key + ); } - async getSubscriberWidgetToken(subscriber: SubscriberEntity): Promise { + async getSubscriberWidgetToken( + subscriber: SubscriberEntity + ): Promise { return this.jwtService.sign( { _id: subscriber._id, @@ -201,20 +269,26 @@ export class AuthService { } async generateUserToken(user: UserEntity) { - const userActiveOrganizations = await this.organizationRepository.findUserActiveOrganizations(user._id); + const userActiveOrganizations = + await this.organizationRepository.findUserActiveOrganizations(user._id); if (userActiveOrganizations && userActiveOrganizations.length) { const organizationToSwitch = userActiveOrganizations[0]; - const userActiveProjects = await this.environmentRepository.findOrganizationEnvironments( - organizationToSwitch._id - ); + const userActiveProjects = + await this.environmentRepository.findOrganizationEnvironments( + organizationToSwitch._id + ); let environmentToSwitch = userActiveProjects[0]; - const reduceEnvsToOnlyDevelopment = (prev, current) => (current.name === 'Development' ? current : prev); + const reduceEnvsToOnlyDevelopment = (prev, current) => + current.name === 'Development' ? current : prev; if (userActiveProjects.length > 1) { - environmentToSwitch = userActiveProjects.reduce(reduceEnvsToOnlyDevelopment, environmentToSwitch); + environmentToSwitch = userActiveProjects.reduce( + reduceEnvsToOnlyDevelopment, + environmentToSwitch + ); } if (environmentToSwitch) { @@ -278,14 +352,21 @@ export class AuthService { if (!user) throw new UnauthorizedException('User not found'); if (payload.organizationId && !isMember) { - throw new UnauthorizedException(`No authorized for organization ${payload.organizationId}`); + throw new UnauthorizedException( + `No authorized for organization ${payload.organizationId}` + ); } return user; } - async validateSubscriber(payload: ISubscriberJwt): Promise { - return await this.getSubscriber({ _environmentId: payload.environmentId, subscriberId: payload.subscriberId }); + async validateSubscriber( + payload: ISubscriberJwt + ): Promise { + return await this.getSubscriber({ + _environmentId: payload.environmentId, + subscriberId: payload.subscriberId, + }); } async decodeJwt(token: string) { @@ -334,8 +415,17 @@ export class AuthService { subscriberId: command.subscriberId, }), }) - private async getSubscriber({ subscriberId, _environmentId }: { subscriberId: string; _environmentId: string }) { - return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); + private async getSubscriber({ + subscriberId, + _environmentId, + }: { + subscriberId: string; + _environmentId: string; + }): Promise { + return await this.subscriberRepository.findBySubscriberId( + _environmentId, + subscriberId + ); } @CachedEntity({ @@ -344,11 +434,12 @@ export class AuthService { apiKey: apiKey, }), }) - private async getUserData({ - apiKey, - }: { - apiKey: string; - }): Promise<{ environment?: EnvironmentEntity; user?: UserEntity; key?: IApiKey; error?: string }> { + private async getUserData({ apiKey }: { apiKey: string }): Promise<{ + environment?: EnvironmentEntity; + user?: UserEntity; + key?: IApiKey; + error?: string; + }> { const environment = await this.environmentRepository.findByApiKey(apiKey); if (!environment) { return { error: 'API Key not found' }; diff --git a/packages/application-generic/src/services/auth/index.ts b/packages/application-generic/src/services/auth/index.ts new file mode 100644 index 00000000000..ef64497a51f --- /dev/null +++ b/packages/application-generic/src/services/auth/index.ts @@ -0,0 +1,2 @@ +export * from './auth.service'; +export * from './shared'; diff --git a/packages/application-generic/src/services/auth/shared.ts b/packages/application-generic/src/services/auth/shared.ts new file mode 100644 index 00000000000..2dc554d42dc --- /dev/null +++ b/packages/application-generic/src/services/auth/shared.ts @@ -0,0 +1,53 @@ +export const buildOauthRedirectUrl = (request): string => { + let url = process.env.FRONT_BASE_URL + '/auth/login'; + + if (!request.user || !request.user.token) { + return `${url}?error=AuthenticationError`; + } + + const redirectUrl = JSON.parse(request.query.state).redirectUrl; + + /** + * Make sure we only allow localhost redirects for CLI use and our own success route + * https://github.com/novuhq/novu/security/code-scanning/3 + */ + if ( + redirectUrl && + redirectUrl.startsWith('http://localhost:') && + !redirectUrl.includes('@') + ) { + url = redirectUrl; + } + + url += `?token=${request.user.token}`; + + if (request.user.newUser) { + url += '&newUser=true'; + } + + /** + * partnerCode, next and configurationId are required during external partners integration + * such as vercel integration etc + */ + const partnerCode = JSON.parse(request.query.state).partnerCode; + if (partnerCode) { + url += `&code=${partnerCode}`; + } + + const next = JSON.parse(request.query.state).next; + if (next) { + url += `&next=${next}`; + } + + const configurationId = JSON.parse(request.query.state).configurationId; + if (configurationId) { + url += `&configurationId=${configurationId}`; + } + + const invitationToken = JSON.parse(request.query.state).invitationToken; + if (invitationToken) { + url += `&invitationToken=${invitationToken}`; + } + + return url; +}; diff --git a/packages/application-generic/src/services/in-memory-provider/in-memory-provider.service.ts b/packages/application-generic/src/services/in-memory-provider/in-memory-provider.service.ts index 33d9a9d66c6..ecc60e20182 100644 --- a/packages/application-generic/src/services/in-memory-provider/in-memory-provider.service.ts +++ b/packages/application-generic/src/services/in-memory-provider/in-memory-provider.service.ts @@ -116,6 +116,7 @@ export class InMemoryProviderService { public isClusterMode(): boolean { const isClusterModeEnabled = this.getIsInMemoryClusterModeEnabled.execute(); + Logger.log( this.descriptiveLogMessage( `Cluster mode ${ @@ -251,6 +252,13 @@ export class InMemoryProviderService { const { getClient, getConfig, isClientReady } = getClientAndConfig(); + console.log( + getClient(), + getConfig(), + isClientReady(this.provider), + LOG_CONTEXT + ); + this.isProviderClientReady = isClientReady; this.inMemoryProviderConfig = getConfig(); const { host, port, ttl } = getConfig(); diff --git a/packages/application-generic/src/services/in-memory-provider/providers/elasticache-cluster-provider.ts b/packages/application-generic/src/services/in-memory-provider/providers/elasticache-cluster-provider.ts index 17fccb7cbcb..5b99a9055e6 100644 --- a/packages/application-generic/src/services/in-memory-provider/providers/elasticache-cluster-provider.ts +++ b/packages/application-generic/src/services/in-memory-provider/providers/elasticache-cluster-provider.ts @@ -52,7 +52,9 @@ export const getElasticacheClusterProviderConfig = }; const host = redisClusterConfig.host; - const port = Number(redisClusterConfig.port); + const port = redisClusterConfig.port + ? Number(redisClusterConfig.port) + : undefined; const password = redisClusterConfig.password; const connectTimeout = redisClusterConfig.connectTimeout ? Number(redisClusterConfig.connectTimeout) diff --git a/packages/application-generic/src/services/in-memory-provider/providers/memory-db-cluster-provider.ts b/packages/application-generic/src/services/in-memory-provider/providers/memory-db-cluster-provider.ts index 5c000896bda..a92a07168c3 100644 --- a/packages/application-generic/src/services/in-memory-provider/providers/memory-db-cluster-provider.ts +++ b/packages/application-generic/src/services/in-memory-provider/providers/memory-db-cluster-provider.ts @@ -55,7 +55,9 @@ export const getMemoryDbClusterProviderConfig = }; const host = redisClusterConfig.host; - const port = Number(redisClusterConfig.port); + const port = redisClusterConfig.port + ? Number(redisClusterConfig.port) + : undefined; const username = redisClusterConfig.username; const password = redisClusterConfig.password; const connectTimeout = redisClusterConfig.connectTimeout diff --git a/packages/application-generic/src/services/in-memory-provider/providers/redis-provider.ts b/packages/application-generic/src/services/in-memory-provider/providers/redis-provider.ts index 239e4af51c2..9fc4bc6d72e 100644 --- a/packages/application-generic/src/services/in-memory-provider/providers/redis-provider.ts +++ b/packages/application-generic/src/services/in-memory-provider/providers/redis-provider.ts @@ -53,8 +53,8 @@ export const getRedisProviderConfig = (): IRedisProviderConfig => { tls: process.env.REDIS_TLS as ConnectionOptions, }; - const db = Number(redisConfig.db); - const port = Number(redisConfig.port) || DEFAULT_PORT; + const db = redisConfig.db ? Number(redisConfig.db) : undefined; + const port = redisConfig.port ? Number(redisConfig.port) : DEFAULT_PORT; const host = redisConfig.host || DEFAULT_HOST; const password = redisConfig.password; const connectTimeout = redisConfig.connectTimeout diff --git a/packages/application-generic/src/services/index.ts b/packages/application-generic/src/services/index.ts index ae96354a041..f3f71f1989b 100644 --- a/packages/application-generic/src/services/index.ts +++ b/packages/application-generic/src/services/index.ts @@ -23,3 +23,4 @@ export { WorkerOptions, OldInstanceBullMqService, } from './bull-mq'; +export * from './auth'; diff --git a/packages/application-generic/src/services/queues/inbound-parse-queue.service.spec.ts b/packages/application-generic/src/services/queues/inbound-parse-queue.service.spec.ts new file mode 100644 index 00000000000..5ed92a3231d --- /dev/null +++ b/packages/application-generic/src/services/queues/inbound-parse-queue.service.spec.ts @@ -0,0 +1,147 @@ +import { Test } from '@nestjs/testing'; + +import { InboundParseQueueService } from './inbound-parse-queue.service'; + +let inboundParseQueueService: InboundParseQueueService; + +describe('Inbound Parse Queue service', () => { + describe('General', () => { + beforeAll(async () => { + inboundParseQueueService = new InboundParseQueueService(); + await inboundParseQueueService.queue.obliterate(); + }); + + beforeEach(async () => { + await inboundParseQueueService.queue.drain(); + }); + + afterAll(async () => { + await inboundParseQueueService.gracefulShutdown(); + }); + + it('should be initialised properly', async () => { + expect(inboundParseQueueService).toBeDefined(); + expect(Object.keys(inboundParseQueueService)).toEqual( + expect.arrayContaining([ + 'topic', + 'DEFAULT_ATTEMPTS', + 'instance', + 'queue', + ]) + ); + expect(inboundParseQueueService.DEFAULT_ATTEMPTS).toEqual(3); + expect(inboundParseQueueService.topic).toEqual('inbound-parse-mail'); + expect(await inboundParseQueueService.bullMqService.getStatus()).toEqual({ + queueIsPaused: false, + queueName: 'inbound-parse-mail', + workerName: undefined, + workerIsPaused: undefined, + workerIsRunning: undefined, + }); + expect(await inboundParseQueueService.isPaused()).toEqual(false); + expect(inboundParseQueueService.queue).toMatchObject( + expect.objectContaining({ + _events: {}, + _eventsCount: 0, + _maxListeners: undefined, + name: 'inbound-parse-mail', + jobsOpts: { + removeOnComplete: true, + }, + }) + ); + expect(inboundParseQueueService.queue.opts.prefix).toEqual('bull'); + }); + + it('should add a job in the queue', async () => { + const jobId = 'inbound-parse-mail-job-id'; + 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', + _environmentId, + _organizationId, + _userId, + }; + await inboundParseQueueService.add(jobId, jobData, _organizationId); + + expect(await inboundParseQueueService.queue.getActiveCount()).toEqual(0); + expect(await inboundParseQueueService.queue.getWaitingCount()).toEqual(1); + + const inboundParseQueueJobs = + await inboundParseQueueService.queue.getJobs(); + expect(inboundParseQueueJobs.length).toEqual(1); + const [inboundParseQueueJob] = inboundParseQueueJobs; + expect(inboundParseQueueJob).toMatchObject( + expect.objectContaining({ + id: '1', + name: jobId, + data: jobData, + attemptsMade: 0, + }) + ); + }); + + 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 inboundParseQueueService.addMinimalJob( + jobId, + jobData, + _organizationId + ); + + expect(await inboundParseQueueService.queue.getActiveCount()).toEqual(0); + expect(await inboundParseQueueService.queue.getWaitingCount()).toEqual(1); + + const inboundParseQueueJobs = + await inboundParseQueueService.queue.getJobs(); + expect(inboundParseQueueJobs.length).toEqual(1); + const [inboundParseQueueJob] = inboundParseQueueJobs; + expect(inboundParseQueueJob).toMatchObject( + expect.objectContaining({ + id: '2', + name: jobId, + data: { + _id: jobId, + _environmentId, + _organizationId, + _userId, + }, + attemptsMade: 0, + }) + ); + }); + }); + + describe('Cluster mode', () => { + beforeAll(async () => { + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + + inboundParseQueueService = new InboundParseQueueService(); + await inboundParseQueueService.queue.obliterate(); + }); + + afterAll(async () => { + await inboundParseQueueService.gracefulShutdown(); + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; + }); + + it('should have prefix in cluster mode', async () => { + expect(inboundParseQueueService.queue.opts.prefix).toEqual( + '{inbound-parse-mail}' + ); + }); + }); +}); diff --git a/packages/application-generic/src/services/queues/inbound-parse-queue.service.ts b/packages/application-generic/src/services/queues/inbound-parse-queue.service.ts index 3e1a5f4096e..6dc20a8ec4d 100644 --- a/packages/application-generic/src/services/queues/inbound-parse-queue.service.ts +++ b/packages/application-generic/src/services/queues/inbound-parse-queue.service.ts @@ -3,6 +3,8 @@ import { JobTopicNameEnum } from '@novu/shared'; import { QueueBaseService } from './index'; +import { QueueOptions } from '../bull-mq'; + const LOG_CONTEXT = 'InboundParseQueueService'; @Injectable() @@ -12,6 +14,20 @@ export class InboundParseQueueService extends QueueBaseService { Logger.log(`Creating queue ${this.topic}`, LOG_CONTEXT); - this.createQueue(); + this.createQueue(this.getOverrideOptions()); + } + + private getOverrideOptions(): QueueOptions { + return { + defaultJobOptions: { + attempts: 5, + backoff: { + delay: 4000, + type: 'exponential', + }, + removeOnComplete: true, + removeOnFail: true, + }, + }; } } diff --git a/packages/application-generic/src/services/queues/queue-base.service.ts b/packages/application-generic/src/services/queues/queue-base.service.ts index 55697a58f4e..02dc5359990 100644 --- a/packages/application-generic/src/services/queues/queue-base.service.ts +++ b/packages/application-generic/src/services/queues/queue-base.service.ts @@ -19,8 +19,18 @@ export class QueueBaseService { return this.instance; } - public createQueue(): void { - this.queue = this.instance.createQueue(this.topic, this.getQueueOptions()); + public createQueue(overrideOptions?: QueueOptions): void { + const options = { + ...this.getQueueOptions(), + ...(overrideOptions && { + defaultJobOptions: { + ...this.getQueueOptions().defaultJobOptions, + ...overrideOptions.defaultJobOptions, + }, + }), + }; + + this.queue = this.instance.createQueue(this.topic, options); } private getQueueOptions(): QueueOptions { diff --git a/apps/api/src/app/user/usecases/create-user/create-user.dto.ts b/packages/application-generic/src/usecases/create-user/create-user.command.ts similarity index 83% rename from apps/api/src/app/user/usecases/create-user/create-user.dto.ts rename to packages/application-generic/src/usecases/create-user/create-user.command.ts index 8a5771e4464..334161fdc27 100644 --- a/apps/api/src/app/user/usecases/create-user/create-user.dto.ts +++ b/packages/application-generic/src/usecases/create-user/create-user.command.ts @@ -1,5 +1,5 @@ import { AuthProviderEnum } from '@novu/shared'; -import { BaseCommand } from '../../../shared/commands/base.command'; +import { BaseCommand } from '../../commands'; export class CreateUserCommand extends BaseCommand { email: string; diff --git a/apps/api/src/app/user/usecases/create-user/create-user.usecase.ts b/packages/application-generic/src/usecases/create-user/create-user.usecase.ts similarity index 94% rename from apps/api/src/app/user/usecases/create-user/create-user.usecase.ts rename to packages/application-generic/src/usecases/create-user/create-user.usecase.ts index bdfbe87a2c3..a365ae2a580 100644 --- a/apps/api/src/app/user/usecases/create-user/create-user.usecase.ts +++ b/packages/application-generic/src/usecases/create-user/create-user.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { UserEntity, UserRepository } from '@novu/dal'; -import { CreateUserCommand } from './create-user.dto'; +import { CreateUserCommand } from './create-user.command'; @Injectable() export class CreateUser { diff --git a/packages/application-generic/src/usecases/create-user/index.ts b/packages/application-generic/src/usecases/create-user/index.ts new file mode 100644 index 00000000000..83b360e0164 --- /dev/null +++ b/packages/application-generic/src/usecases/create-user/index.ts @@ -0,0 +1,2 @@ +export * from './create-user.usecase'; +export * from './create-user.command'; diff --git a/packages/application-generic/src/usecases/index.ts b/packages/application-generic/src/usecases/index.ts index 76aaf8cee1e..cec41c573b7 100644 --- a/packages/application-generic/src/usecases/index.ts +++ b/packages/application-generic/src/usecases/index.ts @@ -25,3 +25,6 @@ export * from './create-tenant'; export * from './get-tenant'; export * from './process-tenant'; export * from './conditions-filter'; +export * from './switch-environment'; +export * from './switch-organization'; +export * from './create-user'; diff --git a/packages/application-generic/src/usecases/switch-environment/index.ts b/packages/application-generic/src/usecases/switch-environment/index.ts new file mode 100644 index 00000000000..cb3beb29002 --- /dev/null +++ b/packages/application-generic/src/usecases/switch-environment/index.ts @@ -0,0 +1,2 @@ +export * from './switch-environment.command'; +export * from './switch-environment.usecase'; diff --git a/apps/api/src/app/auth/usecases/switch-environment/switch-environment.command.ts b/packages/application-generic/src/usecases/switch-environment/switch-environment.command.ts similarity index 65% rename from apps/api/src/app/auth/usecases/switch-environment/switch-environment.command.ts rename to packages/application-generic/src/usecases/switch-environment/switch-environment.command.ts index 5bf2c7cb684..de73e16cffe 100644 --- a/apps/api/src/app/auth/usecases/switch-environment/switch-environment.command.ts +++ b/packages/application-generic/src/usecases/switch-environment/switch-environment.command.ts @@ -1,5 +1,5 @@ import { IsNotEmpty } from 'class-validator'; -import { OrganizationCommand } from '../../../shared/commands/organization.command'; +import { OrganizationCommand } from '../../commands'; export class SwitchEnvironmentCommand extends OrganizationCommand { @IsNotEmpty() diff --git a/apps/api/src/app/auth/usecases/switch-environment/switch-environment.usecase.ts b/packages/application-generic/src/usecases/switch-environment/switch-environment.usecase.ts similarity index 61% rename from apps/api/src/app/auth/usecases/switch-environment/switch-environment.usecase.ts rename to packages/application-generic/src/usecases/switch-environment/switch-environment.usecase.ts index 8963c0793f5..295769ea924 100644 --- a/apps/api/src/app/auth/usecases/switch-environment/switch-environment.usecase.ts +++ b/packages/application-generic/src/usecases/switch-environment/switch-environment.usecase.ts @@ -1,7 +1,17 @@ -import { forwardRef, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; -import { EnvironmentRepository, MemberRepository, OrganizationRepository, UserRepository } from '@novu/dal'; -import { AuthService } from '../../services/auth.service'; +import { + forwardRef, + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { + EnvironmentRepository, + MemberRepository, + UserRepository, +} from '@novu/dal'; import { SwitchEnvironmentCommand } from './switch-environment.command'; +import { AuthService } from '../../services/auth/auth.service'; @Injectable() export class SwitchEnvironment { @@ -13,19 +23,29 @@ export class SwitchEnvironment { ) {} async execute(command: SwitchEnvironmentCommand) { - const project = await this.environmentRepository.findById(command.newEnvironmentId); + const project = await this.environmentRepository.findById( + command.newEnvironmentId + ); if (!project) throw new NotFoundException('Environment not found'); if (project._organizationId !== command.organizationId) { throw new UnauthorizedException('Not authorized for organization'); } - const member = await this.memberRepository.findMemberByUserId(command.organizationId, command.userId); + const member = await this.memberRepository.findMemberByUserId( + command.organizationId, + command.userId + ); if (!member) throw new NotFoundException('Member is not found'); const user = await this.userRepository.findById(command.userId); if (!user) throw new NotFoundException('User is not found'); - const token = await this.authService.getSignedToken(user, command.organizationId, member, command.newEnvironmentId); + const token = await this.authService.getSignedToken( + user, + command.organizationId, + member, + command.newEnvironmentId + ); return token; } diff --git a/packages/application-generic/src/usecases/switch-organization/index.ts b/packages/application-generic/src/usecases/switch-organization/index.ts new file mode 100644 index 00000000000..3d2cfef5222 --- /dev/null +++ b/packages/application-generic/src/usecases/switch-organization/index.ts @@ -0,0 +1,2 @@ +export * from './switch-organization.command'; +export * from './switch-organization.usecase'; diff --git a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts b/packages/application-generic/src/usecases/switch-organization/switch-organization.command.ts similarity index 65% rename from apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts rename to packages/application-generic/src/usecases/switch-organization/switch-organization.command.ts index 35b5ad42fdf..390622e513f 100644 --- a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts +++ b/packages/application-generic/src/usecases/switch-organization/switch-organization.command.ts @@ -1,5 +1,5 @@ import { IsNotEmpty } from 'class-validator'; -import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { AuthenticatedCommand } from '../../commands'; export class SwitchOrganizationCommand extends AuthenticatedCommand { @IsNotEmpty() diff --git a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts b/packages/application-generic/src/usecases/switch-organization/switch-organization.usecase.ts similarity index 55% rename from apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts rename to packages/application-generic/src/usecases/switch-organization/switch-organization.usecase.ts index 527d20b0211..e6108ffbc75 100644 --- a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts +++ b/packages/application-generic/src/usecases/switch-organization/switch-organization.usecase.ts @@ -1,8 +1,18 @@ -import { forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import { MemberRepository, OrganizationRepository, UserRepository, EnvironmentRepository } from '@novu/dal'; +import { + forwardRef, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { + MemberRepository, + OrganizationRepository, + UserRepository, + EnvironmentRepository, +} from '@novu/dal'; import { SwitchOrganizationCommand } from './switch-organization.command'; -import { AuthService } from '../../services/auth.service'; -import { ApiException } from '../../../shared/exceptions/api.exception'; +import { AuthService } from '../../services/auth/auth.service'; +import { ApiException } from '../../utils/exceptions'; @Injectable() export class SwitchOrganization { @@ -15,15 +25,21 @@ export class SwitchOrganization { ) {} async execute(command: SwitchOrganizationCommand): Promise { - const isAuthenticated = await this.authService.isAuthenticatedForOrganization( - command.userId, - command.newOrganizationId - ); + const isAuthenticated = + await this.authService.isAuthenticatedForOrganization( + command.userId, + command.newOrganizationId + ); if (!isAuthenticated) { - throw new UnauthorizedException(`Not authorized for organization ${command.newOrganizationId}`); + throw new UnauthorizedException( + `Not authorized for organization ${command.newOrganizationId}` + ); } - const member = await this.memberRepository.findMemberByUserId(command.newOrganizationId, command.userId); + const member = await this.memberRepository.findMemberByUserId( + command.newOrganizationId, + command.userId + ); if (!member) throw new ApiException('Member not found'); const user = await this.userRepository.findById(command.userId); @@ -34,7 +50,12 @@ export class SwitchOrganization { _parentId: { $exists: false }, }); - const token = await this.authService.getSignedToken(user, command.newOrganizationId, member, environment?._id); + const token = await this.authService.getSignedToken( + user, + command.newOrganizationId, + member, + environment?._id + ); return token; } diff --git a/packages/application-generic/src/utils/email-normalization.ts b/packages/application-generic/src/utils/email-normalization.ts new file mode 100644 index 00000000000..fa18f9f735e --- /dev/null +++ b/packages/application-generic/src/utils/email-normalization.ts @@ -0,0 +1,48 @@ +const PLUS_ONLY = /\+.*$/; +const PLUS_AND_DOT = /\.|\+.*$/g; +const normalizableProviders = { + 'gmail.com': { + cut: PLUS_AND_DOT, + }, + 'googlemail.com': { + cut: PLUS_AND_DOT, + aliasOf: 'gmail.com', + }, + 'hotmail.com': { + cut: PLUS_ONLY, + }, + 'live.com': { + cut: PLUS_AND_DOT, + }, + 'outlook.com': { + cut: PLUS_ONLY, + }, +}; + +export function normalizeEmail(email: string): string { + if (typeof email !== 'string') { + throw new TypeError('normalize-email expects a string'); + } + + const lowerCasedEmail = email.toLowerCase(); + const emailParts = lowerCasedEmail.split(/@/); + + if (emailParts.length !== 2) { + return email; + } + + let username = emailParts[0]; + let domain = emailParts[1]; + + if (normalizableProviders.hasOwnProperty(domain)) { + if (normalizableProviders[domain].hasOwnProperty('cut')) { + username = username.replace(normalizableProviders[domain].cut, ''); + } + + if (normalizableProviders[domain].hasOwnProperty('aliasOf')) { + domain = normalizableProviders[domain].aliasOf; + } + } + + return `${username}@${domain}`; +} diff --git a/packages/node/README.md b/packages/node/README.md index 7f117943f31..12a5a869892 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -29,17 +29,18 @@ The ultimate service for managing multi-channel notifications with a single API. · Twitter · - Notifications Directory - . + Notifications Directory. Read our blog

+ ## ⭐️ Why -Building a notification system is hard, at first it seems like just sending an email but in reality it's just the beginning. In today's world users expect multichannel communication experience over email, sms, push, chat and more... An ever-growing list of providers are popping up each day, and notifications are spread around the code. Novu's goal is to simplify notifications and provide developers the tools to create meaningful communication between the system and its users. + +Building a notification system is hard, at first it seems like just sending an email but in reality, it's just the beginning. In today's world users expect multi-channel communication experience over email, sms, push, chat, and more... An ever-growing list of providers is popping up each day, and notifications are spread around the code. Novu's goal is to simplify notifications and provide developers the tools to create meaningful communication between the system and its users. ## ✨ Features - 🌈 Single API for all messaging providers (Email, SMS, Push, Chat) -- 💅 Easily manage notification over multiple channels +- 💅 Easily manage notifications over multiple channels - 🚀 Equipped with a templating engine for advanced layouts and designs - 🛡 Built-in protection for missing variables - 📦 Easy to set up and integrate @@ -135,3 +136,951 @@ Novu provides a single API to manage providers across multiple channels with a s ## 🔗 Links - [Home page](https://novu.co/) + + +## SDK Methods + +- [Subscribers](#subscribers) +- [Events](#events) +- [Workflows](#workflows) +- [Notification Groups](#notification-groups) +- [Topics](#topics) +- [Feeds](#feeds) +- [Tenants](#tenants) +- [Messages](#messages) +- [Changes](#changes) +- [Environments](#environments) +- [Layouts](#layouts) +- [Integrations](#integrations) + + +### Subscribers + +- #### List all subscribers + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const page = 0; +const limit = 20; + +await novu.subscribers.list(page,limit) +``` + +- #### Identify (create) a new subscriber +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.identify("subscriberId",{ + firstName: "Pawan"; + lastName: "Jain"; + email: "pawan.jain@domain.com"; + phone: "+1234567890"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-US"; + data: { + isDeveloper : true + customKey: "customValue" + }; +}) +``` + + +- #### Bulk create subscribers +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.identify([ + { + subscriberId: "1" + firstName: "Pawan"; + lastName: "Jain"; + email: "pawan.jain@domain.com"; + phone: "+1234567890"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-US"; + data: { + isDeveloper : true + customKey: "customValue" + }; + }, + { + subscriberId: "2" + firstName: "John"; + lastName: "Doe"; + email: "john.doe@domain.com"; + phone: "+1234567891"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-UK"; + data: { + isDeveloper : false + customKey1: "customValue1" + }; + } + // more subscribers ... +]) +``` + + +- #### Get a single subscriber +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.get("subscriberId") +``` + +- #### Update a subscriber + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.update("subscriberId",{ + firstName: "Pawan"; + lastName: "Jain"; + email: "pawan.jain@domain.com"; + phone: "+1234567890"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-US"; + data: { + isDeveloper : true + customKey: "customValue" + customKey2: "customValue2" + }; +}) +``` + +- #### Update provider credentials +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// update fcm token +await novu.subscribers.setCredentials("subscriberId", "fcm", { + deviceTokens: ["token1", "token2"] +}) + +// update slack webhookurl +await novu.subscribers.setCredentials("subscriberId", "slack", { + webhookUrl: ["webhookUrl"] +}) +``` + +- #### Delete provider credentials +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// delete fcm token +await novu.subscribers.deleteCredentials("subscriberId", "fcm") + +// delete slack webhookurl +await novu.subscribers.deleteCredentials("subscriberId", "slack") +``` + +- #### Delete a subscriber + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.delete("subscriberId") +``` + +- #### Update online status + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// mark subscriber as offline +await novu.subscribers.updateOnlineStatus("subscriberId", false) +``` + +- #### Get subscriber preference for all workflows +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.getPreference("subscriberId") +``` + +- #### Update subscriber preference for a workflow +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// enable in-app channel +await novu.subscribers.updatePreference("subscriberId", "workflowId", { + channel: { + type: "in_app" + enabled: true + } +}) + + +// disable email channel +await novu.subscribers.updatePreference("subscriberId", "workflowId", { + channel: { + type: "email" + enabled: + } +}) +``` + +- #### Get in-app messages (notifications) feed for a subscriber + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const params = { + page: 0; + limit: 20; + // copy this value from in-app editor + feedIdentifier: "feedId"; + seen: true + read: false + payload: { + "customkey": "customValue" + } +} + +await novu.subscribers.getNotificationsFeed("subscriberId", params); +``` + +- #### Get seen/unseen in-app messages (notifications) count +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// get seen count +await novu.subscribers.getUnseenCount("subscriberId", true); + +// get unseen count +await novu.subscribers.getUnseenCount("subscriberId", false); +``` + +- #### Mark an in-app message (notification) as seen/unseen/read/unread + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// mark unseen +await novu.subscribers.markMessageAs("subscriberId", "messageId", { + seen: false +}); + +// mark seen and unread +await novu.subscribers.markMessageAs("subscriberId", "messageId", { + seen: true, + read: false +}); +``` + +- #### Mark all in-app messages (notifications) as seen/unseen/read/unread + +```ts +import { Novu, MarkMessagesAsEnum } from '@novu/node'; + +const novu = new Novu(''); + +// mark all messages as seen +await novu.subscribers.markAllMessagesAs("subscriberId", MarkMessageAsEnum.SEEN, "feedId"); + +// mark all messages as read +await novu.subscribers.markAllMessagesAs("subscriberId", MarkMessageAsEnum.READ, "feedId"); +``` + +- #### Mark in-app message (notification) action as seen + +```ts +import { Novu, ButtonTypeEnum, MessageActionStatusEnum } from '@novu/node'; + +const novu = new Novu(''); + +// mark a message's primary action button as pending +await novu.subscribers.markMessageActionSeen("subscriberId", "messageId", ButtonTypeEnum.PRIMARY, { + status: MessageActionStatusEnum.PENDING +}); + +// mark a message's secondary action button as done +await novu.subscribers.markMessageActionSeen("subscriberId", "messageId", ButtonTypeEnum.SECONDARY, { + status: MessageActionStatusEnum.DONE +}); +``` + +### Events + +- #### Trigger workflow to one subscriber + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// trigger to existing subscribers +await novu.subscribers.trigger("workflowIdentifier", { + to: "subscriberId", + payload: { + customKey: "customValue", + customKey1: { + nestedkey1: "nestedValue1" + } + }, + overrides: { + email: { + from: "support@novu.co" + } + }, + // actorId is subscriberId of actor + actor: "actorId" + tenant: "tenantIdentifier" +}); + +// create new subscriber inline with trigger +await novu.subscribers.trigger("workflowIdentifier", { + to: { + subscriberId: "1" + firstName: "Pawan"; + lastName: "Jain"; + email: "pawan.jain@domain.com"; + phone: "+1234567890"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-US"; + data: { + isDeveloper : true + customKey: "customValue" + }; + }, + payload: {}, + overrides:{} , + actor: "actorId" + tenant: "tenantIdentifier" +}); +``` + +- #### Trigger workflow to multiple subscribers + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.trigger("workflowIdentifier", { + to: [ "subscriberId1" , "subscriberId2" ], + payload: {}, + overrides:{} , + actor: "actorId" + tenant: "tenantIdentifier" +}); + + +// create new subscribers inline with trigger +await novu.subscribers.trigger("workflowIdentifier", { + to: [ + { + subscriberId: "1" + firstName: "Pawan"; + lastName: "Jain"; + email: "pawan.jain@domain.com"; + phone: "+1234567890"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-US"; + data: { + isDeveloper : true + customKey: "customValue" + }; + }, + { + subscriberId: "2" + firstName: "John"; + lastName: "Doe"; + email: "john.doe@domain.com"; + phone: "+1234567891"; + avatar: "https://gravatar.com/avatar/553b157d82ac2880237566d5a644e5fe?s=400&d=robohash&r=x"; + locale: "en-UK"; + data: { + isDeveloper : false + customKey1: "customValue1" + }; + } + ], + payload: {}, + overrides:{} , + actor: "actorId" + tenant: "tenantIdentifier" +}); +``` + +- #### Trigger to a topic +```ts +import { Novu, TriggerRecipientsTypeEnum } from '@novu/node'; + +const novu = new Novu(''); + +await novu.events.trigger("workflowIdentifier", { + to: { + type: TriggerRecipientsTypeEnum.TOPIC; + topicKey: TopicKey; + } +}) +``` + +- #### Bulk trigger multiple workflows to multiple subscribers + +There is a limit of 100 items in the array of bulkTrigger. + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.bulkTrigger([ + { + name: "workflowIdentifier_1", + to: "subscriberId_1", + payload: { + customKey: "customValue", + customKey1: { + nestedkey1: "nestedValue1" + } + }, + overrides: { + email: { + from: "support@novu.co" + } + }, + // actorId is subscriberId of actor + actor: "actorId" + tenant: "tenantIdentifier" + }, + { + name: "workflowIdentifier_2", + to: "subscriberId_2", + payload: { + customKey: "customValue", + customKey1: { + nestedkey1: "nestedValue1" + } + }, + overrides: { + email: { + from: "support@novu.co" + } + }, + // actorId is subscriberId of actor + actor: "actorId" + tenant: "tenantIdentifier" + } +]) +``` + +- #### Broadcast to all subscribers + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.broadcast("workflowIdentifier", { + payload: { + customKey: "customValue", + customKey1: { + nestedkey1: "nestedValue1" + } + }, + overrides: { + email: { + from: "support@novu.co" + } + }, + tenant: "tenantIdentifier" +}) +``` + +- #### Cancel the triggered workflow + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.cancel("transactionId"); +``` +### Messages + +- #### List all messages + +```ts +import { Novu, ChannelTypeEnum } from '@novu/node'; + +const novu = new Novu(''); + +const params = { + page: 0, // optional + limit: 20, // optional + subscriberId: "subscriberId" //optional + channel: ChannelTypeEnum.EMAIL //optional + transactionIds : ["txnId1","txnId2"] //optional +} + +await novu.messages.list(params) +``` + +- #### Delete a message by `messageId` + +```ts +import { Novu, ChannelTypeEnum } from '@novu/node'; + +const novu = new Novu(''); + +await novu.messages.deleteById("messageId"); +``` + +### Layouts + +- #### Create a layout + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const payload = { + content: "

Layout Start

{{{body}}}

Layout End

", + description: "Organisation's first layout", + name: "First Layout", + identifier: "firstlayout", + variables: [ + { + type: "String", + name: "body" + required: true + defValue: "" + } + ] + isDefault: "false" +} +await novu.layouts.create(payload); +``` + +- #### Update a layout + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const payloadToUpdate = { + content: "

Layout Start

{{{body}}}

Layout End

", + description: "Organisation's first layout", + name: "First Layout", + identifier: "firstlayout", + variables: [ + { + type: "String", + name: "body" + required: true + defValue: "" + } + ] + isDefault: false +} +await novu.layouts.update("layoutId", payloadToUpdate); +``` + +- #### Set a layout as the default layout + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.layouts.setDefault("layoutId"); +``` + +- #### Get a layout by `layoutId` + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.layouts.get("layoutId"); +``` + +- #### Delete a layout by `layoutId` + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.layouts.delete("layoutId"); +``` + +- #### List all layouts + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const params = { + page: 0, // optional + pageSize: 20, // optional + sortBy: "_id" + orderBy: -1 //optional +} + +await novu.layouts.list(params); +``` + +### Notification Groups + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// create a new notification group +await novu.notificationGroups.create("Product Updates") + +// update an exisiting notification group +await novu.notificationGroups.update("notificationGroupId", { name: "Changelog Updates"}) + +// list all notification groups +await novu.notificationGroups.get() + +// get one exisiting notification group +await novu.notificationGroups.getOne("notificationGroupId") + +// delete an existing notification group +await novu.notificationGroups.delete("notificationGroupId") +``` + +### Topics + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +const payloadToCreate = { + key: "first-topic", + name: "First Topic" +} + +// create new topic +await novu.topics.create(payloadToCreate) + +// add subscribers +await novu.topics.addSubscribers("topicKey", { subscribers: ["subscriberId1", "subscriberId2"] }) + +// check if subscriber is present in topic +await novu.topics.checkSubscriber("topicKey", "subscriberId") + +// remove subscribers from topic +await novu.topics.removeSubscribers("topicKey", { subscribers: ["subscriberId1", "subscriberId2"] } ) + +const topicsListParams = { + page: 0, //optional + pageSize: 20, + key: "topicKey" +} + +// list all topics +await novu.topics.list(topicsListParams) + +// get a topic +await novu.topics.get("topicKey") + +// delete a topic +await novu.topics.delete("topicKey") + +// get a topic +await novu.topics.rename("topicKey", "New Topic Name") +``` + +### Integrations + +```ts +import { Novu, ChannelTypeEnum, ProvidersIdEnum } from '@novu/node'; + +const novu = new Novu(''); + +const updatePayload = { + name: "SendGrid", + identifier: "sendgrid-identifier", + credentials: { + apiKey: "SUPER_SECRET_API_KEY", + from: "sales@novu.co", + senderName: "Novu Sales Team" + // ... other credentials as per provider + }, + active: true, + check: false +} + +const createPayload: { + ...updatePayload, + channel: ChannelTypeEnum.EMAIL, +} + +// create a new integration +await novu.integrations.create(ProvidersIdEnum.SendGrid, createPayload) + +// update integration +await novu.integrations.update("integrationId", updatePayload) + +// get all integrations +await novu.integrations.getAll() + +// get only active integrations +await novu.integrations.getActive() + +// get webhook provider status +await novu.integrations.getWebhookProviderStatus(ProvidersIdEnum.SendGrid) + +// delete existing integration +await novu.integrations.delete("integrationId") + +// get novu in-app status +await novu.integrations.getInAppStatus() +``` + +### Feeds + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// create new in-app feed +await novu.feeds.create("Product Updates") + +/** + * get all in-app feeds + * feeds methods returns only feed information + * use subscriber.notificationsFeed() for in-app messages + */ +await novu.feeds.get() + +// delete a feed +await novu.feeds.delete("feedId") +``` + +### Changes + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// get all changes +await novu.changes.get() + +// get changes count +await novu.changes.getCount() + +// apply only one change +await novu.changes.applyOne("changeId") + +// apply many changes +await novu.changes.applyMany(["changeId1", "changeId2"]) +``` + +### Environments + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// get current environmet +await novu.environments.getCurrent() + +// create new environment +await novu.environments.create({ + name: "Stagging" + parentId: "parentEnvironmentId" +}) + +// get all environmemts +await novu.environments.getAll() + +// update one environment +await novu.environments.updateOne("environmentId", { + name: "Stagging" // optional + parentId: "parentEnvironmentId", // optional + identifier: "environmentIdentifier" // optional +}) + +// get api keys of environmet +await novu.environments.getApiKeys() + +// regenrate api keys +await novu.environments.regenerateApiKeys() +``` + +### Tenants + +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// create new tenat +await novu.tenants.create("tenantIdentifier", { + name: "First Tenant", + // optional + data: { + country: "US", + tokens: ["token1", "token2"], + isDeveloperTenant : true, + numberOfMembers: 2, + isSales : undefined + } +}) + +// update existing tenant +await novu.tenants.update("tenantIdentifier", { + identifier: "tenantIdentifier1", + name: "Second Tenant", + // optional + data: { + country: "India", + tokens: ["token1", "token2"], + isDeveloperTenant : true, + numberOfMembers: 2, + isSales : undefined + } +}) + +// list all tenants +await novu.tenants.list({ + page: 0, // optional + limit: 20 // optional +}) + +// delete a tenant +await novu.tenants.delete("tenantIdentifier") + +// get one tenant +await novu.tenants.get("tenantIdentifier") +``` + +### Workflows + +- #### Create a new workflow + +```ts +import { Novu, TemplateVariableTypeEnum, FilterPartTypeEnum, StepTypeEnum } from '@novu/node'; + +const novu = new Novu(''); + +// List all workflow groups +const { data: workflowGroupsData } = await novu.notificationGroups.get(); + +// Create a new workflow +await novu.notificationTemplates.create({ + name: 'Onboarding Workflow', + // taking first workflow group id + notificationGroupId: workflowGroupsData.data[0]._id, + steps: [ + // Adding one chat step + { + active: true, + shouldStopOnFail: false, + // UUID is optional. + uuid: '78ab8c72-46de-49e4-8464-257085960f9e', + name: 'Chat', + filters: [ + { + value: 'AND', + children: [ + { + field: '{{chatContent}}', + value: 'flag', + operator: 'NOT_IN', + // 'payload' + on: FilterPartTypeEnum.PAYLOAD, + }, + ], + }, + ], + template: { + // 'chat' + type: StepTypeEnum.CHAT, + active: true, + subject: '', + variables: [ + { + name: 'chatContent', + // 'String' + type: TemplateVariableTypeEnum.STRING, + required: true, + }, + ], + content: '{{chatContent}}', + contentType: 'editor', + }, + }, + ], + description: 'Onboarding workflow to trigger after user sign up', + active: true, + draft: false, + critical: false, +}); +``` + +- #### Other Methods + +```ts +import { Novu, TemplateVariableTypeEnum, FilterPartTypeEnum, StepTypeEnum } from '@novu/node'; + +// update a workflow + +await novu.notificationTemplates.update("workflowId", { + name: "Send daily digest email update", + description: "This workflow will send daily digest email to user at 9:00 AM" + /** + * all other fields from create workflow payload + */ +}) + +// get one workflow +await novu.notificationTemplates.getOne("workflowId") + +// delete one workflow +await novu.notificationTemplates.delete("workflowId") + +// update status of one workflow +await novu.notificationTemplates.updateStatus("workflowId", false) + +// list all workflows +await novu.notificationTemplates.getAll({ + page: 0, // optional + limit: 20 // optional +}) +``` diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 7371be1c7be..64cbe3e9569 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,6 +1,9 @@ export { ChatProviderIdEnum, PushProviderIdEnum, + EmailProviderIdEnum, + SmsProviderIdEnum, + ProvidersIdEnum, ChannelCTATypeEnum, TemplateVariableTypeEnum, IMessageTemplate, diff --git a/packages/notification-center/src/i18n/languages/es.ts b/packages/notification-center/src/i18n/languages/es.ts index ae0aeab949b..643acd1f9f5 100644 --- a/packages/notification-center/src/i18n/languages/es.ts +++ b/packages/notification-center/src/i18n/languages/es.ts @@ -3,10 +3,13 @@ import { ITranslationEntry } from '../lang'; export const ES: ITranslationEntry = { translations: { notifications: 'Notificaciones', - markAllAsRead: 'marcar todo como leído', + markAllAsRead: 'Marcar todo como leído', poweredBy: 'Con tecnología de', settings: 'Configuración', - noNewNotification: 'Nada nuevo que ver aquí todavía', + removeMessage: 'Eliminar mensaje', + markAsRead: 'Marcar como leído', + markAsUnRead: 'Marcar como no leído', + noNewNotification: 'Nada nuevo por aquí', }, lang: 'es', }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c53a76ce767..36604f72a1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,7 @@ importers: '@nestjs/testing': ^10.2.2 '@novu/application-generic': ^0.19.0 '@novu/dal': ^0.19.0 + '@novu/ee-auth': ^0.19.0 '@novu/node': ^0.19.0 '@novu/shared': ^0.19.0 '@novu/stateless': ^0.19.0 @@ -296,6 +297,8 @@ importers: swagger-ui-express: 4.6.2_express@4.18.2 twilio: 4.15.0 uuid: 8.3.2 + optionalDependencies: + '@novu/ee-auth': link:../../enterprise/packages/auth devDependencies: '@faker-js/faker': 6.3.1 '@nestjs/cli': 10.1.16 @@ -324,10 +327,14 @@ importers: specifiers: '@novu/application-generic': ^0.19.0 '@novu/shared': ^0.19.0 + '@novu/testing': ^0.19.0 '@sentry/node': ^7.12.1 + '@types/chai': ^4.2.11 '@types/express': ^4.17.8 '@types/html-to-text': ^9.0.1 + '@types/mocha': ^8.2.3 '@types/node': ^14.14.6 + '@types/sinon': ^9.0.0 '@types/smtp-server': ^3.5.7 bluebird: ^2.9.30 cross-env: ^7.0.3 @@ -338,12 +345,13 @@ importers: languagedetect: ^1.1.1 lodash: ^4.17.15 mailparser: ^0.6.0 + mocha: ^8.4.0 newrelic: ^9.15.0 - node-uuid: ^1.4.3 nodemon: ^2.0.7 prettier: ~2.8.0 rimraf: ^3.0.2 shelljs: ^0.8.5 + sinon: ^9.2.4 smtp-server: ^1.4.0 spamc: 0.0.5 ts-jest: ^27.0.7 @@ -351,7 +359,8 @@ importers: ts-node: ~10.9.1 tsconfig-paths: ~4.1.0 typescript: 4.9.5 - winston: ^1.0.0 + uuid: ^9.0.0 + winston: ^3.9.0 dependencies: '@novu/application-generic': link:../../packages/application-generic '@novu/shared': link:../../libs/shared @@ -365,20 +374,26 @@ importers: lodash: 4.17.21 mailparser: 0.6.2 newrelic: 9.15.0 - node-uuid: 1.4.8 rimraf: 3.0.2 shelljs: 0.8.5 smtp-server: 1.17.0 spamc: 0.0.5 - winston: 1.1.2 + uuid: 9.0.0 + winston: 3.10.0 devDependencies: + '@novu/testing': link:../../libs/testing + '@types/chai': 4.3.4 '@types/express': 4.17.17 '@types/html-to-text': 9.0.1 + '@types/mocha': 8.2.3 '@types/node': 14.18.42 + '@types/sinon': 9.0.11 '@types/smtp-server': 3.5.7 cross-env: 7.0.3 + mocha: 8.4.0 nodemon: 2.0.22 prettier: 2.8.7 + sinon: 9.2.4 ts-jest: 27.1.5_cnngzrja2umb46xxazlucyx2qu ts-loader: 9.4.2_rggdtlzfqxxwxudp3onsqdyocm ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna @@ -1137,6 +1152,84 @@ importers: stylelint-scss: 4.6.0_stylelint@15.10.1 typescript: 4.9.5 + enterprise/packages/auth: + specifiers: + '@nestjs/common': '>=9.3.x' + '@nestjs/jwt': '>=9' + '@nestjs/passport': 9.0.3 + '@novu/application-generic': ^0.19.0 + '@novu/dal': ^0.19.0 + '@novu/shared': ^0.19.0 + '@types/chai': ^4.2.11 + '@types/mocha': ^8.0.1 + '@types/node': ^14.6.0 + '@types/sinon': ^9.0.0 + chai: ^4.2.0 + cross-env: ^7.0.3 + mocha: ^8.1.1 + nodemon: ^2.0.3 + passport: 0.6.0 + passport-google-oauth: ^2.0.0 + passport-oauth2: ^1.6.1 + sinon: ^9.2.4 + ts-node: ~10.9.1 + typescript: 4.9.5 + dependencies: + '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde + '@nestjs/jwt': 10.1.0_@nestjs+common@10.2.2 + '@nestjs/passport': 9.0.3_kn4ljbedllcoqpuu4ifhphsdsu + '@novu/application-generic': link:../../../packages/application-generic + '@novu/dal': link:../../../libs/dal + '@novu/shared': link:../../../libs/shared + passport: 0.6.0 + passport-google-oauth: 2.0.0 + passport-oauth2: 1.7.0 + devDependencies: + '@types/chai': 4.3.4 + '@types/mocha': 8.2.3 + '@types/node': 14.18.42 + '@types/sinon': 9.0.11 + chai: 4.3.7 + cross-env: 7.0.3 + mocha: 8.4.0 + nodemon: 2.0.22 + sinon: 9.2.4 + ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna + typescript: 4.9.5 + + enterprise/packages/digest-schedule: + specifiers: + '@novu/shared': ^0.19.0 + '@types/chai': ^4.2.11 + '@types/mocha': ^8.0.1 + '@types/node': ^14.6.0 + '@types/sinon': ^9.0.0 + chai: ^4.2.0 + cross-env: ^7.0.3 + date-fns: ^2.29.2 + mocha: ^8.1.1 + nodemon: ^2.0.3 + rrule: ^2.7.2 + sinon: ^9.2.4 + ts-node: ~10.9.1 + typescript: 4.9.5 + dependencies: + '@novu/shared': link:../../../libs/shared + date-fns: 2.29.3 + rrule: 2.7.2 + devDependencies: + '@types/chai': 4.3.4 + '@types/mocha': 8.2.3 + '@types/node': 14.18.42 + '@types/sinon': 9.0.11 + chai: 4.3.7 + cross-env: 7.0.3 + mocha: 8.4.0 + nodemon: 2.0.22 + sinon: 9.2.4 + ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna + typescript: 4.9.5 + libs/dal: specifiers: '@aws-sdk/client-s3': ^3.382.0 @@ -1386,6 +1479,7 @@ importers: '@istanbuljs/nyc-config-typescript': ^1.0.1 '@nestjs/common': '>=10' '@nestjs/core': '>=10' + '@nestjs/jwt': ^10.1.0 '@nestjs/swagger': '>=6' '@nestjs/terminus': '>=10' '@nestjs/testing': '>=10' @@ -1476,6 +1570,7 @@ importers: '@google-cloud/storage': 6.9.5 '@nestjs/common': 10.2.2_j3td4gnlgk75ora6o6suo62byy '@nestjs/core': 10.2.2_hvzojgbgemkkg4y2oz5vs6hq4y + '@nestjs/jwt': 10.1.0_@nestjs+common@10.2.2 '@nestjs/swagger': 7.1.9_yggpgkps2ewgemp53dklozvzx4 '@nestjs/terminus': 10.0.1_fav3sr7ld5p2uwyjvw6t25yci4 '@nestjs/testing': 10.2.2_h33h3l6i5mruhsbo3bha6vy2fi @@ -3759,7 +3854,7 @@ packages: engines: {node: '>=6.0.0'} dependencies: '@jridgewell/gen-mapping': 0.1.1 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 dev: true /@ampproject/remapping/2.2.1: @@ -10865,6 +10960,14 @@ packages: transitivePeerDependencies: - supports-color + /@dabh/diagnostics/2.0.3: + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + dev: false + /@design-systems/utils/2.12.0_zsjcj4gvi24ks76nprapl4hsmq: resolution: {integrity: sha512-Y/d2Zzr+JJfN6u1gbuBUb1ufBuLMJJRZQk+dRmw8GaTpqKx5uf7cGUYGTwN02dIb3I+Tf+cW8jcGBTRiFxdYFg==} peerDependencies: @@ -13753,7 +13856,7 @@ packages: '@jest/test-result': 29.5.0 '@jest/transform': 29.5.0 '@jest/types': 29.5.0 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 '@types/node': 14.18.42 chalk: 4.1.2 collect-v8-coverage: 1.0.1 @@ -14974,7 +15077,7 @@ packages: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.5.2 + semver: 7.5.4 tar: 6.1.13 transitivePeerDependencies: - encoding @@ -15127,6 +15230,26 @@ packages: - webpack-cli dev: true + /@nestjs/common/10.2.2_atc7tu2sld2m3nk4hmwkqn6qde: + resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.1.13 + rxjs: 7.8.1 + tslib: 2.6.2 + uid: 2.0.2 + dev: false + /@nestjs/common/10.2.2_j3td4gnlgk75ora6o6suo62byy: resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} peerDependencies: @@ -15354,6 +15477,16 @@ packages: passport: 0.6.0 dev: false + /@nestjs/passport/9.0.3_kn4ljbedllcoqpuu4ifhphsdsu: + resolution: {integrity: sha512-HplSJaimEAz1IOZEu+pdJHHJhQyBOPAYWXYHfAPQvRqWtw4FJF1VXl1Qtk9dcXQX1eKytDtH+qBzNQc19GWNEg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 + dependencies: + '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde + passport: 0.6.0 + dev: false + /@nestjs/platform-express/10.2.2_h33h3l6i5mruhsbo3bha6vy2fi: resolution: {integrity: sha512-g5AeXgPQrVm62JOl9FXk0w3Tq1tD4f6ouGikLYs/Aahy0q/Z2HNP9NjXZYpqcjHrpafPYnc3bfBuUwedKW1oHg==} peerDependencies: @@ -23114,6 +23247,7 @@ packages: /@types/node/18.15.11: resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} + dev: true /@types/node/20.4.7: resolution: {integrity: sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==} @@ -25411,10 +25545,6 @@ packages: /async-validator/4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - /async/1.0.0: - resolution: {integrity: sha512-5mO7DX4CbJzp9zjaFXusQQ4tzKJARjNB1Ih1pVBi8wkbmXy/xzIDgEMXxWePLzt2OdFwaxfneIlT1nCiXubrPQ==} - dev: false - /async/2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} dependencies: @@ -27536,6 +27666,13 @@ packages: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + /color/3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + dev: false + /color/4.2.3: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} @@ -27557,12 +27694,20 @@ packages: /colors/1.0.3: resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} engines: {node: '>=0.1.90'} + dev: true /colors/1.4.0: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} dev: true + /colorspace/1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + dev: false + /columnify/1.6.0: resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} engines: {node: '>=8.0.0'} @@ -27768,7 +27913,7 @@ packages: resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /concat-stream/1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -29230,6 +29375,7 @@ packages: /cycle/1.0.3: resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} engines: {node: '>=0.4.0'} + dev: true /cyclist/1.0.1: resolution: {integrity: sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==} @@ -30267,6 +30413,10 @@ packages: resolution: {integrity: sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg==} dev: false + /enabled/2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + dev: false + /encodeurl/1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -31193,7 +31343,7 @@ packages: object.values: 1.1.6 prop-types: 15.8.1 resolve: 2.0.0-next.4 - semver: 6.3.1 + semver: 6.3.0 string.prototype.matchall: 4.0.8 /eslint-plugin-spellcheck/0.0.20_eslint@8.38.0: @@ -31893,6 +32043,7 @@ packages: /eyes/0.1.8: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + dev: true /fast-copy/3.0.1: resolution: {integrity: sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==} @@ -32450,6 +32601,10 @@ packages: - encoding dev: false + /fn.name/1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + dev: false + /focus-lock/0.8.1: resolution: {integrity: sha512-/LFZOIo82WDsyyv7h7oc0MJF9ACOvDRdx9rWPZ2pgMfNWu/z8hQDBtOchuB/0BVLmuFOZjV02YwUVzNsWx/EzA==} engines: {node: '>=10'} @@ -36846,7 +37001,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 18.15.11 + '@types/node': 14.18.42 chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.11 @@ -37282,7 +37437,7 @@ packages: dev: false /json-buffer/3.0.0: - resolution: {integrity: sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=} + resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} /json-buffer/3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -37596,6 +37751,10 @@ packages: /known-css-properties/0.27.0: resolution: {integrity: sha512-uMCj6+hZYDoffuvAJjFAPz56E9uoowFHmTkqRtRq5WyC5Q6Cu/fTZKNQpX/RbzChBYLLl3lo8CjFZBAZXq9qFg==} + /kuler/2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + dev: false + /language-subtag-registry/0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} @@ -40081,12 +40240,6 @@ packages: /node-releases/2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} - /node-uuid/1.4.8: - resolution: {integrity: sha512-TkCET/3rr9mUuRp+CpO7qfgT++aAxfDRaalQhwPFzI9BY/2rCDn6OfpZOVggi1AXfTPpfkTrg5f5WQx5G1uLxA==} - deprecated: Use uuid module instead - hasBin: true - dev: false - /nodemailer-fetch/1.6.0: resolution: {integrity: sha512-P7S5CEVGAmDrrpn351aXOLYs1R/7fD5NamfMCHyi6WIkbjS2eeZUB/TkuvpOQr0bvRZicVqo59+8wbhR3yrJbQ==} dev: false @@ -40920,6 +41073,12 @@ packages: dependencies: wrappy: 1.0.2 + /one-time/1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + dependencies: + fn.name: 1.1.0 + dev: false + /onesignal-node/3.4.0: resolution: {integrity: sha512-9dNpfU5Xp6VhJLkdZT4kVqmOaU36RJOgp+6REQHyv+hLOcgqqa4/FRXxuHbjRCE51x9BK4jIC/gn2Mnw0gQgFQ==} engines: {node: '>=8.13.0'} @@ -41593,6 +41752,27 @@ packages: passport-oauth2: 1.7.0 dev: false + /passport-google-oauth/2.0.0: + resolution: {integrity: sha512-JKxZpBx6wBQXX1/a1s7VmdBgwOugohH+IxCy84aPTZNq/iIPX6u7Mqov1zY7MKRz3niFPol0KJz8zPLBoHKtYA==} + engines: {node: '>= 0.4.0'} + dependencies: + passport-google-oauth1: 1.0.0 + passport-google-oauth20: 2.0.0 + dev: false + + /passport-google-oauth1/1.0.0: + resolution: {integrity: sha512-qpCEhuflJgYrdg5zZIpAq/K3gTqa1CtHjbubsEsidIdpBPLkEVq6tB1I8kBNcH89RdSiYbnKpCBXAZXX/dtx1Q==} + dependencies: + passport-oauth1: 1.3.0 + dev: false + + /passport-google-oauth20/2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + dependencies: + passport-oauth2: 1.7.0 + dev: false + /passport-jwt/4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} dependencies: @@ -41600,6 +41780,15 @@ packages: passport-strategy: 1.0.0 dev: false + /passport-oauth1/1.3.0: + resolution: {integrity: sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==} + engines: {node: '>= 0.4.0'} + dependencies: + oauth: 0.9.15 + passport-strategy: 1.0.0 + utils-merge: 1.0.1 + dev: false + /passport-oauth2/1.7.0: resolution: {integrity: sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==} engines: {node: '>= 0.4.0'} @@ -41959,11 +42148,6 @@ packages: dependencies: find-up: 3.0.0 - /pkginfo/0.3.1: - resolution: {integrity: sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==} - engines: {node: '>= 0.4.0'} - dev: false - /please-upgrade-node/3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} dependencies: @@ -46114,7 +46298,7 @@ packages: /rrule/2.7.2: resolution: {integrity: sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==} dependencies: - tslib: 2.5.0 + tslib: 2.6.2 dev: false /rsvp/4.8.5: @@ -46645,6 +46829,7 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 + dev: true /semver/7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} @@ -48657,9 +48842,9 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 jest-worker: 27.5.1 - schema-utils: 3.1.2 + schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.3 webpack: 5.76.1 @@ -48681,9 +48866,9 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 jest-worker: 27.5.1 - schema-utils: 3.1.2 + schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.3 webpack: 5.78.0 @@ -48728,9 +48913,9 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 jest-worker: 27.5.1 - schema-utils: 3.1.2 + schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.3 webpack: 5.88.2 @@ -48751,10 +48936,10 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.19 '@swc/core': 1.3.49 jest-worker: 27.5.1 - schema-utils: 3.1.2 + schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.3 webpack: 5.88.2_@swc+core@1.3.49 @@ -48818,6 +49003,10 @@ packages: engines: {node: '>=0.10'} dev: true + /text-hex/1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + dev: false + /text-table/0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -49214,7 +49403,6 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.22.11 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 jest: 27.5.1_ts-node@10.9.1 @@ -51626,19 +51814,6 @@ packages: triple-beam: 1.3.0 dev: false - /winston/1.1.2: - resolution: {integrity: sha512-rl9hA8se2gjdYI6nP1f+kjjSCFCZrObIJB/eXOcMdzWxxcYp7exyc5Bs248fwLT+wHA/+aK0VtBlPHL8qO0T0w==} - engines: {node: '>= 0.8.0'} - dependencies: - async: 1.0.0 - colors: 1.0.3 - cycle: 1.0.3 - eyes: 0.1.8 - isstream: 0.1.2 - pkginfo: 0.3.1 - stack-trace: 0.0.10 - dev: false - /winston/2.4.7: resolution: {integrity: sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==} engines: {node: '>= 0.10.0'} @@ -51651,6 +51826,23 @@ packages: stack-trace: 0.0.10 dev: true + /winston/3.10.0: + resolution: {integrity: sha512-nT6SIDaE9B7ZRO0u3UvdrimG0HkB7dSTAgInQnNR2SOPJ4bvq5q79+pXLftKmP52lJGW15+H5MCK0nM9D3KB/g==} + engines: {node: '>= 12.0.0'} + dependencies: + '@colors/colors': 1.5.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.4 + is-stream: 2.0.1 + logform: 2.5.1 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.4.3 + stack-trace: 0.0.10 + triple-beam: 1.3.0 + winston-transport: 4.5.0 + dev: false + /word-wrap/1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'}