diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..4ac4973fb15 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..df95e3b5cc1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,12 @@ +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000000..393a1b14aef --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,64 @@ +module.exports = { + root: true, + extends: [ + 'airbnb-typescript', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended', + 'prettier/@typescript-eslint', + 'plugin:prettier/recommended', + 'plugin:promise/recommended', + ], + ignorePatterns: ['.eslintrc.js'], + plugins: ['import', 'promise', '@typescript-eslint', 'prettier'], + parser: '@typescript-eslint/parser', + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + }, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + }, + rules: { + '@typescript-eslint/lines-between-class-members': 'off', + 'react/jsx-wrap-multilines': 'off', + 'promise/catch-or-return': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/jsx-closing-bracket-location': 'off', + '@typescript-eslint/no-var-requires': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['off'], + 'mocha/no-mocha-arrows': 'off', + 'no-return-await': 'off', + 'no-await-in-loop': 'off', + 'no-continue': 'off', + 'no-prototype-builtins': 'off', + 'import/no-cycle': 'off', + 'class-methods-use-this': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-explicit-any': 1, + 'no-restricted-syntax': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + 'no-underscore-dangle': 'off', + 'import/prefer-default-export': 'off', + // A temporary hack related to IDE not resolving correct package.json + 'import/no-extraneous-dependencies': 'off', + 'react/jsx-one-expression-per-line': 'off', + 'react/jsx-no-bind': 'off', + 'lines-between-class-members': 'off', + 'max-classes-per-file': 'off', + 'react/react-in-jsx-scope': 'off', + 'max-len': ['warn', { code: 140 }], + '@typescript-eslint/return-await': 'off', + 'no-restricted-imports': [ + 'error', + { + patterns: ['@notifire/shared/*', '@notifire/dal/*', '!import2/good'], + }, + ], + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index b3c8e844255..00000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { "project": "./tsconfig.json" }, - "env": { "es6": true }, - "ignorePatterns": ["node_modules", "build", "coverage"], - "plugins": ["import", "eslint-comments", "functional"], - "extends": [ - "eslint:recommended", - "plugin:eslint-comments/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:import/typescript", - "prettier", - "prettier/@typescript-eslint" - ], - "globals": { "BigInt": true, "console": true, "WebAssembly": true }, - "rules": { - "functional/no-class": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "eslint-comments/disable-enable-pair": [ - "error", - { "allowWholeFile": true } - ], - "eslint-comments/no-unused-disable": "error", - "sort-imports": [ - "error", - { "ignoreDeclarationSort": true, "ignoreCase": true } - ] - } -} diff --git a/.github/workflows/test-api.yml b/.github/workflows/test-api.yml new file mode 100644 index 00000000000..0e746185323 --- /dev/null +++ b/.github/workflows/test-api.yml @@ -0,0 +1,92 @@ +# This is a basic workflow to help you get started with Actions + +name: Test API + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + pull_request: + paths: + - 'package.json' + - 'yarn.lock' + - 'apps/api/**' + - 'libs/dal/**' + - 'libs/testing/**' + - 'libs/shared/**' + +# 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" + test_api: + # The type of runner that the job will run on + runs-on: ubuntu-latest + timeout-minutes: 80 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - uses: actions/setup-node@v2 + with: + node-version: '15.11.0' + - name: Start Redis + uses: supercharge/redis-github-action@1.2.0 + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.3.0 + with: + mongodb-version: 4.2.8 + + - name: restore lerna + uses: actions/cache@master + with: + path: | + node_modules + */*/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + # Runs a single command using the runners shell + - name: Install project dependencies + run: yarn install + + - name: Start Local Stack + env: + AWS_DEFAULT_REGION: us-east-1 + DEFAULT_REGION: us-east-1 + AWS_ACCOUNT_ID: "000000000000" + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_EC2_METADATA_DISABLED: true + working-directory: environment/test + run: | + docker-compose up -d + sleep 10 + max_retry=30 + counter=0 + until $command + do + sleep 1 + [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 + aws --endpoint-url=http://localhost:4566 s3 ls + echo "Trying again. Try #$counter" + ((counter++)) + done + docker-compose logs --tail="all" + aws --endpoint-url=http://localhost:4566 s3 mb s3://notifire-test + + # Runs a single command using the runners shell + - name: Bootstrap + run: yarn run bootstrap + + # Runs a single command using the runners shell + - name: Build API + run: CI='' yarn run build:api + + # Runs a set of commands using the runners shell + - name: Run a test + run: | + cd apps/api && yarn run test:e2e + yarn run test + diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index e7c83cfceae..d8d617dc387 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -12,6 +12,15 @@ jobs: with: node-version: "14" - run: rm -rf build + + - name: restore lerna + uses: actions/cache@master + with: + path: | + node_modules + */*/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn install - run: yarn bootstrap - name: Test diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml new file mode 100644 index 00000000000..087dedda48a --- /dev/null +++ b/.github/workflows/test-sdk.yml @@ -0,0 +1,50 @@ +# This is a basic workflow to help you get started with Actions + +name: Test SDK + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + pull_request: + paths: + - 'libs/sdk/**' + +# 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" + test_sdk: + # The type of runner that the job will run on + runs-on: ubuntu-latest + timeout-minutes: 80 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - uses: actions/setup-node@v2 + with: + node-version: '15.11.0' + + - name: restore lerna + uses: actions/cache@master + with: + path: | + node_modules + **/node_modules + /home/runner/.cache/Cypress + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + # Runs a single command using the runners shell + - name: Install project dependencies + run: yarn install + + # Runs a single command using the runners shell + - name: Bootstrap + run: yarn run bootstrap + + # Runs a single command using the runners shell + - name: Build + working-directory: libs/sdk + run: CI='' yarn run build:dev diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml new file mode 100644 index 00000000000..16413a8ce92 --- /dev/null +++ b/.github/workflows/test-web.yml @@ -0,0 +1,105 @@ +# This is a basic workflow to help you get started with Actions + +name: Test 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: + inputs: + deploy: + description: 'Should deploy' + required: false + default: "true" + pull_request: + paths: + - 'apps/web/**' + - 'libs/shared/**' + +# 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" + test_web: + # The type of runner that the job will run on + runs-on: ubuntu-latest + timeout-minutes: 80 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - uses: actions/setup-node@v2 + with: + node-version: '15.11.0' + - name: Start Redis + uses: supercharge/redis-github-action@1.2.0 + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.3.0 + with: + mongodb-version: 4.2.8 + + - name: restore lerna + uses: actions/cache@master + with: + path: | + node_modules + **/node_modules + /home/runner/.cache/Cypress + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + + # Runs a single command using the runners shell + - name: Install project dependencies + run: yarn install + + # Runs a single command using the runners shell + - name: Bootstrap + run: yarn run bootstrap + + - name: Start Local Stack + env: + DEFAULT_REGION: us-east-1 + AWS_DEFAULT_REGION: us-east-1 + AWS_ACCOUNT_ID: "000000000000" + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_EC2_METADATA_DISABLED: true + working-directory: environment/test + run: | + docker-compose up -d + sleep 5 + max_retry=30 + counter=0 + until $command + do + sleep 1 + [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 + aws --endpoint-url=http://localhost:4566 s3 ls + echo "Trying again. Try #$counter" + ((counter++)) + done + aws --endpoint-url=http://localhost:4566 s3 mb s3://notifire-test + + # Runs a single command using the runners shell + - name: Build + run: CI='' yarn run build:web + + # Runs a single command using the runners shell + - name: Start Client + run: yarn run start:web & + + - name: Cypress run + uses: cypress-io/github-action@v2 + env: + NODE_ENV: test + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + record: true + working-directory: apps/web + start: yarn run start:api + wait-on: http://localhost:1336/v1/health-check + browser: chrome + headless: true diff --git a/.github/workflows/test-widget.yml b/.github/workflows/test-widget.yml new file mode 100644 index 00000000000..a3020b4fb7b --- /dev/null +++ b/.github/workflows/test-widget.yml @@ -0,0 +1,90 @@ +# This is a basic workflow to help you get started with Actions + +name: Test WIDGET + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + workflow_dispatch: + inputs: + deploy: + description: 'Should deploy' + required: false + default: "true" + pull_request: + paths: + - 'apps/widget/**' + - 'apps/ws/**' + - 'libs/shared/**' + +# 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" + test_widget: + # The type of runner that the job will run on + runs-on: ubuntu-latest + timeout-minutes: 80 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - uses: actions/setup-node@v2 + with: + node-version: '15.11.0' + - name: Start Redis + uses: supercharge/redis-github-action@1.2.0 + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.3.0 + with: + mongodb-version: 4.2.8 + + - name: restore lerna + uses: actions/cache@master + with: + path: | + node_modules + **/node_modules + /home/runner/.cache/Cypress + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + # Runs a single command using the runners shell + - name: Install project dependencies + run: yarn install + + # Runs a single command using the runners shell + - name: Bootstrap + run: yarn run bootstrap + + # Runs a single command using the runners shell + - name: Build + run: CI='' yarn run build:widget + + # Runs a single command using the runners shell + - name: Start Client + run: yarn run start:widget & + + # Runs a single command using the runners shell + - name: Start WS + run: yarn run start:ws & + + # Runs a single command using the runners shell + - name: Wait for WS + run: npx wait-on --timeout=30000 http://localhost:1340/v1/health-check + + - name: Cypress run + uses: cypress-io/github-action@v2 + env: + NODE_ENV: test + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + working-directory: apps/widget + start: yarn run start:api + wait-on: http://localhost:3500/v1/health-check + record: true + browser: chrome + headless: true + diff --git a/.gitignore b/.gitignore index 963d5292865..cceadf7d25e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,94 @@ .nyc_output build node_modules -test src/**.js coverage *.log package-lock.json + +node_modules +build +*.log +coverage +.DS_Store +dist +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/tasks.xml +.eslintcache +.idea/codestream.xml +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# Compiled files +*.tfstate +*.tfstate.backup +.terraform.tfstate.lock.info +# Module directory +.terraform/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..275f334e87e --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14.4.0 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000000..248d44a7299 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 120, + "trailingComma": "es5", + "singleQuote": true, + "semi": true, + "tabWidth": 2, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "jsxBracketSameLine": true, + "arrowParens": "always" +} diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000000..e6fe73b6347 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +network-timeout 60000 diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 00000000000..e4a040ed071 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,31 @@ +FROM node:15.11.0 +ENV NODE_ENV=prod + +WORKDIR /usr/src/app + +RUN npm i yarn -g --loglevel notice --force +RUN npm i pm2 -g + +COPY package.json . + +COPY apps/api ./apps/api +COPY libs/core ./libs/core +COPY libs/shared ./libs/shared + +COPY lerna.json . +COPY tsconfig.json . +COPY tsconfig.base.json . + +RUN yarn install +RUN yarn bootstrap +RUN yarn build:api + +WORKDIR /usr/src/app/apps/api +RUN cp src/.env.test dist/src/.env.test +RUN cp src/.env.development dist/src/.env.development +RUN cp src/.env.production dist/src/.env.production + +RUN mkdir dist/src/app/content-templates/usecases/compile-template/templates +RUN cp src/app/content-templates/usecases/compile-template/templates/* dist/src/app/content-templates/usecases/compile-template/templates/ + +CMD [ "pm2-runtime", "dist/src/main.js" ] diff --git a/Dockerfile.ws b/Dockerfile.ws new file mode 100644 index 00000000000..a3c9fbdb514 --- /dev/null +++ b/Dockerfile.ws @@ -0,0 +1,28 @@ +FROM node:15.11.0 +ENV NODE_ENV=prod + +WORKDIR /usr/src/app + +RUN npm i yarn -g --loglevel notice --force +RUN npm i pm2 -g + +COPY package.json . + +COPY apps/ws ./apps/ws +COPY libs/core ./libs/core +COPY libs/shared ./libs/shared + +COPY lerna.json . +COPY tsconfig.json . +COPY tsconfig.base.json . + +RUN yarn install +RUN yarn bootstrap +RUN yarn build:ws + +WORKDIR /usr/src/app/apps/ws +RUN cp src/.env.test dist/src/.env.test +RUN cp src/.env.development dist/src/.env.development +RUN cp src/.env.production dist/src/.env.production + +CMD [ "pm2-runtime", "dist/src/main.js" ] diff --git a/_templates/module/new/controller.ejs.t b/_templates/module/new/controller.ejs.t new file mode 100644 index 00000000000..bdb3ace4b41 --- /dev/null +++ b/_templates/module/new/controller.ejs.t @@ -0,0 +1,9 @@ +--- +to: apps/api/src/app/<%= name %>/<%= name %>.controller.ts +--- +import { Controller } from '@nestjs/common'; + +@Controller('/<%= name %>') +export class <%= h.changeCase.pascal(name) %>Controller { + constructor() {} +} diff --git a/_templates/module/new/module.ejs.t b/_templates/module/new/module.ejs.t new file mode 100644 index 00000000000..ca03710ee48 --- /dev/null +++ b/_templates/module/new/module.ejs.t @@ -0,0 +1,14 @@ +--- +to: apps/api/src/app/<%= name %>/<%= name %>.module.ts +--- +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { <%= h.changeCase.pascal(name) %>Controller } from './<%= name %>.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [<%= h.changeCase.pascal(name) %>Controller], +}) +export class <%= h.changeCase.pascal(name) %>Module {} diff --git a/_templates/module/new/prompt.ejs.t b/_templates/module/new/prompt.ejs.t new file mode 100644 index 00000000000..91c8f63d61c --- /dev/null +++ b/_templates/module/new/prompt.ejs.t @@ -0,0 +1,10 @@ +// see types of prompts: +// https://github.com/enquirer/enquirer/tree/master/examples +// +module.exports = [ + { + type: 'input', + name: 'name', + message: "What's the name of the usecase?" + } +] diff --git a/_templates/module/new/usecase-index.ejs.t b/_templates/module/new/usecase-index.ejs.t new file mode 100644 index 00000000000..4eaae734285 --- /dev/null +++ b/_templates/module/new/usecase-index.ejs.t @@ -0,0 +1,7 @@ +--- +to: apps/api/src/app/<%= name %>/usecases/index.ts +--- +export const USE_CASES = [ + // +]; + diff --git a/_templates/usecase/new/command.ejs.t b/_templates/usecase/new/command.ejs.t new file mode 100644 index 00000000000..0bb964f9097 --- /dev/null +++ b/_templates/usecase/new/command.ejs.t @@ -0,0 +1,13 @@ +--- +to: apps/api/src/app/<%= module %>/usecases/<%= name %>/<%= name %>.command.ts +--- +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class <%= h.changeCase.pascal(name) %>Command extends ApplicationWithUserCommand { + static create(data: <%= h.changeCase.pascal(name) %>Command) { + return CommandHelper.create<<%= h.changeCase.pascal(name) %>Command>(<%= h.changeCase.pascal(name) %>Command, data); + } +} + + diff --git a/_templates/usecase/new/import-inject.ejs.t b/_templates/usecase/new/import-inject.ejs.t new file mode 100644 index 00000000000..5f56029e825 --- /dev/null +++ b/_templates/usecase/new/import-inject.ejs.t @@ -0,0 +1,8 @@ +--- +to: apps/api/src/app/<%= module %>/usecases/index.ts +inject: true +skip_if: <%= h.changeCase.pascal(name) %> +after: "const USE_CASES = \\[" +eof_last: false +--- + <%= h.changeCase.pascal(name) %>, diff --git a/_templates/usecase/new/import-row-inject.ejs.t b/_templates/usecase/new/import-row-inject.ejs.t new file mode 100644 index 00000000000..3fec183ca64 --- /dev/null +++ b/_templates/usecase/new/import-row-inject.ejs.t @@ -0,0 +1,8 @@ +--- +to: apps/api/src/app/<%= module %>/usecases/index.ts +inject: true +skip_if: import { <%= h.changeCase.pascal(name) %> +prepend: true +eof_last: false +--- +import { <%= h.changeCase.pascal(name) %> } from './<%= name %>/<%= name %>.usecase'; diff --git a/_templates/usecase/new/prompt.ejs.t b/_templates/usecase/new/prompt.ejs.t new file mode 100644 index 00000000000..fafba29ac51 --- /dev/null +++ b/_templates/usecase/new/prompt.ejs.t @@ -0,0 +1,15 @@ +// see types of prompts: +// https://github.com/enquirer/enquirer/tree/master/examples +// +module.exports = [ + { + type: 'input', + name: 'module', + message: "What module add this use case to?" + }, + { + type: 'input', + name: 'name', + message: "What's the name of the usecase?" + } +] diff --git a/_templates/usecase/new/usecase.ejs.t b/_templates/usecase/new/usecase.ejs.t new file mode 100644 index 00000000000..64188fc3e27 --- /dev/null +++ b/_templates/usecase/new/usecase.ejs.t @@ -0,0 +1,14 @@ +--- +to: apps/api/src/app/<%= module %>/usecases/<%= name %>/<%= name %>.usecase.ts +--- +import { Injectable } from '@nestjs/common'; +import { <%= h.changeCase.pascal(name) %>Command } from './<%= name %>.command'; + +@Injectable() +export class <%= h.changeCase.pascal(name) %> { + constructor() {} + + async execute(command: <%= h.changeCase.pascal(name) %>Command): Promise { + return 'Is working'; + } +} diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js new file mode 100644 index 00000000000..a57fabcc485 --- /dev/null +++ b/apps/api/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + rules: { + 'func-names': 'off', + }, + ignorePatterns: '*.spec.ts', +}; diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 00000000000..78bd28b805f --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,120 @@ +# Created by .ignore support plugin (hsz.mobi) +### Node template +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.idea +.build +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +dist +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/*/workspace.xml +.idea/**/*/tasks.xml +.idea/**/*/dictionaries +.idea/**/*/shelf + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ +cmake-build-release/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +.serverless +newrelic_agent.log diff --git a/apps/api/.mocharc.json b/apps/api/.mocharc.json new file mode 100644 index 00000000000..19e868a6e9d --- /dev/null +++ b/apps/api/.mocharc.json @@ -0,0 +1,7 @@ +{ + "timeout": 10000, + "require": "ts-node/register", + "file": ["e2e/setup.ts"], + "exit": true, + "files": ["e2e/**/*.e2e.ts", "src/**/*.e2e.ts", "src/**/**/*.spec.ts"] +} diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 00000000000..71059336214 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,79 @@ +

+ Nest Logo +

+ +[travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master +[travis-url]: https://travis-ci.org/nestjs/nest +[linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux +[linux-url]: https://travis-ci.org/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

+

+NPM Version +Package License +NPM Downloads +Travis +Linux +Coverage +Gitter +Backers on Open Collective +Sponsors on Open Collective + + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ npm install +``` + +## Running the app + +```bash +# development +$ npm run start + +# watch mode +$ npm run start:dev + +# incremental rebuild (webpack) +$ npm run webpack +$ npm run start:hmr + +# production mode +$ npm run start:prod +``` + +## Test + +```bash +# unit tests +$ npm run test + +# e2e tests +$ npm run test:e2e + +# test coverage +$ npm run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myƛliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/api/e2e/api/healthcheck/health-check.e2e.ts b/apps/api/e2e/api/healthcheck/health-check.e2e.ts new file mode 100644 index 00000000000..32bd3dc8fc4 --- /dev/null +++ b/apps/api/e2e/api/healthcheck/health-check.e2e.ts @@ -0,0 +1,19 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Health-check', () => { + const session = new UserSession(); + + before(async () => { + await session.initialize(); + }); + + describe('/health-check (GET)', () => { + it('should correctly return a health check', async () => { + const { + body: { data }, + } = await session.testAgent.get('/v1/health-check'); + expect(data.status).to.equal('ok'); + }); + }); +}); diff --git a/apps/api/e2e/api/organization/create-organization.e2e.ts b/apps/api/e2e/api/organization/create-organization.e2e.ts new file mode 100644 index 00000000000..c3bc31815ab --- /dev/null +++ b/apps/api/e2e/api/organization/create-organization.e2e.ts @@ -0,0 +1,43 @@ +import { MemberRepository, OrganizationRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { MemberRoleEnum } from '@notifire/shared'; +import { expect } from 'chai'; + +describe('Create Organization - /organizations (POST)', async () => { + let session: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + + before(async () => { + session = new UserSession(); + await session.initialize({ + noOrganization: true, + }); + }); + + describe('Valid Creation', () => { + it('should add the user as admin', async () => { + const { body } = await session.testAgent + .post('/v1/organizations') + .send({ + name: 'Test Org 2', + }) + .expect(201); + const dbOrganization = await organizationRepository.findById(body.data._id); + + const members = await memberRepository.getOrganizationMembers(dbOrganization._id); + + expect(members.length).to.eq(1); + expect(members[0]._userId).to.eq(session.user._id); + expect(members[0].roles[0]).to.eq(MemberRoleEnum.ADMIN); + }); + + it('should create organization with correct name', async () => { + const demoOrganization = { + name: 'Hello Org', + }; + const { body } = await session.testAgent.post('/v1/organizations').send(demoOrganization).expect(201); + expect(body.data.name).to.eq(demoOrganization.name); + }); + }); +}); diff --git a/apps/api/e2e/api/organization/get-my-organization.e2e.ts b/apps/api/e2e/api/organization/get-my-organization.e2e.ts new file mode 100644 index 00000000000..9048a584f2a --- /dev/null +++ b/apps/api/e2e/api/organization/get-my-organization.e2e.ts @@ -0,0 +1,19 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Get my organization - /organizations/me (GET)', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + describe('Get organization profile', () => { + it('should return the correct organization', async () => { + const { body } = await session.testAgent.get('/v1/organizations/me').expect(200); + + expect(body.data._id).to.eq(session.organization._id); + }); + }); +}); diff --git a/apps/api/e2e/api/organization/members/change-member-role.e2e.ts b/apps/api/e2e/api/organization/members/change-member-role.e2e.ts new file mode 100644 index 00000000000..04c77a39ff0 --- /dev/null +++ b/apps/api/e2e/api/organization/members/change-member-role.e2e.ts @@ -0,0 +1,64 @@ +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; +import { describe } from 'mocha'; + +describe('Change member role - /organizations/members/:memberId/role (PUT)', async () => { + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + let session: UserSession; + let user2: UserSession; + let user3: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + + user2 = new UserSession(); + await user2.initialize({ + noOrganization: true, + }); + + user3 = new UserSession(); + await user3.initialize({ + noOrganization: true, + }); + }); + + it('should update admin to member', async () => { + await memberRepository.addMember(session.organization._id, { + _userId: user2.user._id, + invite: null, + roles: [MemberRoleEnum.ADMIN], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + const member = await memberRepository.findMemberByUserId(session.organization._id, user2.user._id); + const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({ + role: MemberRoleEnum.MEMBER, + }); + + expect(body.data.roles.length).to.equal(1); + expect(body.data.roles[0]).to.equal(MemberRoleEnum.MEMBER); + }); + + it('should update member to admin', async () => { + await memberRepository.addMember(session.organization._id, { + _userId: user3.user._id, + invite: null, + roles: [MemberRoleEnum.MEMBER], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + const member = await memberRepository.findMemberByUserId(session.organization._id, user3.user._id); + + const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({ + role: MemberRoleEnum.ADMIN, + }); + + expect(body.data.roles.length).to.equal(1); + expect(body.data.roles.includes(MemberRoleEnum.ADMIN)).to.be.ok; + expect(body.data.roles.includes(MemberRoleEnum.MEMBER)).not.to.be.ok; + }); +}); diff --git a/apps/api/e2e/api/organization/members/get-members.e2e.ts b/apps/api/e2e/api/organization/members/get-members.e2e.ts new file mode 100644 index 00000000000..84859207d98 --- /dev/null +++ b/apps/api/e2e/api/organization/members/get-members.e2e.ts @@ -0,0 +1,57 @@ +import { MemberEntity, OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; +import { describe } from 'mocha'; + +describe('Get Organization members - /organizations/members (GET)', async () => { + let session: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + let user2: UserSession; + let user3: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + + user2 = new UserSession(); + await user2.initialize({ + noOrganization: true, + }); + + user3 = new UserSession(); + await user3.initialize({ + noOrganization: true, + }); + }); + + it('should return all organization members', async () => { + await memberRepository.addMember(session.organization._id, { + _userId: user2.user._id, + invite: null, + roles: [MemberRoleEnum.ADMIN], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + await memberRepository.addMember(session.organization._id, { + _userId: user3.user._id, + invite: null, + roles: [MemberRoleEnum.ADMIN], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + const { body } = await session.testAgent.get('/v1/organizations/members'); + + const response: MemberEntity[] = body.data; + + expect(response.length).to.equal(3); + const user2Member = response.find((i) => i._userId === user2.user._id); + + expect(user2Member).to.be.ok; + expect(user2Member.memberStatus).to.equal(MemberStatusEnum.ACTIVE); + expect(user2Member.user).to.be.ok; + expect(user2Member.user.firstName).to.equal(user2.user.firstName); + expect(user2Member.user.email).to.equal(user2.user.email); + }); +}); diff --git a/apps/api/e2e/api/organization/members/remove-member.e2e.ts b/apps/api/e2e/api/organization/members/remove-member.e2e.ts new file mode 100644 index 00000000000..ca7096c07a5 --- /dev/null +++ b/apps/api/e2e/api/organization/members/remove-member.e2e.ts @@ -0,0 +1,66 @@ +import { MemberEntity, OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; + +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; +import { describe } from 'mocha'; + +describe('Remove organization member - /organizations/members/:memberId (DELETE)', async () => { + let session: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + + let user2: UserSession; + let user3: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + + user2 = new UserSession(); + await user2.initialize({ + noOrganization: true, + }); + + user3 = new UserSession(); + await user3.initialize({ + noOrganization: true, + }); + + await memberRepository.addMember(session.organization._id, { + _userId: user2.user._id, + invite: null, + roles: [MemberRoleEnum.ADMIN], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + await memberRepository.addMember(session.organization._id, { + _userId: user3.user._id, + invite: null, + roles: [MemberRoleEnum.ADMIN], + memberStatus: MemberStatusEnum.ACTIVE, + }); + + user2.organization = session.organization; + user3.organization = session.organization; + }); + + it('should remove the member by his id', async () => { + const members: MemberEntity[] = await getOrganizationMembers(); + const user2Member = members.find((i) => i._userId === user2.user._id); + + const { body } = await session.testAgent.delete(`/v1/organizations/members/${user2Member._id}`).expect(200); + + expect(body.data._id).to.equal(user2Member._id); + + const membersAfterRemoval: MemberEntity[] = await getOrganizationMembers(); + const user2Removed = membersAfterRemoval.find((i) => i._userId === user2.user._id); + expect(user2Removed).to.not.be.ok; + }); + + async function getOrganizationMembers() { + const { body } = await session.testAgent.get('/v1/organizations/members'); + + return body.data; + } +}); diff --git a/apps/api/e2e/api/user/get-me.e2e.ts b/apps/api/e2e/api/user/get-me.e2e.ts new file mode 100644 index 00000000000..1560272bb3d --- /dev/null +++ b/apps/api/e2e/api/user/get-me.e2e.ts @@ -0,0 +1,21 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('User get my profile', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should return a correct user profile', async () => { + const { body } = await session.testAgent.get('/v1/users/me').expect(200); + + const me = body.data; + expect(me._id).to.equal(session.user._id); + expect(me.firstName).to.equal(session.user.firstName); + expect(me.lastName).to.equal(session.user.lastName); + expect(me.email).to.equal(session.user.email); + }); +}); diff --git a/apps/api/e2e/mocha.e2e.opts b/apps/api/e2e/mocha.e2e.opts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/e2e/setup.ts b/apps/api/e2e/setup.ts new file mode 100644 index 00000000000..03a2d80f909 --- /dev/null +++ b/apps/api/e2e/setup.ts @@ -0,0 +1,25 @@ +import { DalService } from '@notifire/dal'; +import { testServer } from '@notifire/testing'; +import * as sinon from 'sinon'; +import { bootstrap } from '../src/bootstrap'; + +const dalService = new DalService(); +before(async () => { + await testServer.create(await bootstrap()); + await dalService.connect(process.env.MONGO_URL); +}); + +after(async () => { + await testServer.teardown(); + try { + await dalService.destroy(); + } catch (e) { + if (e.code !== 12586) { + throw e; + } + } +}); + +afterEach(() => { + sinon.restore(); +}); diff --git a/apps/api/nodemon-debug.json b/apps/api/nodemon-debug.json new file mode 100644 index 00000000000..052b0236723 --- /dev/null +++ b/apps/api/nodemon-debug.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": ["src/**/*.spec.ts"], + "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" +} diff --git a/apps/api/nodemon.json b/apps/api/nodemon.json new file mode 100644 index 00000000000..4e2bc9177c9 --- /dev/null +++ b/apps/api/nodemon.json @@ -0,0 +1,8 @@ +{ + "watch": ["src", "../core/dist"], + "ext": "ts", + "delay": 2, + "ignoreRoot": [".git"], + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node -r tsconfig-paths/register src/main.ts" +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000000..e14fb1e52a0 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,89 @@ +{ + "name": "@notifire/api", + "version": "0.2.63", + "description": "description", + "author": "", + "private": "true", + "license": "MIT", + "scripts": { + "build": "node --max-old-space-size=1500 node_modules/.bin/tsc -p tsconfig.build.json", + "format": "prettier --write \"src/**/*.ts\"", + "precommit": "lint-staged", + "start": "TZ=UTC ts-node src/main.ts", + "start:dev": "cross-env TZ=UTC nodemon", + "start:test": "cross-env NODE_ENV=test PORT=1336 TZ=UTC nodemon", + "start:debug": "TZ=UTC nodemon --config nodemon-debug.json", + "start:prod": "TZ=UTC node dist/main.js", + "lint": "eslint --fix", + "test": "cross-env TZ=UTC NODE_ENV=test mocha --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/**/*.spec.ts", + "test:e2e": "cross-env TZ=UTC NODE_ENV=test mocha --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts e2e/**/*.e2e.ts src/**/*.e2e.ts" + }, + "dependencies": { + "@godaddy/terminus": "^4.3.1", + "@nestjs/common": "^7.6.12", + "@nestjs/core": "^7.6.12", + "@nestjs/graphql": "^7.9.8", + "@nestjs/jwt": "^7.2.0", + "@nestjs/passport": "^7.1.5", + "@nestjs/platform-express": "^7.6.12", + "@nestjs/swagger": "^4.7.12", + "@nestjs/terminus": "^7.1.0", + "@nestjsx/crud": "^4.6.2", + "@notifire/dal": "^0.2.33", + "@notifire/testing": "^0.2.33", + "@notifire/node": "^1.0.4", + "@notifire/shared": "^0.2.29", + "@sentry/node": "^6.1.0", + "@sentry/tracing": "^6.3.6", + "@types/handlebars": "^4.1.0", + "@vendia/serverless-express": "^4.3.4", + "bcrypt": "^5.0.0", + "class-transformer": "^0.4.0", + "class-validator": "^0.12.2", + "compression": "^1.7.4", + "dotenv": "^8.2.0", + "envalid": "^6.0.1", + "handlebars": "^4.7.7", + "hat": "^0.0.3", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.15", + "moment": "^2.29.1", + "nanoid": "^3.1.20", + "nest-raven": "^7.2.0", + "newrelic": "^7.4.0", + "passport": "^0.4.1", + "passport-github": "^1.1.0", + "passport-jwt": "^4.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^6.5.5", + "sanitize-html": "^2.4.0", + "shortid": "^2.2.16", + "slugify": "^1.4.6", + "swagger-ui-express": "^4.1.4", + "typescript": "^4.1.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@nestjs/testing": "^7.4.2", + "@types/bcrypt": "^3.0.0", + "@types/chai": "^4.2.11", + "@types/express": "4.17.7", + "@types/mocha": "^8.0.1", + "@types/node": "^14.6.0", + "@types/passport-github": "^1.1.5", + "@types/passport-jwt": "^3.0.3", + "@types/sinon": "^9.0.0", + "@types/supertest": "^2.0.8", + "chai": "^4.2.0", + "faker": "^5.5.3", + "mocha": "^8.1.1", + "nodemon": "^2.0.3", + "sinon": "^9.0.2", + "ts-node": "^9.0.0" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --fix" + ] + } +} diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env new file mode 100644 index 00000000000..f9763c77d0c --- /dev/null +++ b/apps/api/src/.example.env @@ -0,0 +1,32 @@ +NODE_ENV=dev +PORT=3000 +API_ROOT_URL=http://localhost:3000 +FRONT_BASE_URL=http://localhost:4200 +NEST_STARTER_MAIL=support@notifire.co + +MONGO_URL=mongodb://localhost:27017/notifire-db +REDIS_PORT=6379 +REDIS_HOST=localhost +REDIS_DB_INDEX=2 + +JWT_SECRET=%TEST_REPLACE_THIS +SENDGRID_API_KEY=1 + +S3_BUCKET_NAME=notifire-dev +S3_ACCESS_KEY=1 +S3_SECRET=1 +S3_REGION=us-east-1 + +SENTRY_DSN=1 +MIXPANEL_TOKEN=1 + +CLIENT_SUCCESS_AUTH_REDIRECT=http://localhost:4200/login + +GITHUB_OAUTH_CLIENT_ID=1 +GITHUB_OAUTH_CLIENT_SECRET=1 +GITHUB_OAUTH_REDIRECT=1 + +GOOGLE_OAUTH_CLIENT_ID=1 +GOOGLE_OAUTH_CLIENT_SECRET=1 +GOOGLE_OAUTH_REDIRECT=http://localhost:4200/login + diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 00000000000..972f6bb7315 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,71 @@ +import { DynamicModule, Module, OnModuleInit } from '@nestjs/common'; +import { RavenInterceptor, RavenModule } from 'nest-raven'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { Type } from '@nestjs/common/interfaces/type.interface'; +import { ForwardReference } from '@nestjs/common/interfaces/modules/forward-reference.interface'; +import { SharedModule } from './app/shared/shared.module'; +import { UserModule } from './app/user/user.module'; +import { AuthModule } from './app/auth/auth.module'; +import { TestingModule } from './app/testing/testing.module'; +import { HealthModule } from './app/health/health.module'; +import { AdminModule } from './app/admin/admin.module'; +import { OrganizationModule } from './app/organization/organization.module'; +import { ApplicationsModule } from './app/applications/applications.module'; +import { NotificationTemplateModule } from './app/notification-template/notification-template.module'; +import { EventsModule } from './app/events/events.module'; +import { WidgetsModule } from './app/widgets/widgets.module'; +import { ActivityModule } from './app/activity/activity.module'; +import { ChannelsModule } from './app/channels/channels.module'; +import { StorageModule } from './app/storage/storage.module'; +import { NotificationGroupsModule } from './app/notification-groups/notification-groups.module'; +import { InvitesModule } from './app/invites/invites.module'; +import { ContentTemplatesModule } from './app/content-templates/content-templates.module'; +import { QueueService } from './app/shared/services/queue'; + +const modules: Array | DynamicModule | Promise | ForwardReference> = [ + OrganizationModule, + SharedModule, + UserModule, + AuthModule, + HealthModule, + AdminModule, + ApplicationsModule, + NotificationTemplateModule, + EventsModule, + WidgetsModule, + ActivityModule, + ChannelsModule, + StorageModule, + NotificationGroupsModule, + InvitesModule, + ContentTemplatesModule, +]; + +const providers = []; + +if (process.env.SENTRY_DSN) { + modules.push(RavenModule); + providers.push({ + provide: APP_INTERCEPTOR, + useValue: new RavenInterceptor({ + user: ['_id', 'firstName', 'email', 'organizationId', 'applicationId'], + }), + }); +} + +if (process.env.NODE_ENV === 'test') { + modules.push(TestingModule); +} + +@Module({ + imports: modules, + controllers: [], + providers, +}) +export class AppModule implements OnModuleInit { + constructor(private queueService: QueueService) {} + + async onModuleInit() { + // + } +} diff --git a/apps/api/src/app/activity/activity.controller.ts b/apps/api/src/app/activity/activity.controller.ts new file mode 100644 index 00000000000..07a9c7ce374 --- /dev/null +++ b/apps/api/src/app/activity/activity.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ChannelTypeEnum, IJwtPayload } from '@notifire/shared'; +import { GetActivityFeed } from './usecases/get-activity-feed/get-activity-feed.usecase'; +import { GetActivityFeedCommand } from './usecases/get-activity-feed/get-activity-feed.command'; +import { UserSession } from '../shared/framework/user.decorator'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { GetActivityStats } from './usecases/get-activity-stats/get-activity-stats.usecase'; +import { GetActivityStatsCommand } from './usecases/get-activity-stats/get-activity-stats.command'; + +@Controller('/activity') +export class ActivityController { + constructor(private getActivityFeedUsecase: GetActivityFeed, private getActivityStatsUsecase: GetActivityStats) {} + + @Get('') + @UseGuards(JwtAuthGuard) + getActivityFeed( + @UserSession() user: IJwtPayload, + @Query('page') page = 0, + @Query('channels') channels: ChannelTypeEnum[] | ChannelTypeEnum, + @Query('templates') templates: string[] | string, + @Query('search') search: string + ) { + let channelsQuery: ChannelTypeEnum[]; + + if (channels) { + channelsQuery = Array.isArray(channels) ? channels : [channels]; + } + + let templatesQuery: string[]; + if (templates) { + templatesQuery = Array.isArray(templates) ? templates : [templates]; + } + + return this.getActivityFeedUsecase.execute( + GetActivityFeedCommand.create({ + page: page ? Number(page) : 0, + organizationId: user.organizationId, + applicationId: user.applicationId, + userId: user._id, + channels: channelsQuery, + templates: templatesQuery, + search, + }) + ); + } + + @Get('/stats') + @UseGuards(JwtAuthGuard) + getActivityStats(@UserSession() user: IJwtPayload, @Query('page') page = 0) { + return this.getActivityStatsUsecase.execute( + GetActivityStatsCommand.create({ + organizationId: user.organizationId, + applicationId: user.applicationId, + userId: user._id, + }) + ); + } +} diff --git a/apps/api/src/app/activity/activity.module.ts b/apps/api/src/app/activity/activity.module.ts new file mode 100644 index 00000000000..ed7f968f896 --- /dev/null +++ b/apps/api/src/app/activity/activity.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { ActivityController } from './activity.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [ActivityController], +}) +export class ActivityModule {} diff --git a/apps/api/src/app/activity/e2e/get-activity-feed.e2e.ts b/apps/api/src/app/activity/e2e/get-activity-feed.e2e.ts new file mode 100644 index 00000000000..d21f402ee57 --- /dev/null +++ b/apps/api/src/app/activity/e2e/get-activity-feed.e2e.ts @@ -0,0 +1,126 @@ +import { NotificationTemplateEntity } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; +import { ChannelTypeEnum, IMessage } from '@notifire/shared'; + +describe('Get activity feed - /activity (GET)', async () => { + let session: UserSession; + let template: NotificationTemplateEntity; + let smsOnlyTemplate: NotificationTemplateEntity; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + smsOnlyTemplate = await session.createChannelTemplate(ChannelTypeEnum.SMS); + await session.testAgent + .post('/v1/widgets/session/initialize') + .send({ + applicationIdentifier: session.application.identifier, + $user_id: '12345', + $first_name: 'Test', + $last_name: 'User', + $email: 'test@example.com', + }) + .expect(201); + }); + + it('should get the current activity feed of user', async function () { + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + const { body } = await session.testAgent.get('/v1/activity?page=0'); + + const activities = body.data; + expect(body.totalCount).to.equal(4); + expect(activities.length).to.equal(4); + expect(activities[0].template.name).to.equal(template.name); + expect(activities[0].template._id).to.equal(template._id); + expect(activities[0].subscriber.firstName).to.equal('Test'); + expect(activities[0].channel).to.be.oneOf(Object.keys(ChannelTypeEnum).map((i) => ChannelTypeEnum[i])); + }); + + it('should filter by channel', async function () { + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(smsOnlyTemplate.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(smsOnlyTemplate.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + const { body } = await session.testAgent.get(`/v1/activity?page=0&channels=${ChannelTypeEnum.SMS}`); + const activities: IMessage[] = body.data; + expect(activities.length).to.equal(2); + expect(activities[0].channel).to.equal(ChannelTypeEnum.SMS); + expect(activities[0].template.name).to.equal(smsOnlyTemplate.name); + }); + + it('should filter by templateId', async function () { + await session.triggerEvent(smsOnlyTemplate.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + const { body } = await session.testAgent.get(`/v1/activity?page=0&templates=${template._id}`); + const activities: IMessage[] = body.data; + expect(activities.length).to.equal(4); + expect(activities[0]._templateId).to.equal(template._id); + expect(activities[1]._templateId).to.equal(template._id); + expect(activities[2]._templateId).to.equal(template._id); + expect(activities[3]._templateId).to.equal(template._id); + }); + + it('should filter by email', async function () { + await session.triggerEvent(smsOnlyTemplate.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '1234564', + firstName: 'Test', + $email: 'test@email.coms', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + const { body } = await session.testAgent.get(`/v1/activity?page=0&search=test@email.coms`); + const activities: IMessage[] = body.data; + expect(activities.length).to.equal(2); + expect(activities[0]._templateId).to.equal(template._id); + expect(activities[1]._templateId).to.equal(template._id); + }); +}); diff --git a/apps/api/src/app/activity/e2e/get-activity-stats.e2e.ts b/apps/api/src/app/activity/e2e/get-activity-stats.e2e.ts new file mode 100644 index 00000000000..e5ad50ea604 --- /dev/null +++ b/apps/api/src/app/activity/e2e/get-activity-stats.e2e.ts @@ -0,0 +1,69 @@ +import { MessageRepository, NotificationTemplateEntity } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; +import * as moment from 'moment'; + +describe('Get activity stats - /activity/stats (GET)', async () => { + let session: UserSession; + let template: NotificationTemplateEntity; + const messageRepository = new MessageRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + + await session.testAgent + .post('/v1/widgets/session/initialize') + .send({ + applicationIdentifier: session.application.identifier, + $user_id: '12345', + $first_name: 'Test', + $last_name: 'User', + $email: 'test@example.com', + }) + .expect(201); + }); + + it('should retrieve last month and last week activity', async function () { + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + firstName: 'Test', + }); + + const existing = await messageRepository.find( + { + _applicationId: session.application._id, + }, + null, + { limit: 2 } + ); + + await messageRepository._model.updateMany( + { + _id: existing.map((i) => i._id), + }, + { + $set: { + createdAt: moment().subtract(12, 'days').toDate(), + }, + }, + { + multi: true, + timestamps: false, + } + ); + + const { + body: { data }, + } = await session.testAgent.get('/v1/activity/stats'); + + expect(data.weeklySent).to.equal(2); + expect(data.monthlySent).to.equal(4); + }); +}); diff --git a/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.command.ts b/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.command.ts new file mode 100644 index 00000000000..cbbc35e8712 --- /dev/null +++ b/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.command.ts @@ -0,0 +1,29 @@ +import { IsArray, IsEnum, IsMongoId, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetActivityFeedCommand extends ApplicationWithUserCommand { + static create(data: GetActivityFeedCommand) { + return CommandHelper.create(GetActivityFeedCommand, data); + } + + @IsNumber() + @IsOptional() + page: number; + + @IsOptional() + @IsEnum(ChannelTypeEnum, { + each: true, + }) + channels?: ChannelTypeEnum[]; + + @IsOptional() + @IsArray() + @IsMongoId({ each: true }) + templates?: string[]; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.usecase.ts b/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.usecase.ts new file mode 100644 index 00000000000..d4cb939b920 --- /dev/null +++ b/apps/api/src/app/activity/usecases/get-activity-feed/get-activity-feed.usecase.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { + MessageEntity, + MessageRepository, + NotificationEntity, + NotificationRepository, + SubscriberRepository, +} from '@notifire/dal'; +import { GetActivityFeedCommand } from './get-activity-feed.command'; + +@Injectable() +export class GetActivityFeed { + constructor( + private notificationRepository: NotificationRepository, + private messageRepository: MessageRepository, + private subscribersRepository: SubscriberRepository + ) {} + + async execute( + command: GetActivityFeedCommand + ): Promise<{ totalCount: number; data: MessageEntity[]; pageSize: number; page: number }> { + const LIMIT = 10; + + let subscriberId: string; + if (command.search) { + const foundSubscriber = await this.subscribersRepository.searchSubscriber(command.applicationId, command.search); + subscriberId = foundSubscriber?._id; + + if (!subscriberId) { + return { + page: 0, + totalCount: 0, + pageSize: LIMIT, + data: [], + }; + } + } + + const { data: messages, totalCount } = await this.messageRepository.getFeed( + command.applicationId, + { channels: command.channels, templates: command.templates, subscriberId }, + command.page * LIMIT, + LIMIT + ); + + return { + page: command.page, + totalCount, + pageSize: LIMIT, + data: messages, + }; + } +} diff --git a/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.command.ts b/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.command.ts new file mode 100644 index 00000000000..cb4d8e6279b --- /dev/null +++ b/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetActivityStatsCommand extends ApplicationWithUserCommand { + static create(data: GetActivityStatsCommand) { + return CommandHelper.create(GetActivityStatsCommand, data); + } +} diff --git a/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.usecase.ts b/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.usecase.ts new file mode 100644 index 00000000000..e73afe34ee9 --- /dev/null +++ b/apps/api/src/app/activity/usecases/get-activity-stats/get-activity-stats.usecase.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { MessageRepository } from '@notifire/dal'; +import * as moment from 'moment'; +import { GetActivityStatsCommand } from './get-activity-stats.command'; + +@Injectable() +export class GetActivityStats { + constructor(private messageRepository: MessageRepository) {} + + async execute( + command: GetActivityStatsCommand + ): Promise<{ + weeklySent: number; + monthlySent: number; + }> { + const monthly = await this.messageRepository.count({ + _applicationId: command.applicationId, + _organizationId: command.organizationId, + createdAt: { + $gte: String(moment().subtract(1, 'month').toDate()), + }, + }); + + const weekly = await this.messageRepository.count({ + _applicationId: command.applicationId, + _organizationId: command.organizationId, + createdAt: { + $gte: String(moment().subtract(1, 'week').toDate()), + }, + }); + + return { + weeklySent: weekly, + monthlySent: monthly, + }; + } +} diff --git a/apps/api/src/app/activity/usecases/index.ts b/apps/api/src/app/activity/usecases/index.ts new file mode 100644 index 00000000000..463e637d501 --- /dev/null +++ b/apps/api/src/app/activity/usecases/index.ts @@ -0,0 +1,8 @@ +import { GetActivityStats } from './get-activity-stats/get-activity-stats.usecase'; +import { GetActivityFeed } from './get-activity-feed/get-activity-feed.usecase'; + +export const USE_CASES = [ + GetActivityStats, + GetActivityFeed, + // +]; diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts new file mode 100644 index 00000000000..af238ee0413 --- /dev/null +++ b/apps/api/src/app/admin/admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { UsersController } from './entities/users/users.controller'; +import { UsersService } from './entities/users/users.service'; + +@Module({ + imports: [SharedModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class AdminModule {} diff --git a/apps/api/src/app/admin/entities/users/users.controller.ts b/apps/api/src/app/admin/entities/users/users.controller.ts new file mode 100644 index 00000000000..f7818dd1287 --- /dev/null +++ b/apps/api/src/app/admin/entities/users/users.controller.ts @@ -0,0 +1,21 @@ +import { Controller } from '@nestjs/common'; +import { Crud } from '@nestjsx/crud'; +import { UserEntity } from '@notifire/dal'; +import { UsersService } from './users.service'; + +@Crud({ + model: { + type: UserEntity, + }, + params: { + id: { + type: 'string', + primary: true, + field: 'id', + }, + }, +}) +@Controller('/admin/entities/users') +export class UsersController { + constructor(public service: UsersService) {} +} diff --git a/apps/api/src/app/admin/entities/users/users.service.ts b/apps/api/src/app/admin/entities/users/users.service.ts new file mode 100644 index 00000000000..fdf24de3599 --- /dev/null +++ b/apps/api/src/app/admin/entities/users/users.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { UserEntity, UserRepository } from '@notifire/dal'; +import { MongooseCrudService } from '../../../shared/crud/mongoose-crud.service'; + +@Injectable() +export class UsersService extends MongooseCrudService { + constructor(private usersRepository: UserRepository) { + super(usersRepository._model); + } +} diff --git a/apps/api/src/app/applications/applications.controller.ts b/apps/api/src/app/applications/applications.controller.ts new file mode 100644 index 00000000000..f35108bc74e --- /dev/null +++ b/apps/api/src/app/applications/applications.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Get, + Post, + Put, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { IJwtPayload } from '@notifire/shared'; +import { ApplicationEntity } from '@notifire/dal'; +import { AuthGuard } from '@nestjs/passport'; +import { UserSession } from '../shared/framework/user.decorator'; +import { CreateApplication } from './usecases/create-application/create-application.usecase'; +import { CreateApplicationCommand } from './usecases/create-application/create-application.command'; +import { CreateApplicationBodyDto } from './dto/create-application.dto'; +import { GetApiKeysCommand } from './usecases/get-api-keys/get-api-keys.command'; +import { GetApiKeys } from './usecases/get-api-keys/get-api-keys.usecase'; +import { GetApplication, GetApplicationCommand } from './usecases/get-application'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { UpdateBrandingDetails } from './usecases/update-branding-details/update-branding-details.usecase'; +import { UpdateBrandingDetailsCommand } from './usecases/update-branding-details/update-branding-details.command'; + +@Controller('/applications') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class ApplicationsController { + constructor( + private createApplicationUsecase: CreateApplication, + private getApiKeysUsecase: GetApiKeys, + private getApplicationUsecase: GetApplication, + private updateBrandingDetailsUsecase: UpdateBrandingDetails + ) {} + + @Get('/me') + async getCurrentApplication(@UserSession() user: IJwtPayload): Promise { + return await this.getApplicationUsecase.execute( + GetApplicationCommand.create({ + applicationId: user.applicationId, + userId: user._id, + organizationId: user.organizationId, + }) + ); + } + + @Post('/') + async createApplication( + @UserSession() user: IJwtPayload, + @Body() body: CreateApplicationBodyDto + ): Promise { + return await this.createApplicationUsecase.execute( + CreateApplicationCommand.create({ + name: body.name, + userId: user._id, + organizationId: user.organizationId, + }) + ); + } + + @Put('/branding') + async updateBrandingDetails( + @UserSession() user: IJwtPayload, + @Body() body: { color: string; logo: string; fontColor: string; contentBackground: string; fontFamily: string } + ) { + return await this.updateBrandingDetailsUsecase.execute( + UpdateBrandingDetailsCommand.create({ + logo: body.logo, + color: body.color, + applicationId: user.applicationId, + userId: user._id, + organizationId: user.organizationId, + fontColor: body.fontColor, + fontFamily: body.fontFamily, + contentBackground: body.contentBackground, + }) + ); + } + + @Get('/api-keys') + async getOrganizationApiKeys(@UserSession() user: IJwtPayload) { + const command = GetApiKeysCommand.create({ + userId: user._id, + organizationId: user.organizationId, + applicationId: user.applicationId, + }); + + return await this.getApiKeysUsecase.execute(command); + } +} diff --git a/apps/api/src/app/applications/applications.module.ts b/apps/api/src/app/applications/applications.module.ts new file mode 100644 index 00000000000..5801458b34f --- /dev/null +++ b/apps/api/src/app/applications/applications.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { USE_CASES } from './usecases'; +import { ApplicationsController } from './applications.controller'; + +@Module({ + imports: [SharedModule], + controllers: [ApplicationsController], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class ApplicationsModule {} diff --git a/apps/api/src/app/applications/dto/create-application.dto.ts b/apps/api/src/app/applications/dto/create-application.dto.ts new file mode 100644 index 00000000000..6d58113b8e9 --- /dev/null +++ b/apps/api/src/app/applications/dto/create-application.dto.ts @@ -0,0 +1,6 @@ +import { IsDefined, IsEnum } from 'class-validator'; + +export class CreateApplicationBodyDto { + @IsDefined() + name: string; +} diff --git a/apps/api/src/app/applications/e2e/update-branding-details.e2e.ts b/apps/api/src/app/applications/e2e/update-branding-details.e2e.ts new file mode 100644 index 00000000000..ded51688fb0 --- /dev/null +++ b/apps/api/src/app/applications/e2e/update-branding-details.e2e.ts @@ -0,0 +1,32 @@ +import { ApplicationRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Update Branding Details - /applications/branding (PUT)', function () { + let session: UserSession; + const applicationRepository = new ApplicationRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should update the branding details', async function () { + const payload = { + color: '#fefefe', + fontColor: '#f4f4f4', + contentBackground: '#fefefe', + fontFamily: 'Nunito', + logo: 'https://st.depositphotos.com/1186248/2404/i/600/depositphotos_24043595-stock-photo-fake-rubber-stamp.jpg', + }; + + await session.testAgent.put('/v1/applications/branding').send(payload); + + const application = await applicationRepository.findById(session.application._id); + expect(application.branding.color).to.equal(payload.color); + expect(application.branding.logo).to.equal(payload.logo); + expect(application.branding.fontColor).to.equal(payload.fontColor); + expect(application.branding.fontFamily).to.equal(payload.fontFamily); + expect(application.branding.contentBackground).to.equal(payload.contentBackground); + }); +}); diff --git a/apps/api/src/app/applications/usecases/create-application/create-application.command.ts b/apps/api/src/app/applications/usecases/create-application/create-application.command.ts new file mode 100644 index 00000000000..23345e14b26 --- /dev/null +++ b/apps/api/src/app/applications/usecases/create-application/create-application.command.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { OrganizationCommand } from '../../../shared/commands/organization.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class CreateApplicationCommand extends OrganizationCommand { + static create(data: CreateApplicationCommand) { + return CommandHelper.create(CreateApplicationCommand, data); + } + + @IsDefined() + name: string; +} diff --git a/apps/api/src/app/applications/usecases/create-application/create-application.e2e.ts b/apps/api/src/app/applications/usecases/create-application/create-application.e2e.ts new file mode 100644 index 00000000000..dfe72ffc0b5 --- /dev/null +++ b/apps/api/src/app/applications/usecases/create-application/create-application.e2e.ts @@ -0,0 +1,34 @@ +import { ApplicationRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Create Application - /applications (POST)', async () => { + let session: UserSession; + const applicationRepository = new ApplicationRepository(); + before(async () => { + session = new UserSession(); + await session.initialize({ + noApplication: true, + }); + }); + + it('should create application entity correctly', async () => { + const demoApplication = { + name: 'Hello App', + }; + const { body } = await session.testAgent.post('/v1/applications').send(demoApplication).expect(201); + expect(body.data.name).to.eq(demoApplication.name); + expect(body.data._organizationId).to.eq(session.organization._id); + expect(body.data.identifier).to.be.ok; + const dbApp = await applicationRepository.findById(body.data._id); + expect(dbApp.apiKeys.length).to.equal(1); + expect(dbApp.apiKeys[0].key).to.be.ok; + expect(dbApp.apiKeys[0]._userId).to.equal(session.user._id); + }); + + it('should fail when no name provided', async () => { + const demoApplication = {}; + const { body } = await session.testAgent.post('/v1/applications').send(demoApplication).expect(400); + expect(body.message[0]).to.contain('name should not be null'); + }); +}); diff --git a/apps/api/src/app/applications/usecases/create-application/create-application.usecase.ts b/apps/api/src/app/applications/usecases/create-application/create-application.usecase.ts new file mode 100644 index 00000000000..66444458cea --- /dev/null +++ b/apps/api/src/app/applications/usecases/create-application/create-application.usecase.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationRepository } from '@notifire/dal'; +import * as hat from 'hat'; +import { nanoid } from 'nanoid'; +import { CreateApplicationCommand } from './create-application.command'; + +@Injectable() +export class CreateApplication { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: CreateApplicationCommand) { + return await this.applicationRepository.create({ + _organizationId: command.organizationId, + name: command.name, + identifier: nanoid(12), + apiKeys: [ + { + key: hat(), + _userId: command.userId, + }, + ], + }); + } +} diff --git a/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.command.ts b/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.command.ts new file mode 100644 index 00000000000..407b8d813e5 --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetApiKeysCommand extends ApplicationWithUserCommand { + static create(data: GetApiKeysCommand) { + return CommandHelper.create(GetApiKeysCommand, data); + } +} diff --git a/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.usecase.ts b/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.usecase.ts new file mode 100644 index 00000000000..ed0c934a041 --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-api-keys/get-api-keys.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { IApiKey, ApplicationRepository } from '@notifire/dal'; +import { GetApiKeysCommand } from './get-api-keys.command'; + +@Injectable() +export class GetApiKeys { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: GetApiKeysCommand): Promise { + return await this.applicationRepository.getApiKeys(command.applicationId); + } +} diff --git a/apps/api/src/app/applications/usecases/get-application/get-application.command.ts b/apps/api/src/app/applications/usecases/get-application/get-application.command.ts new file mode 100644 index 00000000000..ccc3662a729 --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-application/get-application.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetApplicationCommand extends ApplicationWithUserCommand { + static create(data: GetApplicationCommand) { + return CommandHelper.create(GetApplicationCommand, data); + } +} diff --git a/apps/api/src/app/applications/usecases/get-application/get-application.e2e.ts b/apps/api/src/app/applications/usecases/get-application/get-application.e2e.ts new file mode 100644 index 00000000000..20b9af67dc6 --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-application/get-application.e2e.ts @@ -0,0 +1,18 @@ +import { ApplicationRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Get Application - /applications/me (GET)', async () => { + let session: UserSession; + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should return correct application to user', async () => { + const { body } = await session.testAgent.get('/v1/applications/me'); + expect(body.data.name).to.eq(session.application.name); + expect(body.data._organizationId).to.eq(session.organization._id); + expect(body.data.identifier).to.equal(session.application.identifier); + }); +}); diff --git a/apps/api/src/app/applications/usecases/get-application/get-application.usecase.ts b/apps/api/src/app/applications/usecases/get-application/get-application.usecase.ts new file mode 100644 index 00000000000..ee3f10167ed --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-application/get-application.usecase.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationRepository } from '@notifire/dal'; +import { GetApplicationCommand } from './get-application.command'; + +@Injectable() +export class GetApplication { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: GetApplicationCommand) { + return await this.applicationRepository.findOne( + { + _id: command.applicationId, + _organizationId: command.organizationId, + }, + '-apiKeys' + ); + } +} diff --git a/apps/api/src/app/applications/usecases/get-application/index.ts b/apps/api/src/app/applications/usecases/get-application/index.ts new file mode 100644 index 00000000000..39427581a75 --- /dev/null +++ b/apps/api/src/app/applications/usecases/get-application/index.ts @@ -0,0 +1,2 @@ +export * from './get-application.command'; +export * from './get-application.usecase'; diff --git a/apps/api/src/app/applications/usecases/index.ts b/apps/api/src/app/applications/usecases/index.ts new file mode 100644 index 00000000000..1c200c08e9c --- /dev/null +++ b/apps/api/src/app/applications/usecases/index.ts @@ -0,0 +1,12 @@ +import { UpdateBrandingDetails } from './update-branding-details/update-branding-details.usecase'; +import { CreateApplication } from './create-application/create-application.usecase'; +import { GetApiKeys } from './get-api-keys/get-api-keys.usecase'; +import { GetApplication } from './get-application'; + +export const USE_CASES = [ + // + CreateApplication, + GetApiKeys, + GetApplication, + UpdateBrandingDetails, +]; diff --git a/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.command.ts b/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.command.ts new file mode 100644 index 00000000000..19be48c87d5 --- /dev/null +++ b/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.command.ts @@ -0,0 +1,27 @@ +import { IsHexColor, IsOptional, IsUrl } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class UpdateBrandingDetailsCommand extends ApplicationWithUserCommand { + static create(data: UpdateBrandingDetailsCommand) { + return CommandHelper.create(UpdateBrandingDetailsCommand, data); + } + + @IsUrl({ require_tld: false }) + logo: string; + + @IsOptional() + @IsHexColor() + color: string; + + @IsOptional() + @IsHexColor() + fontColor: string; + + @IsOptional() + @IsHexColor() + contentBackground: string; + + @IsOptional() + fontFamily?: string; +} diff --git a/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.usecase.ts b/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.usecase.ts new file mode 100644 index 00000000000..c8eccad729c --- /dev/null +++ b/apps/api/src/app/applications/usecases/update-branding-details/update-branding-details.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationRepository } from '@notifire/dal'; +import { UpdateBrandingDetailsCommand } from './update-branding-details.command'; + +@Injectable() +export class UpdateBrandingDetails { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: UpdateBrandingDetailsCommand) { + const payload = { + color: command.color, + logo: command.logo, + fontColor: command.fontColor, + contentBackground: command.contentBackground, + fontFamily: command.fontFamily, + }; + + await this.applicationRepository.updateBrandingDetails(command.applicationId, payload); + + return payload; + } +} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts new file mode 100644 index 00000000000..2010730da83 --- /dev/null +++ b/apps/api/src/app/auth/auth.controller.ts @@ -0,0 +1,176 @@ +import { + BadRequestException, + Body, + ClassSerializerInterceptor, + Controller, + Get, + HttpCode, + NotFoundException, + Param, + Post, + Query, + Req, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { MemberRepository, OrganizationRepository, UserRepository } from '@notifire/dal'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from '@nestjs/passport'; +import { IJwtPayload } from '@notifire/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'; +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 { SwitchApplication } from './usecases/switch-application/switch-application.usecase'; +import { SwitchApplicationCommand } from './usecases/switch-application/switch-application.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'; +import { PasswordResetCommand } from './usecases/password-reset/password-reset.command'; +import { PasswordReset } from './usecases/password-reset/password-reset.usecase'; + +@Controller('/auth') +@UseInterceptors(ClassSerializerInterceptor) +export class AuthController { + constructor( + private userRepository: UserRepository, + private jwtService: JwtService, + private authService: AuthService, + private userRegisterUsecase: UserRegister, + private loginUsecase: Login, + private organizationRepository: OrganizationRepository, + private switchApplicationUsecase: SwitchApplication, + private switchOrganizationUsecase: SwitchOrganization, + private memberRepository: MemberRepository, + private passwordResetRequestUsecase: PasswordResetRequest, + private passwordResetUsecase: PasswordReset + ) {} + + @Get('/github') + githubTestAuth() { + return { + success: true, + }; + } + + @Get('/github/callback') + @UseGuards(AuthGuard('github')) + async githubCallback(@Req() request, @Res() response) { + if (!request.user || !request.user.token) { + return response.redirect(`${process.env.CLIENT_SUCCESS_AUTH_REDIRECT}?error=AuthenticationError`); + } + + let url = `${process.env.CLIENT_SUCCESS_AUTH_REDIRECT}?token=${request.user.token}`; + if (request.user.newUser) { + url += '&newUser=true'; + } + + return response.redirect(url); + } + + @Get('/refresh') + @UseGuards(JwtAuthGuard) + refreshToken(@UserSession() user: IJwtPayload) { + if (!user || !user._id) throw new BadRequestException(); + + return this.authService.refreshToken(user._id); + } + + @Post('/register') + async userRegistration(@Body() body: UserRegistrationBodyDto) { + return await this.userRegisterUsecase.execute( + UserRegisterCommand.create({ + email: body.email, + password: body.password, + firstName: body.firstName, + lastName: body.lastName, + organizationName: body.organizationName, + }) + ); + } + + @Post('/reset/request') + async forgotPasswordRequest(@Body() body: { email: string }) { + return await this.passwordResetRequestUsecase.execute( + PasswordResetRequestCommand.create({ + email: body.email, + }) + ); + } + + @Post('/reset') + async passwordReset(@Body() body: { password: string; token: string }) { + return await this.passwordResetUsecase.execute( + PasswordResetCommand.create({ + password: body.password, + token: body.token, + }) + ); + } + + @Post('/login') + async userLogin(@Body() body: LoginBodyDto) { + return await this.loginUsecase.execute( + LoginCommand.create({ + email: body.email, + password: body.password, + }) + ); + } + + @Post('/organizations/:organizationId/switch') + @UseGuards(JwtAuthGuard) + @HttpCode(200) + async organizationSwitch( + @UserSession() user: IJwtPayload, + @Param('organizationId') organizationId: string + ): Promise { + const command = SwitchOrganizationCommand.create({ + userId: user._id, + newOrganizationId: organizationId, + }); + + return await this.switchOrganizationUsecase.execute(command); + } + + @Post('/applications/:applicationId/switch') + @UseGuards(JwtAuthGuard) + @HttpCode(200) + async projectSwitch( + @UserSession() user: IJwtPayload, + @Param('applicationId') applicationId: string + ): Promise<{ token: string }> { + const command = SwitchApplicationCommand.create({ + userId: user._id, + newApplicationId: applicationId, + organizationId: user.organizationId, + }); + + return { + token: await this.switchApplicationUsecase.execute(command), + }; + } + + @Get('/test/token/:userId') + async authenticateTest( + @Param('userId') userId: string, + @Query('organizationId') organizationId: string, + @Query('applicationId') applicationId: string + ) { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + const user = await this.userRepository.findById(userId); + if (!user) throw new BadRequestException('No user found'); + + const member = organizationId ? await this.memberRepository.findMemberByUserId(organizationId, user._id) : null; + + return await this.authService.getSignedToken(user, organizationId, member, applicationId); + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts new file mode 100644 index 00000000000..04fc65903dd --- /dev/null +++ b/apps/api/src/app/auth/auth.module.ts @@ -0,0 +1,68 @@ +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { authenticate } from 'passport'; +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'; +import { OrganizationModule } from '../organization/organization.module'; +import { ApplicationsModule } from '../applications/applications.module'; +import { JwtSubscriberStrategy } from './services/passport/subscriber-jwt.strategy'; +import { JwtAuthGuard } from './framework/auth.guard'; + +const AUTH_STRATEGIES = []; + +if (process.env.GITHUB_OAUTH_CLIENT_ID) { + AUTH_STRATEGIES.push(GithubStrategy); +} + +@Module({ + imports: [ + OrganizationModule, + SharedModule, + UserModule, + PassportModule.register({ + defaultStrategy: 'jwt', + }), + JwtModule.register({ + secretOrKeyProvider: () => process.env.JWT_SECRET as string, + signOptions: { + expiresIn: 360000, + }, + }), + ApplicationsModule, + ], + controllers: [AuthController], + providers: [ + JwtAuthGuard, + ...USE_CASES, + ...AUTH_STRATEGIES, + JwtStrategy, + AuthService, + RolesGuard, + JwtSubscriberStrategy, + ], + exports: [RolesGuard, AuthService, ...USE_CASES, JwtAuthGuard], +}) +export class AuthModule implements NestModule { + public configure(consumer: MiddlewareConsumer) { + if (process.env.GITHUB_OAUTH_CLIENT_ID) { + consumer + .apply( + authenticate('github', { + session: false, + scope: [], + }) + ) + .forRoutes({ + path: '/auth/github', + method: RequestMethod.GET, + }); + } + } +} diff --git a/apps/api/src/app/auth/dtos/login.dto.ts b/apps/api/src/app/auth/dtos/login.dto.ts new file mode 100644 index 00000000000..f724aebbe67 --- /dev/null +++ b/apps/api/src/app/auth/dtos/login.dto.ts @@ -0,0 +1,10 @@ +import { IsDefined, IsEmail } from 'class-validator'; + +export class LoginBodyDto { + @IsDefined() + @IsEmail() + email: string; + + @IsDefined() + password: string; +} diff --git a/apps/api/src/app/auth/dtos/user-registration.dto.ts b/apps/api/src/app/auth/dtos/user-registration.dto.ts new file mode 100644 index 00000000000..1dace8014fc --- /dev/null +++ b/apps/api/src/app/auth/dtos/user-registration.dto.ts @@ -0,0 +1,20 @@ +import { IsDefined, IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator'; + +export class UserRegistrationBodyDto { + @IsDefined() + @IsEmail() + email: string; + + @IsDefined() + @MinLength(8) + password: string; + + @IsDefined() + firstName: string; + + @IsDefined() + lastName: string; + + @IsOptional() + organizationName: string; +} diff --git a/apps/api/src/app/auth/e2e/login.e2e.ts b/apps/api/src/app/auth/e2e/login.e2e.ts new file mode 100644 index 00000000000..9834d3c6d00 --- /dev/null +++ b/apps/api/src/app/auth/e2e/login.e2e.ts @@ -0,0 +1,49 @@ +import { UserSession } from '@notifire/testing'; +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; +import { IJwtPayload } from '@notifire/shared'; + +describe('User login - /auth/login (POST)', async () => { + let session: UserSession; + const userCredentials = { + email: 'Testy.test22@gmail.com', + password: '123456789', + }; + + before(async () => { + session = new UserSession(); + await session.initialize(); + + const { body } = await session.testAgent + .post('/v1/auth/register') + .send({ + email: userCredentials.email, + password: userCredentials.password, + firstName: 'Test', + lastName: 'User', + }) + .expect(201); + }); + + it('should login the user correctly', async () => { + const { body } = await session.testAgent.post('/v1/auth/login').send({ + email: userCredentials.email, + password: userCredentials.password, + }); + + const jwtContent = ((await jwt.decode(body.data.token)) as unknown) as IJwtPayload; + expect(jwtContent.firstName).to.equal('test'); + expect(jwtContent.lastName).to.equal('user'); + expect(jwtContent.email).to.equal('testytest22@gmail.com'); + }); + + it('should fail on bad password', async () => { + const { body } = await session.testAgent.post('/v1/auth/login').send({ + email: userCredentials.email, + password: '123123213123', + }); + + expect(body.statusCode).to.equal(400); + expect(body.message).to.contain('Wrong credentials provided'); + }); +}); diff --git a/apps/api/src/app/auth/e2e/password-reset.e2e.ts b/apps/api/src/app/auth/e2e/password-reset.e2e.ts new file mode 100644 index 00000000000..8803895dd73 --- /dev/null +++ b/apps/api/src/app/auth/e2e/password-reset.e2e.ts @@ -0,0 +1,76 @@ +import { UserRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; +import * as moment from 'moment'; + +describe('Password reset - /auth/reset (POST)', async () => { + let session: UserSession; + const userRepository = new UserRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should request a password reset for existing user', async () => { + const { body } = await session.testAgent.post('/v1/auth/reset/request').send({ + email: session.user.email, + }); + + expect(body.data.success).to.equal(true); + const found = await userRepository.findById(session.user._id); + expect(found.resetToken).to.be.ok; + }); + + it('should change a password after reset', async () => { + const { body } = await session.testAgent.post('/v1/auth/reset/request').send({ + email: session.user.email, + }); + + expect(body.data.success).to.equal(true); + const foundUser = await userRepository.findById(session.user._id); + + const { body: resetChange } = await session.testAgent.post('/v1/auth/reset').send({ + password: 'ASd3ASD$Fdfdf', + token: foundUser.resetToken, + }); + expect(resetChange.data.token).to.be.ok; + + const { body: loginBody } = await session.testAgent.post('/v1/auth/login').send({ + email: session.user.email, + password: 'ASd3ASD$Fdfdf', + }); + + expect(loginBody.data.token).to.be.ok; + + const foundUserAfterChange = await userRepository.findById(session.user._id); + expect(foundUserAfterChange.resetToken).to.not.be.ok; + expect(foundUserAfterChange.resetTokenDate).to.not.be.ok; + }); + + it('should fail to change password for bad token', async () => { + const { body } = await session.testAgent.post('/v1/auth/reset/request').send({ + email: session.user.email, + }); + + expect(body.data.success).to.equal(true); + await userRepository.update( + { + _id: session.user._id, + }, + { + $set: { + resetTokenDate: moment().subtract(20, 'days').toDate(), + }, + } + ); + + const foundUser = await userRepository.findById(session.user._id); + + const { body: resetChange } = await session.testAgent.post('/v1/auth/reset').send({ + password: 'ASd3ASD$Fdfdf', + token: foundUser.resetToken, + }); + expect(resetChange.message).to.contain('Token has expired'); + }); +}); diff --git a/apps/api/src/app/auth/e2e/switch-organization.e2e.ts b/apps/api/src/app/auth/e2e/switch-organization.e2e.ts new file mode 100644 index 00000000000..7aa750b7b6e --- /dev/null +++ b/apps/api/src/app/auth/e2e/switch-organization.e2e.ts @@ -0,0 +1,72 @@ +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; +import { OrganizationEntity } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { IJwtPayload, MemberRoleEnum } from '@notifire/shared'; + +describe('Switch Organization - /auth/organizations/:id/switch (POST)', async () => { + let session: UserSession; + + describe('no organization for user', () => { + before(async () => { + session = new UserSession(); + await session.initialize({ + noOrganization: true, + }); + }); + + it('should fail for not authorized organization', async () => { + const { body } = await session.testAgent + .post('/v1/auth/organizations/5c573a9941a86c60689cf63a/switch') + .expect(401); + }); + }); + + describe('user has single organizations', () => { + before(async () => { + session = new UserSession(); + await session.initialize({ + noOrganization: true, + }); + }); + + it('should switch the user current organization', async () => { + const content = jwt.decode(session.token.split(' ')[1]) as IJwtPayload; + expect(content._id).to.equal(session.user._id); + const organization = await session.addOrganization(); + + const { body } = await session.testAgent.post(`/v1/auth/organizations/${organization._id}/switch`).expect(200); + + const newJwt = jwt.decode(body.data) as IJwtPayload; + expect(newJwt._id).to.equal(session.user._id); + expect(newJwt.organizationId).to.equal(organization._id); + expect(newJwt.roles.length).to.equal(1); + expect(newJwt.roles[0]).to.equal(MemberRoleEnum.ADMIN); + }); + }); + + describe('user has multiple organizations', () => { + let secondOrganization: OrganizationEntity; + let firstOrganization: OrganizationEntity; + + before(async () => { + session = new UserSession(); + await session.initialize(); + firstOrganization = session.organization; + secondOrganization = await session.addOrganization(); + }); + + it('should switch to second organization', async () => { + const content = jwt.decode(session.token.split(' ')[1]) as IJwtPayload; + expect(content.organizationId).to.equal(firstOrganization._id); + + const { body } = await session.testAgent + .post(`/v1/auth/organizations/${secondOrganization._id}/switch`) + .expect(200); + + const newJwt = jwt.decode(body.data) as IJwtPayload; + expect(newJwt._id).to.equal(session.user._id); + expect(newJwt.organizationId).to.equal(secondOrganization._id); + }); + }); +}); diff --git a/apps/api/src/app/auth/e2e/user-registration.e2e.ts b/apps/api/src/app/auth/e2e/user-registration.e2e.ts new file mode 100644 index 00000000000..3710443fb9d --- /dev/null +++ b/apps/api/src/app/auth/e2e/user-registration.e2e.ts @@ -0,0 +1,86 @@ +import { ApplicationRepository, OrganizationRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; +import { IJwtPayload, MemberRoleEnum } from '@notifire/shared'; + +describe('User registration - /auth/register (POST)', async () => { + let session: UserSession; + const applicationRepository = new ApplicationRepository(); + const organizationRepository = new OrganizationRepository(); + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should throw validation error for not enough information', async () => { + const { body } = await session.testAgent.post('/v1/auth/register').send({ + email: '123', + }); + + expect(body.statusCode).to.equal(400); + expect(body.message.find((i) => i.includes('email'))).to.be.ok; + expect(body.message.find((i) => i.includes('password'))).to.be.ok; + expect(body.message.find((i) => i.includes('firstName'))).to.be.ok; + expect(body.message.find((i) => i.includes('lastName'))).to.be.ok; + }); + + it('should create a new user successfully', async () => { + const { body } = await session.testAgent.post('/v1/auth/register').send({ + email: 'Testy.test@gmail.com', + firstName: 'Test', + lastName: 'User', + password: '123456789', + }); + + expect(body.data.token).to.be.ok; + + const jwtContent = ((await jwt.decode(body.data.token)) as unknown) as IJwtPayload; + + expect(jwtContent.firstName).to.equal('test'); + expect(jwtContent.lastName).to.equal('user'); + expect(jwtContent.email).to.equal('testytest@gmail.com'); + }); + + it('should create a user with organization', async () => { + const { body } = await session.testAgent.post('/v1/auth/register').send({ + email: 'Testy.test-org@gmail.com', + firstName: 'Test', + lastName: 'User', + password: '123456789', + organizationName: 'Sample org', + }); + + expect(body.data.token).to.be.ok; + + const jwtContent = ((await jwt.decode(body.data.token)) as unknown) as IJwtPayload; + + expect(jwtContent.firstName).to.equal('test'); + expect(jwtContent.lastName).to.equal('user'); + + // Should generate organization + expect(jwtContent.organizationId).to.be.ok; + const organization = await organizationRepository.findById(jwtContent.organizationId); + expect(organization.name).to.equal('Sample org'); + + // Should generate application and api keys + expect(jwtContent.applicationId).to.be.ok; + const application = await applicationRepository.findById(jwtContent.applicationId); + expect(application.apiKeys.length).to.equal(1); + expect(application.apiKeys[0].key).to.ok; + + expect(jwtContent.roles[0]).to.equal(MemberRoleEnum.ADMIN); + }); + + it('should throw error when registering same user twice', async () => { + const { body } = await session.testAgent.post('/v1/auth/register').send({ + email: 'Testy.test@gmail.com', + firstName: 'Test', + lastName: 'User', + password: '123456789', + }); + + expect(body.message).to.contain('User already exists'); + }); +}); diff --git a/apps/api/src/app/auth/framework/auth.guard.ts b/apps/api/src/app/auth/framework/auth.guard.ts new file mode 100644 index 00000000000..a6a9f1b8eee --- /dev/null +++ b/apps/api/src/app/auth/framework/auth.guard.ts @@ -0,0 +1,34 @@ +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'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor( + @Inject(forwardRef(() => AuthService)) private authService: AuthService, + private readonly reflector: Reflector + ) { + super(); + } + + canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const authorizationHeader = request.headers.authorization; + + if (authorizationHeader && authorizationHeader.includes('ApiKey')) { + const apiEnabled = this.reflector.get('external_api_accessible', context.getHandler()); + if (!apiEnabled) throw new UnauthorizedException('API endpoint not available'); + + const key = authorizationHeader.split(' ')[1]; + + return this.authService.apiKeyAuthenticate(key).then((result) => { + request.headers.authorization = `Bearer ${result}`; + + return super.canActivate(context); + }); + } + + return super.canActivate(context); + } +} diff --git a/apps/api/src/app/auth/framework/external-api.decorator.ts b/apps/api/src/app/auth/framework/external-api.decorator.ts new file mode 100644 index 00000000000..0b0b95f7fb5 --- /dev/null +++ b/apps/api/src/app/auth/framework/external-api.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ExternalApiAccessible = () => SetMetadata('external_api_accessible', true); diff --git a/apps/api/src/app/auth/framework/roles.decorator.ts b/apps/api/src/app/auth/framework/roles.decorator.ts new file mode 100644 index 00000000000..b0376727cc3 --- /dev/null +++ b/apps/api/src/app/auth/framework/roles.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const Roles = (...roles: string[]) => SetMetadata('roles', roles); diff --git a/apps/api/src/app/auth/framework/roles.guard.ts b/apps/api/src/app/auth/framework/roles.guard.ts new file mode 100644 index 00000000000..e4e69e20cfd --- /dev/null +++ b/apps/api/src/app/auth/framework/roles.guard.ts @@ -0,0 +1,27 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IJwtPayload } from '@notifire/shared'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const roles = this.reflector.get('roles', context.getHandler()); + if (!roles) { + return true; + } + + const request = context.switchToHttp().getRequest(); + if (!request.headers.authorization) return false; + + const token = request.headers.authorization.split(' ')[1]; + if (!token) return false; + + const user = jwt.decode(token) as IJwtPayload; + if (!user) return false; + + return true; + } +} diff --git a/apps/api/src/app/auth/framework/subscriber-route.decorator.ts b/apps/api/src/app/auth/framework/subscriber-route.decorator.ts new file mode 100644 index 00000000000..2e609dd877b --- /dev/null +++ b/apps/api/src/app/auth/framework/subscriber-route.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SubscriberRoute = () => SetMetadata('subscriberRouteGuard', true); diff --git a/apps/api/src/app/auth/framework/subscriber-route.guard.ts b/apps/api/src/app/auth/framework/subscriber-route.guard.ts new file mode 100644 index 00000000000..fd254ea31ca --- /dev/null +++ b/apps/api/src/app/auth/framework/subscriber-route.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ISubscriberJwt } from '@notifire/shared'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class SubscriberRouteGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const subscriberRouteGuard = this.reflector.get('subscriberRouteGuard', context.getHandler()); + if (!subscriberRouteGuard) { + return true; + } + + const request = context.switchToHttp().getRequest(); + if (!request.headers.authorization) return false; + + const token = request.headers.authorization.split(' ')[1]; + if (!token) return false; + + const tokenContent = jwt.decode(token) as ISubscriberJwt; + if (!tokenContent) return false; + if (tokenContent.aud !== 'widget_user') return false; + if (!tokenContent.applicationId) return false; + + return true; + } +} diff --git a/apps/api/src/app/auth/services/auth.interface.ts b/apps/api/src/app/auth/services/auth.interface.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/src/app/auth/services/auth.service.ts b/apps/api/src/app/auth/services/auth.service.ts new file mode 100644 index 00000000000..f6d59ba0212 --- /dev/null +++ b/apps/api/src/app/auth/services/auth.service.ts @@ -0,0 +1,235 @@ +import { forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { + UserEntity, + UserRepository, + MemberEntity, + OrganizationRepository, + ApplicationRepository, + SubscriberEntity, + SubscriberRepository, + MemberRepository, +} from '@notifire/dal'; +import { AuthProviderEnum, IJwtPayload, ISubscriberJwt, MemberRoleEnum } from '@notifire/shared'; + +import { CreateUserCommand } from '../../user/usecases/create-user/create-user.dto'; +import { CreateUser } from '../../user/usecases/create-user/create-user.usecase'; +import { SwitchApplicationCommand } from '../usecases/switch-application/switch-application.command'; +import { SwitchApplication } from '../usecases/switch-application/switch-application.usecase'; +import { SwitchOrganization } from '../usecases/switch-organization/switch-organization.usecase'; +import { SwitchOrganizationCommand } from '../usecases/switch-organization/switch-organization.command'; +import { QueueService } from '../../shared/services/queue'; +import { AnalyticsService } from '../../shared/services/analytics/analytics.service'; + +@Injectable() +export class AuthService { + constructor( + private userRepository: UserRepository, + private subscriberRepository: SubscriberRepository, + private createUserUsecase: CreateUser, + private jwtService: JwtService, + private queueService: QueueService, + private analyticsService: AnalyticsService, + private organizationRepository: OrganizationRepository, + private applicationRepository: ApplicationRepository, + private memberRepository: MemberRepository, + @Inject(forwardRef(() => SwitchOrganization)) private switchOrganizationUsecase: SwitchOrganization, + @Inject(forwardRef(() => SwitchApplication)) private switchApplicationUsecase: SwitchApplication + ) {} + + async authenticate( + authProvider: AuthProviderEnum, + accessToken: string, + refreshToken: string, + profile: { name: string; login: string; email: string; avatar_url: string; id: string }, + distinctId: string + ) { + let user = await this.userRepository.findByLoginProvider(profile.id, authProvider); + let newUser = false; + + if (!user) { + user = await this.createUserUsecase.execute( + CreateUserCommand.create({ + picture: profile.avatar_url, + email: profile.email, + lastName: profile.name ? profile.name.split(' ').slice(-1).join(' ') : null, + firstName: profile.name ? profile.name.split(' ').slice(0, -1).join(' ') : profile.login, + auth: { + profileId: profile.id, + provider: authProvider, + accessToken, + refreshToken, + }, + }) + ); + newUser = true; + + this.analyticsService.upsertUser(user, distinctId || user._id); + + if (distinctId) { + this.analyticsService.alias(distinctId, user._id); + } + } else { + this.analyticsService.track('[Authentication] - Login', user._id, { + loginType: authProvider, + }); + } + + return { + newUser, + token: await this.generateUserToken(user), + }; + } + + async refreshToken(userId: string) { + const user = await this.userRepository.findById(userId); + + return this.getSignedToken(user); + } + + async isAuthenticatedForOrganization(userId: string, organizationId: string): Promise { + return !!(await this.memberRepository.isMemberOfOrganization(organizationId, userId)); + } + + async apiKeyAuthenticate(apiKey: string) { + const application = await this.applicationRepository.findByApiKey(apiKey); + if (!application) throw new UnauthorizedException('API Key not found'); + + const key = application.apiKeys.find((i) => i.key === apiKey); + const user = await this.userRepository.findById(key._userId); + + return await this.getApiSignedToken(user, application._organizationId, application._id, key.key); + } + + async getSubscriberWidgetToken(subscriber: SubscriberEntity) { + return this.jwtService.sign( + { + _id: subscriber._id, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + email: subscriber.email, + organizationId: subscriber._organizationId, + applicationId: subscriber._applicationId, + subscriberId: subscriber.subscriberId, + }, + { + expiresIn: '15 day', + issuer: 'notifire_api', + audience: 'widget_user', + } + ); + } + + async getApiSignedToken( + user: UserEntity, + organizationId: string, + applicationId: string, + apiKey: string + ): Promise { + return this.jwtService.sign( + { + _id: user._id, + firstName: null, + lastName: null, + email: null, + profilePicture: null, + organizationId, + roles: [MemberRoleEnum.ADMIN], + apiKey, + applicationId, + }, + { + expiresIn: '1 day', + issuer: 'notifire_api', + audience: 'api_token', + } + ); + } + + async generateUserToken(user: UserEntity) { + const userActiveOrganizations = await this.organizationRepository.findUserActiveOrganizations(user._id); + + if (userActiveOrganizations && userActiveOrganizations.length) { + const organizationToSwitch = userActiveOrganizations[0]; + + const userActiveProjects = await this.applicationRepository.findOrganizationApplications( + organizationToSwitch._id + ); + const applicationToSwitch = userActiveProjects[0]; + + if (applicationToSwitch) { + return await this.switchApplicationUsecase.execute( + SwitchApplicationCommand.create({ + newApplicationId: applicationToSwitch._id, + organizationId: organizationToSwitch._id, + userId: user._id, + }) + ); + } + + return await this.switchOrganizationUsecase.execute( + SwitchOrganizationCommand.create({ + newOrganizationId: organizationToSwitch._id, + userId: user._id, + }) + ); + } + + return this.getSignedToken(user); + } + + async getSignedToken( + user: UserEntity, + organizationId?: string, + member?: MemberEntity, + applicationId?: string + ): Promise { + const roles = []; + if (member && member.roles) { + roles.push(...member.roles); + } + + return this.jwtService.sign( + { + _id: user._id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + profilePicture: user.profilePicture, + organizationId: organizationId || null, + roles, + applicationId: applicationId || null, + }, + { + expiresIn: '30 days', + issuer: 'notifire_api', + } + ); + } + + async validateUser(payload: IJwtPayload): Promise { + const user = await this.userRepository.findById(payload._id); + if (payload.organizationId) { + const isMember = await this.isAuthenticatedForOrganization(payload._id, payload.organizationId); + if (!isMember) throw new UnauthorizedException(`No authorized for organization ${payload.organizationId}`); + } + + return user; + } + + async validateSubscriber(payload: ISubscriberJwt): Promise { + const subscriber = await this.subscriberRepository.findOne({ + _applicationId: payload.applicationId, + _id: payload._id, + }); + return subscriber; + } + + async decodeJwt(token: string) { + return this.jwtService.decode(token) as T; + } + + async verifyJwt(jwt: string) { + return this.jwtService.verify(jwt); + } +} diff --git a/apps/api/src/app/auth/services/passport/github.strategy.ts b/apps/api/src/app/auth/services/passport/github.strategy.ts new file mode 100644 index 00000000000..9ca5b057112 --- /dev/null +++ b/apps/api/src/app/auth/services/passport/github.strategy.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import * as githubPassport from 'passport-github'; +import { Metadata, StateStoreStoreCallback, StateStoreVerifyCallback } from 'passport-oauth2'; +import { AuthProviderEnum } from '@notifire/shared'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(githubPassport.Strategy, 'github') { + constructor(private authService: AuthService) { + super({ + clientID: process.env.GITHUB_OAUTH_CLIENT_ID, + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + callbackURL: process.env.GITHUB_OAUTH_REDIRECT, + passReqToCallback: true, + store: { + verify(req, state: string, meta: Metadata, callback: StateStoreVerifyCallback) { + callback(null, true, req.query.distinctId); + }, + store(req, meta: Metadata, callback: StateStoreStoreCallback) { + callback(null, req.query.distinctId); + }, + }, + }); + } + + async validate(req, accessToken: string, refreshToken: string, profile, done: (err, data) => void) { + try { + const response = await this.authService.authenticate( + AuthProviderEnum.GITHUB, + accessToken, + refreshToken, + profile._json, + req.query.state + ); + + done(null, { + token: response.token, + newUser: response.newUser, + }); + } catch (err) { + done(err, false); + } + } +} diff --git a/apps/api/src/app/auth/services/passport/jwt.strategy.ts b/apps/api/src/app/auth/services/passport/jwt.strategy.ts new file mode 100644 index 00000000000..60dab7f978a --- /dev/null +++ b/apps/api/src/app/auth/services/passport/jwt.strategy.ts @@ -0,0 +1,24 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { IJwtPayload } from '@notifire/shared'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: IJwtPayload) { + const user = await this.authService.validateUser(payload); + if (!user) { + throw new UnauthorizedException(); + } + + return payload; + } +} 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 new file mode 100644 index 00000000000..bade8e71fc1 --- /dev/null +++ b/apps/api/src/app/auth/services/passport/subscriber-jwt.strategy.ts @@ -0,0 +1,29 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { IJwtPayload, ISubscriberJwt } from '@notifire/shared'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtSubscriberStrategy extends PassportStrategy(Strategy, 'subscriberJwt') { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: ISubscriberJwt) { + const subscriber = await this.authService.validateSubscriber(payload); + + if (!subscriber) { + throw new UnauthorizedException(); + } + + if (payload.aud !== 'widget_user') { + throw new UnauthorizedException(); + } + + return subscriber; + } +} diff --git a/apps/api/src/app/auth/usecases/index.ts b/apps/api/src/app/auth/usecases/index.ts new file mode 100644 index 00000000000..ff92ff637ef --- /dev/null +++ b/apps/api/src/app/auth/usecases/index.ts @@ -0,0 +1,16 @@ +import { PasswordResetRequest } from './password-reset-request/password-reset-request.usecase'; +import { UserRegister } from './register/user-register.usecase'; +import { Login } from './login/login.usecase'; +import { SwitchApplication } from './switch-application/switch-application.usecase'; +import { SwitchOrganization } from './switch-organization/switch-organization.usecase'; +import { PasswordReset } from './password-reset/password-reset.usecase'; + +export const USE_CASES = [ + // + UserRegister, + Login, + SwitchApplication, + SwitchOrganization, + PasswordResetRequest, + PasswordReset, +]; diff --git a/apps/api/src/app/auth/usecases/login/login.command.ts b/apps/api/src/app/auth/usecases/login/login.command.ts new file mode 100644 index 00000000000..96c7f386daf --- /dev/null +++ b/apps/api/src/app/auth/usecases/login/login.command.ts @@ -0,0 +1,16 @@ +import { IsDefined, IsEmail, IsNotEmpty } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class LoginCommand { + static create(data: LoginCommand) { + return CommandHelper.create(LoginCommand, data); + } + + @IsDefined() + @IsNotEmpty() + @IsEmail() + email: string; + + @IsDefined() + password: string; +} diff --git a/apps/api/src/app/auth/usecases/login/login.usecase.ts b/apps/api/src/app/auth/usecases/login/login.usecase.ts new file mode 100644 index 00000000000..25c98927f0d --- /dev/null +++ b/apps/api/src/app/auth/usecases/login/login.usecase.ts @@ -0,0 +1,27 @@ +import * as bcrypt from 'bcrypt'; +import { Injectable } from '@nestjs/common'; +import { UserRepository } from '@notifire/dal'; +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'; + +@Injectable() +export class Login { + constructor(private userRepository: UserRepository, private authService: AuthService) {} + + async execute(command: LoginCommand) { + const email = normalizeEmail(command.email); + const user = await this.userRepository.findByEmail(email); + if (!user) throw new ApiException('User not found'); + if (!user.password) throw new ApiException('OAuth user login attempt'); + + const isMatching = await bcrypt.compare(command.password, user.password); + if (!isMatching) throw new ApiException('Wrong credentials provided'); + + return { + token: await this.authService.generateUserToken(user), + }; + } +} diff --git a/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.command.ts b/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.command.ts new file mode 100644 index 00000000000..5c8d020284e --- /dev/null +++ b/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.command.ts @@ -0,0 +1,13 @@ +import { IsDefined, IsEmail } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class PasswordResetRequestCommand { + static create(data: PasswordResetRequestCommand) { + return CommandHelper.create(PasswordResetRequestCommand, data); + } + + @IsEmail() + @IsDefined() + email: string; +} diff --git a/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.usecase.ts b/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.usecase.ts new file mode 100644 index 00000000000..38b69f19f5a --- /dev/null +++ b/apps/api/src/app/auth/usecases/password-reset-request/password-reset-request.usecase.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Notifire } from '@notifire/node'; +import { UserRepository } from '@notifire/dal'; +import { v4 as uuidv4 } from 'uuid'; +import { PasswordResetRequestCommand } from './password-reset-request.command'; + +@Injectable() +export class PasswordResetRequest { + constructor(private userRepository: UserRepository) {} + + async execute(command: PasswordResetRequestCommand): Promise<{ success: boolean }> { + const foundUser = await this.userRepository.findByEmail(command.email); + if (foundUser) { + const token = uuidv4(); + await this.userRepository.updatePasswordResetToken(foundUser._id, token); + + if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'prod') { + const notifire = new Notifire(process.env.NOTIFIRE_API_KEY); + await notifire.trigger('password-reset-request-8bTC73NsY', { + $user_id: foundUser._id, + resetPasswordLink: `${process.env.FRONT_BASE_URL}/auth/reset/${token}`, + $email: foundUser.email, + }); + } + } + + return { + success: true, + }; + } +} diff --git a/apps/api/src/app/auth/usecases/password-reset/password-reset.command.ts b/apps/api/src/app/auth/usecases/password-reset/password-reset.command.ts new file mode 100644 index 00000000000..dae9bff77a9 --- /dev/null +++ b/apps/api/src/app/auth/usecases/password-reset/password-reset.command.ts @@ -0,0 +1,20 @@ +import { IsDefined, IsString, IsUUID, MinLength } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class PasswordResetCommand { + static create(data: PasswordResetCommand) { + return CommandHelper.create(PasswordResetCommand, data); + } + + @IsString() + @IsDefined() + @MinLength(8) + password: string; + + @IsUUID(4, { + message: 'Bad token provided', + }) + @IsDefined() + token: string; +} 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 new file mode 100644 index 00000000000..d19a9d7b384 --- /dev/null +++ b/apps/api/src/app/auth/usecases/password-reset/password-reset.usecase.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from '@notifire/dal'; +import * as bcrypt from 'bcrypt'; +import * as moment from 'moment'; +import { PasswordResetCommand } from './password-reset.command'; +import { ApiException } from '../../../shared/exceptions/api.exception'; +import { AuthService } from '../../services/auth.service'; + +@Injectable() +export class PasswordReset { + constructor(private userRepository: UserRepository, private authService: AuthService) {} + + async execute(command: PasswordResetCommand): Promise<{ token: string }> { + const user = await this.userRepository.findUserByToken(command.token); + if (!user) { + throw new ApiException('Bad token provided'); + } + + if (moment(user.resetTokenDate).isBefore(moment().subtract(7, 'days'))) { + throw new ApiException('Token has expired'); + } + + const passwordHash = await bcrypt.hash(command.password, 10); + await this.userRepository.update( + { + _id: user._id, + }, + { + $set: { + password: passwordHash, + }, + $unset: { + resetToken: 1, + resetTokenDate: 1, + }, + } + ); + + return { + token: await this.authService.generateUserToken(user), + }; + } +} diff --git a/apps/api/src/app/auth/usecases/register/user-register.command.ts b/apps/api/src/app/auth/usecases/register/user-register.command.ts new file mode 100644 index 00000000000..7e6897a0e13 --- /dev/null +++ b/apps/api/src/app/auth/usecases/register/user-register.command.ts @@ -0,0 +1,26 @@ +import { IsDefined, IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class UserRegisterCommand { + static create(data: UserRegisterCommand) { + return CommandHelper.create(UserRegisterCommand, data); + } + + @IsDefined() + @IsNotEmpty() + @IsEmail() + email: string; + + @IsDefined() + @MinLength(8) + password: string; + + @IsDefined() + firstName: string; + + @IsDefined() + lastName: string; + + @IsOptional() + organizationName?: string; +} 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 new file mode 100644 index 00000000000..f640cc75b6f --- /dev/null +++ b/apps/api/src/app/auth/usecases/register/user-register.usecase.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; +import { MemberEntity, MemberRepository, OrganizationEntity, UserRepository } from '@notifire/dal'; +import * as bcrypt from 'bcrypt'; +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'; +import { CreateOrganization } from '../../../organization/usecases/create-organization/create-organization.usecase'; +import { CreateOrganizationCommand } from '../../../organization/usecases/create-organization/create-organization.command'; +import { CreateApplication } from '../../../applications/usecases/create-application/create-application.usecase'; +import { CreateApplicationCommand } from '../../../applications/usecases/create-application/create-application.command'; +import { AnalyticsService } from '../../../shared/services/analytics/analytics.service'; +// eslint-disable-next-line max-len + +@Injectable() +export class UserRegister { + constructor( + private authService: AuthService, + private userRepository: UserRepository, + private createOrganizationUsecase: CreateOrganization, + private createApplicationUsecase: CreateApplication, + private memberRepository: MemberRepository, + private analyticsService: AnalyticsService + ) {} + + async execute(command: UserRegisterCommand) { + const email = normalizeEmail(command.email); + const existingUser = await this.userRepository.findByEmail(email); + if (existingUser) throw new ApiException('User already exists'); + + const passwordHash = await bcrypt.hash(command.password, 10); + const user = await this.userRepository.create({ + email, + firstName: command.firstName.toLowerCase(), + lastName: command.lastName.toLowerCase(), + password: passwordHash, + }); + + let organization: OrganizationEntity; + let member: MemberEntity; + if (command.organizationName) { + organization = await this.createOrganizationUsecase.execute( + CreateOrganizationCommand.create({ + name: command.organizationName, + userId: user._id, + }) + ); + + this.analyticsService.upsertUser( + { + firstName: command.organizationName, + lastName: '', + email: user.email, + _id: user._id, + createdAt: user.createdAt, + } as never, + organization._id + ); + + await this.createApplicationUsecase.execute( + CreateApplicationCommand.create({ + userId: user._id, + name: `${organization.name} App`, + organizationId: organization._id, + }) + ); + } + + return { + user: await this.userRepository.findById(user._id), + token: await this.authService.generateUserToken(user), + }; + } +} diff --git a/apps/api/src/app/auth/usecases/switch-application/switch-application.command.ts b/apps/api/src/app/auth/usecases/switch-application/switch-application.command.ts new file mode 100644 index 00000000000..e5280483d0f --- /dev/null +++ b/apps/api/src/app/auth/usecases/switch-application/switch-application.command.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../shared/commands/organization.command'; + +export class SwitchApplicationCommand extends OrganizationCommand { + static create(data: SwitchApplicationCommand) { + return CommandHelper.create(SwitchApplicationCommand, data); + } + + @IsNotEmpty() + newApplicationId: string; +} diff --git a/apps/api/src/app/auth/usecases/switch-application/switch-application.e2e.ts b/apps/api/src/app/auth/usecases/switch-application/switch-application.e2e.ts new file mode 100644 index 00000000000..6e4131afcdd --- /dev/null +++ b/apps/api/src/app/auth/usecases/switch-application/switch-application.e2e.ts @@ -0,0 +1,35 @@ +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; +import { ApplicationEntity } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { IJwtPayload } from '@notifire/shared'; + +describe('Switch Application - /auth/applications/:id/switch (POST)', async () => { + let session: UserSession; + + describe('user has multiple applications', () => { + let secondApplication: ApplicationEntity; + let firstApplication: ApplicationEntity; + + before(async () => { + session = new UserSession(); + await session.initialize(); + firstApplication = session.application; + secondApplication = await session.createApplication(); + }); + + it('should switch to second application', async () => { + const content = jwt.decode(session.token.split(' ')[1]) as IJwtPayload; + expect(content.applicationId).to.equal(firstApplication._id); + + const { body } = await session.testAgent + .post(`/v1/auth/applications/${secondApplication._id}/switch`) + .expect(200); + + const newJwt = jwt.decode(body.data.token) as IJwtPayload; + expect(newJwt._id).to.equal(session.user._id); + expect(newJwt.organizationId).to.equal(session.organization._id); + expect(newJwt.applicationId).to.equal(secondApplication._id); + }); + }); +}); diff --git a/apps/api/src/app/auth/usecases/switch-application/switch-application.usecase.ts b/apps/api/src/app/auth/usecases/switch-application/switch-application.usecase.ts new file mode 100644 index 00000000000..fed02c1d2bc --- /dev/null +++ b/apps/api/src/app/auth/usecases/switch-application/switch-application.usecase.ts @@ -0,0 +1,28 @@ +import { forwardRef, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { ApplicationRepository, MemberRepository, OrganizationRepository, UserRepository } from '@notifire/dal'; +import { AuthService } from '../../services/auth.service'; +import { SwitchApplicationCommand } from './switch-application.command'; + +@Injectable() +export class SwitchApplication { + constructor( + private applicationRepository: ApplicationRepository, + private userRepository: UserRepository, + private memberRepository: MemberRepository, + @Inject(forwardRef(() => AuthService)) private authService: AuthService + ) {} + + async execute(command: SwitchApplicationCommand) { + const project = await this.applicationRepository.findById(command.newApplicationId); + if (!project) throw new NotFoundException('Application 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 user = await this.userRepository.findById(command.userId); + const token = await this.authService.getSignedToken(user, command.organizationId, member, command.newApplicationId); + + return token; + } +} diff --git a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts b/apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts new file mode 100644 index 00000000000..485430cf0e5 --- /dev/null +++ b/apps/api/src/app/auth/usecases/switch-organization/switch-organization.command.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class SwitchOrganizationCommand extends AuthenticatedCommand { + static create(data: SwitchOrganizationCommand) { + return CommandHelper.create(SwitchOrganizationCommand, data); + } + + @IsNotEmpty() + newOrganizationId: string; +} diff --git a/apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts b/apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts new file mode 100644 index 00000000000..6d2fc35d123 --- /dev/null +++ b/apps/api/src/app/auth/usecases/switch-organization/switch-organization.usecase.ts @@ -0,0 +1,30 @@ +import { forwardRef, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { MemberRepository, OrganizationRepository, UserRepository } from '@notifire/dal'; +import { SwitchOrganizationCommand } from './switch-organization.command'; +import { AuthService } from '../../services/auth.service'; + +@Injectable() +export class SwitchOrganization { + constructor( + private organizationRepository: OrganizationRepository, + private userRepository: UserRepository, + private memberRepository: MemberRepository, + @Inject(forwardRef(() => AuthService)) private authService: AuthService + ) {} + + async execute(command: SwitchOrganizationCommand): Promise { + const isAuthenticated = await this.authService.isAuthenticatedForOrganization( + command.userId, + command.newOrganizationId + ); + if (!isAuthenticated) { + throw new UnauthorizedException(`Not authorized for organization ${command.newOrganizationId}`); + } + + const member = await this.memberRepository.findMemberByUserId(command.newOrganizationId, command.userId); + const user = await this.userRepository.findById(command.userId); + const token = await this.authService.getSignedToken(user, command.newOrganizationId, member); + + return token; + } +} diff --git a/apps/api/src/app/channels/channels.controller.ts b/apps/api/src/app/channels/channels.controller.ts new file mode 100644 index 00000000000..26af5f2117c --- /dev/null +++ b/apps/api/src/app/channels/channels.controller.ts @@ -0,0 +1,49 @@ +import { Body, ClassSerializerInterceptor, Controller, Put, UseGuards, UseInterceptors } from '@nestjs/common'; +import { IJwtPayload, MemberRoleEnum } from '@notifire/shared'; +import { UserSession } from '../shared/framework/user.decorator'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { Roles } from '../auth/framework/roles.decorator'; +import { UpdateMailSettings } from './usecases/update-mail-settings/update-mail-settings.usecase'; +import { UpdateMailSettingsCommand } from './usecases/update-mail-settings/update-mail-settings.command'; +import { UpdateSmsSettings } from './usecases/update-sms-settings/update-sms-settings.usecase'; +import { UpdateSmsSettingsCommand } from './usecases/update-sms-settings/update-sms-settings.command'; + +@Controller('/channels') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class ChannelsController { + constructor( + private updateMailSettingsUsecase: UpdateMailSettings, + private updateSmsSettingsUsecase: UpdateSmsSettings + ) {} + + @Put('/email/settings') + @Roles(MemberRoleEnum.ADMIN) + updateMailSettings(@UserSession() user: IJwtPayload, @Body() body: { senderEmail: string; senderName: string }) { + return this.updateMailSettingsUsecase.execute( + UpdateMailSettingsCommand.create({ + userId: user._id, + applicationId: user.applicationId, + organizationId: user.organizationId, + senderEmail: body.senderEmail, + senderName: body.senderName, + }) + ); + } + + @Put('/sms/settings') + @Roles(MemberRoleEnum.ADMIN) + updateSmsSettings( + @UserSession() user: IJwtPayload, + @Body() body: { twillio: { authToken: string; accountSid: string; phoneNumber: string } } + ) { + return this.updateSmsSettingsUsecase.execute( + UpdateSmsSettingsCommand.create({ + userId: user._id, + applicationId: user.applicationId, + organizationId: user.organizationId, + twillio: body.twillio, + }) + ); + } +} diff --git a/apps/api/src/app/channels/channels.module.ts b/apps/api/src/app/channels/channels.module.ts new file mode 100644 index 00000000000..5f8c1e9c539 --- /dev/null +++ b/apps/api/src/app/channels/channels.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { ChannelsController } from './channels.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [ChannelsController], +}) +export class ChannelsModule {} diff --git a/apps/api/src/app/channels/e2e/update-email-settings.e2e.ts b/apps/api/src/app/channels/e2e/update-email-settings.e2e.ts new file mode 100644 index 00000000000..bb7d2d87c84 --- /dev/null +++ b/apps/api/src/app/channels/e2e/update-email-settings.e2e.ts @@ -0,0 +1,23 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Update Email Settings - /channels/email/settings (PUT)', function () { + let session: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should update the senderEmail', async function () { + const { + body: { data }, + } = await session.testAgent.put('/v1/channels/email/settings').send({ + senderEmail: 'new-test-email@ntest.co', + senderName: 'new test name', + }); + + expect(data.channels.email.senderEmail).to.equal('new-test-email@ntest.co'); + expect(data.channels.email.senderName).to.equal('new test name'); + }); +}); diff --git a/apps/api/src/app/channels/e2e/update-sms-settings.e2e.ts b/apps/api/src/app/channels/e2e/update-sms-settings.e2e.ts new file mode 100644 index 00000000000..89d7056e4ed --- /dev/null +++ b/apps/api/src/app/channels/e2e/update-sms-settings.e2e.ts @@ -0,0 +1,25 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Update SMS Settings - /channels/sms/settings (PUT)', function () { + let session: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should update the sms settings', async function () { + const { body } = await session.testAgent.put('/v1/channels/sms/settings').send({ + twillio: { + authToken: '5678', + accountSid: '12345', + phoneNumber: '+11111111', + }, + }); + const { data } = body; + expect(data.channels.sms.twillio.authToken).to.equal('5678'); + expect(data.channels.sms.twillio.accountSid).to.equal('12345'); + expect(data.channels.sms.twillio.phoneNumber).to.equal('+11111111'); + }); +}); diff --git a/apps/api/src/app/channels/usecases/index.ts b/apps/api/src/app/channels/usecases/index.ts new file mode 100644 index 00000000000..2d58c9a99d5 --- /dev/null +++ b/apps/api/src/app/channels/usecases/index.ts @@ -0,0 +1,8 @@ +import { UpdateSmsSettings } from './update-sms-settings/update-sms-settings.usecase'; +import { UpdateMailSettings } from './update-mail-settings/update-mail-settings.usecase'; + +export const USE_CASES = [ + UpdateSmsSettings, + UpdateMailSettings, + // +]; diff --git a/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.command.ts b/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.command.ts new file mode 100644 index 00000000000..c30bb468271 --- /dev/null +++ b/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.command.ts @@ -0,0 +1,16 @@ +import { IsDefined, IsEmail } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class UpdateMailSettingsCommand extends ApplicationWithUserCommand { + static create(data: UpdateMailSettingsCommand) { + return CommandHelper.create(UpdateMailSettingsCommand, data); + } + + @IsDefined() + @IsEmail() + senderEmail: string; + + @IsDefined() + senderName: string; +} diff --git a/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.usecase.ts b/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.usecase.ts new file mode 100644 index 00000000000..c3b29ea2a0e --- /dev/null +++ b/apps/api/src/app/channels/usecases/update-mail-settings/update-mail-settings.usecase.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationEntity, ApplicationRepository } from '@notifire/dal'; +import { UpdateMailSettingsCommand } from './update-mail-settings.command'; + +@Injectable() +export class UpdateMailSettings { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: UpdateMailSettingsCommand): Promise { + await this.applicationRepository.update( + { + _id: command.applicationId, + }, + { + $set: { + 'channels.email.senderEmail': command.senderEmail, + 'channels.email.senderName': command.senderName, + }, + } + ); + + return await this.applicationRepository.findById(command.applicationId); + } +} diff --git a/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.command.ts b/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.command.ts new file mode 100644 index 00000000000..29ec56330b7 --- /dev/null +++ b/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.command.ts @@ -0,0 +1,24 @@ +import { IsDefined, ValidateNested } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +class TwillioSettings { + @IsDefined() + authToken: string; + + @IsDefined() + accountSid: string; + + @IsDefined() + phoneNumber; +} + +export class UpdateSmsSettingsCommand extends ApplicationWithUserCommand { + static create(data: UpdateSmsSettingsCommand) { + return CommandHelper.create(UpdateSmsSettingsCommand, data); + } + + @IsDefined() + @ValidateNested() + twillio: TwillioSettings; +} diff --git a/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.usecase.ts b/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.usecase.ts new file mode 100644 index 00000000000..d8f4aa98748 --- /dev/null +++ b/apps/api/src/app/channels/usecases/update-sms-settings/update-sms-settings.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationEntity, ApplicationRepository } from '@notifire/dal'; +import { UpdateSmsSettingsCommand } from './update-sms-settings.command'; + +@Injectable() +export class UpdateSmsSettings { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: UpdateSmsSettingsCommand): Promise { + await this.applicationRepository.update( + { + _id: command.applicationId, + }, + { + $set: { + 'channels.sms.twillio.accountSid': command.twillio.accountSid, + 'channels.sms.twillio.authToken': command.twillio.authToken, + 'channels.sms.twillio.phoneNumber': command.twillio.phoneNumber, + }, + } + ); + + return await this.applicationRepository.findById(command.applicationId); + } +} diff --git a/apps/api/src/app/content-templates/content-templates.controller.ts b/apps/api/src/app/content-templates/content-templates.controller.ts new file mode 100644 index 00000000000..e8881266c01 --- /dev/null +++ b/apps/api/src/app/content-templates/content-templates.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('/content-templates') +export class ContentTemplatesController {} diff --git a/apps/api/src/app/content-templates/content-templates.module.ts b/apps/api/src/app/content-templates/content-templates.module.ts new file mode 100644 index 00000000000..550b2fdea32 --- /dev/null +++ b/apps/api/src/app/content-templates/content-templates.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { ContentTemplatesController } from './content-templates.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + exports: [...USE_CASES], + controllers: [ContentTemplatesController], +}) +export class ContentTemplatesModule {} diff --git a/apps/api/src/app/content-templates/usecases/compile-template/compile-template.command.ts b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.command.ts new file mode 100644 index 00000000000..9d7686842f8 --- /dev/null +++ b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.command.ts @@ -0,0 +1,17 @@ +import { IsDefined, IsObject, IsOptional } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class CompileTemplateCommand { + static create(data: CompileTemplateCommand) { + return CommandHelper.create(CompileTemplateCommand, data); + } + + @IsDefined() + templateId: 'basic' | 'custom'; + + @IsOptional() + customTemplate?: string; + + @IsObject() + data: any; +} diff --git a/apps/api/src/app/content-templates/usecases/compile-template/compile-template.spec.ts b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.spec.ts new file mode 100644 index 00000000000..6ff21e9a8ef --- /dev/null +++ b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.spec.ts @@ -0,0 +1,70 @@ +import { Test } from '@nestjs/testing'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; +import { SharedModule } from '../../../shared/shared.module'; +import { ContentTemplatesModule } from '../../content-templates.module'; +import { CompileTemplate } from './compile-template.usecase'; +import { CompileTemplateCommand } from './compile-template.command'; + +describe('Compile Template', function () { + let useCase: CompileTemplate; + let session: UserSession; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [SharedModule, ContentTemplatesModule], + providers: [], + }).compile(); + + session = new UserSession(); + await session.initialize(); + + useCase = moduleRef.get(CompileTemplate); + }); + + it('should render custom html', async function () { + const result = await useCase.execute( + CompileTemplateCommand.create({ + templateId: 'custom', + data: { + branding: { + color: '#e7e7e7e9', + }, + name: 'Test Name', + }, + customTemplate: '
{{name}}
', + }) + ); + + expect(result).to.equal('
Test Name
'); + }); + + it('should compile basic template successfully', async function () { + const result = await useCase.execute( + CompileTemplateCommand.create({ + templateId: 'basic', + data: { + branding: { + color: '#e7e7e7e9', + }, + blocks: [ + { + type: 'text', + content: 'Hello TESTTTT content ', + }, + { + type: 'button', + content: 'Button content of text', + }, + ], + }, + }) + ); + + expect(result).to.contain('Hello TESTTTT content'); + expect(result).to.not.contain('{{#each blocks}}'); + expect(result).to.not.contains('ff6f61'); + expect(result).to.contain('#e7e7e7e9'); + expect(result).to.contain('Button content of text'); + }); +}); diff --git a/apps/api/src/app/content-templates/usecases/compile-template/compile-template.usecase.ts b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.usecase.ts new file mode 100644 index 00000000000..cb6242b136f --- /dev/null +++ b/apps/api/src/app/content-templates/usecases/compile-template/compile-template.usecase.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import * as Handlebars from 'handlebars'; +import * as fs from 'fs'; +import { CompileTemplateCommand } from './compile-template.command'; + +Handlebars.registerHelper('equals', function (arg1, arg2, options) { + // eslint-disable-next-line eqeqeq + return arg1 == arg2 ? options.fn(this) : options.inverse(this); +}); + +const cache = new Map(); + +@Injectable() +export class CompileTemplate { + async execute(command: CompileTemplateCommand): Promise { + let templateContent = cache.get(command.templateId); + if (!templateContent) { + templateContent = await this.loadTemplateContent('basic.handlebars'); + cache.set(command.templateId, templateContent); + } + + if (command.templateId === 'custom') { + templateContent = command.customTemplate; + } + + const template = Handlebars.compile(templateContent); + return template(command.data); + } + + private async loadTemplateContent(name: string) { + return new Promise((resolve, reject) => { + fs.readFile(`${__dirname}/templates/${name}`, (err, content) => { + if (err) { + return reject(err); + } + + return resolve(content.toString()); + }); + }); + } +} diff --git a/apps/api/src/app/content-templates/usecases/compile-template/templates/basic.handlebars b/apps/api/src/app/content-templates/usecases/compile-template/templates/basic.handlebars new file mode 100644 index 00000000000..4a0789239a9 --- /dev/null +++ b/apps/api/src/app/content-templates/usecases/compile-template/templates/basic.handlebars @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + +
+ {{#each blocks}} +
+ {{#equals type 'text'}} +
+
+

+ {{{content}}} +

+
+
+ {{/equals}} + {{#equals type 'button'}} + + {{/equals}} +
+ {{/each}} +
+ + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
+ +
+
+
+
+
+ + diff --git a/apps/api/src/app/content-templates/usecases/index.ts b/apps/api/src/app/content-templates/usecases/index.ts new file mode 100644 index 00000000000..c86dead8e8a --- /dev/null +++ b/apps/api/src/app/content-templates/usecases/index.ts @@ -0,0 +1,6 @@ +import { CompileTemplate } from './compile-template/compile-template.usecase'; + +export const USE_CASES = [ + CompileTemplate, + // +]; diff --git a/apps/api/src/app/events/dto/trigger-event.dto.ts b/apps/api/src/app/events/dto/trigger-event.dto.ts new file mode 100644 index 00000000000..84ec767a22d --- /dev/null +++ b/apps/api/src/app/events/dto/trigger-event.dto.ts @@ -0,0 +1,10 @@ +import { IsDefined, IsObject, IsString } from 'class-validator'; + +export class TriggerEventDto { + @IsString() + @IsDefined() + name: string; + + @IsObject() + payload: any; +} diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts new file mode 100644 index 00000000000..3763cc0f9fc --- /dev/null +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -0,0 +1,373 @@ +import { + LogRepository, + MessageRepository, + NotificationRepository, + NotificationTemplateEntity, + SubscriberEntity, + SubscriberRepository, +} from '@notifire/dal'; +import { UserSession, SubscribersService } from '@notifire/testing'; + +import { expect } from 'chai'; +import { ChannelTypeEnum, IEmailBlock } from '@notifire/shared'; +import axios from 'axios'; +import { mock, stub } from 'sinon'; +import { SmsService } from '../../shared/services/sms/sms.service'; + +const axiosInstance = axios.create(); + +describe('Trigger event - /v1/events/trigger (POST)', function () { + let session: UserSession; + let template: NotificationTemplateEntity; + let subscriber: SubscriberEntity; + let subscriberService: SubscribersService; + const notificationRepository = new NotificationRepository(); + const messageRepository = new MessageRepository(); + const subscriberRepository = new SubscriberRepository(); + const logRepository = new LogRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + subscriberService = new SubscribersService(session.organization._id, session.application._id); + subscriber = await subscriberService.createSubscriber(); + }); + + it('should generate logs for the notification', async function () { + const response = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + firstName: 'Testing of User Name', + urlVariable: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + const logs = await logRepository.find({ + _applicationId: session.application._id, + _organizationId: session.organization._id, + }); + + expect(logs.length).to.be.gt(2); + }); + + it('should trigger an event successfully', async function () { + const response = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + firstName: 'Testing of User Name', + urlVariable: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const { data: body } = response; + expect(body.data).to.be.ok; + expect(body.data.status).to.equal('processed'); + expect(body.data.acknowledged).to.equal(true); + }); + + it('should create a subscriber based on event', async function () { + const payload = { + $user_id: 'new-test-if-id', + $first_name: 'Test Name', + $last_name: 'Last of name', + $email: 'test@email.notifire', + firstName: 'Testing of User Name', + urlVar: '/test/url/path', + }; + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const createdSubscriber = await subscriberRepository.findBySubscriberId(session.application._id, 'new-test-if-id'); + + expect(createdSubscriber.subscriberId).to.equal(payload.$user_id); + expect(createdSubscriber.firstName).to.equal(payload.$first_name); + expect(createdSubscriber.lastName).to.equal(payload.$last_name); + expect(createdSubscriber.email).to.equal(payload.$email); + }); + + it('should override subscriber email based on event data', async function () { + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + $email: 'new-test-email@gmail.com', + firstName: 'Testing of User Name', + urlVar: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const messages = await messageRepository.findBySubscriberChannel( + session.application._id, + subscriber._id, + ChannelTypeEnum.EMAIL + ); + expect(subscriber.email).to.not.equal('new-test-email@gmail.com'); + expect(messages[0].email).to.equal('new-test-email@gmail.com'); + }); + + it('should generate message and notification based on event', async function () { + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + firstName: 'Testing of User Name', + urlVar: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + const notifications = await notificationRepository.findBySubscriberId(session.application._id, subscriber._id); + expect(notifications.length).to.equal(1); + + const notification = notifications[0]; + expect(notification._organizationId).to.equal(session.organization._id); + expect(notification._templateId).to.equal(template._id); + + const messages = await messageRepository.findBySubscriberChannel( + session.application._id, + subscriber._id, + ChannelTypeEnum.IN_APP + ); + + expect(messages.length).to.equal(1); + const message = messages[0]; + + expect(message.channel).to.equal(ChannelTypeEnum.IN_APP); + expect(message.content as string).to.equal('Test content for Testing of User Name'); + expect(message.seen).to.equal(false); + expect(message.cta.data.url).to.equal('/cypress/test-shell/example/test?test-param=true'); + expect(message.lastSeenDate).to.be.not.ok; + + const emails = await messageRepository.findBySubscriberChannel( + session.application._id, + subscriber._id, + ChannelTypeEnum.EMAIL + ); + expect(emails.length).to.equal(1); + const email = emails[0]; + + expect(email.channel).to.equal(ChannelTypeEnum.EMAIL); + expect(Array.isArray(email.content)).to.be.ok; + expect((email.content[0] as IEmailBlock).type).to.equal('text'); + expect((email.content[0] as IEmailBlock).content).to.equal( + 'This are the text contents of the template for Testing of User Name' + ); + }); + + it('should trigger based on $channels in payload', async function () { + template = await session.createTemplate({ + messages: [ + { + type: ChannelTypeEnum.SMS, + content: 'Hello world {{firstName}}' as string, + }, + { + type: ChannelTypeEnum.IN_APP, + content: 'Hello world {{firstName}}' as string, + }, + ], + }); + + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + $phone: '+972547801111', + $channels: [ChannelTypeEnum.IN_APP], + firstName: 'Testing of User Name', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const message = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.SMS, + }); + + expect(message).to.not.be.ok; + + const inAppMessages = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.IN_APP, + }); + + expect(inAppMessages).to.be.ok; + }); + + it('should ignore all templates if $channels is empty', async function () { + template = await session.createTemplate({ + messages: [ + { + type: ChannelTypeEnum.SMS, + content: 'Hello world {{firstName}}' as string, + }, + { + type: ChannelTypeEnum.IN_APP, + content: 'Hello world {{firstName}}' as string, + }, + ], + }); + + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + $phone: '+972547801111', + $channels: [], + firstName: 'Testing of User Name', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const message = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.SMS, + }); + + expect(message).to.not.be.ok; + + const inAppMessages = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.IN_APP, + }); + + expect(inAppMessages).to.not.be.ok; + }); + + it('should trigger SMS notification', async function () { + template = await session.createTemplate({ + messages: [ + { + type: ChannelTypeEnum.SMS, + content: 'Hello world {{firstName}}' as string, + }, + ], + }); + + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + $phone: '+972547801111', + firstName: 'Testing of User Name', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + const message = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.SMS, + }); + + expect(message.phone).to.equal('+972547801111'); + }); + + it('should trigger an sms error', async function () { + template = await session.createTemplate({ + messages: [ + { + type: ChannelTypeEnum.SMS, + content: 'Hello world {{firstName}}' as string, + }, + ], + }); + const mocked = stub(SmsService.prototype, 'sendMessage').throws(new Error('Error from twillio')); + const { data: body } = await axiosInstance.post( + `${session.serverUrl}/v1/events/trigger`, + { + name: template.triggers[0].identifier, + payload: { + $user_id: subscriber.subscriberId, + $phone: '+972547802737', + firstName: 'Testing of User Name', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + const message = await messageRepository._model.findOne({ + _applicationId: session.application._id, + _templateId: template._id, + _subscriberId: subscriber._id, + }); + expect(message.status).to.equal('error'); + expect(message.errorText).to.equal('Error from twillio'); + }); +}); diff --git a/apps/api/src/app/events/events.controller.ts b/apps/api/src/app/events/events.controller.ts new file mode 100644 index 00000000000..ecb485808fb --- /dev/null +++ b/apps/api/src/app/events/events.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { IJwtPayload } from '@notifire/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { TriggerEvent, TriggerEventCommand } from './usecases/trigger-event'; +import { UserSession } from '../shared/framework/user.decorator'; +import { TriggerEventDto } from './dto/trigger-event.dto'; +import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; + +@Controller('events') +export class EventsController { + constructor(private triggerEvent: TriggerEvent) {} + + @ExternalApiAccessible() + @UseGuards(JwtAuthGuard) + @Post('/trigger') + trackEvent(@UserSession() user: IJwtPayload, @Body() body: TriggerEventDto) { + return this.triggerEvent.execute( + TriggerEventCommand.create({ + userId: user._id, + applicationId: user.applicationId, + organizationId: user.organizationId, + identifier: body.name, + payload: body.payload, + transactionId: uuidv4(), + }) + ); + } +} diff --git a/apps/api/src/app/events/events.module.ts b/apps/api/src/app/events/events.module.ts new file mode 100644 index 00000000000..90d2a49afdb --- /dev/null +++ b/apps/api/src/app/events/events.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { SharedModule } from '../shared/shared.module'; +import { EventsController } from './events.controller'; +import { USE_CASES } from './usecases'; +import { WidgetsModule } from '../widgets/widgets.module'; +import { AuthModule } from '../auth/auth.module'; +import { SubscribersModule } from '../subscribers/subscribers.module'; +import { LogsModule } from '../logs/logs.module'; +import { ContentTemplatesModule } from '../content-templates/content-templates.module'; + +@Module({ + imports: [ + SharedModule, + TerminusModule, + WidgetsModule, + AuthModule, + SubscribersModule, + LogsModule, + ContentTemplatesModule, + ], + controllers: [EventsController], + providers: [...USE_CASES], +}) +export class EventsModule {} diff --git a/apps/api/src/app/events/usecases/index.ts b/apps/api/src/app/events/usecases/index.ts new file mode 100644 index 00000000000..c5557b55bdc --- /dev/null +++ b/apps/api/src/app/events/usecases/index.ts @@ -0,0 +1,3 @@ +import { TriggerEvent } from './trigger-event'; + +export const USE_CASES = [TriggerEvent]; diff --git a/apps/api/src/app/events/usecases/trigger-event/index.ts b/apps/api/src/app/events/usecases/trigger-event/index.ts new file mode 100644 index 00000000000..469dcb99e99 --- /dev/null +++ b/apps/api/src/app/events/usecases/trigger-event/index.ts @@ -0,0 +1,2 @@ +export * from './trigger-event.command'; +export * from './trigger-event.usecase'; diff --git a/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.spec.ts b/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.spec.ts new file mode 100644 index 00000000000..c295050e5b4 --- /dev/null +++ b/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.spec.ts @@ -0,0 +1,239 @@ +import { BuilderFieldOperator, ChannelTypeEnum } from '@notifire/shared'; +import { expect } from 'chai'; +import { MessageEntity, MessageFilter, NotificationMessagesEntity } from '@notifire/dal'; +import { matchMessageWithFilters } from './message-filter.matcher'; + +describe('Message filter matcher', function () { + it('should filter correct message by the filter value', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'OR', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + ]), + messageWrapper('Bad Match', 'OR', [ + { + operator: 'EQUAL', + value: 'otherValue', + field: 'varField', + }, + ]), + ], + { + varField: 'firstVar', + } + ); + + expect(matchedMessage.length).to.equal(1); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + }); + + it('should filter correct message by the channel', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'OR', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + ]), + messageWrapper( + 'Bad Match', + 'OR', + [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + ], + ChannelTypeEnum.IN_APP + ), + ], + { + varField: 'firstVar', + } + ); + + expect(matchedMessage.length).to.equal(1); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + }); + + it('should handle multiple message matches', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'OR', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + ]), + messageWrapper('Correct Message', 'OR', [ + { + operator: 'EQUAL', + value: 'secondVar', + field: 'secondField', + }, + ]), + ], + { + varField: 'firstVar', + secondField: 'secondVar', + } + ); + + expect(matchedMessage.length).to.equal(2); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + }); + + it('should match a message for AND filter group', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'AND', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + { + operator: 'EQUAL', + value: 'secondVar', + field: 'secondField', + }, + ]), + ], + { + varField: 'firstVar', + secondField: 'secondVar', + } + ); + + expect(matchedMessage.length).to.equal(1); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + }); + + it('should not match AND group for single bad item', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Title', 'AND', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + { + operator: 'EQUAL', + value: 'secondVar', + field: 'secondField', + }, + ]), + ], + { + varField: 'firstVar', + secondField: 'secondVarBad', + } + ); + + expect(matchedMessage.length).to.equal(0); + }); + + it('should match a NOT_EQUAL for EQUAL var', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'AND', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + { + operator: 'NOT_EQUAL', + value: 'secondVar', + field: 'secondField', + }, + ]), + ], + { + varField: 'firstVar', + secondField: 'secondVarBad', + } + ); + + expect(matchedMessage.length).to.equal(1); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + }); + + it('should fall thru for no filters item', function () { + const matchedMessage = matchMessageWithFilters( + ChannelTypeEnum.EMAIL, + [ + messageWrapper('Correct Match', 'AND', [ + { + operator: 'EQUAL', + value: 'firstVar', + field: 'varField', + }, + { + operator: 'NOT_EQUAL', + value: 'secondVar', + field: 'secondField', + }, + ]), + messageWrapper('Correct Match 2', 'OR', []), + ], + { + varField: 'firstVar', + secondField: 'secondVarBad', + } + ); + + expect(matchedMessage.length).to.equal(2); + expect(matchedMessage[0].template.name).to.equal('Correct Match'); + expect(matchedMessage[1].template.name).to.equal('Correct Match 2'); + }); +}); + +function messageWrapper( + name: string, + groupOperator: 'AND' | 'OR', + filters: { + field: string; + value: string; + operator: BuilderFieldOperator; + }[], + channel = ChannelTypeEnum.EMAIL +): NotificationMessagesEntity { + return { + _templateId: '123', + template: { + subject: 'Test Subject', + type: channel, + name, + content: 'Test', + _organizationId: '123', + _applicationId: 'asdas', + _creatorId: '123', + }, + filters: filters?.length + ? [ + { + isNegated: false, + type: 'GROUP', + value: groupOperator, + children: filters, + }, + ] + : [], + }; +} diff --git a/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.ts b/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.ts new file mode 100644 index 00000000000..3f75754bfa4 --- /dev/null +++ b/apps/api/src/app/events/usecases/trigger-event/message-filter.matcher.ts @@ -0,0 +1,58 @@ +import { ChannelTypeEnum } from '@notifire/shared'; +import { NotificationMessagesEntity } from '@notifire/dal'; + +export function matchMessageWithFilters( + channel: ChannelTypeEnum, + messages: NotificationMessagesEntity[], + payloadVariables: { [key: string]: string | string[] | { [key: string]: string } } +): NotificationMessagesEntity[] { + return messages.filter((message) => { + const channelIsMatching = message.template.type === channel; + + if (message.filters?.length) { + const foundFilter = message.filters.find((filter) => { + if (filter.type === 'GROUP') { + return handleGroupFilters(filter, payloadVariables); + } + + return false; + }); + + return channelIsMatching && foundFilter; + } + + return channelIsMatching; + }); +} + +function handleGroupFilters(filter, payloadVariables) { + if (filter.value === 'OR') { + return handleOrFilters(filter, payloadVariables); + } + + if (filter.value === 'AND') { + return handleAndFilters(filter, payloadVariables); + } + + return false; +} + +function handleAndFilters(filter, payloadVariables) { + const foundFilterMatches = filter.children.filter((i) => processFilterEquality(i, payloadVariables)); + + return foundFilterMatches.length === filter.children.length; +} + +function handleOrFilters(filter, payloadVariables) { + return filter.children.find((i) => processFilterEquality(i, payloadVariables)); +} + +function processFilterEquality(i, payloadVariables) { + if (i.operator === 'EQUAL') { + return payloadVariables[i.field] === i.value; + } + if (i.operator === 'NOT_EQUAL') { + return payloadVariables[i.field] !== i.value; + } + return false; +} diff --git a/apps/api/src/app/events/usecases/trigger-event/trigger-event.command.ts b/apps/api/src/app/events/usecases/trigger-event/trigger-event.command.ts new file mode 100644 index 00000000000..13ab1bf15b6 --- /dev/null +++ b/apps/api/src/app/events/usecases/trigger-event/trigger-event.command.ts @@ -0,0 +1,20 @@ +import { IsDefined, IsString, IsUUID } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class TriggerEventCommand extends ApplicationWithUserCommand { + static create(data: TriggerEventCommand) { + return CommandHelper.create(TriggerEventCommand, data); + } + + @IsDefined() + @IsString() + identifier: string; + + @IsDefined() + payload: any; + + @IsUUID() + @IsDefined() + transactionId: string; +} diff --git a/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts b/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts new file mode 100644 index 00000000000..d10539f0ce0 --- /dev/null +++ b/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts @@ -0,0 +1,566 @@ +import { Injectable } from '@nestjs/common'; +import { + ApplicationEntity, + ApplicationRepository, + IEmailBlock, + MessageRepository, + NotificationEntity, + NotificationMessagesEntity, + NotificationRepository, + NotificationTemplateEntity, + NotificationTemplateRepository, + SubscriberEntity, + SubscriberRepository, +} from '@notifire/dal'; +import { ChannelTypeEnum, LogCodeEnum, LogStatusEnum } from '@notifire/shared'; +import * as Sentry from '@sentry/node'; +import { TriggerEventCommand } from './trigger-event.command'; +import { ContentService } from '../../../shared/helpers/content.service'; +import { CreateSubscriber, CreateSubscriberCommand } from '../../../subscribers/usecases/create-subscriber'; +import { matchMessageWithFilters } from './message-filter.matcher'; +import { CreateLog } from '../../../logs/usecases/create-log/create-log.usecase'; +import { CreateLogCommand } from '../../../logs/usecases/create-log/create-log.command'; +import { CompileTemplate } from '../../../content-templates/usecases/compile-template/compile-template.usecase'; +import { CompileTemplateCommand } from '../../../content-templates/usecases/compile-template/compile-template.command'; +import { ISendMail, MailService } from '../../../shared/services/mail/mail.service'; +import { QueueService } from '../../../shared/services/queue'; +import { AnalyticsService } from '../../../shared/services/analytics/analytics.service'; +import { SmsService } from '../../../shared/services/sms/sms.service'; + +@Injectable() +export class TriggerEvent { + constructor( + private notificationTemplateRepository: NotificationTemplateRepository, + private subscriberRepository: SubscriberRepository, + private notificationRepository: NotificationRepository, + private messageRepository: MessageRepository, + private mailService: MailService, + private queueService: QueueService, + private applicationRepository: ApplicationRepository, + private createSubscriberUsecase: CreateSubscriber, + private createLogUsecase: CreateLog, + private analyticsService: AnalyticsService, + private compileTemplate: CompileTemplate + ) {} + + async execute(command: TriggerEventCommand) { + Sentry.addBreadcrumb({ + message: 'Sending trigger', + data: { + triggerIdentifier: command.identifier, + }, + }); + + this.createLogUsecase + .execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.INFO, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'Trigger request received', + userId: command.userId, + code: LogCodeEnum.TRIGGER_RECEIVED, + raw: { + payload: command.payload, + }, + }) + ) + .catch((e) => console.error(e)); + + const template = await this.notificationTemplateRepository.findByTriggerIdentifier( + command.organizationId, + command.identifier + ); + + if (!template) { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'Template not found', + userId: command.userId, + code: LogCodeEnum.TEMPLATE_NOT_FOUND, + raw: { + triggerIdentifier: command.identifier, + }, + }) + ); + + return { + acknowledged: true, + status: 'template_not_found', + }; + } + + if (!template.active || template.draft) { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'Template not active', + userId: command.userId, + code: LogCodeEnum.TEMPLATE_NOT_ACTIVE, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + + return { + acknowledged: true, + status: 'trigger_not_active', + }; + } + + let subscriber = await this.subscriberRepository.findBySubscriberId( + command.applicationId, + command.payload.$user_id + ); + + if (!subscriber) { + if (command.payload.$email || command.payload.$phone) { + subscriber = await this.createSubscriberUsecase.execute( + CreateSubscriberCommand.create({ + applicationId: command.applicationId, + organizationId: command.organizationId, + subscriberId: command.payload.$user_id, + email: command.payload.$email, + firstName: command.payload.$first_name, + lastName: command.payload.$last_name, + phone: command.payload.$phone, + }) + ); + } else { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'Subscriber not found', + userId: command.userId, + code: LogCodeEnum.SUBSCRIBER_NOT_FOUND, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + + return { + acknowledged: true, + status: 'subscriber_not_found', + }; + } + } + + const notification = await this.notificationRepository.create({ + _applicationId: command.applicationId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + _templateId: template._id, + transactionId: command.transactionId, + }); + + const application = await this.applicationRepository.findById(command.applicationId); + const { smsMessages, inAppChannelMessages, emailChannelMessages } = this.extractMatchingMessages( + template, + command.payload + ); + + let channelsToSend: ChannelTypeEnum[] = []; + if (!command.payload.$channels || !Array.isArray(command.payload.$channels)) { + if (smsMessages?.length) { + channelsToSend.push(ChannelTypeEnum.SMS); + } + + if (inAppChannelMessages?.length) { + channelsToSend.push(ChannelTypeEnum.IN_APP); + } + + if (emailChannelMessages?.length) { + channelsToSend.push(ChannelTypeEnum.EMAIL); + } + } else { + channelsToSend = command.payload.$channels; + } + + if (smsMessages?.length && this.shouldSendChannel(channelsToSend, ChannelTypeEnum.SMS)) { + await this.sendSmsMessage(smsMessages, command, notification, subscriber, template, application); + } + + if (inAppChannelMessages?.length && this.shouldSendChannel(channelsToSend, ChannelTypeEnum.IN_APP)) { + await this.sendInAppMessage(inAppChannelMessages, command, notification, subscriber, template); + } + + if (emailChannelMessages.length && this.shouldSendChannel(channelsToSend, ChannelTypeEnum.EMAIL)) { + await this.sendEmailMessage(emailChannelMessages, command, notification, subscriber, template, application); + } + + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.INFO, + applicationId: command.applicationId, + organizationId: command.organizationId, + notificationId: notification._id, + text: 'Request processed', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.TRIGGER_PROCESSED, + templateId: template._id, + }) + ); + + this.analyticsService.track('Notification event trigger - [Triggers]', command.userId, { + smsChannel: !!smsMessages?.length, + emailChannel: !!emailChannelMessages?.length, + inAppChannel: !!inAppChannelMessages?.length, + }); + + return { + acknowledged: true, + status: 'processed', + }; + } + + private shouldSendChannel(channels: ChannelTypeEnum[], channel: ChannelTypeEnum) { + return channels.includes(channel); + } + + private extractMatchingMessages(template: NotificationTemplateEntity, payload) { + const smsMessages = matchMessageWithFilters(ChannelTypeEnum.SMS, template.messages, payload); + const inAppChannelMessages = matchMessageWithFilters(ChannelTypeEnum.IN_APP, template.messages, payload); + const emailChannelMessages = matchMessageWithFilters(ChannelTypeEnum.EMAIL, template.messages, payload); + + return { smsMessages, inAppChannelMessages, emailChannelMessages }; + } + + private async sendSmsMessage( + smsMessages: NotificationMessagesEntity[], + command: TriggerEventCommand, + notification: NotificationEntity, + subscriber: SubscriberEntity, + template: NotificationTemplateEntity, + application: ApplicationEntity + ) { + Sentry.addBreadcrumb({ + message: 'Sending SMS', + }); + const smsChannel = smsMessages[0]; + const contentService = new ContentService(); + const content = contentService.replaceVariables(smsChannel.template.content as string, command.payload); + + const message = await this.messageRepository.create({ + _notificationId: notification._id, + _applicationId: command.applicationId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + _templateId: template._id, + _messageTemplateId: smsChannel.template._id, + channel: ChannelTypeEnum.SMS, + transactionId: command.transactionId, + phone: command.payload.$phone, + content, + }); + + if ( + command.payload.$phone && + application.channels?.sms?.twillio?.authToken && + application.channels?.sms?.twillio?.accountSid + ) { + try { + const smsService = new SmsService( + application.channels?.sms?.twillio?.authToken, + application.channels?.sms?.twillio?.accountSid + ); + const smsResponse = await smsService.sendMessage( + command.payload.$phone, + application.channels?.sms?.twillio?.phoneNumber, + content + ); + } catch (e) { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: e.message || e.name || 'Un-expect SMS provider error', + userId: command.userId, + code: LogCodeEnum.SMS_ERROR, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + + await this.messageRepository.updateMessageStatus( + message._id, + 'error', + e, + 'unexpected_sms_error', + e.message || e.name || 'Un-expect SMS provider error' + ); + } + } else if (!command.payload.$phone) { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'Subscriber does not have active phone', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.SUBSCRIBER_MISSING_PHONE, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + await this.messageRepository.updateMessageStatus( + message._id, + 'warning', + null, + 'no_subscriber_phone', + 'Subscriber does not have active phone' + ); + } else if (!application.channels?.sms?.twillio?.authToken) { + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + text: 'No sms provider was configured', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.MISSING_SMS_PROVIDER, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + await this.messageRepository.updateMessageStatus( + message._id, + 'warning', + null, + 'no_sms_provider_connection', + 'No SMS provider token found' + ); + } + } + + private async sendInAppMessage( + inAppChannelMessages: NotificationMessagesEntity[], + command: TriggerEventCommand, + notification: NotificationEntity, + subscriber: SubscriberEntity, + template: NotificationTemplateEntity + ) { + Sentry.addBreadcrumb({ + message: 'Sending In App', + }); + const inAppChannel = inAppChannelMessages[0]; + + const contentService = new ContentService(); + + const content = contentService.replaceVariables(inAppChannel.template.content as string, command.payload); + if (inAppChannel.template.cta?.data?.url) { + inAppChannel.template.cta.data.url = contentService.replaceVariables( + inAppChannel.template.cta?.data?.url, + command.payload + ); + } + + const message = await this.messageRepository.create({ + _notificationId: notification._id, + _applicationId: command.applicationId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + _templateId: template._id, + _messageTemplateId: inAppChannel.template._id, + channel: ChannelTypeEnum.IN_APP, + cta: inAppChannel.template.cta, + transactionId: command.transactionId, + content, + }); + + const count = await this.messageRepository.getUnseenCount( + command.applicationId, + subscriber._id, + ChannelTypeEnum.IN_APP + ); + + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.SUCCESS, + applicationId: command.applicationId, + organizationId: command.organizationId, + notificationId: notification._id, + messageId: message._id, + text: 'In App message created', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.IN_APP_MESSAGE_CREATED, + templateId: template._id, + raw: { + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + + this.queueService.wsSocketQueue.add({ + event: 'unseen_count_changed', + userId: subscriber._id, + payload: { + unseenCount: count, + }, + }); + } + + private async sendEmailMessage( + emailChannelMessages: NotificationMessagesEntity[], + command: TriggerEventCommand, + notification: NotificationEntity, + subscriber: SubscriberEntity, + template: NotificationTemplateEntity, + application: ApplicationEntity + ) { + const email = command.payload.$email || subscriber.email; + Sentry.addBreadcrumb({ + message: 'Sending Email', + }); + const emailChannel = emailChannelMessages[0]; + const isEditorMode = !emailChannel.template.contentType || emailChannel.template.contentType === 'editor'; + + let content: string | IEmailBlock[] = ''; + + if (isEditorMode) { + content = [...emailChannel.template.content] as IEmailBlock[]; + for (const block of content) { + const contentService = new ContentService(); + block.content = contentService.replaceVariables(block.content, command.payload); + block.url = contentService.replaceVariables(block.url, command.payload); + } + } else { + content = emailChannel.template.content; + } + + const message = await this.messageRepository.create({ + _notificationId: notification._id, + _applicationId: command.applicationId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + _templateId: template._id, + _messageTemplateId: emailChannel.template._id, + content, + channel: ChannelTypeEnum.EMAIL, + transactionId: command.transactionId, + email, + }); + + const contentService = new ContentService(); + const subject = contentService.replaceVariables(emailChannel.template.subject, command.payload); + + const html = await this.compileTemplate.execute( + CompileTemplateCommand.create({ + templateId: isEditorMode ? 'basic' : 'custom', + customTemplate: emailChannel.template.contentType === 'customHtml' ? (content as string) : undefined, + data: { + subject, + branding: { + logo: application.branding?.logo, + color: application.branding?.color || '#f47373', + }, + blocks: isEditorMode ? content : [], + ...command.payload, + }, + }) + ); + + const mailData: ISendMail = { + from: { + name: command.payload.$sender_name || application.channels?.email?.senderName || application.name, + email: command.payload.$sender_email || application.channels?.email?.senderEmail || 'no-reply@notifire.co', + }, + html, + subject, + to: email, + }; + + if (email) { + this.mailService.sendMail(mailData).catch((error) => { + Sentry.captureException(error?.response?.body || error?.response || error); + this.messageRepository.updateMessageStatus( + message._id, + 'error', + error?.response?.body || error?.response || error, + 'mail_unexpected_error', + 'Error while sending email with provider' + ); + + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + notificationId: notification._id, + messageId: message._id, + text: 'Error while sending email with provider', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.MAIL_PROVIDER_DELIVERY_ERROR, + templateId: template._id, + raw: { + error: error?.response?.body || error?.response || error, + payload: command.payload, + triggerIdentifier: command.identifier, + }, + }) + ); + }); + } else { + await this.messageRepository.updateMessageStatus( + message._id, + 'warning', + null, + 'mail_unexpected_error', + 'Subscriber does not have an email address' + ); + + this.createLogUsecase.execute( + CreateLogCommand.create({ + transactionId: command.transactionId, + status: LogStatusEnum.ERROR, + applicationId: command.applicationId, + organizationId: command.organizationId, + notificationId: notification._id, + text: 'Subscriber does not have an email address', + userId: command.userId, + subscriberId: subscriber._id, + code: LogCodeEnum.SUBSCRIBER_MISSING_EMAIL, + templateId: template._id, + }) + ); + } + } +} diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts new file mode 100644 index 00000000000..969210d6d15 --- /dev/null +++ b/apps/api/src/app/health/health.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get } from '@nestjs/common'; +import { HealthCheck, HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus'; +import { DalService } from '@notifire/dal'; +import { version } from '../../../package.json'; + +@Controller('health-check') +export class HealthController { + constructor( + private healthCheckService: HealthCheckService, + private healthIndicator: HttpHealthIndicator, + private dalService: DalService + ) {} + + @Get() + @HealthCheck() + healthCheck() { + return this.healthCheckService.check([ + async () => { + return { + db: { + status: this.dalService.connection.readyState === 1 ? 'up' : 'down', + }, + }; + }, + async () => this.healthIndicator.pingCheck('dns', 'https://google.com'), + async () => { + return { + apiVersion: { + version, + status: 'up', + }, + }; + }, + ]); + } +} diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts new file mode 100644 index 00000000000..b9d9898ee43 --- /dev/null +++ b/apps/api/src/app/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { SharedModule } from '../shared/shared.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [SharedModule, TerminusModule], + controllers: [HealthController], + providers: [], +}) +export class HealthModule {} diff --git a/apps/api/src/app/invites/e2e/accept-invite.e2e.ts b/apps/api/src/app/invites/e2e/accept-invite.e2e.ts new file mode 100644 index 00000000000..966a08c2f02 --- /dev/null +++ b/apps/api/src/app/invites/e2e/accept-invite.e2e.ts @@ -0,0 +1,75 @@ +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; + +describe('Accept invite - /invites/:inviteToken/accept (POST)', async () => { + let session: UserSession; + let invitedUserSession: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + + async function setup() { + session = new UserSession(); + invitedUserSession = new UserSession(); + await invitedUserSession.initialize({ + noOrganization: true, + noApplication: true, + }); + + await session.initialize(); + + await session.testAgent.post('/v1/invites/bulk').send({ + invitees: [ + { + email: 'asdas@dasdas.com', + }, + ], + }); + } + + describe('Valid invite accept flow', async () => { + let response; + before(async () => { + await setup(); + + const organization = await organizationRepository.findById(session.organization._id); + const members = await memberRepository.getOrganizationMembers(session.organization._id); + const invitee = members.find((i) => !i._userId); + + const { body } = await invitedUserSession.testAgent + .post(`/v1/invites/${invitee.invite.token}/accept`) + .expect(201); + + response = body.data; + }); + + it('should change the member status to active', async () => { + const member = await memberRepository.findMemberByUserId(session.organization._id, invitedUserSession.user._id); + + expect(member._userId).to.equal(invitedUserSession.user._id); + expect(member.memberStatus).to.equal(MemberStatusEnum.ACTIVE); + }); + }); + + describe('Invalid accept requests handling', async () => { + before(async () => { + await setup(); + }); + + it('should reject expired token', async () => { + const organization = await organizationRepository.findById(session.organization._id); + const members = await memberRepository.getOrganizationMembers(session.organization._id); + const invitee = members.find((i) => !i._userId); + expect(invitee.memberStatus).to.eq(MemberStatusEnum.INVITED); + + await invitedUserSession.testAgent.post(`/v1/invites/${invitee.invite.token}/accept`).expect(201); + + const { body } = await invitedUserSession.testAgent + .post(`/v1/invites/${invitee.invite.token}/accept`) + .expect(400); + + expect(body.message).to.contain('expired'); + }); + }); +}); diff --git a/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts b/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts new file mode 100644 index 00000000000..a8914359a3f --- /dev/null +++ b/apps/api/src/app/invites/e2e/bulk-invite.e2e.ts @@ -0,0 +1,143 @@ +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { IBulkInviteResponse, MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; + +describe('Bulk invite members - /invites/bulk (POST)', async () => { + let session: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should fail without passing invitees', async () => { + const { body } = await session.testAgent + .post('/v1/invites/bulk') + .send({ + invitees: [], + }) + .expect(400); + }); + + it('should fail with bad emails', async () => { + const { body } = await session.testAgent + .post('/v1/invites/bulk') + .send({ + invitees: [ + { + email: 'asdasda', + role: 'admin', + }, + ], + }) + .expect(400); + }); + + it('should invite member as admin', async () => { + session = new UserSession(); + await session.initialize(); + + const { body } = await session.testAgent + .post('/v1/invites/bulk') + .send({ + invitees: [ + { + email: 'dddd@asdas.com', + role: 'admin', + }, + ], + }) + .expect(201); + + const members = await memberRepository.getOrganizationMembers(session.organization._id); + expect(members.length).to.eq(2); + + const member = members.find((i) => !i._userId); + expect(member.invite.email).to.equal('dddd@asdas.com'); + expect(member.invite._inviterId).to.equal(session.user._id); + expect(member.roles.length).to.equal(1); + expect(member.roles[0]).to.equal(MemberRoleEnum.ADMIN); + expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED); + }); + + it('should invite member as member', async () => { + session = new UserSession(); + await session.initialize(); + + const { body } = await session.testAgent + .post('/v1/invites/bulk') + .send({ + invitees: [ + { + email: 'aaaaa2@asdas.com', + role: 'member', + }, + ], + }) + .expect(201); + + const members = await memberRepository.getOrganizationMembers(session.organization._id); + expect(members.length).to.eq(2); + + const member = members.find((i) => !i._userId); + expect(member.roles[0]).to.equal(MemberRoleEnum.MEMBER); + expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED); + }); + + describe('send valid invites', () => { + let inviteResponse: IBulkInviteResponse[]; + + const invitee = { + email: 'asdasda@asdas.com', + role: 'member', + }; + + before(async () => { + session = new UserSession(); + await session.initialize(); + + const { body } = await session.testAgent + .post('/v1/invites/bulk') + .send({ + invitees: [invitee], + }) + .expect(201); + + inviteResponse = body.data; + }); + + it('should return a matching response', async () => { + expect(inviteResponse.length).to.equal(1); + expect(inviteResponse[0].success).to.equal(true); + expect(inviteResponse[0].email).to.equal(invitee.email); + }); + + it('should create invited member entity', async () => { + const members = await memberRepository.getOrganizationMembers(session.organization._id); + + expect(members.length).to.eq(2); + + const member = members.find((i) => !i._userId); + expect(member.invite.email).to.equal(invitee.email); + expect(member.invite._inviterId).to.equal(session.user._id); + expect(member.roles.length).to.equal(1); + expect(member.roles[0]).to.equal(MemberRoleEnum.MEMBER); + + expect(member.memberStatus).to.equal(MemberStatusEnum.INVITED); + expect(member._userId).to.be.not.ok; + }); + + it('should fail invite already invited person', async () => { + const { body } = await session.testAgent.post('/v1/invites/bulk').send({ + invitees: [invitee], + }); + + expect(body.data.length).to.equal(1); + expect(body.data[0].failReason).to.include('Already invited'); + expect(body.data[0].success).to.equal(false); + }); + }); +}); diff --git a/apps/api/src/app/invites/e2e/get-invite.e2e.ts b/apps/api/src/app/invites/e2e/get-invite.e2e.ts new file mode 100644 index 00000000000..29faf075e82 --- /dev/null +++ b/apps/api/src/app/invites/e2e/get-invite.e2e.ts @@ -0,0 +1,72 @@ +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import { MemberStatusEnum } from '@notifire/shared'; +import { expect } from 'chai'; + +describe('Get invite object - /invites/:inviteToken (GET)', async () => { + let session: UserSession; + const organizationRepository = new OrganizationRepository(); + const memberRepository = new MemberRepository(); + + describe('valid token returned', async () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + + await session.testAgent.post('/v1/invites/bulk').send({ + invitees: [ + { + email: 'asdas@dasdas.com', + }, + ], + }); + }); + + it('should return a valid invite object', async () => { + const members = await memberRepository.getOrganizationMembers(session.organization._id); + const member = members.find((i) => i.memberStatus === MemberStatusEnum.INVITED); + + const { body } = await session.testAgent.get(`/v1/invites/${member.invite.token}`); + + const response = body.data; + + expect(response.inviter._id).to.equal(session.user._id); + expect(response.organization._id).to.equal(session.organization._id); + }); + }); + + describe('error state validation', async () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + + await session.testAgent.post('/v1/invites/bulk').send({ + invitees: [ + { + email: 'asdas@dasdas.com', + }, + ], + }); + }); + + it('should return an error for expired token', async () => { + const organization = await organizationRepository.findById(session.organization._id); + const members = await memberRepository.getOrganizationMembers(session.organization._id); + const member = members.find((i) => i.memberStatus === MemberStatusEnum.INVITED); + + await memberRepository.update( + { + _id: member._id, + 'invite.token': member.invite.token, + }, + { + memberStatus: MemberStatusEnum.ACTIVE, + } + ); + + const { body } = await session.testAgent.get(`/v1/invites/${member.invite.token}`).expect(400); + + expect(body.message).to.contain('expired'); + }); + }); +}); diff --git a/apps/api/src/app/invites/invites.controller.ts b/apps/api/src/app/invites/invites.controller.ts new file mode 100644 index 00000000000..006d4fab112 --- /dev/null +++ b/apps/api/src/app/invites/invites.controller.ts @@ -0,0 +1,94 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Get, + Param, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { IBulkInviteResponse, IGetInviteResponseDto, IJwtPayload, MemberRoleEnum } from '@notifire/shared'; +import { UserSession } from '../shared/framework/user.decorator'; +import { GetInviteCommand } from './usecases/get-invite/get-invite.command'; +import { AcceptInviteCommand } from './usecases/accept-invite/accept-invite.command'; +import { Roles } from '../auth/framework/roles.decorator'; +import { InviteMemberDto } from '../organization/dtos/invite-member.dto'; +import { InviteMemberCommand } from './usecases/invite-member/invite-member.command'; +import { BulkInviteMembersDto } from '../organization/dtos/bulk-invite-members.dto'; +import { BulkInviteCommand } from './usecases/bulk-invite/bulk-invite.command'; +import { InviteMember } from './usecases/invite-member/invite-member.usecase'; +import { BulkInvite } from './usecases/bulk-invite/bulk-invite.usecase'; +import { AcceptInvite } from './usecases/accept-invite/accept-invite.usecase'; +import { GetInvite } from './usecases/get-invite/get-invite.usecase'; + +@UseInterceptors(ClassSerializerInterceptor) +@Controller('/invites') +export class InvitesController { + constructor( + private inviteMemberUsecase: InviteMember, + private bulkInviteUsecase: BulkInvite, + private acceptInviteUsecase: AcceptInvite, + private getInvite: GetInvite + ) {} + + @Get('/:inviteToken') + async getInviteData(@Param('inviteToken') inviteToken: string): Promise { + const command = GetInviteCommand.create({ + token: inviteToken, + }); + + return await this.getInvite.execute(command); + } + + @Post('/:inviteToken/accept') + @UseGuards(AuthGuard('jwt')) + async acceptInviteToken( + @UserSession() user: IJwtPayload, + @Param('inviteToken') inviteToken: string + ): Promise { + const command = AcceptInviteCommand.create({ + token: inviteToken, + userId: user._id, + }); + + return await this.acceptInviteUsecase.execute(command); + } + + @Post('/') + @Roles(MemberRoleEnum.ADMIN) + @UseGuards(AuthGuard('jwt')) + async inviteMember(@UserSession() user: IJwtPayload, @Body() body: InviteMemberDto): Promise<{ success: boolean }> { + const command = InviteMemberCommand.create({ + userId: user._id, + organizationId: user.organizationId, + email: body.email, + role: body.role, + }); + + await this.inviteMemberUsecase.execute(command); + + return { + success: true, + }; + } + + @Post('/bulk') + @UseGuards(AuthGuard('jwt')) + @Roles(MemberRoleEnum.ADMIN) + async bulkInviteMembers( + @UserSession() user: IJwtPayload, + @Body() body: BulkInviteMembersDto + ): Promise { + const command = BulkInviteCommand.create({ + userId: user._id, + organizationId: user.organizationId, + invitees: body.invitees, + }); + + const response = await this.bulkInviteUsecase.execute(command); + + return response; + } +} diff --git a/apps/api/src/app/invites/invites.module.ts b/apps/api/src/app/invites/invites.module.ts new file mode 100644 index 00000000000..f6968f0a263 --- /dev/null +++ b/apps/api/src/app/invites/invites.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { InvitesController } from './invites.controller'; +import { USE_CASES } from './usecases'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [SharedModule, AuthModule], + controllers: [InvitesController], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class InvitesModule {} diff --git a/apps/api/src/app/invites/usecases/accept-invite/accept-invite.command.ts b/apps/api/src/app/invites/usecases/accept-invite/accept-invite.command.ts new file mode 100644 index 00000000000..f9c9105e879 --- /dev/null +++ b/apps/api/src/app/invites/usecases/accept-invite/accept-invite.command.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class AcceptInviteCommand extends AuthenticatedCommand { + static create(data: AcceptInviteCommand) { + return CommandHelper.create(AcceptInviteCommand, data); + } + + @IsString() + readonly token: string; +} 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 new file mode 100644 index 00000000000..ace13761293 --- /dev/null +++ b/apps/api/src/app/invites/usecases/accept-invite/accept-invite.usecase.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger, Scope } from '@nestjs/common'; +import { MemberEntity, OrganizationRepository, UserEntity, MemberRepository, UserRepository } from '@notifire/dal'; +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { Notifire } from '@notifire/node'; +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({ + scope: Scope.REQUEST, +}) +export class AcceptInvite { + private organizationId: string; + + constructor( + private organizationRepository: OrganizationRepository, + private memberRepository: MemberRepository, + private userRepository: UserRepository, + private authService: AuthService + ) {} + + async execute(command: AcceptInviteCommand): Promise { + const member = await this.memberRepository.findByInviteToken(command.token); + if (!member) throw new ApiException('No organization found'); + const organization = await this.organizationRepository.findById(member._organizationId); + const user = await this.userRepository.findById(command.userId); + + this.organizationId = organization._id; + + if (member.memberStatus !== MemberStatusEnum.INVITED) throw new ApiException('Token expired'); + + const inviter = await this.userRepository.findById(member.invite._inviterId); + + await this.memberRepository.convertInvitedUserToMember(command.token, { + memberStatus: MemberStatusEnum.ACTIVE, + _userId: command.userId, + answerDate: new Date(), + }); + + this.sendInviterAcceptedEmail(inviter, member); + + return this.authService.generateUserToken(user); + } + + async sendInviterAcceptedEmail(inviter: UserEntity, member: MemberEntity) { + try { + if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'prod') { + const notifire = new Notifire(process.env.NOTIFIRE_API_KEY); + await notifire.trigger('invite-accepted-r5Q7-sQE-', { + $user_id: inviter._id, + $email: inviter.email, + firstName: capitalize(inviter.firstName), + invitedUserEmail: member.invite.email, + ctaUrl: '/settings/organization', + }); + } + } catch (e) { + Logger.error(e.message, e.stack, 'Accept inviter send email'); + } + } +} diff --git a/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.command.ts b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.command.ts new file mode 100644 index 00000000000..9fd513f4b04 --- /dev/null +++ b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.command.ts @@ -0,0 +1,14 @@ +import { MemberRoleEnum } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../shared/commands/organization.command'; + +export class BulkInviteCommand extends OrganizationCommand { + static create(data: BulkInviteCommand) { + return CommandHelper.create(BulkInviteCommand, data); + } + + invitees: { + email: string; + role?: MemberRoleEnum; + }[]; +} diff --git a/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts new file mode 100644 index 00000000000..aceafba8dd1 --- /dev/null +++ b/apps/api/src/app/invites/usecases/bulk-invite/bulk-invite.usecase.ts @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node'; +import { Injectable, Logger, Scope } from '@nestjs/common'; +import { MemberRoleEnum } from '@notifire/shared'; +import { InviteMemberCommand } from '../invite-member/invite-member.command'; +import { InviteMember } from '../invite-member/invite-member.usecase'; +import { BulkInviteCommand } from './bulk-invite.command'; + +interface IBulkInviteResponse { + success: boolean; + email: string; + failReason?: string; +} + +@Injectable({ + scope: Scope.REQUEST, +}) +export class BulkInvite { + constructor(private inviteMemberUsecase: InviteMember) {} + + async execute(command: BulkInviteCommand): Promise { + const invites: IBulkInviteResponse[] = []; + + for (const invitee of command.invitees) { + try { + await this.inviteMemberUsecase.execute( + InviteMemberCommand.create({ + email: invitee.email, + role: invitee.role || MemberRoleEnum.MEMBER, + organizationId: command.organizationId, + userId: command.userId, + }) + ); + + invites.push({ + success: true, + email: invitee.email, + }); + } catch (e) { + if (e.message.includes('Already invited')) { + invites.push({ + failReason: 'Already invited', + success: false, + email: invitee.email, + }); + } else { + Logger.error(e); + Sentry.captureException(e); + invites.push({ + failReason: null, + success: false, + email: invitee.email, + }); + } + } + } + + return invites; + } +} diff --git a/apps/api/src/app/invites/usecases/get-invite/get-invite.command.ts b/apps/api/src/app/invites/usecases/get-invite/get-invite.command.ts new file mode 100644 index 00000000000..705bd788d6c --- /dev/null +++ b/apps/api/src/app/invites/usecases/get-invite/get-invite.command.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class GetInviteCommand { + static create(data: GetInviteCommand) { + return CommandHelper.create(GetInviteCommand, data); + } + + @IsNotEmpty() + readonly token: string; +} diff --git a/apps/api/src/app/invites/usecases/get-invite/get-invite.usecase.ts b/apps/api/src/app/invites/usecases/get-invite/get-invite.usecase.ts new file mode 100644 index 00000000000..2bb626e7432 --- /dev/null +++ b/apps/api/src/app/invites/usecases/get-invite/get-invite.usecase.ts @@ -0,0 +1,44 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { OrganizationRepository, UserRepository, MemberRepository } from '@notifire/dal'; +import { MemberStatusEnum } from '@notifire/shared'; +import { ApiException } from '../../../shared/exceptions/api.exception'; +import { GetInviteCommand } from './get-invite.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class GetInvite { + constructor( + private organizationRepository: OrganizationRepository, + private memberRepository: MemberRepository, + private userRepository: UserRepository + ) {} + + async execute(command: GetInviteCommand) { + const member = await this.memberRepository.findByInviteToken(command.token); + if (!member) throw new ApiException('No invite found'); + const organization = await this.organizationRepository.findById(member._organizationId); + const invitedMember = member; + + if (invitedMember.memberStatus !== MemberStatusEnum.INVITED) { + throw new ApiException('Invite token expired'); + } + + const user = await this.userRepository.findById(invitedMember.invite._inviterId); + + return { + inviter: { + _id: user._id, + firstName: user.firstName, + lastName: user.lastName, + profilePicture: user.profilePicture, + }, + organization: { + _id: organization._id, + name: organization.name, + logo: organization.logo, + }, + email: member.invite.email, + }; + } +} diff --git a/apps/api/src/app/invites/usecases/index.ts b/apps/api/src/app/invites/usecases/index.ts new file mode 100644 index 00000000000..ca12832c09f --- /dev/null +++ b/apps/api/src/app/invites/usecases/index.ts @@ -0,0 +1,6 @@ +import { AcceptInvite } from './accept-invite/accept-invite.usecase'; +import { GetInvite } from './get-invite/get-invite.usecase'; +import { BulkInvite } from './bulk-invite/bulk-invite.usecase'; +import { InviteMember } from './invite-member/invite-member.usecase'; + +export const USE_CASES = [AcceptInvite, GetInvite, BulkInvite, InviteMember]; diff --git a/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts b/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts new file mode 100644 index 00000000000..350f41bbcfe --- /dev/null +++ b/apps/api/src/app/invites/usecases/invite-member/invite-member.command.ts @@ -0,0 +1,17 @@ +import { IsDefined, IsEmail, IsString, IsEnum } from 'class-validator'; +import { MemberRoleEnum } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../shared/commands/organization.command'; + +export class InviteMemberCommand extends OrganizationCommand { + static create(data: InviteMemberCommand) { + return CommandHelper.create(InviteMemberCommand, data); + } + + @IsEmail() + readonly email: string; + + @IsDefined() + @IsEnum(MemberRoleEnum) + readonly role: MemberRoleEnum; +} diff --git a/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts new file mode 100644 index 00000000000..bd94ec8b55c --- /dev/null +++ b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts @@ -0,0 +1,56 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { OrganizationRepository, UserRepository, MemberRepository } from '@notifire/dal'; +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; +import { Notifire } from '@notifire/node'; +import { ApiException } from '../../../shared/exceptions/api.exception'; +import { InviteMemberCommand } from './invite-member.command'; +import { MailService } from '../../../shared/services/mail/mail.service'; +import { capitalize, createGuid } from '../../../shared/services/helper/helper.service'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class InviteMember { + constructor( + private organizationRepository: OrganizationRepository, + private mailService: MailService, + private userRepository: UserRepository, + private memberRepository: MemberRepository + ) {} + + async execute(command: InviteMemberCommand) { + const organization = await this.organizationRepository.findById(command.organizationId); + if (!organization) throw new ApiException('No organization found'); + + const foundInvitee = await this.memberRepository.findInviteeByEmail(organization._id, command.email); + + if (foundInvitee) throw new ApiException('Already invited'); + + const inviterUser = await this.userRepository.findById(command.userId); + + const token = createGuid(); + + if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'prod') { + const notifire = new Notifire(process.env.NOTIFIRE_API_KEY); + await notifire.trigger('invite-to-organization-qUE8d-GRq', { + $user_id: command.email, + $email: command.email, + inviteeName: capitalize(command.email.split('@')[0]), + organizationName: capitalize(organization.name), + inviterName: capitalize(inviterUser.firstName), + acceptInviteUrl: `${process.env.FRONT_BASE_URL}/auth/invitation/${token}`, + }); + } + + await this.memberRepository.addMember(organization._id, { + roles: [command.role], + memberStatus: MemberStatusEnum.INVITED, + invite: { + token, + _inviterId: command.userId, + email: command.email, + invitationDate: new Date(), + }, + }); + } +} diff --git a/apps/api/src/app/logs/logs.controller.ts b/apps/api/src/app/logs/logs.controller.ts new file mode 100644 index 00000000000..52ebf0f1c44 --- /dev/null +++ b/apps/api/src/app/logs/logs.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('/logs') +export class LogsController {} diff --git a/apps/api/src/app/logs/logs.module.ts b/apps/api/src/app/logs/logs.module.ts new file mode 100644 index 00000000000..e3cac9adc07 --- /dev/null +++ b/apps/api/src/app/logs/logs.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { LogsController } from './logs.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + exports: [...USE_CASES], + controllers: [LogsController], +}) +export class LogsModule {} diff --git a/apps/api/src/app/logs/usecases/create-log/create-log.command.ts b/apps/api/src/app/logs/usecases/create-log/create-log.command.ts new file mode 100644 index 00000000000..4a202bed2b6 --- /dev/null +++ b/apps/api/src/app/logs/usecases/create-log/create-log.command.ts @@ -0,0 +1,44 @@ +import { IsDefined, IsEnum, IsMongoId, IsOptional, IsString, IsUUID } from 'class-validator'; +import { LogCodeEnum, LogStatusEnum } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class CreateLogCommand extends ApplicationWithUserCommand { + static create(data: CreateLogCommand) { + return CommandHelper.create(CreateLogCommand, data); + } + + @IsDefined() + @IsUUID() + transactionId: string; + + @IsOptional() + @IsMongoId() + notificationId?: string; + + @IsOptional() + @IsMongoId() + messageId?: string; + + @IsOptional() + @IsMongoId() + templateId?: string; + + @IsOptional() + @IsMongoId() + subscriberId?: string; + + @IsOptional() + @IsEnum(LogStatusEnum) + status: LogStatusEnum; + + @IsString() + text: string; + + @IsOptional() + @IsEnum(LogCodeEnum) + code?: LogCodeEnum; + + @IsOptional() + raw?: any; +} diff --git a/apps/api/src/app/logs/usecases/create-log/create-log.usecase.ts b/apps/api/src/app/logs/usecases/create-log/create-log.usecase.ts new file mode 100644 index 00000000000..f7bd9d036b2 --- /dev/null +++ b/apps/api/src/app/logs/usecases/create-log/create-log.usecase.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { LogEntity, LogRepository } from '@notifire/dal'; +import { CreateLogCommand } from './create-log.command'; + +@Injectable() +export class CreateLog { + constructor(private logRepository: LogRepository) {} + + async execute(command: CreateLogCommand): Promise { + let rawData: string = null; + if (command.raw) { + try { + rawData = JSON.stringify(command.raw); + // eslint-disable-next-line no-empty + } catch (e) {} + } + + // + return await this.logRepository.create({ + _applicationId: command.applicationId, + transactionId: command.transactionId, + _organizationId: command.organizationId, + _notificationId: command.notificationId, + _messageId: command.messageId, + _subscriberId: command.subscriberId, + status: command.status, + text: command.text, + code: command.code, + raw: rawData, + }); + } +} diff --git a/apps/api/src/app/logs/usecases/index.ts b/apps/api/src/app/logs/usecases/index.ts new file mode 100644 index 00000000000..c07812e566b --- /dev/null +++ b/apps/api/src/app/logs/usecases/index.ts @@ -0,0 +1,6 @@ +import { CreateLog } from './create-log/create-log.usecase'; + +export const USE_CASES = [ + CreateLog, + // +]; diff --git a/apps/api/src/app/message-template/message-template.controller.ts b/apps/api/src/app/message-template/message-template.controller.ts new file mode 100644 index 00000000000..f8f2091fd88 --- /dev/null +++ b/apps/api/src/app/message-template/message-template.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('/message-templates') +export class MessageTemplateController {} diff --git a/apps/api/src/app/message-template/message-template.module.ts b/apps/api/src/app/message-template/message-template.module.ts new file mode 100644 index 00000000000..f04c75a30e8 --- /dev/null +++ b/apps/api/src/app/message-template/message-template.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { MessageTemplateController } from './message-template.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + exports: [...USE_CASES], + controllers: [MessageTemplateController], +}) +export class MessageTemplateModule {} diff --git a/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts b/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts new file mode 100644 index 00000000000..947cddf6021 --- /dev/null +++ b/apps/api/src/app/message-template/shared/sanitizer.service.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai'; +import { sanitizeHTML, sanitizeMessageContent } from './sanitizer.service'; + +describe('HTML Sanitizer', function () { + it('should sanitize bad html', function () { + const sanitizedHtml = sanitizeHTML('hello bold '); + expect(sanitizedHtml).to.equal('hello bold '); + }); + + it('should sanitized message text content', function () { + const result = sanitizeMessageContent('hello bold '); + expect(result).to.equal('hello bold '); + }); + + it('should sanitized message email block content', function () { + const result = sanitizeMessageContent([ + { + subject: 'subject', + type: 'text', + content: 'hello bold ', + url: '', + }, + ]); + expect(result[0].content).to.equal('hello bold '); + expect(result[0].subject).to.equal('subject'); + }); +}); diff --git a/apps/api/src/app/message-template/shared/sanitizer.service.ts b/apps/api/src/app/message-template/shared/sanitizer.service.ts new file mode 100644 index 00000000000..341ff082eab --- /dev/null +++ b/apps/api/src/app/message-template/shared/sanitizer.service.ts @@ -0,0 +1,26 @@ +import * as sanitize from 'sanitize-html'; +import { IEmailBlock } from '@notifire/shared'; + +export function sanitizeHTML(html: string) { + if (!html) return html; + + return sanitize(html); +} + +export function sanitizeMessageContent(content: string | IEmailBlock[]) { + if (typeof content === 'string') { + return sanitizeHTML(content); + } + + if (Array.isArray(content)) { + return content.map((i) => { + return { + ...i, + subject: sanitizeHTML(i.subject), + content: sanitizeHTML(i.content), + }; + }); + } + + return content; +} diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts new file mode 100644 index 00000000000..1110f5b3106 --- /dev/null +++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts @@ -0,0 +1,31 @@ +import { IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { ChannelTypeEnum, IEmailBlock } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; +import { ChannelCTADto } from '../../../notification-template/dto/create-notification-template.dto'; + +export class CreateMessageTemplateCommand extends ApplicationWithUserCommand { + static create(data: CreateMessageTemplateCommand) { + return CommandHelper.create(CreateMessageTemplateCommand, data); + } + + @IsDefined() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsOptional() + name?: string; + + @IsOptional() + subject?: string; + + @IsDefined() + content: string | IEmailBlock[]; + + @IsOptional() + contentType: 'editor' | 'customHtml'; + + @IsOptional() + @ValidateNested() + cta: ChannelCTADto; +} diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts new file mode 100644 index 00000000000..0ca30f55f28 --- /dev/null +++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { MessageTemplateEntity, MessageTemplateRepository } from '@notifire/dal'; +import { CreateMessageTemplateCommand } from './create-message-template.command'; +import { sanitizeMessageContent } from '../../shared/sanitizer.service'; + +@Injectable() +export class CreateMessageTemplate { + constructor(private messageTemplateRepository: MessageTemplateRepository) {} + + async execute(command: CreateMessageTemplateCommand): Promise { + return await this.messageTemplateRepository.create({ + cta: command.cta, + name: command.name, + content: command.contentType === 'editor' ? sanitizeMessageContent(command.content) : command.content, + contentType: command.contentType, + subject: command.subject, + type: command.type, + _organizationId: command.organizationId, + _applicationId: command.applicationId, + _creatorId: command.userId, + }); + } +} diff --git a/apps/api/src/app/message-template/usecases/index.ts b/apps/api/src/app/message-template/usecases/index.ts new file mode 100644 index 00000000000..d3ffab0c2cd --- /dev/null +++ b/apps/api/src/app/message-template/usecases/index.ts @@ -0,0 +1,8 @@ +import { UpdateMessageTemplate } from './update-message-template/update-message-template.usecase'; +import { CreateMessageTemplate } from './create-message-template/create-message-template.usecase'; + +export const USE_CASES = [ + UpdateMessageTemplate, + CreateMessageTemplate, + // +]; diff --git a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts new file mode 100644 index 00000000000..494699d1b7e --- /dev/null +++ b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts @@ -0,0 +1,35 @@ +import { IsDefined, IsEnum, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; +import { ChannelTypeEnum, IEmailBlock } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; +import { ChannelCTADto } from '../../../notification-template/dto/create-notification-template.dto'; + +export class UpdateMessageTemplateCommand extends ApplicationWithUserCommand { + static create(data: UpdateMessageTemplateCommand) { + return CommandHelper.create(UpdateMessageTemplateCommand, data); + } + + @IsDefined() + @IsMongoId() + templateId: string; + + @IsOptional() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsOptional() + name?: string; + + @IsOptional() + subject?: string; + + @IsOptional() + content: string | IEmailBlock[]; + + @IsOptional() + contentType: 'editor' | 'customHtml'; + + @IsOptional() + @ValidateNested() + cta: ChannelCTADto; +} diff --git a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts new file mode 100644 index 00000000000..c102aa5a7d3 --- /dev/null +++ b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { MessageTemplateEntity, MessageTemplateRepository } from '@notifire/dal'; +import { UpdateMessageTemplateCommand } from './update-message-template.command'; +import { sanitizeMessageContent } from '../../shared/sanitizer.service'; + +@Injectable() +export class UpdateMessageTemplate { + constructor(private messageTemplateRepository: MessageTemplateRepository) {} + + async execute(command: UpdateMessageTemplateCommand): Promise { + const existingTemplate = await this.messageTemplateRepository.findById(command.templateId); + if (!existingTemplate) throw new NotFoundException(`Entity with id ${command.templateId} not found`); + + const updatePayload: Partial = {}; + if (command.name) { + updatePayload.name = command.name; + } + + if (command.content) { + updatePayload.content = + command.contentType === 'editor' ? sanitizeMessageContent(command.content) : command.content; + } + + if (command.cta) { + updatePayload.cta = command.cta; + } + + if (command.subject) { + updatePayload.subject = command.subject; + } + + if (!Object.keys(updatePayload).length) { + throw new BadRequestException('No properties found for update'); + } + + await this.messageTemplateRepository.update( + { + _id: command.templateId, + _organizationId: command.organizationId, + }, + { + $set: updatePayload, + } + ); + + return await this.messageTemplateRepository.findById(command.templateId); + } +} diff --git a/apps/api/src/app/notification-groups/dto/create-notification-group.dto.ts b/apps/api/src/app/notification-groups/dto/create-notification-group.dto.ts new file mode 100644 index 00000000000..92ff457ea72 --- /dev/null +++ b/apps/api/src/app/notification-groups/dto/create-notification-group.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsDefined, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; + +export class CreateNotificationGroupDto { + @IsString() + @IsDefined() + name: string; +} diff --git a/apps/api/src/app/notification-groups/e2e/create-notification-group.e2e.ts b/apps/api/src/app/notification-groups/e2e/create-notification-group.e2e.ts new file mode 100644 index 00000000000..e6be30b6472 --- /dev/null +++ b/apps/api/src/app/notification-groups/e2e/create-notification-group.e2e.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import { UserSession } from '@notifire/testing'; + +describe('Create Notification Group - /notification-groups (POST)', async () => { + let session: UserSession; + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should create notification group', async function () { + const testTemplate = { + name: 'Test name', + }; + + const { body } = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate); + expect(body.data).to.be.ok; + const group = body.data; + expect(group.name).to.equal(`Test name`); + expect(group._applicationId).to.equal(session.application._id); + }); +}); diff --git a/apps/api/src/app/notification-groups/e2e/get-notification-groups.e2e.ts b/apps/api/src/app/notification-groups/e2e/get-notification-groups.e2e.ts new file mode 100644 index 00000000000..e80ebe371b1 --- /dev/null +++ b/apps/api/src/app/notification-groups/e2e/get-notification-groups.e2e.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai'; +import { UserSession } from '@notifire/testing'; + +describe('Get Notification Groups - /notification-groups (GET)', async () => { + let session: UserSession; + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should get all notification groups', async function () { + await session.testAgent.post(`/v1/notification-groups`).send({ + name: 'Test name', + }); + await session.testAgent.post(`/v1/notification-groups`).send({ + name: 'Test name 2', + }); + + const { body } = await session.testAgent.get(`/v1/notification-groups`); + expect(body.data.length).to.equal(3); + const group = body.data.find((i) => i.name === 'Test name'); + expect(group.name).to.equal(`Test name`); + expect(group._applicationId).to.equal(session.application._id); + }); + + it('should create a default group when fetching', async function () { + const { body } = await session.testAgent.get(`/v1/notification-groups`); + expect(body.data.length).to.equal(1); + + const group = body.data[0]; + expect(group.name).to.equal(`General`); + }); +}); diff --git a/apps/api/src/app/notification-groups/notification-groups.controller.ts b/apps/api/src/app/notification-groups/notification-groups.controller.ts new file mode 100644 index 00000000000..686c34d86f6 --- /dev/null +++ b/apps/api/src/app/notification-groups/notification-groups.controller.ts @@ -0,0 +1,45 @@ +import { Body, ClassSerializerInterceptor, Controller, Get, Post, UseGuards, UseInterceptors } from '@nestjs/common'; +import { IJwtPayload, MemberRoleEnum } from '@notifire/shared'; +import { CreateNotificationGroup } from './usecases/create-notification-group/create-notification-group.usecase'; +import { Roles } from '../auth/framework/roles.decorator'; +import { UserSession } from '../shared/framework/user.decorator'; +import { CreateNotificationGroupCommand } from './usecases/create-notification-group/create-notification-group.command'; +import { CreateNotificationGroupDto } from './dto/create-notification-group.dto'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { GetNotificationGroups } from './usecases/get-notification-groups/get-notification-groups.usecase'; +import { GetNotificationGroupsCommand } from './usecases/get-notification-groups/get-notification-groups.command'; + +@Controller('/notification-groups') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class NotificationGroupsController { + constructor( + private createNotificationGroupUsecase: CreateNotificationGroup, + private getNotificationGroupsUsecase: GetNotificationGroups + ) {} + + @Post('') + @Roles(MemberRoleEnum.ADMIN) + createNotificationGroup(@UserSession() user: IJwtPayload, @Body() body: CreateNotificationGroupDto) { + return this.createNotificationGroupUsecase.execute( + CreateNotificationGroupCommand.create({ + organizationId: user.organizationId, + userId: user._id, + applicationId: user.applicationId, + name: body.name, + }) + ); + } + + @Get('') + @Roles(MemberRoleEnum.ADMIN) + getNotificationGroups(@UserSession() user: IJwtPayload) { + return this.getNotificationGroupsUsecase.execute( + GetNotificationGroupsCommand.create({ + organizationId: user.organizationId, + userId: user._id, + applicationId: user.applicationId, + }) + ); + } +} diff --git a/apps/api/src/app/notification-groups/notification-groups.module.ts b/apps/api/src/app/notification-groups/notification-groups.module.ts new file mode 100644 index 00000000000..fee1513dcff --- /dev/null +++ b/apps/api/src/app/notification-groups/notification-groups.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { NotificationGroupsController } from './notification-groups.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [NotificationGroupsController], +}) +export class NotificationGroupsModule {} diff --git a/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.command.ts b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.command.ts new file mode 100644 index 00000000000..0be9870a4a7 --- /dev/null +++ b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.command.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class CreateNotificationGroupCommand extends ApplicationWithUserCommand { + static create(data: CreateNotificationGroupCommand) { + return CommandHelper.create(CreateNotificationGroupCommand, data); + } + + @IsString() + name: string; +} diff --git a/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts new file mode 100644 index 00000000000..ed95c714e06 --- /dev/null +++ b/apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationGroupRepository, NotificationGroupEntity } from '@notifire/dal'; +import { CreateNotificationGroupCommand } from './create-notification-group.command'; + +@Injectable() +export class CreateNotificationGroup { + constructor(private notificationGroupRepository: NotificationGroupRepository) {} + + async execute(command: CreateNotificationGroupCommand): Promise { + return await this.notificationGroupRepository.create({ + _applicationId: command.applicationId, + _organizationId: command.organizationId, + name: command.name, + }); + } +} diff --git a/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.command.ts b/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.command.ts new file mode 100644 index 00000000000..ec7afbc4519 --- /dev/null +++ b/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetNotificationGroupsCommand extends ApplicationWithUserCommand { + static create(data: GetNotificationGroupsCommand) { + return CommandHelper.create(GetNotificationGroupsCommand, data); + } +} diff --git a/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.usecase.ts b/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.usecase.ts new file mode 100644 index 00000000000..4c38fe8b763 --- /dev/null +++ b/apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.usecase.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationGroupRepository, NotificationGroupEntity } from '@notifire/dal'; +import { GetNotificationGroupsCommand } from './get-notification-groups.command'; +import { CreateNotificationGroup } from '../create-notification-group/create-notification-group.usecase'; +import { CreateNotificationGroupCommand } from '../create-notification-group/create-notification-group.command'; + +@Injectable() +export class GetNotificationGroups { + constructor( + private notificationGroupRepository: NotificationGroupRepository, + private createNotificationGroup: CreateNotificationGroup + ) {} + + async execute(command: GetNotificationGroupsCommand): Promise { + const groups = await this.notificationGroupRepository.find({ + _applicationId: command.applicationId, + }); + + if (!groups.length) { + await this.createNotificationGroup.execute( + CreateNotificationGroupCommand.create({ + organizationId: command.organizationId, + applicationId: command.applicationId, + userId: command.userId, + name: 'General', + }) + ); + } + + return await this.notificationGroupRepository.find({ + _applicationId: command.applicationId, + }); + } +} diff --git a/apps/api/src/app/notification-groups/usecases/index.ts b/apps/api/src/app/notification-groups/usecases/index.ts new file mode 100644 index 00000000000..a9e60b2dd3f --- /dev/null +++ b/apps/api/src/app/notification-groups/usecases/index.ts @@ -0,0 +1,8 @@ +import { GetNotificationGroups } from './get-notification-groups/get-notification-groups.usecase'; +import { CreateNotificationGroup } from './create-notification-group/create-notification-group.usecase'; + +export const USE_CASES = [ + GetNotificationGroups, + CreateNotificationGroup, + // +]; diff --git a/apps/api/src/app/notification-template/dto/change-template-status.dto.ts b/apps/api/src/app/notification-template/dto/change-template-status.dto.ts new file mode 100644 index 00000000000..388108c1a22 --- /dev/null +++ b/apps/api/src/app/notification-template/dto/change-template-status.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsDefined } from 'class-validator'; + +export class ChangeTemplateStatusDto { + @IsDefined() + @IsBoolean() + active: boolean; +} diff --git a/apps/api/src/app/notification-template/dto/create-notification-template.dto.ts b/apps/api/src/app/notification-template/dto/create-notification-template.dto.ts new file mode 100644 index 00000000000..bfd72b62aba --- /dev/null +++ b/apps/api/src/app/notification-template/dto/create-notification-template.dto.ts @@ -0,0 +1,89 @@ +import { IsArray, IsDefined, IsEnum, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; +import { + BuilderFieldOperator, + BuilderFieldType, + BuilderGroupValues, + ChannelCTATypeEnum, + ChannelTypeEnum, + ICreateNotificationTemplateDto, + IEmailBlock, +} from '@notifire/shared'; + +export class ChannelCTADto { + @IsEnum(ChannelCTATypeEnum) + type: ChannelCTATypeEnum; + + data: { + url: string; + }; +} + +export class NotificationChannelDto { + @IsDefined() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsOptional() + @IsString() + subject?: string; + + @IsOptional() + name?: string; + + @IsDefined() + content: string | IEmailBlock[]; + + @IsOptional() + contentType?: 'editor' | 'customHtml'; + + @ValidateNested() + cta?: ChannelCTADto; + + @IsArray() + @ValidateNested() + @IsOptional() + filters?: MessageFilter[]; +} + +export class MessageFilter { + isNegated: boolean; + + @IsString() + type: BuilderFieldType; + + @IsString() + value: BuilderGroupValues; + + @IsArray() + children: { + field: string; + value: string; + operator: BuilderFieldOperator; + }[]; +} + +export class CreateNotificationTemplateDto implements ICreateNotificationTemplateDto { + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined({ + message: 'Notification group must be provided', + }) + notificationGroupId: string; + + @IsOptional() + @IsArray() + tags: string[]; + + @IsString() + @IsOptional() + @MaxLength(100) + description: string; + + @IsDefined() + @IsArray() + @ValidateNested() + messages: NotificationChannelDto[]; +} diff --git a/apps/api/src/app/notification-template/dto/update-notification-template.dto.ts b/apps/api/src/app/notification-template/dto/update-notification-template.dto.ts new file mode 100644 index 00000000000..96b9e8a1b9a --- /dev/null +++ b/apps/api/src/app/notification-template/dto/update-notification-template.dto.ts @@ -0,0 +1,58 @@ +import { + IsArray, + IsDefined, + IsEnum, + IsMongoId, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ChannelCTATypeEnum, ChannelTypeEnum, ICreateNotificationTemplateDto } from '@notifire/shared'; + +export class ChannelCTADto { + @IsEnum(ChannelCTATypeEnum) + type: ChannelCTATypeEnum; + + data: { + url: string; + }; +} + +export class NotificationChannelDto { + @IsDefined() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsString() + @IsDefined() + content: string; + + @IsDefined() + @ValidateNested() + cta: ChannelCTADto; +} + +export class UpdateNotificationTemplateDto implements ICreateNotificationTemplateDto { + @IsString() + @IsOptional() + name: string; + + @IsArray() + @IsOptional() + tags: string[]; + + @IsString() + @IsOptional() + @MaxLength(100) + description: string; + + @IsArray() + @IsOptional() + @ValidateNested() + messages: NotificationChannelDto[]; + + @IsOptional() + @IsMongoId() + notificationGroupId: string; +} diff --git a/apps/api/src/app/notification-template/e2e/change-template-status.e2e.ts b/apps/api/src/app/notification-template/e2e/change-template-status.e2e.ts new file mode 100644 index 00000000000..2e5336d20a9 --- /dev/null +++ b/apps/api/src/app/notification-template/e2e/change-template-status.e2e.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai'; +import { NotificationTemplateRepository } from '@notifire/dal'; +import { UserSession, NotificationTemplateService } from '@notifire/testing'; + +describe('Change template status by id - /notification-templates/:templateId/status (PUT)', async () => { + let session: UserSession; + const notificationTemplateRepository = new NotificationTemplateRepository(); + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should change the status from active false to active true', async function () { + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + const template = await notificationTemplateService.createTemplate({ + active: false, + draft: true, + }); + const beforeChange = await notificationTemplateRepository.findById(template._id, template._organizationId); + expect(beforeChange.active).to.equal(false); + expect(beforeChange.draft).to.equal(true); + const { body } = await session.testAgent.put(`/v1/notification-templates/${template._id}/status`).send({ + active: true, + }); + const found = await notificationTemplateRepository.findById(template._id, template._organizationId); + expect(found.active).to.equal(true); + expect(found.draft).to.equal(false); + }); +}); diff --git a/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts b/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts new file mode 100644 index 00000000000..c7843d93818 --- /dev/null +++ b/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts @@ -0,0 +1,137 @@ +import { expect } from 'chai'; +import { UserSession } from '@notifire/testing'; +import { ChannelCTATypeEnum, ChannelTypeEnum, INotificationTemplate, TriggerTypeEnum } from '@notifire/shared'; +import * as moment from 'moment'; +import { CreateNotificationTemplateDto } from '../dto/create-notification-template.dto'; + +describe('Create Notification template - /notification-templates (POST)', async () => { + let session: UserSession; + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should create email template', async function () { + const testTemplate: Partial = { + name: 'test email template', + description: 'This is a test description', + tags: ['test-tag'], + notificationGroupId: session.notificationGroups[0]._id, + messages: [ + { + name: 'Message Name', + subject: 'Test email subject', + type: ChannelTypeEnum.EMAIL, + filters: [ + { + isNegated: false, + type: 'GROUP', + value: 'AND', + children: [ + { + field: 'firstName', + value: 'test value', + operator: 'EQUAL', + }, + ], + }, + ], + content: [ + { + type: 'text', + content: 'This is a sample text block', + }, + ], + }, + ], + }; + + const { body } = await session.testAgent.post(`/v1/notification-templates`).send(testTemplate); + expect(body.data).to.be.ok; + const template: INotificationTemplate = body.data; + + expect(template._notificationGroupId).to.equal(testTemplate.notificationGroupId); + const message = template.messages[0]; + expect(message.template.name).to.equal(`${testTemplate.messages[0].name}`); + expect(message.template.subject).to.equal(`${testTemplate.messages[0].subject}`); + expect(message.filters[0].type).to.equal(testTemplate.messages[0].filters[0].type); + expect(message.filters[0].children.length).to.equal(testTemplate.messages[0].filters[0].children.length); + + expect(message.filters[0].children[0].value).to.equal(testTemplate.messages[0].filters[0].children[0].value); + + expect(message.filters[0].children[0].operator).to.equal(testTemplate.messages[0].filters[0].children[0].operator); + + expect(message.template.type).to.equal(ChannelTypeEnum.EMAIL); + expect(template.tags[0]).to.equal('test-tag'); + if (Array.isArray(message.template.content) && Array.isArray(testTemplate.messages[0].content)) { + expect(message.template.content[0].type).to.equal(testTemplate.messages[0].content[0].type); + } else { + throw new Error('content must be an array'); + } + }); + + it('should create a valid notification', async () => { + const testTemplate: Partial = { + name: 'test template', + description: 'This is a test description', + notificationGroupId: session.notificationGroups[0]._id, + messages: [ + { + type: ChannelTypeEnum.IN_APP, + content: 'Test Template', + cta: { + type: ChannelCTATypeEnum.REDIRECT, + data: { + url: 'https://example.org/profile', + }, + }, + }, + ], + }; + const { body } = await session.testAgent.post(`/v1/notification-templates`).send(testTemplate); + + expect(body.data).to.be.ok; + + const template: INotificationTemplate = body.data; + expect(template._id).to.be.ok; + expect(template.description).to.equal(testTemplate.description); + expect(template.name).to.equal(testTemplate.name); + expect(template.draft).to.equal(true); + expect(template.active).to.equal(false); + expect(moment(template.createdAt).isSame(moment(), 'day')); + + expect(template.messages.length).to.equal(1); + expect(template.messages[0].template.type).to.equal(ChannelTypeEnum.IN_APP); + expect(template.messages[0].template.content).to.equal(testTemplate.messages[0].content); + expect(template.messages[0].template.cta.data.url).to.equal(testTemplate.messages[0].cta.data.url); + }); + + it('should create event trigger', async () => { + const testTemplate: Partial = { + name: 'test template', + notificationGroupId: session.notificationGroups[0]._id, + description: 'This is a test description', + messages: [ + { + type: ChannelTypeEnum.IN_APP, + content: 'Test Template {{name}} {{lastName}}', + cta: { + type: ChannelCTATypeEnum.REDIRECT, + data: { + url: 'https://example.org/profile', + }, + }, + }, + ], + }; + + const { body } = await session.testAgent.post(`/v1/notification-templates`).send(testTemplate); + + expect(body.data).to.be.ok; + + const template: INotificationTemplate = body.data; + expect(template.triggers.length).to.equal(1); + expect(template.triggers[0].identifier).to.include('test'); + expect(template.triggers[0].type).to.equal(TriggerTypeEnum.EVENT); + }); +}); diff --git a/apps/api/src/app/notification-template/e2e/get-notification-template.e2e.ts b/apps/api/src/app/notification-template/e2e/get-notification-template.e2e.ts new file mode 100644 index 00000000000..53088a69455 --- /dev/null +++ b/apps/api/src/app/notification-template/e2e/get-notification-template.e2e.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { NotificationTemplateService, UserSession } from '@notifire/testing'; +import { INotificationTemplate } from '@notifire/shared'; + +describe('Get notification template by id - /notification-templates/:templateId (GET)', async () => { + let session: UserSession; + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should return the template by its id', async function () { + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + const template = await notificationTemplateService.createTemplate(); + const { body } = await session.testAgent.get(`/v1/notification-templates/${template._id}`); + + const foundTemplate: INotificationTemplate = body.data; + + expect(foundTemplate._id).to.equal(template._id); + expect(foundTemplate.name).to.equal(template.name); + expect(foundTemplate.messages.length).to.equal(template.messages.length); + expect(foundTemplate.messages[0].template).to.be.ok; + expect(foundTemplate.messages[0].template.content).to.equal(template.messages[0].template.content); + expect(foundTemplate.messages[0]._templateId).to.be.ok; + expect(foundTemplate.triggers.length).to.equal(template.triggers.length); + }); +}); diff --git a/apps/api/src/app/notification-template/e2e/get-notification-templates.e2e.ts b/apps/api/src/app/notification-template/e2e/get-notification-templates.e2e.ts new file mode 100644 index 00000000000..fe679189696 --- /dev/null +++ b/apps/api/src/app/notification-template/e2e/get-notification-templates.e2e.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { NotificationTemplateEntity } from '@notifire/dal'; +import { UserSession, NotificationTemplateService } from '@notifire/testing'; + +describe('Get Notification templates - /notification-templates (GET)', async () => { + let session: UserSession; + const templates: NotificationTemplateEntity[] = []; + before(async () => { + session = new UserSession(); + await session.initialize(); + + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + templates.push(await notificationTemplateService.createTemplate()); + templates.push(await notificationTemplateService.createTemplate()); + templates.push(await notificationTemplateService.createTemplate()); + }); + + it('should return all templates for organization', async () => { + const { body } = await session.testAgent.get(`/v1/notification-templates`); + expect(body.data.length).to.equal(3); + + const found = body.data.find((i) => templates[0]._id === i._id); + expect(found).to.be.ok; + expect(found.name).to.equal(templates[0].name); + expect(found.notificationGroup.name).to.equal('General'); + }); +}); diff --git a/apps/api/src/app/notification-template/e2e/update-notification-template.e2e.ts b/apps/api/src/app/notification-template/e2e/update-notification-template.e2e.ts new file mode 100644 index 00000000000..ed31c9c8d27 --- /dev/null +++ b/apps/api/src/app/notification-template/e2e/update-notification-template.e2e.ts @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import { UserSession, NotificationTemplateService } from '@notifire/testing'; +import { ChannelTypeEnum, INotificationTemplate, IUpdateNotificationTemplate } from '@notifire/shared'; + +describe('Update notification template by id - /notification-templates/:templateId (PUT)', async () => { + let session: UserSession; + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should update the notification template', async function () { + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + const template = await notificationTemplateService.createTemplate(); + const update: IUpdateNotificationTemplate = { + name: 'new name for notification', + messages: [ + { + type: ChannelTypeEnum.IN_APP, + content: 'This is new content for notification', + }, + ], + }; + const { body } = await session.testAgent.put(`/v1/notification-templates/${template._id}`).send(update); + const foundTemplate: INotificationTemplate = body.data; + expect(foundTemplate._id).to.equal(template._id); + expect(foundTemplate.name).to.equal('new name for notification'); + expect(foundTemplate.description).to.equal(template.description); + expect(foundTemplate.messages.length).to.equal(1); + expect(foundTemplate.messages[0].template.content).to.equal(update.messages[0].content); + }); + + it('should generate new variables on update', async function () { + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + + const template = await notificationTemplateService.createTemplate({ + messages: [ + { + type: ChannelTypeEnum.IN_APP, + content: 'This is new content for notification {{otherVariable}}', + }, + ], + }); + + const update: IUpdateNotificationTemplate = { + messages: [ + { + type: ChannelTypeEnum.IN_APP, + content: 'This is new content for notification {{newVariableFromUpdate}}', + }, + ], + }; + const { body } = await session.testAgent.put(`/v1/notification-templates/${template._id}`).send(update); + const foundTemplate: INotificationTemplate = body.data; + expect(foundTemplate._id).to.equal(template._id); + expect(foundTemplate.triggers[0].variables[0].name).to.equal('newVariableFromUpdate'); + }); +}); diff --git a/apps/api/src/app/notification-template/notification-template.controller.ts b/apps/api/src/app/notification-template/notification-template.controller.ts new file mode 100644 index 00000000000..1b61d078169 --- /dev/null +++ b/apps/api/src/app/notification-template/notification-template.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Get, + Param, + Post, + Put, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { IJwtPayload, IUpdateNotificationTemplate, MemberRoleEnum } from '@notifire/shared'; +import { UserSession } from '../shared/framework/user.decorator'; +import { Roles } from '../auth/framework/roles.decorator'; +import { GetNotificationTemplates } from './usecases/get-notification-templates/get-notification-templates.usecase'; +import { GetNotificationTemplatesCommand } from './usecases/get-notification-templates/get-notification-templates.command'; +import { CreateNotificationTemplate, CreateNotificationTemplateCommand } from './usecases/create-notification-template'; +import { CreateNotificationTemplateDto } from './dto/create-notification-template.dto'; +import { GetNotificationTemplate } from './usecases/get-notification-template/get-notification-template.usecase'; +import { GetNotificationTemplateCommand } from './usecases/get-notification-template/get-notification-template.command'; +import { UpdateNotificationTemplate } from './usecases/update-notification-template/update-notification-template.usecase'; +import { UpdateNotificationTemplateCommand } from './usecases/update-notification-template/update-notification-template.command'; +import { UpdateNotificationTemplateDto } from './dto/update-notification-template.dto'; +import { ChangeTemplateActiveStatus } from './usecases/change-template-active-status/change-template-active-status.usecase'; +import { ChangeTemplateActiveStatusCommand } from './usecases/change-template-active-status/change-template-active-status.command'; +import { ChangeTemplateStatusDto } from './dto/change-template-status.dto'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; + +@Controller('/notification-templates') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class NotificationTemplateController { + constructor( + private getNotificationTemplatesUsecase: GetNotificationTemplates, + private createNotificationTemplateUsecase: CreateNotificationTemplate, + private getNotificationTemplateUsecase: GetNotificationTemplate, + private updateTemplateByIdUsecase: UpdateNotificationTemplate, + private changeTemplateActiveStatusUsecase: ChangeTemplateActiveStatus + ) {} + + @Get('') + @Roles(MemberRoleEnum.ADMIN) + getNotificationTemplates(@UserSession() user: IJwtPayload) { + return this.getNotificationTemplatesUsecase.execute( + GetNotificationTemplatesCommand.create({ + organizationId: user.organizationId, + userId: user._id, + applicationId: user.applicationId, + }) + ); + } + + @Put('/:templateId') + @Roles(MemberRoleEnum.ADMIN) + updateTemplateById( + @UserSession() user: IJwtPayload, + @Param('templateId') templateId: string, + @Body() body: UpdateNotificationTemplateDto + ) { + return this.updateTemplateByIdUsecase.execute( + UpdateNotificationTemplateCommand.create({ + applicationId: user.applicationId, + organizationId: user.organizationId, + userId: user._id, + templateId, + name: body.name, + tags: body.tags, + description: body.description, + messages: body.messages, + notificationGroupId: body.notificationGroupId, + }) + ); + } + + @Get('/:templateId') + @Roles(MemberRoleEnum.ADMIN) + getNotificationTemplateById(@UserSession() user: IJwtPayload, @Param('templateId') templateId: string) { + return this.getNotificationTemplateUsecase.execute( + GetNotificationTemplateCommand.create({ + applicationId: user.applicationId, + organizationId: user.organizationId, + userId: user._id, + templateId, + }) + ); + } + + @Post('') + @Roles(MemberRoleEnum.ADMIN) + createNotificationTemplates(@UserSession() user: IJwtPayload, @Body() body: CreateNotificationTemplateDto) { + return this.createNotificationTemplateUsecase.execute( + CreateNotificationTemplateCommand.create({ + organizationId: user.organizationId, + userId: user._id, + applicationId: user.applicationId, + name: body.name, + tags: body.tags, + description: body.description, + messages: body.messages, + notificationGroupId: body.notificationGroupId, + }) + ); + } + + @Put('/:templateId/status') + @Roles(MemberRoleEnum.ADMIN) + changeActiveStatus( + @UserSession() user: IJwtPayload, + @Body() body: ChangeTemplateStatusDto, + @Param('templateId') templateId: string + ) { + return this.changeTemplateActiveStatusUsecase.execute( + ChangeTemplateActiveStatusCommand.create({ + organizationId: user.organizationId, + userId: user._id, + applicationId: user.applicationId, + active: body.active, + templateId, + }) + ); + } +} diff --git a/apps/api/src/app/notification-template/notification-template.module.ts b/apps/api/src/app/notification-template/notification-template.module.ts new file mode 100644 index 00000000000..e8f35640b66 --- /dev/null +++ b/apps/api/src/app/notification-template/notification-template.module.ts @@ -0,0 +1,15 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { USE_CASES } from './usecases'; +import { NotificationTemplateController } from './notification-template.controller'; +import { MessageTemplateModule } from '../message-template/message-template.module'; + +@Module({ + imports: [SharedModule, MessageTemplateModule], + controllers: [NotificationTemplateController], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class NotificationTemplateModule implements NestModule { + configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {} +} diff --git a/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.command.ts b/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.command.ts new file mode 100644 index 00000000000..97221d4b0bf --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.command.ts @@ -0,0 +1,17 @@ +import { IsBoolean, IsDefined, IsMongoId } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class ChangeTemplateActiveStatusCommand extends ApplicationWithUserCommand { + static create(data: ChangeTemplateActiveStatusCommand) { + return CommandHelper.create(ChangeTemplateActiveStatusCommand, data); + } + + @IsBoolean() + @IsDefined() + active: boolean; + + @IsMongoId() + @IsDefined() + templateId: string; +} diff --git a/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.usecase.ts b/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.usecase.ts new file mode 100644 index 00000000000..ab54a139092 --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/change-template-active-status/change-template-active-status.usecase.ts @@ -0,0 +1,36 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { NotificationTemplateEntity, NotificationTemplateRepository } from '@notifire/dal'; +import { ChangeTemplateActiveStatusCommand } from './change-template-active-status.command'; + +@Injectable() +export class ChangeTemplateActiveStatus { + constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} + + async execute(command: ChangeTemplateActiveStatusCommand): Promise { + const foundTemplate = await this.notificationTemplateRepository.findOne({ + _organizationId: command.organizationId, + _id: command.templateId, + }); + if (!foundTemplate) { + throw new NotFoundException(`Template with id ${command.templateId} not found`); + } + + if (foundTemplate.active === command.active) { + throw new BadRequestException('You must provide a different status from the current status'); + } + + await this.notificationTemplateRepository.update( + { + _id: command.templateId, + }, + { + $set: { + active: command.active, + draft: command.active === false, + }, + } + ); + + return await this.notificationTemplateRepository.findById(command.templateId, command.organizationId); + } +} diff --git a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.command.ts b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.command.ts new file mode 100644 index 00000000000..613b74d9924 --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.command.ts @@ -0,0 +1,106 @@ +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsEnum, + IsMongoId, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { + BuilderFieldOperator, + BuilderFieldType, + BuilderGroupValues, + ChannelCTATypeEnum, + ChannelTypeEnum, + IEmailBlock, +} from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class CreateNotificationTemplateCommand extends ApplicationWithUserCommand { + static create(data: CreateNotificationTemplateCommand) { + return CommandHelper.create(CreateNotificationTemplateCommand, data); + } + + @IsMongoId() + @IsDefined() + notificationGroupId: string; + + @IsOptional() + @IsArray() + tags: string[]; + + @IsDefined() + @IsString() + name: string; + + @IsString() + @IsOptional() + description: string; + + @IsDefined() + @IsArray() + @ValidateNested() + @ArrayNotEmpty() + messages: NotificationChannelDto[]; +} + +export class ChannelCTADto { + @IsEnum(ChannelCTATypeEnum) + type: ChannelCTATypeEnum; + + data: { + url: string; + }; +} + +export class NotificationChannelDto { + @IsOptional() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsDefined() + content: string | IEmailBlock[]; + + @IsOptional() + contentType?: 'editor' | 'customHtml'; + + @IsOptional() + @ValidateNested() + cta?: ChannelCTADto; + + @IsOptional() + name?: string; + + @IsOptional() + subject?: string; + + @IsOptional() + @IsArray() + @ValidateNested() + filters?: MessageFilter[]; + + @IsMongoId() + @IsOptional() + _id?: string; +} + +export class MessageFilter { + isNegated: boolean; + + @IsString() + type: BuilderFieldType; + + @IsString() + value: BuilderGroupValues; + + @IsArray() + children: { + field: string; + value: string; + operator: BuilderFieldOperator; + }[]; +} diff --git a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts new file mode 100644 index 00000000000..1193fb117ac --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationTemplateRepository } from '@notifire/dal'; +import { INotificationTrigger, TriggerTypeEnum } from '@notifire/shared'; +import slugify from 'slugify'; +import * as shortid from 'shortid'; +import { CreateNotificationTemplateCommand } from './create-notification-template.command'; +import { ContentService } from '../../../shared/helpers/content.service'; +import { CreateMessageTemplate } from '../../../message-template/usecases/create-message-template/create-message-template.usecase'; +import { CreateMessageTemplateCommand } from '../../../message-template/usecases/create-message-template/create-message-template.command'; + +@Injectable() +export class CreateNotificationTemplate { + constructor( + private notificationTemplateRepository: NotificationTemplateRepository, + private createMessageTemplate: CreateMessageTemplate + ) {} + + async execute(command: CreateNotificationTemplateCommand) { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables(command.messages); + + const trigger: INotificationTrigger = { + type: TriggerTypeEnum.EVENT, + identifier: `${slugify(command.name, { + lower: true, + strict: true, + })}-${shortid.generate()}`, + variables: variables.map((i) => { + return { + name: i, + }; + }), + }; + + const templateMessages = []; + for (const message of command.messages) { + const template = await this.createMessageTemplate.execute( + CreateMessageTemplateCommand.create({ + type: message.type, + name: message.name, + content: message.content, + contentType: message.contentType, + organizationId: command.organizationId, + applicationId: command.applicationId, + userId: command.userId, + cta: message.cta, + subject: message.subject, + }) + ); + + templateMessages.push({ + _templateId: template._id, + filters: message.filters, + }); + } + + const savedTemplate = await this.notificationTemplateRepository.create({ + _organizationId: command.organizationId, + _creatorId: command.userId, + _applicationId: command.applicationId, + name: command.name, + tags: command.tags, + description: command.description, + messages: templateMessages, + triggers: [trigger], + _notificationGroupId: command.notificationGroupId, + }); + + return await this.notificationTemplateRepository.findById(savedTemplate._id, command.organizationId); + } +} diff --git a/apps/api/src/app/notification-template/usecases/create-notification-template/index.ts b/apps/api/src/app/notification-template/usecases/create-notification-template/index.ts new file mode 100644 index 00000000000..1721621522c --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/create-notification-template/index.ts @@ -0,0 +1,2 @@ +export * from './create-notification-template.command'; +export * from './create-notification-template.usecase'; diff --git a/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.command.ts b/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.command.ts new file mode 100644 index 00000000000..dd843f030e6 --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.command.ts @@ -0,0 +1,13 @@ +import { IsDefined, IsMongoId } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetNotificationTemplateCommand extends ApplicationWithUserCommand { + static create(data: GetNotificationTemplateCommand) { + return CommandHelper.create(GetNotificationTemplateCommand, data); + } + + @IsDefined() + @IsMongoId() + templateId: string; +} diff --git a/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.usecase.ts new file mode 100644 index 00000000000..4329ddca22c --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/get-notification-template/get-notification-template.usecase.ts @@ -0,0 +1,17 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { NotificationTemplateEntity, NotificationTemplateRepository } from '@notifire/dal'; +import { GetNotificationTemplateCommand } from './get-notification-template.command'; + +@Injectable() +export class GetNotificationTemplate { + constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} + + async execute(command: GetNotificationTemplateCommand): Promise { + const template = await this.notificationTemplateRepository.findById(command.templateId, command.organizationId); + if (!template) { + throw new NotFoundException(`Template with id ${command.templateId} not found`); + } + + return template; + } +} diff --git a/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.command.ts b/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.command.ts new file mode 100644 index 00000000000..3369dcf8ee3 --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.command.ts @@ -0,0 +1,9 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../shared/commands/organization.command'; +import { ApplicationCommand, ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetNotificationTemplatesCommand extends ApplicationWithUserCommand { + static create(data: GetNotificationTemplatesCommand) { + return CommandHelper.create(GetNotificationTemplatesCommand, data); + } +} diff --git a/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.usecase.ts b/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.usecase.ts new file mode 100644 index 00000000000..f27244c8d58 --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/get-notification-templates/get-notification-templates.usecase.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationTemplateEntity, NotificationTemplateRepository, OrganizationEntity } from '@notifire/dal'; +import { GetNotificationTemplatesCommand } from './get-notification-templates.command'; + +@Injectable() +export class GetNotificationTemplates { + constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} + + async execute(command: GetNotificationTemplatesCommand): Promise { + const list = await this.notificationTemplateRepository.getList(command.organizationId, command.applicationId); + return list; + } +} diff --git a/apps/api/src/app/notification-template/usecases/index.ts b/apps/api/src/app/notification-template/usecases/index.ts new file mode 100644 index 00000000000..1dc8932b61f --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/index.ts @@ -0,0 +1,14 @@ +import { ChangeTemplateActiveStatus } from './change-template-active-status/change-template-active-status.usecase'; +import { UpdateNotificationTemplate } from './update-notification-template/update-notification-template.usecase'; +import { GetNotificationTemplates } from './get-notification-templates/get-notification-templates.usecase'; +import { CreateNotificationTemplate } from './create-notification-template'; +import { GetNotificationTemplate } from './get-notification-template/get-notification-template.usecase'; + +export const USE_CASES = [ + // + ChangeTemplateActiveStatus, + UpdateNotificationTemplate, + GetNotificationTemplates, + CreateNotificationTemplate, + GetNotificationTemplate, +]; diff --git a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.command.ts b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.command.ts new file mode 100644 index 00000000000..c67491912bc --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.command.ts @@ -0,0 +1,88 @@ +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsEnum, + IsMongoId, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { ChannelCTATypeEnum, ChannelTypeEnum } from '@notifire/shared'; +import { IEmailBlock } from '@notifire/dal'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; +import { MessageFilter } from '../create-notification-template'; + +export class UpdateNotificationTemplateCommand extends ApplicationWithUserCommand { + static create(data: UpdateNotificationTemplateCommand) { + return CommandHelper.create(UpdateNotificationTemplateCommand, data); + } + + @IsDefined() + @IsMongoId() + templateId: string; + + @IsArray() + @IsOptional() + tags: string[]; + + @IsString() + @IsOptional() + name: string; + + @IsString() + @IsOptional() + description: string; + + @IsOptional() + @IsMongoId({ + message: 'Bad group id name', + }) + notificationGroupId: string; + + @IsArray() + @ValidateNested() + @IsOptional() + messages: NotificationChannelDto[]; +} + +export class ChannelCTADto { + @IsEnum(ChannelCTATypeEnum) + type: ChannelCTATypeEnum; + + data: { + url: string; + }; +} + +export class NotificationChannelDto { + @IsOptional() + @IsEnum(ChannelTypeEnum) + type: ChannelTypeEnum; + + @IsDefined() + content: string | IEmailBlock[]; + + @IsOptional() + contentType?: 'editor' | 'customHtml'; + + @IsOptional() + @ValidateNested() + cta: ChannelCTADto; + + @IsOptional() + name?: string; + + @IsOptional() + subject?: string; + + @IsOptional() + @IsArray() + @ValidateNested() + filters?: MessageFilter[]; + + @IsMongoId() + @IsOptional() + _id?: string; +} diff --git a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts new file mode 100644 index 00000000000..0a2baed1cea --- /dev/null +++ b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts @@ -0,0 +1,118 @@ +// eslint-ignore max-len + +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { NotificationTemplateEntity, NotificationTemplateRepository, NotificationMessagesEntity } from '@notifire/dal'; + +import { UpdateNotificationTemplateCommand } from './update-notification-template.command'; +import { ContentService } from '../../../shared/helpers/content.service'; +import { CreateMessageTemplate } from '../../../message-template/usecases/create-message-template/create-message-template.usecase'; +import { CreateMessageTemplateCommand } from '../../../message-template/usecases/create-message-template/create-message-template.command'; +import { UpdateMessageTemplateCommand } from '../../../message-template/usecases/update-message-template/update-message-template.command'; +import { UpdateMessageTemplate } from '../../../message-template/usecases/update-message-template/update-message-template.usecase'; + +@Injectable() +export class UpdateNotificationTemplate { + constructor( + private notificationTemplateRepository: NotificationTemplateRepository, + private createMessageTemplate: CreateMessageTemplate, + private updateMessageTemplate: UpdateMessageTemplate + ) {} + + async execute(command: UpdateNotificationTemplateCommand): Promise { + const existingTemplate = await this.notificationTemplateRepository.findById( + command.templateId, + command.organizationId + ); + if (!existingTemplate) throw new NotFoundException(`Entity with id ${command.templateId} not found`); + + const updatePayload: Partial = {}; + if (command.name) { + updatePayload.name = command.name; + } + + if (command.description) { + updatePayload.description = command.description; + } + + if (command.notificationGroupId) { + updatePayload._notificationGroupId = command.notificationGroupId; + } + + if (command.messages) { + const contentService = new ContentService(); + const { messages } = command; + + const variables = contentService.extractMessageVariables(command.messages); + updatePayload['triggers.0.variables'] = variables.map((i) => { + return { + name: i, + }; + }); + + const templateMessages: NotificationMessagesEntity[] = []; + for (const message of messages) { + if (message._id) { + const template = await this.updateMessageTemplate.execute( + UpdateMessageTemplateCommand.create({ + templateId: message._id, + type: message.type, + name: message.name, + content: message.content, + organizationId: command.organizationId, + applicationId: command.applicationId, + userId: command.userId, + contentType: message.contentType, + cta: message.cta, + subject: message.subject, + }) + ); + + templateMessages.push({ + _templateId: template._id, + filters: message.filters, + }); + } else { + const template = await this.createMessageTemplate.execute( + CreateMessageTemplateCommand.create({ + type: message.type, + name: message.name, + content: message.content, + organizationId: command.organizationId, + applicationId: command.applicationId, + contentType: message.contentType, + userId: command.userId, + cta: message.cta, + subject: message.subject, + }) + ); + + templateMessages.push({ + _templateId: template._id, + filters: message.filters, + }); + } + } + updatePayload.messages = templateMessages; + } + + if (command.tags) { + updatePayload.tags = command.tags; + } + + if (!Object.keys(updatePayload).length) { + throw new BadRequestException('No properties found for update'); + } + + await this.notificationTemplateRepository.update( + { + _id: command.templateId, + _organizationId: command.organizationId, + }, + { + $set: updatePayload, + } + ); + + return await this.notificationTemplateRepository.findById(command.templateId, command.organizationId); + } +} diff --git a/apps/api/src/app/organization/dtos/bulk-invite-members.dto.ts b/apps/api/src/app/organization/dtos/bulk-invite-members.dto.ts new file mode 100644 index 00000000000..762b685e5d6 --- /dev/null +++ b/apps/api/src/app/organization/dtos/bulk-invite-members.dto.ts @@ -0,0 +1,18 @@ +import { IBulkInviteRequestDto } from '@notifire/shared'; +import { Type } from 'class-transformer'; +import { ArrayNotEmpty, IsArray, IsDefined, IsEmail, IsNotEmpty, ValidateNested } from 'class-validator'; + +class EmailInvitee { + @IsDefined() + @IsNotEmpty() + @IsEmail() + email: string; +} + +export class BulkInviteMembersDto implements IBulkInviteRequestDto { + @ArrayNotEmpty() + @IsArray() + @ValidateNested() + @Type(() => EmailInvitee) + invitees: EmailInvitee[]; +} diff --git a/apps/api/src/app/organization/dtos/create-organization.dto.ts b/apps/api/src/app/organization/dtos/create-organization.dto.ts new file mode 100644 index 00000000000..100df3f7a0e --- /dev/null +++ b/apps/api/src/app/organization/dtos/create-organization.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString } from 'class-validator'; +import { ICreateOrganizationDto } from '@notifire/shared'; + +export class CreateOrganizationDto implements ICreateOrganizationDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + logo?: string; +} diff --git a/apps/api/src/app/organization/dtos/get-invite.dto.ts b/apps/api/src/app/organization/dtos/get-invite.dto.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/src/app/organization/dtos/get-my-organization.dto.ts b/apps/api/src/app/organization/dtos/get-my-organization.dto.ts new file mode 100644 index 00000000000..3952ecc9ee2 --- /dev/null +++ b/apps/api/src/app/organization/dtos/get-my-organization.dto.ts @@ -0,0 +1,3 @@ +import { OrganizationEntity } from '@notifire/dal'; + +export type IGetMyOrganizationDto = OrganizationEntity; diff --git a/apps/api/src/app/organization/dtos/invite-member.dto.ts b/apps/api/src/app/organization/dtos/invite-member.dto.ts new file mode 100644 index 00000000000..a6934e10426 --- /dev/null +++ b/apps/api/src/app/organization/dtos/invite-member.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsEnum, IsNotEmpty } from 'class-validator'; +import { MemberRoleEnum } from '@notifire/shared'; + +export class InviteMemberDto { + @IsEmail() + @IsNotEmpty() + email: string; + + @IsEnum(MemberRoleEnum) + role: MemberRoleEnum; +} diff --git a/apps/api/src/app/organization/organization.controller.ts b/apps/api/src/app/organization/organization.controller.ts new file mode 100644 index 00000000000..4c6e56dfc0d --- /dev/null +++ b/apps/api/src/app/organization/organization.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { OrganizationEntity } from '@notifire/dal'; +import { IJwtPayload, MemberRoleEnum } from '@notifire/shared'; +import { AuthGuard } from '@nestjs/passport'; +import { Roles } from '../auth/framework/roles.decorator'; +import { UserSession } from '../shared/framework/user.decorator'; +import { CreateOrganizationDto } from './dtos/create-organization.dto'; +import { CreateOrganizationCommand } from './usecases/create-organization/create-organization.command'; +import { CreateOrganization } from './usecases/create-organization/create-organization.usecase'; +import { GetMyOrganizationCommand } from './usecases/get-my-organization/get-my-organization.command'; +import { GetMyOrganization } from './usecases/get-my-organization/get-my-organization.usecase'; +import { RemoveMember } from './usecases/membership/remove-member/remove-member.usecase'; +import { RemoveMemberCommand } from './usecases/membership/remove-member/remove-member.command'; +import { IGetMyOrganizationDto } from './dtos/get-my-organization.dto'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { GetMembersCommand } from './usecases/membership/membership/get-members/get-members.command'; +import { GetMembers } from './usecases/membership/membership/get-members/get-members.usecase'; +import { ChangeMemberRoleCommand } from './usecases/membership/membership/change-member-role/change-member-role.command'; +import { ChangeMemberRole } from './usecases/membership/membership/change-member-role/change-member-role.usecase'; + +@Controller('/organizations') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class OrganizationController { + constructor( + private createOrganizationUsecase: CreateOrganization, + private getMyOrganizationUsecase: GetMyOrganization, + private getMembers: GetMembers, + private removeMemberUsecase: RemoveMember, + private changeMemberRoleUsecase: ChangeMemberRole + ) {} + + @Post('/') + async createOrganization( + @UserSession() user: IJwtPayload, + @Body() body: CreateOrganizationDto + ): Promise { + const command = CreateOrganizationCommand.create({ + userId: user._id, + logo: body.logo, + name: body.name, + }); + const organization = await this.createOrganizationUsecase.execute(command); + + return organization; + } + + @Delete('/members/:memberId') + @Roles(MemberRoleEnum.ADMIN) + async removeMember(@UserSession() user: IJwtPayload, @Param('memberId') memberId: string) { + return await this.removeMemberUsecase.execute( + RemoveMemberCommand.create({ + userId: user._id, + organizationId: user.organizationId, + memberId, + }) + ); + } + + @Put('/members/:memberId/roles') + @Roles(MemberRoleEnum.ADMIN) + async updateMemberRoles( + @UserSession() user: IJwtPayload, + @Param('memberId') memberId: string, + @Body('role') role: MemberRoleEnum + ) { + return await this.changeMemberRoleUsecase.execute( + ChangeMemberRoleCommand.create({ + memberId, + role, + userId: user._id, + organizationId: user.organizationId, + }) + ); + } + + @Get('/members') + @Roles(MemberRoleEnum.ADMIN) + async getMember(@UserSession() user: IJwtPayload) { + return await this.getMembers.execute( + GetMembersCommand.create({ + userId: user._id, + organizationId: user.organizationId, + }) + ); + } + + @Post('/members/invite') + @Roles(MemberRoleEnum.ADMIN) + async inviteMember(@UserSession() user: IJwtPayload) { + return await this.getMembers.execute( + GetMembersCommand.create({ + userId: user._id, + organizationId: user.organizationId, + }) + ); + } + + @Get('/me') + async getMyOrganization(@UserSession() user: IJwtPayload): Promise { + const command = GetMyOrganizationCommand.create({ + userId: user._id, + id: user.organizationId, + }); + + return await this.getMyOrganizationUsecase.execute(command); + } +} diff --git a/apps/api/src/app/organization/organization.module.ts b/apps/api/src/app/organization/organization.module.ts new file mode 100644 index 00000000000..27393892b8c --- /dev/null +++ b/apps/api/src/app/organization/organization.module.ts @@ -0,0 +1,21 @@ +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { SharedModule } from '../shared/shared.module'; +import { UserModule } from '../user/user.module'; +import { OrganizationController } from './organization.controller'; +import { USE_CASES } from './usecases'; + +@Module({ + imports: [SharedModule, UserModule], + controllers: [OrganizationController], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class OrganizationModule implements NestModule { + configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void { + consumer.apply(AuthGuard).exclude({ + method: RequestMethod.GET, + path: '/organizations/invite/:inviteToken', + }); + } +} diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts new file mode 100644 index 00000000000..83cfe7d999c --- /dev/null +++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts @@ -0,0 +1,12 @@ +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class CreateOrganizationCommand extends AuthenticatedCommand { + static create(data: CreateOrganizationCommand) { + return CommandHelper.create(CreateOrganizationCommand, data); + } + + public readonly logo?: string; + + public readonly name: string; +} diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts new file mode 100644 index 00000000000..cb4ec7c96b6 --- /dev/null +++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger, Scope } from '@nestjs/common'; +import { OrganizationEntity, OrganizationRepository, UserEntity, UserRepository } from '@notifire/dal'; +import { MemberRoleEnum } from '@notifire/shared'; +import { capitalize } from '../../../shared/services/helper/helper.service'; +import { MailService } from '../../../shared/services/mail/mail.service'; +import { QueueService } from '../../../shared/services/queue'; +import { GetOrganizationCommand } from '../get-organization/get-organization.command'; +import { GetOrganization } from '../get-organization/get-organization.usecase'; +import { AddMemberCommand } from '../membership/add-member/add-member.command'; +import { AddMember } from '../membership/add-member/add-member.usecase'; +import { CreateOrganizationCommand } from './create-organization.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class CreateOrganization { + constructor( + private readonly organizationRepository: OrganizationRepository, + private readonly addMemberUsecase: AddMember, + private readonly getOrganizationUsecase: GetOrganization, + private readonly queueService: QueueService, + private readonly userRepository: UserRepository, + private readonly mailService: MailService + ) {} + + async execute(command: CreateOrganizationCommand): Promise { + const organization = new OrganizationEntity(); + organization.logo = command.logo; + organization.name = command.name; + + const user = await this.userRepository.findById(command.userId); + + const createdOrganization = await this.organizationRepository.create(organization); + + await this.addMemberUsecase.execute( + AddMemberCommand.create({ + roles: [MemberRoleEnum.ADMIN], + organizationId: createdOrganization._id, + userId: command.userId, + }) + ); + + await this.sendWelcomeEmail(user, organization); + + const organizationAfterChanges = await this.getOrganizationUsecase.execute( + GetOrganizationCommand.create({ + id: createdOrganization._id, + userId: command.userId, + }) + ); + + return organizationAfterChanges; + } + + private async sendWelcomeEmail(user: UserEntity, organization: OrganizationEntity) { + try { + await this.mailService.sendMail({ + templateId: '35339302-a24e-4dc2-bff5-02f32b8537cc', + to: user.email, + from: { + email: 'hi@notifire.co', + name: 'Notifire', + }, + params: { + firstName: capitalize(user.firstName), + organizationName: capitalize(organization.name), + }, + }); + } catch (e) { + Logger.error(e.message); + } + } +} diff --git a/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.command.ts b/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.command.ts new file mode 100644 index 00000000000..a8462a520a3 --- /dev/null +++ b/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.command.ts @@ -0,0 +1,12 @@ +import { IsDefined } from 'class-validator'; +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class GetMyOrganizationCommand extends AuthenticatedCommand { + static create(data: GetMyOrganizationCommand) { + return CommandHelper.create(GetMyOrganizationCommand, data); + } + + @IsDefined() + public readonly id: string; +} diff --git a/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.usecase.ts b/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.usecase.ts new file mode 100644 index 00000000000..0426a7d6949 --- /dev/null +++ b/apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.usecase.ts @@ -0,0 +1,23 @@ +import { Injectable, Scope, UnauthorizedException } from '@nestjs/common'; +import { GetMyOrganizationCommand } from './get-my-organization.command'; +import { GetOrganization } from '../get-organization/get-organization.usecase'; +import { GetOrganizationCommand } from '../get-organization/get-organization.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class GetMyOrganization { + constructor(private getOrganizationUseCase: GetOrganization) {} + + async execute(command: GetMyOrganizationCommand) { + const organization = await this.getOrganizationUseCase.execute( + GetOrganizationCommand.create({ + id: command.id, + userId: command.userId, + }) + ); + if (!organization) throw new UnauthorizedException('No organization found'); + + return organization; + } +} diff --git a/apps/api/src/app/organization/usecases/get-organization/get-organization.command.ts b/apps/api/src/app/organization/usecases/get-organization/get-organization.command.ts new file mode 100644 index 00000000000..f17290ed99f --- /dev/null +++ b/apps/api/src/app/organization/usecases/get-organization/get-organization.command.ts @@ -0,0 +1,10 @@ +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class GetOrganizationCommand extends AuthenticatedCommand { + static create(data: GetOrganizationCommand) { + return CommandHelper.create(GetOrganizationCommand, data); + } + + public readonly id: string; +} diff --git a/apps/api/src/app/organization/usecases/get-organization/get-organization.usecase.ts b/apps/api/src/app/organization/usecases/get-organization/get-organization.usecase.ts new file mode 100644 index 00000000000..65050bd6b38 --- /dev/null +++ b/apps/api/src/app/organization/usecases/get-organization/get-organization.usecase.ts @@ -0,0 +1,16 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { OrganizationRepository } from '@notifire/dal'; +import { GetOrganizationCommand } from './get-organization.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class GetOrganization { + constructor(private readonly organizationRepository: OrganizationRepository) {} + + async execute(command: GetOrganizationCommand) { + const organization = await this.organizationRepository.findById(command.id); + + return organization; + } +} diff --git a/apps/api/src/app/organization/usecases/index.ts b/apps/api/src/app/organization/usecases/index.ts new file mode 100644 index 00000000000..3034583d77b --- /dev/null +++ b/apps/api/src/app/organization/usecases/index.ts @@ -0,0 +1,17 @@ +import { CreateOrganization } from './create-organization/create-organization.usecase'; +import { GetOrganization } from './get-organization/get-organization.usecase'; +import { GetMyOrganization } from './get-my-organization/get-my-organization.usecase'; +import { AddMember } from './membership/add-member/add-member.usecase'; +import { RemoveMember } from './membership/remove-member/remove-member.usecase'; +import { GetMembers } from './membership/membership/get-members/get-members.usecase'; +import { ChangeMemberRole } from './membership/membership/change-member-role/change-member-role.usecase'; + +export const USE_CASES = [ + AddMember, + CreateOrganization, + GetOrganization, + GetMyOrganization, + GetMembers, + RemoveMember, + ChangeMemberRole, +]; diff --git a/apps/api/src/app/organization/usecases/membership/add-member/add-member.command.ts b/apps/api/src/app/organization/usecases/membership/add-member/add-member.command.ts new file mode 100644 index 00000000000..0e95ec33bb7 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/add-member/add-member.command.ts @@ -0,0 +1,13 @@ +import { MemberRoleEnum } from '@notifire/shared'; +import { ArrayNotEmpty } from 'class-validator'; +import { CommandHelper } from '../../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../../shared/commands/organization.command'; + +export class AddMemberCommand extends OrganizationCommand { + static create(data: AddMemberCommand) { + return CommandHelper.create(AddMemberCommand, data); + } + + @ArrayNotEmpty() + public readonly roles: MemberRoleEnum[]; +} diff --git a/apps/api/src/app/organization/usecases/membership/add-member/add-member.usecase.ts b/apps/api/src/app/organization/usecases/membership/add-member/add-member.usecase.ts new file mode 100644 index 00000000000..7f6b8c3f343 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/add-member/add-member.usecase.ts @@ -0,0 +1,34 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { MemberRepository, OrganizationRepository } from '@notifire/dal'; +import { MemberStatusEnum } from '@notifire/shared'; +import { ApiException } from '../../../../shared/exceptions/api.exception'; +import { AddMemberCommand } from './add-member.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class AddMember { + private organizationId: string; + + constructor( + private readonly organizationRepository: OrganizationRepository, + private readonly memberRepository: MemberRepository + ) {} + + async execute(command: AddMemberCommand): Promise { + this.organizationId = command.organizationId; + + const isAlreadyMember = await this.isMember(command.userId); + if (isAlreadyMember) throw new ApiException('Member already exists'); + + await this.memberRepository.addMember(command.organizationId, { + _userId: command.userId, + roles: command.roles, + memberStatus: MemberStatusEnum.ACTIVE, + }); + } + + private async isMember(userId: string): Promise { + return !!(await this.memberRepository.findMemberByUserId(this.organizationId, userId)); + } +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.command.ts b/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.command.ts new file mode 100644 index 00000000000..7587a8c71f0 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.command.ts @@ -0,0 +1,13 @@ +import { MemberRoleEnum } from '@notifire/shared'; +import { ArrayNotEmpty } from 'class-validator'; +import { OrganizationCommand } from '../../../../../shared/commands/organization.command'; +import { CommandHelper } from '../../../../../shared/commands/command.helper'; + +export class AddMemberCommand extends OrganizationCommand { + static create(data: AddMemberCommand) { + return CommandHelper.create(AddMemberCommand, data); + } + + @ArrayNotEmpty() + public readonly roles: MemberRoleEnum[]; +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.usecase.ts b/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.usecase.ts new file mode 100644 index 00000000000..7eff041f7d2 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/add-member/add-member.usecase.ts @@ -0,0 +1,34 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { OrganizationRepository, UserRepository, MemberRepository } from '@notifire/dal'; +import { MemberStatusEnum } from '@notifire/shared'; +import { AddMemberCommand } from './add-member.command'; +import { ApiException } from '../../../../../shared/exceptions/api.exception'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class AddMember { + private organizationId: string; + + constructor( + private readonly organizationRepository: OrganizationRepository, + private readonly memberRepository: MemberRepository + ) {} + + async execute(command: AddMemberCommand): Promise { + this.organizationId = command.organizationId; + + const isAlreadyMember = await this.isMember(command.userId); + if (isAlreadyMember) throw new ApiException('Member already exists'); + + await this.memberRepository.addMember(command.organizationId, { + _userId: command.userId, + roles: command.roles, + memberStatus: MemberStatusEnum.ACTIVE, + }); + } + + private async isMember(userId: string): Promise { + return !!(await this.memberRepository.findMemberByUserId(this.organizationId, userId)); + } +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.command.ts b/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.command.ts new file mode 100644 index 00000000000..77aac47f511 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.command.ts @@ -0,0 +1,17 @@ +import { MemberRoleEnum } from '@notifire/shared'; +import { IsDefined, IsEnum } from 'class-validator'; +import { OrganizationCommand } from '../../../../../shared/commands/organization.command'; +import { CommandHelper } from '../../../../../shared/commands/command.helper'; + +export class ChangeMemberRoleCommand extends OrganizationCommand { + static create(data: ChangeMemberRoleCommand) { + return CommandHelper.create(ChangeMemberRoleCommand, data); + } + + @IsEnum(MemberRoleEnum) + @IsDefined() + role: MemberRoleEnum; + + @IsDefined() + memberId: string; +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.usecase.ts b/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.usecase.ts new file mode 100644 index 00000000000..7af88176339 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/change-member-role/change-member-role.usecase.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { OrganizationRepository, UserRepository, MemberRepository } from '@notifire/dal'; +import { MemberRoleEnum } from '@notifire/shared'; +import { ChangeMemberRoleCommand } from './change-member-role.command'; +import { ApiException } from '../../../../../shared/exceptions/api.exception'; + +@Injectable() +export class ChangeMemberRole { + constructor( + private organizationRepository: OrganizationRepository, + private userRepository: UserRepository, + private memberRepository: MemberRepository + ) {} + + async execute(command: ChangeMemberRoleCommand) { + const organization = await this.organizationRepository.findById(command.organizationId); + const user = await this.userRepository.findById(command.userId); + + const member = await this.memberRepository.findMemberById(organization._id, command.memberId); + if (!member) throw new ApiException('No member was found'); + + if (![MemberRoleEnum.MEMBER, MemberRoleEnum.ADMIN].includes(command.role)) { + throw new ApiException('Not supported role type'); + } + + /* if (organization._creatorId === member._userId && command.role === MemberRoleEnum.MEMBER) { + throw new ApiException('Could not remove admin permission to organization creator'); + } +*/ + const roles = [command.role]; + + await this.memberRepository.updateMemberRoles(organization._id, command.memberId, roles); + + return this.memberRepository.findMemberByUserId(organization._id, member._userId); + } +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.command.ts b/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.command.ts new file mode 100644 index 00000000000..876c3ebd95e --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.command.ts @@ -0,0 +1,8 @@ +import { OrganizationCommand } from '../../../../../shared/commands/organization.command'; +import { CommandHelper } from '../../../../../shared/commands/command.helper'; + +export class GetMembersCommand extends OrganizationCommand { + static create(data: GetMembersCommand) { + return CommandHelper.create(GetMembersCommand, data); + } +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.usecase.ts b/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.usecase.ts new file mode 100644 index 00000000000..53b6f376cd7 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/get-members/get-members.usecase.ts @@ -0,0 +1,14 @@ +import { Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { GetMembersCommand } from './get-members.command'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class GetMembers { + constructor(private organizationRepository: OrganizationRepository, private membersRepository: MemberRepository) {} + + async execute(command: GetMembersCommand) { + return await this.membersRepository.getOrganizationMembers(command.organizationId); + } +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.command.ts b/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.command.ts new file mode 100644 index 00000000000..f0e97ebb9ff --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.command.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { OrganizationCommand } from '../../../../../shared/commands/organization.command'; +import { CommandHelper } from '../../../../../shared/commands/command.helper'; + +export class RemoveMemberCommand extends OrganizationCommand { + static create(data: RemoveMemberCommand) { + return CommandHelper.create(RemoveMemberCommand, data); + } + + @IsString() + memberId: string; +} diff --git a/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.usecase.ts b/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.usecase.ts new file mode 100644 index 00000000000..d7b4066dd57 --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/membership/remove-member/remove-member.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { OrganizationRepository, MemberRepository } from '@notifire/dal'; +import { RemoveMemberCommand } from './remove-member.command'; +import { ApiException } from '../../../../../shared/exceptions/api.exception'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class RemoveMember { + constructor(private organizationRepository: OrganizationRepository, private memberRepository: MemberRepository) {} + + async execute(command: RemoveMemberCommand) { + const members = await this.memberRepository.getOrganizationMembers(command.organizationId); + const memberToRemove = members.find((i) => i._id === command.memberId); + + if (!memberToRemove) throw new NotFoundException('Member not found'); + if (memberToRemove._userId && memberToRemove._userId && memberToRemove._userId === command.userId) { + throw new ApiException('Cannot remove self from members'); + } + + await this.memberRepository.removeMemberById(command.organizationId, memberToRemove._id); + + return memberToRemove; + } +} diff --git a/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.command.ts b/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.command.ts new file mode 100644 index 00000000000..7d6e16ffbbe --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.command.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { CommandHelper } from '../../../../shared/commands/command.helper'; +import { OrganizationCommand } from '../../../../shared/commands/organization.command'; + +export class RemoveMemberCommand extends OrganizationCommand { + static create(data: RemoveMemberCommand) { + return CommandHelper.create(RemoveMemberCommand, data); + } + + @IsString() + memberId: string; +} diff --git a/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.usecase.ts b/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.usecase.ts new file mode 100644 index 00000000000..05fb8f0989d --- /dev/null +++ b/apps/api/src/app/organization/usecases/membership/remove-member/remove-member.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { MemberRepository, OrganizationRepository } from '@notifire/dal'; +import { RemoveMemberCommand } from './remove-member.command'; +import { ApiException } from '../../../../shared/exceptions/api.exception'; + +@Injectable({ + scope: Scope.REQUEST, +}) +export class RemoveMember { + constructor(private organizationRepository: OrganizationRepository, private memberRepository: MemberRepository) {} + + async execute(command: RemoveMemberCommand) { + const members = await this.memberRepository.getOrganizationMembers(command.organizationId); + const memberToRemove = members.find((i) => i._id === command.memberId); + + if (!memberToRemove) throw new NotFoundException('Member not found'); + if (memberToRemove._userId && memberToRemove._userId && memberToRemove._userId === command.userId) { + throw new ApiException('Cannot remove self from members'); + } + + await this.memberRepository.removeMemberById(command.organizationId, memberToRemove._id); + + return memberToRemove; + } +} diff --git a/apps/api/src/app/shared/commands/authenticated.command.ts b/apps/api/src/app/shared/commands/authenticated.command.ts new file mode 100644 index 00000000000..b436cb899f5 --- /dev/null +++ b/apps/api/src/app/shared/commands/authenticated.command.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export abstract class AuthenticatedCommand { + @IsNotEmpty() + public readonly userId: string; +} diff --git a/apps/api/src/app/shared/commands/command.helper.ts b/apps/api/src/app/shared/commands/command.helper.ts new file mode 100644 index 00000000000..d80f8ca726d --- /dev/null +++ b/apps/api/src/app/shared/commands/command.helper.ts @@ -0,0 +1,21 @@ +import { ClassConstructor, plainToClass } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { BadRequestException, flatten } from '@nestjs/common'; + +export class CommandHelper { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + static create(command: ClassConstructor, data: any): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const convertedObject = plainToClass(command, { + ...data, + }); + + const errors = validateSync(convertedObject); + if (errors?.length) { + const mappedErrors = flatten(errors.map((item) => Object.values(item.constraints))); + throw new BadRequestException(mappedErrors); + } + + return convertedObject; + } +} diff --git a/apps/api/src/app/shared/commands/organization.command.ts b/apps/api/src/app/shared/commands/organization.command.ts new file mode 100644 index 00000000000..f0750198c38 --- /dev/null +++ b/apps/api/src/app/shared/commands/organization.command.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty } from 'class-validator'; +import { AuthenticatedCommand } from './authenticated.command'; + +export abstract class OrganizationCommand extends AuthenticatedCommand { + @IsNotEmpty() + readonly organizationId: string; +} diff --git a/apps/api/src/app/shared/commands/project.command.ts b/apps/api/src/app/shared/commands/project.command.ts new file mode 100644 index 00000000000..4f3a7e76f40 --- /dev/null +++ b/apps/api/src/app/shared/commands/project.command.ts @@ -0,0 +1,31 @@ +import { IsNotEmpty } from 'class-validator'; + +export abstract class ApplicationWithUserCommand { + @IsNotEmpty() + readonly applicationId: string; + + @IsNotEmpty() + readonly organizationId: string; + + @IsNotEmpty() + readonly userId: string; +} + +export abstract class ApplicationWithSubscriber { + @IsNotEmpty() + readonly applicationId: string; + + @IsNotEmpty() + readonly organizationId: string; + + @IsNotEmpty() + readonly subscriberId: string; +} + +export abstract class ApplicationCommand { + @IsNotEmpty() + readonly applicationId: string; + + @IsNotEmpty() + readonly organizationId: string; +} diff --git a/apps/api/src/app/shared/constants.ts b/apps/api/src/app/shared/constants.ts new file mode 100644 index 00000000000..806cf48024b --- /dev/null +++ b/apps/api/src/app/shared/constants.ts @@ -0,0 +1,2 @@ +export const QUEUE_SERVICE = 'QueueService'; +export const DAL_SERVICE = 'DalService'; diff --git a/apps/api/src/app/shared/crud/mongoose-crud.service.ts b/apps/api/src/app/shared/crud/mongoose-crud.service.ts new file mode 100644 index 00000000000..df7bba73100 --- /dev/null +++ b/apps/api/src/app/shared/crud/mongoose-crud.service.ts @@ -0,0 +1,168 @@ +/* eslint-disable no-sequences */ +import { CrudRequest, CreateManyDto, CrudService } from '@nestjsx/crud'; +import { Model } from 'mongoose'; + +export class MongooseCrudService extends CrudService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(public model: Model) { + super(); + } + + buildQuery(req: CrudRequest) { + const { limit = 10, page = 1, filter = [], fields = [], sort = [], join = [], paramsFilter = [] } = req.parsed; + + let { offset: skip = 0 } = req.parsed; + if (page > 1) { + skip = (page - 1) * limit; + } + + const options = { + page, + skip, + limit, + // eslint-disable-next-line no-return-assign + sort: sort.reduce((acc, v) => ((acc[v.field] = v.order === 'ASC' ? 1 : -1), acc), {}), + populate: join.map((v) => v.field), + select: fields.join(' '), + }; + + const where = filter.reduce((acc, { field, operator, value }) => { + let cond = null; + switch (operator) { + case 'starts': + cond = new RegExp(`^${value}`, 'i'); + break; + case 'ends': + cond = new RegExp(`${value}$`, 'i'); + break; + case 'cont': + cond = new RegExp(`${value}`, 'i'); + break; + case 'excl': + cond = { $ne: new RegExp(`${value}`, 'i') }; + break; + case 'notin': + cond = { $nin: value }; + break; + case 'isnull': + cond = null; + break; + case 'notnull': + cond = { $ne: null }; + break; + case 'between': { + const [min, max] = value; + cond = { $gte: min, $lte: max }; + break; + } + default: + cond = { [`$${operator}`]: value }; + } + acc[field] = cond; + return acc; + }, {}); + + const idParam = paramsFilter.find((v) => v.field === 'id'); + return { options, where, id: idParam ? idParam.value : null }; + } + + async getMany(req: CrudRequest) { + const { options, where } = this.buildQuery(req); + const queryBuilder = this.model + .find() + .setOptions({ + ...options, + } as never) + .where({ + ...where, + }); + + options.populate.forEach((v) => { + queryBuilder.populate(v); + }); + + const data = await queryBuilder.exec(); + if (options.page) { + const total = await this.model.countDocuments(where); + return this.createPageInfo( + data.map((i) => JSON.parse(JSON.stringify(i))), + total, + options.limit, + options.skip + ); + } + return data; + } + + async getOne(req: CrudRequest): Promise { + const { options, where, id } = this.buildQuery(req); + const queryBuilder = this.model + .findById(id) + .setOptions({ + ...options, + } as never) + .where({ + ...where, + }); + options.populate.forEach((v) => { + queryBuilder.populate(v); + }); + + const data = await queryBuilder.exec(); + + if (!data) { + this.throwNotFoundException(this.model.modelName); + } + + return data; + } + + async createOne(req: CrudRequest, dto: T): Promise { + return await this.model.create(dto); + } + + async createMany(req: CrudRequest, dto: CreateManyDto): Promise { + return await this.model.insertMany(dto.bulk); + } + + async updateOne(req: CrudRequest, dto: T): Promise { + const { id } = this.buildQuery(req); + const data = await this.model.findByIdAndUpdate(id, dto, { + new: true, + runValidators: true, + }); + if (!data) { + this.throwNotFoundException(this.model.modelName); + } + + return data; + } + + async replaceOne(req: CrudRequest, dto: T): Promise { + const { id } = this.buildQuery(req); + const data = await this.model.replaceOne( + { + _id: id, + }, + dto + ); + + if (!data) { + this.throwNotFoundException(this.model.modelName); + } + + return this.model.findById(id); + } + + async deleteOne(req: CrudRequest): Promise { + const { id } = this.buildQuery(req); + const data = await this.model.findById(id); + if (!data) { + this.throwNotFoundException(this.model.modelName); + } + + await this.model.findByIdAndDelete(id); + + return data; + } +} diff --git a/apps/api/src/app/shared/exceptions/api.exception.ts b/apps/api/src/app/shared/exceptions/api.exception.ts new file mode 100644 index 00000000000..94acedff15d --- /dev/null +++ b/apps/api/src/app/shared/exceptions/api.exception.ts @@ -0,0 +1,3 @@ +import { BadRequestException } from '@nestjs/common'; + +export class ApiException extends BadRequestException {} diff --git a/apps/api/src/app/shared/framework/response.interceptor.ts b/apps/api/src/app/shared/framework/response.interceptor.ts new file mode 100644 index 00000000000..92cb9ab08bd --- /dev/null +++ b/apps/api/src/app/shared/framework/response.interceptor.ts @@ -0,0 +1,44 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { isObject, isArray } from 'lodash'; +import { classToPlain } from 'class-transformer'; + +export interface Response { + data: T; +} + +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { + intercept(context, next: CallHandler): Observable> { + if (context.getType() === 'graphql') return next.handle(); + + return next.handle().pipe( + map((data) => { + // For paginated results that already contain the data wrapper, return the whole object + if (data?.data) { + return { + ...data, + data: isObject(data.data) ? this.transformResponse(data.data) : data.data, + }; + } + + return { + data: isObject(data) ? this.transformResponse(data) : data, + }; + }) + ); + } + + private transformResponse(response) { + if (isArray(response)) { + return response.map((item) => this.transformToPlain(item)); + } + + return this.transformToPlain(response); + } + + private transformToPlain(plainOrClass) { + return plainOrClass && plainOrClass.constructor !== Object ? classToPlain(plainOrClass) : plainOrClass; + } +} diff --git a/apps/api/src/app/shared/framework/user.decorator.ts b/apps/api/src/app/shared/framework/user.decorator.ts new file mode 100644 index 00000000000..16f852bdab3 --- /dev/null +++ b/apps/api/src/app/shared/framework/user.decorator.ts @@ -0,0 +1,50 @@ +import { createParamDecorator, UnauthorizedException } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; + +export const UserSession = createParamDecorator((data, ctx) => { + let req; + if (ctx.getType() === 'graphql') { + req = ctx.getArgs()[2].req; + } else { + req = ctx.switchToHttp().getRequest(); + } + + if (req.user) return req.user; + + if (req.headers) { + if (req.headers.authorization) { + const tokenParts = req.headers.authorization.split(' '); + if (tokenParts[0] !== 'Bearer') throw new UnauthorizedException('bad_token'); + if (!tokenParts[1]) throw new UnauthorizedException('bad_token'); + + const user = jwt.decode(tokenParts[1]); + return user; + } + } + + return null; +}); + +export const SubscriberSession = createParamDecorator((data, ctx) => { + let req; + if (ctx.getType() === 'graphql') { + req = ctx.getArgs()[2].req; + } else { + req = ctx.switchToHttp().getRequest(); + } + + if (req.user) return req.user; + + if (req.headers) { + if (req.headers.authorization) { + const tokenParts = req.headers.authorization.split(' '); + if (tokenParts[0] !== 'Bearer') throw new UnauthorizedException('bad_token'); + if (!tokenParts[1]) throw new UnauthorizedException('bad_token'); + + const user = jwt.decode(tokenParts[1]); + return user; + } + } + + return null; +}); diff --git a/apps/api/src/app/shared/helpers/content.service.spec.ts b/apps/api/src/app/shared/helpers/content.service.spec.ts new file mode 100644 index 00000000000..bb1e91206ef --- /dev/null +++ b/apps/api/src/app/shared/helpers/content.service.spec.ts @@ -0,0 +1,184 @@ +import { expect } from 'chai'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { ContentService } from './content.service'; + +describe('ContentService', function () { + describe('replaceVariables', function () { + it('should replace duplicates entries', function () { + const variables = { + firstName: 'Name', + lastName: 'Last Name', + }; + + const contentService = new ContentService(); + const modified = contentService.replaceVariables( + '{{firstName}} is the first {{firstName}} of {{firstName}}', + variables + ); + expect(modified).to.equal('Name is the first Name of Name'); + }); + + it('should replace multiple variables', function () { + const variables = { + firstName: 'Name', + lastName: 'Last Name', + }; + + const contentService = new ContentService(); + const modified = contentService.replaceVariables( + '{{firstName}} is the first {{lastName}} of {{firstName}}', + variables + ); + expect(modified).to.equal('Name is the first Last Name of Name'); + }); + + it('should not manipulate variables for text without them', function () { + const variables = { + firstName: 'Name', + lastName: 'Last Name', + }; + + const contentService = new ContentService(); + const modified = contentService.replaceVariables('This is a text without variables', variables); + expect(modified).to.equal('This is a text without variables'); + }); + }); + + describe('extractVariables', function () { + it('should not find any variables', function () { + const contentService = new ContentService(); + const extractVariables = contentService.extractVariables( + 'This is a text without variables {{ asdasdas }} {{ aasdasda sda{ {na}}' + ); + expect(extractVariables.length).to.equal(0); + expect(Array.isArray(extractVariables)).to.equal(true); + }); + + it('should extract all valid variables', function () { + const contentService = new ContentService(); + const extractVariables = contentService.extractVariables( + ' {{name}} d {{lastName}} dd {{_validName}} {{not valid}} aa {{0notValid}}tr {{organization_name}}' + ); + expect(extractVariables.length).to.equal(4); + expect(extractVariables).to.include('_validName'); + expect(extractVariables).to.include('lastName'); + expect(extractVariables).to.include('name'); + expect(extractVariables).to.include('organization_name'); + }); + }); + + describe('extractMessageVariables', function () { + it('should not extract variables', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.IN_APP, + subject: 'Test', + content: 'Text', + }, + ]); + expect(variables.length).to.equal(0); + }); + + it('should extract subject variables', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test {{firstName}}', + content: [], + }, + ]); + expect(variables.length).to.equal(2); + expect(variables).to.include('firstName'); + }); + + it('should add $phone when SMS channel Exists', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.IN_APP, + subject: 'Test', + content: 'Text', + }, + { + type: ChannelTypeEnum.SMS, + content: 'Text', + }, + ]); + expect(variables.length).to.equal(1); + expect(variables[0]).to.equal('$phone'); + }); + + it('should add $email when EMAIL channel Exists', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test', + content: 'Text', + }, + { + type: ChannelTypeEnum.IN_APP, + content: 'Text', + }, + ]); + expect(variables.length).to.equal(1); + expect(variables[0]).to.equal('$email'); + }); + + it('should extract email content variables', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test {{firstName}}', + content: [ + { + content: 'Test of {{lastName}}', + type: 'text', + }, + { + content: 'Test of {{lastName}}', + type: 'text', + url: 'Test of {{url}}', + }, + ], + }, + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test {{email}}', + content: [ + { + content: 'Test of {{lastName}}', + type: 'text', + }, + { + content: 'Test of {{lastName}}', + type: 'text', + url: 'Test of {{url}}', + }, + ], + }, + ]); + expect(variables.length).to.equal(5); + expect(variables).to.include('lastName'); + expect(variables).to.include('url'); + expect(variables).to.include('firstName'); + expect(variables).to.include('email'); + }); + + it('should extract in-app content variables', function () { + const contentService = new ContentService(); + const variables = contentService.extractMessageVariables([ + { + type: ChannelTypeEnum.IN_APP, + content: '{{customVariables}}', + }, + ]); + + expect(variables.length).to.equal(1); + expect(variables).to.include('customVariables'); + }); + }); +}); diff --git a/apps/api/src/app/shared/helpers/content.service.ts b/apps/api/src/app/shared/helpers/content.service.ts new file mode 100644 index 00000000000..68ce0005f94 --- /dev/null +++ b/apps/api/src/app/shared/helpers/content.service.ts @@ -0,0 +1,77 @@ +import { ChannelTypeEnum, IMessageTemplate } from '@notifire/shared'; + +export class ContentService { + replaceVariables(content: string, variables: { [key: string]: string }) { + if (!content) return content; + let modifiedContent = content; + + for (const key in variables) { + if (!variables.hasOwnProperty(key)) continue; + modifiedContent = modifiedContent.replace(new RegExp(`{{${key}}}`, 'g'), variables[key]); + } + + return modifiedContent; + } + + extractVariables(content: string): string[] { + if (!content) return []; + + const regExp = /{{([a-zA-Z_][a-zA-Z0-9_-]*?)}}/gm; + const matchedItems = content.match(regExp); + + const result = []; + if (!matchedItems || !Array.isArray(matchedItems)) { + return result; + } + + for (const item of matchedItems) { + result.push(item.replace('{{', '').replace('}}', '')); + } + + return result; + } + + extractMessageVariables(messages: IMessageTemplate[]): string[] { + const variables = []; + + for (const text of this.messagesTextIterator(messages)) { + const extractedVariables = this.extractVariables(text); + variables.push(...extractedVariables); + } + + const hasSmsMessage = !!messages.find((i) => i.type === ChannelTypeEnum.SMS); + if (hasSmsMessage) { + variables.push('$phone'); + } + + const hasEmailMessage = !!messages.find((i) => i.type === ChannelTypeEnum.EMAIL); + if (hasEmailMessage) { + variables.push('$email'); + } + + return Array.from(new Set(variables)); + } + + private *messagesTextIterator(messages: IMessageTemplate[]): Generator { + for (const message of messages) { + if (message.type === ChannelTypeEnum.IN_APP) { + yield message.content as string; + + if (message?.cta?.data?.url) { + yield message.cta.data.url; + } + } else if (message.type === ChannelTypeEnum.SMS) { + yield message.content as string; + } else if (Array.isArray(message.content)) { + yield message.subject; + + for (const block of message.content) { + yield block.url; + yield block.content; + } + } else if (typeof message.content === 'string') { + yield message.content; + } + } + } +} diff --git a/apps/api/src/app/shared/helpers/email-normalization.service.ts b/apps/api/src/app/shared/helpers/email-normalization.service.ts new file mode 100644 index 00000000000..7293c9a604f --- /dev/null +++ b/apps/api/src/app/shared/helpers/email-normalization.service.ts @@ -0,0 +1,48 @@ +const PLUS_ONLY = /\+.*$/; +const PLUS_AND_DOT = /\.|\+.*$/g; +const normalizeableProviders = { + '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 (normalizeableProviders.hasOwnProperty(domain)) { + if (normalizeableProviders[domain].hasOwnProperty('cut')) { + username = username.replace(normalizeableProviders[domain].cut, ''); + } + + if (normalizeableProviders[domain].hasOwnProperty('aliasOf')) { + domain = normalizeableProviders[domain].aliasOf; + } + } + + return `${username}@${domain}`; +} diff --git a/apps/api/src/app/shared/helpers/regex.service.ts b/apps/api/src/app/shared/helpers/regex.service.ts new file mode 100644 index 00000000000..56da4f3c4f7 --- /dev/null +++ b/apps/api/src/app/shared/helpers/regex.service.ts @@ -0,0 +1,5 @@ +export function escapeRegExp(text: string): string { + if (!text) return text; + + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/api/src/app/shared/services/analytics/analytics.service.ts b/apps/api/src/app/shared/services/analytics/analytics.service.ts new file mode 100644 index 00000000000..f4218f5c8aa --- /dev/null +++ b/apps/api/src/app/shared/services/analytics/analytics.service.ts @@ -0,0 +1,51 @@ +import * as MixpanelInstance from 'mixpanel'; + +import { Mixpanel } from 'mixpanel'; +import { UserEntity } from '@notifire/dal'; + +export class AnalyticsService { + private mixpanel: Mixpanel; + + async initialize() { + if (process.env.MIXPANEL_TOKEN) { + this.mixpanel = MixpanelInstance.init(process.env.MIXPANEL_TOKEN); + } + } + + alias(distinctId: string, userId: string) { + if (!this.analyticsEnabled) return; + + this.mixpanel.alias(distinctId, userId); + } + + upsertUser(user: UserEntity, distinctId: string) { + if (!this.analyticsEnabled) return; + + this.mixpanel.people.set(distinctId, { + $first_name: user.firstName, + $last_name: user.lastName, + $created: user.createdAt || new Date(), + $email: user.email, + userId: user._id, + }); + } + + setValue(userId: string, propertyName: string, value: string | number) { + if (!this.analyticsEnabled) return; + + this.mixpanel.people.set(userId, propertyName, value); + } + + track(name: string, userId: string, data: object = {}) { + if (!this.analyticsEnabled) return; + + this.mixpanel.track(name, { + distinct_id: userId, + ...data, + }); + } + + private get analyticsEnabled() { + return process.env.NODE_ENV !== 'test' && this.mixpanel; + } +} diff --git a/apps/api/src/app/shared/services/cron/cron.service.ts b/apps/api/src/app/shared/services/cron/cron.service.ts new file mode 100644 index 00000000000..227719ed957 --- /dev/null +++ b/apps/api/src/app/shared/services/cron/cron.service.ts @@ -0,0 +1,30 @@ +// eslint-disable-next-line import/no-named-default, import/no-duplicates +import { AgendaConfig, Processor } from 'agenda'; +// eslint-disable-next-line import/no-duplicates +import * as Agenda from 'agenda'; + +export class CronService { + private agenda: Agenda.Agenda = new (Agenda as any)({ + db: { + address: this.config.mongoUrl, + }, + } as AgendaConfig); + + constructor(private config: { mongoUrl: string }) {} + + async initialize() { + await this.agenda.start(); + } + + define(name: string, callback: Processor): void { + this.agenda.define(name, callback); + } + + async processEvery(name: string, interval: string) { + await this.agenda.every(interval, name, {}, {}); + } + + async processNow(name: string) { + await this.agenda.now(name, null); + } +} diff --git a/apps/api/src/app/shared/services/helper/helper.service.ts b/apps/api/src/app/shared/services/helper/helper.service.ts new file mode 100644 index 00000000000..976f00d76ef --- /dev/null +++ b/apps/api/src/app/shared/services/helper/helper.service.ts @@ -0,0 +1,18 @@ +import { v1 as uuidv1 } from 'uuid'; + +export function createGuid(): string { + return uuidv1(); +} + +export function capitalize(text: string) { + if (typeof text !== 'string') return ''; + + return text.charAt(0).toUpperCase() + text.slice(1); +} + +export function getFileExtensionFromPath(filePath: string): string { + const regexp = /\.([0-9a-z]+)(?:[?#]|$)/i; + const extension = filePath.match(regexp); + + return extension && extension[1]; +} diff --git a/apps/api/src/app/shared/services/mail/mail.service.ts b/apps/api/src/app/shared/services/mail/mail.service.ts new file mode 100644 index 00000000000..cc855fae386 --- /dev/null +++ b/apps/api/src/app/shared/services/mail/mail.service.ts @@ -0,0 +1,56 @@ +import * as sgApi from '@sendgrid/mail'; + +export type IEmailRecipient = string | { name?: string; email: string }; + +export interface ISendMail { + to: IEmailRecipient | IEmailRecipient[]; + from: { + name: string; + email: string; + }; + subject?: string; + text?: string; + html?: string; + templateId?: string; + params?: { + [key: string]: string | any[] | any; + }; +} + +export class MailService { + private sendgrid = sgApi; + + constructor() { + this.sendgrid.setApiKey(process.env.SENDGRID_API_KEY); + } + + async sendMail(mail: ISendMail) { + if (!mail.templateId && !mail.subject) throw new Error('Either templateId or subject must be present'); + if (process.env.NODE_ENV === 'test') return null; + + const mailObject: sgApi.MailDataRequired = { + subject: mail.subject, + dynamicTemplateData: mail.params, + to: mail.to, + from: { + name: mail.from.name, + email: mail.from.email, + }, + templateId: undefined, + }; + + if (mail.templateId) { + mailObject.templateId = mail.templateId; + } + + if (mail.text) { + mailObject.text = mail.text; + } + + if (mail.html) { + mailObject.html = mail.html; + } + + return await this.sendgrid.send(mailObject as never, false); + } +} diff --git a/apps/api/src/app/shared/services/queue/index.ts b/apps/api/src/app/shared/services/queue/index.ts new file mode 100644 index 00000000000..532c20b83fb --- /dev/null +++ b/apps/api/src/app/shared/services/queue/index.ts @@ -0,0 +1 @@ +export * from './queue.service'; diff --git a/apps/api/src/app/shared/services/queue/queue.interface.ts b/apps/api/src/app/shared/services/queue/queue.interface.ts new file mode 100644 index 00000000000..fe32c2f31c0 --- /dev/null +++ b/apps/api/src/app/shared/services/queue/queue.interface.ts @@ -0,0 +1,9 @@ +export interface IDemoQueuePayload { + userId: string; +} + +export interface IWsQueuePayload { + userId: string; + event: string; + payload: any; +} diff --git a/apps/api/src/app/shared/services/queue/queue.service.ts b/apps/api/src/app/shared/services/queue/queue.service.ts new file mode 100644 index 00000000000..bef42f63dcf --- /dev/null +++ b/apps/api/src/app/shared/services/queue/queue.service.ts @@ -0,0 +1,38 @@ +import * as Bull from 'bull'; +import { Queue } from 'bull'; +import { IWsQueuePayload } from './queue.interface'; + +export const WS_SOCKET_QUEUE = 'ws_socket_queue'; + +export class QueueService { + private bullConfig: Bull.QueueOptions = { + settings: { + lockDuration: 90000, + }, + redis: { + db: Number(process.env.REDIS_DB_INDEX), + port: Number(process.env.REDIS_PORT), + host: process.env.REDIS_HOST, + connectTimeout: 50000, + keepAlive: 30000, + family: 4, + }, + }; + + public wsSocketQueue: Queue = new Bull(WS_SOCKET_QUEUE, this.bullConfig) as Queue; + + async getJobStats(type: 'ws_socket_queue'): Promise<{ waiting: number; active: number }> { + if (type === WS_SOCKET_QUEUE) { + return { + waiting: await this.wsSocketQueue.getWaitingCount(), + active: await this.wsSocketQueue.getActiveCount(), + }; + } + + throw new Error(`Unexpected type ${type}`); + } + + async cleanAllQueues() { + await this.wsSocketQueue.empty(); + } +} diff --git a/apps/api/src/app/shared/services/sms/sms.service.ts b/apps/api/src/app/shared/services/sms/sms.service.ts new file mode 100644 index 00000000000..a53a13fc74b --- /dev/null +++ b/apps/api/src/app/shared/services/sms/sms.service.ts @@ -0,0 +1,19 @@ +import * as twilio from 'twilio'; + +export class SmsService { + private provider = process.env.NODE_ENV === 'test' ? null : twilio(this.SID, this.AUTH_TOKEN); + + constructor(private AUTH_TOKEN: string, private SID: string) {} + + async sendMessage(to: string, from: string, body: string) { + if (process.env.NODE_ENV === 'test') { + return null; + } + + return await this.provider.messages.create({ + body, + to, + from, + }); + } +} diff --git a/apps/api/src/app/shared/services/storage/storage.service.ts b/apps/api/src/app/shared/services/storage/storage.service.ts new file mode 100644 index 00000000000..08a41a2a935 --- /dev/null +++ b/apps/api/src/app/shared/services/storage/storage.service.ts @@ -0,0 +1,33 @@ +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { URL } from 'url'; + +export interface IFilePath { + path: string; + name: string; +} + +export class StorageService { + private s3 = new S3Client({ + region: process.env.S3_REGION, + endpoint: process.env.S3_LOCAL_STACK || undefined, + forcePathStyle: true, + }); + + async getSignedUrl(key: string, contentType?: string) { + const command = new PutObjectCommand({ + Key: key, + Bucket: process.env.S3_BUCKET_NAME, + ACL: 'public-read', + ContentType: contentType, + }); + + const signedUrl = await getSignedUrl(this.s3, command, { expiresIn: 3600 }); + const parsedUrl = new URL(signedUrl); + + return { + signedUrl, + path: `${parsedUrl.origin}${parsedUrl.pathname}`, + }; + } +} diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts new file mode 100644 index 00000000000..094f10b2c2d --- /dev/null +++ b/apps/api/src/app/shared/shared.module.ts @@ -0,0 +1,71 @@ +import { Module } from '@nestjs/common'; +import { + DalService, + UserRepository, + OrganizationRepository, + ApplicationRepository, + NotificationTemplateRepository, + SubscriberRepository, + NotificationRepository, + MessageRepository, + NotificationGroupRepository, + MessageTemplateRepository, + MemberRepository, + LogRepository, +} from '@notifire/dal'; +import { AnalyticsService } from './services/analytics/analytics.service'; +import { MailService } from './services/mail/mail.service'; +import { QueueService } from './services/queue'; +import { StorageService } from './services/storage/storage.service'; + +const DAL_MODELS = [ + UserRepository, + OrganizationRepository, + ApplicationRepository, + NotificationTemplateRepository, + SubscriberRepository, + NotificationRepository, + MessageRepository, + MessageTemplateRepository, + NotificationGroupRepository, + MemberRepository, + LogRepository, +]; + +const dalService = new DalService(); +export const ANALYTICS_SERVICE = 'AnalyticsService'; + +const PROVIDERS = [ + { + provide: QueueService, + useFactory: () => { + return new QueueService(); + }, + }, + { + provide: DalService, + useFactory: async () => { + await dalService.connect(process.env.MONGO_URL); + return dalService; + }, + }, + ...DAL_MODELS, + StorageService, + { + provide: ANALYTICS_SERVICE, + useFactory: async () => { + const analyticsService = new AnalyticsService(); + await analyticsService.initialize(); + + return analyticsService; + }, + }, + MailService, +]; + +@Module({ + imports: [], + providers: [...PROVIDERS], + exports: [...PROVIDERS], +}) +export class SharedModule {} diff --git a/apps/api/src/app/storage/e2e/get-signed-url.e2e.ts b/apps/api/src/app/storage/e2e/get-signed-url.e2e.ts new file mode 100644 index 00000000000..9a2eb9bf9cf --- /dev/null +++ b/apps/api/src/app/storage/e2e/get-signed-url.e2e.ts @@ -0,0 +1,21 @@ +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; + +describe('Get Signed Url - /storage/upload-url (GET)', function () { + let session: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should return an S3 signed URL', async function () { + const { + body: { data }, + } = await session.testAgent.get('/v1/storage/upload-url?extension=jpg'); + + expect(data.path).to.contain('.jpg'); + expect(data.signedUrl).to.contain('.jpg'); + expect(data.signedUrl).to.contain(`${session.organization._id}/${session.application._id}`); + }); +}); diff --git a/apps/api/src/app/storage/storage.controller.ts b/apps/api/src/app/storage/storage.controller.ts new file mode 100644 index 00000000000..95155de58fd --- /dev/null +++ b/apps/api/src/app/storage/storage.controller.ts @@ -0,0 +1,25 @@ +import { Body, ClassSerializerInterceptor, Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common'; +import { IJwtPayload } from '@notifire/shared'; +import { GetSignedUrl } from './usecases/get-signed-url/get-signed-url.usecase'; +import { GetSignedUrlCommand } from './usecases/get-signed-url/get-signed-url.command'; +import { UserSession } from '../shared/framework/user.decorator'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; + +@Controller('/storage') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class StorageController { + constructor(private getSignedUrlUsecase: GetSignedUrl) {} + + @Get('/upload-url') + async signedUrl(@UserSession() user: IJwtPayload, @Query('extension') extension: string) { + return await this.getSignedUrlUsecase.execute( + GetSignedUrlCommand.create({ + applicationId: user.applicationId, + organizationId: user.organizationId, + userId: user._id, + extension, + }) + ); + } +} diff --git a/apps/api/src/app/storage/storage.module.ts b/apps/api/src/app/storage/storage.module.ts new file mode 100644 index 00000000000..1855ee2dd3d --- /dev/null +++ b/apps/api/src/app/storage/storage.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { StorageController } from './storage.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [StorageController], +}) +export class StorageModule {} diff --git a/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.command.ts b/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.command.ts new file mode 100644 index 00000000000..f13d5f27a25 --- /dev/null +++ b/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.command.ts @@ -0,0 +1,13 @@ +import { IsEnum, IsIn, IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithUserCommand } from '../../../shared/commands/project.command'; + +export class GetSignedUrlCommand extends ApplicationWithUserCommand { + static create(data: GetSignedUrlCommand) { + return CommandHelper.create(GetSignedUrlCommand, data); + } + + @IsString() + @IsIn(['jpg', 'png', 'jpeg']) + extension: string; +} diff --git a/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.usecase.ts b/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.usecase.ts new file mode 100644 index 00000000000..bb2ecba285b --- /dev/null +++ b/apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.usecase.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import * as hat from 'hat'; +import { StorageService } from '../../../shared/services/storage/storage.service'; +import { GetSignedUrlCommand } from './get-signed-url.command'; + +const mimeTypes = { + jpeg: 'image/jpeg', + png: 'image/png', +}; + +@Injectable() +export class GetSignedUrl { + constructor(private storageService: StorageService) {} + + async execute( + command: GetSignedUrlCommand + ): Promise<{ + signedUrl: string; + path: string; + }> { + const path = `${command.organizationId}/${command.applicationId}/${hat()}.${command.extension}`; + + const response = await this.storageService.getSignedUrl(path, mimeTypes[command.extension]); + + return response; + } +} diff --git a/apps/api/src/app/storage/usecases/index.ts b/apps/api/src/app/storage/usecases/index.ts new file mode 100644 index 00000000000..4a3311405f1 --- /dev/null +++ b/apps/api/src/app/storage/usecases/index.ts @@ -0,0 +1,6 @@ +import { GetSignedUrl } from './get-signed-url/get-signed-url.usecase'; + +export const USE_CASES = [ + GetSignedUrl, + // +]; diff --git a/apps/api/src/app/subscribers/subscribers.module.ts b/apps/api/src/app/subscribers/subscribers.module.ts new file mode 100644 index 00000000000..e420dc7bdce --- /dev/null +++ b/apps/api/src/app/subscribers/subscribers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { SharedModule } from '../shared/shared.module'; +import { USE_CASES } from './usecases'; + +@Module({ + imports: [SharedModule, TerminusModule], + controllers: [], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class SubscribersModule {} diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts new file mode 100644 index 00000000000..d47cd1c8cec --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts @@ -0,0 +1,29 @@ +import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationCommand } from '../../../shared/commands/project.command'; + +export class CreateSubscriberCommand extends ApplicationCommand { + static create(data: CreateSubscriberCommand) { + return CommandHelper.create(CreateSubscriberCommand, data); + } + + @IsString() + @IsDefined() + subscriberId: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + firstName?: string; + + @IsString() + @IsOptional() + lastName?: string; + + @IsString() + @IsOptional() + phone?: string; +} diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.spec.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.spec.ts new file mode 100644 index 00000000000..2a1c7355b63 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.spec.ts @@ -0,0 +1,60 @@ +import { Test } from '@nestjs/testing'; +import { UserSession } from '@notifire/testing'; +import { expect } from 'chai'; +import { CreateSubscriber } from './create-subscriber.usecase'; +import { SharedModule } from '../../../shared/shared.module'; +import { CreateSubscriberCommand } from './create-subscriber.command'; +import { SubscribersModule } from '../../subscribers.module'; + +describe('Create Subscriber', function () { + let useCase: CreateSubscriber; + let session: UserSession; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [SharedModule, SubscribersModule], + providers: [], + }).compile(); + + session = new UserSession(); + await session.initialize(); + + useCase = moduleRef.get(CreateSubscriber); + }); + + it('should create a subscriber', async function () { + const result = await useCase.execute( + CreateSubscriberCommand.create({ + organizationId: session.organization._id, + applicationId: session.application._id, + subscriberId: '1234', + email: 'dima@asdasdas.com', + firstName: 'ASDAS', + }) + ); + }); + + it('should update the subscriber when same id provided', async function () { + await useCase.execute( + CreateSubscriberCommand.create({ + organizationId: session.organization._id, + applicationId: session.application._id, + subscriberId: '1234', + email: 'dima@asdasdas.com', + firstName: 'First Name', + }) + ); + + const result = await useCase.execute( + CreateSubscriberCommand.create({ + organizationId: session.organization._id, + applicationId: session.application._id, + subscriberId: '1234', + email: 'dima@asdasdas.com', + firstName: 'Second Name', + }) + ); + + expect(result.firstName).to.equal('Second Name'); + }); +}); diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts new file mode 100644 index 00000000000..c61f3d5b220 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { SubscriberRepository } from '@notifire/dal'; +import { CreateSubscriberCommand } from './create-subscriber.command'; +import { UpdateSubscriber, UpdateSubscriberCommand } from '../update-subscriber'; + +@Injectable() +export class CreateSubscriber { + constructor(private subscriberRepository: SubscriberRepository, private updateSubscriber: UpdateSubscriber) {} + + async execute(command: CreateSubscriberCommand) { + let subscriber = await this.subscriberRepository.findBySubscriberId(command.applicationId, command.subscriberId); + + if (!subscriber) { + subscriber = await this.subscriberRepository.create({ + _applicationId: command.applicationId, + _organizationId: command.organizationId, + firstName: command.firstName, + lastName: command.lastName, + subscriberId: command.subscriberId, + email: command.email, + phone: command.phone, + }); + } else { + subscriber = await this.updateSubscriber.execute( + UpdateSubscriberCommand.create({ + applicationId: command.applicationId, + organizationId: command.organizationId, + firstName: command.firstName, + lastName: command.lastName, + subscriberId: command.subscriberId, + email: command.email, + phone: command.phone, + }) + ); + } + + return subscriber; + } +} diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/index.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/index.ts new file mode 100644 index 00000000000..b63793678d2 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/index.ts @@ -0,0 +1,2 @@ +export * from './create-subscriber.command'; +export * from './create-subscriber.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/index.ts b/apps/api/src/app/subscribers/usecases/index.ts new file mode 100644 index 00000000000..6ec268d8f4c --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/index.ts @@ -0,0 +1,4 @@ +import { CreateSubscriber } from './create-subscriber'; +import { UpdateSubscriber } from './update-subscriber'; + +export const USE_CASES = [CreateSubscriber, UpdateSubscriber]; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/index.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/index.ts new file mode 100644 index 00000000000..48db0462947 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/index.ts @@ -0,0 +1,2 @@ +export * from './update-subscriber.command'; +export * from './update-subscriber.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts new file mode 100644 index 00000000000..9a11eb51e86 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts @@ -0,0 +1,26 @@ +import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationCommand } from '../../../shared/commands/project.command'; + +export class UpdateSubscriberCommand extends ApplicationCommand { + static create(data: UpdateSubscriberCommand) { + return CommandHelper.create(UpdateSubscriberCommand, data); + } + + @IsString() + subscriberId: string; + + @IsOptional() + firstName?: string; + + @IsOptional() + lastName?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; +} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.spec.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.spec.ts new file mode 100644 index 00000000000..eadaa3a3e07 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.spec.ts @@ -0,0 +1,42 @@ +import { SubscriberRepository } from '@notifire/dal'; +import { UserSession, SubscribersService } from '@notifire/testing'; +import { Test } from '@nestjs/testing'; +import { expect } from 'chai'; +import { SharedModule } from '../../../shared/shared.module'; +import { UpdateSubscriber } from './update-subscriber.usecase'; +import { UpdateSubscriberCommand } from './update-subscriber.command'; + +describe('Update Subscriber', function () { + let useCase: UpdateSubscriber; + let session: UserSession; + const subscriberRepository = new SubscriberRepository(); + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [SharedModule], + providers: [UpdateSubscriber], + }).compile(); + + session = new UserSession(); + await session.initialize(); + + useCase = moduleRef.get(UpdateSubscriber); + }); + + it('should update subscribers name', async function () { + const subscriberService = new SubscribersService(session.organization._id, session.application._id); + const subscriber = await subscriberService.createSubscriber(); + await useCase.execute( + UpdateSubscriberCommand.create({ + organizationId: subscriber._organizationId, + subscriberId: subscriber.subscriberId, + lastName: 'Test Last Name', + applicationId: session.application._id, + }) + ); + + const updatedSubscriber = await subscriberRepository.findById(subscriber._id); + expect(updatedSubscriber.lastName).to.equal('Test Last Name'); + expect(updatedSubscriber.firstName).to.equal(subscriber.firstName); + expect(updatedSubscriber.email).to.equal(subscriber.email); + }); +}); diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts new file mode 100644 index 00000000000..322ea8baeaf --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { SubscriberEntity, SubscriberRepository } from '@notifire/dal'; +import { UpdateSubscriberCommand } from './update-subscriber.command'; +import { ApiException } from '../../../shared/exceptions/api.exception'; + +@Injectable() +export class UpdateSubscriber { + constructor(private subscriberRepository: SubscriberRepository) {} + + async execute(command: UpdateSubscriberCommand) { + const foundSubscriber = await this.subscriberRepository.findBySubscriberId( + command.applicationId, + command.subscriberId + ); + + if (!foundSubscriber) { + throw new ApiException(`SubscriberId: ${command.subscriberId} not found`); + } + + const updatePayload: Partial = {}; + if (command.email != null) { + updatePayload.email = command.email; + } + + if (command.firstName != null) { + updatePayload.firstName = command.firstName; + } + + if (command.lastName != null) { + updatePayload.lastName = command.lastName; + } + + await this.subscriberRepository.update( + { + _id: foundSubscriber, + }, + { $set: updatePayload } + ); + + return { + ...foundSubscriber, + ...updatePayload, + }; + } +} diff --git a/apps/api/src/app/testing/dtos/seed-data.dto.ts b/apps/api/src/app/testing/dtos/seed-data.dto.ts new file mode 100644 index 00000000000..400b8e7de06 --- /dev/null +++ b/apps/api/src/app/testing/dtos/seed-data.dto.ts @@ -0,0 +1,8 @@ +import { UserEntity } from '@notifire/dal'; + +export class SeedDataBodyDto {} + +export interface ISeedDataResponseDto { + token: string; + user: UserEntity; +} diff --git a/apps/api/src/app/testing/testing.controller.ts b/apps/api/src/app/testing/testing.controller.ts new file mode 100644 index 00000000000..322aef30069 --- /dev/null +++ b/apps/api/src/app/testing/testing.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, NotFoundException, Post } from '@nestjs/common'; +import { DalService } from '@notifire/dal'; +import { IUserEntity } from '@notifire/shared'; +import { ISeedDataResponseDto, SeedDataBodyDto } from './dtos/seed-data.dto'; +import { SeedData } from './usecases/seed-data/seed-data.usecase'; +import { SeedDataCommand } from './usecases/seed-data/seed-data.command'; +import { CreateSession } from './usecases/create-session/create-session.usecase'; +import { CreateSessionCommand } from './usecases/create-session/create-session.command'; + +@Controller('/testing') +export class TestingController { + constructor( + private seedDataUsecase: SeedData, + private dalService: DalService, + private createSessionUsecase: CreateSession + ) {} + + @Post('/clear-db') + async clearDB(@Body() body: SeedDataBodyDto): Promise<{ ok: boolean }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + await this.dalService.destroy(); + + return { + ok: true, + }; + } + + /** + * Used for seeding data for client e2e tests, + * Currently just creates a new user session and returns signed JWT + */ + @Post('/session') + async getSession(@Body() body: SeedDataBodyDto): Promise { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + const command = CreateSessionCommand.create({}); + + return await this.createSessionUsecase.execute(command); + } + + @Post('/seed') + async seedData(@Body() body: SeedDataBodyDto): Promise<{ password_user: IUserEntity }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + const command = SeedDataCommand.create({}); + + return await this.seedDataUsecase.execute(command); + } +} diff --git a/apps/api/src/app/testing/testing.module.ts b/apps/api/src/app/testing/testing.module.ts new file mode 100644 index 00000000000..5b6b34a0ac1 --- /dev/null +++ b/apps/api/src/app/testing/testing.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { TestingController } from './testing.controller'; +import { SharedModule } from '../shared/shared.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [SharedModule, AuthModule], + providers: [...USE_CASES], + controllers: [TestingController], +}) +export class TestingModule {} diff --git a/apps/api/src/app/testing/usecases/create-session/create-session.command.ts b/apps/api/src/app/testing/usecases/create-session/create-session.command.ts new file mode 100644 index 00000000000..7b6df95e098 --- /dev/null +++ b/apps/api/src/app/testing/usecases/create-session/create-session.command.ts @@ -0,0 +1,7 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class CreateSessionCommand { + static create(data: CreateSessionCommand) { + return CommandHelper.create(CreateSessionCommand, data); + } +} diff --git a/apps/api/src/app/testing/usecases/create-session/create-session.usecase.ts b/apps/api/src/app/testing/usecases/create-session/create-session.usecase.ts new file mode 100644 index 00000000000..4460deada36 --- /dev/null +++ b/apps/api/src/app/testing/usecases/create-session/create-session.usecase.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { UserSession } from '@notifire/testing'; +import { CreateSessionCommand } from './create-session.command'; + +@Injectable() +export class CreateSession { + async execute(command: CreateSessionCommand) { + const userSession = new UserSession(); + userSession.testServer = null; + await userSession.initialize(); + + return { + token: userSession.token, + user: userSession.user, + }; + } +} diff --git a/apps/api/src/app/testing/usecases/index.ts b/apps/api/src/app/testing/usecases/index.ts new file mode 100644 index 00000000000..289fa8ac809 --- /dev/null +++ b/apps/api/src/app/testing/usecases/index.ts @@ -0,0 +1,4 @@ +import { SeedData } from './seed-data/seed-data.usecase'; +import { CreateSession } from './create-session/create-session.usecase'; + +export const USE_CASES = [SeedData, CreateSession]; diff --git a/apps/api/src/app/testing/usecases/seed-data/seed-data.command.ts b/apps/api/src/app/testing/usecases/seed-data/seed-data.command.ts new file mode 100644 index 00000000000..87d941e0fb1 --- /dev/null +++ b/apps/api/src/app/testing/usecases/seed-data/seed-data.command.ts @@ -0,0 +1,7 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class SeedDataCommand { + static create(data: SeedDataCommand) { + return CommandHelper.create(SeedDataCommand, data); + } +} 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 new file mode 100644 index 00000000000..87182730aee --- /dev/null +++ b/apps/api/src/app/testing/usecases/seed-data/seed-data.usecase.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import * as faker from 'faker'; +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'; + +@Injectable() +export class SeedData { + constructor(private authService: AuthService, private userRegister: UserRegister) {} + + async execute(command: SeedDataCommand) { + const { user } = await this.userRegister.execute( + UserRegisterCommand.create({ + email: 'test-user-1@example.com', + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + password: '123qwe!@#', + organizationName: 'Test Organization', + }) + ); + + return { + password_user: user, + }; + } +} diff --git a/apps/api/src/app/user/dtos/get-my-profile.ts b/apps/api/src/app/user/dtos/get-my-profile.ts new file mode 100644 index 00000000000..280cfea17d1 --- /dev/null +++ b/apps/api/src/app/user/dtos/get-my-profile.ts @@ -0,0 +1,3 @@ +import { UserEntity } from '@notifire/dal'; + +export type IGetMyProfileDto = UserEntity; diff --git a/apps/api/src/app/user/usecases/create-user/create-user.dto.ts b/apps/api/src/app/user/usecases/create-user/create-user.dto.ts new file mode 100644 index 00000000000..ed5adcd5f21 --- /dev/null +++ b/apps/api/src/app/user/usecases/create-user/create-user.dto.ts @@ -0,0 +1,23 @@ +import { AuthProviderEnum } from '@notifire/shared'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class CreateUserCommand { + static create(data: CreateUserCommand) { + return CommandHelper.create(CreateUserCommand, data); + } + + email: string; + + firstName: string; + + lastName: string; + + picture?: string; + + auth: { + profileId: string; + provider: AuthProviderEnum; + accessToken: string; + refreshToken: string; + }; +} diff --git a/apps/api/src/app/user/usecases/create-user/create-user.usecase.ts b/apps/api/src/app/user/usecases/create-user/create-user.usecase.ts new file mode 100644 index 00000000000..916708ba9f1 --- /dev/null +++ b/apps/api/src/app/user/usecases/create-user/create-user.usecase.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { UserEntity, UserRepository } from '@notifire/dal'; +import { CreateUserCommand } from './create-user.dto'; + +@Injectable() +export class CreateUser { + constructor(private readonly userRepository: UserRepository) {} + + async execute(data: CreateUserCommand): Promise { + const user = new UserEntity(); + + user.email = data.email ? data.email.toLowerCase() : null; + user.firstName = data.firstName ? data.firstName.toLowerCase() : null; + user.lastName = data.lastName ? data.lastName.toLowerCase() : data.lastName; + user.profilePicture = data.picture; + user.tokens = [ + { + providerId: data.auth.profileId, + provider: data.auth.provider, + accessToken: data.auth.accessToken, + refreshToken: data.auth.refreshToken, + valid: true, + lastUsed: null, + }, + ]; + + return await this.userRepository.create(user); + } +} diff --git a/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.dto.ts b/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.dto.ts new file mode 100644 index 00000000000..4cd424d2dbc --- /dev/null +++ b/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.dto.ts @@ -0,0 +1,8 @@ +import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class GetMyProfileCommand extends AuthenticatedCommand { + static create(data: GetMyProfileCommand) { + return CommandHelper.create(GetMyProfileCommand, data); + } +} diff --git a/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.usecase.ts b/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.usecase.ts new file mode 100644 index 00000000000..c756554406b --- /dev/null +++ b/apps/api/src/app/user/usecases/get-my-profile/get-my-profile.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from '@notifire/dal'; +import { GetMyProfileCommand } from './get-my-profile.dto'; + +@Injectable() +export class GetMyProfileUsecase { + constructor(private readonly userRepository: UserRepository) {} + + async execute(command: GetMyProfileCommand) { + return await this.userRepository.findById(command.userId); + } +} diff --git a/apps/api/src/app/user/usecases/index.ts b/apps/api/src/app/user/usecases/index.ts new file mode 100644 index 00000000000..e518928959a --- /dev/null +++ b/apps/api/src/app/user/usecases/index.ts @@ -0,0 +1,4 @@ +import { CreateUser } from './create-user/create-user.usecase'; +import { GetMyProfileUsecase } from './get-my-profile/get-my-profile.usecase'; + +export const USE_CASES = [CreateUser, GetMyProfileUsecase]; diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts new file mode 100644 index 00000000000..e9e3003bed2 --- /dev/null +++ b/apps/api/src/app/user/user.controller.ts @@ -0,0 +1,24 @@ +import { ClassSerializerInterceptor, Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { IJwtPayload } from '@notifire/shared'; +import { UserSession } from '../shared/framework/user.decorator'; +import { GetMyProfileUsecase } from './usecases/get-my-profile/get-my-profile.usecase'; +import { GetMyProfileCommand } from './usecases/get-my-profile/get-my-profile.dto'; +import { IGetMyProfileDto } from './dtos/get-my-profile'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; + +@Controller('/users') +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private getMyProfileUsecase: GetMyProfileUsecase) {} + + @Get('/me') + async getMyProfile(@UserSession() user: IJwtPayload): Promise { + const command = GetMyProfileCommand.create({ + userId: user._id, + }); + + return await this.getMyProfileUsecase.execute(command); + } +} diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts new file mode 100644 index 00000000000..75b03882a2c --- /dev/null +++ b/apps/api/src/app/user/user.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { USE_CASES } from './usecases'; +import { UsersController } from './user.controller'; + +@Module({ + imports: [SharedModule], + controllers: [UsersController], + providers: [...USE_CASES], + exports: [...USE_CASES], +}) +export class UserModule {} diff --git a/apps/api/src/app/widgets/dtos/session-initialize.dto.ts b/apps/api/src/app/widgets/dtos/session-initialize.dto.ts new file mode 100644 index 00000000000..1875944362e --- /dev/null +++ b/apps/api/src/app/widgets/dtos/session-initialize.dto.ts @@ -0,0 +1,24 @@ +import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; + +export class SessionInitializeBodyDto { + @IsString() + @IsDefined() + $user_id: string; + + @IsString() + @IsDefined() + applicationIdentifier: string; + + @IsString() + $first_name: string; + + @IsString() + $last_name: string; + + @IsEmail() + $email: string; + + @IsString() + @IsOptional() + $phone: string; +} diff --git a/apps/api/src/app/widgets/e2e/initialize-widget-session.e2e.ts b/apps/api/src/app/widgets/e2e/initialize-widget-session.e2e.ts new file mode 100644 index 00000000000..c4858e9ec53 --- /dev/null +++ b/apps/api/src/app/widgets/e2e/initialize-widget-session.e2e.ts @@ -0,0 +1,32 @@ +import { UserSession } from '@notifire/testing'; +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; + +describe('Initialize Session - /widgets/session/initialize (POST)', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should create a valid app session for current widget user', async function () { + const { body } = await session.testAgent + .post('/v1/widgets/session/initialize') + .send({ + applicationIdentifier: session.application.identifier, + $user_id: '12345', + $first_name: 'Test', + $last_name: 'User', + $email: 'test@example.com', + $phone: '054777777', + }) + .expect(201); + + expect(body.data.token).to.be.ok; + expect(body.data.profile._id).to.be.ok; + expect(body.data.profile.firstName).to.equal('Test'); + expect(body.data.profile.phone).to.equal('054777777'); + expect(body.data.profile.lastName).to.equal('User'); + }); +}); diff --git a/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts b/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts new file mode 100644 index 00000000000..a25623284ea --- /dev/null +++ b/apps/api/src/app/widgets/e2e/mark-as-seen.e2e.ts @@ -0,0 +1,58 @@ +import { MessageRepository, NotificationTemplateEntity } from '@notifire/dal'; +import { UserSession } from '@notifire/testing'; +import axios from 'axios'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { expect } from 'chai'; + +describe('Mark as Seen - /widgets/messages/:messageId/seen (POST)', async () => { + const messageRepository = new MessageRepository(); + let session: UserSession; + let template: NotificationTemplateEntity; + before(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + }); + + it('should change the seen status', async function () { + const { body } = await session.testAgent + .post('/v1/widgets/session/initialize') + .send({ + applicationIdentifier: session.application.identifier, + $user_id: '12345', + $first_name: 'Test', + $last_name: 'User', + $email: 'test@example.com', + }) + .expect(201); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + }); + + await session.triggerEvent(template.triggers[0].identifier, { + $user_id: '12345', + }); + const { token } = body.data; + const messages = await messageRepository.findBySubscriberChannel( + session.application._id, + body.data.profile._id, + ChannelTypeEnum.IN_APP + ); + const messageId = messages[0]._id; + expect(messages[0].seen).to.equal(false); + await axios.post( + `http://localhost:${process.env.PORT}/v1/widgets/messages/${messageId}/seen`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const modifiedMessage = await messageRepository.findById(messageId); + expect(modifiedMessage.seen).to.equal(true); + expect(modifiedMessage.lastSeenDate).to.be.ok; + }); +}); diff --git a/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.command.ts b/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.command.ts new file mode 100644 index 00000000000..65224abfbae --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithSubscriber } from '../../../shared/commands/project.command'; + +export class GetApplicationDataCommand extends ApplicationWithSubscriber { + static create(data: GetApplicationDataCommand) { + return CommandHelper.create(GetApplicationDataCommand, data); + } +} diff --git a/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.usecase.ts b/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.usecase.ts new file mode 100644 index 00000000000..3afbe0af0cd --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-application-data/get-application-data.usecase.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationEntity, ApplicationRepository } from '@notifire/dal'; +import { GetApplicationDataCommand } from './get-application-data.command'; + +@Injectable() +export class GetApplicationData { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute(command: GetApplicationDataCommand): Promise> { + const application = await this.applicationRepository.findById(command.applicationId); + + return { + _id: application._id, + name: application.name, + branding: application.branding, + }; + } +} diff --git a/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.command.ts b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.command.ts new file mode 100644 index 00000000000..9cb9c8d8df6 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.command.ts @@ -0,0 +1,12 @@ +import { IsNumber, IsPositive } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithSubscriber } from '../../../shared/commands/project.command'; + +export class GetNotificationsFeedCommand extends ApplicationWithSubscriber { + static create(data: GetNotificationsFeedCommand) { + return CommandHelper.create(GetNotificationsFeedCommand, data); + } + + @IsNumber() + page: number; +} diff --git a/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts new file mode 100644 index 00000000000..4b226ca9316 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { MessageEntity, MessageRepository } from '@notifire/dal'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { GetNotificationsFeedCommand } from './get-notifications-feed.command'; + +@Injectable() +export class GetNotificationsFeed { + constructor(private messageRepository: MessageRepository) {} + + async execute(command: GetNotificationsFeedCommand): Promise { + return await this.messageRepository.findBySubscriberChannel( + command.applicationId, + command.subscriberId, + ChannelTypeEnum.IN_APP, + { + limit: 10, + skip: command.page * 10, + } + ); + } +} diff --git a/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.command.ts b/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.command.ts new file mode 100644 index 00000000000..a284261fc48 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.command.ts @@ -0,0 +1,8 @@ +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithSubscriber } from '../../../shared/commands/project.command'; + +export class GetUnseenCountCommand extends ApplicationWithSubscriber { + static create(data: GetUnseenCountCommand) { + return CommandHelper.create(GetUnseenCountCommand, data); + } +} diff --git a/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.usecase.ts b/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.usecase.ts new file mode 100644 index 00000000000..15a6fe7a180 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-unseen-count/get-unseen-count.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { MessageRepository } from '@notifire/dal'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { GetUnseenCountCommand } from './get-unseen-count.command'; + +@Injectable() +export class GetUnseenCount { + constructor(private messageRepository: MessageRepository) {} + + async execute(command: GetUnseenCountCommand): Promise<{ count: number }> { + const count = await this.messageRepository.getUnseenCount( + command.applicationId, + command.subscriberId, + ChannelTypeEnum.IN_APP + ); + + return { + count, + }; + } +} diff --git a/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.command.ts b/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.command.ts new file mode 100644 index 00000000000..99a7ec85ce9 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.command.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class GetWidgetSettingsCommand { + static create(data: GetWidgetSettingsCommand) { + return CommandHelper.create(GetWidgetSettingsCommand, data); + } + + @IsDefined() + @IsString() + identifier: string; +} diff --git a/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.usecase.ts b/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.usecase.ts new file mode 100644 index 00000000000..4090afda120 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/get-widget-settings/get-widget-settings.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationRepository } from '@notifire/dal'; +import { GetWidgetSettingsCommand } from './get-widget-settings.command'; + +@Injectable() +export class GetWidgetSettings { + constructor(private applicationRepository: ApplicationRepository) {} + + async execute( + command: GetWidgetSettingsCommand + ): Promise<{ + _id: string; + _organizationId: string; + }> { + const application = await this.applicationRepository.findApplicationByIdentifier(command.identifier); + + return { + _id: application._id, + _organizationId: application._organizationId, + }; + } +} diff --git a/apps/api/src/app/widgets/usecases/index.ts b/apps/api/src/app/widgets/usecases/index.ts new file mode 100644 index 00000000000..d448cec002b --- /dev/null +++ b/apps/api/src/app/widgets/usecases/index.ts @@ -0,0 +1,16 @@ +import { GetApplicationData } from './get-application-data/get-application-data.usecase'; +import { MarkMessageAsSeen } from './mark-message-as-seen/mark-message-as-seen.usecase'; +import { GetUnseenCount } from './get-unseen-count/get-unseen-count.usecase'; +import { GetNotificationsFeed } from './get-notifications-feed/get-notifications-feed.usecase'; +import { InitializeSession } from './initialize-session/initialize-session.usecase'; +import { GetWidgetSettings } from './get-widget-settings/get-widget-settings.usecase'; + +export const USE_CASES = [ + GetApplicationData, + MarkMessageAsSeen, + GetUnseenCount, + GetNotificationsFeed, + InitializeSession, + GetWidgetSettings, + // +]; diff --git a/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.command.ts b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.command.ts new file mode 100644 index 00000000000..42dc1422bfe --- /dev/null +++ b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.command.ts @@ -0,0 +1,27 @@ +import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; + +export class InitializeSessionCommand { + static create(data: InitializeSessionCommand) { + return CommandHelper.create(InitializeSessionCommand, data); + } + + @IsDefined() + @IsString() + subscriberId: string; + + @IsDefined() + @IsString() + applicationIdentifier: string; + + firstName?: string; + + lastName?: string; + + @IsEmail() + email?: string; + + @IsString() + @IsOptional() + phone?: string; +} 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 new file mode 100644 index 00000000000..dafdd6d79ab --- /dev/null +++ b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { ApplicationRepository, SubscriberEntity } from '@notifire/dal'; +import { AuthService } from '../../../auth/services/auth.service'; +import { CreateSubscriber, CreateSubscriberCommand } from '../../../subscribers/usecases/create-subscriber'; +import { InitializeSessionCommand } from './initialize-session.command'; + +@Injectable() +export class InitializeSession { + constructor( + private applicationRepository: ApplicationRepository, + private createSubscriber: CreateSubscriber, + private authService: AuthService + ) {} + + async execute( + command: InitializeSessionCommand + ): Promise<{ + token: string; + profile: Partial; + }> { + const application = await this.applicationRepository.findApplicationByIdentifier(command.applicationIdentifier); + + const commandos = CreateSubscriberCommand.create({ + applicationId: application._id, + organizationId: application._organizationId, + subscriberId: command.subscriberId, + firstName: command.firstName, + lastName: command.lastName, + email: command.email, + phone: command.phone, + }); + + const subscriber = await this.createSubscriber.execute(commandos); + + return { + token: await this.authService.getSubscriberWidgetToken(subscriber), + profile: { + _id: subscriber._id, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + phone: subscriber.phone, + }, + }; + } +} diff --git a/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.command.ts b/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.command.ts new file mode 100644 index 00000000000..d8f95c8d447 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.command.ts @@ -0,0 +1,12 @@ +import { IsMongoId } from 'class-validator'; +import { CommandHelper } from '../../../shared/commands/command.helper'; +import { ApplicationWithSubscriber } from '../../../shared/commands/project.command'; + +export class MarkMessageAsSeenCommand extends ApplicationWithSubscriber { + static create(data: MarkMessageAsSeenCommand) { + return CommandHelper.create(MarkMessageAsSeenCommand, data); + } + + @IsMongoId() + messageId: string; +} diff --git a/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.usecase.ts b/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.usecase.ts new file mode 100644 index 00000000000..ec30f730f04 --- /dev/null +++ b/apps/api/src/app/widgets/usecases/mark-message-as-seen/mark-message-as-seen.usecase.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { MessageEntity, MessageRepository } from '@notifire/dal'; +import { ChannelTypeEnum } from '@notifire/shared'; +import { QueueService } from '../../../shared/services/queue'; +import { MarkMessageAsSeenCommand } from './mark-message-as-seen.command'; + +@Injectable() +export class MarkMessageAsSeen { + constructor(private messageRepository: MessageRepository, private queueService: QueueService) {} + + async execute(command: MarkMessageAsSeenCommand): Promise { + await this.messageRepository.changeSeenStatus(command.subscriberId, command.messageId, true); + + const count = await this.messageRepository.getUnseenCount( + command.applicationId, + command.subscriberId, + ChannelTypeEnum.IN_APP + ); + + this.queueService.wsSocketQueue.add({ + event: 'unseen_count_changed', + userId: command.subscriberId, + payload: { + unseenCount: count, + }, + }); + + return await this.messageRepository.findById(command.messageId); + } +} diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts new file mode 100644 index 00000000000..c18f4793b3a --- /dev/null +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -0,0 +1,110 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { MessageEntity, SubscriberEntity } from '@notifire/dal'; +import { SessionInitializeBodyDto } from './dtos/session-initialize.dto'; +import { InitializeSessionCommand } from './usecases/initialize-session/initialize-session.command'; +import { InitializeSession } from './usecases/initialize-session/initialize-session.usecase'; +import { GetNotificationsFeed } from './usecases/get-notifications-feed/get-notifications-feed.usecase'; +import { GetNotificationsFeedCommand } from './usecases/get-notifications-feed/get-notifications-feed.command'; +import { SubscriberSession } from '../shared/framework/user.decorator'; +import { GetUnseenCount } from './usecases/get-unseen-count/get-unseen-count.usecase'; +import { GetUnseenCountCommand } from './usecases/get-unseen-count/get-unseen-count.command'; +import { MarkMessageAsSeenCommand } from './usecases/mark-message-as-seen/mark-message-as-seen.command'; +import { MarkMessageAsSeen } from './usecases/mark-message-as-seen/mark-message-as-seen.usecase'; +import { GetApplicationData } from './usecases/get-application-data/get-application-data.usecase'; +import { GetApplicationDataCommand } from './usecases/get-application-data/get-application-data.command'; +import { AnalyticsService } from '../shared/services/analytics/analytics.service'; + +@Controller('/widgets') +export class WidgetsController { + constructor( + private initializeSessionUsecase: InitializeSession, + private getNotificationsFeedUsecase: GetNotificationsFeed, + private genUnseenCountUsecase: GetUnseenCount, + private markMessageAsSeenUsecase: MarkMessageAsSeen, + private getApplicationUsecase: GetApplicationData, + private analyticsService: AnalyticsService + ) {} + + @Post('/session/initialize') + async sessionInitialize(@Body() body: SessionInitializeBodyDto) { + return await this.initializeSessionUsecase.execute( + InitializeSessionCommand.create({ + subscriberId: body.$user_id, + applicationIdentifier: body.applicationIdentifier, + email: body.$email, + firstName: body.$first_name, + lastName: body.$last_name, + phone: body.$phone, + }) + ); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/notifications/feed') + async getNotificationsFeed(@SubscriberSession() subscriberSession: SubscriberEntity, @Query('page') page: number) { + const command = GetNotificationsFeedCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession._id, + applicationId: subscriberSession._applicationId, + page, + }); + + return await this.getNotificationsFeedUsecase.execute(command); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/notifications/unseen') + async getUnseenCount(@SubscriberSession() subscriberSession: SubscriberEntity): Promise<{ count: number }> { + const command = GetUnseenCountCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession._id, + applicationId: subscriberSession._applicationId, + }); + return await this.genUnseenCountUsecase.execute(command); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/messages/:messageId/seen') + async markMessageAsSeen( + @SubscriberSession() subscriberSession: SubscriberEntity, + @Param('messageId') messageId: string + ): Promise { + const command = MarkMessageAsSeenCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession._id, + applicationId: subscriberSession._applicationId, + messageId, + }); + + return await this.markMessageAsSeenUsecase.execute(command); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/application') + async getApplication(@SubscriberSession() subscriberSession: SubscriberEntity) { + const command = GetApplicationDataCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession._id, + applicationId: subscriberSession._applicationId, + }); + + return await this.getApplicationUsecase.execute(command); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/usage/log') + async logUsage( + @SubscriberSession() subscriberSession: SubscriberEntity, + @Body() body: { name: string; payload: any } + ) { + this.analyticsService.track(body.name, subscriberSession._organizationId, { + applicationId: subscriberSession._applicationId, + ...(body.payload || {}), + }); + + return { + success: true, + }; + } +} diff --git a/apps/api/src/app/widgets/widgets.module.ts b/apps/api/src/app/widgets/widgets.module.ts new file mode 100644 index 00000000000..6f3f736fbd0 --- /dev/null +++ b/apps/api/src/app/widgets/widgets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { WidgetsController } from './widgets.controller'; +import { SharedModule } from '../shared/shared.module'; +import { AuthModule } from '../auth/auth.module'; +import { SubscribersModule } from '../subscribers/subscribers.module'; + +@Module({ + imports: [SharedModule, SubscribersModule, AuthModule], + providers: [...USE_CASES], + exports: [], + controllers: [WidgetsController], +}) +export class WidgetsModule {} diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 00000000000..360548f24db --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,93 @@ +import './config'; +import 'newrelic'; +import '@sentry/tracing'; + +import { INestApplication, ValidationPipe, Logger } from '@nestjs/common'; +import * as passport from 'passport'; +import * as compression from 'compression'; +import { NestFactory, Reflector } from '@nestjs/core'; + +import * as Sentry from '@sentry/node'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; + +import { ExpressAdapter } from '@nestjs/platform-express'; +import { version } from '../package.json'; +import { AppModule } from './app.module'; +import { ResponseInterceptor } from './app/shared/framework/response.interceptor'; +import { RolesGuard } from './app/auth/framework/roles.guard'; +import { SubscriberRouteGuard } from './app/auth/framework/subscriber-route.guard'; +import { validateEnv } from './config/env-validator'; + +if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV, + release: `v${version}`, + integrations: [ + // enable HTTP calls tracing + new Sentry.Integrations.Http({ tracing: true }), + ], + }); +} + +// Validate the ENV variables after launching SENTRY, so missing variables will report to sentry +validateEnv(); +// +export async function bootstrap(expressApp?): Promise { + let app; + if (expressApp) { + app = await NestFactory.create(AppModule, new ExpressAdapter(expressApp)); + } else { + app = await NestFactory.create(AppModule); + } + + if (process.env.SENTRY_DSN) { + app.use(Sentry.Handlers.requestHandler()); + app.use(Sentry.Handlers.tracingHandler()); + } + + app.enableCors({ + origin: + process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'test' + ? '*' + : [process.env.FRONT_BASE_URL, process.env.WIDGET_BASE_URL], + preflightContinue: false, + allowedHeaders: ['Content-Type', 'Authorization', 'sentry-trace'], + methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + }); + + app.setGlobalPrefix('v1'); + + app.use(passport.initialize()); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + }) + ); + + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalGuards(new RolesGuard(app.get(Reflector))); + app.useGlobalGuards(new SubscriberRouteGuard(app.get(Reflector))); + + app.use(compression()); + + if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'local') { + const options = new DocumentBuilder() + .setTitle('notifire API') + .setDescription('The notifire API description') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('api', app, document); + } + + if (expressApp) { + await app.init(); + } else { + await app.listen(process.env.PORT); + } + + Logger.log(`Started application in NODE_ENV=${process.env.NODE_ENV} port ${process.env.PORT}`); + return app; +} diff --git a/apps/api/src/config/env-validator.ts b/apps/api/src/config/env-validator.ts new file mode 100644 index 00000000000..070b3443d3d --- /dev/null +++ b/apps/api/src/config/env-validator.ts @@ -0,0 +1,32 @@ +import { port, str, url, ValidatorSpec } from 'envalid'; +import * as envalid from 'envalid'; + +const validators: { [K in keyof any]: ValidatorSpec } = { + NODE_ENV: str({ + choices: ['dev', 'test', 'prod', 'ci', 'local'], + default: 'local', + }), + S3_LOCAL_STACK: str({ + default: '', + }), + S3_BUCKET_NAME: str(), + S3_REGION: str(), + PORT: port(), + FRONT_BASE_URL: url(), + REDIS_HOST: str(), + REDIS_PORT: port(), + JWT_SECRET: str(), + SENDGRID_API_KEY: str(), + MONGO_URL: str(), + AWS_ACCESS_KEY_ID: str(), + AWS_SECRET_ACCESS_KEY: str(), + NOTIFIRE_API_KEY: str(), +}; + +if (process.env.NODE_ENV !== 'local' && process.env.NODE_ENV !== 'test') { + validators.SENTRY_DSN = str(); +} + +export function validateEnv() { + envalid.cleanEnv(process.env, validators); +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts new file mode 100644 index 00000000000..105ed542d97 --- /dev/null +++ b/apps/api/src/config/index.ts @@ -0,0 +1,29 @@ +import * as dotenv from 'dotenv'; +import * as envalid from 'envalid'; +import { str, url, port, ValidatorSpec } from 'envalid'; + +dotenv.config(); + +let path; +switch (process.env.NODE_ENV) { + case 'prod': + path = `${__dirname}/../.env.production`; + break; + case 'test': + path = `${__dirname}/../.env.test`; + break; + case 'ci': + path = `${__dirname}/../.env.ci`; + break; + case 'local': + path = `${__dirname}/../.env.local`; + break; + case 'dev': + path = `${__dirname}/../.env.development`; + break; + default: + path = `${__dirname}/../.env.local`; +} +// +const { error } = dotenv.config({ path }); +if (error && !process.env.LAMBDA_TASK_ROOT) throw error; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 00000000000..2fb9dfe34e7 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,3 @@ +import { bootstrap } from './bootstrap'; + +bootstrap(); diff --git a/apps/api/src/newrelic.js b/apps/api/src/newrelic.js new file mode 100644 index 00000000000..5be72789fa0 --- /dev/null +++ b/apps/api/src/newrelic.js @@ -0,0 +1,68 @@ +/** + * New Relic agent configuration. + * + * See lib/config/default.js in the agent distribution for a more complete + * description of configuration variables and their potential values. + */ +exports.config = { + /** + * Array of application names. + */ + app_name: [process.env.NEW_RELIC_APP_NAME], + /** + * Your New Relic license key. + */ + license_key: process.env.NEW_RELIC_LICENSE_KEY, + /** + * This setting controls distributed tracing. + * Distributed tracing lets you see the path that a request takes through your + * distributed system. Enabling distributed tracing changes the behavior of some + * New Relic features, so carefully consult the transition guide before you enable + * this feature: https://docs.newrelic.com/docs/transition-guide-distributed-tracing + * Default is true. + */ + distributed_tracing: { + /** + * Enables/disables distributed tracing. + * + * @env NEW_RELIC_DISTRIBUTED_TRACING_ENABLED + */ + enabled: true, + }, + logging: { + /** + * Level at which to log. 'trace' is most useful to New Relic when diagnosing + * issues with the agent, 'info' and higher will impose the least overhead on + * production applications. + */ + level: 'info', + }, + /** + * When true, all request headers except for those listed in attributes.exclude + * will be captured for all traces, unless otherwise specified in a destination's + * attributes include/exclude lists. + */ + allow_all_headers: true, + attributes: { + /** + * Prefix of attributes to exclude from all destinations. Allows * as wildcard + * at end. + * + * NOTE: If excluding headers, they must be in camelCase form to be filtered. + * + * @env NEW_RELIC_ATTRIBUTES_EXCLUDE + */ + exclude: [ + 'request.headers.cookie', + 'request.headers.authorization', + 'request.headers.proxyAuthorization', + 'request.headers.setCookie*', + 'request.headers.x*', + 'response.headers.cookie', + 'response.headers.authorization', + 'response.headers.proxyAuthorization', + 'response.headers.setCookie*', + 'response.headers.x*', + ], + }, +}; diff --git a/apps/api/src/types/env.d.ts b/apps/api/src/types/env.d.ts new file mode 100644 index 00000000000..c32ee225825 --- /dev/null +++ b/apps/api/src/types/env.d.ts @@ -0,0 +1,13 @@ +declare namespace NodeJS { + export interface ProcessEnv { + MONGO_URL: string; + REDIS_URL: string; + SYNC_PATH: string; + GOOGLE_OAUTH_CLIENT_SECRET: string; + GOOGLE_OAUTH_CLIENT_ID: string; + NODE_ENV: 'test' | 'prod' | 'dev' | 'ci' | 'local'; + PORT: string; + FRONT_BASE_URL: string; + SENTRY_DSN: string; + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 00000000000..ce43a4259c3 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "noImplicitAny": false, + "removeComments": true, + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es6", + "esModuleInterop": false, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./src", + "types": ["node", "mocha"] + }, + "include": [".eslintrc.js", "src/**/*", "src/**/*.d.ts"], + "exclude": ["node_modules", "**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000000..5ade64e83dd --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "allowSyntheticDefaultImports": true, + "types": ["node", "mocha", "chai", "sinon"], + "target": "es2017", + "allowJs": false, + "esModuleInterop": false, + "declarationMap": true + } +} diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json new file mode 100644 index 00000000000..1be0ce0aeb7 --- /dev/null +++ b/apps/api/tsconfig.spec.json @@ -0,0 +1,7 @@ +{ + "extends": "apps/api/tsconfig.json", + "compilerOptions": { + "types": ["mocha", "node"], + "esModuleInterop": false + } +} diff --git a/apps/web/.babelrc b/apps/web/.babelrc new file mode 100644 index 00000000000..5bcf3a7ba3b --- /dev/null +++ b/apps/web/.babelrc @@ -0,0 +1,18 @@ +{ + "presets": [ + "@babel/preset-typescript", + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-env" + ], + "plugins": ["babel-plugin-styled-components", "@babel/plugin-proposal-optional-chaining",[ + "@babel/plugin-transform-runtime", + { + "regenerator": true + } + ]] +} diff --git a/apps/web/.env b/apps/web/.env new file mode 100644 index 00000000000..6f809cc2540 --- /dev/null +++ b/apps/web/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js new file mode 100644 index 00000000000..cdb38c1f519 --- /dev/null +++ b/apps/web/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + rules: { + 'react/jsx-props-no-spreading': 'off', + 'react/no-array-index-key': 'off', + 'no-empty-pattern': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/jsx-closing-bracket-location': 'off', + '@typescript-eslint/ban-types': 'off', + 'react/jsx-wrap-multilines': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'promise/catch-or-return': 'off', + 'react/jsx-one-expression-per-line': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + 'jsx-a11y/aria-role': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'react/require-default-props': 'off', + 'react/no-danger': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + }, + env: { + 'cypress/globals': true, + }, + ignorePatterns: ['craco.config.js', 'cypress/*'], + extends: ['plugin:cypress/recommended', '../../.eslintrc.js'], + plugins: ['cypress'], + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + }, +}; diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000000..3ec1e0edf03 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +cypress/videos +cypress/screenshots diff --git a/apps/web/.vscode/settings.json b/apps/web/.vscode/settings.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/apps/web/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000000..b58e0af830e --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,46 @@ +# Getting Started with Create React App + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/apps/web/craco.config.js b/apps/web/craco.config.js new file mode 100644 index 00000000000..b4b855c8dd7 --- /dev/null +++ b/apps/web/craco.config.js @@ -0,0 +1,19 @@ +const CracoAntDesignPlugin = require('craco-antd'); +const path = require('path'); +const BabelRcPlugin = require('@jackwilsdon/craco-use-babelrc'); + +module.exports = { + eslint: { + enable: false, + }, + plugins: [ + { plugin: BabelRcPlugin }, + + { + plugin: CracoAntDesignPlugin, + options: { + customizeThemeLessPath: path.join(__dirname, 'src/styles/index.less'), + }, + }, + ], +}; diff --git a/apps/web/cypress.json b/apps/web/cypress.json new file mode 100644 index 00000000000..06c6cfe2f7f --- /dev/null +++ b/apps/web/cypress.json @@ -0,0 +1,20 @@ +{ + "baseUrl": "http://localhost:4200", + "integrationFolder": "cypress/tests", + "viewportHeight": 700, + "viewportWidth": 1200, + "componentFolder": "src", + "testFiles": "**/*.spec.*", + "firefoxGcInterval": null, + "video": false, + "retries": { + "runMode": 2, + "openMode": 1 + }, + "env": { + "NODE_ENV": "test", + "apiUrl": "http://localhost:1336", + "coverage": false + }, + "projectId": "cayav5" +} diff --git a/apps/web/cypress/.eslintrc.js b/apps/web/cypress/.eslintrc.js new file mode 100644 index 00000000000..3b4c2c0e4cf --- /dev/null +++ b/apps/web/cypress/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + extends: ['plugin:cypress/recommended'], + plugins: ['cypress'], + ignorePatterns: ['tests/*'], + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + }, +}; diff --git a/apps/web/cypress/fixtures/test-logo.png b/apps/web/cypress/fixtures/test-logo.png new file mode 100644 index 00000000000..6485aac5ddc Binary files /dev/null and b/apps/web/cypress/fixtures/test-logo.png differ diff --git a/apps/web/cypress/global.d.ts b/apps/web/cypress/global.d.ts new file mode 100644 index 00000000000..e3b82e75318 --- /dev/null +++ b/apps/web/cypress/global.d.ts @@ -0,0 +1,31 @@ +/// + +import { INotificationTemplate } from '@notifire/shared'; + +declare namespace Cypress { + interface Chainable { + getByTestId(dataTestAttribute: string, args?: any): Chainable; + getBySelectorLike(dataTestPrefixAttribute: string, args?: any): Chainable; + + /** + * Window object with additional properties used during test. + */ + window(options?: Partial): Chainable; + + seed(): Chainable; + + clear(): Chainable; + /** + * Logs-in user by using UI + */ + login(username: string, password: string): void; + + /** + * Logs-in user by using API request + */ + initializeSession(settings?: { + noApplication?: boolean; + partialTemplate?: Partial; + }): Chainable; + } +} diff --git a/apps/web/cypress/plugins/index.ts b/apps/web/cypress/plugins/index.ts new file mode 100644 index 00000000000..b95663003d4 --- /dev/null +++ b/apps/web/cypress/plugins/index.ts @@ -0,0 +1,98 @@ +/** + * @type {Cypress.PluginConfig} + */ +const injectReactScriptsDevServer = require('@cypress/react/plugins/react-scripts'); +import { DalService, NotificationTemplateEntity, UserRepository } from '@notifire/dal'; +import { UserSession, SubscribersService, NotificationTemplateService, NotificationsService } from '@notifire/testing'; + +const preprocess = require('@cypress/react/plugins/react-scripts'); + +const userRepository = new UserRepository(); +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config + on('task', { + async createNotifications({ identifier, token, count = 1, applicationId, organizationId }) { + const subscriberService = new SubscribersService(organizationId, applicationId); + const subscriber = await subscriberService.createSubscriber(); + + const triggerIdentifier = identifier; + const service = new NotificationsService(token); + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < count; i++) { + await service.triggerEvent(triggerIdentifier, { + $user_id: subscriber.subscriberId, + }); + } + + return 'ok'; + }, + async clearDatabase() { + const dal = new DalService(); + await dal.connect('mongodb://localhost:27017/notifire-test'); + await dal.destroy(); + return true; + }, + async seedDatabase() { + const dal = new DalService(); + await dal.connect('mongodb://localhost:27017/notifire-test'); + + const session = new UserSession('http://localhost:1336'); + + return true; + }, + async passwordResetToken(id: string) { + const dal = new DalService(); + await dal.connect('mongodb://localhost:27017/notifire-test'); + const user = await userRepository.findOne({ + _id: id, + }); + return user?.resetToken; + }, + async getSession( + settings: { noApplication?: boolean; partialTemplate?: Partial } = {} + ) { + const dal = new DalService(); + await dal.connect('mongodb://localhost:27017/notifire-test'); + + const session = new UserSession('http://localhost:1336'); + await session.initialize({ + noApplication: settings?.noApplication, + }); + + const notificationTemplateService = new NotificationTemplateService( + session.user._id, + session.organization._id, + session.application._id + ); + + let templates; + if (!settings?.noApplication) { + let templatePartial = settings?.partialTemplate || {}; + + templates = await Promise.all([ + notificationTemplateService.createTemplate({ ...templatePartial }), + notificationTemplateService.createTemplate({ + active: false, + draft: true, + }), + notificationTemplateService.createTemplate(), + notificationTemplateService.createTemplate(), + notificationTemplateService.createTemplate(), + notificationTemplateService.createTemplate(), + ]); + } + + return { + token: session.token.split(' ')[1], + user: session.user, + organization: session.organization, + application: session.application, + templates, + }; + }, + }); + + injectReactScriptsDevServer(on, config); +}; diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts new file mode 100644 index 00000000000..d24af1591cf --- /dev/null +++ b/apps/web/cypress/support/commands.ts @@ -0,0 +1,30 @@ +// load the global Cypress types +/// + +import 'cypress-file-upload'; + +Cypress.Commands.add('getByTestId', (selector, ...args) => { + return cy.get(`[data-test-id=${selector}]`, ...args); +}); + +Cypress.Commands.add('getBySelectorLike', (selector, ...args) => { + return cy.get(`[data-test*=${selector}]`, ...args); +}); + +Cypress.Commands.add('seed', () => { + return cy.request('POST', `${Cypress.env('apiUrl')}/v1/testing/seed`, {}); +}); + +Cypress.Commands.add('initializeSession', (settings = {}) => { + return cy.task('getSession', settings).then((response: any) => { + window.localStorage.setItem('auth_token', response.token); + + return response; + }); +}); + +Cypress.Commands.add('logout', (settings = {}) => { + return window.localStorage.removeItem('auth_token'); +}); + +export {}; diff --git a/apps/web/cypress/support/index.ts b/apps/web/cypress/support/index.ts new file mode 100644 index 00000000000..ff4289280e5 --- /dev/null +++ b/apps/web/cypress/support/index.ts @@ -0,0 +1,24 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +// load the global Cypress types +/// + +import 'cypress-localstorage-commands'; + +// Import commands.js using ES2015 syntax: +import './commands'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/apps/web/cypress/tests/activities-page.spec.ts b/apps/web/cypress/tests/activities-page.spec.ts new file mode 100644 index 00000000000..b87fd70b3ab --- /dev/null +++ b/apps/web/cypress/tests/activities-page.spec.ts @@ -0,0 +1,69 @@ +describe('Activity Feed Screen', function () { + beforeEach(function () { + cy.initializeSession() + .as('session') + .then((session: any) => { + cy.wait(500); + + return cy.task('createNotifications', { + identifier: session.templates[0].triggers[0].identifier, + token: session.token, + count: 25, + organizationId: session.organization._id, + applicationId: session.application._id, + }); + }); + }); + + it('should display notification templates list', function () { + cy.visit('/activities'); + cy.getByTestId('activities-table') + .find('tbody tr') + .first() + .getByTestId('row-template-name') + .contains(this.session.templates[0].name); + + cy.getByTestId('activities-table').find('tbody tr').first().getByTestId('row-in-app-channel').should('be.visible'); + cy.getByTestId('activities-table').find('tbody tr').first().getByTestId('row-email-channel').should('be.visible'); + }); + + it('should display stats on top of page', function () { + cy.visit('/activities'); + cy.get('.ant-statistic') + .contains('Sent this month', { + matchCase: false, + }) + .parent('.ant-statistic') + .contains('50'); + cy.get('.ant-statistic') + .contains('Sent this week', { + matchCase: false, + }) + .parent('.ant-statistic') + .contains('50'); + }); + + it('should show errors and warning', function () { + cy.intercept(/.*activity\?page.*/, (r) => { + r.continue((res) => { + res.body.data[0].status = 'error'; + res.body.data[0].errorText = 'Test Error Text'; + res.body.data[2].status = 'warning'; + + res.send({ body: res.body }); + }); + }); + cy.visit('/activities'); + cy.get('tbody tr').eq(0).get('.ant-badge-status-error').should('be.visible'); + cy.get('tbody tr').eq(1).get('.ant-badge-status-success').should('be.visible'); + cy.get('tbody tr').eq(2).get('.ant-badge-status-warning').should('be.visible'); + }); + + it('should filter by email channel', function () { + cy.visit('/activities'); + cy.getByTestId('row-email-channel').should('not.have.length', 10); + cy.getByTestId('activities-filter').click(); + cy.get('.ant-select-item').contains('Email').click(); + cy.getByTestId('row-email-channel').should('have.length', 10); + }); +}); diff --git a/apps/web/cypress/tests/auth.spec.ts b/apps/web/cypress/tests/auth.spec.ts new file mode 100644 index 00000000000..27392e10241 --- /dev/null +++ b/apps/web/cypress/tests/auth.spec.ts @@ -0,0 +1,69 @@ +import { MemberRoleEnum } from '@notifire/shared'; + +describe('User Sign-up and Login', function () { + describe('Sign up', function () { + beforeEach(function () { + cy.task('clearDatabase'); + cy.seed(); + }); + + it('should allow a visitor to sign-up, login, and logout', function () { + cy.visit('/auth/signup'); + cy.getByTestId('fullName').type('Test User'); + cy.getByTestId('email').type('example@example.com'); + cy.getByTestId('password').type('usEr_password_123'); + cy.getByTestId('companyName').type('Mega Corp Company'); + cy.getByTestId('submitButton').click(); + cy.location('pathname').should('equal', '/templates'); + }); + }); + + describe('Password Reset', function () { + before(() => { + cy.initializeSession().as('session'); + }); + + it('should request a password reset flow', function () { + cy.visit('/auth/reset/request'); + cy.getByTestId('email').type(this.session.user.email); + cy.getByTestId('submit-btn').click(); + cy.getByTestId('success-screen-reset').should('be.visible'); + cy.task('passwordResetToken', this.session.user._id).then((token) => { + cy.visit('/auth/reset/' + token); + }); + cy.getByTestId('password').type('123e3e3e3'); + cy.getByTestId('password-repeat').type('123e3e3e3'); + + cy.getByTestId('submit-btn').click(); + }); + }); + + describe('Login', function () { + beforeEach(function () { + cy.task('clearDatabase'); + cy.seed(); + }); + it('should be redirect login with no auth', function () { + cy.visit('/'); + cy.location('pathname').should('equal', '/auth/login'); + }); + + it('should successfully login the user', function () { + cy.visit('/auth/login'); + + cy.getByTestId('email').type('test-user-1@example.com'); + cy.getByTestId('password').type('123qwe!@#'); + cy.getByTestId('submit-btn').click(); + cy.location('pathname').should('equal', '/templates'); + }); + + it('should show bad password error when authenticating with bad credentials', function () { + cy.visit('/auth/login'); + + cy.getByTestId('email').type('test-user-1@example.com'); + cy.getByTestId('password').type('123456'); + cy.getByTestId('submit-btn').click(); + cy.getByTestId('error-alert-banner').contains('Wrong credentials'); + }); + }); +}); diff --git a/apps/web/cypress/tests/explore.spec.ts b/apps/web/cypress/tests/explore.spec.ts new file mode 100644 index 00000000000..abff83539ff --- /dev/null +++ b/apps/web/cypress/tests/explore.spec.ts @@ -0,0 +1,9 @@ +describe('Just launch the app for exploration', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + }); + + it('should launch the app', function () { + cy.visit('/'); + }); +}); diff --git a/apps/web/cypress/tests/invites.spec.ts b/apps/web/cypress/tests/invites.spec.ts new file mode 100644 index 00000000000..292b5a3c797 --- /dev/null +++ b/apps/web/cypress/tests/invites.spec.ts @@ -0,0 +1,43 @@ +import { MemberRoleEnum, MemberStatusEnum } from '@notifire/shared'; + +describe('Invites module', function () { + beforeEach(function () { + cy.task('clearDatabase'); + cy.initializeSession() + .then((session) => { + cy.request({ + method: 'POST', + url: `${Cypress.env('apiUrl')}/v1/invites`, + body: { + email: 'testing-amazing@user.com', + role: MemberRoleEnum.ADMIN, + }, + auth: { + bearer: session.token, + }, + }); + cy.request({ + method: 'GET', + url: `${Cypress.env('apiUrl')}/v1/organizations/members`, + auth: { + bearer: session.token, + }, + }) + .then((response) => { + const member = response.body.data.find((i) => i.memberStatus === MemberStatusEnum.INVITED); + return member.invite.token; + }) + .as('token'); + + cy.logout(); + }) + .as('session'); + }); + + it('should accept invite to organization', function () { + cy.visit('/auth/invitation/' + this.token); + cy.getByTestId('fullName').type('Invited to org user'); + cy.getByTestId('password').type('asd#Faf4fd'); + cy.getByTestId('submitButton').click(); + }); +}); diff --git a/apps/web/cypress/tests/layout/header.spec.ts b/apps/web/cypress/tests/layout/header.spec.ts new file mode 100644 index 00000000000..fbf60b1530e --- /dev/null +++ b/apps/web/cypress/tests/layout/header.spec.ts @@ -0,0 +1,41 @@ +describe('App Header', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + cy.visit('/'); + }); + + it('should display correct user photo', function () { + cy.getByTestId('header-profile-avatar') + .find('img') + .should('have.attr', 'src') + .should('include', this.session.user.profilePicture); + }); + + it('should display user name in dropdown', function () { + cy.getByTestId('header-profile-avatar').click(); + cy.getByTestId('header-dropdown-username').should('contain', this.session.user.firstName); + cy.getByTestId('header-dropdown-username').should('contain', this.session.user.lastName); + }); + + it('should display organization name in dropdown', function () { + cy.getByTestId('header-profile-avatar').click(); + cy.getByTestId('header-dropdown-organization-name').contains(this.session.organization.name, { + matchCase: false, + }); + }); + + it('logout user successfully', function () { + cy.getByTestId('header-profile-avatar').click(); + cy.getByTestId('logout-button').click(); + cy.location('pathname').should('equal', '/auth/login'); + + cy.window() + .then((win) => { + return win.localStorage.getItem('auth_token'); + }) + .should('not.be.ok'); + + cy.visit('/'); + cy.location('pathname').should('equal', '/auth/login'); + }); +}); diff --git a/apps/web/cypress/tests/layout/side-menu.spec.ts b/apps/web/cypress/tests/layout/side-menu.spec.ts new file mode 100644 index 00000000000..0f99ee668ad --- /dev/null +++ b/apps/web/cypress/tests/layout/side-menu.spec.ts @@ -0,0 +1,14 @@ +describe('Side Menu', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + cy.visit('/'); + }); + + it('should navigate correctly to notification-templates', function () { + cy.getByTestId('side-nav-templates-link').should('have.attr', 'href').should('include', '/templates'); + }); + + it('should navigate correctly to settings', function () { + cy.getByTestId('side-nav-settings-link').should('have.attr', 'href').should('include', '/settings/widget'); + }); +}); diff --git a/apps/web/cypress/tests/notifications-editor.spec.ts b/apps/web/cypress/tests/notifications-editor.spec.ts new file mode 100644 index 00000000000..681660299f1 --- /dev/null +++ b/apps/web/cypress/tests/notifications-editor.spec.ts @@ -0,0 +1,384 @@ +import { ChannelTypeEnum, INotificationTemplate } from '@notifire/shared'; + +describe('Notifications Creator', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + }); + + it('should not reset data when switching channel types', function () { + cy.visit('/templates/create'); + cy.getByTestId('inAppSelector').click({ force: true }); + cy.getByTestId('in-app-editor-content-input').type('{{firstName}} someone assigned you to {{taskName}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('editable-text-content').clear().type('This text is written from a test {{firstName}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('emailSubject').type('this is email subject'); + + cy.getByTestId('inAppSelector').click({ force: true }); + cy.getByTestId('in-app-editor-content-input').contains('someone assigned you to'); + + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('editable-text-content').contains('This text is written from a test'); + cy.getByTestId('emailSubject').should('have.value', 'this is email subject'); + }); + + it('should create in-app notification', function () { + cy.visit('/templates/create'); + cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('description').type('This is a test description for a test title'); + cy.getByTestId('tags').type('General {enter}'); + cy.getByTestId('tags').type('Tasks {enter}'); + cy.get('body').click(); + cy.getByTestId('trigger-code-snippet').should('not.exist'); + cy.getByTestId('groupSelector').contains('General'); + + cy.getByTestId('inAppSelector').click({ force: true }); + cy.getByTestId('inAppRedirect').type('/example/test'); + cy.getByTestId('in-app-editor-content-input').type('{{firstName}} someone assigned you to {{taskName}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('success-trigger-modal').should('be.visible'); + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-notification'); + cy.getByTestId('success-trigger-modal') + .getByTestId('trigger-code-snippet') + .contains("import { Notifire } from '@notifire/node'"); + + cy.get('.ant-tabs-tab-btn').contains('Curl').click(); + cy.getByTestId('success-trigger-modal') + .getByTestId('trigger-curl-snippet') + .contains("--header 'Authorization: ApiKey"); + + cy.getByTestId('success-trigger-modal').getByTestId('trigger-curl-snippet').contains('taskName'); + + cy.get('.ant-modal-footer .ant-btn.ant-btn-primary').click(); + cy.location('pathname').should('equal', '/templates'); + }); + + it('should create email notification', function () { + cy.visit('/templates/create'); + cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('description').type('This is a test description for a test title'); + cy.getByTestId('tags').type('General {enter}'); + cy.getByTestId('tags').type('Tasks {enter}'); + cy.get('body').click(); + + cy.getByTestId('emailSelector').click({ force: true }); + + cy.getByTestId('email-editor').getByTestId('editor-row').click(); + cy.getByTestId('control-add').click({ force: true }); + cy.getByTestId('add-btn-block').click(); + cy.getByTestId('button-block-wrapper').should('be.visible'); + cy.getByTestId('button-block-wrapper').find('button').click(); + cy.getByTestId('button-text-input').clear().type('Example Text Of {{ctaName}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('button-block-wrapper').find('button').contains('Example Text Of {{ctaName}}'); + cy.getByTestId('editable-text-content').clear().type('This text is written from a test {{firstName}}', { + parseSpecialCharSequences: false, + }); + + cy.getByTestId('email-editor').getByTestId('editor-row').eq(1).click(); + cy.getByTestId('control-add').click({ force: true }); + cy.getByTestId('add-text-block').click(); + cy.getByTestId('editable-text-content').eq(1).clear().type('This another text will be {{customVariable}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('editable-text-content').eq(1).click(); + + cy.getByTestId('settings-row-btn').eq(1).invoke('show').click(); + cy.getByTestId('remove-row-btn').click(); + cy.getByTestId('button-block-wrapper').should('not.exist'); + + cy.getByTestId('emailSubject').type('this is email subject'); + + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('success-trigger-modal').should('be.visible'); + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-notification'); + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('firstName:'); + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('customVariable:'); + }); + + it('should create and edit group id', function () { + const template = this.session.templates[0]; + cy.visit('/templates/edit/' + template._id); + + cy.getByTestId('groupSelector').click(); + cy.getByTestId('category-text-input').type('New Test Category'); + cy.getByTestId('submit-category-btn').click(); + cy.getByTestId('groupSelector').contains('New Test Category'); + + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('template-edit-link'); + cy.visit('/templates/edit/' + template._id); + cy.getByTestId('groupSelector').contains('New Test Category'); + }); + + it('should edit notification', function () { + const template = this.session.templates[0]; + cy.visit('/templates/edit/' + template._id); + cy.getByTestId('inAppSelector').click({ force: true }); + cy.getByTestId('title').get('input').should('have.value', template.name); + cy.getByTestId('in-app-editor-content-input') + .getByTestId('in-app-editor-content-input') + .contains('Test content for {{firstName}}'); + + cy.getByTestId('title').type(' This is the new notification title'); + cy.getByTestId('in-app-editor-content-input').clear().type('new content for notification'); + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('template-edit-link'); + cy.getByTestId('notifications-template').get('tbody tr td').contains('This is the new notification title', { + matchCase: false, + }); + }); + + it('should update notification active status', function () { + const template = this.session.templates[0]; + cy.visit('/templates/edit/' + template._id); + cy.getByTestId('active-toggle-switch').contains('Active'); + cy.getByTestId('active-toggle-switch').click(); + cy.getByTestId('active-toggle-switch').contains('Disabled'); + + cy.visit('/templates/edit/' + template._id); + cy.getByTestId('active-toggle-switch').contains('Disabled'); + }); + + it('should toggle active states of channels', function () { + cy.visit('/templates/create'); + // Enable email from button click + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('emailSelector').find('.ant-switch-checked').should('exist'); + cy.getByTestId('emailSelector').find('.ant-switch').click({ force: true }); + + // should hide when switch clicked + cy.getByTestId('email-editor-wrapper').should('not.visible'); + + // enable email selector + cy.getByTestId('emailSelector').click(); + + // enable in app without changing select item + cy.getByTestId('inAppSelector').find('.ant-switch').click({ force: true }); + cy.getByTestId('inAppSelector').find('.ant-switch-checked').should('exist'); + cy.getByTestId('email-editor-wrapper').should('exist'); + + // when hiding current selector, should navigate to closest available + cy.getByTestId('emailSelector').find('.ant-switch').click({ force: true }); + cy.getByTestId('in-app-editor-wrapper').should('be.visible'); + }); + + it('should show trigger snippet block when editing', function () { + const template = this.session.templates[0]; + cy.visit('/templates/edit/' + template._id); + + cy.getByTestId('trigger-code-snippet').contains('test-event'); + }); + + it('should handle multiple email messages', function () { + cy.visit('/templates/create'); + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('emailSubject').eq(1).should('not.exist'); + + cy.getByTestId('add-message-button').click(); + cy.getByTestId('emailSubject').eq(1).click(); + cy.getByTestId('emailSubject').eq(1).should('be.visible'); + cy.getByTestId('emailSubject').eq(1).type('this is email subject 2'); + cy.getByTestId('emailSubject').eq(0).should('not.be.visible'); + cy.getByTestId('message-header-title').eq(0).click(); + cy.getByTestId('emailSubject').eq(0).should('be.visible'); + cy.getByTestId('emailSubject').eq(1).should('not.be.visible'); + cy.getByTestId('emailSubject').eq(0).type('this is email subject 1'); + cy.getByTestId('message-header-title').eq(1).find('.ant-typography-edit').click(); + cy.getByTestId('message-header-title').eq(1).find('textarea').type(' editing message name {enter}'); + cy.getByTestId('message-header-title').eq(1).contains('editing message name'); + + cy.getByTestId('AddRule').eq(0).click(); + cy.getByTestId('filters-builder').eq(0).find('[title="Select your option"]').click(); + cy.get('.ant-select-item-option-content').contains('First Name').click(); + cy.getByTestId('filter-builder-row').find('input[type="text"]').type('First Value'); + + cy.getByTestId('AddRule').eq(0).click(); + cy.getByTestId('filter-builder-row').eq(1).find('[title="Select your option"]').click(); + + cy.getByTestId('remove-message-template-btn').eq(0).click(); + cy.get('.ant-popover-placement-bottom button').contains('Yes').click(); + + cy.getByTestId('emailSubject').eq(1).should('not.exist'); + cy.getByTestId('emailSubject').should('have.value', 'this is email subject 2'); + }); + + describe('Email Filters', function () { + beforeEach(function () { + cy.initializeSession({ + partialTemplate: { + messages: [ + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test', + name: 'Test Name of message', + content: [ + { + type: 'button', + content: 'Test button', + }, + ], + filters: [ + { + type: 'GROUP', + value: 'OR', + children: [ + { + field: 'firstName', + value: 'Test', + operator: 'EQUAL', + }, + ], + }, + ], + }, + { + type: ChannelTypeEnum.EMAIL, + subject: 'Test 2', + name: 'Test Name of message 2', + content: [ + { + type: 'button', + content: 'Test button 2', + }, + ], + filters: [ + { + type: 'GROUP', + value: 'OR', + children: [ + { + field: 'firstName', + value: 'Test 2', + operator: 'EQUAL', + }, + ], + }, + ], + }, + ], + } as Partial, + }).as('session'); + }); + + it('should prefill saved multiple email messages and filters', function () { + const template = this.session.templates[0]; + cy.visit('/templates/edit/' + template._id); + cy.getByTestId('message-header-title').eq(0).contains('Test Name of message'); + cy.getByTestId('message-header-title').eq(1).contains('Test Name of message 2'); + cy.getByTestId('filter-builder-row').eq(1).find('input[type=text]').should('have.value', 'Test 2'); + cy.getByTestId('filter-builder-row').eq(1).find('.ant-select-selection-item').contains('First Name'); + }); + }); + + it('should validate form inputs', function () { + cy.visit('/templates/create'); + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('title').should('have.class', 'ant-form-item-has-error'); + + cy.getByTestId('inAppSelector').click({ force: true }); + cy.getByTestId('submit-btn').click(); + cy.getByTestId('in-app-content-form-item').should('have.class', 'ant-form-item-has-error'); + }); + + it('should allow uploading a logo from email editor', function () { + cy.intercept(/.*applications\/me.*/, (r) => { + r.continue((res) => { + if (res.body) { + delete res.body.data.branding.logo; + } + + res.send({ body: res.body }); + }); + }); + cy.visit('/templates/create'); + cy.getByTestId('emailSelector').click({ force: true }); + + cy.getByTestId('logo-upload-button').click(); + cy.get('.ant-popconfirm button').contains('Yes').click(); + cy.location('pathname').should('equal', '/settings/widget'); + }); + + it('should show the brand logo on main page', function () { + cy.visit('/templates/create'); + cy.getByTestId('emailSelector').click({ force: true }); + + cy.getByTestId('email-editor') + .getByTestId('brand-logo') + .should('have.attr', 'src', 'https://notifire.co/img/logo.png'); + }); + + it('should support RTL text content', function () { + cy.visit('/templates/create'); + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('settings-row-btn').eq(0).invoke('show').click(); + cy.getByTestId('editable-text-content').should('have.css', 'direction', 'ltr'); + cy.getByTestId('style-setting-row-btn-drawer').click(); + cy.getByTestId('text-direction-input').get('.ant-radio-button-wrapper').contains('RTL').click(); + cy.getByTestId('drawer-submit-btn').click(); + cy.getByTestId('editable-text-content').should('have.css', 'direction', 'rtl'); + }); + + it('should create an SMS channel message', function () { + cy.visit('/templates/create'); + cy.getByTestId('title').type('Test SMS Notification Title'); + cy.getByTestId('description').type('This is a SMS test description for a test title'); + + cy.getByTestId('smsSelector').click({ force: true }); + cy.getByTestId('smsNotificationContent').type('{{firstName}} someone assigned you to {{taskName}}', { + parseSpecialCharSequences: false, + }); + cy.getByTestId('submit-btn').click(); + + cy.getByTestId('success-trigger-modal').should('be.visible'); + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-sms-notification'); + cy.getByTestId('success-trigger-modal') + .getByTestId('trigger-code-snippet') + .contains("import { Notifire } from '@notifire/node'"); + + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('taskName'); + + cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('firstName'); + + cy.get('.ant-modal-footer .ant-btn.ant-btn-primary').click(); + cy.location('pathname').should('equal', '/templates'); + }); + + it('should prompt for filling sms settings before accessing the data', function () { + cy.intercept(/.*applications\/me.*/, (r) => { + r.continue((res) => { + delete res.body.data.channels.sms; + res.send({ body: res.body }); + }); + }); + + cy.visit('/templates/create'); + cy.getByTestId('configure-sms-button').click(); + cy.get('.ant-popover button').contains('Yes').click(); + cy.url().should('include', '/settings/widget'); + }); + + it('should save HTML template email', function () { + cy.visit('/templates/create'); + cy.getByTestId('title').type('Custom Code HTML Notification Title'); + cy.getByTestId('emailSelector').click({ force: true }); + cy.getByTestId('emailSubject').type('this is email subject'); + cy.getByTestId('editor-type-selector').find('label').contains('Custom Code', { matchCase: false }).click(); + cy.get('#codeEditor').type('Hello world code {{name}}
Test', { parseSpecialCharSequences: false }); + cy.getByTestId('submit-btn').click(); + cy.get('.ant-modal-footer .ant-btn.ant-btn-primary').click(); + cy.get('tbody').contains('Custom Code HTML Notification').parent('tr').find('button').click(); + cy.get('#codeEditor').contains('Hello world code {{name}}
Test
'); + }); +}); diff --git a/apps/web/cypress/tests/notifications.spec.ts b/apps/web/cypress/tests/notifications.spec.ts new file mode 100644 index 00000000000..f246542e63a --- /dev/null +++ b/apps/web/cypress/tests/notifications.spec.ts @@ -0,0 +1,27 @@ +describe('Notification Templates Screen', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + }); + + it('should display notification templates list', function () { + cy.visit('/templates'); + cy.getByTestId('notifications-template') + .find('tbody tr') + .first() + .getByTestId('template-edit-link') + .then((a: any) => { + const found = this.session.templates.find((i) => a.attr('href').includes(i._id)); + expect(found).to.be.ok; + return expect(a.attr('href')).to.equal(`/templates/edit/${found._id}`); + }); + + cy.getByTestId('notifications-template') + .find('tbody tr') + .first() + .getByTestId('active-status-label') + .should('be.visible'); + + cy.getByTestId('create-template-btn').should('have.attr', 'href', '/templates/create'); + cy.getByTestId('category-label').contains('General'); + }); +}); diff --git a/apps/web/cypress/tests/organization-settings.spec.ts b/apps/web/cypress/tests/organization-settings.spec.ts new file mode 100644 index 00000000000..45b8e3a675c --- /dev/null +++ b/apps/web/cypress/tests/organization-settings.spec.ts @@ -0,0 +1,11 @@ +describe('Settings Screen', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + cy.visit('/settings/organization'); + }); + + it('should send organization invitation', function () { + cy.getByTestId('invite-email-field').type('test-user@email.com'); + cy.getByTestId('submit-btn').click(); + }); +}); diff --git a/apps/web/cypress/tests/settings.spec.ts b/apps/web/cypress/tests/settings.spec.ts new file mode 100644 index 00000000000..feeebd9456e --- /dev/null +++ b/apps/web/cypress/tests/settings.spec.ts @@ -0,0 +1,112 @@ +describe('Settings Screen', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + cy.visit('/settings/widget'); + }); + + it('should update the twilio credentials', function () { + cy.get('.ant-tabs-tab-btn').contains('SMS').click(); + cy.getByTestId('account-sid').clear().type('12345'); + cy.getByTestId('auth-token').clear().type('56789'); + cy.getByTestId('phone-number').clear().type('+1111111'); + cy.getByTestId('submit-update-settings').click(); + cy.reload(); + cy.get('.ant-tabs-tab-btn').contains('SMS').click(); + cy.getByTestId('auth-token').should('have.value', '56789'); + cy.getByTestId('account-sid').should('have.value', '12345'); + }); + + it('should display the embed code successfully', function () { + cy.get('.ant-tabs-tab-btn').contains('In App Center').click(); + + cy.getByTestId('embed-code-snippet').then(function (a) { + expect(a).to.contain(this.session.application.identifier); + expect(a).to.contain('notifire.init'); + }); + }); + + it('should display the api key of the app', function () { + cy.get('.ant-tabs-tab-btn').contains('Api Keys').click(); + cy.getByTestId('api-key-container').contains(this.session.application.apiKeys[0].key); + }); + + it('should update the email channel senderEmail', function () { + cy.get('.ant-tabs-tab-btn').contains('Email settings').click(); + cy.getByTestId('sender-email').type('new-testing@email.com'); + cy.getByTestId('sender-name').type('Test Sender Name'); + cy.getByTestId('submit-update-settings').click(); + cy.reload(); + + cy.get('.ant-tabs-tab-btn').contains('Email settings').click(); + cy.getByTestId('sender-email').should('have.value', 'new-testing@email.com'); + cy.getByTestId('sender-name').should('have.value', 'Test Sender Name'); + }); + + it('should update logo', function () { + cy.fixture('test-logo.png').then((fileContent) => { + cy.getByTestId('upload-image-button').attachFile({ + fileContent: b64toBlob(fileContent), + fileName: 'test-logo.png', + mimeType: 'image/png', + }); + }); + + cy.get('.ant-upload-picture-card-wrapper img').should('have.attr', 'src').should('include', '.png'); + cy.get('.ant-upload-picture-card-wrapper img') + .should('have.attr', 'src') + .should('include', this.session.organization._id); + cy.getByTestId('submit-branding-settings').click(); + + cy.get('.ant-upload-picture-card-wrapper img').should('have.attr', 'src').should('include', '.png'); + cy.get('.ant-upload-picture-card-wrapper img') + .should('have.attr', 'src') + .should('include', this.session.organization._id); + }); + + it.skip('should change look and feel settings', function () { + cy.getByTestId('color-picker').click({ force: true }); + cy.get('.block-picker:visible div[title="#ba68c8"]').click({ force: true }); + cy.getByTestId('color-picker').click({ force: true }); + cy.getByTestId('color-picker-value').should('have.value', '#ba68c8'); + + cy.getByTestId('font-color-picker').click({ force: true }); + cy.get('body').click(); + cy.get('.block-picker:visible div[title="#37D67A"]').click({ force: true }); + cy.getByTestId('font-color-picker').click({ force: true }); + cy.getByTestId('font-color-picker-value').should('have.value', '#37d67a'); + + cy.getByTestId('content-background-picker').click({ force: true }); + cy.get('.block-picker:visible div[title="#2CCCE4"]').click({ force: true }); + cy.getByTestId('content-background-picker').click({ force: true }); + cy.getByTestId('content-background-picker-value').should('have.value', '#2ccce4'); + + cy.getByTestId('font-family-selector').type('Nunito{enter}'); + + cy.getByTestId('submit-branding-settings').click({ force: true }); + cy.reload(); + cy.getByTestId('color-picker-value').should('have.value', '#ba68c8'); + cy.getByTestId('font-color-picker-value').should('have.value', '#37d67a'); + cy.getByTestId('content-background-picker-value').should('have.value', '#2ccce4'); + cy.getByTestId('font-family-selector').contains('Nunito'); + }); +}); + +function b64toBlob(b64Data, contentType = '', sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays: any[] = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + const blob = new Blob(byteArrays, { type: contentType }); + return blob; +} diff --git a/apps/web/cypress/tsconfig.json b/apps/web/cypress/tsconfig.json new file mode 100644 index 00000000000..9b5b966c676 --- /dev/null +++ b/apps/web/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "global.d.ts"], + "exclude": [], + "compilerOptions": { + "types": ["cypress", "cypress-file-upload"], + "lib": ["es2015", "dom"], + "isolatedModules": false, + "allowJs": true, + "noEmit": true + } +} diff --git a/apps/web/netlify.toml b/apps/web/netlify.toml new file mode 100644 index 00000000000..b87b8d3ddaa --- /dev/null +++ b/apps/web/netlify.toml @@ -0,0 +1,4 @@ +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000000..b8bd41ffc77 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,109 @@ +{ + "name": "@notifire/web", + "version": "0.2.58", + "private": true, + "dependencies": { + "@ant-design/icons": "^4.6.2", + "@auth0/nextjs-auth0": "^0.16.0", + "@craco/craco": "^6.1.1", + "@cypress/react": "^5.3.2", + "@cypress/webpack-dev-server": "^1.1.2", + "@editorjs/editorjs": "^2.19.3", + "@editorjs/paragraph": "^2.8.0", + "@jackwilsdon/craco-use-babelrc": "^1.0.0", + "@notifire/shared": "^0.2.29", + "@sentry/react": "^6.3.1", + "@sentry/tracing": "^6.3.1", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "@types/jest": "^26.0.15", + "@types/node": "^12.0.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "ace-builds": "^1.4.12", + "antd": "^4.10.0", + "autoprefixer": "^9.8.6", + "babel-plugin-import": "^1.13.3", + "graphql": "^15.4.0", + "history": "^5.0.0", + "jwt-decode": "^3.1.2", + "less": "^4.1.0", + "lodash.capitalize": "^4.2.1", + "polished": "^4.1.3", + "react": "^17.0.1", + "react-ace": "^9.4.3", + "react-color": "^2.19.3", + "react-css-theme-switcher": "^0.2.2", + "react-custom-scrollbars": "^4.2.1", + "react-dom": "^17.0.1", + "react-editor-js": "^1.9.0", + "react-hook-form": "^7.2.3", + "react-query": "^3.5.16", + "react-router-dom": "^5.2.0", + "react-scripts": "4.0.3", + "react-syntax-highlighter": "^15.4.3", + "styled-components": "^5.2.1", + "typescript": "^4.1.2", + "uniqid": "^5.3.0", + "web-vitals": "^1.0.1" + }, + "devDependencies": { + "@babel/polyfill": "^7.12.1", + "@babel/preset-env": "^7.13.15", + "@babel/preset-react": "^7.13.13", + "@babel/preset-typescript": "^7.13.0", + "@babel/runtime": "^7.14.6", + "@notifire/dal": "^0.2.33", + "@notifire/testing": "^0.2.33", + "@types/react": "^17.0.0", + "@types/styled-components": "^5.1.7", + "babel-plugin-styled-components": "^1.12.0", + "craco-antd": "^1.19.0", + "cypress": "^7.3.0", + "cypress-file-upload": "^5.0.7", + "cypress-localstorage-commands": "^1.4.0", + "eslint-plugin-cypress": "^2.11.2", + "http-server": "^0.12.3", + "less-loader": "4.1.0", + "start-server-and-test": "1.11.6" + }, + "scripts": { + "start": "PORT=4200 craco start", + "build": "craco build", + "test": "craco test", + "precommit": "lint-staged", + "start:static:build": "http-server build -p 4200 --proxy http://localhost:4200?", + "start:dev": "npm run start", + "cypress:run": "NODE_ENV=test cypress run", + "cypress:open": "NODE_ENV=test cypress open", + "start:api": "cd ../../ && yarn run start:e2e:api" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "workspaces": { + "nohoist": [ + "**/react-scripts", + "**/react-scripts/**", + "**/react", + "**/react-dom", + "**/@cypress", + "**/@cypress/**" + ] + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint" + ] + } +} diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 00000000000..4965832f2c9 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/index.html b/apps/web/public/index.html new file mode 100644 index 00000000000..5a1778229df --- /dev/null +++ b/apps/web/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + Notifire Manage Platform + + + +
+ + + diff --git a/apps/web/public/logo192.png b/apps/web/public/logo192.png new file mode 100644 index 00000000000..fc44b0a3796 Binary files /dev/null and b/apps/web/public/logo192.png differ diff --git a/apps/web/public/logo512.png b/apps/web/public/logo512.png new file mode 100644 index 00000000000..a4e47a6545b Binary files /dev/null and b/apps/web/public/logo512.png differ diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000000..080d6c77ac2 --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 00000000000..e9e57dc4d41 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/web/public/static/images/avatar.png b/apps/web/public/static/images/avatar.png new file mode 100644 index 00000000000..09892098aa9 Binary files /dev/null and b/apps/web/public/static/images/avatar.png differ diff --git a/apps/web/public/static/images/login_bg.jpg b/apps/web/public/static/images/login_bg.jpg new file mode 100644 index 00000000000..2adb050ee2e Binary files /dev/null and b/apps/web/public/static/images/login_bg.jpg differ diff --git a/apps/web/public/static/images/login_bg.png b/apps/web/public/static/images/login_bg.png new file mode 100644 index 00000000000..97542da9d24 Binary files /dev/null and b/apps/web/public/static/images/login_bg.png differ diff --git a/apps/web/public/static/images/login_illustration.png b/apps/web/public/static/images/login_illustration.png new file mode 100644 index 00000000000..ad0252cfb2e Binary files /dev/null and b/apps/web/public/static/images/login_illustration.png differ diff --git a/apps/web/public/static/images/login_illustration.svg b/apps/web/public/static/images/login_illustration.svg new file mode 100644 index 00000000000..e0a5d06981e --- /dev/null +++ b/apps/web/public/static/images/login_illustration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/static/images/logo-black-white.png b/apps/web/public/static/images/logo-black-white.png new file mode 100644 index 00000000000..4285a1453e2 Binary files /dev/null and b/apps/web/public/static/images/logo-black-white.png differ diff --git a/apps/web/public/static/images/logo-light.png b/apps/web/public/static/images/logo-light.png new file mode 100644 index 00000000000..4ec35000c47 Binary files /dev/null and b/apps/web/public/static/images/logo-light.png differ diff --git a/apps/web/public/static/images/logo.png b/apps/web/public/static/images/logo.png new file mode 100644 index 00000000000..e439e7fc10e Binary files /dev/null and b/apps/web/public/static/images/logo.png differ diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg new file mode 100644 index 00000000000..fbf0e25a651 --- /dev/null +++ b/apps/web/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 00000000000..2a68616d984 --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 00000000000..0a33f9a14e9 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import * as Sentry from '@sentry/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Route, Switch, Redirect, BrowserRouter } from 'react-router-dom'; +import { Integrations } from '@sentry/tracing'; +import { AuthContext } from './store/authContext'; +import { applyToken, getToken, useAuthController } from './store/use-auth-controller'; +import './styles/index.less'; +import { ActivitiesPage } from './pages/activities/ActivitiesPage'; +import LoginPage from './pages/auth/login'; +import SignUpPage from './pages/auth/signup'; +import HomePage from './pages/HomePage'; +import ApplicationOnBoarding from './pages/onboarding/application'; +import TemplateEditorPage from './pages/templates/editor/TemplateEditorPage'; +import NotificationList from './pages/templates/TemplatesListPage'; +import { AppLayout } from './components/layout/app-layout/AppLayout'; +import { WidgetSettingsPage } from './pages/settings/WidgetSettingsPage'; +import { OrganizationSettingsPage } from './pages/organization-settings/OrganizationSettingsPage'; +import InvitationScreen from './pages/auth/InvitationScreen'; +import { api } from './api/api.client'; +import PasswordResetPage from './pages/auth/password-reset'; + +if (process.env.REACT_APP_SENTRY_DSN) { + Sentry.init({ + dsn: process.env.REACT_APP_SENTRY_DSN, + integrations: [new Integrations.BrowserTracing()], + environment: process.env.REACT_APP_ENVIRONMENT, + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + // We recommend adjusting this value in production + tracesSampleRate: 1.0, + }); +} + +const defaultQueryFn = async ({ queryKey }: { queryKey: string }) => { + const response = await api.get(`${queryKey[0]}`); + return response.data?.data; +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + queryFn: defaultQueryFn as any, + }, + }, +}); + +const tokenStoredToken: string = getToken(); +applyToken(tokenStoredToken); + +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function PrivateRoute({ children, ...rest }: any) { + return ( + { + return getToken() ? ( + children + ) : ( + + ); + }} + /> + ); +} + +function AuthHandlerComponent({ children }: { children: React.ReactNode }) { + const { token, setToken, user, logout } = useAuthController(); + + return ( + + {children} + + ); +} + +export default Sentry.withProfiler(App); + +// diff --git a/apps/web/src/api/activity.ts b/apps/web/src/api/activity.ts new file mode 100644 index 00000000000..c7077d6ee69 --- /dev/null +++ b/apps/web/src/api/activity.ts @@ -0,0 +1,14 @@ +import { api } from './api.client'; + +export function getActivityList(page = 0, filters) { + return api.getFullResponse(`/v1/activity`, { + page, + channels: filters?.channels, + templates: filters?.templates, + search: filters?.search, + }); +} + +export function getActivityStats() { + return api.get(`/v1/activity/stats`); +} diff --git a/apps/web/src/api/api.client.ts b/apps/web/src/api/api.client.ts new file mode 100644 index 00000000000..a560ccaba8c --- /dev/null +++ b/apps/web/src/api/api.client.ts @@ -0,0 +1,45 @@ +import axios from 'axios'; +import { API_ROOT } from '../config'; + +export const api = { + get(url: string) { + return axios + .get(`${API_ROOT}${url}`) + .then((response) => { + return response.data?.data; + }) + .catch((error) => { + // eslint-disable-next-line promise/no-return-wrap + return Promise.reject(error?.response?.data || error?.response || error); + }); + }, + getFullResponse(url: string, params?: { [key: string]: string | string[] | number }) { + return axios + .get(`${API_ROOT}${url}`, { + params, + }) + .then((response) => response.data) + .catch((error) => { + // eslint-disable-next-line promise/no-return-wrap + return Promise.reject(error?.response?.data || error?.response || error); + }); + }, + put(url: string, payload) { + return axios + .put(`${API_ROOT}${url}`, payload) + .then((response) => response.data?.data) + .catch((error) => { + // eslint-disable-next-line promise/no-return-wrap + return Promise.reject(error?.response?.data || error?.response || error); + }); + }, + post(url: string, payload) { + return axios + .post(`${API_ROOT}${url}`, payload) + .then((response) => response.data?.data) + .catch((error) => { + // eslint-disable-next-line promise/no-return-wrap + return Promise.reject(error?.response?.data || error?.response || error); + }); + }, +}; diff --git a/apps/web/src/api/application.ts b/apps/web/src/api/application.ts new file mode 100644 index 00000000000..de0e7a33355 --- /dev/null +++ b/apps/web/src/api/application.ts @@ -0,0 +1,21 @@ +import { api } from './api.client'; + +export function getCurrentApplication() { + return api.get('/v1/applications/me'); +} + +export function getApiKeys() { + return api.get(`/v1/applications/api-keys`); +} + +export function updateEmailSettings(payload: { senderEmail: string; senderName: string }) { + return api.put(`/v1/channels/email/settings`, payload); +} + +export function updateSmsSettings(payload: { authToken: string; accountSid: string; phoneNumber: string }) { + return api.put(`/v1/channels/sms/settings`, { twillio: payload }); +} + +export function updateBrandingSettings(payload: { color: string | undefined; logo: string | undefined }) { + return api.put(`/v1/applications/branding`, payload); +} diff --git a/apps/web/src/api/hooks/use-application.ts b/apps/web/src/api/hooks/use-application.ts new file mode 100644 index 00000000000..68ebd96d452 --- /dev/null +++ b/apps/web/src/api/hooks/use-application.ts @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query'; +import { IApplication } from '@notifire/shared'; +import { getCurrentApplication } from '../application'; + +export function useApplication() { + const { data: application, isLoading, refetch } = useQuery('currentApplication', getCurrentApplication); + + return { + application, + loading: isLoading, + refetch, + }; +} diff --git a/apps/web/src/api/hooks/use-templates.ts b/apps/web/src/api/hooks/use-templates.ts new file mode 100644 index 00000000000..5b0dadcf30f --- /dev/null +++ b/apps/web/src/api/hooks/use-templates.ts @@ -0,0 +1,12 @@ +import { useQuery } from 'react-query'; +import { INotificationTemplate } from '@notifire/shared'; +import { getNotificationsList } from '../notifications'; + +export function useTemplates() { + const { data, isLoading } = useQuery('notificationsList', getNotificationsList); + + return { + templates: data, + loading: isLoading, + }; +} diff --git a/apps/web/src/api/invitation.ts b/apps/web/src/api/invitation.ts new file mode 100644 index 00000000000..55e01e3d799 --- /dev/null +++ b/apps/web/src/api/invitation.ts @@ -0,0 +1,5 @@ +import { api } from './api.client'; + +export function getInviteTokenData(token: string) { + return api.get(`/v1/invites/${token}`); +} diff --git a/apps/web/src/api/notifications.ts b/apps/web/src/api/notifications.ts new file mode 100644 index 00000000000..27df0f837f3 --- /dev/null +++ b/apps/web/src/api/notifications.ts @@ -0,0 +1,9 @@ +import { api } from './api.client'; + +export function getNotificationsList() { + return api.get(`/v1/notification-templates`); +} + +export function getNotificationGroups() { + return api.get(`/v1/notification-groups`); +} diff --git a/apps/web/src/api/organization.ts b/apps/web/src/api/organization.ts new file mode 100644 index 00000000000..49caabf1c2b --- /dev/null +++ b/apps/web/src/api/organization.ts @@ -0,0 +1,17 @@ +import { MemberRoleEnum } from '@notifire/shared'; +import { api } from './api.client'; + +export function getOrganizationMembers() { + return api.get(`/v1/organizations/members`); +} + +export function getCurrentOrganization() { + return api.get(`/v1/organizations/me`); +} + +export function inviteMember(email: string) { + return api.post(`/v1/invites`, { + email, + role: MemberRoleEnum.ADMIN, + }); +} diff --git a/apps/web/src/api/storage.ts b/apps/web/src/api/storage.ts new file mode 100644 index 00000000000..bfe2f40973c --- /dev/null +++ b/apps/web/src/api/storage.ts @@ -0,0 +1,5 @@ +import { api } from './api.client'; + +export function getSignedUrl(extension: string) { + return api.get(`/v1/storage/upload-url?extension=${extension}`); +} diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts new file mode 100644 index 00000000000..4a90a5c31fa --- /dev/null +++ b/apps/web/src/api/templates.ts @@ -0,0 +1,18 @@ +import { ICreateNotificationTemplateDto } from '@notifire/shared'; +import { api } from './api.client'; + +export async function createTemplate(data: ICreateNotificationTemplateDto) { + return api.post(`/v1/notification-templates`, data); +} + +export async function updateTemplate(templateId: string, data: Partial) { + return api.put(`/v1/notification-templates/${templateId}`, data); +} + +export async function getTemplateById(id: string) { + return api.get(`/v1/notification-templates/${id}`); +} + +export async function updateTemplateStatus(templateId: string, active: boolean) { + return api.put(`/v1/notification-templates/${templateId}/status`, { active }); +} diff --git a/apps/web/src/api/user.ts b/apps/web/src/api/user.ts new file mode 100644 index 00000000000..df1699b1902 --- /dev/null +++ b/apps/web/src/api/user.ts @@ -0,0 +1,5 @@ +import { api } from './api.client'; + +export async function getUser() { + return api.get('/v1/users/me'); +} diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx new file mode 100644 index 00000000000..1d3c5510a73 --- /dev/null +++ b/apps/web/src/components/auth/LoginForm.tsx @@ -0,0 +1,100 @@ +import { Button, Form, Input, Divider, Alert } from 'antd'; +import Icon, { MailOutlined, LockOutlined } from '@ant-design/icons'; +import { useHistory, Link } from 'react-router-dom'; +import { useContext } from 'react'; +import { useMutation } from 'react-query'; +import * as Sentry from '@sentry/react'; +import { AuthContext } from '../../store/authContext'; +import { api } from '../../api/api.client'; + +type Props = {}; + +export function LoginForm({}: Props) { + const router = useHistory(); + const { setToken } = useContext(AuthContext); + const { isLoading, mutateAsync, isError, error } = useMutation< + { token: string }, + { error: string; message: string; statusCode: number }, + { + email: string; + password: string; + } + >((data) => api.post(`/v1/auth/login`, data)); + + const onLogin = async (data) => { + const itemData = { + email: data.email, + password: data.password, + }; + + try { + const response = await mutateAsync(itemData); + setToken((response as any).token); + router.push('/templates'); + } catch (e: any) { + if (e.statusCode !== 400) { + Sentry.captureException(e); + } + } + }; + + return ( + <> +
+ + } /> + + + Password + + Forget Password? + +
+ } + rules={[ + { + required: true, + message: 'Please input your password', + }, + ]}> + } /> + + + + + {/*
+ + or + +
+ +
+
*/} + + {isError ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/auth/PasswordResetForm.tsx b/apps/web/src/components/auth/PasswordResetForm.tsx new file mode 100644 index 00000000000..c2cae9b9f63 --- /dev/null +++ b/apps/web/src/components/auth/PasswordResetForm.tsx @@ -0,0 +1,113 @@ +import { Button, Form, Input, Divider, Alert, message } from 'antd'; +import { LockOutlined, MailOutlined } from '@ant-design/icons'; +import { useHistory, Link } from 'react-router-dom'; +import { useMutation } from 'react-query'; +import * as Sentry from '@sentry/react'; +import { useContext } from 'react'; +import { AuthContext } from '../../store/authContext'; +import { api } from '../../api/api.client'; + +type Props = { + token: string; +}; + +export function PasswordResetForm({ token }: Props) { + const { setToken } = useContext(AuthContext); + + const history = useHistory(); + const { isLoading, mutateAsync, isError, error } = useMutation< + { token: string }, + { error: string; message: string; statusCode: number }, + { + password: string; + token: string; + } + >((data) => api.post(`/v1/auth/reset`, data)); + + const onForgotPassword = async (data) => { + if (data.password !== data.passwordRepeat) { + return message.error('Passwords do not match'); + } + + const itemData = { + password: data.password, + token, + }; + + try { + const response = await mutateAsync(itemData); + setToken(response.token); + message.success('Password was changed successfully'); + history.push('/templates'); + } catch (e: any) { + if (e.statusCode !== 400) { + Sentry.captureException(e); + } + } + + return true; + }; + + return ( + <> +
+ + Password + + } + rules={[ + { + required: true, + message: 'Please input your password', + }, + { + min: 8, + message: 'Minimum 8 characters', + }, + { + pattern: /^(?=.*\d)(?=.*[a-z])(?!.*\s).{8,}$/, + message: 'The password must contain numbers and letters', + }, + ]}> + } /> + + + Repeat Password + + } + rules={[ + { + required: true, + message: 'Please input your password', + }, + { + min: 8, + message: 'Minimum 8 characters', + }, + { + pattern: /^(?=.*\d)(?=.*[a-z])(?!.*\s).{8,}$/, + message: 'The password must contain numbers and letters', + }, + ]}> + } /> + + + + +
+ {isError ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/auth/PasswordResetRequestForm.tsx b/apps/web/src/components/auth/PasswordResetRequestForm.tsx new file mode 100644 index 00000000000..dd807d61535 --- /dev/null +++ b/apps/web/src/components/auth/PasswordResetRequestForm.tsx @@ -0,0 +1,63 @@ +import { Button, Form, Input, Divider, Alert, Result } from 'antd'; +import { MailOutlined } from '@ant-design/icons'; +import { Link } from 'react-router-dom'; +import { useMutation } from 'react-query'; +import * as Sentry from '@sentry/react'; +import { api } from '../../api/api.client'; + +type Props = { + onSent: () => void; +}; + +export function PasswordRequestResetForm({ onSent }: Props) { + const { isLoading, mutateAsync } = useMutation< + { success: boolean }, + { error: string; message: string; statusCode: number }, + { + email: string; + } + >((data) => api.post(`/v1/auth/reset/request`, data)); + + const onForgotPassword = async (data) => { + const itemData = { + email: data.email, + }; + + try { + const response = await mutateAsync(itemData); + onSent(); + } catch (e: any) { + if (e.statusCode !== 400) { + Sentry.captureException(e); + } + } + }; + + return ( + <> +
+ + } /> + + + + +
+ + ); +} diff --git a/apps/web/src/components/auth/SignUpForm.tsx b/apps/web/src/components/auth/SignUpForm.tsx new file mode 100644 index 00000000000..4ac225b4168 --- /dev/null +++ b/apps/web/src/components/auth/SignUpForm.tsx @@ -0,0 +1,146 @@ +import { Alert, Button, Form, Input, message } from 'antd'; +import { LockOutlined, MailOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'; +import { useContext } from 'react'; +import { useMutation } from 'react-query'; +import { useHistory } from 'react-router-dom'; +import { AuthContext } from '../../store/authContext'; +import { api } from '../../api/api.client'; + +type Props = { + token?: string; + email?: string; +}; + +export function SignUpForm({ token, email }: Props) { + const router = useHistory(); + const { setToken } = useContext(AuthContext); + const { isLoading: loadingAcceptInvite, mutateAsync: acceptInvite } = useMutation< + string, + { error: string; message: string; statusCode: number }, + string + >((tokenItem) => api.post(`/v1/invites/${tokenItem}/accept`, {})); + + const { isLoading, mutateAsync, isError, error } = useMutation< + { token: string }, + { error: string; message: string; statusCode: number }, + { + firstName: string; + lastName: string; + email: string; + password: string; + } + >((data) => api.post(`/v1/auth/register`, data)); + + const onSubmit = async (data) => { + const itemData = { + firstName: data.fullName.split(' ')[0], + lastName: data.fullName.split(' ')[1], + email: data.email, + password: data.password, + organizationName: data.organizationName, + }; + + if (!itemData.lastName) { + return message.error('Please write your full name including last name'); + } + const response = await mutateAsync(itemData); + setToken((response as any).token); + + if (token) { + const responseInvite = await acceptInvite(token); + setToken(responseInvite); + } + + router.push('/templates'); + return true; + }; + + return ( + <> + {isError ? : null} + +
+ + } placeholder="Your full name goes here" /> + + + } + placeholder="Work email goes here" + /> + + + } + placeholder="Password, not your birthdate" + /> + + {!token ? ( + + } placeholder="Mega Corp" /> + + ) : null} + + + + +
+ + ); +} diff --git a/apps/web/src/components/layout/LoginLayout.tsx b/apps/web/src/components/layout/LoginLayout.tsx new file mode 100644 index 00000000000..0711f4bddc3 --- /dev/null +++ b/apps/web/src/components/layout/LoginLayout.tsx @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +type Props = { + children: JSX.Element; +}; + +export function AuthLayout({ children }: Props) { + return ( + <> +
{children}
+ + ); +} diff --git a/apps/web/src/components/layout/app-layout/AppLayout.tsx b/apps/web/src/components/layout/app-layout/AppLayout.tsx new file mode 100644 index 00000000000..722903b9535 --- /dev/null +++ b/apps/web/src/components/layout/app-layout/AppLayout.tsx @@ -0,0 +1,116 @@ +import React, { useContext, useEffect } from 'react'; +import { Layout, Grid, Result, Button, Divider } from 'antd'; +import * as Sentry from '@sentry/react'; +import { ThemeProvider } from 'styled-components'; +import { HeaderNav } from '../components/HeaderNav'; +import { SideNav } from '../components/SideNav'; +import { AuthContext } from '../../../store/authContext'; +import { useApplication } from '../../../api/hooks/use-application'; + +const { Header, Content, Footer } = Layout; + +export function AppLayout({ children }: { children: any }) { + const authContext = useContext(AuthContext); + const { application } = useApplication(); + const theme = { + colors: { + main: application?.branding?.color || '#cd5450', + }, + layout: { + direction: application?.branding?.direction || 'ltr', + }, + }; + + useEffect(() => { + if ( + (process.env.REACT_APP_ENVIRONMENT === 'dev' || process.env.REACT_APP_ENVIRONMENT === 'prod') && + authContext.currentUser + ) { + (function (n, o, t, i, f) { + let m; + /* eslint-disable */ + (n[i] = {}), (m = ['init']); + n[i]._c = []; + m.forEach( + (me) => + (n[i][me] = function () { + n[i]._c.push([me, arguments]); + }) + ); + const elt: any = o.createElement(f); + elt.type = 'text/javascript'; + elt.async = true; + elt.src = t; + const before = o.getElementsByTagName(f)[0]; + before.parentNode?.insertBefore(elt, before); + })(window, document, process.env.REACT_APP_WIDGET_SDK_PATH, 'notifire', 'script'); + + (window as any).notifire.init( + process.env.REACT_APP_NOTIFIRE_APP_ID, + { bellSelector: '#notification-bell', unseenBadgeSelector: '#unseen-badge-selector' }, + { + $user_id: authContext.currentUser?._id, + $last_name: authContext.currentUser?.lastName, + $first_name: authContext.currentUser?.firstName, + $email: authContext.currentUser?.email, + } + ); + } + }, [authContext.currentUser]); + + return ( + <> + + + + + + + ( + <> + + Sorry, but something went wrong.
+ Our team been notified about it and we will look at it asap. +
+ + } + extra={ + <> +
+
+ +
+ + + + Event Id: {eventId}. +
+ {error.toString()} +
+
+
+ + } + /> + + )}> + + {children} +
Notifire ©2021
+
+
+
+
+
+
+ + ); +} diff --git a/apps/web/src/components/layout/components/HeaderNav.tsx b/apps/web/src/components/layout/components/HeaderNav.tsx new file mode 100644 index 00000000000..041068de979 --- /dev/null +++ b/apps/web/src/components/layout/components/HeaderNav.tsx @@ -0,0 +1,112 @@ +import styled from 'styled-components'; +import { Menu, Dropdown, Avatar, Layout, Button } from 'antd'; +import { MenuUnfoldOutlined, SettingOutlined, LogoutOutlined, BellOutlined } from '@ant-design/icons'; +import { useQuery } from 'react-query'; +import { IOrganizationEntity, IUserEntity } from '@notifire/shared'; +import { useContext } from 'react'; +import * as capitalize from 'lodash.capitalize'; +import { AuthContext } from '../../../store/authContext'; +import { getUser } from '../../../api/user'; +import { getCurrentOrganization } from '../../../api/organization'; + +const { Header } = Layout; + +type Props = {}; +const menuItem = [ + { + title: 'Invite Members', + icon: SettingOutlined, + path: '/settings/organization', + }, +]; + +export function HeaderNav({}: Props) { + const authContext = useContext(AuthContext); + const { data: user, isLoading: isUserLoading } = useQuery('/v1/users/me', getUser); + const { data: organization, isLoading: isOrganizationLoading } = useQuery( + '/v1/organizations/me', + getCurrentOrganization + ); + + const profileMenu = ( +
+
+
+ +
+

+ {capitalize(user?.firstName as string)} {capitalize(user?.lastName as string)} +

+ + {capitalize(organization?.name as string)} + +
+
+
+
+ + {menuItem.map((el, i) => { + return ( + + + {el.title} + + + ); + })} + + + + + Sign Out + + + +
+
+ ); + + return ( + <> +
+
+ logo +
+
+
+
+ +
+ + + + + + + + +
+
+
+ + ); +} + +const StyledUnseenCounter = styled.span` + position: absolute !important; +`; diff --git a/apps/web/src/components/layout/components/PageHeader.tsx b/apps/web/src/components/layout/components/PageHeader.tsx new file mode 100644 index 00000000000..f1a49d455d2 --- /dev/null +++ b/apps/web/src/components/layout/components/PageHeader.tsx @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export function PageHeader({ actions, title }: { actions?: JSX.Element; title: string }) { + return ( + +
+
+

{title}

+ {actions &&
{actions}
} +
+
+
+ ); +} + +const HeaderWrapper = styled.div` + padding: 25px; + background: white; + margin-top: -25px; + margin-left: -25px; + margin-right: -25px; + margin-bottom: 25px; + + h2 { + margin-bottom: 0; + } +`; diff --git a/apps/web/src/components/layout/components/SideNav.tsx b/apps/web/src/components/layout/components/SideNav.tsx new file mode 100644 index 00000000000..58d87e47891 --- /dev/null +++ b/apps/web/src/components/layout/components/SideNav.tsx @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import { Layout, Menu } from 'antd'; +import { Scrollbars } from 'react-custom-scrollbars'; +import { useQuery } from 'react-query'; +import { IOrganizationEntity } from '@notifire/shared'; +import { NavLink } from 'react-router-dom'; +import { SettingOutlined, NotificationOutlined, MonitorOutlined, TeamOutlined } from '@ant-design/icons'; + +const { Sider } = Layout; + +type Props = {}; + +export function SideNav({}: Props) { + const { data: organization, isLoading: isOrganizationLoading } = useQuery( + '/v1/organizations/me' + ); + + return ( + + + + }> + + Notifications + + + }> + + Activity Feed + + + }> + + Settings + + + }> + + Team Members + + + + + + ); +} diff --git a/apps/web/src/components/layout/components/TopNav.tsx b/apps/web/src/components/layout/components/TopNav.tsx new file mode 100644 index 00000000000..dd3796a8106 --- /dev/null +++ b/apps/web/src/components/layout/components/TopNav.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import { Menu } from 'antd'; + +type Props = {}; + +export function TopNav({}: Props) { + return ( + <> +
+
+ + + Test Path + + +
+
+ + ); +} diff --git a/apps/web/src/components/onboarding/ApplicationCreateForm.tsx b/apps/web/src/components/onboarding/ApplicationCreateForm.tsx new file mode 100644 index 00000000000..e4b3674cdf0 --- /dev/null +++ b/apps/web/src/components/onboarding/ApplicationCreateForm.tsx @@ -0,0 +1,72 @@ +import { Button, Form, Input } from 'antd'; +import { TeamOutlined } from '@ant-design/icons'; +import { useContext, useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import decode from 'jwt-decode'; +import { IJwtPayload } from '@notifire/shared'; +import { useHistory } from 'react-router-dom'; +import { AuthContext } from '../../store/authContext'; +import { api } from '../../api/api.client'; + +type Props = {}; + +export function ApplicationCreateForm({}: Props) { + const history = useHistory(); + + const { setToken, token } = useContext(AuthContext); + + const [loading, setLoading] = useState(); + const { mutateAsync } = useMutation< + { _id: string }, + { error: string; message: string; statusCode: number }, + { + name: string; + } + >((data) => api.post(`/v1/applications`, data)); + + useEffect(() => { + if (token) { + const userData = decode(token); + if (userData.applicationId) { + history.push('/'); + } + } + }, []); + + const onSubmit = async (data) => { + setLoading(true); + const itemData = { + name: data.applicationName, + }; + + const response = (await mutateAsync(itemData)) as any; + const tokenResponse = await api.post(`/v1/auth/applications/${response.data._id}/switch`, data); + setToken(tokenResponse.data.token); + setLoading(false); + history.push('/'); + }; + + return ( + <> +
+ + } placeholder="Amazing App" /> + + + + +
+ + ); +} diff --git a/apps/web/src/components/query-builder/components/Builder.tsx b/apps/web/src/components/query-builder/components/Builder.tsx new file mode 100644 index 00000000000..1ceed738903 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Builder.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import uniqid from 'uniqid'; +import { BuilderFieldOperator, BuilderFieldType } from '@notifire/shared'; +import { Strings, strings as defaultStrings } from '../constants/strings'; +import { assignIds } from '../utils/assignIds'; +import { denormalizeTree } from '../utils/denormalizeTree'; +import { normalizeTree } from '../utils/normalizeTree'; +import { Button } from './Button'; +import { Component } from './Component/Component'; +import { BuilderContextProvider } from './Context'; +import { Input } from './Form/Input'; +import { Select } from './Form/Select'; +import { SelectMulti } from './Form/SelectMulti'; +import { Switch } from './Form/Switch'; +import { Group } from './Group/Group'; +import { Option as GroupHeaderOption } from './Group/Option'; +import { Iterator } from './Iterator'; +import { Text } from './Text'; +import { DeleteButton } from './RemoveButton'; +/* eslint react/prop-types: 0 */ +/* eslint no-param-reassign: 0 */ + +export const StyledBuilder = styled.div` + background: #fff; +`; + +export interface BuilderFieldProps { + field: string; + label: string; + value?: string | string[] | boolean | Array<{ value: React.ReactText; label: string }>; + type: BuilderFieldType; + /* List of available operators */ + operators?: BuilderFieldOperator[]; +} + +export interface BuilderComponentsProps { + form?: { + Select?: any; + SelectMulti?: any; + Switch?: any; + Input?: any; + }; + Remove?: any; + Add?: any; + Component?: any; + Group?: any; + GroupHeaderOption?: any; + Text?: any; +} + +export interface BuilderProps { + fields: BuilderFieldProps[]; + data: any; + components?: BuilderComponentsProps; + strings?: Strings; + readOnly?: boolean; + onChange?: (data: any) => any; +} + +export const defaultComponents: BuilderComponentsProps = { + form: { + Input, + Select, + SelectMulti, + Switch, + }, + Remove: DeleteButton, + Add: Button, + Component, + Group, + GroupHeaderOption, + Text, +}; + +export const Builder: React.FC = ({ + data: originalData, + fields, + components = defaultComponents, + strings = defaultStrings, + readOnly = false, + onChange, +}) => { + let normalizedData: any; + originalData = assignIds(originalData); + + if (originalData.length === 0) { + originalData = [ + { + type: 'GROUP', + value: 'AND', + isNegated: false, + id: uniqid(), + children: originalData, + }, + ]; + } + + try { + normalizedData = normalizeTree(originalData); + } catch (e) { + throw new Error('Input data tree is in invalid format'); + } + + const [data, setData] = useState(normalizedData); + const filteredData = data.filter((item: any) => !item.parent); + + useEffect(() => { + handleChange(normalizedData); + }, []); + + const handleChange = (nextData: any) => { + if (onChange) { + try { + onChange(denormalizeTree(nextData)); + } catch (e) { + throw new Error('Input data tree is in invalid format'); + } + } + }; + + return ( + + + + + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Button.tsx b/apps/web/src/components/query-builder/components/Button.tsx new file mode 100644 index 00000000000..11dd1071fdf --- /dev/null +++ b/apps/web/src/components/query-builder/components/Button.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Button as ButtonBase } from 'antd'; + +export interface ButtonProps { + onClick: () => void; + className?: string; + label: string; +} + +export const Button: React.FC = ({ label, onClick, ...rest }: ButtonProps) => ( + + {label} + +); diff --git a/apps/web/src/components/query-builder/components/Component/Component.tsx b/apps/web/src/components/query-builder/components/Component/Component.tsx new file mode 100644 index 00000000000..f8b57fe9eb6 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Component/Component.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +export interface ComponentProps { + children: React.ReactNode | React.ReactNodeArray; + controls: React.ReactNode | React.ReactNodeArray; + className?: string; +} + +export const Paper = styled.div<{ hideBorder?: boolean }>` + background-color: #ffffff; + + ${({ hideBorder }) => { + return hideBorder + ? css` + border: none; + ` + : css` + border: 1px solid #edf2f9 !important; + `; + }} +`; + +const Container = styled(Paper)` + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + margin: 0.5rem 0; +`; + +const Content = styled.div` + padding: 1rem; + display: grid; + grid-auto-columns: max-content; + grid-auto-flow: column; + grid-gap: 0.5rem; +`; + +const Header = styled.div` + padding: 1rem; + display: grid; + grid-auto-columns: max-content; + grid-auto-flow: column; + justify-content: flex-end; +`; + +export const Component: React.FC = ({ children, controls, ...rest }: ComponentProps) => ( + + {children} +
{controls}
+
+); diff --git a/apps/web/src/components/query-builder/components/Component/index.tsx b/apps/web/src/components/query-builder/components/Component/index.tsx new file mode 100644 index 00000000000..8700860e447 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Component/index.tsx @@ -0,0 +1,135 @@ +import React, { useContext } from 'react'; +import styled from 'styled-components'; +import { BuilderFieldOperator } from '@notifire/shared'; +import { clone } from '../../utils/clone'; +import { isBoolean, isOptionList, isString, isStringArray, isUndefined } from '../../utils/types'; +import { BuilderContext } from '../Context'; +import { Boolean } from '../Widgets/Boolean'; +import { FieldSelect } from '../Widgets/FieldSelect'; +import { Input } from '../Widgets/Input'; +import { OperatorSelect } from '../Widgets/OperatorSelect'; +import { Select } from '../Widgets/Select'; +import { SelectMulti } from '../Widgets/SelectMulti'; + +const BooleanContainer = styled.div` + align-self: center; +`; + +export interface ComponentProps { + field: string; + value?: string | string[] | boolean; + operator?: BuilderFieldOperator; + id: string; +} + +export const Component: React.FC = ({ + field: fieldRef, + value: selectedValue, + operator, + id, +}: ComponentProps) => { + const { fields, data, setData, onChange, components, strings, readOnly } = useContext(BuilderContext); + const { Component: ComponentContainer, Remove } = components; + + const handleDelete = () => { + let clonedData = clone(data); + const index = clonedData.findIndex((item: any) => item.id === id); + const parentIndex = clonedData.findIndex((item: any) => item.id === clonedData[index].parent); + const parent = clonedData[parentIndex]; + + parent.children = parent.children.filter((item: string) => item !== id); + clonedData = clonedData.filter((item: any) => item.id !== id); + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (fields && strings.component) { + if (fieldRef === '') { + return ( + } + data-test-id="filter-builder-row"> + + + ); + } + try { + const fieldIndex = fields.findIndex((item) => item.field === fieldRef); + + const { field, operators, type, value: fieldValue } = fields[fieldIndex]; + + const operatorsOptionList = + operators && + operators.map((item) => ({ + value: item, + label: strings.operators && strings.operators[item], + })); + + return ( + } + data-test-id="filter-builder-row"> + + + {type === 'BOOLEAN' && isBoolean(selectedValue) && ( + + + + )} + + {type === 'LIST' && isString(selectedValue) && isOptionList(fieldValue) && isOptionList(operatorsOptionList) && ( + <> + + {operator && } + + )} + + {type === 'NUMBER' && + isOptionList(operatorsOptionList) && + (isString(selectedValue) || isStringArray(selectedValue)) && ( + <> + + {operator && } + + )} + + {type === 'DATE' && + isOptionList(operatorsOptionList) && + (isString(selectedValue) || isStringArray(selectedValue)) && ( + <> + + {!isUndefined(operator) && } + + )} + + ); + } catch (e) { + // tslint:disable-next-line: no-console + console.error(`Field "${fieldRef}" not found in fields definition.`); + } + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Context.tsx b/apps/web/src/components/query-builder/components/Context.tsx new file mode 100644 index 00000000000..a3b17fcee77 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Context.tsx @@ -0,0 +1,77 @@ +import React, { createContext } from 'react'; +import { Strings, strings as defaultStrings } from '../constants/strings'; +import { BuilderComponentsProps, BuilderFieldProps, defaultComponents } from './Builder'; +/* eslint react/prop-types: 0 */ +export interface BuilderContextProps { + fields: BuilderFieldProps[]; + data: any; + readOnly: boolean; + components: BuilderComponentsProps; + strings: Strings; + setData: React.Dispatch; + onChange?: (data: any) => void; +} + +export const BuilderContext = createContext( + // tslint:disable-next-line: no-object-literal-type-assertion + {} as BuilderContextProps +); + +export interface BuilderContextProviderProps extends BuilderContextProps { + children: React.ReactNode | React.ReactNodeArray; +} + +export const BuilderContextProvider: React.FC = ({ + fields, + components, + strings, + data, + readOnly, + setData, + onChange, + children, +}) => { + // eslint-disable-next-line no-param-reassign + components = { + ...defaultComponents, + ...components, + form: { ...defaultComponents.form, ...components.form }, + }; + + // eslint-disable-next-line no-param-reassign + strings = { + ...defaultStrings, + ...strings, + component: { + ...defaultStrings.component, + ...strings.component, + }, + form: { + ...defaultStrings.form, + ...strings.form, + }, + group: { + ...defaultStrings.group, + ...strings.group, + }, + operators: { + ...defaultStrings.operators, + ...strings.operators, + }, + }; + + return ( + + {children} + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Form/Input.tsx b/apps/web/src/components/query-builder/components/Form/Input.tsx new file mode 100644 index 00000000000..f31626b4e2b --- /dev/null +++ b/apps/web/src/components/query-builder/components/Form/Input.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Input as InputBase, DatePicker } from 'antd'; +import moment from 'moment'; + +export interface InputProps { + type: 'date' | 'number' | 'text'; + value: string; + onChange: (value: string) => void; + className?: string; + disabled?: boolean; +} + +export const Input: React.FC = ({ onChange, value, type }: InputProps) => { + const handleChange = (event: React.ChangeEvent) => { + onChange(event.target.value); + }; + + const handleChangeDate = (date: any) => { + onChange(date.format('YYYY-MM-DD')); + }; + + if (type === 'date') { + return ; + } + return ; +}; diff --git a/apps/web/src/components/query-builder/components/Form/Select.tsx b/apps/web/src/components/query-builder/components/Form/Select.tsx new file mode 100644 index 00000000000..6b192809a1f --- /dev/null +++ b/apps/web/src/components/query-builder/components/Form/Select.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Select as SelectBase } from 'antd'; + +export interface SelectProps { + values: Array<{ value: string; label: string }>; + selectedValue?: string; + emptyValue?: string; + onChange: (value: any) => void; + className?: string; + disabled?: boolean; +} + +const { Option } = SelectBase; + +export const Select: React.FC = ({ onChange, selectedValue, values }: SelectProps) => { + const handleChange = (value: string) => { + onChange(value); + }; + + return ( + + + {values.map((option) => ( + + ))} + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Form/SelectMulti.tsx b/apps/web/src/components/query-builder/components/Form/SelectMulti.tsx new file mode 100644 index 00000000000..6e1e90e3efb --- /dev/null +++ b/apps/web/src/components/query-builder/components/Form/SelectMulti.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Select } from 'antd'; +import { SelectProps } from './Select'; + +const { Option } = Select; + +export interface SelectMultiProps extends Pick { + onDelete: (value: string) => void; + selectedValue: string[]; + emptyValue?: string; + disabled?: boolean; + className?: string; +} + +export const SelectMulti: React.FC = ({ + onChange, + onDelete, + selectedValue, + values, +}: SelectMultiProps) => { + const handleChange = (value: any) => { + onChange(String(value)); + }; + + const handleDelete = (value: any) => { + onDelete(String(value)); + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Form/Switch.tsx b/apps/web/src/components/query-builder/components/Form/Switch.tsx new file mode 100644 index 00000000000..626a5c85cd3 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Form/Switch.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; +import { colors } from '../../constants/colors'; + +const Knob = styled.div` + position: absolute; + width: 1.3rem; + height: 1.3rem; + background: white; + border: 1px solid ${colors.dark}; + border-radius: 50%; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.4); +`; + +const StyledSwitch = styled.div<{ switched: boolean; disabled: boolean }>` + position: relative; + width: 3rem; + height: 1.65rem; + background-color: ${({ switched }) => (switched ? colors.primary : colors.darker)}; + border: 1px solid ${colors.dark}; + border-radius: 1.4rem; + cursor: pointer; + transition: all 0.5s; + + ${({ disabled }) => + disabled && + css` + background-color: ${colors.darker}; + cursor: initial; + + ${Knob} { + background: ${colors.disabled}; + } + `} + + ${Knob} { + top: 0.1rem; + left: ${({ switched }) => (switched ? '1.3rem' : '0.1rem')}; + transition: all 0.5s; + } +`; + +export interface SwitchProps { + switched: boolean; + onChange?: (value: boolean) => void; + disabled?: boolean; + className?: string; +} + +export const Switch: React.FC = ({ switched, onChange, disabled = false, className }: SwitchProps) => { + const handleClick = () => { + if (onChange && !disabled) { + onChange(!switched); + } + }; + + return ( + + + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Group/Group.tsx b/apps/web/src/components/query-builder/components/Group/Group.tsx new file mode 100644 index 00000000000..b99b277735d --- /dev/null +++ b/apps/web/src/components/query-builder/components/Group/Group.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import ButtonGroup from 'antd/es/button/button-group'; +import styled, { css } from 'styled-components'; +import { Paper } from '../Component/Component'; + +export interface GroupProps { + controlsLeft?: React.ReactNode | React.ReactNodeArray; + controlsRight?: React.ReactNode | React.ReactNodeArray; + children: React.ReactNode | React.ReactNodeArray; + className?: string; + isRoot?: boolean; + show?: boolean; +} + +const Container = styled(Paper)` + margin: 0.5rem 0; + background-color: #f9f9f9; + + && { + background-color: #f9f9f9; +`; + +const Content = styled.div` + padding: 1rem; +`; + +const Header = styled.div` + padding: 1rem 1rem 0; + display: flex; + justify-content: center; +`; + +const Right = styled.div` + display: grid; + grid-auto-flow: column; + grid-auto-columns: min-content; + grid-auto-rows: min-content; + align-items: center; + justify-content: end; + grid-gap: 0.5rem; +`; + +export const Group: React.FC = ({ children, controlsLeft, controlsRight, isRoot, show }: GroupProps) => { + return ( + + {show && ( +
+
+ {controlsLeft} +
+
+ )} + {children} +
+ ); +}; diff --git a/apps/web/src/components/query-builder/components/Group/Option.tsx b/apps/web/src/components/query-builder/components/Group/Option.tsx new file mode 100644 index 00000000000..7ce829e8be9 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Group/Option.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Button } from 'antd'; + +export interface OptionProps { + children: React.ReactNode | React.ReactNodeArray; + value: any; + onClick: (value: any) => void; + disabled: boolean; + isSelected: boolean; + className?: string; +} + +export const Option: React.FC = ({ children, isSelected, onClick, value }: OptionProps) => { + const handleClick = () => { + onClick(value); + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/query-builder/components/Group/index.tsx b/apps/web/src/components/query-builder/components/Group/index.tsx new file mode 100644 index 00000000000..673542a2694 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Group/index.tsx @@ -0,0 +1,163 @@ +import React, { useContext } from 'react'; +import uniqid from 'uniqid'; +import { BuilderGroupValues } from '@notifire/shared'; +import { Switch } from 'antd'; +import { clone } from '../../utils/clone'; +import { BuilderContext } from '../Context'; + +export interface GroupProps { + value?: BuilderGroupValues; + isNegated?: boolean; + children?: React.ReactNode | React.ReactNodeArray; + id: string; + isRoot: boolean; +} + +export const Group: React.FC = ({ value, isNegated, children, id, isRoot }: GroupProps) => { + const { components, data, setData, onChange, strings, readOnly } = useContext(BuilderContext); + const { Add, Group: GroupContainer, GroupHeaderOption: Option, Remove } = components; + + const findIndex = () => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + let insertAfter = parentIndex; + + if (data[parentIndex].children && data[parentIndex].children.length > 0) { + const lastChildren = clonedData[parentIndex].children.slice(-1)[0]; + insertAfter = clonedData.findIndex((item: any) => item.id === lastChildren); + } + + return { insertAfter, parentIndex, clonedData }; + }; + + const addItem = (payload: any) => { + const { insertAfter, parentIndex, clonedData } = findIndex(); + + if (!clonedData[parentIndex].children) { + clonedData[insertAfter].children = []; + } + + clonedData[parentIndex].children.push(payload.id); + clonedData.splice(Number(insertAfter) + 1, 0, payload); + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + const handleAddGroup = () => { + const EmptyGroup: any = { + type: 'GROUP', + value: 'AND', + isNegated: false, + id: uniqid(), + parent: id, + children: [], + }; + + addItem(EmptyGroup); + }; + + const handleAddRule = () => { + const EmptyRule: any = { + field: '', + id: uniqid(), + parent: id, + }; + + addItem(EmptyRule); + }; + + const handleChangeGroupType = (nextValue: BuilderGroupValues) => { + const { clonedData, parentIndex } = findIndex(); + clonedData[parentIndex].value = nextValue; + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + const handleToggleNegateGroup = (nextValue: boolean) => { + const { clonedData, parentIndex } = findIndex(); + clonedData[parentIndex].isNegated = nextValue; + + setData(clonedData); + + if (onChange) { + onChange(clonedData); + } + }; + + const handleDeleteGroup = () => { + let clonedData = clone(data).filter((item: any) => item.id !== id); + + clonedData = clonedData.map((item: any) => { + if (item.children && item.children.length > 0) { + // eslint-disable-next-line no-param-reassign + item.children = item.children.filter((childId: string) => childId !== id); + } + + return item; + }); + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (strings.group) { + return ( + 2} + controlsLeft={ + <> + {data?.length > 2 && ( + <> + + + + )} + {/* */} + + }> + {children} + {!readOnly && ( +
+ + {/* + +*/} + {!isRoot && } +
+ )} +
+ ); + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Iterator.tsx b/apps/web/src/components/query-builder/components/Iterator.tsx new file mode 100644 index 00000000000..175854b2cfd --- /dev/null +++ b/apps/web/src/components/query-builder/components/Iterator.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Component, ComponentProps } from './Component/index'; +import { Group, GroupProps } from './Group/index'; +/* eslint react/prop-types: 0 */ + +export interface IteratorProps { + originalData: any; + filteredData: any; + isRoot?: boolean; +} + +export const Iterator: React.FC = ({ originalData, filteredData, isRoot = true }) => { + return ( + <> + {filteredData.map((item: any) => { + if (typeof item.children !== 'undefined') { + const items: any = []; + + item.children.forEach((id: any) => { + items.push(originalData.filter((fitem: any) => id === fitem.id)[0]); + }); + + if (item.type === 'GROUP') { + const { id, value, isNegated } = item as GroupProps; + + return ( + + + + ); + } + + return null; + } + const { field, value, id, operator } = item as ComponentProps; + + return ( + + ); + })} + + ); +}; diff --git a/apps/web/src/components/query-builder/components/RemoveButton.tsx b/apps/web/src/components/query-builder/components/RemoveButton.tsx new file mode 100644 index 00000000000..b5e98c35807 --- /dev/null +++ b/apps/web/src/components/query-builder/components/RemoveButton.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Button as ButtonBase, Tooltip } from 'antd'; +import styled from 'styled-components'; +import { MinusCircleOutlined } from '@ant-design/icons'; +import { ButtonProps } from './Button'; + +const StyledButton = styled(ButtonBase)` + white-space: nowrap; +`; + +export const DeleteButton: React.FC = ({ label, onClick }: ButtonProps) => ( + + } ghost /> + +); diff --git a/apps/web/src/components/query-builder/components/SecondaryButton.tsx b/apps/web/src/components/query-builder/components/SecondaryButton.tsx new file mode 100644 index 00000000000..6a0612087d6 --- /dev/null +++ b/apps/web/src/components/query-builder/components/SecondaryButton.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components'; +import { colors } from '../constants/colors'; +import { Button, ButtonProps } from './Button'; + +export const SecondaryButton = styled(Button)` + background-color: ${colors.tertiary}; +`; diff --git a/apps/web/src/components/query-builder/components/Text.tsx b/apps/web/src/components/query-builder/components/Text.tsx new file mode 100644 index 00000000000..135f4e6e267 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Text.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +import { colors } from '../constants/colors'; + +export const Text = styled.span` + min-width: 160px; + padding: 0.4rem 0.6rem; + color: ${colors.dark}; + line-height: 1.3; + border: 1px solid ${colors.medium}; + border-radius: 3px; +`; diff --git a/apps/web/src/components/query-builder/components/Widgets/Boolean.tsx b/apps/web/src/components/query-builder/components/Widgets/Boolean.tsx new file mode 100644 index 00000000000..d32c3722fda --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/Boolean.tsx @@ -0,0 +1,33 @@ +import React, { useContext } from 'react'; +import { clone } from '../../utils/clone'; +import { BuilderContext } from '../Context'; + +export interface BooleanProps { + selectedValue: boolean; + id: string; +} + +// tslint:disable-next-line: variable-name +export const Boolean: React.FC = ({ selectedValue, id }: BooleanProps) => { + const { data, setData, onChange, components, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (value: boolean) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + + clonedData[parentIndex].value = value; + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (form) { + return ; + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Widgets/FieldSelect.tsx b/apps/web/src/components/query-builder/components/Widgets/FieldSelect.tsx new file mode 100644 index 00000000000..c9f1b9fafd0 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/FieldSelect.tsx @@ -0,0 +1,102 @@ +import React, { useContext } from 'react'; +import { clone } from '../../utils/clone'; +import { isOptionList } from '../../utils/types'; +import { BuilderContext } from '../Context'; + +interface FieldSelectProps { + selectedValue: string; + id: string; +} + +export const FieldSelect: React.FC = ({ selectedValue, id }: FieldSelectProps) => { + const { fields, data, setData, onChange, components, strings, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (value: string) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + const nextField = fields.filter((item) => item.field === value)[0]; + + clonedData[parentIndex].field = value; + delete clonedData[parentIndex].value; + delete clonedData[parentIndex].operators; + delete clonedData[parentIndex].operator; + + // eslint-disable-next-line default-case + switch (nextField.type) { + case 'BOOLEAN': + clonedData[parentIndex].value = false; + break; + + case 'DATE': + clonedData[parentIndex].value = + nextField.operators && ['BETWEEN', 'NOT_BETWEEN'].includes(nextField.operators[0]) ? ['', ''] : ''; + + clonedData[parentIndex].operator = nextField.operators && nextField.operators[0]; + clonedData[parentIndex].operators = nextField.operators; + break; + + case 'TEXT': + clonedData[parentIndex].value = + nextField.operators && ['BETWEEN', 'NOT_BETWEEN'].includes(nextField.operators[0]) ? ['', ''] : ''; + + clonedData[parentIndex].operator = nextField.operators && nextField.operators[0]; + clonedData[parentIndex].operators = nextField.operators; + break; + + case 'NUMBER': + clonedData[parentIndex].value = + nextField.operators && ['BETWEEN', 'NOT_BETWEEN'].includes(nextField.operators[0]) ? ['0', '0'] : '0'; + + clonedData[parentIndex].operator = nextField.operators && nextField.operators[0]; + clonedData[parentIndex].operators = nextField.operators; + break; + + case 'LIST': + if (isOptionList(nextField.value)) { + clonedData[parentIndex].value = nextField.value[0].value; + } + + clonedData[parentIndex].operator = nextField.operators && nextField.operators[0]; + clonedData[parentIndex].operators = nextField.operators; + break; + + case 'MULTI_LIST': + if (isOptionList(nextField.value)) { + clonedData[parentIndex].value = []; + } + + clonedData[parentIndex].operator = nextField.operators && nextField.operators[0]; + clonedData[parentIndex].operators = nextField.operators; + break; + case 'STATEMENT': + clonedData[parentIndex].value = nextField.value; + break; + } + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + const fieldNames = fields.map((field) => ({ + value: field.field, + label: field.label, + })); + + if (form && strings.form) { + return ( + + ); + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Widgets/Input.tsx b/apps/web/src/components/query-builder/components/Widgets/Input.tsx new file mode 100644 index 00000000000..961546045f2 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/Input.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from 'react'; +import { clone } from '../../utils/clone'; +import { isStringArray, isUndefined } from '../../utils/types'; +import { BuilderContext } from '../Context'; + +interface InputProps { + type: 'date' | 'number' | 'text'; + value: string | string[]; + id: string; +} + +export const Input: React.FC = ({ type, value, id }: InputProps) => { + const { data, setData, onChange, components, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (changedValue: any, index?: number) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + + if (!isUndefined(index)) { + clonedData[parentIndex].value[index] = changedValue; + } else { + clonedData[parentIndex].value = changedValue; + } + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (form) { + if (isStringArray(value)) { + return ( + <> + handleChange(changedValue, 0)} + disabled={readOnly} + /> + handleChange(changedValue, 1)} + disabled={readOnly} + /> + + ); + } + + return ; + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Widgets/OperatorSelect.tsx b/apps/web/src/components/query-builder/components/Widgets/OperatorSelect.tsx new file mode 100644 index 00000000000..de7ba08c3e7 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/OperatorSelect.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from 'react'; +import { BuilderFieldOperator } from '@notifire/shared'; +import { clone } from '../../utils/clone'; +import { isStringArray } from '../../utils/types'; +import { BuilderContext } from '../Context'; + +export interface OperatorSelectValuesProps { + value: BuilderFieldOperator; + label?: string; +} + +export interface OperatorSelectProps { + values: OperatorSelectValuesProps[]; + selectedValue?: BuilderFieldOperator; + id: string; +} + +export const OperatorSelect: React.FC = ({ values, selectedValue, id }: OperatorSelectProps) => { + const { fields, data, setData, onChange, components, strings, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (value: BuilderFieldOperator) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + const fieldIndex = fields.findIndex((item: any) => clonedData[parentIndex].field === item.field); + + if (['DATE', 'TEXT', 'NUMBER'].includes(fields[fieldIndex].type)) { + if (!['BETWEEN', 'NOT_BETWEEN'].includes(value) && isStringArray(clonedData[parentIndex].value)) { + clonedData[parentIndex].value = fields[fieldIndex].type === 'NUMBER' ? '0' : ''; + } else if (['BETWEEN', 'NOT_BETWEEN'].includes(value) && !isStringArray(clonedData[parentIndex].value)) { + clonedData[parentIndex].value = fields[fieldIndex].type === 'NUMBER' ? ['0', '0'] : ['', '']; + } + } + + clonedData[parentIndex].operator = value; + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (form && strings.form) { + return ( + + ); + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Widgets/Select.tsx b/apps/web/src/components/query-builder/components/Widgets/Select.tsx new file mode 100644 index 00000000000..c36bd7b2e59 --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/Select.tsx @@ -0,0 +1,41 @@ +import React, { useContext } from 'react'; +import { clone } from '../../utils/clone'; +import { BuilderContext } from '../Context'; + +export interface SelectProps { + selectedValue: string; + values: Array<{ value: string; label: string }>; + id: string; +} + +export const Select: React.FC = ({ selectedValue, values, id }: SelectProps) => { + const { data, setData, onChange, components, strings, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (value: string) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + + clonedData[parentIndex].value = value; + + setData(clonedData); + if (onChange) { + onChange(clonedData); + } + }; + + if (form && strings.form && !readOnly) { + return ( + + ); + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/components/Widgets/SelectMulti.tsx b/apps/web/src/components/query-builder/components/Widgets/SelectMulti.tsx new file mode 100644 index 00000000000..f1f7365efbc --- /dev/null +++ b/apps/web/src/components/query-builder/components/Widgets/SelectMulti.tsx @@ -0,0 +1,56 @@ +import React, { useContext } from 'react'; +import { clone } from '../../utils/clone'; +import { BuilderContext } from '../Context'; + +export interface SelectMultiProps { + values: Array<{ value: string; label: string }>; + selectedValue: string[]; + id: string; +} + +export const SelectMulti: React.FC = ({ values, selectedValue, id }: SelectMultiProps) => { + const { data, setData, onChange, components, strings, readOnly } = useContext(BuilderContext); + + const { form } = components; + + const handleChange = (value: string) => { + if (setData && onChange) { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + + clonedData[parentIndex].value = clonedData[parentIndex].value.filter((item: any) => item !== value); + clonedData[parentIndex].value.push(value); + + setData(clonedData); + onChange(clonedData); + } + }; + + const handleDelete = (value: string) => { + const clonedData = clone(data); + const parentIndex = clonedData.findIndex((item: any) => item.id === id); + + clonedData[parentIndex].value = clonedData[parentIndex].value.filter((item: any) => item !== value); + + setData(clonedData); + + if (onChange) { + onChange(clonedData); + } + }; + + if (form && strings.form) { + return ( + + ); + } + + return null; +}; diff --git a/apps/web/src/components/query-builder/constants/colors.ts b/apps/web/src/components/query-builder/constants/colors.ts new file mode 100644 index 00000000000..75b2120e7ca --- /dev/null +++ b/apps/web/src/components/query-builder/constants/colors.ts @@ -0,0 +1,13 @@ +export const colors = { + primary: '#20639B', + secondary: '#3CAEA3', + tertiary: '#911803', + + light: '#f4f4f4', + medium: '#d9d9d9', + darker: '#bfbfbf', + dark: '#848484', + + enabled: '#3caea3', + disabled: '#9fb2b0', +}; diff --git a/apps/web/src/components/query-builder/constants/strings.ts b/apps/web/src/components/query-builder/constants/strings.ts new file mode 100644 index 00000000000..41914691c14 --- /dev/null +++ b/apps/web/src/components/query-builder/constants/strings.ts @@ -0,0 +1,63 @@ +export interface Strings { + group?: { + not?: string; + or?: string; + and?: string; + addRule?: string; + addGroup?: string; + delete?: string; + }; + component?: { + delete?: string; + }; + form?: { + selectYourValue?: string; + }; + operators?: { + LARGER?: string; + SMALLER?: string; + LARGER_EQUAL?: string; + SMALLER_EQUAL?: string; + EQUAL?: string; + NOT_EQUAL?: string; + ALL_IN?: string; + ANY_IN?: string; + NOT_IN?: string; + BETWEEN?: string; + NOT_BETWEEN?: string; + LIKE?: string; + NOT_LIKE?: string; + }; +} + +export const strings: Strings = { + group: { + not: 'Not', + or: 'Or', + and: 'And', + addRule: 'Add Filter', + addGroup: 'Add Group', + delete: 'Delete', + }, + component: { + delete: 'Delete', + }, + form: { + selectYourValue: 'Select your value', + }, + operators: { + LARGER: 'Larger', + SMALLER: 'Smaller', + LARGER_EQUAL: 'Larger or equal', + SMALLER_EQUAL: 'Smaller or equal', + EQUAL: 'Equal', + NOT_EQUAL: 'Not equal', + ALL_IN: 'All in', + ANY_IN: 'Any in', + NOT_IN: 'Not in', + BETWEEN: 'Between', + NOT_BETWEEN: 'Not between', + LIKE: 'Like', + NOT_LIKE: 'Not like', + }, +}; diff --git a/apps/web/src/components/query-builder/utils/assignIds.ts b/apps/web/src/components/query-builder/utils/assignIds.ts new file mode 100644 index 00000000000..fc7960bc331 --- /dev/null +++ b/apps/web/src/components/query-builder/utils/assignIds.ts @@ -0,0 +1,21 @@ +import uniqid from 'uniqid'; +import { clone } from './clone'; +/* eslint react/prop-types: 0 */ +/* eslint no-param-reassign: 0 */ + +export const assignIds = (data: any): any => { + data = { children: clone(data) }; + + const run = (d: any): any => { + if (typeof d.children !== 'undefined') { + d.children = d.children.map((item: any) => { + item.id = uniqid(); + return run(item); + }); + } + + return d; + }; + + return run(data).children; +}; diff --git a/apps/web/src/components/query-builder/utils/clone.ts b/apps/web/src/components/query-builder/utils/clone.ts new file mode 100644 index 00000000000..af89b327f91 --- /dev/null +++ b/apps/web/src/components/query-builder/utils/clone.ts @@ -0,0 +1 @@ +export const clone = (data: any) => JSON.parse(JSON.stringify(data)); diff --git a/apps/web/src/components/query-builder/utils/denormalizeTree.ts b/apps/web/src/components/query-builder/utils/denormalizeTree.ts new file mode 100644 index 00000000000..0f6b1f024ff --- /dev/null +++ b/apps/web/src/components/query-builder/utils/denormalizeTree.ts @@ -0,0 +1,40 @@ +import { clone } from './clone'; +/* eslint no-param-reassign: 0 */ + +export const denormalizeTree = (data: any) => { + const clonedData: any = clone(data); + const denormalizedData: any = clonedData.filter((item: any) => !item.parent); + + const run = (d: any, originalData: any) => { + // eslint-disable-next-line array-callback-return + d.map((item: any) => { + if (typeof item.children !== 'undefined') { + const tmpItem = clone(item); + + // eslint-disable-next-line no-param-reassign + delete item.children; + delete item.id; + delete item.parent; + delete item.operators; + + item.children = []; + + // eslint-disable-next-line array-callback-return + tmpItem.children.map((id: any) => { + const clonedChildrenData = clone(originalData.filter((oItem: any) => oItem.id === id)[0]); + + delete clonedChildrenData.id; + delete clonedChildrenData.parent; + delete clonedChildrenData.operators; + + item.children.push(clonedChildrenData); + }); + + run(item.children, originalData); + } + }); + }; + + run(denormalizedData, clonedData); + return denormalizedData; +}; diff --git a/apps/web/src/components/query-builder/utils/normalizeTree.ts b/apps/web/src/components/query-builder/utils/normalizeTree.ts new file mode 100644 index 00000000000..5d498b094a3 --- /dev/null +++ b/apps/web/src/components/query-builder/utils/normalizeTree.ts @@ -0,0 +1,39 @@ +import { clone } from './clone'; +/* eslint no-param-reassign: 0 */ + +export const normalizeTree = (data: any[]) => { + const clonedData: any = { children: clone(data) }; + const normalizedData: any = []; + + const run = (d: any, parentId = 0) => { + if (typeof d.children !== 'undefined') { + const children: any = []; + + // eslint-disable-next-line array-callback-return + d.children.map((item: any) => { + if (parentId !== 0) { + item.parent = parentId; + } + + const tmpItem = clone(item); + delete tmpItem.children; + + normalizedData.push(tmpItem); + children.push(tmpItem.id); + + run(item, item.id); + }); + + if (parentId !== 0) { + for (const item of normalizedData) { + if (item.id === parentId) { + item.children = children; + } + } + } + } + }; + + run(clonedData); + return normalizedData; +}; diff --git a/apps/web/src/components/query-builder/utils/types.ts b/apps/web/src/components/query-builder/utils/types.ts new file mode 100644 index 00000000000..3e7f72e5838 --- /dev/null +++ b/apps/web/src/components/query-builder/utils/types.ts @@ -0,0 +1,33 @@ +import { BuilderFieldOperator } from '@notifire/shared'; + +export const isBoolean = (value: any): value is boolean => { + return typeof value === 'boolean'; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isNumber = (value: any): value is number => { + return typeof value === 'number'; +}; + +export const isUndefined = (value: any): value is undefined => { + return typeof value === 'undefined'; +}; + +export const isArray = (value: any): value is any[] => { + return Array.isArray(value); +}; + +export const isStringArray = (value: any): value is string[] => { + return isArray(value) && value.every((item: any) => isString(item)); +}; + +export const isOptionList = (value: any): value is Array<{ value: string; label: string }> => { + return isArray(value) && value.every((item: any) => isString(item.value) && isString(item.label)); +}; + +export const isOperator = (value: any): value is BuilderFieldOperator => { + return !!value; +}; diff --git a/apps/web/src/components/templates/EmailContentCard.tsx b/apps/web/src/components/templates/EmailContentCard.tsx new file mode 100644 index 00000000000..dd05b0fea5b --- /dev/null +++ b/apps/web/src/components/templates/EmailContentCard.tsx @@ -0,0 +1,186 @@ +import { Card, Collapse, Form, Input, Radio } from 'antd'; +import { Control, Controller, useFormContext } from 'react-hook-form'; +import { IApplication, IEmailBlock } from '@notifire/shared'; +import AceEditor from 'react-ace'; + +import 'ace-builds/src-noconflict/mode-handlebars'; +import 'ace-builds/src-noconflict/theme-monokai'; +import styled from 'styled-components'; +import { EmailMessageEditor } from './email-editor/EmailMessageEditor'; +import { Builder, BuilderFieldProps } from '../query-builder/components/Builder'; + +export function EmailContentCard({ + index, + showFilters, + variables = [], + application, +}: { + index: number; + showFilters: boolean; + variables: { + name: string; + }[]; + application: IApplication | undefined; +}) { + const { + control, + formState: { errors }, + getValues, + watch, + } = useFormContext(); // retrieve all hook methods + const contentType = watch(`emailMessages.${index}.template.contentType`); + + const fields: BuilderFieldProps[] = [ + { + field: 'firstName', + type: 'TEXT', + label: 'First Name', + operators: ['EQUAL', 'NOT_EQUAL'], + }, + { + field: 'lastName', + type: 'TEXT', + label: 'Last Name', + operators: ['EQUAL', 'NOT_EQUAL'], + }, + { + field: 'companyName', + type: 'TEXT', + label: 'Company Name', + operators: ['EQUAL', 'NOT_EQUAL'], + }, + { + field: 'companyId', + type: 'TEXT', + label: 'Company Id', + operators: ['EQUAL', 'NOT_EQUAL'], + }, + ]; + + for (const variable of variables) { + const found = fields.find((i) => i.field === variable.name); + + if (!found) { + fields.push({ + field: variable.name, + type: 'TEXT', + label: variable.name, + operators: ['EQUAL', 'NOT_EQUAL'], + }); + } + } + + return ( + <> + + + + { + return ( + + ); + }} + /> + + + + ( + + )} + /> + + {!contentType || contentType === 'editor' ? ( + + { + return ( + + ); + }} + /> + + ) : null} + {contentType === 'customHtml' ? ( + + { + return ( + <> + + + ); + }} + /> + + ) : null} + + + {showFilters && ( + + ( + + )} + /> + + )} + + ); +} + +const SubjectLineWrapper = styled.div``; diff --git a/apps/web/src/components/templates/EmailMessagesCards.tsx b/apps/web/src/components/templates/EmailMessagesCards.tsx new file mode 100644 index 00000000000..fa331a8570f --- /dev/null +++ b/apps/web/src/components/templates/EmailMessagesCards.tsx @@ -0,0 +1,127 @@ +import { FieldArrayWithId, useFormContext } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { Button, Collapse, Popconfirm } from 'antd'; +import { MinusCircleOutlined } from '@ant-design/icons'; +import styled, { css } from 'styled-components'; +import { useQuery } from 'react-query'; +import { IApplication } from '@notifire/shared'; +import { MessageNameEditorHeader } from './MessageNameEditorHeader'; +import { EmailContentCard } from './EmailContentCard'; +import { IForm } from '../../pages/templates/editor/use-template-controller.hook'; +import { getCurrentApplication } from '../../api/application'; + +export function EmailMessagesCards({ + emailMessagesFields, + onRemoveTab, + variables, +}: { + emailMessagesFields: FieldArrayWithId[]; + onRemoveTab: (index: number) => void; + variables: { name: string }[]; +}) { + const { data: application } = useQuery('currentApplication', getCurrentApplication); + + const [activeTab, setActiveTab] = useState(); + + useEffect(() => { + const messageToSelect = emailMessagesFields[emailMessagesFields.length - 1]; + + if (messageToSelect) { + setActiveTab(messageToSelect.id); + } + }, [emailMessagesFields]); + + const showCollapse = emailMessagesFields.length !== 1; + const collapseProps: { expandIcon?: () => null; ghost?: boolean } = {}; + if (!showCollapse) { + collapseProps.expandIcon = () => null; + collapseProps.ghost = true; + } + + useEffect(() => { + setActiveTab(null as any); + setTimeout(() => { + const messageToSelect = emailMessagesFields[emailMessagesFields.length - 1]; + + if (messageToSelect) { + setActiveTab(messageToSelect.id); + } + }, 100); + }, [emailMessagesFields]); + + if (!emailMessagesFields?.length) { + return null; + } + + return ( + <> + + (emailMessagesFields?.length === 1 ? id && setActiveTab(id) : setActiveTab(id))}> + {emailMessagesFields.map((message, index) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }}> + onRemoveTab(index)} + okText="Yes" + cancelText="No"> + + + + ); +} + +const ButtonOptionsMenu = styled.div` + padding: 5px; + background: white; + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); +`; diff --git a/apps/web/src/components/templates/email-editor/ContentRow.tsx b/apps/web/src/components/templates/email-editor/ContentRow.tsx new file mode 100644 index 00000000000..7c98d53472a --- /dev/null +++ b/apps/web/src/components/templates/email-editor/ContentRow.tsx @@ -0,0 +1,149 @@ +import styled from 'styled-components'; +import { Button, Col, Drawer, Dropdown, Form, Input, Menu, Radio, Row } from 'antd'; +import { EllipsisOutlined, SettingOutlined } from '@ant-design/icons'; +import { useRef, useState } from 'react'; +import { IEmailBlock } from '@notifire/shared'; + +export function ContentRow({ + children, + onHoverElement, + onRemove, + allowRemove, + block, + onStyleChanged, +}: { + children: JSX.Element | JSX.Element[]; + onHoverElement: (data: { top: number; height: number }) => void; + onRemove: () => void; + allowRemove: boolean; + block: IEmailBlock; + onStyleChanged: (data: { textDirection: 'ltr' | 'rtl' }) => void; +}) { + const [form] = Form.useForm(); + + const [drawerVisible, setDrawerVisible] = useState(); + const parentRef = useRef(null); + function onHover() { + if (!parentRef.current) return; + + onHoverElement({ + top: parentRef.current.offsetTop, + height: parentRef.current.offsetHeight, + }); + } + + const menu = ( + + setDrawerVisible(true)}> + Style Settings + + {allowRemove && ( + <> + + + Remove Row + + + )} + + ); + + function submitRowStyles() { + setDrawerVisible(false); + onStyleChanged({ + textDirection: form.getFieldsValue().textDirection, + }); + } + + return ( + <> +
+ +
{children}
+
+ + } + /> + +
+
+
+ + setDrawerVisible(false)} + visible={drawerVisible} + bodyStyle={{ paddingBottom: 80 }} + footer={ +
+ + +
+ }> +
+ + + + + + + +
+
+ + ); +} + +const SettingsButton = styled(Button)` + display: none; + right: -10px; + position: absolute; +`; + +const StyledButton = styled(Button)` + display: none; + right: -25px; + position: absolute; +`; + +const ContentRowWrapper = styled.div` + width: 100%; + outline: transparent; + padding-bottom: 10px; + display: flex; + + &:hover { + ${StyledButton} { + display: inline-block; + } + + ${SettingsButton} { + display: inline-block; + } + } +`; diff --git a/apps/web/src/components/templates/email-editor/ControlBar.tsx b/apps/web/src/components/templates/email-editor/ControlBar.tsx new file mode 100644 index 00000000000..e6f1611349a --- /dev/null +++ b/apps/web/src/components/templates/email-editor/ControlBar.tsx @@ -0,0 +1,72 @@ +import { Button, Dropdown, Menu } from 'antd'; +import { BuildOutlined, MenuOutlined, PlusOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; + +export function ControlBar({ top, onBlockAdd }: { top: number; onBlockAdd: (type: 'button' | 'text') => void }) { + const menu = ( + + + + + + + + + ); + + return ( + + + + +
+ + )} + + setActionBarVisible(true)} onMouseLeave={() => setActionBarVisible(false)}> +
+ + {blocks.map((block, index) => { + return ( + onBlockStyleChanged(index, data)} + key={index} + block={block} + onHoverElement={onHoverElement} + onRemove={() => removeBlock(index)} + allowRemove={blocks?.length > 1}> + {[block.type].map((type, blockIndex) => { + if (type === 'text') { + return ( +
onTextChange(block, e)} + style={{ + display: 'inline-block', + width: '100%', + direction: block.styles?.textDirection || 'ltr', + }} + /> + ); + } + + if (type === 'button') { + return ( + { + // eslint-disable-next-line no-param-reassign + block.url = url; + }} + onTextChange={(text) => { + // eslint-disable-next-line no-param-reassign + block.content = text; + }} + /> + ); + } + + return <>; + })} + + ); + })} + + {controlBarVisible && } +
+ + + + ); +} + +const Body = styled.div` + outline: transparent; + z-index: 2; + background: transparent; + position: relative; + display: inline-block; + width: 100%; + * { + outline: none; + } +`; + +const Wrapper = styled.div` + width: 590px; + background: white; + border: 1px solid #e0e0e0; + border-top: 5px solid #ff6f61; + padding: 30px; + position: relative; +`; + +const SectionWrapper = styled.div` + background-color: #f9f9f9; + display: flex; + padding: 30px; + flex-direction: column; + justify-content: space-between; + width: 100%; + align-items: center; +`; + +const LogoWrapper = styled.div` + margin-bottom: 20px; + + img { + max-width: 240px; + } +`; + +const LogoUploaderWrapper = styled.div` + border: 1px dashed #cdcdcd; + padding: 15px 25px; + display: flex; + align-items: center; + justify-content: center; + + flex-direction: column; +`; diff --git a/apps/web/src/components/widget/InAppWidgetPreview.tsx b/apps/web/src/components/widget/InAppWidgetPreview.tsx new file mode 100644 index 00000000000..c5cdd5eb52d --- /dev/null +++ b/apps/web/src/components/widget/InAppWidgetPreview.tsx @@ -0,0 +1,198 @@ +import styled, { css, ThemeProvider, useTheme } from 'styled-components'; +import { lighten } from 'polished'; +import moment from 'moment'; +import { Badge } from 'antd'; +import { BellOutlined } from '@ant-design/icons'; + +export function InAppWidgetPreview({ children }: { children: JSX.Element }) { + const theme = useTheme(); + + return ( + + + + + + + + Notifications + + {/* */} + + + + {children} + + {moment(moment().subtract(5, 'minutes')).fromNow()} + + + + + Example content of notification + + {moment(moment().subtract(25, 'minutes')).fromNow()} + + + + Content of notification + + {moment(moment().subtract(25, 'minutes')).fromNow()} + + + +