Compare commits
1 Commits
2023.4.3
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11b592543 |
104
.dockerignore
Normal file
104
.dockerignore
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
Dockerfile
|
||||||
|
.vscode
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
.firebase
|
||||||
|
|
||||||
|
### Node template
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# 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 (https://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
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# Nuxt generate
|
||||||
|
dist
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# IDE / Editor
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Service worker
|
||||||
|
sw.*
|
||||||
|
|
||||||
|
# Mac OSX
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Vim swap files
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Build data
|
||||||
|
.hoppscotch
|
||||||
|
|
||||||
|
# File explorer
|
||||||
|
.directory
|
||||||
76
.env.example
76
.env.example
@@ -1,59 +1,31 @@
|
|||||||
#-----------------------Backend Config------------------------------#
|
# Google Analytics ID
|
||||||
# Prisma Config
|
VITE_GA_ID=UA-61422507-4
|
||||||
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
|
||||||
|
|
||||||
# Auth Tokens Config
|
# Google Tag Manager ID
|
||||||
JWT_SECRET="secret1233"
|
VITE_GTM_ID=GTM-NMKVBMV
|
||||||
TOKEN_SALT_COMPLEXITY=10
|
|
||||||
MAGIC_LINK_TOKEN_VALIDITY= 3
|
|
||||||
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
|
|
||||||
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
|
|
||||||
SESSION_SECRET='add some secret here'
|
|
||||||
|
|
||||||
# Hoppscotch App Domain Config
|
|
||||||
REDIRECT_URL="http://localhost:3000"
|
|
||||||
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
|
||||||
|
|
||||||
# Google Auth Config
|
|
||||||
GOOGLE_CLIENT_ID="************************************************"
|
|
||||||
GOOGLE_CLIENT_SECRET="************************************************"
|
|
||||||
GOOGLE_CALLBACK_URL="http://localhost:3170/v1/auth/google/callback"
|
|
||||||
GOOGLE_SCOPE="email,profile"
|
|
||||||
|
|
||||||
# Github Auth Config
|
|
||||||
GITHUB_CLIENT_ID="************************************************"
|
|
||||||
GITHUB_CLIENT_SECRET="************************************************"
|
|
||||||
GITHUB_CALLBACK_URL="http://localhost:3170/v1/auth/github/callback"
|
|
||||||
GITHUB_SCOPE="user:email"
|
|
||||||
|
|
||||||
# Microsoft Auth Config
|
|
||||||
MICROSOFT_CLIENT_ID="************************************************"
|
|
||||||
MICROSOFT_CLIENT_SECRET="************************************************"
|
|
||||||
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
|
|
||||||
MICROSOFT_SCOPE="user.read"
|
|
||||||
|
|
||||||
# Mailer config
|
|
||||||
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
|
|
||||||
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
|
|
||||||
|
|
||||||
# Rate Limit Config
|
|
||||||
RATE_LIMIT_TTL=60 # In seconds
|
|
||||||
RATE_LIMIT_MAX=100 # Max requests per IP
|
|
||||||
|
|
||||||
|
|
||||||
#-----------------------Frontend Config------------------------------#
|
|
||||||
|
|
||||||
|
# Firebase config
|
||||||
|
VITE_API_KEY=AIzaSyCMsFreESs58-hRxTtiqQrIcimh4i1wbsM
|
||||||
|
VITE_AUTH_DOMAIN=postwoman-api.firebaseapp.com
|
||||||
|
VITE_DATABASE_URL=https://postwoman-api.firebaseio.com
|
||||||
|
VITE_PROJECT_ID=postwoman-api
|
||||||
|
VITE_STORAGE_BUCKET=postwoman-api.appspot.com
|
||||||
|
VITE_MESSAGING_SENDER_ID=421993993223
|
||||||
|
VITE_APP_ID=1:421993993223:web:ec0baa8ee8c02ffa1fc6a2
|
||||||
|
VITE_MEASUREMENT_ID=G-BBJ3R80PJT
|
||||||
|
|
||||||
# Base URLs
|
# Base URLs
|
||||||
VITE_BASE_URL=http://localhost:3000
|
VITE_BASE_URL=https://hoppscotch.io
|
||||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
VITE_SHORTCODE_BASE_URL=https://hopp.sh
|
||||||
VITE_ADMIN_URL=http://localhost:3100
|
|
||||||
|
|
||||||
# Backend URLs
|
# Backend URLs
|
||||||
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
|
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
|
||||||
VITE_BACKEND_WS_URL=wss://localhost:3170/graphql
|
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
|
||||||
VITE_BACKEND_API_URL=http://localhost:3170/v1
|
|
||||||
|
|
||||||
# Terms Of Service And Privacy Policy Links (Optional)
|
# Sentry (Optional)
|
||||||
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
# VITE_SENTRY_DSN: <Sentry DSN here>
|
||||||
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
# VITE_SENTRY_ENVIRONMENT: <Sentry environment value here>
|
||||||
|
# VITE_SENTRY_RELEASE_TAG: <Sentry release tag here (for release monitoring)>
|
||||||
|
|
||||||
|
# Proxyscotch Access Token (Optional)
|
||||||
|
# VITE_PROXYSCOTCH_ACCESS_TOKEN: <Token Set In Proxyscotch Server>
|
||||||
|
|||||||
93
.github/workflows/codeql-analysis.yml
vendored
93
.github/workflows/codeql-analysis.yml
vendored
@@ -1,63 +1,72 @@
|
|||||||
name: "CodeQL analysis"
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ main ]
|
||||||
schedule:
|
schedule:
|
||||||
# ┌───────────── minute (0 - 59)
|
- cron: '39 7 * * 2'
|
||||||
# │ ┌───────────── hour (0 - 23)
|
|
||||||
# │ │ ┌───────────── day of the month (1 - 31)
|
|
||||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
|
||||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
|
||||||
# │ │ │ │ │
|
|
||||||
# │ │ │ │ │
|
|
||||||
# │ │ │ │ │
|
|
||||||
# * * * * *
|
|
||||||
- cron: '30 1 * * 0'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# required for all workflows
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
# only required for workflows in private repositories
|
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
# Run extended queries including queries using machine learning
|
# Run extended queries including queries using machine learning
|
||||||
queries: security-extended
|
queries: security-extended
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below).
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 https://git.io/JvXDl
|
||||||
|
|
||||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||||
# three lines and modify them (or add more) to build your code if your
|
# and modify them (or add more) to build your code if your project
|
||||||
# project uses a compiled language
|
# uses a compiled language
|
||||||
|
|
||||||
#- run: |
|
#- run: |
|
||||||
# make bootstrap
|
# make bootstrap
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
48
.github/workflows/deploy-netlify.yml
vendored
Normal file
48
.github/workflows/deploy-netlify.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Deploy to Netlify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Push build files to Netlify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Environment
|
||||||
|
run: mv .env.example .env
|
||||||
|
|
||||||
|
- name: Setup and run pnpm install
|
||||||
|
uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Build Site
|
||||||
|
env:
|
||||||
|
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
|
VITE_SENTRY_ENVIRONMENT: production
|
||||||
|
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||||
|
run: pnpm run generate
|
||||||
|
|
||||||
|
# Deploy the production site with netlify-cli
|
||||||
|
- name: Deploy to Netlify (production)
|
||||||
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create Sentry Release
|
||||||
|
uses: getsentry/action-release@v1
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
with:
|
||||||
|
environment: production
|
||||||
|
ignore_missing: true
|
||||||
|
ignore_empty: true
|
||||||
|
version: ${{ github.sha }}
|
||||||
60
.github/workflows/deploy-preview-netlify.yml
vendored
Normal file
60
.github/workflows/deploy-preview-netlify.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Deploy to Preview Netlify
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Push build files to Netlify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup and run pnpm install
|
||||||
|
uses: pnpm/action-setup@v2.2.4
|
||||||
|
env:
|
||||||
|
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Build Site
|
||||||
|
env:
|
||||||
|
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
|
||||||
|
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
|
||||||
|
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
|
||||||
|
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
|
||||||
|
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
|
||||||
|
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
|
||||||
|
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
|
||||||
|
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
|
||||||
|
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
|
||||||
|
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||||
|
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||||
|
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
|
||||||
|
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
|
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||||
|
VITE_SENTRY_ENVIRONMENT: staging
|
||||||
|
run: pnpm run generate
|
||||||
|
|
||||||
|
# Deploy the preview site with netlify-cli
|
||||||
|
- name: Deploy to Netlify (preview)
|
||||||
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --alias=preview
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create Sentry Release
|
||||||
|
uses: getsentry/action-release@v1
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
with:
|
||||||
|
environment: preview
|
||||||
|
ignore_missing: true
|
||||||
|
ignore_empty: true
|
||||||
|
version: ${{ github.sha }}
|
||||||
21
.github/workflows/deploy-prod.yml
vendored
Normal file
21
.github/workflows/deploy-prod.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Deploy to Live Channel
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy_live_website:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Deploy to Firebase (production)
|
||||||
|
uses: FirebaseExtended/action-hosting-deploy@v0
|
||||||
|
with:
|
||||||
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_POSTWOMAN_API }}'
|
||||||
|
channelId: live
|
||||||
|
projectId: postwoman-api
|
||||||
60
.github/workflows/deploy-staging-netlify.yml
vendored
Normal file
60
.github/workflows/deploy-staging-netlify.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Deploy to Staging Netlify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# TODO: Migrate to staging branch only
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Push build files to Netlify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup and run pnpm install
|
||||||
|
uses: pnpm/action-setup@v2.2.4
|
||||||
|
env:
|
||||||
|
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Build Site
|
||||||
|
env:
|
||||||
|
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
|
||||||
|
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
|
||||||
|
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
|
||||||
|
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
|
||||||
|
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
|
||||||
|
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
|
||||||
|
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
|
||||||
|
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
|
||||||
|
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
|
||||||
|
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||||
|
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||||
|
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
|
||||||
|
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
|
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||||
|
VITE_SENTRY_ENVIRONMENT: staging
|
||||||
|
run: pnpm run generate
|
||||||
|
|
||||||
|
# Deploy the staging site with netlify-cli
|
||||||
|
- name: Deploy to Netlify (staging)
|
||||||
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create Sentry Release
|
||||||
|
uses: getsentry/action-release@v1
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
with:
|
||||||
|
environment: staging
|
||||||
|
ignore_missing: true
|
||||||
|
ignore_empty: true
|
||||||
|
version: ${{ github.sha }}
|
||||||
46
.github/workflows/publish-docker.yml
vendored
Normal file
46
.github/workflows/publish-docker.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Push Docker image to Docker Hub
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: hoppscotch/hoppscotch
|
||||||
|
flavor: |
|
||||||
|
latest=true
|
||||||
|
prefix=
|
||||||
|
suffix=
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
19
.github/workflows/tests.yml
vendored
19
.github/workflows/tests.yml
vendored
@@ -2,13 +2,12 @@ name: Node.js CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, staging]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, staging]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
build:
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
@@ -16,22 +15,22 @@ jobs:
|
|||||||
node-version: ["lts/*"]
|
node-version: ["lts/*"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup environment
|
- name: Setup Environment
|
||||||
run: mv .env.example .env
|
run: mv .env.example .env
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup and run pnpm install
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 7
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
- name: Setup node
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -171,6 +171,3 @@ tests/*/videos
|
|||||||
|
|
||||||
# PNPM
|
# PNPM
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
# GQL SDL generated for the frontends
|
|
||||||
gql-gen/
|
|
||||||
|
|||||||
30
CODEOWNERS
30
CODEOWNERS
@@ -1,30 +0,0 @@
|
|||||||
# CODEOWNERS is prioritized from bottom to top
|
|
||||||
|
|
||||||
# If none of the below matched
|
|
||||||
* @AndrewBastin @liyasthomas
|
|
||||||
|
|
||||||
# Packages
|
|
||||||
/packages/codemirror-lang-graphql/ @AndrewBastin
|
|
||||||
/packages/hoppscotch-cli/ @AndrewBastin
|
|
||||||
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
|
|
||||||
/packages/hoppscotch-data/ @AndrewBastin
|
|
||||||
/packages/hoppscotch-js-sandbox/ @AndrewBastin
|
|
||||||
/packages/hoppscotch-ui/ @anwarulislam
|
|
||||||
/packages/hoppscotch-web/ @amk-dev
|
|
||||||
/packages/hoppscotch-selfhost-web/ @amk-dev
|
|
||||||
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
|
|
||||||
/packages/hoppscotch-backend/ @ankitsridhar16 @balub
|
|
||||||
|
|
||||||
# Sections within Hoppscotch Common
|
|
||||||
/packages/hoppscotch-common/src/components @anwarulislam
|
|
||||||
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
|
|
||||||
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
|
|
||||||
/packages/hoppscotch-common/src/composables @amk-dev
|
|
||||||
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
|
|
||||||
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
|
|
||||||
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
|
|
||||||
|
|
||||||
README.md @liyasthomas
|
|
||||||
|
|
||||||
# The lockfile has no owner
|
|
||||||
pnpm-lock.yaml
|
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:lts-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="Hoppscotch (support@hoppscotch.io)"
|
||||||
|
|
||||||
|
# Add git as the prebuild target requires it to parse version information
|
||||||
|
RUN apk add --no-cache --virtual .gyp \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ADD . /app/
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
RUN mv .env.example .env
|
||||||
|
|
||||||
|
RUN pnpm i --unsafe-perm=true
|
||||||
|
|
||||||
|
ENV HOST 0.0.0.0
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
RUN pnpm run generate
|
||||||
|
|
||||||
|
CMD ["pnpm", "run", "start"]
|
||||||
48
README.md
48
README.md
@@ -36,14 +36,14 @@
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="./packages/hoppscotch-common/public/images/banner-light.png"
|
src="./packages/hoppscotch-app/public/images/banner-light.png"
|
||||||
alt="Hoppscotch"
|
alt="Hoppscotch"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="./packages/hoppscotch-common/public/images/banner-dark.png"
|
src="./packages/hoppscotch-app/public/images/banner-dark.png"
|
||||||
alt="Hoppscotch"
|
alt="Hoppscotch"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@@ -161,7 +161,7 @@ _Collections are synced with cloud / local session storage_
|
|||||||
- Access APIs served in non-HTTPS (`http://`) endpoints
|
- Access APIs served in non-HTTPS (`http://`) endpoints
|
||||||
- Use your Proxy URL
|
- Use your Proxy URL
|
||||||
|
|
||||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**_
|
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/privacy)**_
|
||||||
|
|
||||||
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ _Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/h
|
|||||||
|
|
||||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||||
|
|
||||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/features/shortcuts)**
|
||||||
|
|
||||||
🌎 **i18n:** Experience the app in your language.
|
🌎 **i18n:** Experience the app in your language.
|
||||||
|
|
||||||
@@ -279,7 +279,45 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
|||||||
|
|
||||||
## **Developing**
|
## **Developing**
|
||||||
|
|
||||||
Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
|
||||||
|
|
||||||
|
_Sample keys only work with the [production build](https://hoppscotch.io)._
|
||||||
|
|
||||||
|
### Browser-based development environment
|
||||||
|
|
||||||
|
- [GitHub codespace](https://docs.github.com/en/codespaces/developing-in-codespaces/creating-a-codespace)
|
||||||
|
- [Gitpod](https://gitpod.io/#https://github.com/hoppscotch/hoppscotch)
|
||||||
|
|
||||||
|
### Local development environment
|
||||||
|
|
||||||
|
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||||
|
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||||
|
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||||
|
4. Start the development server with `pnpm run dev`.
|
||||||
|
5. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
### Docker compose
|
||||||
|
|
||||||
|
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||||
|
2. Run `docker-compose up` within the directory that you cloned (probably `hoppscotch`).
|
||||||
|
3. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
## **Docker**
|
||||||
|
|
||||||
|
**Official container** [](https://hub.docker.com/r/hoppscotch/hoppscotch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --name hoppscotch -p 3000:3000 hoppscotch/hoppscotch:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## **Releasing**
|
||||||
|
|
||||||
|
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||||
|
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||||
|
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||||
|
4. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
|
||||||
|
5. Build the release files with `pnpm run generate`.
|
||||||
|
6. Find the built project in `packages/hoppscotch-app/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
|
||||||
|
|
||||||
## **Contributing**
|
## **Contributing**
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1,23 @@
|
|||||||
# To make it easier to self-host, we have a preset docker compose config that also
|
|
||||||
# has a container with a Postgres instance running.
|
|
||||||
# You can tweak around this file to match your instances
|
|
||||||
version: "3.7"
|
version: "3.7"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# This service runs the backend app in the port 3170
|
web:
|
||||||
hoppscotch-backend:
|
|
||||||
container_name: hoppscotch-backend
|
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
|
||||||
context: .
|
context: .
|
||||||
target: prod
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
|
||||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
|
||||||
- PORT=3000
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./packages/hoppscotch-backend/:/usr/src/app
|
- "./.hoppscotch:/app/.hoppscotch"
|
||||||
- /usr/src/app/node_modules/
|
- "./assets:/app/assets"
|
||||||
depends_on:
|
- "./directives:/app/directives"
|
||||||
- hoppscotch-db
|
- "./layouts:/app/layouts"
|
||||||
|
- "./middleware:/app/middleware"
|
||||||
|
- "./pages:/app/pages"
|
||||||
|
- "./plugins:/app/plugins"
|
||||||
|
- "./static:/app/static"
|
||||||
|
- "./store:/app/store"
|
||||||
|
- "./components:/app/components"
|
||||||
|
- "./helpers:/app/helpers"
|
||||||
ports:
|
ports:
|
||||||
- "3170:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
# The main hoppscotch app. This will be hosted at port 3000
|
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
|
||||||
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
|
||||||
hoppscotch-app:
|
|
||||||
container_name: hoppscotch-app
|
|
||||||
build:
|
|
||||||
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
|
||||||
context: .
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
depends_on:
|
|
||||||
- hoppscotch-backend
|
|
||||||
ports:
|
|
||||||
- "3000:8080"
|
|
||||||
|
|
||||||
# The Self Host dashboard for managing the app. This will be hosted at port 3100
|
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
|
||||||
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
|
||||||
hoppscotch-sh-admin:
|
|
||||||
container_name: hoppscotch-sh-admin
|
|
||||||
build:
|
|
||||||
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
|
||||||
context: .
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
depends_on:
|
|
||||||
- hoppscotch-backend
|
|
||||||
ports:
|
|
||||||
- "3100:8080"
|
|
||||||
|
|
||||||
# The preset DB service, you can delete/comment the below lines if
|
|
||||||
# you are using an external postgres instance
|
|
||||||
# This will be exposed at port 5432
|
|
||||||
hoppscotch-db:
|
|
||||||
image: postgres
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
environment:
|
environment:
|
||||||
# NOTE: Please UPDATE THIS PASSWORD!
|
HOST: 0.0.0.0
|
||||||
POSTGRES_PASSWORD: testpass
|
command: "pnpm run dev"
|
||||||
POSTGRES_DB: hoppscotch
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[[headers]]
|
[[headers]]
|
||||||
for = "/*"
|
for = "/*"
|
||||||
[headers.values]
|
[headers.values]
|
||||||
X-Frame-Options = "SAMEORIGIN"
|
X-Frame-Options = "DENY"
|
||||||
X-XSS-Protection = "1; mode=block"
|
X-XSS-Protection = "1; mode=block"
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
|
|||||||
@@ -9,15 +9,13 @@
|
|||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"dev": "pnpm -r do-dev",
|
"dev": "pnpm -r do-dev",
|
||||||
"gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl",
|
|
||||||
"generate": "pnpm -r do-build-prod",
|
"generate": "pnpm -r do-build-prod",
|
||||||
"start": "http-server packages/hoppscotch-web/dist -p 3000",
|
"start": "http-server packages/hoppscotch-web/dist -p 3000",
|
||||||
"lint": "pnpm -r do-lint",
|
"lint": "pnpm -r do-lint",
|
||||||
"typecheck": "pnpm -r do-typecheck",
|
"typecheck": "pnpm -r do-typecheck",
|
||||||
"lintfix": "pnpm -r do-lintfix",
|
"lintfix": "pnpm -r do-lintfix",
|
||||||
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
||||||
"test": "pnpm -r do-test",
|
"test": "pnpm -r do-test"
|
||||||
"generate-ui": "pnpm -r do-build-ui"
|
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
@@ -30,7 +28,6 @@
|
|||||||
"@commitlint/cli": "^16.2.3",
|
"@commitlint/cli": "^16.2.3",
|
||||||
"@commitlint/config-conventional": "^16.2.1",
|
"@commitlint/config-conventional": "^16.2.1",
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.24",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"http-server": "^14.1.1"
|
"http-server": "^14.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
./node_modules
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
parserOptions: {
|
|
||||||
project: 'tsconfig.json',
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
|
||||||
extends: [
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
],
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ['.eslintrc.js'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
"no-empty-function": "off",
|
|
||||||
"@typescript-eslint/no-empty-function": "error"
|
|
||||||
},
|
|
||||||
};
|
|
||||||
43
packages/hoppscotch-backend/.gitignore
vendored
43
packages/hoppscotch-backend/.gitignore
vendored
@@ -1,43 +0,0 @@
|
|||||||
# compiled output
|
|
||||||
/dist
|
|
||||||
/node_modules
|
|
||||||
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
.env
|
|
||||||
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
/coverage
|
|
||||||
/.nyc_output
|
|
||||||
|
|
||||||
# IDEs and editors
|
|
||||||
/.idea
|
|
||||||
.project
|
|
||||||
.classpath
|
|
||||||
.c9/
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# IDE - VSCode
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
|
|
||||||
# Generated artifacts (GQL Schema SDL generation etc.)
|
|
||||||
gen/
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
FROM node:18.8.0 AS builder
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
# # Install pnpm
|
|
||||||
RUN npm i -g pnpm
|
|
||||||
|
|
||||||
COPY .env .
|
|
||||||
COPY pnpm-lock.yaml .
|
|
||||||
RUN pnpm fetch
|
|
||||||
|
|
||||||
ENV APP_PORT=${PORT}
|
|
||||||
ENV DB_URL=${DATABASE_URL}
|
|
||||||
|
|
||||||
# # PNPM package install
|
|
||||||
COPY ./packages/hoppscotch-backend .
|
|
||||||
RUN pnpm i --filter hoppscotch-backend
|
|
||||||
|
|
||||||
# Prisma bits
|
|
||||||
RUN pnpm exec prisma generate
|
|
||||||
|
|
||||||
FROM builder AS dev
|
|
||||||
|
|
||||||
ENV PRODUCTION="false"
|
|
||||||
|
|
||||||
CMD ["pnpm", "run", "start:dev"]
|
|
||||||
|
|
||||||
EXPOSE 3170
|
|
||||||
|
|
||||||
|
|
||||||
FROM builder AS prod
|
|
||||||
|
|
||||||
ENV PRODUCTION="true"
|
|
||||||
|
|
||||||
CMD ["pnpm", "run", "start:prod"]
|
|
||||||
|
|
||||||
EXPOSE 3170
|
|
||||||
|
|
||||||
1
packages/hoppscotch-backend/global.d.ts
vendored
1
packages/hoppscotch-backend/global.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
import '@relmify/jest-fp-ts';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
require('@relmify/jest-fp-ts');
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"assets": [
|
|
||||||
"**/*.hbs"
|
|
||||||
],
|
|
||||||
"watchAssets": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "hoppscotch-backend",
|
|
||||||
"version": "2023.4.3",
|
|
||||||
"description": "",
|
|
||||||
"author": "",
|
|
||||||
"private": true,
|
|
||||||
"license": "UNLICENSED",
|
|
||||||
"scripts": {
|
|
||||||
"prebuild": "rimraf dist",
|
|
||||||
"build": "nest build",
|
|
||||||
"generate-gql-sdl": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' GENERATE_GQL_SCHEMA=true WHITELISTED_ORIGINS='' nest start",
|
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
||||||
"start": "nest start",
|
|
||||||
"start:dev": "nest start --watch",
|
|
||||||
"start:debug": "nest start --debug --watch",
|
|
||||||
"start:prod": "node dist/main",
|
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
||||||
"test": "jest",
|
|
||||||
"postinstall": "prisma generate && pnpm run generate-gql-sdl",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:cov": "jest --coverage",
|
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
|
||||||
"do-test": "pnpm run test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs-modules/mailer": "^1.8.1",
|
|
||||||
"@nestjs/apollo": "^10.1.6",
|
|
||||||
"@nestjs/common": "^9.2.1",
|
|
||||||
"@nestjs/core": "^9.2.1",
|
|
||||||
"@nestjs/graphql": "^10.1.6",
|
|
||||||
"@nestjs/jwt": "^10.0.1",
|
|
||||||
"@nestjs/passport": "^9.0.0",
|
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
|
||||||
"@nestjs/throttler": "^4.0.0",
|
|
||||||
"@prisma/client": "^4.7.1",
|
|
||||||
"apollo-server-express": "^3.11.1",
|
|
||||||
"apollo-server-plugin-base": "^3.7.1",
|
|
||||||
"argon2": "^0.30.3",
|
|
||||||
"bcrypt": "^5.1.0",
|
|
||||||
"cookie": "^0.5.0",
|
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"express-session": "^1.17.3",
|
|
||||||
"fp-ts": "^2.13.1",
|
|
||||||
"graphql": "^15.5.0",
|
|
||||||
"graphql-query-complexity": "^0.12.0",
|
|
||||||
"graphql-redis-subscriptions": "^2.5.0",
|
|
||||||
"graphql-subscriptions": "^2.0.0",
|
|
||||||
"handlebars": "^4.7.7",
|
|
||||||
"io-ts": "^2.2.16",
|
|
||||||
"luxon": "^3.2.1",
|
|
||||||
"nodemailer": "^6.9.1",
|
|
||||||
"passport": "^0.6.0",
|
|
||||||
"passport-github2": "^0.1.12",
|
|
||||||
"passport-google-oauth20": "^2.0.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"passport-microsoft": "^1.0.0",
|
|
||||||
"prisma": "^4.7.1",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
|
||||||
"rimraf": "^3.0.2",
|
|
||||||
"rxjs": "^7.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nestjs/cli": "^9.1.5",
|
|
||||||
"@nestjs/schematics": "^9.0.3",
|
|
||||||
"@nestjs/testing": "^9.2.1",
|
|
||||||
"@relmify/jest-fp-ts": "^2.0.2",
|
|
||||||
"@types/argon2": "^0.15.0",
|
|
||||||
"@types/bcrypt": "^5.0.0",
|
|
||||||
"@types/cookie": "^0.5.1",
|
|
||||||
"@types/cookie-parser": "^1.4.3",
|
|
||||||
"@types/express": "^4.17.14",
|
|
||||||
"@types/jest": "^29.4.0",
|
|
||||||
"@types/luxon": "^3.2.0",
|
|
||||||
"@types/node": "^18.11.10",
|
|
||||||
"@types/nodemailer": "^6.4.7",
|
|
||||||
"@types/passport-github2": "^1.2.5",
|
|
||||||
"@types/passport-google-oauth20": "^2.0.11",
|
|
||||||
"@types/passport-jwt": "^3.0.8",
|
|
||||||
"@types/passport-microsoft": "^0.0.0",
|
|
||||||
"@types/supertest": "^2.0.12",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
|
||||||
"@typescript-eslint/parser": "^5.45.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"eslint": "^8.29.0",
|
|
||||||
"eslint-config-prettier": "^8.5.0",
|
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
|
||||||
"jest": "^29.4.1",
|
|
||||||
"jest-mock-extended": "^3.0.1",
|
|
||||||
"jwt": "link:@types/nestjs/jwt",
|
|
||||||
"prettier": "^2.8.4",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"supertest": "^6.3.2",
|
|
||||||
"ts-jest": "29.0.5",
|
|
||||||
"ts-loader": "^9.4.2",
|
|
||||||
"ts-node": "^10.9.1",
|
|
||||||
"tsconfig-paths": "4.1.1",
|
|
||||||
"typescript": "^4.9.3"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"setupFilesAfterEnv": [
|
|
||||||
"../jest.setup.js"
|
|
||||||
],
|
|
||||||
"preset": "ts-jest",
|
|
||||||
"clearMocks": true,
|
|
||||||
"collectCoverage": true,
|
|
||||||
"coverageDirectory": "coverage",
|
|
||||||
"coverageProvider": "v8",
|
|
||||||
"rootDir": "src",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Team" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TeamMember" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"role" "TeamMemberRole" NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"teamID" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TeamInvitation" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"teamID" TEXT NOT NULL,
|
|
||||||
"creatorUid" TEXT NOT NULL,
|
|
||||||
"inviteeEmail" TEXT NOT NULL,
|
|
||||||
"inviteeRole" "TeamMemberRole" NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TeamCollection" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"parentID" TEXT,
|
|
||||||
"teamID" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"orderIndex" INTEGER NOT NULL,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TeamRequest" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"collectionID" TEXT NOT NULL,
|
|
||||||
"teamID" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"request" JSONB NOT NULL,
|
|
||||||
"orderIndex" INTEGER NOT NULL,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Shortcode" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"request" JSONB NOT NULL,
|
|
||||||
"creatorUid" TEXT,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TeamEnvironment" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"teamID" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"variables" JSONB NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"uid" TEXT NOT NULL,
|
|
||||||
"displayName" TEXT,
|
|
||||||
"email" TEXT,
|
|
||||||
"photoURL" TEXT,
|
|
||||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"refreshToken" TEXT,
|
|
||||||
"currentRESTSession" JSONB,
|
|
||||||
"currentGQLSession" JSONB,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("uid")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Account" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"providerAccountId" TEXT NOT NULL,
|
|
||||||
"providerRefreshToken" TEXT,
|
|
||||||
"providerAccessToken" TEXT,
|
|
||||||
"providerScope" TEXT,
|
|
||||||
"loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "VerificationToken" (
|
|
||||||
"deviceIdentifier" TEXT NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"expiresOn" TIMESTAMP(3) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "UserSettings" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"properties" JSONB NOT NULL,
|
|
||||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "UserHistory" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"reqType" "ReqType" NOT NULL,
|
|
||||||
"request" JSONB NOT NULL,
|
|
||||||
"responseMetadata" JSONB NOT NULL,
|
|
||||||
"isStarred" BOOLEAN NOT NULL,
|
|
||||||
"executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "UserEnvironment" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"name" TEXT,
|
|
||||||
"variables" JSONB NOT NULL,
|
|
||||||
"isGlobal" BOOLEAN NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "InvitedUsers" (
|
|
||||||
"adminUid" TEXT NOT NULL,
|
|
||||||
"adminEmail" TEXT NOT NULL,
|
|
||||||
"inviteeEmail" TEXT NOT NULL,
|
|
||||||
"invitedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "UserRequest" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"collectionID" TEXT NOT NULL,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"request" JSONB NOT NULL,
|
|
||||||
"type" "ReqType" NOT NULL,
|
|
||||||
"orderIndex" INTEGER NOT NULL,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "UserRequest_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "UserCollection" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"parentID" TEXT,
|
|
||||||
"userUid" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"orderIndex" INTEGER NOT NULL,
|
|
||||||
"type" "ReqType" NOT NULL,
|
|
||||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "UserCollection_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "InvitedUsers_inviteeEmail_key" ON "InvitedUsers"("inviteeEmail");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "InvitedUsers" ADD CONSTRAINT "InvitedUsers_adminUid_fkey" FOREIGN KEY ("adminUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (i.e. Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Team {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
members TeamMember[]
|
|
||||||
TeamInvitation TeamInvitation[]
|
|
||||||
TeamCollection TeamCollection[]
|
|
||||||
TeamRequest TeamRequest[]
|
|
||||||
TeamEnvironment TeamEnvironment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamMember {
|
|
||||||
id String @id @default(uuid()) // Membership ID
|
|
||||||
role TeamMemberRole
|
|
||||||
userUid String
|
|
||||||
teamID String
|
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamID, userUid])
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamInvitation {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamID String
|
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
|
||||||
creatorUid String
|
|
||||||
inviteeEmail String
|
|
||||||
inviteeRole TeamMemberRole
|
|
||||||
|
|
||||||
@@unique([teamID, inviteeEmail])
|
|
||||||
@@index([teamID])
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamCollection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
parentID String?
|
|
||||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
|
||||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
|
||||||
requests TeamRequest[]
|
|
||||||
teamID String
|
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
|
||||||
title String
|
|
||||||
orderIndex Int
|
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamRequest {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
collectionID String
|
|
||||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
|
||||||
teamID String
|
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
|
||||||
title String
|
|
||||||
request Json
|
|
||||||
orderIndex Int
|
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Shortcode {
|
|
||||||
id String @id
|
|
||||||
request Json
|
|
||||||
creatorUid String?
|
|
||||||
createdOn DateTime @default(now())
|
|
||||||
|
|
||||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamEnvironment {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamID String
|
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
|
||||||
name String
|
|
||||||
variables Json
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
uid String @id @default(cuid())
|
|
||||||
displayName String?
|
|
||||||
email String? @unique
|
|
||||||
photoURL String?
|
|
||||||
isAdmin Boolean @default(false)
|
|
||||||
refreshToken String?
|
|
||||||
providerAccounts Account[]
|
|
||||||
VerificationToken VerificationToken[]
|
|
||||||
settings UserSettings?
|
|
||||||
UserHistory UserHistory[]
|
|
||||||
UserEnvironments UserEnvironment[]
|
|
||||||
userCollections UserCollection[]
|
|
||||||
userRequests UserRequest[]
|
|
||||||
currentRESTSession Json?
|
|
||||||
currentGQLSession Json?
|
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
invitedUsers InvitedUsers[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
providerRefreshToken String?
|
|
||||||
providerAccessToken String?
|
|
||||||
providerScope String?
|
|
||||||
loggedIn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
|
|
||||||
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
|
|
||||||
}
|
|
||||||
|
|
||||||
model VerificationToken {
|
|
||||||
deviceIdentifier String
|
|
||||||
token String @unique @default(cuid())
|
|
||||||
userUid String
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
expiresOn DateTime @db.Timestamp(3)
|
|
||||||
|
|
||||||
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserSettings {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userUid String @unique
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
properties Json
|
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserHistory {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userUid String
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
reqType ReqType
|
|
||||||
request Json
|
|
||||||
responseMetadata Json
|
|
||||||
isStarred Boolean
|
|
||||||
executedOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ReqType {
|
|
||||||
REST
|
|
||||||
GQL
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserEnvironment {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userUid String
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
name String?
|
|
||||||
variables Json
|
|
||||||
isGlobal Boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvitedUsers {
|
|
||||||
adminUid String
|
|
||||||
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
|
|
||||||
adminEmail String
|
|
||||||
inviteeEmail String @unique
|
|
||||||
invitedOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserRequest {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
|
||||||
collectionID String
|
|
||||||
userUid String
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
title String
|
|
||||||
request Json
|
|
||||||
type ReqType
|
|
||||||
orderIndex Int
|
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserCollection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
parentID String?
|
|
||||||
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
|
|
||||||
children UserCollection[] @relation("ParentUserCollection")
|
|
||||||
requests UserRequest[]
|
|
||||||
userUid String
|
|
||||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
|
||||||
title String
|
|
||||||
orderIndex Int
|
|
||||||
type ReqType
|
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TeamMemberRole {
|
|
||||||
OWNER
|
|
||||||
VIEWER
|
|
||||||
EDITOR
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ObjectType } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class Admin {}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AdminResolver } from './admin.resolver';
|
|
||||||
import { AdminService } from './admin.service';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
|
||||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
|
||||||
import { UserModule } from '../user/user.module';
|
|
||||||
import { MailerModule } from '../mailer/mailer.module';
|
|
||||||
import { TeamModule } from '../team/team.module';
|
|
||||||
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
|
||||||
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
|
||||||
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
|
||||||
import { TeamRequestModule } from '../team-request/team-request.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
PrismaModule,
|
|
||||||
PubSubModule,
|
|
||||||
UserModule,
|
|
||||||
MailerModule,
|
|
||||||
TeamModule,
|
|
||||||
TeamInvitationModule,
|
|
||||||
TeamEnvironmentsModule,
|
|
||||||
TeamCollectionModule,
|
|
||||||
TeamRequestModule,
|
|
||||||
],
|
|
||||||
providers: [AdminResolver, AdminService],
|
|
||||||
exports: [AdminService],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
import {
|
|
||||||
Args,
|
|
||||||
ID,
|
|
||||||
Mutation,
|
|
||||||
Parent,
|
|
||||||
Query,
|
|
||||||
ResolveField,
|
|
||||||
Resolver,
|
|
||||||
Subscription,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { Admin } from './admin.model';
|
|
||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
|
||||||
import { GqlAdminGuard } from './guards/gql-admin.guard';
|
|
||||||
import { GqlAdmin } from './decorators/gql-admin.decorator';
|
|
||||||
import { AdminService } from './admin.service';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { throwErr } from '../utils';
|
|
||||||
import { AuthUser } from '../types/AuthUser';
|
|
||||||
import { InvitedUser } from './invited-user.model';
|
|
||||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
|
||||||
import { Team, TeamMember } from '../team/team.model';
|
|
||||||
import { User } from '../user/user.model';
|
|
||||||
import { TeamInvitation } from '../team-invitation/team-invitation.model';
|
|
||||||
import { PaginationArgs } from '../types/input-types.args';
|
|
||||||
import {
|
|
||||||
AddUserToTeamArgs,
|
|
||||||
ChangeUserRoleInTeamArgs,
|
|
||||||
} from './input-types.args';
|
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
|
||||||
@Resolver(() => Admin)
|
|
||||||
export class AdminResolver {
|
|
||||||
constructor(
|
|
||||||
private adminService: AdminService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/* Query */
|
|
||||||
|
|
||||||
@Query(() => Admin, {
|
|
||||||
description: 'Gives details of the admin executing this query',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
admin(@GqlAdmin() admin: Admin) {
|
|
||||||
return admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [User], {
|
|
||||||
description: 'Returns a list of all admin users in infra',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async admins() {
|
|
||||||
const admins = await this.adminService.fetchAdmins();
|
|
||||||
return admins;
|
|
||||||
}
|
|
||||||
@ResolveField(() => User, {
|
|
||||||
description: 'Returns a user info by UID',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async userInfo(
|
|
||||||
@Args({
|
|
||||||
name: 'userUid',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'The user UID',
|
|
||||||
})
|
|
||||||
userUid: string,
|
|
||||||
): Promise<AuthUser> {
|
|
||||||
const user = await this.adminService.fetchUserInfo(userUid);
|
|
||||||
if (E.isLeft(user)) throwErr(user.left);
|
|
||||||
return user.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [User], {
|
|
||||||
description: 'Returns a list of all the users in infra',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async allUsers(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args() args: PaginationArgs,
|
|
||||||
): Promise<AuthUser[]> {
|
|
||||||
const users = await this.adminService.fetchUsers(args.cursor, args.take);
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [InvitedUser], {
|
|
||||||
description: 'Returns a list of all the invited users',
|
|
||||||
})
|
|
||||||
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
|
|
||||||
const users = await this.adminService.fetchInvitedUsers();
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [Team], {
|
|
||||||
description: 'Returns a list of all the teams in the infra',
|
|
||||||
})
|
|
||||||
async allTeams(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args() args: PaginationArgs,
|
|
||||||
): Promise<Team[]> {
|
|
||||||
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
@ResolveField(() => Team, {
|
|
||||||
description: 'Returns a team info by ID when requested by Admin',
|
|
||||||
})
|
|
||||||
async teamInfo(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which info to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<Team> {
|
|
||||||
const team = await this.adminService.getTeamInfo(teamID);
|
|
||||||
if (E.isLeft(team)) throwErr(team.left);
|
|
||||||
return team.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return count of all the members in a team',
|
|
||||||
})
|
|
||||||
async membersCountInTeam(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which team members to fetch',
|
|
||||||
nullable: false,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<number> {
|
|
||||||
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
|
|
||||||
return teamMembersCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return count of all the stored collections in a team',
|
|
||||||
})
|
|
||||||
async collectionCountInTeam(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which team members to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<number> {
|
|
||||||
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
|
|
||||||
return teamCollCount;
|
|
||||||
}
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return count of all the stored requests in a team',
|
|
||||||
})
|
|
||||||
async requestCountInTeam(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which team members to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<number> {
|
|
||||||
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
|
|
||||||
return teamReqCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return count of all the stored environments in a team',
|
|
||||||
})
|
|
||||||
async environmentCountInTeam(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which team members to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<number> {
|
|
||||||
const envsCount = await this.adminService.environmentCountInTeam(teamID);
|
|
||||||
return envsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [TeamInvitation], {
|
|
||||||
description: 'Return all the pending invitations in a team',
|
|
||||||
})
|
|
||||||
async pendingInvitationCountInTeam(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which team members to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
const invitations = await this.adminService.pendingInvitationCountInTeam(
|
|
||||||
teamID,
|
|
||||||
);
|
|
||||||
return invitations;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Users in organization',
|
|
||||||
})
|
|
||||||
async usersCount() {
|
|
||||||
return this.adminService.getUsersCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Teams in organization',
|
|
||||||
})
|
|
||||||
async teamsCount() {
|
|
||||||
return this.adminService.getTeamsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Collections in organization',
|
|
||||||
})
|
|
||||||
async teamCollectionsCount() {
|
|
||||||
return this.adminService.getTeamCollectionsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Requests in organization',
|
|
||||||
})
|
|
||||||
async teamRequestsCount() {
|
|
||||||
return this.adminService.getTeamRequestsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mutations */
|
|
||||||
|
|
||||||
@Mutation(() => InvitedUser, {
|
|
||||||
description: 'Invite a user to the infra using email',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async inviteNewUser(
|
|
||||||
@GqlUser() adminUser: AuthUser,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteeEmail',
|
|
||||||
description: 'invitee email',
|
|
||||||
})
|
|
||||||
inviteeEmail: string,
|
|
||||||
): Promise<InvitedUser> {
|
|
||||||
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
|
|
||||||
adminUser.uid,
|
|
||||||
adminUser.email,
|
|
||||||
inviteeEmail,
|
|
||||||
);
|
|
||||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
|
||||||
return invitedUser.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Delete an user account from infra',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async removeUserByAdmin(
|
|
||||||
@Args({
|
|
||||||
name: 'userUID',
|
|
||||||
description: 'users UID',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
userUID: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const invitedUser = await this.adminService.removeUserAccount(userUID);
|
|
||||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
|
||||||
return invitedUser.right;
|
|
||||||
}
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Make user an admin',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async makeUserAdmin(
|
|
||||||
@Args({
|
|
||||||
name: 'userUID',
|
|
||||||
description: 'users UID',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
userUID: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const admin = await this.adminService.makeUserAdmin(userUID);
|
|
||||||
if (E.isLeft(admin)) throwErr(admin.left);
|
|
||||||
return admin.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Remove user as admin',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async removeUserAsAdmin(
|
|
||||||
@Args({
|
|
||||||
name: 'userUID',
|
|
||||||
description: 'users UID',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
userUID: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const admin = await this.adminService.removeUserAsAdmin(userUID);
|
|
||||||
if (E.isLeft(admin)) throwErr(admin.left);
|
|
||||||
return admin.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Team, {
|
|
||||||
description:
|
|
||||||
'Create a new team by providing the user uid to nominate as Team owner',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async createTeamByAdmin(
|
|
||||||
@GqlAdmin() adminUser: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'userUid',
|
|
||||||
description: 'users uid to make team owner',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
userUid: string,
|
|
||||||
@Args({ name: 'name', description: 'Displayed name of the team' })
|
|
||||||
name: string,
|
|
||||||
): Promise<Team> {
|
|
||||||
const createdTeam = await this.adminService.createATeam(userUid, name);
|
|
||||||
if (E.isLeft(createdTeam)) throwErr(createdTeam.left);
|
|
||||||
return createdTeam.right;
|
|
||||||
}
|
|
||||||
@Mutation(() => TeamMember, {
|
|
||||||
description: 'Change the role of a user in a team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async changeUserRoleInTeamByAdmin(
|
|
||||||
@GqlAdmin() adminUser: Admin,
|
|
||||||
@Args() args: ChangeUserRoleInTeamArgs,
|
|
||||||
): Promise<TeamMember> {
|
|
||||||
const updatedRole = await this.adminService.changeRoleOfUserTeam(
|
|
||||||
args.userUID,
|
|
||||||
args.teamID,
|
|
||||||
args.newRole,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedRole)) throwErr(updatedRole.left);
|
|
||||||
return updatedRole.right;
|
|
||||||
}
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Remove the user from a team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async removeUserFromTeamByAdmin(
|
|
||||||
@GqlAdmin() adminUser: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'userUid',
|
|
||||||
description: 'users UID',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
userUid: string,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'team ID',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const removedUser = await this.adminService.removeUserFromTeam(
|
|
||||||
userUid,
|
|
||||||
teamID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
|
||||||
return removedUser.right;
|
|
||||||
}
|
|
||||||
@Mutation(() => TeamMember, {
|
|
||||||
description: 'Add a user to a team with email and team member role',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async addUserToTeamByAdmin(
|
|
||||||
@GqlAdmin() adminUser: Admin,
|
|
||||||
@Args() args: AddUserToTeamArgs,
|
|
||||||
): Promise<TeamMember> {
|
|
||||||
const addedUser = await this.adminService.addUserToTeam(
|
|
||||||
args.teamID,
|
|
||||||
args.userEmail,
|
|
||||||
args.role,
|
|
||||||
);
|
|
||||||
if (E.isLeft(addedUser)) throwErr(addedUser.left);
|
|
||||||
return addedUser.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Team, {
|
|
||||||
description: 'Change a team name',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async renameTeamByAdmin(
|
|
||||||
@GqlAdmin() adminUser: Admin,
|
|
||||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
|
||||||
teamID: string,
|
|
||||||
@Args({ name: 'newName', description: 'The updated name of the team' })
|
|
||||||
newName: string,
|
|
||||||
): Promise<Team> {
|
|
||||||
const renamedTeam = await this.adminService.renameATeam(teamID, newName);
|
|
||||||
if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left);
|
|
||||||
return renamedTeam.right;
|
|
||||||
}
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Delete a team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async deleteTeamByAdmin(
|
|
||||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
|
||||||
teamID: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const deletedTeam = await this.adminService.deleteATeam(teamID);
|
|
||||||
if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left);
|
|
||||||
return deletedTeam.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscriptions */
|
|
||||||
|
|
||||||
@Subscription(() => InvitedUser, {
|
|
||||||
description: 'Listen for User Invitation',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
userInvited(@GqlUser() admin: AuthUser) {
|
|
||||||
return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { AdminService } from './admin.service';
|
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
|
||||||
import { mockDeep } from 'jest-mock-extended';
|
|
||||||
import { InvitedUsers } from '@prisma/client';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { TeamService } from '../team/team.service';
|
|
||||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
|
||||||
import { TeamRequestService } from '../team-request/team-request.service';
|
|
||||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
|
||||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
|
||||||
import { MailerService } from '../mailer/mailer.service';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import {
|
|
||||||
DUPLICATE_EMAIL,
|
|
||||||
INVALID_EMAIL,
|
|
||||||
USER_ALREADY_INVITED,
|
|
||||||
} from '../errors';
|
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
|
||||||
const mockUserService = mockDeep<UserService>();
|
|
||||||
const mockTeamService = mockDeep<TeamService>();
|
|
||||||
const mockTeamEnvironmentsService = mockDeep<TeamEnvironmentsService>();
|
|
||||||
const mockTeamRequestService = mockDeep<TeamRequestService>();
|
|
||||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
|
||||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
|
||||||
const mockMailerService = mockDeep<MailerService>();
|
|
||||||
|
|
||||||
const adminService = new AdminService(
|
|
||||||
mockUserService,
|
|
||||||
mockTeamService,
|
|
||||||
mockTeamCollectionService,
|
|
||||||
mockTeamRequestService,
|
|
||||||
mockTeamEnvironmentsService,
|
|
||||||
mockTeamInvitationService,
|
|
||||||
mockPubSub as any,
|
|
||||||
mockPrisma as any,
|
|
||||||
mockMailerService,
|
|
||||||
);
|
|
||||||
|
|
||||||
const invitedUsers: InvitedUsers[] = [
|
|
||||||
{
|
|
||||||
adminUid: 'uid1',
|
|
||||||
adminEmail: 'admin1@example.com',
|
|
||||||
inviteeEmail: 'i@example.com',
|
|
||||||
invitedOn: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
adminUid: 'uid2',
|
|
||||||
adminEmail: 'admin2@example.com',
|
|
||||||
inviteeEmail: 'u@example.com',
|
|
||||||
invitedOn: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
describe('AdminService', () => {
|
|
||||||
describe('fetchInvitedUsers', () => {
|
|
||||||
test('should resolve right and return an array of invited users', async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers();
|
|
||||||
expect(results).toEqual(invitedUsers);
|
|
||||||
});
|
|
||||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
|
||||||
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers();
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('inviteUserToSignInViaEmail', () => {
|
|
||||||
test('should resolve right and create a invited user', async () => {
|
|
||||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
|
||||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
|
||||||
const result = await adminService.inviteUserToSignInViaEmail(
|
|
||||||
invitedUsers[0].adminUid,
|
|
||||||
invitedUsers[0].adminEmail,
|
|
||||||
invitedUsers[0].inviteeEmail,
|
|
||||||
);
|
|
||||||
expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
adminUid: invitedUsers[0].adminUid,
|
|
||||||
adminEmail: invitedUsers[0].adminEmail,
|
|
||||||
inviteeEmail: invitedUsers[0].inviteeEmail,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return expect(result).toEqualRight(invitedUsers[0]);
|
|
||||||
});
|
|
||||||
test('should resolve right, create a invited user and publish a subscription', async () => {
|
|
||||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
|
||||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
|
||||||
await adminService.inviteUserToSignInViaEmail(
|
|
||||||
invitedUsers[0].adminUid,
|
|
||||||
invitedUsers[0].adminEmail,
|
|
||||||
invitedUsers[0].inviteeEmail,
|
|
||||||
);
|
|
||||||
return expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`admin/${invitedUsers[0].adminUid}/invited`,
|
|
||||||
invitedUsers[0],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test('should resolve left and return an error when invalid invitee email is passed', async () => {
|
|
||||||
const result = await adminService.inviteUserToSignInViaEmail(
|
|
||||||
invitedUsers[0].adminUid,
|
|
||||||
invitedUsers[0].adminEmail,
|
|
||||||
'invalidemail',
|
|
||||||
);
|
|
||||||
return expect(result).toEqualLeft(INVALID_EMAIL);
|
|
||||||
});
|
|
||||||
test('should resolve left and return an error when user already invited', async () => {
|
|
||||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]);
|
|
||||||
const result = await adminService.inviteUserToSignInViaEmail(
|
|
||||||
invitedUsers[0].adminUid,
|
|
||||||
invitedUsers[0].adminEmail,
|
|
||||||
invitedUsers[0].inviteeEmail,
|
|
||||||
);
|
|
||||||
return expect(result).toEqualLeft(USER_ALREADY_INVITED);
|
|
||||||
});
|
|
||||||
test('should resolve left and return an error when invitee and admin email is same', async () => {
|
|
||||||
const result = await adminService.inviteUserToSignInViaEmail(
|
|
||||||
invitedUsers[0].adminUid,
|
|
||||||
invitedUsers[0].inviteeEmail,
|
|
||||||
invitedUsers[0].inviteeEmail,
|
|
||||||
);
|
|
||||||
return expect(result).toEqualLeft(DUPLICATE_EMAIL);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUsersCount', () => {
|
|
||||||
test('should return count of all users in the organization', async () => {
|
|
||||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
|
||||||
|
|
||||||
const result = await adminService.getUsersCount();
|
|
||||||
expect(result).toEqual(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTeamsCount', () => {
|
|
||||||
test('should return count of all teams in the organization', async () => {
|
|
||||||
mockTeamService.getTeamsCount.mockResolvedValueOnce(10);
|
|
||||||
|
|
||||||
const result = await adminService.getTeamsCount();
|
|
||||||
expect(result).toEqual(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTeamCollectionsCount', () => {
|
|
||||||
test('should return count of all Team Collections in the organization', async () => {
|
|
||||||
mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce(
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await adminService.getTeamCollectionsCount();
|
|
||||||
expect(result).toEqual(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTeamRequestsCount', () => {
|
|
||||||
test('should return count of all Team Collections in the organization', async () => {
|
|
||||||
mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10);
|
|
||||||
|
|
||||||
const result = await adminService.getTeamRequestsCount();
|
|
||||||
expect(result).toEqual(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { validateEmail } from '../utils';
|
|
||||||
import {
|
|
||||||
DUPLICATE_EMAIL,
|
|
||||||
EMAIL_FAILED,
|
|
||||||
INVALID_EMAIL,
|
|
||||||
ONLY_ONE_ADMIN_ACCOUNT,
|
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
|
||||||
USER_ALREADY_INVITED,
|
|
||||||
USER_IS_ADMIN,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from '../errors';
|
|
||||||
import { MailerService } from '../mailer/mailer.service';
|
|
||||||
import { InvitedUser } from './invited-user.model';
|
|
||||||
import { TeamService } from '../team/team.service';
|
|
||||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
|
||||||
import { TeamRequestService } from '../team-request/team-request.service';
|
|
||||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
|
||||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
|
||||||
import { TeamMemberRole } from '../team/team.model';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminService {
|
|
||||||
constructor(
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
private readonly teamCollectionService: TeamCollectionService,
|
|
||||||
private readonly teamRequestService: TeamRequestService,
|
|
||||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
|
||||||
private readonly teamInvitationService: TeamInvitationService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly mailerService: MailerService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all the users in the infra.
|
|
||||||
* @param cursorID Users uid
|
|
||||||
* @param take number of users to fetch
|
|
||||||
* @returns an Either of array of user or error
|
|
||||||
*/
|
|
||||||
async fetchUsers(cursorID: string, take: number) {
|
|
||||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
|
||||||
return allUsers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invite a user to join the infra.
|
|
||||||
* @param adminUID Admin's UID
|
|
||||||
* @param adminEmail Admin's email
|
|
||||||
* @param inviteeEmail Invitee's email
|
|
||||||
* @returns an Either of `InvitedUser` object or error
|
|
||||||
*/
|
|
||||||
async inviteUserToSignInViaEmail(
|
|
||||||
adminUID: string,
|
|
||||||
adminEmail: string,
|
|
||||||
inviteeEmail: string,
|
|
||||||
) {
|
|
||||||
if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
|
|
||||||
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
|
|
||||||
|
|
||||||
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
|
|
||||||
where: {
|
|
||||||
inviteeEmail: inviteeEmail,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
|
||||||
template: 'code-your-own',
|
|
||||||
variables: {
|
|
||||||
inviteeEmail: inviteeEmail,
|
|
||||||
magicLink: `${process.env.VITE_BASE_URL}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return E.left(EMAIL_FAILED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add invitee email to the list of invited users by admin
|
|
||||||
const dbInvitedUser = await this.prisma.invitedUsers.create({
|
|
||||||
data: {
|
|
||||||
adminUid: adminUID,
|
|
||||||
adminEmail: adminEmail,
|
|
||||||
inviteeEmail: inviteeEmail,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitedUser = <InvitedUser>{
|
|
||||||
adminEmail: dbInvitedUser.adminEmail,
|
|
||||||
adminUid: dbInvitedUser.adminUid,
|
|
||||||
inviteeEmail: dbInvitedUser.inviteeEmail,
|
|
||||||
invitedOn: dbInvitedUser.invitedOn,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Publish invited user subscription
|
|
||||||
await this.pubsub.publish(`admin/${adminUID}/invited`, invitedUser);
|
|
||||||
|
|
||||||
return E.right(invitedUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the list of invited users by the admin.
|
|
||||||
* @returns an Either of array of `InvitedUser` object or error
|
|
||||||
*/
|
|
||||||
async fetchInvitedUsers() {
|
|
||||||
const invitedUsers = await this.prisma.invitedUsers.findMany();
|
|
||||||
|
|
||||||
const users: InvitedUser[] = invitedUsers.map(
|
|
||||||
(user) => <InvitedUser>{ ...user },
|
|
||||||
);
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all the teams in the infra.
|
|
||||||
* @param cursorID team id
|
|
||||||
* @param take number of items to fetch
|
|
||||||
* @returns an array of teams
|
|
||||||
*/
|
|
||||||
async fetchAllTeams(cursorID: string, take: number) {
|
|
||||||
const allTeams = await this.teamService.fetchAllTeams(cursorID, take);
|
|
||||||
return allTeams;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the count of all the members in a team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a count of team members
|
|
||||||
*/
|
|
||||||
async membersCountInTeam(teamID: string) {
|
|
||||||
const teamMembersCount = await this.teamService.getCountOfMembersInTeam(
|
|
||||||
teamID,
|
|
||||||
);
|
|
||||||
return teamMembersCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch count of all the collections in a team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a of count of collections
|
|
||||||
*/
|
|
||||||
async collectionCountInTeam(teamID: string) {
|
|
||||||
const teamCollectionsCount =
|
|
||||||
await this.teamCollectionService.totalCollectionsInTeam(teamID);
|
|
||||||
return teamCollectionsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the count of all the requests in a team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a count of total requests in a team
|
|
||||||
*/
|
|
||||||
async requestCountInTeam(teamID: string) {
|
|
||||||
const teamRequestsCount =
|
|
||||||
await this.teamRequestService.totalRequestsInATeam(teamID);
|
|
||||||
|
|
||||||
return teamRequestsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the count of all the environments in a team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a count of environments in a team
|
|
||||||
*/
|
|
||||||
async environmentCountInTeam(teamID: string) {
|
|
||||||
const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID);
|
|
||||||
return envCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all the invitations for a given team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns an array team invitations
|
|
||||||
*/
|
|
||||||
async pendingInvitationCountInTeam(teamID: string) {
|
|
||||||
const invitations = await this.teamInvitationService.getAllTeamInvitations(
|
|
||||||
teamID,
|
|
||||||
);
|
|
||||||
|
|
||||||
return invitations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the role of a user in a team
|
|
||||||
* @param userUid users uid
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns an Either of updated `TeamMember` object or error
|
|
||||||
*/
|
|
||||||
async changeRoleOfUserTeam(
|
|
||||||
userUid: string,
|
|
||||||
teamID: string,
|
|
||||||
newRole: TeamMemberRole,
|
|
||||||
) {
|
|
||||||
const updatedTeamMember = await this.teamService.updateTeamMemberRole(
|
|
||||||
teamID,
|
|
||||||
userUid,
|
|
||||||
newRole,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left);
|
|
||||||
|
|
||||||
return E.right(updatedTeamMember.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the user from a team
|
|
||||||
* @param userUid users uid
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async removeUserFromTeam(userUid: string, teamID: string) {
|
|
||||||
const removedUser = await this.teamService.leaveTeam(teamID, userUid);
|
|
||||||
if (E.isLeft(removedUser)) return E.left(removedUser.left);
|
|
||||||
|
|
||||||
return E.right(removedUser.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add the user to a team
|
|
||||||
* @param teamID team id
|
|
||||||
* @param userEmail users email
|
|
||||||
* @param role team member role for the user
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async addUserToTeam(teamID: string, userEmail: string, role: TeamMemberRole) {
|
|
||||||
if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL);
|
|
||||||
|
|
||||||
const user = await this.userService.findUserByEmail(userEmail);
|
|
||||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
|
||||||
|
|
||||||
const isUserAlreadyMember = await this.teamService.getTeamMemberTE(
|
|
||||||
teamID,
|
|
||||||
user.value.uid,
|
|
||||||
)();
|
|
||||||
if (E.left(isUserAlreadyMember)) {
|
|
||||||
const addedUser = await this.teamService.addMemberToTeamWithEmail(
|
|
||||||
teamID,
|
|
||||||
userEmail,
|
|
||||||
role,
|
|
||||||
);
|
|
||||||
if (E.isLeft(addedUser)) return E.left(addedUser.left);
|
|
||||||
|
|
||||||
return E.right(addedUser.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new team
|
|
||||||
* @param userUid user uid
|
|
||||||
* @param name team name
|
|
||||||
* @returns an Either of `Team` object or error
|
|
||||||
*/
|
|
||||||
async createATeam(userUid: string, name: string) {
|
|
||||||
const validUser = await this.userService.findUserById(userUid);
|
|
||||||
if (O.isNone(validUser)) return E.left(USER_NOT_FOUND);
|
|
||||||
|
|
||||||
const createdTeam = await this.teamService.createTeam(name, userUid);
|
|
||||||
if (E.isLeft(createdTeam)) return E.left(createdTeam.left);
|
|
||||||
|
|
||||||
return E.right(createdTeam.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames a team
|
|
||||||
* @param teamID team ID
|
|
||||||
* @param newName new team name
|
|
||||||
* @returns an Either of `Team` object or error
|
|
||||||
*/
|
|
||||||
async renameATeam(teamID: string, newName: string) {
|
|
||||||
const renamedTeam = await this.teamService.renameTeam(teamID, newName);
|
|
||||||
if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left);
|
|
||||||
|
|
||||||
return E.right(renamedTeam.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a team
|
|
||||||
* @param teamID team ID
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async deleteATeam(teamID: string) {
|
|
||||||
const deleteTeam = await this.teamService.deleteTeam(teamID);
|
|
||||||
if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left);
|
|
||||||
|
|
||||||
return E.right(deleteTeam.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all admin accounts
|
|
||||||
* @returns an array of admin users
|
|
||||||
*/
|
|
||||||
async fetchAdmins() {
|
|
||||||
const admins = this.userService.fetchAdminUsers();
|
|
||||||
return admins;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a user by UID
|
|
||||||
* @param userUid User UID
|
|
||||||
* @returns an Either of `User` obj or error
|
|
||||||
*/
|
|
||||||
async fetchUserInfo(userUid: string) {
|
|
||||||
const user = await this.userService.findUserById(userUid);
|
|
||||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
|
||||||
|
|
||||||
return E.right(user.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a user account by UID
|
|
||||||
* @param userUid User UID
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async removeUserAccount(userUid: string) {
|
|
||||||
const user = await this.userService.findUserById(userUid);
|
|
||||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
|
||||||
|
|
||||||
if (user.value.isAdmin) return E.left(USER_IS_ADMIN);
|
|
||||||
|
|
||||||
const delUser = await this.userService.deleteUserByUID(user.value)();
|
|
||||||
if (E.isLeft(delUser)) return E.left(delUser.left);
|
|
||||||
return E.right(delUser.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a user an admin
|
|
||||||
* @param userUid User UID
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async makeUserAdmin(userUID: string) {
|
|
||||||
const admin = await this.userService.makeAdmin(userUID);
|
|
||||||
if (E.isLeft(admin)) return E.left(admin.left);
|
|
||||||
return E.right(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove user as admin
|
|
||||||
* @param userUid User UID
|
|
||||||
* @returns an Either of boolean or error
|
|
||||||
*/
|
|
||||||
async removeUserAsAdmin(userUID: string) {
|
|
||||||
const adminUsers = await this.userService.fetchAdminUsers();
|
|
||||||
if (adminUsers.length === 1) return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
|
||||||
|
|
||||||
const admin = await this.userService.removeUserAsAdmin(userUID);
|
|
||||||
if (E.isLeft(admin)) return E.left(admin.left);
|
|
||||||
return E.right(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Users in org
|
|
||||||
* @returns number of users in the org
|
|
||||||
*/
|
|
||||||
async getUsersCount() {
|
|
||||||
const usersCount = this.userService.getUsersCount();
|
|
||||||
return usersCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Teams in org
|
|
||||||
* @returns number of users in the org
|
|
||||||
*/
|
|
||||||
async getTeamsCount() {
|
|
||||||
const teamsCount = this.teamService.getTeamsCount();
|
|
||||||
return teamsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Team Collections in org
|
|
||||||
* @returns number of users in the org
|
|
||||||
*/
|
|
||||||
async getTeamCollectionsCount() {
|
|
||||||
const teamCollectionCount =
|
|
||||||
this.teamCollectionService.getTeamCollectionsCount();
|
|
||||||
return teamCollectionCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Team Requests in org
|
|
||||||
* @returns number of users in the org
|
|
||||||
*/
|
|
||||||
async getTeamRequestsCount() {
|
|
||||||
const teamRequestCount = this.teamRequestService.getTeamRequestsCount();
|
|
||||||
return teamRequestCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get team info by ID
|
|
||||||
* @param teamID Team ID
|
|
||||||
* @returns an Either of `Team` or error
|
|
||||||
*/
|
|
||||||
async getTeamInfo(teamID: string) {
|
|
||||||
const team = await this.teamService.getTeamWithIDTE(teamID)();
|
|
||||||
if (E.isLeft(team)) return E.left(team.left);
|
|
||||||
return E.right(team.right);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
export const GqlAdmin = createParamDecorator(
|
|
||||||
(data: unknown, context: ExecutionContext) => {
|
|
||||||
const ctx = GqlExecutionContext.create(context);
|
|
||||||
return ctx.getContext().req.user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GqlAdminGuard implements CanActivate {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const ctx = GqlExecutionContext.create(context);
|
|
||||||
const { req, headers } = ctx.getContext();
|
|
||||||
const request = headers ? headers : req;
|
|
||||||
const user = request.user;
|
|
||||||
if (user.isAdmin) return true;
|
|
||||||
else return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Field, ID, ArgsType } from '@nestjs/graphql';
|
|
||||||
import { TeamMemberRole } from '../team/team.model';
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class ChangeUserRoleInTeamArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'userUID',
|
|
||||||
description: 'users UID',
|
|
||||||
})
|
|
||||||
userUID: string;
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'team ID',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field(() => TeamMemberRole, {
|
|
||||||
name: 'newRole',
|
|
||||||
description: 'updated team role',
|
|
||||||
})
|
|
||||||
newRole: TeamMemberRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class AddUserToTeamArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'team ID',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field(() => TeamMemberRole, {
|
|
||||||
name: 'role',
|
|
||||||
description: 'The role of the user to add in the team',
|
|
||||||
})
|
|
||||||
role: TeamMemberRole;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'userEmail',
|
|
||||||
description: 'Email of the user to add to team',
|
|
||||||
})
|
|
||||||
userEmail: string;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ObjectType, ID, Field } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class InvitedUser {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'Admin UID',
|
|
||||||
})
|
|
||||||
adminUid: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Admin email',
|
|
||||||
})
|
|
||||||
adminEmail: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Invitee email',
|
|
||||||
})
|
|
||||||
inviteeEmail: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Date when the user invitation was sent',
|
|
||||||
})
|
|
||||||
invitedOn: Date;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { ForbiddenException, HttpException, Module } from '@nestjs/common';
|
|
||||||
import { GraphQLModule } from '@nestjs/graphql';
|
|
||||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
|
||||||
import { UserModule } from './user/user.module';
|
|
||||||
import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin';
|
|
||||||
import { AuthModule } from './auth/auth.module';
|
|
||||||
import { UserSettingsModule } from './user-settings/user-settings.module';
|
|
||||||
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
|
|
||||||
import { UserRequestModule } from './user-request/user-request.module';
|
|
||||||
import { UserHistoryModule } from './user-history/user-history.module';
|
|
||||||
import { subscriptionContextCookieParser } from './auth/helper';
|
|
||||||
import { TeamModule } from './team/team.module';
|
|
||||||
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
|
||||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
|
||||||
import { TeamRequestModule } from './team-request/team-request.module';
|
|
||||||
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
|
||||||
import { AdminModule } from './admin/admin.module';
|
|
||||||
import { UserCollectionModule } from './user-collection/user-collection.module';
|
|
||||||
import { ShortcodeModule } from './shortcode/shortcode.module';
|
|
||||||
import { COOKIES_NOT_FOUND } from './errors';
|
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
|
||||||
buildSchemaOptions: {
|
|
||||||
numberScalarMode: 'integer',
|
|
||||||
},
|
|
||||||
cors: {
|
|
||||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
playground: process.env.PRODUCTION !== 'true',
|
|
||||||
debug: process.env.PRODUCTION !== 'true',
|
|
||||||
autoSchemaFile: true,
|
|
||||||
installSubscriptionHandlers: true,
|
|
||||||
subscriptions: {
|
|
||||||
'subscriptions-transport-ws': {
|
|
||||||
path: '/graphql',
|
|
||||||
onConnect: (_, websocket) => {
|
|
||||||
try {
|
|
||||||
const cookies = subscriptionContextCookieParser(
|
|
||||||
websocket.upgradeReq.headers.cookie,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
|
||||||
cause: new Error(COOKIES_NOT_FOUND),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
context: ({ req, res, connection }) => ({
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
connection,
|
|
||||||
}),
|
|
||||||
driver: ApolloDriver,
|
|
||||||
}),
|
|
||||||
ThrottlerModule.forRoot({
|
|
||||||
ttl: +process.env.RATE_LIMIT_TTL,
|
|
||||||
limit: +process.env.RATE_LIMIT_MAX,
|
|
||||||
}),
|
|
||||||
UserModule,
|
|
||||||
AuthModule,
|
|
||||||
AdminModule,
|
|
||||||
UserSettingsModule,
|
|
||||||
UserEnvironmentsModule,
|
|
||||||
UserHistoryModule,
|
|
||||||
UserRequestModule,
|
|
||||||
TeamModule,
|
|
||||||
TeamEnvironmentsModule,
|
|
||||||
TeamCollectionModule,
|
|
||||||
TeamRequestModule,
|
|
||||||
TeamInvitationModule,
|
|
||||||
UserCollectionModule,
|
|
||||||
ShortcodeModule,
|
|
||||||
],
|
|
||||||
providers: [GQLComplexityPlugin],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
Request,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
|
||||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
|
||||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
|
||||||
import { authCookieHandler, throwHTTPErr } from './helper';
|
|
||||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
|
||||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
|
||||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
|
||||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
|
||||||
@Controller({ path: 'auth', version: '1' })
|
|
||||||
export class AuthController {
|
|
||||||
constructor(private authService: AuthService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to initiate magic-link auth for a users email
|
|
||||||
*/
|
|
||||||
@Post('signin')
|
|
||||||
async signInMagicLink(
|
|
||||||
@Body() authData: SignInMagicDto,
|
|
||||||
@Query('origin') origin: string,
|
|
||||||
) {
|
|
||||||
const deviceIdToken = await this.authService.signInMagicLink(
|
|
||||||
authData.email,
|
|
||||||
origin,
|
|
||||||
);
|
|
||||||
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
|
|
||||||
return deviceIdToken.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to verify and sign in a valid user via magic-link
|
|
||||||
*/
|
|
||||||
@Post('verify')
|
|
||||||
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
|
|
||||||
const authTokens = await this.authService.verifyMagicLinkTokens(data);
|
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
|
||||||
authCookieHandler(res, authTokens.right, false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to refresh auth tokens with Refresh Token Rotation
|
|
||||||
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
|
|
||||||
*/
|
|
||||||
@Get('refresh')
|
|
||||||
@UseGuards(RTJwtAuthGuard)
|
|
||||||
async refresh(
|
|
||||||
@GqlUser() user: AuthUser,
|
|
||||||
@RTCookie() refresh_token: string,
|
|
||||||
@Res() res,
|
|
||||||
) {
|
|
||||||
const newTokenPair = await this.authService.refreshAuthTokens(
|
|
||||||
refresh_token,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left);
|
|
||||||
authCookieHandler(res, newTokenPair.right, false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to initiate SSO auth via Google
|
|
||||||
*/
|
|
||||||
@Get('google')
|
|
||||||
@UseGuards(GoogleSSOGuard)
|
|
||||||
async googleAuth(@Request() req) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Callback URL for Google SSO
|
|
||||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
|
||||||
*/
|
|
||||||
@Get('google/callback')
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GoogleSSOGuard)
|
|
||||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
|
||||||
authCookieHandler(
|
|
||||||
res,
|
|
||||||
authTokens.right,
|
|
||||||
true,
|
|
||||||
req.authInfo.state.redirect_uri,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to initiate SSO auth via Github
|
|
||||||
*/
|
|
||||||
@Get('github')
|
|
||||||
@UseGuards(GithubSSOGuard)
|
|
||||||
async githubAuth(@Request() req) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Callback URL for Github SSO
|
|
||||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
|
||||||
*/
|
|
||||||
@Get('github/callback')
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GithubSSOGuard)
|
|
||||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
|
||||||
authCookieHandler(
|
|
||||||
res,
|
|
||||||
authTokens.right,
|
|
||||||
true,
|
|
||||||
req.authInfo.state.redirect_uri,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Route to initiate SSO auth via Microsoft
|
|
||||||
*/
|
|
||||||
@Get('microsoft')
|
|
||||||
@UseGuards(MicrosoftSSOGuard)
|
|
||||||
async microsoftAuth(@Request() req) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Callback URL for Microsoft SSO
|
|
||||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
|
||||||
*/
|
|
||||||
@Get('microsoft/callback')
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(MicrosoftSSOGuard)
|
|
||||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
|
||||||
authCookieHandler(
|
|
||||||
res,
|
|
||||||
authTokens.right,
|
|
||||||
true,
|
|
||||||
req.authInfo.state.redirect_uri,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Log user out by clearing cookies containing auth tokens
|
|
||||||
*/
|
|
||||||
@Get('logout')
|
|
||||||
async logout(@Res() res: Response) {
|
|
||||||
res.clearCookie('access_token');
|
|
||||||
res.clearCookie('refresh_token');
|
|
||||||
return res.status(200).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('verify/admin')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async verifyAdmin(@GqlUser() user: AuthUser) {
|
|
||||||
const userInfo = await this.authService.verifyAdmin(user);
|
|
||||||
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
|
|
||||||
return userInfo.right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { UserModule } from 'src/user/user.module';
|
|
||||||
import { MailerModule } from 'src/mailer/mailer.module';
|
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
|
||||||
import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
|
||||||
import { GoogleStrategy } from './strategies/google.strategy';
|
|
||||||
import { GithubStrategy } from './strategies/github.strategy';
|
|
||||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
PrismaModule,
|
|
||||||
UserModule,
|
|
||||||
MailerModule,
|
|
||||||
PassportModule,
|
|
||||||
JwtModule.register({
|
|
||||||
secret: process.env.JWT_SECRET,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AuthService,
|
|
||||||
JwtStrategy,
|
|
||||||
RTJwtStrategy,
|
|
||||||
GoogleStrategy,
|
|
||||||
GithubStrategy,
|
|
||||||
MicrosoftStrategy,
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { Account, VerificationToken } from '@prisma/client';
|
|
||||||
import { mockDeep, mockFn } from 'jest-mock-extended';
|
|
||||||
import {
|
|
||||||
INVALID_EMAIL,
|
|
||||||
INVALID_MAGIC_LINK_DATA,
|
|
||||||
INVALID_REFRESH_TOKEN,
|
|
||||||
MAGIC_LINK_EXPIRED,
|
|
||||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
USERS_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { MailerService } from 'src/mailer/mailer.service';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import * as argon2 from 'argon2';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
|
||||||
const mockUser = mockDeep<UserService>();
|
|
||||||
const mockJWT = mockDeep<JwtService>();
|
|
||||||
const mockMailer = mockDeep<MailerService>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
|
|
||||||
|
|
||||||
const currentTime = new Date();
|
|
||||||
|
|
||||||
const user: AuthUser = {
|
|
||||||
uid: '123344',
|
|
||||||
email: 'dwight@dundermifflin.com',
|
|
||||||
displayName: 'Dwight Schrute',
|
|
||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
|
||||||
isAdmin: false,
|
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
|
||||||
createdOn: currentTime,
|
|
||||||
currentGQLSession: {},
|
|
||||||
currentRESTSession: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const passwordlessData: VerificationToken = {
|
|
||||||
deviceIdentifier: 'k23hb7u7gdcujhb',
|
|
||||||
token: 'jhhj24sdjvl',
|
|
||||||
userUid: user.uid,
|
|
||||||
expiresOn: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const magicLinkVerify: VerifyMagicDto = {
|
|
||||||
deviceIdentifier: 'Dscdc',
|
|
||||||
token: 'SDcsdc',
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountDetails: Account = {
|
|
||||||
id: '123dcdc',
|
|
||||||
userId: user.uid,
|
|
||||||
provider: 'email',
|
|
||||||
providerAccountId: user.uid,
|
|
||||||
providerRefreshToken: 'dscsdc',
|
|
||||||
providerAccessToken: 'sdcsdcsdc',
|
|
||||||
providerScope: 'user.email',
|
|
||||||
loggedIn: currentTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
let nowPlus30 = new Date();
|
|
||||||
nowPlus30.setMinutes(nowPlus30.getMinutes() + 30000);
|
|
||||||
nowPlus30 = new Date(nowPlus30);
|
|
||||||
|
|
||||||
const encodedRefreshToken =
|
|
||||||
'$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I';
|
|
||||||
|
|
||||||
describe('signInMagicLink', () => {
|
|
||||||
test('Should throw error if email is not in valid format', async () => {
|
|
||||||
const result = await authService.signInMagicLink('bbbgmail.com', 'admin');
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: INVALID_EMAIL,
|
|
||||||
statusCode: HttpStatus.BAD_REQUEST,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should successfully create a new user account and return the passwordless details', async () => {
|
|
||||||
// check to see if user exists, return none
|
|
||||||
mockUser.findUserByEmail.mockResolvedValue(O.none);
|
|
||||||
// create new user
|
|
||||||
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
|
||||||
// create new entry in VerificationToken table
|
|
||||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
|
||||||
|
|
||||||
const result = await authService.signInMagicLink(
|
|
||||||
'dwight@dundermifflin.com',
|
|
||||||
'admin',
|
|
||||||
);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should successfully return the passwordless details for a pre-existing user account', async () => {
|
|
||||||
// check to see if user exists, return error
|
|
||||||
mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user));
|
|
||||||
// create new entry in VerificationToken table
|
|
||||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
|
||||||
|
|
||||||
const result = await authService.signInMagicLink(
|
|
||||||
'dwight@dundermifflin.com',
|
|
||||||
'admin',
|
|
||||||
);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyMagicLinkTokens', () => {
|
|
||||||
test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => {
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: INVALID_MAGIC_LINK_DATA,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw USER_NOT_FOUND if user is invalid', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
|
||||||
passwordlessData,
|
|
||||||
);
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.none);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should successfully return auth token pair with provider account existing', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
|
||||||
...passwordlessData,
|
|
||||||
expiresOn: nowPlus30,
|
|
||||||
});
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
|
||||||
// checkIfProviderAccountExists
|
|
||||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
|
||||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
|
||||||
// deletePasswordlessVerificationToken
|
|
||||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
access_token: user.refreshToken,
|
|
||||||
refresh_token: user.refreshToken,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should successfully return auth token pair with provider account not existing', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
|
||||||
...passwordlessData,
|
|
||||||
expiresOn: nowPlus30,
|
|
||||||
});
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
|
||||||
// checkIfProviderAccountExists
|
|
||||||
mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
|
||||||
mockUser.createUserSSO.mockResolvedValueOnce(user);
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
|
||||||
// deletePasswordlessVerificationToken
|
|
||||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
access_token: user.refreshToken,
|
|
||||||
refresh_token: user.refreshToken,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
|
||||||
passwordlessData,
|
|
||||||
);
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
|
||||||
// checkIfProviderAccountExists
|
|
||||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: MAGIC_LINK_EXPIRED,
|
|
||||||
statusCode: HttpStatus.UNAUTHORIZED,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
|
||||||
...passwordlessData,
|
|
||||||
expiresOn: nowPlus30,
|
|
||||||
});
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
|
||||||
// checkIfProviderAccountExists
|
|
||||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
|
||||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
|
||||||
E.left(USER_NOT_FOUND),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => {
|
|
||||||
// validatePasswordlessTokens
|
|
||||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
|
||||||
...passwordlessData,
|
|
||||||
expiresOn: nowPlus30,
|
|
||||||
});
|
|
||||||
// findUserById
|
|
||||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
|
||||||
// checkIfProviderAccountExists
|
|
||||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
|
||||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
|
||||||
// deletePasswordlessVerificationToken
|
|
||||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateAuthTokens', () => {
|
|
||||||
test('Should successfully generate tokens with valid inputs', async () => {
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
|
||||||
|
|
||||||
const result = await authService.generateAuthTokens(user.uid);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
access_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
|
||||||
refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
|
||||||
E.left(USER_NOT_FOUND),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.generateAuthTokens(user.uid);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('argon2', () => {
|
|
||||||
return {
|
|
||||||
verify: jest.fn((x, y) => {
|
|
||||||
if (y === null) return false;
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
hash: jest.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('refreshAuthTokens', () => {
|
|
||||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
|
||||||
E.left(USER_NOT_FOUND),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.refreshAuthTokens(
|
|
||||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw USER_NOT_FOUND when user is invalid', async () => {
|
|
||||||
const result = await authService.refreshAuthTokens(
|
|
||||||
'jshdcbjsdhcbshdbc',
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should successfully refresh the tokens and generate a new auth token pair', async () => {
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
|
||||||
// UpdateUserRefreshToken
|
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
|
||||||
E.right({
|
|
||||||
...user,
|
|
||||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.refreshAuthTokens(
|
|
||||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
access_token: 'sdhjcbjsdhcbshjdcb',
|
|
||||||
refresh_token: 'sdhjcbjsdhcbshjdcb',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => {
|
|
||||||
// generateAuthTokens
|
|
||||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
|
||||||
mockPrisma.user.update.mockResolvedValueOnce({
|
|
||||||
...user,
|
|
||||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await authService.refreshAuthTokens(null, user);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: INVALID_REFRESH_TOKEN,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyAdmin', () => {
|
|
||||||
test('should successfully elevate user to admin when userCount is 1 ', async () => {
|
|
||||||
// getUsersCount
|
|
||||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
|
||||||
// makeAdmin
|
|
||||||
mockUser.makeAdmin.mockResolvedValueOnce(
|
|
||||||
E.right({
|
|
||||||
...user,
|
|
||||||
isAdmin: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await authService.verifyAdmin(user);
|
|
||||||
expect(result).toEqualRight({ isAdmin: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return true if user is already an admin', async () => {
|
|
||||||
const result = await authService.verifyAdmin({ ...user, isAdmin: true });
|
|
||||||
expect(result).toEqualRight({ isAdmin: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw USERS_NOT_FOUND when userUid is invalid', async () => {
|
|
||||||
// getUsersCount
|
|
||||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
|
||||||
// makeAdmin
|
|
||||||
mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND));
|
|
||||||
|
|
||||||
const result = await authService.verifyAdmin(user);
|
|
||||||
expect(result).toEqualLeft({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return false when user is not an admin and userCount is greater than 1', async () => {
|
|
||||||
// getUsersCount
|
|
||||||
mockUser.getUsersCount.mockResolvedValueOnce(13);
|
|
||||||
|
|
||||||
const result = await authService.verifyAdmin(user);
|
|
||||||
expect(result).toEqualRight({ isAdmin: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
|
||||||
import { MailerService } from 'src/mailer/mailer.service';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import * as argon2 from 'argon2';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { DeviceIdentifierToken } from 'src/types/Passwordless';
|
|
||||||
import {
|
|
||||||
INVALID_EMAIL,
|
|
||||||
INVALID_MAGIC_LINK_DATA,
|
|
||||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
|
||||||
MAGIC_LINK_EXPIRED,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
INVALID_REFRESH_TOKEN,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { validateEmail } from 'src/utils';
|
|
||||||
import {
|
|
||||||
AccessTokenPayload,
|
|
||||||
AuthTokens,
|
|
||||||
RefreshTokenPayload,
|
|
||||||
} from 'src/types/AuthTokens';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { AuthError } from 'src/types/AuthError';
|
|
||||||
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
|
||||||
import { VerificationToken } from '@prisma/client';
|
|
||||||
import { Origin } from './helper';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
private usersService: UserService,
|
|
||||||
private prismaService: PrismaService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
private readonly mailerService: MailerService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Id and token for email Magic-Link auth
|
|
||||||
*
|
|
||||||
* @param user User Object
|
|
||||||
* @returns Created VerificationToken token
|
|
||||||
*/
|
|
||||||
private async generateMagicLinkTokens(user: AuthUser) {
|
|
||||||
const salt = await bcrypt.genSalt(
|
|
||||||
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
|
|
||||||
);
|
|
||||||
const expiresOn = DateTime.now()
|
|
||||||
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
|
|
||||||
.toISO()
|
|
||||||
.toString();
|
|
||||||
|
|
||||||
const idToken = await this.prismaService.verificationToken.create({
|
|
||||||
data: {
|
|
||||||
deviceIdentifier: salt,
|
|
||||||
userUid: user.uid,
|
|
||||||
expiresOn: expiresOn,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return idToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if VerificationToken exist or not
|
|
||||||
*
|
|
||||||
* @param magicLinkTokens Object containing deviceIdentifier and token
|
|
||||||
* @returns Option of VerificationToken token
|
|
||||||
*/
|
|
||||||
private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) {
|
|
||||||
try {
|
|
||||||
const tokens =
|
|
||||||
await this.prismaService.verificationToken.findUniqueOrThrow({
|
|
||||||
where: {
|
|
||||||
passwordless_deviceIdentifier_tokens: {
|
|
||||||
deviceIdentifier: magicLinkTokens.deviceIdentifier,
|
|
||||||
token: magicLinkTokens.token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return O.some(tokens);
|
|
||||||
} catch (error) {
|
|
||||||
return O.none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate new refresh token for user
|
|
||||||
*
|
|
||||||
* @param userUid User Id
|
|
||||||
* @returns Generated refreshToken
|
|
||||||
*/
|
|
||||||
private async generateRefreshToken(userUid: string) {
|
|
||||||
const refreshTokenPayload: RefreshTokenPayload = {
|
|
||||||
iss: process.env.VITE_BASE_URL,
|
|
||||||
sub: userUid,
|
|
||||||
aud: [process.env.VITE_BASE_URL],
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
|
||||||
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
|
||||||
|
|
||||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
|
||||||
refreshTokenHash,
|
|
||||||
userUid,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedUser))
|
|
||||||
return E.left(<AuthError>{
|
|
||||||
message: updatedUser.left,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate access and refresh token pair
|
|
||||||
*
|
|
||||||
* @param userUid User ID
|
|
||||||
* @returns Either of generated AuthTokens
|
|
||||||
*/
|
|
||||||
async generateAuthTokens(userUid: string) {
|
|
||||||
const accessTokenPayload: AccessTokenPayload = {
|
|
||||||
iss: process.env.VITE_BASE_URL,
|
|
||||||
sub: userUid,
|
|
||||||
aud: [process.env.VITE_BASE_URL],
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshToken = await this.generateRefreshToken(userUid);
|
|
||||||
if (E.isLeft(refreshToken)) return E.left(refreshToken.left);
|
|
||||||
|
|
||||||
return E.right(<AuthTokens>{
|
|
||||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
|
||||||
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
|
|
||||||
}),
|
|
||||||
refresh_token: refreshToken.right,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deleted used VerificationToken tokens
|
|
||||||
*
|
|
||||||
* @param passwordlessTokens VerificationToken entry to delete from DB
|
|
||||||
* @returns Either of deleted VerificationToken token
|
|
||||||
*/
|
|
||||||
private async deleteMagicLinkVerificationTokens(
|
|
||||||
passwordlessTokens: VerificationToken,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const deletedPasswordlessToken =
|
|
||||||
await this.prismaService.verificationToken.delete({
|
|
||||||
where: {
|
|
||||||
passwordless_deviceIdentifier_tokens: {
|
|
||||||
deviceIdentifier: passwordlessTokens.deviceIdentifier,
|
|
||||||
token: passwordlessTokens.token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return E.right(deletedPasswordlessToken);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if Provider account exists for User
|
|
||||||
*
|
|
||||||
* @param user User Object
|
|
||||||
* @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft)
|
|
||||||
* @returns Either of existing user provider Account
|
|
||||||
*/
|
|
||||||
async checkIfProviderAccountExists(user: AuthUser, SSOUserData) {
|
|
||||||
const provider = await this.prismaService.account.findUnique({
|
|
||||||
where: {
|
|
||||||
verifyProviderAccount: {
|
|
||||||
provider: SSOUserData.provider,
|
|
||||||
providerAccountId: SSOUserData.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!provider) return O.none;
|
|
||||||
|
|
||||||
return O.some(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create User (if not already present) and send email to initiate Magic-Link auth
|
|
||||||
*
|
|
||||||
* @param email User's email
|
|
||||||
* @returns Either containing DeviceIdentifierToken
|
|
||||||
*/
|
|
||||||
async signInMagicLink(email: string, origin: string) {
|
|
||||||
if (!validateEmail(email))
|
|
||||||
return E.left({
|
|
||||||
message: INVALID_EMAIL,
|
|
||||||
statusCode: HttpStatus.BAD_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
let user: AuthUser;
|
|
||||||
const queriedUser = await this.usersService.findUserByEmail(email);
|
|
||||||
|
|
||||||
if (O.isNone(queriedUser)) {
|
|
||||||
user = await this.usersService.createUserViaMagicLink(email);
|
|
||||||
} else {
|
|
||||||
user = queriedUser.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedTokens = await this.generateMagicLinkTokens(user);
|
|
||||||
|
|
||||||
// check to see if origin is valid
|
|
||||||
let url: string;
|
|
||||||
switch (origin) {
|
|
||||||
case Origin.ADMIN:
|
|
||||||
url = process.env.VITE_ADMIN_URL;
|
|
||||||
break;
|
|
||||||
case Origin.APP:
|
|
||||||
url = process.env.VITE_BASE_URL;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// if origin is invalid by default set URL to Hoppscotch-App
|
|
||||||
url = process.env.VITE_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.mailerService.sendAuthEmail(email, {
|
|
||||||
template: 'code-your-own',
|
|
||||||
variables: {
|
|
||||||
inviteeEmail: email,
|
|
||||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(<DeviceIdentifierToken>{
|
|
||||||
deviceIdentifier: generatedTokens.deviceIdentifier,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify and authenticate user from received data for Magic-Link
|
|
||||||
*
|
|
||||||
* @param magicLinkIDTokens magic-link verification tokens from client
|
|
||||||
* @returns Either of generated AuthTokens
|
|
||||||
*/
|
|
||||||
async verifyMagicLinkTokens(
|
|
||||||
magicLinkIDTokens: VerifyMagicDto,
|
|
||||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
|
||||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
|
||||||
magicLinkIDTokens,
|
|
||||||
);
|
|
||||||
if (O.isNone(passwordlessTokens))
|
|
||||||
return E.left({
|
|
||||||
message: INVALID_MAGIC_LINK_DATA,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await this.usersService.findUserById(
|
|
||||||
passwordlessTokens.value.userUid,
|
|
||||||
);
|
|
||||||
if (O.isNone(user))
|
|
||||||
return E.left({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * Check to see if entry for Magic-Link is present in the Account table for user
|
|
||||||
* * If user was created with another provider findUserById may return true
|
|
||||||
*/
|
|
||||||
const profile = {
|
|
||||||
provider: 'magic',
|
|
||||||
id: user.value.email,
|
|
||||||
};
|
|
||||||
const providerAccountExists = await this.checkIfProviderAccountExists(
|
|
||||||
user.value,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (O.isNone(providerAccountExists)) {
|
|
||||||
await this.usersService.createProviderAccount(
|
|
||||||
user.value,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = DateTime.now().toISO();
|
|
||||||
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
|
|
||||||
return E.left({
|
|
||||||
message: MAGIC_LINK_EXPIRED,
|
|
||||||
statusCode: HttpStatus.UNAUTHORIZED,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = await this.generateAuthTokens(
|
|
||||||
passwordlessTokens.value.userUid,
|
|
||||||
);
|
|
||||||
if (E.isLeft(tokens))
|
|
||||||
return E.left({
|
|
||||||
message: tokens.left.message,
|
|
||||||
statusCode: tokens.left.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const deletedPasswordlessToken =
|
|
||||||
await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value);
|
|
||||||
if (E.isLeft(deletedPasswordlessToken))
|
|
||||||
return E.left({
|
|
||||||
message: deletedPasswordlessToken.left,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(tokens.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh refresh and auth tokens
|
|
||||||
*
|
|
||||||
* @param hashedRefreshToken Hashed refresh token received from client
|
|
||||||
* @param user User Object
|
|
||||||
* @returns Either of generated AuthTokens
|
|
||||||
*/
|
|
||||||
async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) {
|
|
||||||
// Check to see user is valid
|
|
||||||
if (!user)
|
|
||||||
return E.left({
|
|
||||||
message: USER_NOT_FOUND,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB
|
|
||||||
const isTokenMatched = await argon2.verify(
|
|
||||||
user.refreshToken,
|
|
||||||
hashedRefreshToken,
|
|
||||||
);
|
|
||||||
if (!isTokenMatched)
|
|
||||||
return E.left({
|
|
||||||
message: INVALID_REFRESH_TOKEN,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
// if tokens match, generate new pair of auth tokens
|
|
||||||
const generatedAuthTokens = await this.generateAuthTokens(user.uid);
|
|
||||||
if (E.isLeft(generatedAuthTokens))
|
|
||||||
return E.left({
|
|
||||||
message: generatedAuthTokens.left.message,
|
|
||||||
statusCode: generatedAuthTokens.left.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(generatedAuthTokens.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify is signed in User is an admin or not
|
|
||||||
*
|
|
||||||
* @param user User Object
|
|
||||||
* @returns Either of boolean if user is admin or not
|
|
||||||
*/
|
|
||||||
async verifyAdmin(user: AuthUser) {
|
|
||||||
if (user.isAdmin) return E.right(<IsAdmin>{ isAdmin: true });
|
|
||||||
|
|
||||||
const usersCount = await this.usersService.getUsersCount();
|
|
||||||
if (usersCount === 1) {
|
|
||||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
|
||||||
if (E.isLeft(elevatedUser))
|
|
||||||
return E.left(<AuthError>{
|
|
||||||
message: elevatedUser.left,
|
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(<IsAdmin>{ isAdmin: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.right(<IsAdmin>{ isAdmin: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Inputs to initiate Magic-Link auth flow
|
|
||||||
export class SignInMagicDto {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Inputs to verify and sign a user in via magic-link
|
|
||||||
export class VerifyMagicDto {
|
|
||||||
deviceIdentifier: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GithubSSOGuard extends AuthGuard('github') {
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
|
||||||
const req = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
redirect_uri: req.query.redirect_uri,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GoogleSSOGuard extends AuthGuard('google') {
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
|
||||||
const req = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
redirect_uri: req.query.redirect_uri,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
|
||||||
const req = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
redirect_uri: req.query.redirect_uri,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { AuthError } from 'src/types/AuthError';
|
|
||||||
import { AuthTokens } from 'src/types/AuthTokens';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import * as cookie from 'cookie';
|
|
||||||
import { COOKIES_NOT_FOUND } from 'src/errors';
|
|
||||||
|
|
||||||
enum AuthTokenType {
|
|
||||||
ACCESS_TOKEN = 'access_token',
|
|
||||||
REFRESH_TOKEN = 'refresh_token',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Origin {
|
|
||||||
ADMIN = 'admin',
|
|
||||||
APP = 'app',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function allows throw to be used as an expression
|
|
||||||
* @param errMessage Message present in the error message
|
|
||||||
*/
|
|
||||||
export function throwHTTPErr(errorData: AuthError): never {
|
|
||||||
const { message, statusCode } = errorData;
|
|
||||||
throw new HttpException(message, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets and returns the cookies in the response object on successful authentication
|
|
||||||
* @param res Express Response Object
|
|
||||||
* @param authTokens Object containing the access and refresh tokens
|
|
||||||
* @param redirect if true will redirect to provided URL else just send a 200 status code
|
|
||||||
*/
|
|
||||||
export const authCookieHandler = (
|
|
||||||
res: Response,
|
|
||||||
authTokens: AuthTokens,
|
|
||||||
redirect: boolean,
|
|
||||||
redirectUrl: string | null,
|
|
||||||
) => {
|
|
||||||
const currentTime = DateTime.now();
|
|
||||||
const accessTokenValidity = currentTime
|
|
||||||
.plus({
|
|
||||||
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
|
|
||||||
})
|
|
||||||
.toMillis();
|
|
||||||
const refreshTokenValidity = currentTime
|
|
||||||
.plus({
|
|
||||||
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
|
|
||||||
})
|
|
||||||
.toMillis();
|
|
||||||
|
|
||||||
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: accessTokenValidity,
|
|
||||||
});
|
|
||||||
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: refreshTokenValidity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!redirect) {
|
|
||||||
return res.status(HttpStatus.OK).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
// check to see if redirectUrl is a whitelisted url
|
|
||||||
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
|
|
||||||
if (!whitelistedOrigins.includes(redirectUrl))
|
|
||||||
// if it is not redirect by default to REDIRECT_URL
|
|
||||||
redirectUrl = process.env.REDIRECT_URL;
|
|
||||||
|
|
||||||
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode the cookie header from incoming websocket connects and returns a auth token pair
|
|
||||||
* @param rawCookies cookies from the websocket connection
|
|
||||||
* @returns AuthTokens for JWT strategy to use
|
|
||||||
*/
|
|
||||||
export const subscriptionContextCookieParser = (rawCookies: string) => {
|
|
||||||
const cookies = cookie.parse(rawCookies);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!cookies[AuthTokenType.ACCESS_TOKEN] &&
|
|
||||||
!cookies[AuthTokenType.REFRESH_TOKEN]
|
|
||||||
) {
|
|
||||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
|
||||||
cause: new Error(COOKIES_NOT_FOUND),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AuthTokens>{
|
|
||||||
access_token: cookies[AuthTokenType.ACCESS_TOKEN],
|
|
||||||
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Strategy } from 'passport-github2';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GithubStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(
|
|
||||||
private authService: AuthService,
|
|
||||||
private usersService: UserService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
clientID: process.env.GITHUB_CLIENT_ID,
|
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
||||||
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
|
||||||
scope: [process.env.GITHUB_SCOPE],
|
|
||||||
store: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(accessToken, refreshToken, profile, done) {
|
|
||||||
const user = await this.usersService.findUserByEmail(
|
|
||||||
profile.emails[0].value,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (O.isNone(user)) {
|
|
||||||
const createdUser = await this.usersService.createUserSSO(
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
return createdUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
|
||||||
*/
|
|
||||||
if (!user.value.displayName || !user.value.photoURL) {
|
|
||||||
const updatedUser = await this.usersService.updateUserDetails(
|
|
||||||
user.value,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedUser)) {
|
|
||||||
throw new UnauthorizedException(updatedUser.left);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * Check to see if entry for Github is present in the Account table for user
|
|
||||||
* * If user was created with another provider findUserByEmail may return true
|
|
||||||
*/
|
|
||||||
const providerAccountExists =
|
|
||||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
|
||||||
|
|
||||||
if (O.isNone(providerAccountExists))
|
|
||||||
await this.usersService.createProviderAccount(
|
|
||||||
user.value,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
|
|
||||||
return user.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(
|
|
||||||
private usersService: UserService,
|
|
||||||
private authService: AuthService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
||||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
|
||||||
scope: process.env.GOOGLE_SCOPE.split(','),
|
|
||||||
passReqToCallback: true,
|
|
||||||
store: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(
|
|
||||||
req: Request,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
done: VerifyCallback,
|
|
||||||
) {
|
|
||||||
const user = await this.usersService.findUserByEmail(
|
|
||||||
profile.emails[0].value,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (O.isNone(user)) {
|
|
||||||
const createdUser = await this.usersService.createUserSSO(
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
return createdUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
|
||||||
*/
|
|
||||||
if (!user.value.displayName || !user.value.photoURL) {
|
|
||||||
const updatedUser = await this.usersService.updateUserDetails(
|
|
||||||
user.value,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedUser)) {
|
|
||||||
throw new UnauthorizedException(updatedUser.left);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * Check to see if entry for Google is present in the Account table for user
|
|
||||||
* * If user was created with another provider findUserByEmail may return true
|
|
||||||
*/
|
|
||||||
const providerAccountExists =
|
|
||||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
|
||||||
|
|
||||||
if (O.isNone(providerAccountExists))
|
|
||||||
await this.usersService.createProviderAccount(
|
|
||||||
user.value,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
|
|
||||||
return user.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
ForbiddenException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import {
|
|
||||||
COOKIES_NOT_FOUND,
|
|
||||||
INVALID_ACCESS_TOKEN,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|
||||||
constructor(private usersService: UserService) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
|
||||||
(request: Request) => {
|
|
||||||
const ATCookie = request.cookies['access_token'];
|
|
||||||
if (!ATCookie) {
|
|
||||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return ATCookie;
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
secretOrKey: process.env.JWT_SECRET,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(payload: AccessTokenPayload) {
|
|
||||||
if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN);
|
|
||||||
|
|
||||||
const user = await this.usersService.findUserById(payload.sub);
|
|
||||||
if (O.isNone(user)) {
|
|
||||||
throw new UnauthorizedException(USER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Strategy } from 'passport-microsoft';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(
|
|
||||||
private authService: AuthService,
|
|
||||||
private usersService: UserService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
clientID: process.env.MICROSOFT_CLIENT_ID,
|
|
||||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
|
||||||
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
|
|
||||||
scope: [process.env.MICROSOFT_SCOPE],
|
|
||||||
passReqToCallback: true,
|
|
||||||
store: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(accessToken: string, refreshToken: string, profile, done) {
|
|
||||||
const user = await this.usersService.findUserByEmail(
|
|
||||||
profile.emails[0].value,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (O.isNone(user)) {
|
|
||||||
const createdUser = await this.usersService.createUserSSO(
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
return createdUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
|
||||||
*/
|
|
||||||
if (!user.value.displayName || !user.value.photoURL) {
|
|
||||||
const updatedUser = await this.usersService.updateUserDetails(
|
|
||||||
user.value,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedUser)) {
|
|
||||||
throw new UnauthorizedException(updatedUser.left);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* * Check to see if entry for Microsoft is present in the Account table for user
|
|
||||||
* * If user was created with another provider findUserByEmail may return true
|
|
||||||
*/
|
|
||||||
const providerAccountExists =
|
|
||||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
|
||||||
|
|
||||||
if (O.isNone(providerAccountExists))
|
|
||||||
await this.usersService.createProviderAccount(
|
|
||||||
user.value,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
|
|
||||||
return user.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
ForbiddenException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { RefreshTokenPayload } from 'src/types/AuthTokens';
|
|
||||||
import {
|
|
||||||
COOKIES_NOT_FOUND,
|
|
||||||
INVALID_REFRESH_TOKEN,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
|
||||||
constructor(private usersService: UserService) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
|
||||||
(request: Request) => {
|
|
||||||
const RTCookie = request.cookies['refresh_token'];
|
|
||||||
if (!RTCookie) {
|
|
||||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return RTCookie;
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
secretOrKey: process.env.JWT_SECRET,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(payload: RefreshTokenPayload) {
|
|
||||||
if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN);
|
|
||||||
|
|
||||||
const user = await this.usersService.findUserById(payload.sub);
|
|
||||||
if (O.isNone(user)) {
|
|
||||||
throw new UnauthorizedException(USER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
export const GqlUser = createParamDecorator(
|
|
||||||
(data: unknown, context: ExecutionContext) => {
|
|
||||||
const ctx = GqlExecutionContext.create(context);
|
|
||||||
const { req, headers } = ctx.getContext();
|
|
||||||
return headers ? headers.user : req.user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
/**
|
|
||||||
** Decorator to fetch refresh_token from cookie
|
|
||||||
*/
|
|
||||||
export const RTCookie = createParamDecorator(
|
|
||||||
(data: unknown, context: ExecutionContext) => {
|
|
||||||
const ctx = GqlExecutionContext.create(context);
|
|
||||||
return ctx.getContext().req.cookies['refresh_token'];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
export const INVALID_EMAIL = 'invalid/email' as const;
|
|
||||||
|
|
||||||
export const EMAIL_FAILED = 'email/failed' as const;
|
|
||||||
export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only one admin account found in infra
|
|
||||||
* (AdminService)
|
|
||||||
*/
|
|
||||||
export const ONLY_ONE_ADMIN_ACCOUNT =
|
|
||||||
'admin/only_one_admin_account_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token Authorization failed (Check 'Authorization' Header)
|
|
||||||
* (GqlAuthGuard)
|
|
||||||
*/
|
|
||||||
export const AUTH_FAIL = 'auth/fail';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid JSON
|
|
||||||
* (Utils)
|
|
||||||
*/
|
|
||||||
export const JSON_INVALID = 'json_invalid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to delete an user data document from fb firestore but failed.
|
|
||||||
* (FirebaseService)
|
|
||||||
*/
|
|
||||||
export const USER_FB_DOCUMENT_DELETION_FAILED =
|
|
||||||
'fb/firebase_document_deletion_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to do an action on a user where user is not found
|
|
||||||
*/
|
|
||||||
export const USER_NOT_FOUND = 'user/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User is already invited by admin
|
|
||||||
*/
|
|
||||||
export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User update failure
|
|
||||||
* (UserService)
|
|
||||||
*/
|
|
||||||
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User deletion failure
|
|
||||||
* (UserService)
|
|
||||||
*/
|
|
||||||
export const USER_DELETION_FAILED = 'user/deletion_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Users not found
|
|
||||||
* (UserService)
|
|
||||||
*/
|
|
||||||
export const USERS_NOT_FOUND = 'user/users_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User deletion failure error due to user being a team owner
|
|
||||||
* (UserService)
|
|
||||||
*/
|
|
||||||
export const USER_IS_OWNER = 'user/is_owner' as const;
|
|
||||||
/**
|
|
||||||
* User deletion failure error due to user being an admin
|
|
||||||
* (UserService)
|
|
||||||
*/
|
|
||||||
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Teams not found
|
|
||||||
* (TeamsService)
|
|
||||||
*/
|
|
||||||
export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to find user collection but failed
|
|
||||||
* (UserRequestService)
|
|
||||||
*/
|
|
||||||
export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to reorder user request but failed
|
|
||||||
* (UserRequestService)
|
|
||||||
*/
|
|
||||||
export const USER_REQUEST_CREATION_FAILED =
|
|
||||||
'user_request/creation_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to do an action on a user request but user request is not matched with user collection
|
|
||||||
* (UserRequestService)
|
|
||||||
*/
|
|
||||||
export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to do an action on a user request where user request is not found
|
|
||||||
* (UserRequestService)
|
|
||||||
*/
|
|
||||||
export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to reorder user request but failed
|
|
||||||
* (UserRequestService)
|
|
||||||
*/
|
|
||||||
export const USER_REQUEST_REORDERING_FAILED =
|
|
||||||
'user_request/reordering_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to perform action on a team which they are not a member of
|
|
||||||
* (GqlTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to perform action on a team that doesn't accept their member role level
|
|
||||||
* (GqlTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Team name validation failure
|
|
||||||
* (TeamService)
|
|
||||||
*/
|
|
||||||
export const TEAM_NAME_INVALID = 'team/name_invalid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find the sync data from the user
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There was a problem resolving the firebase collection path
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Could not find the team in the database
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cannot make parent collection a child of a collection that a child of itself
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_IS_PARENT_COLL = 'team_coll/collection_is_parent_coll';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target and Parent collections are not from the same team
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_NOT_SAME_TEAM = 'team_coll/collections_not_same_team';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target and Parent collections are the same
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_DEST_SAME =
|
|
||||||
'team_coll/target_and_destination_collection_are_same';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection is already a root collection
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COL_ALREADY_ROOT =
|
|
||||||
'team_coll/target_collection_is_already_root_collection';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collections have different parents
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COL_NOT_SAME_PARENT =
|
|
||||||
'team_coll/team_collections_have_different_parents';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection and next Collection are the same
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COL_SAME_NEXT_COLL =
|
|
||||||
'team_coll/collection_and_next_collection_are_same';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Team Collection Re-Ordering Failed
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COL_REORDERING_FAILED = 'team_coll/reordering_failed';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to update the team to a state it doesn't have any owners
|
|
||||||
* (TeamService)
|
|
||||||
*/
|
|
||||||
export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid or non-existent Team ID
|
|
||||||
* (TeamService)
|
|
||||||
*/
|
|
||||||
export const TEAM_INVALID_ID = 'team/invalid_id' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid or non-existent collection id
|
|
||||||
* (GqlCollectionTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid team id or user id
|
|
||||||
* (TeamService)
|
|
||||||
*/
|
|
||||||
export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The provided title for the team collection is short (less than 3 characters)
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The JSON used is not valid
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Team Collection does not belong to the team
|
|
||||||
* (TeamCollectionService)
|
|
||||||
*/
|
|
||||||
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to perform action on a request that doesn't accept their member role level
|
|
||||||
* (GqlRequestTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to operate on a request which does not exist
|
|
||||||
* (TeamRequestService)
|
|
||||||
*/
|
|
||||||
export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid or non-existent collection id
|
|
||||||
* (TeamRequestService)
|
|
||||||
*/
|
|
||||||
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
|
||||||
'team_req/invalid_target_id' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to reorder team request but failed
|
|
||||||
* (TeamRequestService)
|
|
||||||
*/
|
|
||||||
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No Postmark Sender Email defined
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tried to perform action on a request when the user is not even member of the team
|
|
||||||
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
|
|
||||||
|
|
||||||
export const TEAM_INVITE_MEMBER_HAS_INVITE =
|
|
||||||
'team_invite/member_has_invite' as const;
|
|
||||||
|
|
||||||
export const TEAM_INVITE_NO_INVITE_FOUND =
|
|
||||||
'team_invite/no_invite_found' as const;
|
|
||||||
|
|
||||||
export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const;
|
|
||||||
|
|
||||||
export const TEAM_INVITE_EMAIL_DO_NOT_MATCH =
|
|
||||||
'team_invite/email_do_not_match' as const;
|
|
||||||
|
|
||||||
export const TEAM_INVITE_NOT_VALID_VIEWER =
|
|
||||||
'team_invite/not_valid_viewer' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No team invitations found
|
|
||||||
* (TeamInvitationService)
|
|
||||||
*/
|
|
||||||
export const TEAM_INVITATION_NOT_FOUND =
|
|
||||||
'team_invite/invitations_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ShortCode not found in DB
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid ShortCode format
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ShortCode already exists in DB
|
|
||||||
* (ShortcodeService)
|
|
||||||
*/
|
|
||||||
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid or non-existent TEAM ENVIRONMMENT ID
|
|
||||||
* (TeamEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user is not a member of the team of the given environment
|
|
||||||
* (GqlTeamEnvTeamGuard)
|
|
||||||
*/
|
|
||||||
export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER =
|
|
||||||
'team_environment/not_team_member' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User setting not found for a user
|
|
||||||
* (UserSettingsService)
|
|
||||||
*/
|
|
||||||
export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User setting already exists for a user
|
|
||||||
* (UserSettingsService)
|
|
||||||
*/
|
|
||||||
export const USER_SETTINGS_ALREADY_EXISTS =
|
|
||||||
'user_settings/settings_already_exists' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User setting invalid (null) settings
|
|
||||||
* (UserSettingsService)
|
|
||||||
*/
|
|
||||||
export const USER_SETTINGS_NULL_SETTINGS =
|
|
||||||
'user_settings/null_settings' as const;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Global environment doesnt exists for the user
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
|
|
||||||
'user_environment/global_env_does_not_exists' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global environment already exists for the user
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
|
|
||||||
'user_environment/global_env_already_exists' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User environment doesn't exist for the user
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
|
|
||||||
'user_environment/user_env_does_not_exists' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cannot delete the global user environment
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
|
|
||||||
'user_environment/user_env_global_env_deletion_failed' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User environment is not a global environment
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
|
|
||||||
'user_environment/user_env_is_not_global' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User environment update failed
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_UPDATE_FAILED =
|
|
||||||
'user_environment/user_env_update_failed' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User environment invalid environment name
|
|
||||||
* (UserEnvironmentsService)
|
|
||||||
*/
|
|
||||||
export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
|
|
||||||
'user_environment/user_env_invalid_env_name' as const;
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User history not found
|
|
||||||
* (UserHistoryService)
|
|
||||||
*/
|
|
||||||
export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const;
|
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalid Request Type in History
|
|
||||||
* (UserHistoryService)
|
|
||||||
*/
|
|
||||||
export const USER_HISTORY_INVALID_REQ_TYPE =
|
|
||||||
'user_history/req_type_invalid' as const;
|
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
|------------------------------------|
|
|
||||||
|Server errors that are actually bugs|
|
|
||||||
|------------------------------------|
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied)
|
|
||||||
* (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present)
|
|
||||||
* (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
|
||||||
* (GqlTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists)
|
|
||||||
* (GqlCollectionTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find 'requestID' param to the attached GQL operation. (Check if exists)
|
|
||||||
* (GqlRequestTeamMemberGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id';
|
|
||||||
|
|
||||||
export const BUG_TEAM_INVITE_NO_INVITE_ID =
|
|
||||||
'bug/team_invite/no_invite_id' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
|
||||||
* (GqlTeamEnvTeamGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES =
|
|
||||||
'bug/team_env/guard_no_require_roles' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couldn't find 'id' param to the operation. (Check if it is applied)
|
|
||||||
* (GqlTeamEnvTeamGuard)
|
|
||||||
*/
|
|
||||||
export const BUG_TEAM_ENV_GUARD_NO_ENV_ID =
|
|
||||||
'bug/team_env/guard_no_env_id' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The data sent to the verify route are invalid
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Could not find VerificationToken entry in the db
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const VERIFICATION_TOKEN_DATA_NOT_FOUND =
|
|
||||||
'auth/verification_token_data_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth Tokens expired
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const TOKEN_EXPIRED = 'auth/token_expired' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VerificationToken Tokens expired i.e. magic-link expired
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No cookies were found in the auth request
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access Token is malformed or invalid
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh Token is malformed or invalid
|
|
||||||
* (AuthService)
|
|
||||||
*/
|
|
||||||
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The provided title for the user collection is short (less than 3 characters)
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User Collection could not be found
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UserCollection is already a root collection
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_ALREADY_ROOT =
|
|
||||||
'user_coll/target_user_collection_is_already_root_user_collection' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target and Parent user collections are the same
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_DEST_SAME =
|
|
||||||
'user_coll/target_and_destination_user_collection_are_same' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target and Parent user collections are not from the same user
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target and Parent user collections are not from the same type
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cannot make a parent user collection a child of itself
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_IS_PARENT_COLL =
|
|
||||||
'user_coll/user_collection_is_parent_coll' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User Collection Re-Ordering Failed
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_REORDERING_FAILED =
|
|
||||||
'user_coll/reordering_failed' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Collection and Next User Collection are the same
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_SAME_NEXT_COLL =
|
|
||||||
'user_coll/user_collection_and_next_user_collection_are_same' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The User Collection does not belong to the logged-in user
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The JSON used is not valid
|
|
||||||
* (UserCollectionService)
|
|
||||||
*/
|
|
||||||
export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* MAILER_SMTP_URL environment variable is not defined
|
|
||||||
* (MailerModule)
|
|
||||||
*/
|
|
||||||
export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAILER_ADDRESS_FROM environment variable is not defined
|
|
||||||
* (MailerModule)
|
|
||||||
*/
|
|
||||||
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
|
||||||
'mailer/from_address_undefined' as const;
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import {
|
|
||||||
GraphQLSchemaBuilderModule,
|
|
||||||
GraphQLSchemaFactory,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { printSchema } from 'graphql/utilities';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { ShortcodeResolver } from './shortcode/shortcode.resolver';
|
|
||||||
import { TeamCollectionResolver } from './team-collection/team-collection.resolver';
|
|
||||||
import { TeamEnvironmentsResolver } from './team-environments/team-environments.resolver';
|
|
||||||
import { TeamInvitationResolver } from './team-invitation/team-invitation.resolver';
|
|
||||||
import { TeamRequestResolver } from './team-request/team-request.resolver';
|
|
||||||
import { TeamMemberResolver } from './team/team-member.resolver';
|
|
||||||
import { TeamResolver } from './team/team.resolver';
|
|
||||||
import { UserCollectionResolver } from './user-collection/user-collection.resolver';
|
|
||||||
import { UserEnvironmentsResolver } from './user-environment/user-environments.resolver';
|
|
||||||
import { UserHistoryResolver } from './user-history/user-history.resolver';
|
|
||||||
import { UserRequestResolver } from './user-request/resolvers/user-request.resolver';
|
|
||||||
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
|
|
||||||
import { UserResolver } from './user/user.resolver';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { AdminResolver } from './admin/admin.resolver';
|
|
||||||
import { TeamEnvsTeamResolver } from './team-environments/team.resolver';
|
|
||||||
import { TeamTeamInviteExtResolver } from './team-invitation/team-teaminvite-ext.resolver';
|
|
||||||
import { UserRequestUserCollectionResolver } from './user-request/resolvers/user-collection.resolver';
|
|
||||||
import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
|
||||||
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
|
||||||
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the resolvers present in the application.
|
|
||||||
*
|
|
||||||
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
|
||||||
*/
|
|
||||||
const RESOLVERS = [
|
|
||||||
AdminResolver,
|
|
||||||
ShortcodeResolver,
|
|
||||||
TeamResolver,
|
|
||||||
TeamEnvsTeamResolver,
|
|
||||||
TeamMemberResolver,
|
|
||||||
TeamCollectionResolver,
|
|
||||||
TeamTeamInviteExtResolver,
|
|
||||||
TeamEnvironmentsResolver,
|
|
||||||
TeamEnvsTeamResolver,
|
|
||||||
TeamInvitationResolver,
|
|
||||||
TeamRequestResolver,
|
|
||||||
UserResolver,
|
|
||||||
UserCollectionResolver,
|
|
||||||
UserEnvironmentsResolver,
|
|
||||||
UserEnvsUserResolver,
|
|
||||||
UserHistoryUserResolver,
|
|
||||||
UserHistoryResolver,
|
|
||||||
UserCollectionResolver,
|
|
||||||
UserRequestResolver,
|
|
||||||
UserRequestUserCollectionResolver,
|
|
||||||
UserSettingsResolver,
|
|
||||||
UserSettingsUserResolver,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the custom scalars present in the application.
|
|
||||||
*
|
|
||||||
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
|
||||||
*/
|
|
||||||
const SCALARS = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the GraphQL Schema SDL definition and writes it into the location
|
|
||||||
* specified by the `GQL_SCHEMA_EMIT_LOCATION` environment variable.
|
|
||||||
*/
|
|
||||||
export async function emitGQLSchemaFile() {
|
|
||||||
const logger = new Logger('emitGQLSchemaFile');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const destination = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
process.env.GQL_SCHEMA_EMIT_LOCATION ?? '../gen/schema.gql',
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.log(`GQL_SCHEMA_EMIT_LOCATION: ${destination}`);
|
|
||||||
|
|
||||||
const app = await NestFactory.create(GraphQLSchemaBuilderModule);
|
|
||||||
await app.init();
|
|
||||||
|
|
||||||
const gqlSchemaFactory = app.get(GraphQLSchemaFactory);
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
`Generating Schema against ${RESOLVERS.length} resolvers and ${SCALARS.length} custom scalars`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const schema = await gqlSchemaFactory.create(RESOLVERS, SCALARS, {
|
|
||||||
numberScalarMode: 'integer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const schemaString = printSchema(schema, {
|
|
||||||
commentDescriptions: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
|
||||||
|
|
||||||
// Generating folders if required to emit to the given output
|
|
||||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
||||||
fs.writeFileSync(destination, schemaString);
|
|
||||||
|
|
||||||
logger.log(`Wrote schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
`Failed writing schema to GQL_SCHEMA_EMIT_LOCATION. Reason: ${e}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GqlAuthGuard extends AuthGuard('jwt') {
|
|
||||||
getRequest(context: ExecutionContext) {
|
|
||||||
const ctx = GqlExecutionContext.create(context);
|
|
||||||
const { req, headers } = ctx.getContext();
|
|
||||||
return headers ? headers : req;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GqlThrottlerGuard extends ThrottlerGuard {
|
|
||||||
getRequestResponse(context: ExecutionContext) {
|
|
||||||
const gqlCtx = GqlExecutionContext.create(context);
|
|
||||||
const ctx = gqlCtx.getContext();
|
|
||||||
return { req: ctx.req, res: ctx.res };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
|
||||||
protected getTracker(req: Record<string, any>): string {
|
|
||||||
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
|
|
||||||
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export type MailDescription = {
|
|
||||||
template: 'team-invitation';
|
|
||||||
variables: {
|
|
||||||
invitee: string;
|
|
||||||
invite_team_name: string;
|
|
||||||
action_url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserMagicLinkMailDescription = {
|
|
||||||
template: 'code-your-own';
|
|
||||||
variables: {
|
|
||||||
inviteeEmail: string;
|
|
||||||
magicLink: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminUserInvitationMailDescription = {
|
|
||||||
template: 'code-your-own';
|
|
||||||
variables: {
|
|
||||||
inviteeEmail: string;
|
|
||||||
magicLink: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
|
||||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
|
||||||
import { MailerService } from './mailer.service';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import {
|
|
||||||
MAILER_FROM_ADDRESS_UNDEFINED,
|
|
||||||
MAILER_SMTP_URL_UNDEFINED,
|
|
||||||
} from 'src/errors';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
NestMailerModule.forRoot({
|
|
||||||
transport:
|
|
||||||
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
|
||||||
defaults: {
|
|
||||||
from:
|
|
||||||
process.env.MAILER_ADDRESS_FROM ??
|
|
||||||
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
dir: __dirname + '/templates',
|
|
||||||
adapter: new HandlebarsAdapter(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
providers: [MailerService],
|
|
||||||
exports: [MailerService],
|
|
||||||
})
|
|
||||||
export class MailerModule {}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
AdminUserInvitationMailDescription,
|
|
||||||
MailDescription,
|
|
||||||
UserMagicLinkMailDescription,
|
|
||||||
} from './MailDescriptions';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { EMAIL_FAILED } from 'src/errors';
|
|
||||||
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MailerService {
|
|
||||||
constructor(private readonly nestMailerService: NestMailerService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes an input mail description and spits out the Email subject required for it
|
|
||||||
* @param mailDesc The mail description to get subject for
|
|
||||||
* @returns The subject of the email
|
|
||||||
*/
|
|
||||||
private resolveSubjectForMailDesc(
|
|
||||||
mailDesc:
|
|
||||||
| MailDescription
|
|
||||||
| UserMagicLinkMailDescription
|
|
||||||
| AdminUserInvitationMailDescription,
|
|
||||||
): string {
|
|
||||||
switch (mailDesc.template) {
|
|
||||||
case 'team-invitation':
|
|
||||||
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
|
|
||||||
|
|
||||||
case 'code-your-own':
|
|
||||||
return 'Sign in to Hoppscotch';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an email to the given email address given a mail description
|
|
||||||
* @param to The email address to be sent to (NOTE: this is not validated)
|
|
||||||
* @param mailDesc Definition of what email to be sent
|
|
||||||
*/
|
|
||||||
sendMail(
|
|
||||||
to: string,
|
|
||||||
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
|
||||||
) {
|
|
||||||
return TE.tryCatch(
|
|
||||||
async () => {
|
|
||||||
await this.nestMailerService.sendMail({
|
|
||||||
to,
|
|
||||||
template: mailDesc.template,
|
|
||||||
subject: this.resolveSubjectForMailDesc(mailDesc),
|
|
||||||
context: mailDesc.variables,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
() => EMAIL_FAILED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param to Receiver's email id
|
|
||||||
* @param mailDesc Details of email to be sent for Magic-Link auth
|
|
||||||
* @returns Response if email was send successfully or not
|
|
||||||
*/
|
|
||||||
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
|
|
||||||
try {
|
|
||||||
await this.nestMailerService.sendMail({
|
|
||||||
to,
|
|
||||||
template: mailDesc.template,
|
|
||||||
subject: this.resolveSubjectForMailDesc(mailDesc),
|
|
||||||
context: mailDesc.variables,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return throwErr(EMAIL_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param to Receiver's email id
|
|
||||||
* @param mailDesc Details of email to be sent for user invitation
|
|
||||||
* @returns Response if email was send successfully or not
|
|
||||||
*/
|
|
||||||
async sendUserInvitationEmail(
|
|
||||||
to: string,
|
|
||||||
mailDesc: AdminUserInvitationMailDescription,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const res = await this.nestMailerService.sendMail({
|
|
||||||
to,
|
|
||||||
template: mailDesc.template,
|
|
||||||
subject: this.resolveSubjectForMailDesc(mailDesc),
|
|
||||||
context: mailDesc.variables,
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErr(EMAIL_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="x-apple-disable-message-reformatting" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
<meta name="supported-color-schemes" content="light dark" />
|
|
||||||
<title></title>
|
|
||||||
<!--
|
|
||||||
The style block is collapsed on page load to save you some scrolling.
|
|
||||||
Postmark automatically inlines all CSS properties for maximum email client
|
|
||||||
compatibility. You can just update styles here, and Postmark does the rest.
|
|
||||||
-->
|
|
||||||
<style type="text/css" rel="stylesheet" media="all">
|
|
||||||
/* Base ------------------------------ */
|
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
|
||||||
body {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #3869D4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a img {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preheader {
|
|
||||||
display: none !important;
|
|
||||||
visibility: hidden;
|
|
||||||
mso-hide: all;
|
|
||||||
font-size: 1px;
|
|
||||||
line-height: 1px;
|
|
||||||
max-height: 0;
|
|
||||||
max-width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
/* Type ------------------------------ */
|
|
||||||
|
|
||||||
body,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
blockquote {
|
|
||||||
margin: .4em 0 1.1875em;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.625;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.sub {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
/* Utilities ------------------------------ */
|
|
||||||
|
|
||||||
.align-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
/* Buttons ------------------------------ */
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background-color: #3869D4;
|
|
||||||
border-top: 10px solid #3869D4;
|
|
||||||
border-right: 18px solid #3869D4;
|
|
||||||
border-bottom: 10px solid #3869D4;
|
|
||||||
border-left: 18px solid #3869D4;
|
|
||||||
display: inline-block;
|
|
||||||
color: #FFF;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--green {
|
|
||||||
background-color: #22BC66;
|
|
||||||
border-top: 10px solid #22BC66;
|
|
||||||
border-right: 18px solid #22BC66;
|
|
||||||
border-bottom: 10px solid #22BC66;
|
|
||||||
border-left: 18px solid #22BC66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--red {
|
|
||||||
background-color: #FF6136;
|
|
||||||
border-top: 10px solid #FF6136;
|
|
||||||
border-right: 18px solid #FF6136;
|
|
||||||
border-bottom: 10px solid #FF6136;
|
|
||||||
border-left: 18px solid #FF6136;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 500px) {
|
|
||||||
.button {
|
|
||||||
width: 100% !important;
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Attribute list ------------------------------ */
|
|
||||||
|
|
||||||
.attributes {
|
|
||||||
margin: 0 0 21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes_content {
|
|
||||||
background-color: #F4F4F7;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes_item {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* Related Items ------------------------------ */
|
|
||||||
|
|
||||||
.related {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 25px 0 0 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item {
|
|
||||||
padding: 10px 0;
|
|
||||||
color: #CBCCCF;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item-title {
|
|
||||||
display: block;
|
|
||||||
margin: .5em 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item-thumb {
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_heading {
|
|
||||||
border-top: 1px solid #CBCCCF;
|
|
||||||
text-align: center;
|
|
||||||
padding: 25px 0 10px;
|
|
||||||
}
|
|
||||||
/* Discount Code ------------------------------ */
|
|
||||||
|
|
||||||
.discount {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #F4F4F7;
|
|
||||||
border: 2px dashed #CBCCCF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount_heading {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount_body {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
/* Social Icons ------------------------------ */
|
|
||||||
|
|
||||||
.social {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social td {
|
|
||||||
padding: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social_icon {
|
|
||||||
height: 20px;
|
|
||||||
margin: 0 8px 10px 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* Data table ------------------------------ */
|
|
||||||
|
|
||||||
.purchase {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 35px 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_content {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 25px 0 0 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_item {
|
|
||||||
padding: 10px 0;
|
|
||||||
color: #51545E;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_heading {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_heading p {
|
|
||||||
margin: 0;
|
|
||||||
color: #85878E;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_footer {
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_total {
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_total--label {
|
|
||||||
padding: 0 15px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #F2F4F6;
|
|
||||||
color: #51545E;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #51545E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #F2F4F6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
/* Masthead ----------------------- */
|
|
||||||
|
|
||||||
.email-masthead {
|
|
||||||
padding: 25px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-masthead_logo {
|
|
||||||
width: 94px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-masthead_name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #A8AAAF;
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow: 0 1px 0 white;
|
|
||||||
}
|
|
||||||
/* Body ------------------------------ */
|
|
||||||
|
|
||||||
.email-body {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-body_inner {
|
|
||||||
width: 570px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 570px;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer {
|
|
||||||
width: 570px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 570px;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer p {
|
|
||||||
color: #A8AAAF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-action {
|
|
||||||
width: 100%;
|
|
||||||
margin: 30px auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-sub {
|
|
||||||
margin-top: 25px;
|
|
||||||
padding-top: 25px;
|
|
||||||
border-top: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-cell {
|
|
||||||
padding: 45px;
|
|
||||||
}
|
|
||||||
/*Media Queries ------------------------------ */
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.email-body_inner,
|
|
||||||
.email-footer {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body,
|
|
||||||
.email-body,
|
|
||||||
.email-body_inner,
|
|
||||||
.email-content,
|
|
||||||
.email-wrapper,
|
|
||||||
.email-masthead,
|
|
||||||
.email-footer {
|
|
||||||
background-color: #333333 !important;
|
|
||||||
color: #FFF !important;
|
|
||||||
}
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
blockquote,
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
span,
|
|
||||||
.purchase_item {
|
|
||||||
color: #FFF !important;
|
|
||||||
}
|
|
||||||
.attributes_content,
|
|
||||||
.discount {
|
|
||||||
background-color: #222 !important;
|
|
||||||
}
|
|
||||||
.email-masthead_name {
|
|
||||||
text-shadow: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: light dark;
|
|
||||||
supported-color-schemes: light dark;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!--[if mso]>
|
|
||||||
<style type="text/css">
|
|
||||||
.f-fallback {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="email-masthead">
|
|
||||||
<a href="https://hoppscotch.io" class="f-fallback email-masthead_name">
|
|
||||||
Hoppscotch
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Email Body -->
|
|
||||||
<tr>
|
|
||||||
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
|
||||||
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<!-- Body content -->
|
|
||||||
<tr>
|
|
||||||
<td class="content-cell">
|
|
||||||
<div class="f-fallback">
|
|
||||||
|
|
||||||
<h1>Hello,</h1>
|
|
||||||
|
|
||||||
<p>We received a request to sign in to Hoppscotch using this email address. If you want to sign in with your {{inviteeEmail}} account, click this link:</p>
|
|
||||||
|
|
||||||
|
|
||||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{{magicLink}}" class="button button--green" target="_blank">Sign in to Hoppscotch</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p>
|
|
||||||
|
|
||||||
|
|
||||||
<p>If you did not request this link, you can safely ignore this email. </p>
|
|
||||||
|
|
||||||
<p>Thanks,</p>
|
|
||||||
|
|
||||||
<p>Your Hoppscotch team</p>
|
|
||||||
|
|
||||||
<table class="body-sub">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
|
||||||
<p class="sub">{{magicLink}}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="content-cell" align="center">
|
|
||||||
<p class="f-fallback sub align-center">© 2021 Hoppscotch</p>
|
|
||||||
<p class="f-fallback sub align-center">12 New Fetter Lane, London, United Kingdom, EC4A 1JP.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="x-apple-disable-message-reformatting" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
<meta name="supported-color-schemes" content="light dark" />
|
|
||||||
<title></title>
|
|
||||||
<!--
|
|
||||||
The style block is collapsed on page load to save you some scrolling.
|
|
||||||
Postmark automatically inlines all CSS properties for maximum email client
|
|
||||||
compatibility. You can just update styles here, and Postmark does the rest.
|
|
||||||
-->
|
|
||||||
<style type="text/css" rel="stylesheet" media="all">
|
|
||||||
/* Base ------------------------------ */
|
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
|
||||||
body {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #3869D4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a img {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preheader {
|
|
||||||
display: none !important;
|
|
||||||
visibility: hidden;
|
|
||||||
mso-hide: all;
|
|
||||||
font-size: 1px;
|
|
||||||
line-height: 1px;
|
|
||||||
max-height: 0;
|
|
||||||
max-width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
/* Type ------------------------------ */
|
|
||||||
|
|
||||||
body,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333333;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
blockquote {
|
|
||||||
margin: .4em 0 1.1875em;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.625;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.sub {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
/* Utilities ------------------------------ */
|
|
||||||
|
|
||||||
.align-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
/* Buttons ------------------------------ */
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background-color: #3869D4;
|
|
||||||
border-top: 10px solid #3869D4;
|
|
||||||
border-right: 18px solid #3869D4;
|
|
||||||
border-bottom: 10px solid #3869D4;
|
|
||||||
border-left: 18px solid #3869D4;
|
|
||||||
display: inline-block;
|
|
||||||
color: #FFF;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--green {
|
|
||||||
background-color: #22BC66;
|
|
||||||
border-top: 10px solid #22BC66;
|
|
||||||
border-right: 18px solid #22BC66;
|
|
||||||
border-bottom: 10px solid #22BC66;
|
|
||||||
border-left: 18px solid #22BC66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--red {
|
|
||||||
background-color: #FF6136;
|
|
||||||
border-top: 10px solid #FF6136;
|
|
||||||
border-right: 18px solid #FF6136;
|
|
||||||
border-bottom: 10px solid #FF6136;
|
|
||||||
border-left: 18px solid #FF6136;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 500px) {
|
|
||||||
.button {
|
|
||||||
width: 100% !important;
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Attribute list ------------------------------ */
|
|
||||||
|
|
||||||
.attributes {
|
|
||||||
margin: 0 0 21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes_content {
|
|
||||||
background-color: #F4F4F7;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes_item {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* Related Items ------------------------------ */
|
|
||||||
|
|
||||||
.related {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 25px 0 0 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item {
|
|
||||||
padding: 10px 0;
|
|
||||||
color: #CBCCCF;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item-title {
|
|
||||||
display: block;
|
|
||||||
margin: .5em 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_item-thumb {
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related_heading {
|
|
||||||
border-top: 1px solid #CBCCCF;
|
|
||||||
text-align: center;
|
|
||||||
padding: 25px 0 10px;
|
|
||||||
}
|
|
||||||
/* Discount Code ------------------------------ */
|
|
||||||
|
|
||||||
.discount {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #F4F4F7;
|
|
||||||
border: 2px dashed #CBCCCF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount_heading {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount_body {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
/* Social Icons ------------------------------ */
|
|
||||||
|
|
||||||
.social {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social td {
|
|
||||||
padding: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social_icon {
|
|
||||||
height: 20px;
|
|
||||||
margin: 0 8px 10px 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* Data table ------------------------------ */
|
|
||||||
|
|
||||||
.purchase {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 35px 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_content {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 25px 0 0 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_item {
|
|
||||||
padding: 10px 0;
|
|
||||||
color: #51545E;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_heading {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_heading p {
|
|
||||||
margin: 0;
|
|
||||||
color: #85878E;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_footer {
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_total {
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purchase_total--label {
|
|
||||||
padding: 0 15px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #F2F4F6;
|
|
||||||
color: #51545E;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #51545E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #F2F4F6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
/* Masthead ----------------------- */
|
|
||||||
|
|
||||||
.email-masthead {
|
|
||||||
padding: 25px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-masthead_logo {
|
|
||||||
width: 94px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-masthead_name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #A8AAAF;
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow: 0 1px 0 white;
|
|
||||||
}
|
|
||||||
/* Body ------------------------------ */
|
|
||||||
|
|
||||||
.email-body {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-body_inner {
|
|
||||||
width: 570px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 570px;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer {
|
|
||||||
width: 570px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 570px;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer p {
|
|
||||||
color: #A8AAAF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-action {
|
|
||||||
width: 100%;
|
|
||||||
margin: 30px auto;
|
|
||||||
padding: 0;
|
|
||||||
-premailer-width: 100%;
|
|
||||||
-premailer-cellpadding: 0;
|
|
||||||
-premailer-cellspacing: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-sub {
|
|
||||||
margin-top: 25px;
|
|
||||||
padding-top: 25px;
|
|
||||||
border-top: 1px solid #EAEAEC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-cell {
|
|
||||||
padding: 45px;
|
|
||||||
}
|
|
||||||
/*Media Queries ------------------------------ */
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.email-body_inner,
|
|
||||||
.email-footer {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body,
|
|
||||||
.email-body,
|
|
||||||
.email-body_inner,
|
|
||||||
.email-content,
|
|
||||||
.email-wrapper,
|
|
||||||
.email-masthead,
|
|
||||||
.email-footer {
|
|
||||||
background-color: #333333 !important;
|
|
||||||
color: #FFF !important;
|
|
||||||
}
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
blockquote,
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
span,
|
|
||||||
.purchase_item {
|
|
||||||
color: #FFF !important;
|
|
||||||
}
|
|
||||||
.attributes_content,
|
|
||||||
.discount {
|
|
||||||
background-color: #222 !important;
|
|
||||||
}
|
|
||||||
.email-masthead_name {
|
|
||||||
text-shadow: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: light dark;
|
|
||||||
supported-color-schemes: light dark;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!--[if mso]>
|
|
||||||
<style type="text/css">
|
|
||||||
.f-fallback {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="email-masthead">
|
|
||||||
<a href="https://hoppscotch.io" class="f-fallback email-masthead_name">
|
|
||||||
Hoppscotch
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Email Body -->
|
|
||||||
<tr>
|
|
||||||
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
|
||||||
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<!-- Body content -->
|
|
||||||
<tr>
|
|
||||||
<td class="content-cell">
|
|
||||||
<div class="f-fallback">
|
|
||||||
<h1>Hi there,</h1>
|
|
||||||
<p>{{invitee}} with {{invite_team_name}} has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
|
|
||||||
<!-- Action -->
|
|
||||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{{action_url}}" class="button button--green" target="_blank">Join {{invite_team_name}}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p>
|
|
||||||
Welcome aboard, <br />
|
|
||||||
Your friends at Hoppscotch
|
|
||||||
</p>
|
|
||||||
<p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
|
|
||||||
<!-- Sub copy -->
|
|
||||||
<table class="body-sub">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
|
||||||
<p class="sub">{{action_url}}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="content-cell" align="center">
|
|
||||||
<p class="f-fallback sub align-center">© 2021 Hoppscotch</p>
|
|
||||||
<p class="f-fallback sub align-center">12 New Fetter Lane, London, United Kingdom, EC4A 1JP.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import { json } from 'express';
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
import * as cookieParser from 'cookie-parser';
|
|
||||||
import { VersioningType } from '@nestjs/common';
|
|
||||||
import * as session from 'express-session';
|
|
||||||
import { emitGQLSchemaFile } from './gql-schema';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
|
||||||
console.log(`Port: ${process.env.PORT}`);
|
|
||||||
console.log(`Database: ${process.env.DATABASE_URL}`);
|
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
session({
|
|
||||||
secret: process.env.SESSION_SECRET,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Increase fil upload limit to 50MB
|
|
||||||
app.use(
|
|
||||||
json({
|
|
||||||
limit: '100mb',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.env.PRODUCTION === 'false') {
|
|
||||||
console.log('Enabling CORS with development settings');
|
|
||||||
|
|
||||||
app.enableCors({
|
|
||||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('Enabling CORS with production settings');
|
|
||||||
|
|
||||||
app.enableCors({
|
|
||||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
app.enableVersioning({
|
|
||||||
type: VersioningType.URI,
|
|
||||||
});
|
|
||||||
app.use(cookieParser());
|
|
||||||
await app.listen(process.env.PORT || 3170);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.GENERATE_GQL_SCHEMA) {
|
|
||||||
bootstrap();
|
|
||||||
} else {
|
|
||||||
emitGQLSchemaFile();
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
|
||||||
import {
|
|
||||||
ApolloServerPlugin,
|
|
||||||
GraphQLRequestListener,
|
|
||||||
} from 'apollo-server-plugin-base';
|
|
||||||
import { Plugin } from '@nestjs/apollo';
|
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import {
|
|
||||||
fieldExtensionsEstimator,
|
|
||||||
getComplexity,
|
|
||||||
simpleEstimator,
|
|
||||||
} from 'graphql-query-complexity';
|
|
||||||
|
|
||||||
const COMPLEXITY_LIMIT = 50;
|
|
||||||
|
|
||||||
@Plugin()
|
|
||||||
export class GQLComplexityPlugin implements ApolloServerPlugin {
|
|
||||||
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
|
|
||||||
|
|
||||||
async requestDidStart(): Promise<GraphQLRequestListener> {
|
|
||||||
const { schema } = this.gqlSchemaHost;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async didResolveOperation({ request, document }) {
|
|
||||||
const complexity = getComplexity({
|
|
||||||
schema,
|
|
||||||
operationName: request.operationName,
|
|
||||||
query: document,
|
|
||||||
variables: request.variables,
|
|
||||||
estimators: [
|
|
||||||
fieldExtensionsEstimator(),
|
|
||||||
simpleEstimator({ defaultComplexity: 1 }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (complexity > COMPLEXITY_LIMIT) {
|
|
||||||
throw new GraphQLError(
|
|
||||||
`Query is too complex: ${complexity}. Maximum allowed complexity: ${COMPLEXITY_LIMIT}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log('Query Complexity:', complexity);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common/decorators';
|
|
||||||
import { PrismaService } from './prisma.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [PrismaService],
|
|
||||||
exports: [PrismaService],
|
|
||||||
})
|
|
||||||
export class PrismaModule {}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PrismaService
|
|
||||||
extends PrismaClient
|
|
||||||
implements OnModuleInit, OnModuleDestroy
|
|
||||||
{
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
async onModuleInit() {
|
|
||||||
await this.$connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy() {
|
|
||||||
await this.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { PubSubService } from './pubsub.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [PubSubService],
|
|
||||||
exports: [PubSubService],
|
|
||||||
})
|
|
||||||
export class PubSubModule {}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { OnModuleInit } from '@nestjs/common';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PubSub as LocalPubSub } from 'graphql-subscriptions';
|
|
||||||
import { TopicDef } from './topicsDefs';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Figure out which PubSub to use (simple/local for dev and Redis for production)
|
|
||||||
* and expose it
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PubSubService implements OnModuleInit {
|
|
||||||
private pubsub: LocalPubSub;
|
|
||||||
|
|
||||||
onModuleInit() {
|
|
||||||
console.log('Initialize PubSub');
|
|
||||||
|
|
||||||
this.pubsub = new LocalPubSub();
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncIterator<T>(topic: string | string[]): AsyncIterator<T> {
|
|
||||||
return this.pubsub.asyncIterator(topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish<T extends keyof TopicDef>(topic: T, payload: TopicDef[T]) {
|
|
||||||
await this.pubsub.publish(topic, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
UserRequest,
|
|
||||||
UserRequestReorderData,
|
|
||||||
} from 'src/user-request/user-request.model';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { UserSettings } from 'src/user-settings/user-settings.model';
|
|
||||||
import { UserEnvironment } from '../user-environment/user-environments.model';
|
|
||||||
import {
|
|
||||||
UserHistory,
|
|
||||||
UserHistoryDeletedManyData,
|
|
||||||
} from '../user-history/user-history.model';
|
|
||||||
import { TeamMember } from 'src/team/team.model';
|
|
||||||
import { TeamEnvironment } from 'src/team-environments/team-environments.model';
|
|
||||||
import {
|
|
||||||
CollectionReorderData,
|
|
||||||
TeamCollection,
|
|
||||||
} from 'src/team-collection/team-collection.model';
|
|
||||||
import {
|
|
||||||
RequestReorderData,
|
|
||||||
TeamRequest,
|
|
||||||
} from 'src/team-request/team-request.model';
|
|
||||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
|
||||||
import { InvitedUser } from '../admin/invited-user.model';
|
|
||||||
import { UserCollection } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
UserCollectionRemovedData,
|
|
||||||
UserCollectionReorderData,
|
|
||||||
} from 'src/user-collection/user-collections.model';
|
|
||||||
import { Shortcode } from 'src/shortcode/shortcode.model';
|
|
||||||
|
|
||||||
// A custom message type that defines the topic and the corresponding payload.
|
|
||||||
// For every module that publishes a subscription add its type def and the possible subscription type.
|
|
||||||
export type TopicDef = {
|
|
||||||
[topic: `admin/${string}/${'invited'}`]: InvitedUser;
|
|
||||||
[topic: `user/${string}/${'updated' | 'deleted'}`]: User;
|
|
||||||
[topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings;
|
|
||||||
[
|
|
||||||
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
|
||||||
]: UserEnvironment;
|
|
||||||
[topic: `user_environment/${string}/deleted_many`]: number;
|
|
||||||
[
|
|
||||||
topic: `user_request/${string}/${'created' | 'updated' | 'deleted'}`
|
|
||||||
]: UserRequest;
|
|
||||||
[topic: `user_request/${string}/${'moved'}`]: UserRequestReorderData;
|
|
||||||
[
|
|
||||||
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
|
|
||||||
]: UserHistory;
|
|
||||||
[
|
|
||||||
topic: `user_coll/${string}/${'created' | 'updated' | 'moved'}`
|
|
||||||
]: UserCollection;
|
|
||||||
[topic: `user_coll/${string}/${'deleted'}`]: UserCollectionRemovedData;
|
|
||||||
[topic: `user_coll/${string}/${'order_updated'}`]: UserCollectionReorderData;
|
|
||||||
[topic: `team/${string}/member_removed`]: string;
|
|
||||||
[topic: `team/${string}/${'member_added' | 'member_updated'}`]: TeamMember;
|
|
||||||
[
|
|
||||||
topic: `team_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
|
||||||
]: TeamEnvironment;
|
|
||||||
[
|
|
||||||
topic: `team_coll/${string}/${'coll_added' | 'coll_updated'}`
|
|
||||||
]: TeamCollection;
|
|
||||||
[topic: `team_coll/${string}/${'coll_removed'}`]: string;
|
|
||||||
[topic: `team_coll/${string}/${'coll_moved'}`]: TeamCollection;
|
|
||||||
[topic: `team_coll/${string}/${'coll_order_updated'}`]: CollectionReorderData;
|
|
||||||
[topic: `user_history/${string}/deleted_many`]: UserHistoryDeletedManyData;
|
|
||||||
[
|
|
||||||
topic: `team_req/${string}/${'req_created' | 'req_updated' | 'req_moved'}`
|
|
||||||
]: TeamRequest;
|
|
||||||
[topic: `team_req/${string}/req_order_updated`]: RequestReorderData;
|
|
||||||
[topic: `team_req/${string}/req_deleted`]: string;
|
|
||||||
[topic: `team/${string}/invite_added`]: TeamInvitation;
|
|
||||||
[topic: `team/${string}/invite_removed`]: string;
|
|
||||||
[topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode;
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class Shortcode {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'The shortcode. 12 digit alphanumeric.',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'JSON string representing the request data',
|
|
||||||
})
|
|
||||||
request: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Timestamp of when the Shortcode was created',
|
|
||||||
})
|
|
||||||
createdOn: Date;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
|
||||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
|
||||||
import { UserModule } from 'src/user/user.module';
|
|
||||||
import { ShortcodeResolver } from './shortcode.resolver';
|
|
||||||
import { ShortcodeService } from './shortcode.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
PrismaModule,
|
|
||||||
UserModule,
|
|
||||||
PubSubModule,
|
|
||||||
JwtModule.register({
|
|
||||||
secret: process.env.JWT_SECRET,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
providers: [ShortcodeService, ShortcodeResolver],
|
|
||||||
exports: [ShortcodeService],
|
|
||||||
})
|
|
||||||
export class ShortcodeModule {}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import {
|
|
||||||
Args,
|
|
||||||
Context,
|
|
||||||
ID,
|
|
||||||
Mutation,
|
|
||||||
Query,
|
|
||||||
Resolver,
|
|
||||||
Subscription,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { Shortcode } from './shortcode.model';
|
|
||||||
import { ShortcodeService } from './shortcode.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
|
||||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { AuthUser } from '../types/AuthUser';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
|
||||||
@Resolver(() => Shortcode)
|
|
||||||
export class ShortcodeResolver {
|
|
||||||
constructor(
|
|
||||||
private readonly shortcodeService: ShortcodeService,
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/* Queries */
|
|
||||||
@Query(() => Shortcode, {
|
|
||||||
description: 'Resolves and returns a shortcode data',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
async shortcode(
|
|
||||||
@Args({
|
|
||||||
name: 'code',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'The shortcode to resolve',
|
|
||||||
})
|
|
||||||
code: string,
|
|
||||||
) {
|
|
||||||
const result = await this.shortcodeService.getShortCode(code);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
|
||||||
return result.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => [Shortcode], {
|
|
||||||
description: 'List all shortcodes the current user has generated',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
async myShortcodes(@GqlUser() user: AuthUser, @Args() args: PaginationArgs) {
|
|
||||||
return this.shortcodeService.fetchUserShortCodes(user.uid, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mutations */
|
|
||||||
@Mutation(() => Shortcode, {
|
|
||||||
description: 'Create a shortcode for the given request.',
|
|
||||||
})
|
|
||||||
async createShortcode(
|
|
||||||
@Args({
|
|
||||||
name: 'request',
|
|
||||||
description: 'JSON string of the request object',
|
|
||||||
})
|
|
||||||
request: string,
|
|
||||||
@Context() ctx: any,
|
|
||||||
) {
|
|
||||||
const decodedAccessToken = this.jwtService.verify(
|
|
||||||
ctx.req.cookies['access_token'],
|
|
||||||
);
|
|
||||||
const result = await this.shortcodeService.createShortcode(
|
|
||||||
request,
|
|
||||||
decodedAccessToken?.sub,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
|
||||||
return result.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Revoke a user generated shortcode',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
async revokeShortcode(
|
|
||||||
@GqlUser() user: User,
|
|
||||||
@Args({
|
|
||||||
name: 'code',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'The shortcode to resolve',
|
|
||||||
})
|
|
||||||
code: string,
|
|
||||||
) {
|
|
||||||
const result = await this.shortcodeService.revokeShortCode(code, user.uid);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
|
||||||
return result.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscriptions */
|
|
||||||
@Subscription(() => Shortcode, {
|
|
||||||
description: 'Listen for shortcode creation',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
myShortcodesCreated(@GqlUser() user: AuthUser) {
|
|
||||||
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => Shortcode, {
|
|
||||||
description: 'Listen for shortcode deletion',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard)
|
|
||||||
myShortcodesRevoked(@GqlUser() user: AuthUser): AsyncIterator<Shortcode> {
|
|
||||||
return this.pubsub.asyncIterator(`shortcode/${user.uid}/revoked`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import {
|
|
||||||
SHORTCODE_ALREADY_EXISTS,
|
|
||||||
SHORTCODE_INVALID_JSON,
|
|
||||||
SHORTCODE_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { Shortcode } from './shortcode.model';
|
|
||||||
import { ShortcodeService } from './shortcode.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
|
||||||
|
|
||||||
const mockPubSub = {
|
|
||||||
publish: jest.fn().mockResolvedValue(null),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDocFunc = jest.fn();
|
|
||||||
|
|
||||||
const mockFB = {
|
|
||||||
firestore: {
|
|
||||||
doc: mockDocFunc,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const mockUserService = new UserService(mockFB as any, mockPubSub as any);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const shortcodeService = new ShortcodeService(
|
|
||||||
mockPrisma,
|
|
||||||
mockPubSub as any,
|
|
||||||
mockUserService,
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockReset(mockPrisma);
|
|
||||||
mockPubSub.publish.mockClear();
|
|
||||||
});
|
|
||||||
const createdOn = new Date();
|
|
||||||
|
|
||||||
const shortCodeWithOutUser = {
|
|
||||||
id: '123',
|
|
||||||
request: '{}',
|
|
||||||
createdOn: createdOn,
|
|
||||||
creatorUid: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const shortCodeWithUser = {
|
|
||||||
id: '123',
|
|
||||||
request: '{}',
|
|
||||||
createdOn: createdOn,
|
|
||||||
creatorUid: 'user_uid_1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const shortcodes = [
|
|
||||||
{
|
|
||||||
id: 'blablabla',
|
|
||||||
request: {
|
|
||||||
hello: 'there',
|
|
||||||
},
|
|
||||||
creatorUid: 'testuser',
|
|
||||||
createdOn: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'blablabla1',
|
|
||||||
request: {
|
|
||||||
hello: 'there',
|
|
||||||
},
|
|
||||||
creatorUid: 'testuser',
|
|
||||||
createdOn: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('ShortcodeService', () => {
|
|
||||||
describe('getShortCode', () => {
|
|
||||||
test('should return a valid shortcode with valid shortcode ID', async () => {
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(
|
|
||||||
shortCodeWithOutUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await shortcodeService.getShortCode(
|
|
||||||
shortCodeWithOutUser.id,
|
|
||||||
);
|
|
||||||
expect(result).toEqualRight(<Shortcode>{
|
|
||||||
id: shortCodeWithOutUser.id,
|
|
||||||
createdOn: shortCodeWithOutUser.createdOn,
|
|
||||||
request: JSON.stringify(shortCodeWithOutUser.request),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw SHORTCODE_NOT_FOUND error when shortcode ID is invalid', async () => {
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await shortcodeService.getShortCode('invalidID');
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchUserShortCodes', () => {
|
|
||||||
test('should return list of shortcodes with valid inputs and no cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
|
||||||
cursor: null,
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
expect(result).toEqual(<Shortcode[]>[
|
|
||||||
{
|
|
||||||
id: shortcodes[0].id,
|
|
||||||
request: JSON.stringify(shortcodes[0].request),
|
|
||||||
createdOn: shortcodes[0].createdOn,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: shortcodes[1].id,
|
|
||||||
request: JSON.stringify(shortcodes[1].request),
|
|
||||||
createdOn: shortcodes[1].createdOn,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return list of shortcodes with valid inputs and cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
|
||||||
cursor: 'blablabla',
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
expect(result).toEqual(<Shortcode[]>[
|
|
||||||
{
|
|
||||||
id: shortcodes[1].id,
|
|
||||||
request: JSON.stringify(shortcodes[1].request),
|
|
||||||
createdOn: shortcodes[1].createdOn,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return an empty array for an invalid cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes('testuser', {
|
|
||||||
cursor: 'invalidcursor',
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return an empty array for an invalid user id and null cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes('invalidid', {
|
|
||||||
cursor: null,
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return an empty array for an invalid user id and an invalid cursor', async () => {
|
|
||||||
mockPrisma.shortcode.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await shortcodeService.fetchUserShortCodes('invalidid', {
|
|
||||||
cursor: 'invalidcursor',
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createShortcode', () => {
|
|
||||||
test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => {
|
|
||||||
const result = await shortcodeService.createShortcode(
|
|
||||||
'invalidRequest',
|
|
||||||
'user_uid_1',
|
|
||||||
);
|
|
||||||
expect(result).toEqualLeft(SHORTCODE_INVALID_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should successfully create a new shortcode with valid user uid', async () => {
|
|
||||||
// generateUniqueShortCodeID --> getShortCode
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
id: shortCodeWithUser.id,
|
|
||||||
createdOn: shortCodeWithUser.createdOn,
|
|
||||||
request: JSON.stringify(shortCodeWithUser.request),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should successfully create a new shortcode with null user uid', async () => {
|
|
||||||
// generateUniqueShortCodeID --> getShortCode
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', null);
|
|
||||||
expect(result).toEqualRight({
|
|
||||||
id: shortCodeWithUser.id,
|
|
||||||
createdOn: shortCodeWithUser.createdOn,
|
|
||||||
request: JSON.stringify(shortCodeWithOutUser.request),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => {
|
|
||||||
// generateUniqueShortCodeID --> getShortCode
|
|
||||||
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
|
|
||||||
'NotFoundError',
|
|
||||||
);
|
|
||||||
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
|
|
||||||
|
|
||||||
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`shortcode/${shortCodeWithUser.creatorUid}/created`,
|
|
||||||
{
|
|
||||||
id: shortCodeWithUser.id,
|
|
||||||
createdOn: shortCodeWithUser.createdOn,
|
|
||||||
request: JSON.stringify(shortCodeWithUser.request),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revokeShortCode', () => {
|
|
||||||
test('should return true on successful deletion of shortcode with valid inputs', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
|
|
||||||
|
|
||||||
const result = await shortcodeService.revokeShortCode(
|
|
||||||
shortCodeWithUser.id,
|
|
||||||
shortCodeWithUser.creatorUid,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
creator_uid_shortcode_unique: {
|
|
||||||
creatorUid: shortCodeWithUser.creatorUid,
|
|
||||||
id: shortCodeWithUser.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqualRight(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
|
||||||
expect(
|
|
||||||
shortcodeService.revokeShortCode('invalid', 'testuser'),
|
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
|
||||||
expect(
|
|
||||||
shortcodeService.revokeShortCode('blablablabla', 'invalidUser'),
|
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
|
|
||||||
expect(
|
|
||||||
shortcodeService.revokeShortCode('invalid', 'invalid'),
|
|
||||||
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => {
|
|
||||||
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
|
|
||||||
|
|
||||||
const result = await shortcodeService.revokeShortCode(
|
|
||||||
shortCodeWithUser.id,
|
|
||||||
shortCodeWithUser.creatorUid,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`shortcode/${shortCodeWithUser.creatorUid}/revoked`,
|
|
||||||
{
|
|
||||||
id: shortCodeWithUser.id,
|
|
||||||
createdOn: shortCodeWithUser.createdOn,
|
|
||||||
request: JSON.stringify(shortCodeWithUser.request),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteUserShortCodes', () => {
|
|
||||||
test('should successfully delete all users shortcodes with valid user uid', async () => {
|
|
||||||
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
|
|
||||||
|
|
||||||
const result = await shortcodeService.deleteUserShortCodes(
|
|
||||||
shortCodeWithUser.creatorUid,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return 0 when user uid is invalid', async () => {
|
|
||||||
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
|
|
||||||
|
|
||||||
const result = await shortcodeService.deleteUserShortCodes(
|
|
||||||
shortCodeWithUser.creatorUid,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors';
|
|
||||||
import { UserDataHandler } from 'src/user/user.data.handler';
|
|
||||||
import { Shortcode } from './shortcode.model';
|
|
||||||
import { Shortcode as DBShortCode } from '@prisma/client';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { stringToJson } from 'src/utils';
|
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
|
||||||
import { AuthUser } from '../types/AuthUser';
|
|
||||||
|
|
||||||
const SHORT_CODE_LENGTH = 12;
|
|
||||||
const SHORT_CODE_CHARS =
|
|
||||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
private readonly userService: UserService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onModuleInit() {
|
|
||||||
this.userService.registerUserDataHandler(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
canAllowUserDeletion(user: AuthUser): TO.TaskOption<string> {
|
|
||||||
return TO.none;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUserDelete(user: AuthUser): T.Task<void> {
|
|
||||||
return async () => {
|
|
||||||
await this.deleteUserShortCodes(user.uid);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Prisma Shortcode type into the Shortcode model
|
|
||||||
*
|
|
||||||
* @param shortcodeInfo Prisma Shortcode type
|
|
||||||
* @returns GQL Shortcode
|
|
||||||
*/
|
|
||||||
private returnShortCode(shortcodeInfo: DBShortCode): Shortcode {
|
|
||||||
return <Shortcode>{
|
|
||||||
id: shortcodeInfo.id,
|
|
||||||
request: JSON.stringify(shortcodeInfo.request),
|
|
||||||
createdOn: shortcodeInfo.createdOn,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a shortcode
|
|
||||||
*
|
|
||||||
* @returns generated shortcode
|
|
||||||
*/
|
|
||||||
private generateShortCodeID(): string {
|
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < SHORT_CODE_LENGTH; i++) {
|
|
||||||
result +=
|
|
||||||
SHORT_CODE_CHARS[Math.floor(Math.random() * SHORT_CODE_CHARS.length)];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check to see if ShortCode is already present in DB
|
|
||||||
*
|
|
||||||
* @returns Shortcode
|
|
||||||
*/
|
|
||||||
private async generateUniqueShortCodeID() {
|
|
||||||
while (true) {
|
|
||||||
const code = this.generateShortCodeID();
|
|
||||||
|
|
||||||
const data = await this.getShortCode(code);
|
|
||||||
|
|
||||||
if (E.isLeft(data)) return E.right(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch details regarding a ShortCode
|
|
||||||
*
|
|
||||||
* @param shortcode ShortCode
|
|
||||||
* @returns Either of ShortCode details or error
|
|
||||||
*/
|
|
||||||
async getShortCode(shortcode: string) {
|
|
||||||
try {
|
|
||||||
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
|
|
||||||
where: { id: shortcode },
|
|
||||||
});
|
|
||||||
return E.right(this.returnShortCode(shortcodeInfo));
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(SHORTCODE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new ShortCode
|
|
||||||
*
|
|
||||||
* @param request JSON string of request details
|
|
||||||
* @param userUID user UID, if present
|
|
||||||
* @returns Either of ShortCode or error
|
|
||||||
*/
|
|
||||||
async createShortcode(request: string, userUID: string | null) {
|
|
||||||
const shortcodeData = stringToJson(request);
|
|
||||||
if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON);
|
|
||||||
|
|
||||||
const user = await this.userService.findUserById(userUID);
|
|
||||||
|
|
||||||
const generatedShortCode = await this.generateUniqueShortCodeID();
|
|
||||||
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
|
|
||||||
|
|
||||||
const createdShortCode = await this.prisma.shortcode.create({
|
|
||||||
data: {
|
|
||||||
id: generatedShortCode.right,
|
|
||||||
request: shortcodeData.right,
|
|
||||||
creatorUid: O.isNone(user) ? null : user.value.uid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only publish event if creator is not null
|
|
||||||
if (createdShortCode.creatorUid) {
|
|
||||||
this.pubsub.publish(
|
|
||||||
`shortcode/${createdShortCode.creatorUid}/created`,
|
|
||||||
this.returnShortCode(createdShortCode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.right(this.returnShortCode(createdShortCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch ShortCodes created by a User
|
|
||||||
*
|
|
||||||
* @param uid User Uid
|
|
||||||
* @param args Pagination arguments
|
|
||||||
* @returns Array of ShortCodes
|
|
||||||
*/
|
|
||||||
async fetchUserShortCodes(uid: string, args: PaginationArgs) {
|
|
||||||
const shortCodes = await this.prisma.shortcode.findMany({
|
|
||||||
where: {
|
|
||||||
creatorUid: uid,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdOn: 'desc',
|
|
||||||
},
|
|
||||||
skip: 1,
|
|
||||||
take: args.take,
|
|
||||||
cursor: args.cursor ? { id: args.cursor } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
|
|
||||||
this.returnShortCode(code),
|
|
||||||
);
|
|
||||||
|
|
||||||
return fetchedShortCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a ShortCode
|
|
||||||
*
|
|
||||||
* @param shortcode ShortCode
|
|
||||||
* @param uid User Uid
|
|
||||||
* @returns Boolean on successful deletion
|
|
||||||
*/
|
|
||||||
async revokeShortCode(shortcode: string, uid: string) {
|
|
||||||
try {
|
|
||||||
const deletedShortCodes = await this.prisma.shortcode.delete({
|
|
||||||
where: {
|
|
||||||
creator_uid_shortcode_unique: {
|
|
||||||
creatorUid: uid,
|
|
||||||
id: shortcode,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`shortcode/${deletedShortCodes.creatorUid}/revoked`,
|
|
||||||
this.returnShortCode(deletedShortCodes),
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(SHORTCODE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all the Users ShortCodes
|
|
||||||
* @param uid User Uid
|
|
||||||
* @returns number of all deleted user ShortCodes
|
|
||||||
*/
|
|
||||||
async deleteUserShortCodes(uid: string) {
|
|
||||||
const deletedShortCodes = await this.prisma.shortcode.deleteMany({
|
|
||||||
where: {
|
|
||||||
creatorUid: uid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return deletedShortCodes.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
import { TeamCollectionService } from '../team-collection.service';
|
|
||||||
import { TeamService } from '../../team/team.service';
|
|
||||||
import { TeamMemberRole } from '../../team/team.model';
|
|
||||||
import {
|
|
||||||
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
|
|
||||||
BUG_AUTH_NO_USER_CTX,
|
|
||||||
BUG_TEAM_COLL_NO_COLL_ID,
|
|
||||||
TEAM_INVALID_COLL_ID,
|
|
||||||
TEAM_REQ_NOT_MEMBER,
|
|
||||||
} from 'src/errors';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GqlCollectionTeamMemberGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
private readonly teamCollectionService: TeamCollectionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const requireRoles = this.reflector.get<TeamMemberRole[]>(
|
|
||||||
'requiresTeamRole',
|
|
||||||
context.getHandler(),
|
|
||||||
);
|
|
||||||
if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE);
|
|
||||||
|
|
||||||
const gqlExecCtx = GqlExecutionContext.create(context);
|
|
||||||
|
|
||||||
const { user } = gqlExecCtx.getContext().req;
|
|
||||||
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
|
|
||||||
|
|
||||||
const { collectionID } = gqlExecCtx.getArgs<{ collectionID: string }>();
|
|
||||||
if (!collectionID) throw new Error(BUG_TEAM_COLL_NO_COLL_ID);
|
|
||||||
|
|
||||||
const collection = await this.teamCollectionService.getCollection(
|
|
||||||
collectionID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(collection)) throw new Error(TEAM_INVALID_COLL_ID);
|
|
||||||
|
|
||||||
const member = await this.teamService.getTeamMember(
|
|
||||||
collection.right.teamID,
|
|
||||||
user.uid,
|
|
||||||
);
|
|
||||||
if (!member) throw new Error(TEAM_REQ_NOT_MEMBER);
|
|
||||||
|
|
||||||
return requireRoles.includes(member.role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { ArgsType, Field, ID } from '@nestjs/graphql';
|
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class GetRootTeamCollectionsArgs extends PaginationArgs {
|
|
||||||
@Field(() => ID, { name: 'teamID', description: 'ID of the team' })
|
|
||||||
teamID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class CreateRootTeamCollectionArgs {
|
|
||||||
@Field(() => ID, { name: 'teamID', description: 'ID of the team' })
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field({ name: 'title', description: 'Title of the new collection' })
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class CreateChildTeamCollectionArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the parent to the new collection',
|
|
||||||
})
|
|
||||||
collectionID: string;
|
|
||||||
|
|
||||||
@Field({ name: 'childTitle', description: 'Title of the new collection' })
|
|
||||||
childTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class RenameTeamCollectionArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
})
|
|
||||||
collectionID: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'newTitle',
|
|
||||||
description: 'The updated title of the collection',
|
|
||||||
})
|
|
||||||
newTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class MoveTeamCollectionArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'parentCollectionID',
|
|
||||||
description: 'ID of the parent to the new collection',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
parentCollectionID: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
})
|
|
||||||
collectionID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class UpdateTeamCollectionOrderArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
})
|
|
||||||
collectionID: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'destCollID',
|
|
||||||
description:
|
|
||||||
'ID of the collection that comes after the updated collection in its new position',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
destCollID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class ReplaceTeamCollectionArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'Id of the team to add to',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'jsonString',
|
|
||||||
description: 'JSON string to replace with',
|
|
||||||
})
|
|
||||||
jsonString: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'parentCollectionID',
|
|
||||||
description:
|
|
||||||
'ID to the collection to which to import to (null if to import to the root of team)',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
parentCollectionID?: string;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { ObjectType, Field, ID } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class TeamCollection {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the collection',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Displayed title of the collection',
|
|
||||||
})
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the collection',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
parentID: string;
|
|
||||||
teamID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class CollectionReorderData {
|
|
||||||
@Field({
|
|
||||||
description: 'Team Collection being moved',
|
|
||||||
})
|
|
||||||
collection: TeamCollection;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description:
|
|
||||||
'Team Collection succeeding the collection being moved in its new position',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
nextCollection?: TeamCollection;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
|
||||||
import { TeamCollectionService } from './team-collection.service';
|
|
||||||
import { TeamCollectionResolver } from './team-collection.resolver';
|
|
||||||
import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-member.guard';
|
|
||||||
import { TeamModule } from '../team/team.module';
|
|
||||||
import { UserModule } from '../user/user.module';
|
|
||||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
|
|
||||||
providers: [
|
|
||||||
TeamCollectionService,
|
|
||||||
TeamCollectionResolver,
|
|
||||||
GqlCollectionTeamMemberGuard,
|
|
||||||
],
|
|
||||||
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
|
|
||||||
})
|
|
||||||
export class TeamCollectionModule {}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
import {
|
|
||||||
Resolver,
|
|
||||||
ResolveField,
|
|
||||||
Parent,
|
|
||||||
Args,
|
|
||||||
Query,
|
|
||||||
Mutation,
|
|
||||||
Subscription,
|
|
||||||
ID,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { CollectionReorderData, TeamCollection } from './team-collection.model';
|
|
||||||
import { Team, TeamMemberRole } from '../team/team.model';
|
|
||||||
import { TeamCollectionService } from './team-collection.service';
|
|
||||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
|
||||||
import { GqlTeamMemberGuard } from '../team/guards/gql-team-member.guard';
|
|
||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { RequiresTeamRole } from '../team/decorators/requires-team-role.decorator';
|
|
||||||
import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-member.guard';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
|
||||||
import {
|
|
||||||
CreateChildTeamCollectionArgs,
|
|
||||||
CreateRootTeamCollectionArgs,
|
|
||||||
GetRootTeamCollectionsArgs,
|
|
||||||
MoveTeamCollectionArgs,
|
|
||||||
RenameTeamCollectionArgs,
|
|
||||||
ReplaceTeamCollectionArgs,
|
|
||||||
UpdateTeamCollectionOrderArgs,
|
|
||||||
} from './input-type.args';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
|
||||||
@Resolver(() => TeamCollection)
|
|
||||||
export class TeamCollectionResolver {
|
|
||||||
constructor(
|
|
||||||
private readonly teamCollectionService: TeamCollectionService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// Field resolvers
|
|
||||||
@ResolveField(() => Team, {
|
|
||||||
description: 'Team the collection belongs to',
|
|
||||||
complexity: 5,
|
|
||||||
})
|
|
||||||
async team(@Parent() collection: TeamCollection) {
|
|
||||||
const team = await this.teamCollectionService.getTeamOfCollection(
|
|
||||||
collection.id,
|
|
||||||
);
|
|
||||||
if (E.isLeft(team)) throwErr(team.left);
|
|
||||||
return team.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => TeamCollection, {
|
|
||||||
description: 'Return the parent Team Collection (null if root )',
|
|
||||||
nullable: true,
|
|
||||||
complexity: 3,
|
|
||||||
})
|
|
||||||
async parent(@Parent() collection: TeamCollection) {
|
|
||||||
return this.teamCollectionService.getParentOfCollection(collection.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => [TeamCollection], {
|
|
||||||
description: 'List of children Team Collections',
|
|
||||||
complexity: 3,
|
|
||||||
})
|
|
||||||
async children(
|
|
||||||
@Parent() collection: TeamCollection,
|
|
||||||
@Args() args: PaginationArgs,
|
|
||||||
) {
|
|
||||||
return this.teamCollectionService.getChildrenOfCollection(
|
|
||||||
collection.id,
|
|
||||||
args.cursor,
|
|
||||||
args.take,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queries
|
|
||||||
|
|
||||||
@Query(() => String, {
|
|
||||||
description:
|
|
||||||
'Returns the JSON string giving the collections and their contents of the team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
)
|
|
||||||
async exportCollectionsToJSON(
|
|
||||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
const jsonString = await this.teamCollectionService.exportCollectionsToJSON(
|
|
||||||
teamID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(jsonString)) throwErr(jsonString.left as string);
|
|
||||||
return jsonString.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => [TeamCollection], {
|
|
||||||
description: 'Returns the collections of a team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
)
|
|
||||||
async rootCollectionsOfTeam(@Args() args: GetRootTeamCollectionsArgs) {
|
|
||||||
return this.teamCollectionService.getTeamRootCollections(
|
|
||||||
args.teamID,
|
|
||||||
args.cursor,
|
|
||||||
args.take,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => TeamCollection, {
|
|
||||||
description: 'Get a Team Collection with ID or null (if not exists)',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
)
|
|
||||||
async collection(
|
|
||||||
@Args({
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
collectionID: string,
|
|
||||||
) {
|
|
||||||
const teamCollections = await this.teamCollectionService.getCollection(
|
|
||||||
collectionID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(teamCollections)) throwErr(teamCollections.left);
|
|
||||||
return teamCollections.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
@Mutation(() => TeamCollection, {
|
|
||||||
description:
|
|
||||||
'Creates a collection at the root of the team hierarchy (no parent collection)',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async createRootCollection(@Args() args: CreateRootTeamCollectionArgs) {
|
|
||||||
const teamCollection = await this.teamCollectionService.createCollection(
|
|
||||||
args.teamID,
|
|
||||||
args.title,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(teamCollection)) throwErr(teamCollection.left);
|
|
||||||
return teamCollection.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Import collections from JSON string to the specified Team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async importCollectionsFromJSON(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Id of the team to add to',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
@Args({
|
|
||||||
name: 'jsonString',
|
|
||||||
description: 'JSON string to import',
|
|
||||||
})
|
|
||||||
jsonString: string,
|
|
||||||
@Args({
|
|
||||||
name: 'parentCollectionID',
|
|
||||||
type: () => ID,
|
|
||||||
description:
|
|
||||||
'ID to the collection to which to import to (null if to import to the root of team)',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
parentCollectionID?: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const importedCollection =
|
|
||||||
await this.teamCollectionService.importCollectionsFromJSON(
|
|
||||||
jsonString,
|
|
||||||
teamID,
|
|
||||||
parentCollectionID ?? null,
|
|
||||||
);
|
|
||||||
if (E.isLeft(importedCollection)) throwErr(importedCollection.left);
|
|
||||||
return importedCollection.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description:
|
|
||||||
'Replace existing collections of a specific team with collections in JSON string',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async replaceCollectionsWithJSON(@Args() args: ReplaceTeamCollectionArgs) {
|
|
||||||
const teamCollection =
|
|
||||||
await this.teamCollectionService.replaceCollectionsWithJSON(
|
|
||||||
args.jsonString,
|
|
||||||
args.teamID,
|
|
||||||
args.parentCollectionID ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(teamCollection)) throwErr(teamCollection.left);
|
|
||||||
return teamCollection.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamCollection, {
|
|
||||||
description: 'Create a collection that has a parent collection',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async createChildCollection(@Args() args: CreateChildTeamCollectionArgs) {
|
|
||||||
const team = await this.teamCollectionService.getTeamOfCollection(
|
|
||||||
args.collectionID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(team)) throwErr(team.left);
|
|
||||||
|
|
||||||
const teamCollection = await this.teamCollectionService.createCollection(
|
|
||||||
team.right.id,
|
|
||||||
args.childTitle,
|
|
||||||
args.collectionID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(teamCollection)) throwErr(teamCollection.left);
|
|
||||||
return teamCollection.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamCollection, {
|
|
||||||
description: 'Rename a collection',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async renameCollection(@Args() args: RenameTeamCollectionArgs) {
|
|
||||||
const updatedTeamCollection =
|
|
||||||
await this.teamCollectionService.renameCollection(
|
|
||||||
args.collectionID,
|
|
||||||
args.newTitle,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(updatedTeamCollection)) throwErr(updatedTeamCollection.left);
|
|
||||||
return updatedTeamCollection.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Delete a collection',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async deleteCollection(
|
|
||||||
@Args({
|
|
||||||
name: 'collectionID',
|
|
||||||
description: 'ID of the collection',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
collectionID: string,
|
|
||||||
) {
|
|
||||||
const result = await this.teamCollectionService.deleteCollection(
|
|
||||||
collectionID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (E.isLeft(result)) throwErr(result.left);
|
|
||||||
return result.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamCollection, {
|
|
||||||
description:
|
|
||||||
'Move a collection into a new parent collection or the root of the team',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async moveCollection(@Args() args: MoveTeamCollectionArgs) {
|
|
||||||
const res = await this.teamCollectionService.moveCollection(
|
|
||||||
args.collectionID,
|
|
||||||
args.parentCollectionID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(res)) throwErr(res.left);
|
|
||||||
return res.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Update the order of collections',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
async updateCollectionOrder(@Args() args: UpdateTeamCollectionOrderArgs) {
|
|
||||||
const request = await this.teamCollectionService.updateCollectionOrder(
|
|
||||||
args.collectionID,
|
|
||||||
args.destCollID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(request)) throwErr(request.left);
|
|
||||||
return request.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscriptions
|
|
||||||
|
|
||||||
@Subscription(() => TeamCollection, {
|
|
||||||
description:
|
|
||||||
'Listen to when a collection has been added to a team. The emitted value is the team added',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
teamCollectionAdded(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the team to listen to',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_added`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => TeamCollection, {
|
|
||||||
description: 'Listen to when a collection has been updated.',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
teamCollectionUpdated(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the team to listen to',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_updated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => ID, {
|
|
||||||
description: 'Listen to when a collection has been removed',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
teamCollectionRemoved(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the team to listen to',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_removed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => TeamCollection, {
|
|
||||||
description: 'Listen to when a collection has been moved',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
teamCollectionMoved(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the team to listen to',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_moved`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => CollectionReorderData, {
|
|
||||||
description: 'Listen to when a collections position has changed',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
collectionOrderUpdated(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the team to listen to',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_order_updated`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,974 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { TeamCollection } from './team-collection.model';
|
|
||||||
import {
|
|
||||||
TEAM_COLL_SHORT_TITLE,
|
|
||||||
TEAM_COLL_INVALID_JSON,
|
|
||||||
TEAM_INVALID_COLL_ID,
|
|
||||||
TEAM_NOT_OWNER,
|
|
||||||
TEAM_COLL_NOT_FOUND,
|
|
||||||
TEAM_COL_ALREADY_ROOT,
|
|
||||||
TEAM_COLL_DEST_SAME,
|
|
||||||
TEAM_COLL_NOT_SAME_TEAM,
|
|
||||||
TEAM_COLL_IS_PARENT_COLL,
|
|
||||||
TEAM_COL_SAME_NEXT_COLL,
|
|
||||||
TEAM_COL_REORDERING_FAILED,
|
|
||||||
} from '../errors';
|
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
|
||||||
import { isValidLength } from 'src/utils';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
|
||||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
|
||||||
import { stringToJson } from 'src/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TeamCollectionService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
TITLE_LENGTH = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a Prisma query object representation of a collection and its child collections and requests
|
|
||||||
*
|
|
||||||
* @param folder CollectionFolder from client
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @param orderIndex Initial OrderIndex of
|
|
||||||
* @returns A Prisma query object to create a collection, its child collections and requests
|
|
||||||
*/
|
|
||||||
private generatePrismaQueryObjForFBCollFolder(
|
|
||||||
folder: CollectionFolder,
|
|
||||||
teamID: string,
|
|
||||||
orderIndex: number,
|
|
||||||
): Prisma.TeamCollectionCreateInput {
|
|
||||||
return {
|
|
||||||
title: folder.name,
|
|
||||||
team: {
|
|
||||||
connect: {
|
|
||||||
id: teamID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requests: {
|
|
||||||
create: folder.requests.map((r, index) => ({
|
|
||||||
title: r.name,
|
|
||||||
team: {
|
|
||||||
connect: {
|
|
||||||
id: teamID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: r,
|
|
||||||
orderIndex: index + 1,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
orderIndex: orderIndex,
|
|
||||||
children: {
|
|
||||||
create: folder.folders.map((f, index) =>
|
|
||||||
this.generatePrismaQueryObjForFBCollFolder(f, teamID, index + 1),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JSON containing all the contents of a collection
|
|
||||||
*
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @param collectionID The Collection ID
|
|
||||||
* @returns A JSON string containing all the contents of a collection
|
|
||||||
*/
|
|
||||||
private async exportCollectionToJSONObject(
|
|
||||||
teamID: string,
|
|
||||||
collectionID: string,
|
|
||||||
) {
|
|
||||||
const collection = await this.getCollection(collectionID);
|
|
||||||
if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID);
|
|
||||||
|
|
||||||
const childrenCollection = await this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
teamID,
|
|
||||||
parentID: collectionID,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'asc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const childrenCollectionObjects = [];
|
|
||||||
for (const coll of childrenCollection) {
|
|
||||||
const result = await this.exportCollectionToJSONObject(teamID, coll.id);
|
|
||||||
if (E.isLeft(result)) return E.left(result.left);
|
|
||||||
|
|
||||||
childrenCollectionObjects.push(result.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requests = await this.prisma.teamRequest.findMany({
|
|
||||||
where: {
|
|
||||||
teamID,
|
|
||||||
collectionID,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'asc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: CollectionFolder = {
|
|
||||||
name: collection.right.title,
|
|
||||||
folders: childrenCollectionObjects,
|
|
||||||
requests: requests.map((x) => x.request),
|
|
||||||
};
|
|
||||||
|
|
||||||
return E.right(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JSON containing all the contents of collections and requests of a team
|
|
||||||
*
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @returns A JSON string containing all the contents of collections and requests of a team
|
|
||||||
*/
|
|
||||||
async exportCollectionsToJSON(teamID: string) {
|
|
||||||
const rootCollections = await this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
teamID,
|
|
||||||
parentID: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootCollectionObjects = [];
|
|
||||||
for (const coll of rootCollections) {
|
|
||||||
const result = await this.exportCollectionToJSONObject(teamID, coll.id);
|
|
||||||
if (E.isLeft(result)) return E.left(result.left);
|
|
||||||
|
|
||||||
rootCollectionObjects.push(result.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.right(JSON.stringify(rootCollectionObjects));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new TeamCollections and TeamRequests from JSON string
|
|
||||||
*
|
|
||||||
* @param jsonString The JSON string of the content
|
|
||||||
* @param destTeamID The Team ID
|
|
||||||
* @param destCollectionID The Collection ID
|
|
||||||
* @returns An Either of a Boolean if the creation operation was successful
|
|
||||||
*/
|
|
||||||
async importCollectionsFromJSON(
|
|
||||||
jsonString: string,
|
|
||||||
destTeamID: string,
|
|
||||||
destCollectionID: string | null,
|
|
||||||
) {
|
|
||||||
// Check to see if jsonString is valid
|
|
||||||
const collectionsList = stringToJson<CollectionFolder[]>(jsonString);
|
|
||||||
if (E.isLeft(collectionsList)) return E.left(TEAM_COLL_INVALID_JSON);
|
|
||||||
|
|
||||||
// Check to see if parsed jsonString is an array
|
|
||||||
if (!Array.isArray(collectionsList.right))
|
|
||||||
return E.left(TEAM_COLL_INVALID_JSON);
|
|
||||||
|
|
||||||
// Get number of root or child collections for destCollectionID(if destcollectionID != null) or destTeamID(if destcollectionID == null)
|
|
||||||
const count = !destCollectionID
|
|
||||||
? await this.getRootCollectionsCount(destTeamID)
|
|
||||||
: await this.getChildCollectionsCount(destCollectionID);
|
|
||||||
|
|
||||||
// Generate Prisma Query Object for all child collections in collectionsList
|
|
||||||
const queryList = collectionsList.right.map((x) =>
|
|
||||||
this.generatePrismaQueryObjForFBCollFolder(x, destTeamID, count + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const parent = destCollectionID
|
|
||||||
? {
|
|
||||||
connect: {
|
|
||||||
id: destCollectionID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const teamCollections = await this.prisma.$transaction(
|
|
||||||
queryList.map((x) =>
|
|
||||||
this.prisma.teamCollection.create({
|
|
||||||
data: {
|
|
||||||
...x,
|
|
||||||
parent,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
teamCollections.forEach((x) =>
|
|
||||||
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace all the existing contents of a collection (or root collections) with data from JSON String
|
|
||||||
*
|
|
||||||
* @param jsonString The JSON string of the content
|
|
||||||
* @param destTeamID The Team ID
|
|
||||||
* @param destCollectionID The Collection ID
|
|
||||||
* @returns An Either of a Boolean if the operation was successful
|
|
||||||
*/
|
|
||||||
async replaceCollectionsWithJSON(
|
|
||||||
jsonString: string,
|
|
||||||
destTeamID: string,
|
|
||||||
destCollectionID: string | null,
|
|
||||||
) {
|
|
||||||
// Check to see if jsonString is valid
|
|
||||||
const collectionsList = stringToJson<CollectionFolder[]>(jsonString);
|
|
||||||
if (E.isLeft(collectionsList)) return E.left(TEAM_COLL_INVALID_JSON);
|
|
||||||
|
|
||||||
// Check to see if parsed jsonString is an array
|
|
||||||
if (!Array.isArray(collectionsList.right))
|
|
||||||
return E.left(TEAM_COLL_INVALID_JSON);
|
|
||||||
|
|
||||||
// Fetch all child collections of destCollectionID
|
|
||||||
const childrenCollection = await this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
teamID: destTeamID,
|
|
||||||
parentID: destCollectionID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const coll of childrenCollection) {
|
|
||||||
const deletedTeamCollection = await this.deleteCollection(coll.id);
|
|
||||||
if (E.isLeft(deletedTeamCollection))
|
|
||||||
return E.left(deletedTeamCollection.left);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get number of root or child collections for destCollectionID(if destcollectionID != null) or destTeamID(if destcollectionID == null)
|
|
||||||
const count = !destCollectionID
|
|
||||||
? await this.getRootCollectionsCount(destTeamID)
|
|
||||||
: await this.getChildCollectionsCount(destCollectionID);
|
|
||||||
|
|
||||||
const queryList = collectionsList.right.map((x) =>
|
|
||||||
this.generatePrismaQueryObjForFBCollFolder(x, destTeamID, count + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const parent = destCollectionID
|
|
||||||
? {
|
|
||||||
connect: {
|
|
||||||
id: destCollectionID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const teamCollections = await this.prisma.$transaction(
|
|
||||||
queryList.map((x) =>
|
|
||||||
this.prisma.teamCollection.create({
|
|
||||||
data: {
|
|
||||||
...x,
|
|
||||||
parent,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
teamCollections.forEach((x) =>
|
|
||||||
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typecast a database TeamCollection to a TeamCollection model
|
|
||||||
* @param teamCollection database TeamCollection
|
|
||||||
* @returns TeamCollection model
|
|
||||||
*/
|
|
||||||
private cast(teamCollection: DBTeamCollection): TeamCollection {
|
|
||||||
return <TeamCollection>{ ...teamCollection };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Team of given Collection ID
|
|
||||||
*
|
|
||||||
* @param collectionID The collection ID
|
|
||||||
* @returns Team of given Collection ID
|
|
||||||
*/
|
|
||||||
async getTeamOfCollection(collectionID: string) {
|
|
||||||
try {
|
|
||||||
const teamCollection = await this.prisma.teamCollection.findUnique({
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(teamCollection.team);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_INVALID_COLL_ID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get parent of given Collection ID
|
|
||||||
*
|
|
||||||
* @param collectionID The collection ID
|
|
||||||
* @returns Parent TeamCollection of given Collection ID
|
|
||||||
*/
|
|
||||||
async getParentOfCollection(collectionID: string) {
|
|
||||||
const teamCollection = await this.prisma.teamCollection.findUnique({
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
parent: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!teamCollection) return null;
|
|
||||||
|
|
||||||
return teamCollection.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get child collections of given Collection ID
|
|
||||||
*
|
|
||||||
* @param collectionID The collection ID
|
|
||||||
* @param cursor collectionID for pagination
|
|
||||||
* @param take Number of items we want returned
|
|
||||||
* @returns A list of child collections
|
|
||||||
*/
|
|
||||||
getChildrenOfCollection(
|
|
||||||
collectionID: string,
|
|
||||||
cursor: string | null,
|
|
||||||
take: number,
|
|
||||||
) {
|
|
||||||
return this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
parentID: collectionID,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'asc',
|
|
||||||
},
|
|
||||||
take: take, // default: 10
|
|
||||||
skip: cursor ? 1 : 0,
|
|
||||||
cursor: cursor ? { id: cursor } : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get root collections of given Collection ID
|
|
||||||
*
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @param cursor collectionID for pagination
|
|
||||||
* @param take Number of items we want returned
|
|
||||||
* @returns A list of root TeamCollections
|
|
||||||
*/
|
|
||||||
async getTeamRootCollections(
|
|
||||||
teamID: string,
|
|
||||||
cursor: string | null,
|
|
||||||
take: number,
|
|
||||||
) {
|
|
||||||
return this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
teamID,
|
|
||||||
parentID: null,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'asc',
|
|
||||||
},
|
|
||||||
take: take, // default: 10
|
|
||||||
skip: cursor ? 1 : 0,
|
|
||||||
cursor: cursor ? { id: cursor } : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get collection details
|
|
||||||
*
|
|
||||||
* @param collectionID The collection ID
|
|
||||||
* @returns An Either of the Collection details
|
|
||||||
*/
|
|
||||||
async getCollection(collectionID: string) {
|
|
||||||
try {
|
|
||||||
const teamCollection = await this.prisma.teamCollection.findUniqueOrThrow(
|
|
||||||
{
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return E.right(teamCollection);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check to see if Collection belongs to Team
|
|
||||||
*
|
|
||||||
* @param collectionID getChildCollectionsCount
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @returns An Option of a Boolean
|
|
||||||
*/
|
|
||||||
private async isOwnerCheck(collectionID: string, teamID: string) {
|
|
||||||
try {
|
|
||||||
await this.prisma.teamCollection.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
teamID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return O.some(true);
|
|
||||||
} catch (error) {
|
|
||||||
return O.none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the count of child collections present for a given collectionID
|
|
||||||
* * The count returned is highest OrderIndex + 1
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection ID
|
|
||||||
* @returns Number of Child Collections
|
|
||||||
*/
|
|
||||||
private async getChildCollectionsCount(collectionID: string) {
|
|
||||||
const childCollectionCount = await this.prisma.teamCollection.findMany({
|
|
||||||
where: { parentID: collectionID },
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!childCollectionCount.length) return 0;
|
|
||||||
return childCollectionCount[0].orderIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the count of root collections present for a given teamID
|
|
||||||
* * The count returned is highest OrderIndex + 1
|
|
||||||
*
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @returns Number of Root Collections
|
|
||||||
*/
|
|
||||||
private async getRootCollectionsCount(teamID: string) {
|
|
||||||
const rootCollectionCount = await this.prisma.teamCollection.findMany({
|
|
||||||
where: { teamID, parentID: null },
|
|
||||||
orderBy: {
|
|
||||||
orderIndex: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!rootCollectionCount.length) return 0;
|
|
||||||
return rootCollectionCount[0].orderIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new TeamCollection
|
|
||||||
*
|
|
||||||
* @param teamID The Team ID
|
|
||||||
* @param title The title of new TeamCollection
|
|
||||||
* @param parentTeamCollectionID The parent collectionID (null if root collection)
|
|
||||||
* @returns An Either of TeamCollection
|
|
||||||
*/
|
|
||||||
async createCollection(
|
|
||||||
teamID: string,
|
|
||||||
title: string,
|
|
||||||
parentTeamCollectionID: string | null,
|
|
||||||
) {
|
|
||||||
const isTitleValid = isValidLength(title, this.TITLE_LENGTH);
|
|
||||||
if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE);
|
|
||||||
|
|
||||||
// Check to see if parentTeamCollectionID belongs to this Team
|
|
||||||
if (parentTeamCollectionID !== null) {
|
|
||||||
const isOwner = await this.isOwnerCheck(parentTeamCollectionID, teamID);
|
|
||||||
if (O.isNone(isOwner)) return E.left(TEAM_NOT_OWNER);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isParent = parentTeamCollectionID
|
|
||||||
? {
|
|
||||||
connect: {
|
|
||||||
id: parentTeamCollectionID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const teamCollection = await this.prisma.teamCollection.create({
|
|
||||||
data: {
|
|
||||||
title: title,
|
|
||||||
team: {
|
|
||||||
connect: {
|
|
||||||
id: teamID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parent: isParent,
|
|
||||||
orderIndex: !parentTeamCollectionID
|
|
||||||
? (await this.getRootCollectionsCount(teamID)) + 1
|
|
||||||
: (await this.getChildCollectionsCount(parentTeamCollectionID)) + 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(`team_coll/${teamID}/coll_added`, teamCollection);
|
|
||||||
|
|
||||||
return E.right(this.cast(teamCollection));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the title of a TeamCollection
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection ID
|
|
||||||
* @param newTitle The new title of collection
|
|
||||||
* @returns An Either of the updated TeamCollection
|
|
||||||
*/
|
|
||||||
async renameCollection(collectionID: string, newTitle: string) {
|
|
||||||
const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH);
|
|
||||||
if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedTeamCollection = await this.prisma.teamCollection.update({
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: newTitle,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${updatedTeamCollection.teamID}/coll_updated`,
|
|
||||||
updatedTeamCollection,
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(updatedTeamCollection);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the OrderIndex of all collections in given parentID
|
|
||||||
*
|
|
||||||
* @param parentID The Parent collectionID
|
|
||||||
* @param orderIndexCondition Condition to decide what collections will be updated
|
|
||||||
* @param dataCondition Increment/Decrement OrderIndex condition
|
|
||||||
* @returns A Collection with updated OrderIndexes
|
|
||||||
*/
|
|
||||||
private async updateOrderIndex(
|
|
||||||
parentID: string,
|
|
||||||
orderIndexCondition: Prisma.IntFilter,
|
|
||||||
dataCondition: Prisma.IntFieldUpdateOperationsInput,
|
|
||||||
) {
|
|
||||||
const updatedTeamCollection = await this.prisma.teamCollection.updateMany({
|
|
||||||
where: {
|
|
||||||
parentID: parentID,
|
|
||||||
orderIndex: orderIndexCondition,
|
|
||||||
},
|
|
||||||
data: { orderIndex: dataCondition },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedTeamCollection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a TeamCollection from the DB
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection Id
|
|
||||||
* @returns The deleted TeamCollection
|
|
||||||
*/
|
|
||||||
private async removeTeamCollection(collectionID: string) {
|
|
||||||
try {
|
|
||||||
const deletedTeamCollection = await this.prisma.teamCollection.delete({
|
|
||||||
where: {
|
|
||||||
id: collectionID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(deletedTeamCollection);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete child collection and requests of a TeamCollection
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection Id
|
|
||||||
* @returns A Boolean of deletion status
|
|
||||||
*/
|
|
||||||
private async deleteCollectionData(collection: DBTeamCollection) {
|
|
||||||
// Get all child collections in collectionID
|
|
||||||
const childCollectionList = await this.prisma.teamCollection.findMany({
|
|
||||||
where: {
|
|
||||||
parentID: collection.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete child collections
|
|
||||||
await Promise.all(
|
|
||||||
childCollectionList.map((coll) => this.deleteCollection(coll.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete all requests in collectionID
|
|
||||||
await this.prisma.teamRequest.deleteMany({
|
|
||||||
where: {
|
|
||||||
collectionID: collection.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete collection from TeamCollection table
|
|
||||||
const deletedTeamCollection = await this.removeTeamCollection(
|
|
||||||
collection.id,
|
|
||||||
);
|
|
||||||
if (E.isLeft(deletedTeamCollection))
|
|
||||||
return E.left(deletedTeamCollection.left);
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${deletedTeamCollection.right.teamID}/coll_removed`,
|
|
||||||
deletedTeamCollection.right.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(deletedTeamCollection.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a TeamCollection
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection Id
|
|
||||||
* @returns An Either of Boolean of deletion status
|
|
||||||
*/
|
|
||||||
async deleteCollection(collectionID: string) {
|
|
||||||
const collection = await this.getCollection(collectionID);
|
|
||||||
if (E.isLeft(collection)) return E.left(collection.left);
|
|
||||||
|
|
||||||
// Delete all child collections and requests in the collection
|
|
||||||
const collectionData = await this.deleteCollectionData(collection.right);
|
|
||||||
if (E.isLeft(collectionData)) return E.left(collectionData.left);
|
|
||||||
|
|
||||||
// Update orderIndexes in TeamCollection table for user
|
|
||||||
await this.updateOrderIndex(
|
|
||||||
collectionData.right.parentID,
|
|
||||||
{ gt: collectionData.right.orderIndex },
|
|
||||||
{ decrement: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change parentID of TeamCollection's
|
|
||||||
*
|
|
||||||
* @param collectionID The collection ID
|
|
||||||
* @param parentCollectionID The new parent's collection ID or change to root collection
|
|
||||||
* @returns If successful return an Either of true
|
|
||||||
*/
|
|
||||||
private async changeParent(
|
|
||||||
collection: DBTeamCollection,
|
|
||||||
parentCollectionID: string | null,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
let collectionCount: number;
|
|
||||||
|
|
||||||
if (!parentCollectionID)
|
|
||||||
collectionCount = await this.getRootCollectionsCount(collection.teamID);
|
|
||||||
collectionCount = await this.getChildCollectionsCount(parentCollectionID);
|
|
||||||
|
|
||||||
const updatedCollection = await this.prisma.teamCollection.update({
|
|
||||||
where: {
|
|
||||||
id: collection.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
// if parentCollectionID == null, collection becomes root collection
|
|
||||||
// if parentCollectionID != null, collection becomes child collection
|
|
||||||
parentID: parentCollectionID,
|
|
||||||
orderIndex: collectionCount + 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return E.right(this.cast(updatedCollection));
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if collection is parent of destCollection
|
|
||||||
*
|
|
||||||
* @param collection The ID of collection being moved
|
|
||||||
* @param destCollection The ID of collection into which we are moving target collection into
|
|
||||||
* @returns An Option of boolean, is parent or not
|
|
||||||
*/
|
|
||||||
private async isParent(
|
|
||||||
collection: TeamCollection,
|
|
||||||
destCollection: TeamCollection,
|
|
||||||
): Promise<O.Option<boolean>> {
|
|
||||||
//* Recursively check if collection is a parent by going up the tree of child-parent collections until we reach a root collection i.e parentID === null
|
|
||||||
//* Valid condition, isParent returns false
|
|
||||||
//* Consider us moving Collection_E into Collection_D
|
|
||||||
//* Collection_A [parent:null !== Collection_E] return false, exit
|
|
||||||
//* |--> Collection_B [parent:Collection_A !== Collection_E] call isParent(Collection_E,Collection_A)
|
|
||||||
//* |--> Collection_C [parent:Collection_B !== Collection_E] call isParent(Collection_E,Collection_B)
|
|
||||||
//* |--> Collection_D [parent:Collection_C !== Collection_E] call isParent(Collection_E,Collection_C)
|
|
||||||
//* Invalid condition, isParent returns true
|
|
||||||
//* Consider us moving Collection_B into Collection_D
|
|
||||||
//* Collection_A
|
|
||||||
//* |--> Collection_B
|
|
||||||
//* |--> Collection_C [parent:Collection_B === Collection_B] return true, exit
|
|
||||||
//* |--> Collection_D [parent:Collection_C !== Collection_B] call isParent(Collection_B,Collection_C)
|
|
||||||
|
|
||||||
// Check if collection and destCollection are same
|
|
||||||
if (collection === destCollection) {
|
|
||||||
return O.none;
|
|
||||||
}
|
|
||||||
if (destCollection.parentID !== null) {
|
|
||||||
// Check if ID of collection is same as parent of destCollection
|
|
||||||
if (destCollection.parentID === collection.id) {
|
|
||||||
return O.none;
|
|
||||||
}
|
|
||||||
// Get collection details of collection one step above in the tree i.e the parent collection
|
|
||||||
const parentCollection = await this.getCollection(
|
|
||||||
destCollection.parentID,
|
|
||||||
);
|
|
||||||
if (E.isLeft(parentCollection)) {
|
|
||||||
return O.none;
|
|
||||||
}
|
|
||||||
// Call isParent again now with parent collection
|
|
||||||
return await this.isParent(collection, parentCollection.right);
|
|
||||||
} else {
|
|
||||||
return O.some(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move TeamCollection into root or another collection
|
|
||||||
*
|
|
||||||
* @param collectionID The ID of collection being moved
|
|
||||||
* @param destCollectionID The ID of collection the target collection is being moved into or move target collection to root
|
|
||||||
* @returns An Either of the moved TeamCollection
|
|
||||||
*/
|
|
||||||
async moveCollection(collectionID: string, destCollectionID: string | null) {
|
|
||||||
// Get collection details of collectionID
|
|
||||||
const collection = await this.getCollection(collectionID);
|
|
||||||
if (E.isLeft(collection)) return E.left(collection.left);
|
|
||||||
|
|
||||||
// destCollectionID == null i.e move collection to root
|
|
||||||
if (!destCollectionID) {
|
|
||||||
if (!collection.right.parentID) {
|
|
||||||
// collection is a root collection
|
|
||||||
// Throw error if collection is already a root collection
|
|
||||||
return E.left(TEAM_COL_ALREADY_ROOT);
|
|
||||||
}
|
|
||||||
// Move child collection into root and update orderIndexes for root teamCollections
|
|
||||||
await this.updateOrderIndex(
|
|
||||||
collection.right.parentID,
|
|
||||||
{ gt: collection.right.orderIndex },
|
|
||||||
{ decrement: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Change parent from child to root i.e child collection becomes a root collection
|
|
||||||
const updatedCollection = await this.changeParent(collection.right, null);
|
|
||||||
if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left);
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${collection.right.teamID}/coll_moved`,
|
|
||||||
updatedCollection.right,
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(updatedCollection.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
// destCollectionID != null i.e move into another collection
|
|
||||||
if (collectionID === destCollectionID) {
|
|
||||||
// Throw error if collectionID and destCollectionID are the same
|
|
||||||
return E.left(TEAM_COLL_DEST_SAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get collection details of destCollectionID
|
|
||||||
const destCollection = await this.getCollection(destCollectionID);
|
|
||||||
if (E.isLeft(destCollection)) return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
|
|
||||||
// Check if collection and destCollection belong to the same user account
|
|
||||||
if (collection.right.teamID !== destCollection.right.teamID) {
|
|
||||||
return E.left(TEAM_COLL_NOT_SAME_TEAM);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if collection is present on the parent tree for destCollection
|
|
||||||
const checkIfParent = await this.isParent(
|
|
||||||
collection.right,
|
|
||||||
destCollection.right,
|
|
||||||
);
|
|
||||||
if (O.isNone(checkIfParent)) {
|
|
||||||
return E.left(TEAM_COLL_IS_PARENT_COLL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move root/child collection into another child collection and update orderIndexes of the previous parent
|
|
||||||
await this.updateOrderIndex(
|
|
||||||
collection.right.parentID,
|
|
||||||
{ gt: collection.right.orderIndex },
|
|
||||||
{ decrement: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Change parent from null to teamCollection i.e collection becomes a child collection
|
|
||||||
const updatedCollection = await this.changeParent(
|
|
||||||
collection.right,
|
|
||||||
destCollection.right.id,
|
|
||||||
);
|
|
||||||
if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left);
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${collection.right.teamID}/coll_moved`,
|
|
||||||
updatedCollection.right,
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(updatedCollection.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the number of child collections present in collectionID
|
|
||||||
*
|
|
||||||
* @param collectionID The Collection ID
|
|
||||||
* @returns Number of collections
|
|
||||||
*/
|
|
||||||
getCollectionCount(collectionID: string): Promise<number> {
|
|
||||||
return this.prisma.teamCollection.count({
|
|
||||||
where: { parentID: collectionID },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update order of root or child collectionID's
|
|
||||||
*
|
|
||||||
* @param collectionID The ID of collection being re-ordered
|
|
||||||
* @param nextCollectionID The ID of collection that is after the moved collection in its new position
|
|
||||||
* @returns If successful return an Either of true
|
|
||||||
*/
|
|
||||||
async updateCollectionOrder(
|
|
||||||
collectionID: string,
|
|
||||||
nextCollectionID: string | null,
|
|
||||||
) {
|
|
||||||
// Throw error if collectionID and nextCollectionID are the same
|
|
||||||
if (collectionID === nextCollectionID)
|
|
||||||
return E.left(TEAM_COL_SAME_NEXT_COLL);
|
|
||||||
|
|
||||||
// Get collection details of collectionID
|
|
||||||
const collection = await this.getCollection(collectionID);
|
|
||||||
if (E.isLeft(collection)) return E.left(collection.left);
|
|
||||||
|
|
||||||
if (!nextCollectionID) {
|
|
||||||
// nextCollectionID == null i.e move collection to the end of the list
|
|
||||||
try {
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
// Step 1: Decrement orderIndex of all items that come after collection.orderIndex till end of list of items
|
|
||||||
await tx.teamCollection.updateMany({
|
|
||||||
where: {
|
|
||||||
parentID: collection.right.parentID,
|
|
||||||
orderIndex: {
|
|
||||||
gte: collection.right.orderIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
orderIndex: { decrement: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Step 2: Update orderIndex of collection to length of list
|
|
||||||
const updatedTeamCollection = await tx.teamCollection.update({
|
|
||||||
where: { id: collection.right.id },
|
|
||||||
data: {
|
|
||||||
orderIndex: await this.getCollectionCount(
|
|
||||||
collection.right.parentID,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${collection.right.teamID}/coll_order_updated`,
|
|
||||||
{
|
|
||||||
collection: this.cast(collection.right),
|
|
||||||
nextCollection: null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COL_REORDERING_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextCollectionID != null i.e move to a certain position
|
|
||||||
// Get collection details of nextCollectionID
|
|
||||||
const subsequentCollection = await this.getCollection(nextCollectionID);
|
|
||||||
if (E.isLeft(subsequentCollection)) return E.left(TEAM_COLL_NOT_FOUND);
|
|
||||||
|
|
||||||
// Check if collection and subsequentCollection belong to the same collection team
|
|
||||||
if (collection.right.teamID !== subsequentCollection.right.teamID)
|
|
||||||
return E.left(TEAM_COLL_NOT_SAME_TEAM);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
// Step 1: Determine if we are moving collection up or down the list
|
|
||||||
const isMovingUp =
|
|
||||||
subsequentCollection.right.orderIndex < collection.right.orderIndex;
|
|
||||||
// Step 2: Update OrderIndex of items in list depending on moving up or down
|
|
||||||
const updateFrom = isMovingUp
|
|
||||||
? subsequentCollection.right.orderIndex
|
|
||||||
: collection.right.orderIndex + 1;
|
|
||||||
|
|
||||||
const updateTo = isMovingUp
|
|
||||||
? collection.right.orderIndex - 1
|
|
||||||
: subsequentCollection.right.orderIndex - 1;
|
|
||||||
|
|
||||||
await tx.teamCollection.updateMany({
|
|
||||||
where: {
|
|
||||||
parentID: collection.right.parentID,
|
|
||||||
orderIndex: { gte: updateFrom, lte: updateTo },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Step 3: Update OrderIndex of collection
|
|
||||||
const updatedTeamCollection = await tx.teamCollection.update({
|
|
||||||
where: { id: collection.right.id },
|
|
||||||
data: {
|
|
||||||
orderIndex: isMovingUp
|
|
||||||
? subsequentCollection.right.orderIndex
|
|
||||||
: subsequentCollection.right.orderIndex - 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_coll/${collection.right.teamID}/coll_order_updated`,
|
|
||||||
{
|
|
||||||
collection: this.cast(collection.right),
|
|
||||||
nextCollection: this.cast(subsequentCollection.right),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return E.right(true);
|
|
||||||
} catch (error) {
|
|
||||||
return E.left(TEAM_COL_REORDERING_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Team Collections in DB for a particular team
|
|
||||||
* @param teamID Team ID
|
|
||||||
* @returns number of Team Collections in the DB
|
|
||||||
*/
|
|
||||||
async totalCollectionsInTeam(teamID: string) {
|
|
||||||
const collCount = await this.prisma.teamCollection.count({
|
|
||||||
where: {
|
|
||||||
teamID: teamID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return collCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all the Team Collections in DB
|
|
||||||
*
|
|
||||||
* @returns number of Team Collections in the DB
|
|
||||||
*/
|
|
||||||
async getTeamCollectionsCount() {
|
|
||||||
const teamCollectionsCount = this.prisma.teamCollection.count();
|
|
||||||
return teamCollectionsCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as S from 'fp-ts/string';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import {
|
|
||||||
getAnnotatedRequiredRoles,
|
|
||||||
getGqlArg,
|
|
||||||
getUserFromGQLContext,
|
|
||||||
throwErr,
|
|
||||||
} from 'src/utils';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
|
||||||
import {
|
|
||||||
BUG_AUTH_NO_USER_CTX,
|
|
||||||
BUG_TEAM_ENV_GUARD_NO_ENV_ID,
|
|
||||||
BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES,
|
|
||||||
TEAM_ENVIRONMENT_NOT_TEAM_MEMBER,
|
|
||||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A guard which checks whether the caller of a GQL Operation
|
|
||||||
* is in the team which owns the environment.
|
|
||||||
* This guard also requires the RequireRole decorator for access control
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class GqlTeamEnvTeamGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly teamEnvironmentService: TeamEnvironmentsService,
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
return pipe(
|
|
||||||
TE.Do,
|
|
||||||
|
|
||||||
TE.bindW('requiredRoles', () =>
|
|
||||||
pipe(
|
|
||||||
getAnnotatedRequiredRoles(this.reflector, context),
|
|
||||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('user', () =>
|
|
||||||
pipe(
|
|
||||||
getUserFromGQLContext(context),
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('envID', () =>
|
|
||||||
pipe(
|
|
||||||
getGqlArg('id', context),
|
|
||||||
O.fromPredicate(S.isString),
|
|
||||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('membership', ({ envID, user }) =>
|
|
||||||
pipe(
|
|
||||||
this.teamEnvironmentService.getTeamEnvironment(envID),
|
|
||||||
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
|
|
||||||
TE.chainW((env) =>
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(env.teamID, user.uid),
|
|
||||||
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.map(({ membership, requiredRoles }) =>
|
|
||||||
requiredRoles.includes(membership.role),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class TeamEnvironment {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the team this environment belongs to',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Name of the environment',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'All variables present in the environment',
|
|
||||||
})
|
|
||||||
variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
|
||||||
import { TeamEnvironmentsResolver } from './team-environments.resolver';
|
|
||||||
import { UserModule } from 'src/user/user.module';
|
|
||||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
|
||||||
import { TeamModule } from 'src/team/team.module';
|
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
|
||||||
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
|
||||||
import { TeamEnvsTeamResolver } from './team.resolver';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, PubSubModule, UserModule, TeamModule],
|
|
||||||
providers: [
|
|
||||||
TeamEnvironmentsResolver,
|
|
||||||
TeamEnvironmentsService,
|
|
||||||
GqlTeamEnvTeamGuard,
|
|
||||||
TeamEnvsTeamResolver,
|
|
||||||
],
|
|
||||||
exports: [TeamEnvironmentsService, GqlTeamEnvTeamGuard],
|
|
||||||
})
|
|
||||||
export class TeamEnvironmentsModule {}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { Resolver, Mutation, Args, Subscription, ID } from '@nestjs/graphql';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
|
||||||
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
|
|
||||||
import { TeamMemberRole } from 'src/team/team.model';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
|
||||||
@Resolver(() => 'TeamEnvironment')
|
|
||||||
export class TeamEnvironmentsResolver {
|
|
||||||
constructor(
|
|
||||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/* Mutations */
|
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
|
||||||
description: 'Create a new Team Environment for given Team ID',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
createTeamEnvironment(
|
|
||||||
@Args({
|
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
@Args({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
|
||||||
return this.teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
name,
|
|
||||||
teamID,
|
|
||||||
variables,
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Delete a Team Environment for given Team ID',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
deleteTeamEnvironment(
|
|
||||||
@Args({
|
|
||||||
name: 'id',
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
id: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return pipe(
|
|
||||||
this.teamEnvironmentsService.deleteTeamEnvironment(id),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
|
||||||
description:
|
|
||||||
'Add/Edit a single environment variable or variables to a Team Environment',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
updateTeamEnvironment(
|
|
||||||
@Args({
|
|
||||||
name: 'id',
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
id: string,
|
|
||||||
@Args({
|
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string,
|
|
||||||
@Args({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
|
||||||
return pipe(
|
|
||||||
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
|
||||||
description: 'Delete all variables from a Team Environment',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
deleteAllVariablesFromTeamEnvironment(
|
|
||||||
@Args({
|
|
||||||
name: 'id',
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
id: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
|
||||||
return pipe(
|
|
||||||
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
|
||||||
description: 'Create a duplicate of an existing environment',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
|
||||||
createDuplicateEnvironment(
|
|
||||||
@Args({
|
|
||||||
name: 'id',
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
id: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
|
||||||
return pipe(
|
|
||||||
this.teamEnvironmentsService.createDuplicateEnvironment(id),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscriptions */
|
|
||||||
|
|
||||||
@Subscription(() => TeamEnvironment, {
|
|
||||||
description: 'Listen for Team Environment Updates',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
teamEnvironmentUpdated(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_environment/${teamID}/updated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => TeamEnvironment, {
|
|
||||||
description: 'Listen for Team Environment Creation Messages',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
teamEnvironmentCreated(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_environment/${teamID}/created`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => TeamEnvironment, {
|
|
||||||
description: 'Listen for Team Environment Deletion Messages',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
teamEnvironmentDeleted(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
) {
|
|
||||||
return this.pubsub.asyncIterator(`team_environment/${teamID}/deleted`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
|
||||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
|
||||||
|
|
||||||
const mockPubSub = {
|
|
||||||
publish: jest.fn().mockResolvedValue(null),
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const teamEnvironmentsService = new TeamEnvironmentsService(
|
|
||||||
mockPrisma,
|
|
||||||
mockPubSub as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
const teamEnvironment = {
|
|
||||||
id: '123',
|
|
||||||
name: 'test',
|
|
||||||
teamID: 'abc123',
|
|
||||||
variables: [{}],
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockReset(mockPrisma);
|
|
||||||
mockPubSub.publish.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TeamEnvironmentsService', () => {
|
|
||||||
describe('getTeamEnvironment', () => {
|
|
||||||
test('queries the db with the id', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
|
||||||
|
|
||||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: {
|
|
||||||
id: '123',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('requests prisma to reject the query promise if not found', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
|
||||||
|
|
||||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
rejectOnNotFound: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return a Some of the correct environment if exists', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(result).toEqualSome(teamEnvironment);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return a None if the environment does not exist', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(result).toBeNone();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('createTeamEnvironment', () => {
|
|
||||||
test('should create and return a new team environment given a valid name,variable and team ID', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqual(<TeamEnvironment>{
|
|
||||||
id: teamEnvironment.id,
|
|
||||||
name: teamEnvironment.name,
|
|
||||||
teamID: teamEnvironment.teamID,
|
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject if given team ID is invalid', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
'invalidteamid',
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject if provided team environment name is not a string', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
null as any,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject if provided variable is not a string', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
null as any,
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`team_environment/${teamEnvironment.teamID}/created`,
|
|
||||||
result,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteTeamEnvironment', () => {
|
|
||||||
test('should resolve to true given a valid team environment ID', async () => {
|
|
||||||
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if given id is invalid', async () => {
|
|
||||||
mockPrisma.teamEnvironment.delete.mockRejectedValue('RecordNotFound');
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
|
||||||
'invalidid',
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/deleted" if team environment is deleted successfully', async () => {
|
|
||||||
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`team_environment/${teamEnvironment.teamID}/deleted`,
|
|
||||||
{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateVariablesInTeamEnvironment', () => {
|
|
||||||
test('should add new variable to a team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: [{ key: 'value' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{ key: 'value' }]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{ key: 'value' }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should add new variable to already existing list of variables in a team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: [{ key: 'value' }, { key_2: 'value_2' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should edit existing variables in a team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: [{ key: '1234' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{ key: '1234' }]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{ key: '1234' }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should delete existing variable in a team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{}]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{}]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should edit name of an existing team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: [{ key: '123' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{ key: '123' }]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{ key: '123' }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
'invalidid',
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/updated" if team environment is updated successfully', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{ key: 'value' }]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
|
||||||
{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteAllVariablesFromTeamEnvironment', () => {
|
|
||||||
test('should delete all variables in a team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result =
|
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{}]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
|
||||||
|
|
||||||
const result =
|
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
|
||||||
'invalidid',
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/updated" if team environment is updated successfully', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result =
|
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
|
||||||
{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{}]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createDuplicateEnvironment', () => {
|
|
||||||
test('should duplicate an existing team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
|
|
||||||
teamEnvironment,
|
|
||||||
);
|
|
||||||
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is updated successfully', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
|
|
||||||
teamEnvironment,
|
|
||||||
);
|
|
||||||
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
|
||||||
`team_environment/${teamEnvironment.teamID}/created`,
|
|
||||||
{
|
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
|
||||||
variables: JSON.stringify([{}]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('totalEnvsInTeam', () => {
|
|
||||||
test('should resolve right and return a total team envs count ', async () => {
|
|
||||||
mockPrisma.teamEnvironment.count.mockResolvedValueOnce(2);
|
|
||||||
const result = await teamEnvironmentsService.totalEnvsInTeam('id1');
|
|
||||||
expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
teamID: 'id1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(2);
|
|
||||||
});
|
|
||||||
test('should resolve left and return an error when no team envs found', async () => {
|
|
||||||
mockPrisma.teamEnvironment.count.mockResolvedValueOnce(0);
|
|
||||||
const result = await teamEnvironmentsService.totalEnvsInTeam('id1');
|
|
||||||
expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
teamID: 'id1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as A from 'fp-ts/Array';
|
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
|
||||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TeamEnvironmentsService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getTeamEnvironment(id: string) {
|
|
||||||
return TO.tryCatch(() =>
|
|
||||||
this.prisma.teamEnvironment.findFirst({
|
|
||||||
where: { id },
|
|
||||||
rejectOnNotFound: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createTeamEnvironment(name: string, teamID: string, variables: string) {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.create({
|
|
||||||
data: {
|
|
||||||
name: name,
|
|
||||||
teamID: teamID,
|
|
||||||
variables: JSON.parse(variables),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
T.chainFirst(
|
|
||||||
(environment) => () =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/created`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
T.map((data) => {
|
|
||||||
return <TeamEnvironment>{
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
teamID: data.teamID,
|
|
||||||
variables: JSON.stringify(data.variables),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTeamEnvironment(id: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.delete({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/deleted`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map((data) => true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTeamEnvironment(id: string, name: string, variables: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.update({
|
|
||||||
where: { id: id },
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
variables: JSON.parse(variables),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/updated`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteAllVariablesFromTeamEnvironment(id: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.update({
|
|
||||||
where: { id: id },
|
|
||||||
data: {
|
|
||||||
variables: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/updated`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createDuplicateEnvironment(id: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.findFirst({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
rejectOnNotFound: true,
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chain((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.prisma.teamEnvironment.create({
|
|
||||||
data: {
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: environment.variables as Prisma.JsonArray,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/created`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAllTeamEnvironments(teamID: string) {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.findMany({
|
|
||||||
where: {
|
|
||||||
teamID: teamID,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
T.map(
|
|
||||||
A.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the count of environments for a given team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a count of team envs
|
|
||||||
*/
|
|
||||||
async totalEnvsInTeam(teamID: string) {
|
|
||||||
const envCount = await this.prisma.teamEnvironment.count({
|
|
||||||
where: {
|
|
||||||
teamID: teamID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return envCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
|
||||||
import { Team } from 'src/team/team.model';
|
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
|
||||||
|
|
||||||
@Resolver(() => Team)
|
|
||||||
export class TeamEnvsTeamResolver {
|
|
||||||
constructor(private teamEnvironmentService: TeamEnvironmentsService) {}
|
|
||||||
|
|
||||||
@ResolveField(() => [TeamEnvironment], {
|
|
||||||
description: 'Returns all Team Environments for the given Team',
|
|
||||||
})
|
|
||||||
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
|
|
||||||
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
|
||||||
import { TeamMemberRole } from '../team/team.model';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class TeamInvitation {
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the invite',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'ID of the team the invite is to',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
description: 'UID of the creator of the invite',
|
|
||||||
})
|
|
||||||
creatorUid: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
description: 'Email of the invitee',
|
|
||||||
})
|
|
||||||
inviteeEmail: string;
|
|
||||||
|
|
||||||
@Field(() => TeamMemberRole, {
|
|
||||||
description: 'The role that will be given to the invitee',
|
|
||||||
})
|
|
||||||
inviteeRole: TeamMemberRole;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { MailerModule } from 'src/mailer/mailer.module';
|
|
||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
|
||||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
|
||||||
import { TeamModule } from 'src/team/team.module';
|
|
||||||
import { UserModule } from 'src/user/user.module';
|
|
||||||
import { TeamInvitationResolver } from './team-invitation.resolver';
|
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
|
||||||
import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard';
|
|
||||||
import { TeamInviteViewerGuard } from './team-invite-viewer.guard';
|
|
||||||
import { TeamInviteeGuard } from './team-invitee.guard';
|
|
||||||
import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, TeamModule, PubSubModule, UserModule, MailerModule],
|
|
||||||
providers: [
|
|
||||||
TeamInvitationService,
|
|
||||||
TeamInvitationResolver,
|
|
||||||
TeamTeamInviteExtResolver,
|
|
||||||
TeamInviteeGuard,
|
|
||||||
TeamInviteViewerGuard,
|
|
||||||
TeamInviteTeamOwnerGuard,
|
|
||||||
],
|
|
||||||
exports: [TeamInvitationService],
|
|
||||||
})
|
|
||||||
export class TeamInvitationModule {}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import {
|
|
||||||
Args,
|
|
||||||
ID,
|
|
||||||
Mutation,
|
|
||||||
Parent,
|
|
||||||
Query,
|
|
||||||
ResolveField,
|
|
||||||
Resolver,
|
|
||||||
Subscription,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { TeamInvitation } from './team-invitation.model';
|
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
|
||||||
import { EmailCodec } from 'src/types/Email';
|
|
||||||
import {
|
|
||||||
INVALID_EMAIL,
|
|
||||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import { TeamInviteeGuard } from './team-invitee.guard';
|
|
||||||
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
|
|
||||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
|
||||||
import { TeamInviteViewerGuard } from './team-invite-viewer.guard';
|
|
||||||
import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
|
||||||
@Resolver(() => TeamInvitation)
|
|
||||||
export class TeamInvitationResolver {
|
|
||||||
constructor(
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
private readonly teamInvitationService: TeamInvitationService,
|
|
||||||
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@ResolveField(() => Team, {
|
|
||||||
complexity: 5,
|
|
||||||
description: 'Get the team associated to the invite',
|
|
||||||
})
|
|
||||||
async team(@Parent() teamInvitation: TeamInvitation): Promise<Team> {
|
|
||||||
return pipe(
|
|
||||||
this.teamService.getTeamWithIDTE(teamInvitation.teamID),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => User, {
|
|
||||||
complexity: 5,
|
|
||||||
description: 'Get the creator of the invite',
|
|
||||||
})
|
|
||||||
async creator(@Parent() teamInvitation: TeamInvitation): Promise<User> {
|
|
||||||
const user = await this.userService.findUserById(teamInvitation.creatorUid);
|
|
||||||
if (O.isNone(user)) throwErr(USER_NOT_FOUND);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...user.value,
|
|
||||||
currentGQLSession: JSON.stringify(user.value.currentGQLSession),
|
|
||||||
currentRESTSession: JSON.stringify(user.value.currentRESTSession),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => TeamInvitation, {
|
|
||||||
description:
|
|
||||||
'Gets the Team Invitation with the given ID, or null if not exists',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
|
|
||||||
teamInvitation(
|
|
||||||
@GqlUser() user: User,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteID',
|
|
||||||
description: 'ID of the Team Invitation to lookup',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
inviteID: string,
|
|
||||||
): Promise<TeamInvitation> {
|
|
||||||
return pipe(
|
|
||||||
this.teamInvitationService.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
TE.chainW(
|
|
||||||
TE.fromPredicate(
|
|
||||||
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
|
|
||||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamInvitation, {
|
|
||||||
description: 'Creates a Team Invitation',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
|
||||||
createTeamInvitation(
|
|
||||||
@GqlUser()
|
|
||||||
user: User,
|
|
||||||
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team ID to invite from',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteeEmail',
|
|
||||||
description: 'Email of the user to invite',
|
|
||||||
})
|
|
||||||
inviteeEmail: string,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteeRole',
|
|
||||||
type: () => TeamMemberRole,
|
|
||||||
description: 'Role to be given to the user',
|
|
||||||
})
|
|
||||||
inviteeRole: TeamMemberRole,
|
|
||||||
): Promise<TeamInvitation> {
|
|
||||||
return pipe(
|
|
||||||
TE.Do,
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
TE.bindW('email', () =>
|
|
||||||
pipe(
|
|
||||||
EmailCodec.decode(inviteeEmail),
|
|
||||||
TE.fromEither,
|
|
||||||
TE.mapLeft(() => INVALID_EMAIL),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Validate and get Team
|
|
||||||
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
|
|
||||||
|
|
||||||
// Create team
|
|
||||||
TE.chainW(({ email, team }) =>
|
|
||||||
this.teamInvitationService.createInvitation(
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
email,
|
|
||||||
inviteeRole,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// If failed, throw err (so the message is passed) else return value
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
|
||||||
description: 'Revokes an invitation and deletes it',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
|
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
|
||||||
revokeTeamInvitation(
|
|
||||||
@Args({
|
|
||||||
name: 'inviteID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'ID of the invite to revoke',
|
|
||||||
})
|
|
||||||
inviteID: string,
|
|
||||||
): Promise<true> {
|
|
||||||
return pipe(
|
|
||||||
this.teamInvitationService.revokeInvitation(inviteID),
|
|
||||||
TE.map(() => true as const),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => TeamMember, {
|
|
||||||
description: 'Accept an Invitation',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
|
|
||||||
acceptTeamInvitation(
|
|
||||||
@GqlUser() user: User,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'ID of the Invite to accept',
|
|
||||||
})
|
|
||||||
inviteID: string,
|
|
||||||
): Promise<TeamMember> {
|
|
||||||
return pipe(
|
|
||||||
this.teamInvitationService.acceptInvitation(inviteID, user),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscriptions
|
|
||||||
@Subscription(() => TeamInvitation, {
|
|
||||||
description: 'Listens to when a Team Invitation is added',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
teamInvitationAdded(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'ID of the Team to listen to',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): AsyncIterator<TeamInvitation> {
|
|
||||||
return this.pubsub.asyncIterator(`team/${teamID}/invite_added`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscription(() => ID, {
|
|
||||||
description: 'Listens to when a Team Invitation is removed',
|
|
||||||
resolve: (value) => value,
|
|
||||||
})
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
|
||||||
@RequiresTeamRole(
|
|
||||||
TeamMemberRole.OWNER,
|
|
||||||
TeamMemberRole.EDITOR,
|
|
||||||
TeamMemberRole.VIEWER,
|
|
||||||
)
|
|
||||||
teamInvitationRemoved(
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'ID of the Team to listen to',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): AsyncIterator<string> {
|
|
||||||
return this.pubsub.asyncIterator(`team/${teamID}/invite_removed`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { pipe, flow, constVoid } from 'fp-ts/function';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { Team, TeamMemberRole } from 'src/team/team.model';
|
|
||||||
import { Email } from 'src/types/Email';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
|
||||||
import {
|
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
|
||||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
TEAM_INVITE_MEMBER_HAS_INVITE,
|
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { TeamInvitation } from './team-invitation.model';
|
|
||||||
import { MailerService } from 'src/mailer/mailer.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TeamInvitationService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
private readonly mailerService: MailerService,
|
|
||||||
|
|
||||||
private readonly pubsub: PubSubService,
|
|
||||||
) {
|
|
||||||
this.getInvitation = this.getInvitation.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamInvitation.findUnique({
|
|
||||||
where: {
|
|
||||||
id: inviteID,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TO.fromTask,
|
|
||||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
|
||||||
TO.map((x) => x as TeamInvitation),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInvitationWithEmail(email: Email, team: Team) {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamInvitation.findUnique({
|
|
||||||
where: {
|
|
||||||
teamID_inviteeEmail: {
|
|
||||||
inviteeEmail: email,
|
|
||||||
teamID: team.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TO.fromTask,
|
|
||||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createInvitation(
|
|
||||||
creator: User,
|
|
||||||
team: Team,
|
|
||||||
inviteeEmail: Email,
|
|
||||||
inviteeRole: TeamMemberRole,
|
|
||||||
) {
|
|
||||||
return pipe(
|
|
||||||
// Perform all validation checks
|
|
||||||
TE.sequenceArray([
|
|
||||||
// creator should be a TeamMember
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(team.id, creator.uid),
|
|
||||||
TE.map(constVoid),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Invitee should not be a team member
|
|
||||||
pipe(
|
|
||||||
async () => await this.userService.findUserByEmail(inviteeEmail),
|
|
||||||
TO.foldW(
|
|
||||||
() => TE.right(undefined), // If no user, short circuit to completion
|
|
||||||
(user) =>
|
|
||||||
pipe(
|
|
||||||
// If user is found, check if team member
|
|
||||||
this.teamService.getTeamMemberTE(team.id, user.uid),
|
|
||||||
TE.foldW(
|
|
||||||
() => TE.right(undefined), // Not team-member, this is good
|
|
||||||
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(constVoid),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Should not have an existing invite
|
|
||||||
pipe(
|
|
||||||
this.getInvitationWithEmail(inviteeEmail, team),
|
|
||||||
TE.fromTaskOption(() => null),
|
|
||||||
TE.swap,
|
|
||||||
TE.map(constVoid),
|
|
||||||
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Create the invitation
|
|
||||||
TE.chainTaskK(
|
|
||||||
() => () =>
|
|
||||||
this.prisma.teamInvitation.create({
|
|
||||||
data: {
|
|
||||||
teamID: team.id,
|
|
||||||
inviteeEmail,
|
|
||||||
inviteeRole,
|
|
||||||
creatorUid: creator.uid,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Send email, this is a side effect
|
|
||||||
TE.chainFirstTaskK((invitation) =>
|
|
||||||
pipe(
|
|
||||||
this.mailerService.sendMail(inviteeEmail, {
|
|
||||||
template: 'team-invitation',
|
|
||||||
variables: {
|
|
||||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
|
||||||
action_url: `https://hoppscotch.io/join-team?id=${invitation.id}`,
|
|
||||||
invite_team_name: team.name,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Send PubSub topic
|
|
||||||
TE.chainFirstTaskK((invitation) =>
|
|
||||||
TE.fromTask(async () => {
|
|
||||||
const inv: TeamInvitation = {
|
|
||||||
id: invitation.id,
|
|
||||||
teamID: invitation.teamID,
|
|
||||||
creatorUid: invitation.creatorUid,
|
|
||||||
inviteeEmail: invitation.inviteeEmail,
|
|
||||||
inviteeRole: TeamMemberRole[invitation.inviteeRole],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Map to model type
|
|
||||||
TE.map((x) => x as TeamInvitation),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeInvitation(inviteID: string) {
|
|
||||||
return pipe(
|
|
||||||
// Make sure invite exists
|
|
||||||
this.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
|
|
||||||
// Delete team invitation
|
|
||||||
TE.chainTaskK(
|
|
||||||
() => () =>
|
|
||||||
this.prisma.teamInvitation.delete({
|
|
||||||
where: {
|
|
||||||
id: inviteID,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Emit Pubsub Event
|
|
||||||
TE.chainFirst((invitation) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team/${invitation.teamID}/invite_removed`,
|
|
||||||
invitation.id,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// We are not returning anything
|
|
||||||
TE.map(constVoid),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllInvitationsInTeam(team: Team) {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamInvitation.findMany({
|
|
||||||
where: {
|
|
||||||
teamID: team.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
T.map((x) => x as TeamInvitation[]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptInvitation(inviteID: string, acceptedBy: User) {
|
|
||||||
return pipe(
|
|
||||||
TE.Do,
|
|
||||||
|
|
||||||
// First get the invitation
|
|
||||||
TE.bindW('invitation', () =>
|
|
||||||
pipe(
|
|
||||||
this.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Validation checks
|
|
||||||
TE.chainFirstW(({ invitation }) =>
|
|
||||||
TE.sequenceArray([
|
|
||||||
// Make sure the invited user is not part of the team
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
|
|
||||||
TE.swap,
|
|
||||||
TE.bimap(
|
|
||||||
() => TEAM_INVITE_ALREADY_MEMBER,
|
|
||||||
constVoid, // The return type is ignored
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Make sure the invited user and accepting user has the same email
|
|
||||||
pipe(
|
|
||||||
undefined,
|
|
||||||
TE.fromPredicate(
|
|
||||||
(a) => acceptedBy.email === invitation.inviteeEmail,
|
|
||||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Add the team member
|
|
||||||
// TODO: Somehow bring subscriptions to this ?
|
|
||||||
TE.bindW('teamMember', ({ invitation }) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.teamService.addMemberToTeam(
|
|
||||||
invitation.teamID,
|
|
||||||
acceptedBy.uid,
|
|
||||||
invitation.inviteeRole,
|
|
||||||
),
|
|
||||||
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
|
|
||||||
|
|
||||||
TE.map(({ teamMember }) => teamMember),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the count invitations for a given team.
|
|
||||||
* @param teamID team id
|
|
||||||
* @returns a count team invitations for a team
|
|
||||||
*/
|
|
||||||
async getAllTeamInvitations(teamID: string) {
|
|
||||||
const invitations = await this.prisma.teamInvitation.findMany({
|
|
||||||
where: {
|
|
||||||
teamID: teamID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return invitations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
||||||
import {
|
|
||||||
BUG_AUTH_NO_USER_CTX,
|
|
||||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
|
||||||
TEAM_NOT_REQUIRED_ROLE,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { throwErr } from 'src/utils';
|
|
||||||
import { TeamMemberRole } from 'src/team/team.model';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TeamInviteTeamOwnerGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly teamService: TeamService,
|
|
||||||
private readonly teamInviteService: TeamInvitationService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
return pipe(
|
|
||||||
TE.Do,
|
|
||||||
|
|
||||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
|
||||||
|
|
||||||
// Get the invite
|
|
||||||
TE.bindW('invite', ({ gqlCtx }) =>
|
|
||||||
pipe(
|
|
||||||
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
|
|
||||||
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
|
|
||||||
TE.chainW((inviteID) =>
|
|
||||||
pipe(
|
|
||||||
this.teamInviteService.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('user', ({ gqlCtx }) =>
|
|
||||||
pipe(
|
|
||||||
gqlCtx.getContext().req.user,
|
|
||||||
O.fromNullable,
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('userMember', ({ invite, user }) =>
|
|
||||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.chainW(
|
|
||||||
TE.fromPredicate(
|
|
||||||
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
|
|
||||||
() => TEAM_NOT_REQUIRED_ROLE,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.fold(
|
|
||||||
(err) => throwErr(err),
|
|
||||||
() => T.of(true),
|
|
||||||
),
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user