Compare commits

..

1 Commits

Author SHA1 Message Date
Liyas Thomas
c11b592543 feat: ability to export a single environment 2022-12-05 18:11:05 +05:30
699 changed files with 14706 additions and 60987 deletions

104
.dockerignore Normal file
View 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

View File

@@ -1,59 +1,31 @@
#-----------------------Backend Config------------------------------#
# Prisma Config
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
# Google Analytics ID
VITE_GA_ID=UA-61422507-4
# Auth Tokens Config
JWT_SECRET="secret1233"
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------------------------------#
# Google Tag Manager ID
VITE_GTM_ID=GTM-NMKVBMV
# 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
VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000
VITE_ADMIN_URL=http://localhost:3100
VITE_BASE_URL=https://hoppscotch.io
VITE_SHORTCODE_BASE_URL=https://hopp.sh
# Backend URLs
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
VITE_BACKEND_WS_URL=wss://localhost:3170/graphql
VITE_BACKEND_API_URL=http://localhost:3170/v1
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
# Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
# Sentry (Optional)
# VITE_SENTRY_DSN: <Sentry DSN here>
# 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>

View File

@@ -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:
push:
branches: [main]
branches: [ main ]
pull_request:
branches: [main]
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
# ┌───────────── minute (0 - 59)
# │ ┌───────────── 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'
- cron: '39 7 * * 2'
jobs:
analyze:
name: Analyze
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
runs-on: ubuntu-latest
permissions:
# required for all workflows
security-events: write
# only required for workflows in private repositories
actions: 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:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
languages: ${{ matrix.language }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
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).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# 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)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# 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
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following
# three lines and modify them (or add more) to build your code if your
# project uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

48
.github/workflows/deploy-netlify.yml vendored Normal file
View 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 }}

View 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
View 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

View 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
View 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 }}

View File

@@ -2,13 +2,12 @@ name: Node.js CI
on:
push:
branches: [main, staging]
branches: [main]
pull_request:
branches: [main, staging]
branches: [main]
jobs:
test:
name: Test
build:
runs-on: ubuntu-latest
strategy:
@@ -16,22 +15,22 @@ jobs:
node-version: ["lts/*"]
steps:
- name: Checkout
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup environment
- name: Setup Environment
run: mv .env.example .env
- name: Setup pnpm
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7
run_install: true
- name: Setup node
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Run tests

3
.gitignore vendored
View File

@@ -171,6 +171,3 @@ tests/*/videos
# PNPM
.pnpm-store
# GQL SDL generated for the frontends
gql-gen/

View File

@@ -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
View 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"]

View File

@@ -36,14 +36,14 @@
<p>
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
<img
src="./packages/hoppscotch-common/public/images/banner-light.png"
src="./packages/hoppscotch-app/public/images/banner-light.png"
alt="Hoppscotch"
width="100%"
/>
</a>
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
<img
src="./packages/hoppscotch-common/public/images/banner-dark.png"
src="./packages/hoppscotch-app/public/images/banner-dark.png"
alt="Hoppscotch"
width="100%"
/>
@@ -161,7 +161,7 @@ _Collections are synced with cloud / local session storage_
- Access APIs served in non-HTTPS (`http://`) endpoints
- 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.
@@ -178,7 +178,7 @@ _Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/h
⌨️ **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.
@@ -279,7 +279,45 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
## **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** &nbsp; [![hoppscotch/hoppscotch](https://img.shields.io/docker/pulls/hoppscotch/hoppscotch?style=social)](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**

View File

@@ -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"
services:
# This service runs the backend app in the port 3170
hoppscotch-backend:
container_name: hoppscotch-backend
web:
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
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:
- ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
- hoppscotch-db
- "./.hoppscotch:/app/.hoppscotch"
- "./assets:/app/assets"
- "./directives:/app/directives"
- "./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:
- "3170: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"
- "3000:3000"
environment:
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
HOST: 0.0.0.0
command: "pnpm run dev"

View File

@@ -10,7 +10,7 @@
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
[[redirects]]

View File

@@ -9,15 +9,13 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky install",
"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",
"start": "http-server packages/hoppscotch-web/dist -p 3000",
"lint": "pnpm -r do-lint",
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test",
"generate-ui": "pnpm -r do-build-ui"
"test": "pnpm -r do-test"
},
"workspaces": [
"./packages/*"
@@ -30,7 +28,6 @@
"@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1",
"@types/node": "^17.0.24",
"cross-env": "^7.0.3",
"http-server": "^14.1.1"
}
}

View File

@@ -1 +0,0 @@
./node_modules

View File

@@ -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"
},
};

View File

@@ -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/

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -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

View File

@@ -1 +0,0 @@
import '@relmify/jest-fp-ts';

View File

@@ -1 +0,0 @@
require('@relmify/jest-fp-ts');

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"**/*.hbs"
],
"watchAssets": true
}
}

View File

@@ -1,121 +0,0 @@
{
"name": "hoppscotch-backend",
"version": "2023.4.1",
"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"
}
}
}

View File

@@ -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;

View File

@@ -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"

View File

@@ -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
}

View File

@@ -1,4 +0,0 @@
import { ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Admin {}

View File

@@ -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 {}

View File

@@ -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`);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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;
},
);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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 });
});
});

View File

@@ -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}/magic-link?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 });
}
}

View File

@@ -1,4 +0,0 @@
// Inputs to initiate Magic-Link auth flow
export class SignInMagicDto {
email: string;
}

View File

@@ -1,5 +0,0 @@
// Inputs to verify and sign a user in via magic-link
export class VerifyMagicDto {
deviceIdentifier: string;
token: string;
}

View File

@@ -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,
},
};
}
}

View File

@@ -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,
},
};
}
}

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -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,
},
};
}
}

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -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],
};
};

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
},
);

View File

@@ -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'];
},
);

View File

@@ -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;

View File

@@ -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}`,
);
}
}

View File

@@ -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;
}
}

View File

@@ -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 };
}
}

View File

@@ -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
}
}

View File

@@ -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;
};
};

View File

@@ -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 {}

View File

@@ -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);
}
}
}

View File

@@ -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 youre 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">&copy; 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>

View File

@@ -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 youre 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">&copy; 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>

View File

@@ -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();
}

View File

@@ -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);
},
};
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common/decorators';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -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();
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { PubSubService } from './pubsub.service';
@Module({
providers: [PubSubService],
exports: [PubSubService],
})
export class PubSubModule {}

View File

@@ -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);
}
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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`);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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`);
}
}

View File

@@ -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;
}
}

View File

@@ -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),
)();
}
}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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`);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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)();
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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`);
}
}

View File

@@ -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;
}
}

View File

@@ -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