Compare commits

..

1 Commits

Author SHA1 Message Date
Nivedin
ca54c8498c feat: prettify XML response 2023-05-29 20:18:02 -04:00
761 changed files with 20246 additions and 61407 deletions

View File

@@ -5,5 +5,5 @@
"features": {
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
},
"postCreateCommand": "cp .env.example .env && pnpm i"
"postCreateCommand": "mv .env.example .env && pnpm i"
}

View File

@@ -1,2 +0,0 @@
node_modules
**/*/node_modules

View File

@@ -12,8 +12,7 @@ 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"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
# Google Auth Config
GOOGLE_CLIENT_ID="************************************************"
@@ -32,7 +31,6 @@ MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
@@ -53,12 +51,9 @@ VITE_ADMIN_URL=http://localhost:3100
# Backend URLs
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
VITE_BACKEND_WS_URL=ws://localhost:3170/graphql
VITE_BACKEND_WS_URL=wss://localhost:3170/graphql
VITE_BACKEND_API_URL=http://localhost:3170/v1
# 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
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false

View File

@@ -1,84 +0,0 @@
name: "Push containers to Docker Hub on release"
on:
push:
tags:
- '*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: cp .env.example .env
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push the backend container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: backend
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the frontend container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: app
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the admin dashboard container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: sh_admin
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the AIO container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: aio
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}

View File

@@ -2,9 +2,9 @@ name: Node.js CI
on:
push:
branches: [main, staging, "release/**"]
branches: [main, staging]
pull_request:
branches: [main, staging, "release/**"]
branches: [main, staging]
jobs:
test:

View File

@@ -1,42 +0,0 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 8
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -1,8 +1,3 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2
semi: false
}

14
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"recommendations": [
"antfu.iconify",
"vue.volar",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"csstools.postcss",
"folke.vscode-monorepo-workspace"
],
"unwantedRecommendations": [
"octref.vetur"
]
}

View File

@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
@@ -22,17 +22,17 @@ community include:
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
@@ -106,27 +106,23 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

190
README.md
View File

@@ -2,18 +2,23 @@
<a href="https://hoppscotch.io">
<img
src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch"
alt="Hoppscotch Logo"
height="64"
/>
</a>
<h3>
<br />
<p>
<h3>
<b>
Hoppscotch
</b>
</h3>
</p>
<p>
<b>
Hoppscotch
Open source API development ecosystem
</b>
</h3>
<b>
Open Source API Development Ecosystem
</b>
</p>
<p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
@@ -29,18 +34,23 @@
</p>
<br />
<p>
<a href="https://hoppscotch.io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
</picture>
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
<img
src="./packages/hoppscotch-common/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"
alt="Hoppscotch"
width="100%"
/>
</a>
</p>
</div>
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
#### **Support**
[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions)
@@ -49,9 +59,9 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
❤️ **Lightweight:** Crafted with minimalistic UI design.
⚡️ **Fast:** Send requests and get responses in real time.
⚡️ **Fast:** Send requests and get/copy responses in real-time.
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed.
**HTTP Methods**
- `GET` - Requests retrieve resource information
- `POST` - The server creates a new entry in a database
@@ -64,15 +74,17 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
- `TRACE` - Performs a message loop-back test along the path to the target resource
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
- Choose a theme: System preference, Light, Dark, and Black
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
**Theming**
- Choose a theme: System (default), Light, Dark, and Black
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
- Distraction-free Zen mode
_Customized themes are synced with your cloud/local session._
_Customized themes are synced with cloud / local session_
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device.
🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device.
- Instant loading with Service Workers
- Offline support
@@ -95,7 +107,7 @@ _Customized themes are synced with your cloud/local session._
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
🌩 **Socket.IO:** Send and Receive data with the SocketIO server.
🌩 **Socket.IO:** Send and Receive data with SocketIO server.
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
@@ -115,7 +127,7 @@ _Customized themes are synced with your cloud/local session._
- OAuth 2.0
- OIDC Access Token/PKCE
📢 **Headers:** Describes the format the body of your request is being sent in.
📢 **Headers:** Describes the format the body of your request is being sent as.
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
@@ -125,14 +137,14 @@ _Customized themes are synced with your cloud/local session._
- FormData, JSON, and many more
- Toggle between key-value and RAW input parameter list
📮 **Response:** Contains the status line, headers, and the message/response body.
👋 **Response:** Contains the status line, headers, and the message/response body.
- Copy the response to the clipboard
- Download the response as a file
- Copy response to clipboard
- Download response as a file
- View response headers
- View raw and preview HTML, image, JSON, and XML responses
- View raw and preview of HTML, image, JSON, XML responses
**History:** Request entries are synced with your cloud/local session storage.
**History:** Request entries are synced with cloud / local session storage to restore with a single click.
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
@@ -140,32 +152,7 @@ _Customized themes are synced with your cloud/local session._
- Nested folders
- Export and import as a file or GitHub gist
_Collections are synced with your cloud/local session storage._
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
👨‍👩‍👧‍👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
- Create unlimited teams
- Create unlimited shared collections
- Create unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
- Create unlimited workspaces
- Switch between personal and team workspaces
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
_Collections are synced with cloud / local session storage_
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
@@ -174,31 +161,60 @@ _Collections are synced with your 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/support/privacy)**_
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
1. Add your requests to Collections and Folders
2. Export Collections and easily share your APIs with the rest of your team
3. Import Collections and Generate Documentation on-the-go
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌎 **i18n:** Experience the app in your language.
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
📦 **Add-ons:** Official add-ons for hoppscotch.
**Sign in with:**
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fixes `CORS` issues.**
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
**Sign in with**
- GitHub
- Google
- Microsoft
- Email
- SSO (Single Sign-On)[^EE]
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
**Synchronize your data**
- Workspaces
- History
- Collections
- Environments
- Settings
**Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
**Post-Request Tests β:** Write tests associated with a request that is executed after the request's response.
- Check the status code as an integer
- Filter response headers
@@ -206,7 +222,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
- Set environment variables
- Write JavaScript code
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts.
- Unlimited environments and variables
- Initialize through the pre-request script
@@ -225,31 +241,22 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
</details>
👨‍👩‍👧‍👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
- Unlimited teams
- Unlimited shared collections
- Unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
- Entries are separated by newline
- Keys and values are separated by `:`
- Prepend `#` to any row you want to add but keep disabled
🎛️ **Admin dashboard:** Manage your team and invite members.
- Insights
- Manage users
- Manage teams
📦 **Add-ons:** Official add-ons for hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hopp-cli)** - Command-line interface for Hoppscotch.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fix `CORS` issues.**
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
**For more features, please read our [documentation](https://docs.hoppscotch.io).**
## **Demo**
@@ -261,9 +268,18 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
2. Click "Send" to simulate the request
3. View the response
## **Built with**
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
- [TypeScript](https://www.typescriptlang.org)
- [Vue](https://vuejs.org)
- [Vite](https://vitejs.dev)
## **Developing**
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
## **Contributing**
@@ -281,7 +297,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
## **Authors**
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md).
<div align="center">
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
@@ -293,6 +309,4 @@ This project owes its existence to the collective efforts of all those who contr
## **License**
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) see the [`LICENSE`](LICENSE) file for details.
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.

View File

@@ -2,9 +2,8 @@
This document outlines security procedures and general policies for the Hoppscotch project.
- [Security Policy](#security-policy)
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [Incident response process](#incident-response-process)
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
3. [Incident response process](#incident-response-process)
## Reporting a security vulnerability

View File

@@ -9,24 +9,26 @@ Before you start working on a new language, please look through the [open pull r
if there is no existing translation, you can create a new one by following these steps:
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
2. **Checkout the `main` branch for latest translations.**
3. **Create a new branch for your translation with base branch `main`.**
2. **Checkout the `i18n` branch for latest translations.**
3. **Create a new branch for your translation with base branch `i18n`.**
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
6. **Translate the strings in the target language file.**
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
8. **Save and commit changes.**
8. **Save & commit changes.**
9. **Send a pull request.**
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
`i18n` branch will be merged into `main` branch once every week.
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
## Updating a translation
### Corrections
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue.
### Broken links

View File

@@ -1,19 +0,0 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin-multiport-setup
file_server
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
respond 404
}

View File

@@ -1,37 +0,0 @@
:3000 {
respond 404
}
:3100 {
respond 404
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
# Serve the `selfhost-web` SPA by default
root * /site/selfhost-web
file_server
handle_path /admin* {
root * /site/sh-admin-subpath-access
file_server
# Ensures any non-existent file in the server is routed to the SPA
try_files {path} /
}
# Handle requests under `/backend*` path
handle_path /backend* {
reverse_proxy localhost:8080
}
# Catch-all route for unknown paths, serves `selfhost-web` SPA
handle {
root * /site/selfhost-web
file_server
try_files {path} /
}
}

View File

@@ -1,73 +0,0 @@
#!/usr/local/bin/node
// @ts-check
import { execSync, spawn } from "child_process"
import fs from "fs"
import process from "process"
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
childProcess.stdout.on('data', (data) => {
const output = data.toString().trim().split('\n');
output.forEach((line) => {
console.log(`${prefix} | ${line}`);
});
});
childProcess.stderr.on('data', (data) => {
const error = data.toString().trim().split('\n');
error.forEach((line) => {
console.error(`${prefix} | ${line}`);
});
});
childProcess.on('close', (code) => {
console.log(`${prefix} Child process exited with code ${code}`);
});
childProcess.on('error', (stuff) => {
console.log("error")
console.log(stuff)
})
return childProcess
}
const envFileContent = Object.entries(process.env)
.filter(([env]) => env.startsWith("VITE_"))
.map(([env, val]) => `${env}=${
(val.startsWith("\"") && val.endsWith("\""))
? val
: `"${val}"`
}`)
.join("\n")
fs.writeFileSync("build.env", envFileContent)
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
fs.rmSync("build.env")
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
caddyProcess.on("exit", (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`)
process.exit(code)
})
backendProcess.on("exit", (code) => {
console.log(`Exiting process because Backend Server exited with code ${code}`)
process.exit(code)
})
process.on('SIGINT', () => {
console.log("SIGINT received, exiting...")
caddyProcess.kill("SIGINT")
backendProcess.kill("SIGINT")
process.exit(0)
})

View File

@@ -7,107 +7,6 @@ services:
# This service runs the backend app in the port 3170
hoppscotch-backend:
container_name: hoppscotch-backend
build:
dockerfile: prod.Dockerfile
context: .
target: backend
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=8080
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3180:80"
- "3170:3170"
# 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: prod.Dockerfile
context: .
target: app
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3080:80"
- "3000:3000"
# 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: prod.Dockerfile
context: .
target: sh_admin
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3280:80"
- "3100:3100"
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
container_name: hoppscotch-aio
build:
dockerfile: prod.Dockerfile
context: .
target: aio
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3080:80"
# 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:15
ports:
- "5432:5432"
user: postgres
environment:
# The default user defined by the docker image
POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10
# All the services listed below are deprececated
hoppscotch-old-backend:
container_name: hoppscotch-old-backend
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
@@ -120,35 +19,53 @@ services:
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:
condition: service_healthy
- hoppscotch-db
ports:
- "3170:3000"
hoppscotch-old-app:
container_name: hoppscotch-old-app
# 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-old-backend
- hoppscotch-backend
ports:
- "3000:8080"
hoppscotch-old-sh-admin:
container_name: hoppscotch-old-sh-admin
# 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-old-backend
- hoppscotch-backend
ports:
- "3100:8080"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db:
image: postgres
ports:
- "5432:5432"
environment:
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch

View File

@@ -1,14 +0,0 @@
#!/bin/bash
curlCheck() {
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
echo "URL request failed!"
exit 1
else
echo "URL request succeeded!"
fi
}
curlCheck "http://localhost:3000"
curlCheck "http://localhost:3100"
curlCheck "http://localhost:3170/ping"

View File

@@ -11,7 +11,7 @@
"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-selfhost-web/dist -p 3000",
"start": "http-server packages/hoppscotch-web/dist -p 3000",
"lint": "pnpm -r do-lint",
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
@@ -22,22 +22,15 @@
"workspaces": [
"./packages/*"
],
"dependencies": {
"husky": "^7.0.4",
"lint-staged": "^12.3.8"
},
"devDependencies": {
"@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1",
"@types/node": "17.0.27",
"@types/node": "^17.0.24",
"cross-env": "^7.0.3",
"http-server": "^14.1.1",
"husky": "^7.0.4",
"lint-staged": "12.4.0"
},
"pnpm": {
"packageExtensions": {
"httpsnippet@^3.0.1": {
"peerDependencies": {
"ajv": "6.12.3"
}
}
}
"http-server": "^14.1.1"
}
}

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "6.9.0",
"@lezer/highlight": "1.1.4",
"@lezer/lr": "^1.3.13"
"@codemirror/language": "^6.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.2.0"
},
"devDependencies": {
"@lezer/generator": "^1.5.1",
"@lezer/generator": "^1.1.0",
"mocha": "^9.2.2",
"rollup": "^3.29.3",
"rollup-plugin-dts": "^6.0.2",
"rollup-plugin-ts": "^3.4.5",
"typescript": "^5.2.2"
"rollup": "^2.70.2",
"rollup-plugin-dts": "^4.2.1",
"rollup-plugin-ts": "^2.0.7",
"typescript": "^4.6.3"
}
}

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,141 +0,0 @@
# dioc
A small and lightweight dependency injection / inversion of control system.
### About
`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon.
### Demo
```ts
import { Service, Container } from "dioc"
// Here is a simple service, which you can define by extending the Service class
// and providing an ID static field (of type string)
export class PersistenceService extends Service {
// This should be unique for each container
public static ID = "PERSISTENCE_SERVICE"
public read(key: string): string | undefined {
// ...
}
public write(key: string, value: string) {
// ...
}
}
type TodoServiceEvent =
| { type: "TODO_CREATED"; index: number }
| { type: "TODO_DELETED"; index: number }
// Services have a built in event system
// Define the generic argument to say what are the possible emitted values
export class TodoService extends Service<TodoServiceEvent> {
public static ID = "TODO_SERVICE"
// Inject persistence service into this service
private readonly persistence = this.bind(PersistenceService)
public todos = []
// Service constructors cannot have arguments
constructor() {
super()
this.todos = JSON.parse(this.persistence.read("todos") ?? "[]")
}
public addTodo(text: string) {
// ...
// You can access services via the bound fields
this.persistence.write("todos", JSON.stringify(this.todos))
// This is how you emit an event
this.emit({
type: "TODO_CREATED",
index,
})
}
public removeTodo(index: number) {
// ...
this.emit({
type: "TODO_DELETED",
index,
})
}
}
// Services need a container to run in
const container = new Container()
// You can initialize and get services using Container#bind
// It will automatically initialize the service (and its dependencies)
const todoService = container.bind(TodoService) // Returns an instance of TodoService
```
### Demo (Unit Test)
`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container.
```ts
import { TestContainer } from "dioc/testing"
import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet
import { describe, it, expect, vi } from "vitest"
describe("TodoService", () => {
it("addTodo writes to persistence", () => {
const container = new TestContainer()
const writeFn = vi.fn()
// The first parameter is the service to mock and the second parameter
// is the mocked service fields and functions
container.bindMock(PersistenceService, {
read: () => undefined, // Not really important for this test
write: writeFn,
})
// the peristence service bind in TodoService will now use the
// above defined mocked implementation
const todoService = container.bind(TodoService)
todoService.addTodo("sup")
expect(writeFn).toHaveBeenCalledOnce()
expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"]))
})
})
```
### Demo (Vue)
`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services.
In the app entry point:
```ts
import { createApp } from "vue"
import { diocPlugin } from "dioc/vue"
const app = createApp()
app.use(diocPlugin, {
container: new Container(), // You can pass in the container you want to provide to the components here
})
```
In your Vue components:
```vue
<script setup>
import { TodoService } from "./demo.ts" // The above demo
import { useService } from "dioc/vue"
const todoService = useService(TodoService) // Returns an instance of the TodoService class
</script>
```

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/main.d.ts"
export * from "./dist/main.d.ts"

View File

@@ -1,147 +0,0 @@
import { Service } from "./service"
import { Observable, Subject } from 'rxjs'
/**
* Stores the current container instance in the current operating context.
*
* NOTE: This should not be used outside of dioc library code
*/
export let currentContainer: Container | null = null
/**
* The events emitted by the container
*
* `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service
* `SERVICE_INIT` - emitted when a service is initialized
*/
export type ContainerEvent =
| {
type: 'SERVICE_BIND';
/** The Service ID of the service being bounded (the dependency) */
boundeeID: string;
/**
* The Service ID of the bounder that is binding the boundee (the dependent)
*
* NOTE: This will be undefined if the service is bound directly to the container
*/
bounderID: string | undefined
}
| {
type: 'SERVICE_INIT';
/** The Service ID of the service being initialized */
serviceID: string
}
/**
* The dependency injection container, allows for services to be initialized and maintains the dependency trees.
*/
export class Container {
/** Used during the `bind` operation to detect circular dependencies */
private bindStack: string[] = []
/** The map of bound services to their IDs */
protected boundMap = new Map<string, Service<unknown>>()
/** The RxJS observable representing the event stream */
protected event$ = new Subject<ContainerEvent>()
/**
* Returns whether a container has the given service bound
* @param service The service to check for
*/
public hasBound<
T extends typeof Service<any> & { ID: string }
>(service: T): boolean {
return this.boundMap.has(service.ID)
}
/**
* Returns the service bound to the container with the given ID or if not found, undefined.
*
* NOTE: This is an advanced method and should not be used as much as possible.
*
* @param serviceID The ID of the service to get
*/
public getBoundServiceWithID(serviceID: string): Service<unknown> | undefined {
return this.boundMap.get(serviceID)
}
/**
* Binds a service to the container. This is equivalent to marking a service as a dependency.
* @param service The class reference of a service to bind
* @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined)
*/
public bind<T extends typeof Service<any> & { ID: string }>(
service: T,
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
): InstanceType<T> {
// We need to store the current container in a variable so that we can restore it after the bind operation
const oldCurrentContainer = currentContainer;
currentContainer = this;
// If the service is already bound, return the existing instance
if (this.hasBound(service)) {
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container
})
return this.boundMap.get(service.ID) as InstanceType<T> // Casted as InstanceType<T> because service IDs and types are expected to match
}
// Detect circular dependency and throw error
if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) {
const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}`
throw new Error(`Circular dependency detected.\nChain: ${circularServices}`)
}
// Push the service ID onto the bind stack to detect circular dependencies
this.bindStack.push(service.ID)
// Initialize the service and emit events
// NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract
const instance: Service<any> = new (service as any)()
this.boundMap.set(service.ID, instance)
this.bindStack.pop()
this.event$.next({
type: 'SERVICE_INIT',
serviceID: service.ID,
})
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID
})
// Restore the current container
currentContainer = oldCurrentContainer;
// We expect the return type to match the service definition
return instance as InstanceType<T>
}
/**
* Returns an iterator of the currently bound service IDs and their instances
*/
public getBoundServices(): IterableIterator<[string, Service<any>]> {
return this.boundMap.entries()
}
/**
* Returns the public container event stream
*/
public getEventStream(): Observable<ContainerEvent> {
return this.event$.asObservable()
}
}

View File

@@ -1,2 +0,0 @@
export * from "./container"
export * from "./service"

View File

@@ -1,65 +0,0 @@
import { Observable, Subject } from 'rxjs'
import { Container, currentContainer } from './container'
/**
* A Dioc service that can bound to a container and can bind dependency services.
*
* NOTE: Services cannot have a constructor that takes arguments.
*
* @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams
*/
export abstract class Service<EventDef = {}> {
/**
* The internal event stream of the service
*/
private event$ = new Subject<EventDef>()
/** The container the service is bound to */
#container: Container
constructor() {
if (!currentContainer) {
throw new Error(
`Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })`
)
}
this.#container = currentContainer
}
/**
* Binds a dependency service into this service.
* @param service The class reference of the service to bind
*/
protected bind<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
if (!currentContainer) {
throw new Error('No currentContainer defined.')
}
return currentContainer.bind(service, this.constructor as typeof Service<any> & { ID: string })
}
/**
* Returns the container the service is bound to
*/
protected getContainer(): Container {
return this.#container
}
/**
* Emits an event on the service's event stream
* @param event The event to emit
*/
protected emit(event: EventDef) {
this.event$.next(event)
}
/**
* Returns the event stream of the service
*/
public getEventStream(): Observable<EventDef> {
return this.event$.asObservable()
}
}

View File

@@ -1,33 +0,0 @@
import { Container, Service } from "./main";
/**
* A container that can be used for writing tests, contains additional methods
* for binding suitable for writing tests. (see `bindMock`).
*/
export class TestContainer extends Container {
/**
* Binds a mock service to the container.
*
* @param service
* @param mock
*/
public bindMock<
T extends typeof Service<any> & { ID: string },
U extends Partial<InstanceType<T>>
>(service: T, mock: U): U {
if (this.boundMap.has(service.ID)) {
throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`)
}
this.boundMap.set(service.ID, mock as any)
this.event$.next({
type: "SERVICE_BIND",
boundeeID: service.ID,
bounderID: undefined,
})
return mock
}
}

View File

@@ -1,34 +0,0 @@
import { Plugin, inject } from "vue"
import { Container } from "./container"
import { Service } from "./service"
const VUE_CONTAINER_KEY = Symbol()
// TODO: Some Vue version issue with plugin generics is breaking type checking
/**
* The Vue Dioc Plugin, this allows the composables to work and access the container
*
* NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh)
*/
export const diocPlugin: Plugin = {
install(app, { container }) {
app.provide(VUE_CONTAINER_KEY, container)
}
}
/**
* A composable that binds a service to a Vue Component
*
* @param service The class reference of the service to bind
*/
export function useService<
T extends typeof Service<any> & { ID: string }
>(service: T): InstanceType<T> {
const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null
if (!container) {
throw new Error("Container not found, did you forget to install the dioc plugin?")
}
return container.bind(service)
}

View File

@@ -1,54 +0,0 @@
{
"name": "dioc",
"private": true,
"version": "0.1.0",
"type": "module",
"files": [
"dist",
"index.d.ts"
],
"main": "./dist/counter.umd.cjs",
"module": "./dist/counter.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./dist/main.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"./vue": {
"types": "./dist/vue.d.ts",
"require": "./dist/vue.cjs",
"import": "./dist/vue.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"require": "./dist/testing.cjs",
"import": "./dist/testing.js"
}
},
"scripts": {
"dev": "vite",
"build": "vite build && tsc --emitDeclarationOnly",
"prepare": "pnpm run build",
"test": "vitest run",
"do-test": "pnpm run test",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vitest": "^0.29.3"
},
"dependencies": {
"rxjs": "^7.8.1"
},
"peerDependencies": {
"vue": "^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
}

View File

@@ -1,262 +0,0 @@
import { it, expect, describe, vi } from "vitest"
import { Service } from "../lib/service"
import { Container, currentContainer, ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
}
describe("Container", () => {
describe("getBoundServiceWithID", () => {
it("returns the service instance if it is bound to the container", () => {
const container = new Container()
const service = container.bind(TestServiceA)
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service)
})
it("returns undefined if the service is not bound to the container", () => {
const container = new Container()
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined()
})
})
describe("bind", () => {
it("correctly binds the service to it", () => {
const container = new Container()
const service = container.bind(TestServiceA)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(service.getContainer()).toBe(container)
})
it("after bind, the current container is set back to its previous value", () => {
const originalValue = currentContainer
const container = new Container()
container.bind(TestServiceA)
expect(currentContainer).toBe(originalValue)
})
it("dependent services are registered in the same container", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(serviceB.serviceA.getContainer()).toBe(container)
})
it("binding an already initialized service returns the initialized instance (services are singletons)", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceA2 = container.bind(TestServiceA)
expect(serviceA).toBe(serviceA2)
})
it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceA = container.bind(TestServiceA)
expect(serviceB.serviceA).toBe(serviceA)
})
it("binding an initialized service as a dependency returns the same instance", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
const instance = container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
})
it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("the bind event emitted has the correct bounderID when the service is bound to another service", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
// We only care about the bind event of TestServiceA
if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
})
})
describe("hasBound", () => {
it("returns true if the given service is bound to the container", () => {
const container = new Container()
container.bind(TestServiceA)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
it("returns false if the given service is not bound to the container", () => {
const container = new Container()
expect(container.hasBound(TestServiceA)).toEqual(false)
})
it("returns true when the service is bound because it is a dependency of another service", () => {
const container = new Container()
container.bind(TestServiceB)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
})
describe("getEventStream", () => {
it("returns an observable which emits events correctly when services are initialized", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceB.ID,
})
})
it("returns an observable which emits events correctly when services are bound", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceB.ID,
bounderID: undefined,
})
})
})
describe("getBoundServices", () => {
it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => {
const container = new Container()
const instanceB = container.bind(TestServiceB)
const instanceA = instanceB.serviceA
expect(Array.from(container.getBoundServices())).toEqual([
[TestServiceA.ID, instanceA],
[TestServiceB.ID, instanceB],
])
})
it("returns an empty iterator if no services are bound", () => {
const container = new Container()
expect(Array.from(container.getBoundServices())).toEqual([])
})
})
})

View File

@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { Service, Container } from "../lib/main"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service<"test"> {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
public emitTestEvent() {
this.emit("test")
}
}
describe("Service", () => {
describe("constructor", () => {
it("throws an error if the service is initialized without a container", () => {
expect(() => new TestServiceA()).toThrowError(
"Tried to initialize service with no container (ID: TestServiceA)"
)
})
})
describe("bind", () => {
it("correctly binds the dependency service using the container", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
})
describe("getContainer", () => {
it("returns the container the service is bound to", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
// @ts-expect-error getContainer is a protected member, we are just using it to help with testing
expect(serviceA.getContainer()).toBe(container)
})
})
describe("getEventStream", () => {
it("returns the valid event stream of the service", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceFunc = vi.fn()
serviceB.getEventStream().subscribe(serviceFunc)
serviceB.emitTestEvent()
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith("test")
})
})
})

View File

@@ -1,92 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { TestContainer } from "../lib/testing"
import { Service } from "../lib/service"
import { ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
public test() {
return "real"
}
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// declared public to help with testing
public readonly serviceA = this.bind(TestServiceA)
public test() {
return this.serviceA.test()
}
}
describe("TestContainer", () => {
describe("bindMock", () => {
it("returns the fake service defined", () => {
const container = new TestContainer()
const fakeService = {
test: () => "fake",
}
const result = container.bindMock(TestServiceA, fakeService)
expect(result).toBe(fakeService)
})
it("new services bound to the container get the mock service", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(fakeServiceA)
})
it("container emits SERVICE_BIND event", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
const serviceFunc = vi.fn<[ContainerEvent, void]>()
container.getEventStream().subscribe((ev) => {
serviceFunc(ev)
})
container.bindMock(TestServiceA, fakeServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("throws if service already bound", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
expect(() => {
container.bindMock(TestServiceA, fakeServiceA)
}).toThrowError(
"Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?"
)
})
})
})

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/testing.d.ts"
export * from "./dist/testing.d.ts"

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["lib"]
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: {
index: './lib/main.ts',
vue: './lib/vue.ts',
testing: './lib/testing.ts',
},
},
rollupOptions: {
external: ['vue'],
}
},
})

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
}
})

View File

@@ -1,2 +0,0 @@
export { default } from "./dist/vue.d.ts"
export * from "./dist/vue.d.ts"

View File

@@ -1,3 +0,0 @@
:80 :3170 {
reverse_proxy localhost:8080
}

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.8.4-1",
"version": "2023.4.3",
"description": "",
"author": "",
"private": true,
@@ -24,17 +24,18 @@
"do-test": "pnpm run test"
},
"dependencies": {
"@apollo/server": "^4.9.4",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.6",
"@nestjs/core": "^10.2.6",
"@nestjs/graphql": "^12.0.9",
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.6",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^4.16.2",
"@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",
@@ -42,9 +43,9 @@
"express": "^4.17.1",
"express-session": "^1.17.3",
"fp-ts": "^2.13.1",
"graphql": "^16.8.1",
"graphql": "^15.5.0",
"graphql-query-complexity": "^0.12.0",
"graphql-redis-subscriptions": "^2.6.0",
"graphql-redis-subscriptions": "^2.5.0",
"graphql-subscriptions": "^2.0.0",
"handlebars": "^4.7.7",
"io-ts": "^2.2.16",
@@ -56,15 +57,15 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-microsoft": "^1.0.0",
"prisma": "^4.16.2",
"prisma": "^4.7.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.6.0"
},
"devDependencies": {
"@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.6",
"@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",

View File

@@ -1,15 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB,
ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id");
-- AddForeignKey
ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
-- AlterTable
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;

View File

@@ -5,7 +5,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
model Team {
@@ -43,7 +43,6 @@ model TeamInvitation {
model TeamCollection {
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
@@ -69,13 +68,10 @@ model TeamRequest {
}
model Shortcode {
id String @id @unique
request Json
embedProperties Json?
creatorUid String?
User User? @relation(fields: [creatorUid], references: [uid])
createdOn DateTime @default(now())
updatedOn DateTime @default(now()) @updatedAt
id String @id
request Json
creatorUid String?
createdOn DateTime @default(now())
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
}
@@ -106,7 +102,6 @@ model User {
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
}
model Account {
@@ -197,7 +192,6 @@ model UserCollection {
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
data Json?
orderIndex Int
type ReqType
createdOn DateTime @default(now()) @db.Timestamp(3)

View File

@@ -1,66 +0,0 @@
#!/usr/local/bin/node
// @ts-check
import { spawn } from 'child_process';
import process from 'process';
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
childProcess.stdout.on('data', (data) => {
const output = data.toString().trim().split('\n');
output.forEach((line) => {
console.log(`${prefix} | ${line}`);
});
});
childProcess.stderr.on('data', (data) => {
const error = data.toString().trim().split('\n');
error.forEach((line) => {
console.error(`${prefix} | ${line}`);
});
});
childProcess.on('close', (code) => {
console.log(`${prefix} Child process exited with code ${code}`);
});
childProcess.on('error', (stuff) => {
console.error('error');
console.error(stuff);
});
return childProcess;
}
const caddyProcess = runChildProcessWithPrefix(
'caddy',
['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'],
'App/Admin Dashboard Caddy',
);
const backendProcess = runChildProcessWithPrefix(
'pnpm',
['run', 'start:prod'],
'Backend Server',
);
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
});
backendProcess.on('exit', (code) => {
console.log(
`Exiting process because Backend Server exited with code ${code}`,
);
process.exit(code);
});
process.on('SIGINT', () => {
console.log('SIGINT received, exiting...');
caddyProcess.kill('SIGINT');
backendProcess.kill('SIGINT');
process.exit(0);
});

View File

@@ -1,9 +1,4 @@
import { ObjectType, OmitType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
import { ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Admin extends OmitType(User, [
'isAdmin',
'currentRESTSession',
'currentGQLSession',
]) {}
export class Admin {}

View File

@@ -10,8 +10,6 @@ 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';
import { InfraResolver } from './infra.resolver';
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
@Module({
imports: [
@@ -24,9 +22,8 @@ import { ShortcodeModule } from 'src/shortcode/shortcode.module';
TeamEnvironmentsModule,
TeamCollectionModule,
TeamRequestModule,
ShortcodeModule,
],
providers: [InfraResolver, AdminResolver, AdminService],
providers: [AdminResolver, AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -21,15 +21,15 @@ 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';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
@@ -51,7 +51,6 @@ export class AdminResolver {
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
@@ -60,7 +59,6 @@ export class AdminResolver {
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@@ -78,7 +76,6 @@ export class AdminResolver {
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(
@@ -91,7 +88,6 @@ export class AdminResolver {
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
deprecationReason: 'Use `infra` query instead',
})
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
@@ -100,7 +96,6 @@ export class AdminResolver {
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
deprecationReason: 'Use `infra` query instead',
})
async allTeams(
@Parent() admin: Admin,
@@ -111,7 +106,6 @@ export class AdminResolver {
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
deprecationReason: 'Use `infra` query instead',
})
async teamInfo(
@Parent() admin: Admin,
@@ -129,7 +123,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
deprecationReason: 'Use `infra` query instead',
})
async membersCountInTeam(
@Parent() admin: Admin,
@@ -147,7 +140,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
deprecationReason: 'Use `infra` query instead',
})
async collectionCountInTeam(
@Parent() admin: Admin,
@@ -163,7 +155,6 @@ export class AdminResolver {
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
deprecationReason: 'Use `infra` query instead',
})
async requestCountInTeam(
@Parent() admin: Admin,
@@ -180,7 +171,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
deprecationReason: 'Use `infra` query instead',
})
async environmentCountInTeam(
@Parent() admin: Admin,
@@ -197,7 +187,6 @@ export class AdminResolver {
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
deprecationReason: 'Use `infra` query instead',
})
async pendingInvitationCountInTeam(
@Parent() admin: Admin,
@@ -216,7 +205,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
deprecationReason: 'Use `infra` query instead',
})
async usersCount() {
return this.adminService.getUsersCount();
@@ -224,7 +212,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamsCount() {
return this.adminService.getTeamsCount();
@@ -232,7 +219,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
@@ -240,7 +226,6 @@ export class AdminResolver {
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
@@ -426,40 +411,6 @@ export class AdminResolver {
return deletedTeam.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a team Invite by Invite ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeTeamInviteByAdmin(
@Args({
name: 'inviteID',
description: 'Team Invite ID',
type: () => ID,
})
inviteID: string,
): Promise<boolean> {
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
if (E.isLeft(invite)) throwErr(invite.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Revoke Shortcode by ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeShortcodeByAdmin(
@Args({
name: 'code',
description: 'The shortcode to delete',
type: () => ID,
})
code: string,
): Promise<boolean> {
const res = await this.adminService.deleteShortcode(code);
if (E.isLeft(res)) throwErr(res.left);
return true;
}
/* Subscriptions */
@Subscription(() => InvitedUser, {

View File

@@ -15,7 +15,6 @@ import {
INVALID_EMAIL,
USER_ALREADY_INVITED,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -26,7 +25,6 @@ const mockTeamRequestService = mockDeep<TeamRequestService>();
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const adminService = new AdminService(
mockUserService,
@@ -38,7 +36,6 @@ const adminService = new AdminService(
mockPubSub as any,
mockPrisma as any,
mockMailerService,
mockShortcodeService,
);
const invitedUsers: InvitedUsers[] = [

View File

@@ -11,7 +11,6 @@ import {
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USER_ALREADY_INVITED,
USER_IS_ADMIN,
USER_NOT_FOUND,
@@ -24,7 +23,6 @@ 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';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
@Injectable()
export class AdminService {
@@ -38,7 +36,6 @@ export class AdminService {
private readonly pubsub: PubSubService,
private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
) {}
/**
@@ -76,7 +73,7 @@ export class AdminService {
try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'user-invitation',
template: 'code-your-own',
variables: {
inviteeEmail: inviteeEmail,
magicLink: `${process.env.VITE_BASE_URL}`,
@@ -184,7 +181,7 @@ export class AdminService {
* @returns an array team invitations
*/
async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getTeamInvitations(
const invitations = await this.teamInvitationService.getAllTeamInvitations(
teamID,
);
@@ -239,11 +236,11 @@ export class AdminService {
const user = await this.userService.findUserByEmail(userEmail);
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
const teamMember = await this.teamService.getTeamMemberTE(
const isUserAlreadyMember = await this.teamService.getTeamMemberTE(
teamID,
user.value.uid,
)();
if (E.isLeft(teamMember)) {
if (E.left(isUserAlreadyMember)) {
const addedUser = await this.teamService.addMemberToTeamWithEmail(
teamID,
userEmail,
@@ -251,18 +248,6 @@ export class AdminService {
);
if (E.isLeft(addedUser)) return E.left(addedUser.left);
const userInvitation =
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
userEmail,
teamID,
);
if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation(
userInvitation.right.id,
);
}
return E.right(addedUser.right);
}
@@ -419,50 +404,4 @@ export class AdminService {
if (E.isLeft(team)) return E.left(team.left);
return E.right(team.right);
}
/**
* Revoke a team invite by ID
* @param inviteID Team Invite ID
* @returns an Either of boolean or error
*/
async revokeTeamInviteByID(inviteID: string) {
const teamInvite = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
return E.right(teamInvite.right);
}
/**
* Fetch all created ShortCodes
*
* @param args Pagination arguments
* @param userEmail User email
* @returns ShortcodeWithUserEmail
*/
async fetchAllShortcodes(
cursorID: string,
take: number,
userEmail: string = null,
) {
return this.shortcodeService.fetchAllShortcodes(
{ cursor: cursorID, take },
userEmail,
);
}
/**
* Delete a Shortcode
*
* @param shortcodeID ID of Shortcode being deleted
* @returns Boolean on successful deletion
*/
async deleteShortcode(shortcodeID: string) {
const result = await this.shortcodeService.deleteShortcode(shortcodeID);
if (E.isLeft(result)) return E.left(result.left);
return E.right(result.right);
}
}

View File

@@ -1,10 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Admin } from './admin.model';
@ObjectType()
export class Infra {
@Field(() => Admin, {
description: 'Admin who executed the action',
})
executedBy: Admin;
}

View File

@@ -1,225 +0,0 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model';
import { AdminService } from './admin.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlAdminGuard } from './guards/gql-admin.guard';
import { User } from 'src/user/user.model';
import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
export class InfraResolver {
constructor(private adminService: AdminService) {}
@Query(() => Infra, {
description: 'Fetch details of the Infrastructure',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
infra(@GqlAdmin() admin: Admin) {
const infra: Infra = { executedBy: admin };
return infra;
}
@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(@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(): 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(@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(
@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(
@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(
@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(
@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(
@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(
@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();
}
@ResolveField(() => [ShortcodeWithUserEmail], {
description: 'Returns a list of all the shortcodes in the infra',
})
async allShortcodes(
@Args() args: PaginationArgs,
@Args({
name: 'userEmail',
nullable: true,
description: 'Users email to filter shortcodes by',
})
userEmail: string,
) {
return await this.adminService.fetchAllShortcodes(
args.cursor,
args.take,
userEmail,
);
}
}

View File

@@ -1,9 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('ping')
export class AppController {
@Get()
ping(): string {
return 'Success';
}
}

View File

@@ -19,7 +19,6 @@ 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';
import { AppController } from './app.controller';
@Module({
imports: [
@@ -27,7 +26,12 @@ import { AppController } from './app.controller';
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: {
@@ -57,12 +61,10 @@ import { AppController } from './app.controller';
}),
driver: ApolloDriver,
}),
ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
},
]),
ThrottlerModule.forRoot({
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
}),
UserModule,
AuthModule,
AdminModule,
@@ -79,6 +81,5 @@ import { AppController } from './app.controller';
ShortcodeModule,
],
providers: [GQLComplexityPlugin],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -2,9 +2,9 @@ import {
Body,
Controller,
Get,
InternalServerErrorException,
Post,
Query,
Req,
Request,
Res,
UseGuards,
@@ -19,18 +19,12 @@ 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 {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
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';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
@@ -45,9 +39,6 @@ export class AuthController {
@Body() authData: SignInMagicDto,
@Query('origin') origin: string,
) {
if (!authProviderCheck(AuthProvider.EMAIL))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,
origin,

View File

@@ -11,7 +11,6 @@ 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';
import { AuthProvider, authProviderCheck } from './helper';
@Module({
imports: [
@@ -27,9 +26,9 @@ import { AuthProvider, authProviderCheck } from './helper';
AuthService,
JwtStrategy,
RTJwtStrategy,
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
GoogleStrategy,
GithubStrategy,
MicrosoftStrategy,
],
controllers: [AuthController],
})

View File

@@ -228,8 +228,8 @@ export class AuthService {
url = process.env.VITE_BASE_URL;
}
await this.mailerService.sendEmail(email, {
template: 'user-invitation',
await this.mailerService.sendAuthEmail(email, {
template: 'code-your-own',
variables: {
inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`,

View File

@@ -1,20 +1,8 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GITHUB))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
export class GithubSSOGuard extends AuthGuard('github') {
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,20 +1,8 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GOOGLE))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
export class GoogleSSOGuard extends AuthGuard('google') {
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,26 +1,8 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable()
export class MicrosoftSSOGuard
extends AuthGuard('microsoft')
implements CanActivate
{
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.MICROSOFT))
throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404,
});
return super.canActivate(context);
}
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();

View File

@@ -1,11 +1,10 @@
import { HttpException, HttpStatus } from '@nestjs/common';
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 { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
import { COOKIES_NOT_FOUND } from 'src/errors';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
@@ -17,13 +16,6 @@ export enum Origin {
APP = 'app',
}
export enum AuthProvider {
GOOGLE = 'GOOGLE',
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
}
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
@@ -105,25 +97,3 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
};
};
/**
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
*
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false;
return true;
}

View File

@@ -17,7 +17,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [process.env.MICROSOFT_SCOPE],
tenant: process.env.MICROSOFT_TENANT,
passReqToCallback: true,
store: true,
});
}

View File

@@ -23,31 +23,7 @@ export const AUTH_FAIL = 'auth/fail';
export const JSON_INVALID = 'json_invalid';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/**
* Tried to delete a user data document from fb firestore but failed.
* Tried to delete an user data document from fb firestore but failed.
* (FirebaseService)
*/
export const USER_FB_DOCUMENT_DELETION_FAILED =
@@ -255,14 +231,7 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/**
* The Team Collection data is not valid
* (TeamCollectionService)
*/
export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const;
/**
* Tried to perform an action on a request that doesn't accept their member role level
* 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';
@@ -293,7 +262,7 @@ export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
/**
* Tried to perform an action on a request when the user is not even a member of the team
* 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';
@@ -326,18 +295,23 @@ export const TEAM_INVITATION_NOT_FOUND =
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
/**
* Invalid or non-existent TEAM ENVIRONMENT ID
* 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;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/**
* The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard)
@@ -366,7 +340,7 @@ export const USER_SETTINGS_NULL_SETTINGS =
'user_settings/null_settings' as const;
/*
* Global environment doesn't exist for the user
* Global environment doesnt exists for the user
* (UserEnvironmentsService)
*/
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
@@ -592,13 +566,6 @@ export const USER_COLL_REORDERING_FAILED =
export const USER_COLL_SAME_NEXT_COLL =
'user_coll/user_collection_and_next_user_collection_are_same' as const;
/**
* The User Collection data is not valid
* (UserCollectionService)
*/
export const USER_COLL_DATA_INVALID =
'user_coll/user_coll_data_invalid' as const;
/**
* The User Collection does not belong to the logged-in user
* (UserCollectionService)
@@ -623,24 +590,3 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
*/
export const MAILER_FROM_ADDRESS_UNDEFINED =
'mailer/from_address_undefined' as const;
/**
* SharedRequest invalid request JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_REQUEST_JSON =
'shortcode/request_invalid_format' as const;
/**
* SharedRequest invalid properties JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_PROPERTIES_JSON =
'shortcode/properties_invalid_format' as const;
/**
* SharedRequest invalid properties not found
* (ShortcodeService)
*/
export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const;

View File

@@ -27,7 +27,6 @@ import { UserRequestUserCollectionResolver } from './user-request/resolvers/user
import { UserEnvsUserResolver } from './user-environment/user.resolver';
import { UserHistoryUserResolver } from './user-history/user.resolver';
import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
/**
* All the resolvers present in the application.
@@ -35,7 +34,6 @@ import { InfraResolver } from './admin/infra.resolver';
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
*/
const RESOLVERS = [
InfraResolver,
AdminResolver,
ShortcodeResolver,
TeamResolver,
@@ -95,7 +93,9 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer',
});
const schemaString = printSchema(schema);
const schemaString = printSchema(schema, {
commentDescriptions: true,
});
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {
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

@@ -8,7 +8,7 @@ export type MailDescription = {
};
export type UserMagicLinkMailDescription = {
template: 'user-invitation';
template: 'code-your-own';
variables: {
inviteeEmail: string;
magicLink: string;
@@ -16,7 +16,7 @@ export type UserMagicLinkMailDescription = {
};
export type AdminUserInvitationMailDescription = {
template: 'user-invitation';
template: 'code-your-own';
variables: {
inviteeEmail: string;
magicLink: string;

View File

@@ -5,6 +5,7 @@ import {
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';
@@ -27,21 +28,40 @@ export class MailerService {
case 'team-invitation':
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
case 'user-invitation':
case 'code-your-own':
return 'Sign in to Hoppscotch';
}
}
/**
* Sends an email to the given email address given a mail description
* @param to Receiver's email id
* @param to The email address to be sent to (NOTE: this is not validated)
* @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/
async sendEmail(
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,

View File

@@ -14,7 +14,7 @@
-->
<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;
@@ -22,19 +22,19 @@
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;
@@ -47,13 +47,13 @@
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
@@ -61,7 +61,7 @@
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
@@ -69,7 +69,7 @@
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
@@ -77,12 +77,12 @@
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
@@ -91,25 +91,25 @@
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;
@@ -124,7 +124,7 @@
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
@@ -132,7 +132,7 @@
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
@@ -140,7 +140,7 @@
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
@@ -148,21 +148,21 @@
}
}
/* 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;
@@ -171,31 +171,31 @@
-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;
@@ -206,33 +206,33 @@
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;
@@ -241,7 +241,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
@@ -250,50 +250,50 @@
-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;
@@ -303,7 +303,7 @@
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
@@ -313,16 +313,16 @@
-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;
@@ -331,7 +331,7 @@
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
@@ -340,7 +340,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
@@ -350,7 +350,7 @@
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
@@ -360,11 +360,11 @@
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
@@ -374,25 +374,25 @@
-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,

View File

@@ -5,13 +5,11 @@ import * as cookieParser from 'cookie-parser';
import { VersioningType } from '@nestjs/common';
import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils';
async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
console.log(`Database: ${process.env.DATABASE_URL}`);
const app = await NestFactory.create(AppModule);

View File

@@ -1,9 +1,8 @@
import { GraphQLSchemaHost } from '@nestjs/graphql';
import {
ApolloServerPlugin,
BaseContext,
GraphQLRequestListener,
} from '@apollo/server';
} from 'apollo-server-plugin-base';
import { Plugin } from '@nestjs/apollo';
import { GraphQLError } from 'graphql';
import {
@@ -18,7 +17,7 @@ const COMPLEXITY_LIMIT = 50;
export class GQLComplexityPlugin implements ApolloServerPlugin {
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
async requestDidStart(): Promise<GraphQLRequestListener> {
const { schema } = this.gqlSchemaHost;
return {

View File

@@ -21,8 +21,8 @@ import {
} 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 {
UserCollection,
UserCollectionRemovedData,
UserCollectionReorderData,
} from 'src/user-collection/user-collections.model';
@@ -69,7 +69,5 @@ export type TopicDef = {
[topic: `team_req/${string}/req_deleted`]: string;
[topic: `team/${string}/invite_added`]: TeamInvitation;
[topic: `team/${string}/invite_removed`]: string;
[
topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}`
]: Shortcode;
[topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode;
};

View File

@@ -1,10 +1,9 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
@ObjectType()
export class Shortcode {
@Field(() => ID, {
description: 'The 12 digit alphanumeric code',
description: 'The shortcode. 12 digit alphanumeric.',
})
id: string;
@@ -13,57 +12,8 @@ export class Shortcode {
})
request: string;
@Field({
description: 'JSON string representing the properties for an embed',
nullable: true,
})
properties: string;
@Field({
description: 'Timestamp of when the Shortcode was created',
})
createdOn: Date;
}
@ObjectType()
export class ShortcodeCreator {
@Field({
description: 'Uid of user who created the shortcode',
})
uid: string;
@Field({
description: 'Email of user who created the shortcode',
})
email: string;
}
@ObjectType()
export class ShortcodeWithUserEmail {
@Field(() => ID, {
description: 'The 12 digit alphanumeric code',
})
id: string;
@Field({
description: 'JSON string representing the request data',
})
request: string;
@Field({
description: 'JSON string representing the properties for an embed',
nullable: true,
})
properties: string;
@Field({
description: 'Timestamp of when the Shortcode was created',
})
createdOn: Date;
@Field({
description: 'Details of user who created the shortcode',
nullable: true,
})
creator: ShortcodeCreator;
}

View File

@@ -1,4 +1,5 @@
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';
@@ -6,7 +7,14 @@ import { ShortcodeResolver } from './shortcode.resolver';
import { ShortcodeService } from './shortcode.service';
@Module({
imports: [PrismaModule, UserModule, PubSubModule],
imports: [
PrismaModule,
UserModule,
PubSubModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
}),
],
providers: [ShortcodeService, ShortcodeResolver],
exports: [ShortcodeService],
})

View File

@@ -1,5 +1,6 @@
import {
Args,
Context,
ID,
Mutation,
Query,
@@ -8,25 +9,28 @@ import {
} from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { UseGuards } from '@nestjs/common';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
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';
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Shortcode)
export class ShortcodeResolver {
constructor(
private readonly shortcodeService: ShortcodeService,
private readonly userService: UserService,
private readonly pubsub: PubSubService,
private jwtService: JwtService,
) {}
/* Queries */
@@ -60,53 +64,20 @@ export class ShortcodeResolver {
@Mutation(() => Shortcode, {
description: 'Create a shortcode for the given request.',
})
@UseGuards(GqlAuthGuard)
async createShortcode(
@GqlUser() user: AuthUser,
@Args({
name: 'request',
description: 'JSON string of the request object',
})
request: string,
@Args({
name: 'properties',
description: 'JSON string of the properties of the embed',
nullable: true,
})
properties: string,
@Context() ctx: any,
) {
const decodedAccessToken = this.jwtService.verify(
ctx.req.cookies['access_token'],
);
const result = await this.shortcodeService.createShortcode(
request,
properties,
user,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => Shortcode, {
description: 'Update a user generated Shortcode',
})
@UseGuards(GqlAuthGuard)
async updateEmbedProperties(
@GqlUser() user: AuthUser,
@Args({
name: 'code',
type: () => ID,
description: 'The Shortcode to update',
})
code: string,
@Args({
name: 'properties',
description: 'JSON string of the properties of the embed',
})
properties: string,
) {
const result = await this.shortcodeService.updateEmbedProperties(
code,
user.uid,
properties,
decodedAccessToken?.sub,
);
if (E.isLeft(result)) throwErr(result.left);
@@ -122,7 +93,7 @@ export class ShortcodeResolver {
@Args({
name: 'code',
type: () => ID,
description: 'The shortcode to remove',
description: 'The shortcode to resolve',
})
code: string,
) {
@@ -143,16 +114,6 @@ export class ShortcodeResolver {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
}
@Subscription(() => Shortcode, {
description: 'Listen for Shortcode updates',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
myShortcodesUpdated(@GqlUser() user: AuthUser) {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`);
}
@Subscription(() => Shortcode, {
description: 'Listen for shortcode deletion',
resolve: (value) => value,

View File

@@ -1,16 +1,13 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from '../prisma/prisma.service';
import {
INVALID_EMAIL,
SHORTCODE_INVALID_PROPERTIES_JSON,
SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_ALREADY_EXISTS,
SHORTCODE_INVALID_JSON,
SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
import { Shortcode } from './shortcode.model';
import { ShortcodeService } from './shortcode.service';
import { UserService } from 'src/user/user.service';
import { AuthUser } from 'src/types/AuthUser';
const mockPrisma = mockDeep<PrismaService>();
@@ -25,7 +22,7 @@ const mockFB = {
doc: mockDocFunc,
},
};
const mockUserService = new UserService(mockPrisma as any, mockPubSub as any);
const mockUserService = new UserService(mockFB as any, mockPubSub as any);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -41,34 +38,18 @@ beforeEach(() => {
});
const createdOn = 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: createdOn,
currentGQLSession: {},
currentRESTSession: {},
};
const mockEmbed = {
const shortCodeWithOutUser = {
id: '123',
request: '{}',
embedProperties: '{}',
createdOn: createdOn,
creatorUid: user.uid,
updatedOn: createdOn,
creatorUid: null,
};
const mockShortcode = {
const shortCodeWithUser = {
id: '123',
request: '{}',
embedProperties: null,
createdOn: createdOn,
creatorUid: user.uid,
updatedOn: createdOn,
creatorUid: 'user_uid_1',
};
const shortcodes = [
@@ -77,67 +58,33 @@ const shortcodes = [
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
creatorUid: 'testuser',
createdOn: new Date(),
updatedOn: createdOn,
},
{
id: 'blablabla1',
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
creatorUid: 'testuser',
createdOn: new Date(),
updatedOn: createdOn,
},
];
const shortcodesWithUserEmail = [
{
id: 'blablabla',
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(),
updatedOn: createdOn,
User: user,
},
{
id: 'blablabla1',
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(),
updatedOn: createdOn,
User: user,
},
];
describe('ShortcodeService', () => {
describe('getShortCode', () => {
test('should return a valid Shortcode with valid Shortcode ID', async () => {
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed);
test('should return a valid shortcode with valid shortcode ID', async () => {
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(
shortCodeWithOutUser,
);
const result = await shortcodeService.getShortCode(mockEmbed.id);
const result = await shortcodeService.getShortCode(
shortCodeWithOutUser.id,
);
expect(result).toEqualRight(<Shortcode>{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
id: shortCodeWithOutUser.id,
createdOn: shortCodeWithOutUser.createdOn,
request: JSON.stringify(shortCodeWithOutUser.request),
});
});
@@ -152,10 +99,10 @@ describe('ShortcodeService', () => {
});
describe('fetchUserShortCodes', () => {
test('should return list of Shortcode with valid inputs and no cursor', async () => {
test('should return list of shortcodes with valid inputs and no cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: null,
take: 10,
});
@@ -163,22 +110,20 @@ describe('ShortcodeService', () => {
{
id: shortcodes[0].id,
request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify(shortcodes[0].embedProperties),
createdOn: shortcodes[0].createdOn,
},
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
},
]);
});
test('should return list of Shortcode with valid inputs and cursor', async () => {
test('should return list of shortcodes with valid inputs and cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: 'blablabla',
take: 10,
});
@@ -186,7 +131,6 @@ describe('ShortcodeService', () => {
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
},
]);
@@ -195,7 +139,7 @@ describe('ShortcodeService', () => {
test('should return an empty array for an invalid cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([]);
const result = await shortcodeService.fetchUserShortCodes(user.uid, {
const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: 'invalidcursor',
take: 10,
});
@@ -227,111 +171,77 @@ describe('ShortcodeService', () => {
});
describe('createShortcode', () => {
test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => {
test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => {
const result = await shortcodeService.createShortcode(
'invalidRequest',
null,
user,
'user_uid_1',
);
expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON);
expect(result).toEqualLeft(SHORTCODE_INVALID_JSON);
});
test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => {
const result = await shortcodeService.createShortcode(
'{}',
'invalid_data',
user,
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should successfully create a new Embed with valid user uid', async () => {
// generateUniqueShortCodeID --> getShortcode
test('should successfully create a new shortcode with valid user uid', async () => {
// generateUniqueShortCodeID --> getShortCode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', '{}', user);
expect(result).toEqualRight(<Shortcode>{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
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 valid user uid', async () => {
// generateUniqueShortCodeID --> getShortcode
test('should successfully create a new shortcode with null user uid', async () => {
// generateUniqueShortCodeID --> getShortCode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode);
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', null, user);
expect(result).toEqualRight(<Shortcode>{
id: mockShortcode.id,
createdOn: mockShortcode.createdOn,
request: JSON.stringify(mockShortcode.request),
properties: mockShortcode.embedProperties,
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 a Shortcode', async () => {
// generateUniqueShortCodeID --> getShortcode
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(mockShortcode);
const result = await shortcodeService.createShortcode('{}', null, user);
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockShortcode.creatorUid}/created`,
<Shortcode>{
id: mockShortcode.id,
createdOn: mockShortcode.createdOn,
request: JSON.stringify(mockShortcode.request),
properties: mockShortcode.embedProperties,
},
);
});
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => {
// generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.createShortcode('{}', '{}', user);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/created`,
<Shortcode>{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
`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(mockEmbed);
test('should return true on successful deletion of shortcode with valid inputs', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.revokeShortCode(
mockEmbed.id,
mockEmbed.creatorUid,
shortCodeWithUser.id,
shortCodeWithUser.creatorUid,
);
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
where: {
creator_uid_shortcode_unique: {
creatorUid: mockEmbed.creatorUid,
id: mockEmbed.id,
creatorUid: shortCodeWithUser.creatorUid,
id: shortCodeWithUser.id,
},
},
});
@@ -339,53 +249,52 @@ describe('ShortcodeService', () => {
expect(result).toEqualRight(true);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => {
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 () => {
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 () => {
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(mockEmbed);
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(
mockEmbed.id,
mockEmbed.creatorUid,
shortCodeWithUser.id,
shortCodeWithUser.creatorUid,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/revoked`,
`shortcode/${shortCodeWithUser.creatorUid}/revoked`,
{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
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 () => {
test('should successfully delete all users shortcodes with valid user uid', async () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await shortcodeService.deleteUserShortCodes(
mockEmbed.creatorUid,
shortCodeWithUser.creatorUid,
);
expect(result).toEqual(1);
});
@@ -394,176 +303,9 @@ describe('ShortcodeService', () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
const result = await shortcodeService.deleteUserShortCodes(
mockEmbed.creatorUid,
shortCodeWithUser.creatorUid,
);
expect(result).toEqual(0);
});
});
describe('updateShortcode', () => {
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'',
);
expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND);
});
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{kk',
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => {
mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound');
const result = await shortcodeService.updateEmbedProperties(
'invalidID',
user.uid,
'{}',
);
expect(result).toEqualLeft(SHORTCODE_NOT_FOUND);
});
test('should successfully update a Shortcodes with valid inputs', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(result).toEqualRight({
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
});
});
test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/updated`,
{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
},
);
});
});
describe('deleteShortcode', () => {
test('should return true on successful deletion of Shortcode with valid inputs', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.deleteShortcode(mockEmbed.id);
expect(result).toEqualRight(true);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect(shortcodeService.deleteShortcode('invalid')).resolves.toEqualLeft(
SHORTCODE_NOT_FOUND,
);
});
});
describe('fetchAllShortcodes', () => {
test('should return list of Shortcodes with valid inputs and no cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValueOnce(
shortcodesWithUserEmail,
);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: null,
take: 10,
},
user.email,
);
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{
id: shortcodes[0].id,
request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify(shortcodes[0].embedProperties),
createdOn: shortcodes[0].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
]);
});
test('should return list of Shortcode with valid inputs and cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([
shortcodesWithUserEmail[1],
]);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: 'blablabla',
take: 10,
},
user.email,
);
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
]);
});
test('should return an empty array for an invalid cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([]);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: 'invalidcursor',
take: 10,
},
user.email,
);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -1,16 +1,12 @@
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_PROPERTIES_JSON,
SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors';
import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors';
import { UserDataHandler } from 'src/user/user.data.handler';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
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';
@@ -50,14 +46,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* @param shortcodeInfo Prisma Shortcode type
* @returns GQL Shortcode
*/
private cast(shortcodeInfo: DBShortCode): Shortcode {
private returnShortCode(shortcodeInfo: DBShortCode): Shortcode {
return <Shortcode>{
id: shortcodeInfo.id,
request: JSON.stringify(shortcodeInfo.request),
properties:
shortcodeInfo.embedProperties != null
? JSON.stringify(shortcodeInfo.embedProperties)
: null,
createdOn: shortcodeInfo.createdOn,
};
}
@@ -102,7 +94,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
where: { id: shortcode },
});
return E.right(this.cast(shortcodeInfo));
return E.right(this.returnShortCode(shortcodeInfo));
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
@@ -112,22 +104,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* Create a new ShortCode
*
* @param request JSON string of request details
* @param userInfo user UI
* @param properties JSON string of embed properties, if present
* @param userUID user UID, if present
* @returns Either of ShortCode or error
*/
async createShortcode(
request: string,
properties: string | null = null,
userInfo: AuthUser,
) {
const requestData = stringToJson(request);
if (E.isLeft(requestData) || !requestData.right)
return E.left(SHORTCODE_INVALID_REQUEST_JSON);
async createShortcode(request: string, userUID: string | null) {
const shortcodeData = stringToJson(request);
if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON);
const parsedProperties = stringToJson(properties);
if (E.isLeft(parsedProperties))
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
const user = await this.userService.findUserById(userUID);
const generatedShortCode = await this.generateUniqueShortCodeID();
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
@@ -135,9 +119,8 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const createdShortCode = await this.prisma.shortcode.create({
data: {
id: generatedShortCode.right,
request: requestData.right,
embedProperties: parsedProperties.right ?? undefined,
creatorUid: userInfo.uid,
request: shortcodeData.right,
creatorUid: O.isNone(user) ? null : user.value.uid,
},
});
@@ -145,11 +128,11 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
if (createdShortCode.creatorUid) {
this.pubsub.publish(
`shortcode/${createdShortCode.creatorUid}/created`,
this.cast(createdShortCode),
this.returnShortCode(createdShortCode),
);
}
return E.right(this.cast(createdShortCode));
return E.right(this.returnShortCode(createdShortCode));
}
/**
@@ -167,20 +150,20 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
orderBy: {
createdOn: 'desc',
},
skip: args.cursor ? 1 : 0,
skip: 1,
take: args.take,
cursor: args.cursor ? { id: args.cursor } : undefined,
});
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
this.cast(code),
this.returnShortCode(code),
);
return fetchedShortCodes;
}
/**
* Delete a ShortCode created by User of uid
* Delete a ShortCode
*
* @param shortcode ShortCode
* @param uid User Uid
@@ -199,7 +182,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
this.pubsub.publish(
`shortcode/${deletedShortCodes.creatorUid}/revoked`,
this.cast(deletedShortCodes),
this.returnShortCode(deletedShortCodes),
);
return E.right(true);
@@ -222,118 +205,4 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
return deletedShortCodes.count;
}
/**
* Delete a Shortcode
*
* @param shortcodeID ID of Shortcode being deleted
* @returns Boolean on successful deletion
*/
async deleteShortcode(shortcodeID: string) {
try {
await this.prisma.shortcode.delete({
where: {
id: shortcodeID,
},
});
return E.right(true);
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
}
/**
* Update a created Shortcode
* @param shortcodeID Shortcode ID
* @param uid User Uid
* @returns Updated Shortcode
*/
async updateEmbedProperties(
shortcodeID: string,
uid: string,
updatedProps: string,
) {
if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND);
const parsedProperties = stringToJson(updatedProps);
if (E.isLeft(parsedProperties) || !parsedProperties.right)
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
try {
const updatedShortcode = await this.prisma.shortcode.update({
where: {
creator_uid_shortcode_unique: {
creatorUid: uid,
id: shortcodeID,
},
},
data: {
embedProperties: parsedProperties.right,
},
});
this.pubsub.publish(
`shortcode/${updatedShortcode.creatorUid}/updated`,
this.cast(updatedShortcode),
);
return E.right(this.cast(updatedShortcode));
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
}
/**
* Fetch all created ShortCodes
*
* @param args Pagination arguments
* @param userEmail User email
* @returns ShortcodeWithUserEmail
*/
async fetchAllShortcodes(
args: PaginationArgs,
userEmail: string | null = null,
) {
const shortCodes = await this.prisma.shortcode.findMany({
where: userEmail
? {
User: {
email: userEmail,
},
}
: undefined,
orderBy: {
createdOn: 'desc',
},
skip: args.cursor ? 1 : 0,
take: args.take,
cursor: args.cursor ? { id: args.cursor } : undefined,
include: {
User: true,
},
});
const fetchedShortCodes: ShortcodeWithUserEmail[] = shortCodes.map(
(code) => {
return <ShortcodeWithUserEmail>{
id: code.id,
request: JSON.stringify(code.request),
properties:
code.embedProperties != null
? JSON.stringify(code.embedProperties)
: null,
createdOn: code.createdOn,
creator: code.User
? {
uid: code.User.uid,
email: code.User.email,
}
: null,
};
},
);
return fetchedShortCodes;
}
}

View File

@@ -14,13 +14,6 @@ export class CreateRootTeamCollectionArgs {
@Field({ name: 'title', description: 'Title of the new collection' })
title: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}
@ArgsType()
@@ -33,13 +26,6 @@ export class CreateChildTeamCollectionArgs {
@Field({ name: 'childTitle', description: 'Title of the new collection' })
childTitle: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}
@ArgsType()
@@ -47,14 +33,12 @@ export class RenameTeamCollectionArgs {
@Field(() => ID, {
name: 'collectionID',
description: 'ID of the collection',
deprecationReason: 'Switch to updateTeamCollection mutation instead',
})
collectionID: string;
@Field({
name: 'newTitle',
description: 'The updated title of the collection',
deprecationReason: 'Switch to updateTeamCollection mutation instead',
})
newTitle: string;
}
@@ -114,26 +98,3 @@ export class ReplaceTeamCollectionArgs {
})
parentCollectionID?: string;
}
@ArgsType()
export class UpdateTeamCollectionArgs {
@Field(() => ID, {
name: 'collectionID',
description: 'ID of the collection',
})
collectionID: string;
@Field({
name: 'newTitle',
description: 'The updated title of the collection',
nullable: true,
})
newTitle: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}

View File

@@ -12,17 +12,12 @@ export class TeamCollection {
})
title: string;
@Field({
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
@Field(() => ID, {
description: 'ID of the collection',
nullable: true,
})
parentID: string;
teamID: string;
}
@ObjectType()

View File

@@ -25,7 +25,6 @@ import {
MoveTeamCollectionArgs,
RenameTeamCollectionArgs,
ReplaceTeamCollectionArgs,
UpdateTeamCollectionArgs,
UpdateTeamCollectionOrderArgs,
} from './input-type.args';
import * as E from 'fp-ts/Either';
@@ -142,14 +141,7 @@ export class TeamCollectionResolver {
);
if (E.isLeft(teamCollections)) throwErr(teamCollections.left);
return <TeamCollection>{
id: teamCollections.right.id,
title: teamCollections.right.title,
parentID: teamCollections.right.parentID,
data: !teamCollections.right.data
? null
: JSON.stringify(teamCollections.right.data),
};
return teamCollections.right;
}
// Mutations
@@ -163,7 +155,6 @@ export class TeamCollectionResolver {
const teamCollection = await this.teamCollectionService.createCollection(
args.teamID,
args.title,
args.data,
null,
);
@@ -239,7 +230,6 @@ export class TeamCollectionResolver {
const teamCollection = await this.teamCollectionService.createCollection(
team.right.id,
args.childTitle,
args.data,
args.collectionID,
);
@@ -249,7 +239,6 @@ export class TeamCollectionResolver {
@Mutation(() => TeamCollection, {
description: 'Rename a collection',
deprecationReason: 'Switch to updateTeamCollection mutation instead',
})
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
@@ -314,23 +303,6 @@ export class TeamCollectionResolver {
return request.right;
}
@Mutation(() => TeamCollection, {
description: 'Update Team Collection details',
})
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async updateTeamCollection(@Args() args: UpdateTeamCollectionArgs) {
const updatedTeamCollection =
await this.teamCollectionService.updateTeamCollection(
args.collectionID,
args.data,
args.newTitle,
);
if (E.isLeft(updatedTeamCollection)) throwErr(updatedTeamCollection.left);
return updatedTeamCollection.right;
}
// Subscriptions
@Subscription(() => TeamCollection, {

View File

@@ -1,7 +1,6 @@
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
import { mockDeep, mockReset } from 'jest-mock-extended';
import { mock, mockDeep, mockReset } from 'jest-mock-extended';
import {
TEAM_COLL_DATA_INVALID,
TEAM_COLL_DEST_SAME,
TEAM_COLL_INVALID_JSON,
TEAM_COLL_IS_PARENT_COLL,
@@ -19,6 +18,8 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamCollectionModule } from './team-collection.module';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -53,60 +54,35 @@ const rootTeamCollection: DBTeamCollection = {
id: '123',
orderIndex: 1,
parentID: null,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
};
const rootTeamCollectionsCasted: TeamCollection = {
id: rootTeamCollection.id,
title: rootTeamCollection.title,
parentID: rootTeamCollection.parentID,
data: JSON.stringify(rootTeamCollection.data),
};
const rootTeamCollection_2: DBTeamCollection = {
id: 'erv',
orderIndex: 2,
parentID: null,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
};
const rootTeamCollection_2Casted: TeamCollection = {
id: 'erv',
parentID: null,
data: JSON.stringify(rootTeamCollection_2.data),
title: 'Root Collection 1',
};
const childTeamCollection: DBTeamCollection = {
id: 'rfe',
orderIndex: 1,
parentID: rootTeamCollection.id,
data: {},
title: 'Child Collection 1',
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
};
const childTeamCollectionCasted: TeamCollection = {
id: 'rfe',
parentID: rootTeamCollection.id,
data: JSON.stringify(childTeamCollection.data),
title: 'Child Collection 1',
};
const childTeamCollection_2: DBTeamCollection = {
id: 'bgdz',
orderIndex: 1,
data: {},
parentID: rootTeamCollection_2.id,
title: 'Child Collection 1',
teamID: team.id,
@@ -114,20 +90,11 @@ const childTeamCollection_2: DBTeamCollection = {
updatedOn: currentTime,
};
const childTeamCollection_2Casted: TeamCollection = {
id: 'bgdz',
data: JSON.stringify(childTeamCollection_2.data),
parentID: rootTeamCollection_2.id,
title: 'Child Collection 1',
};
const rootTeamCollectionList: DBTeamCollection[] = [
{
id: 'fdv',
orderIndex: 1,
parentID: null,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -138,8 +105,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
orderIndex: 2,
parentID: null,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -149,8 +114,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
orderIndex: 3,
parentID: null,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -159,8 +122,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
id: 'bre3',
orderIndex: 4,
parentID: null,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -171,8 +132,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
orderIndex: 5,
parentID: null,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -183,8 +142,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
parentID: null,
title: 'Root Collection 1',
teamID: team.id,
data: {},
createdOn: currentTime,
updatedOn: currentTime,
},
@@ -194,8 +151,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
parentID: null,
title: 'Root Collection 1',
teamID: team.id,
data: {},
createdOn: currentTime,
updatedOn: currentTime,
},
@@ -204,7 +159,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
orderIndex: 8,
parentID: null,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -214,7 +168,6 @@ const rootTeamCollectionList: DBTeamCollection[] = [
orderIndex: 9,
parentID: null,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -225,83 +178,17 @@ const rootTeamCollectionList: DBTeamCollection[] = [
parentID: null,
title: 'Root Collection 1',
teamID: team.id,
data: {},
createdOn: currentTime,
updatedOn: currentTime,
},
];
const rootTeamCollectionListCasted: TeamCollection[] = [
{
id: 'fdv',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: 'fbbg',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: 'fgbfg',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: 'bre3',
parentID: null,
data: JSON.stringify(rootTeamCollection.data),
title: 'Root Collection 1',
},
{
id: 'hghgf',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: '123',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: '54tyh',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: '234re',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: '34rtg',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
{
id: '45tgh',
parentID: null,
title: 'Root Collection 1',
data: JSON.stringify(rootTeamCollection.data),
},
];
const childTeamCollectionList: DBTeamCollection[] = [
{
id: '123',
orderIndex: 1,
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -311,8 +198,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
orderIndex: 2,
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -322,8 +207,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
orderIndex: 3,
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: {},
teamID: team.id,
createdOn: currentTime,
updatedOn: currentTime,
@@ -332,8 +215,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '567',
orderIndex: 4,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -343,8 +224,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '123',
orderIndex: 5,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -354,8 +233,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '678',
orderIndex: 6,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -365,8 +242,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '789',
orderIndex: 7,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -376,8 +251,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '890',
orderIndex: 8,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -387,7 +260,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '012',
orderIndex: 9,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -397,8 +269,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
id: '0bhu',
orderIndex: 10,
parentID: rootTeamCollection.id,
data: {},
title: 'Root Collection 1',
teamID: team.id,
createdOn: currentTime,
@@ -406,75 +276,6 @@ const childTeamCollectionList: DBTeamCollection[] = [
},
];
const childTeamCollectionListCasted: TeamCollection[] = [
{
id: '123',
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: JSON.stringify({}),
},
{
id: '345',
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: JSON.stringify({}),
},
{
id: '456',
parentID: rootTeamCollection.id,
title: 'Root Collection 1',
data: JSON.stringify({}),
},
{
id: '567',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '123',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '678',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '789',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '890',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '012',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
{
id: '0bhu',
parentID: rootTeamCollection.id,
data: JSON.stringify({}),
title: 'Root Collection 1',
},
];
beforeEach(() => {
mockReset(mockPrisma);
mockPubSub.publish.mockClear();
@@ -513,7 +314,7 @@ describe('getParentOfCollection', () => {
const result = await teamCollectionService.getParentOfCollection(
childTeamCollection.id,
);
expect(result).toEqual(rootTeamCollectionsCasted);
expect(result).toEqual(rootTeamCollection);
});
test('should return null successfully for a root collection with valid collectionID', async () => {
@@ -549,7 +350,7 @@ describe('getChildrenOfCollection', () => {
null,
10,
);
expect(result).toEqual(childTeamCollectionListCasted);
expect(result).toEqual(childTeamCollectionList);
});
test('should return a list of 3 child collections successfully with cursor being equal to the 7th item in the list', async () => {
@@ -565,9 +366,9 @@ describe('getChildrenOfCollection', () => {
10,
);
expect(result).toEqual([
{ ...childTeamCollectionListCasted[7] },
{ ...childTeamCollectionListCasted[8] },
{ ...childTeamCollectionListCasted[9] },
{ ...childTeamCollectionList[7] },
{ ...childTeamCollectionList[8] },
{ ...childTeamCollectionList[9] },
]);
});
@@ -594,7 +395,7 @@ describe('getTeamRootCollections', () => {
null,
10,
);
expect(result).toEqual(rootTeamCollectionListCasted);
expect(result).toEqual(rootTeamCollectionList);
});
test('should return a list of 3 root collections successfully with cursor being equal to the 7th item in the list', async () => {
@@ -610,9 +411,9 @@ describe('getTeamRootCollections', () => {
10,
);
expect(result).toEqual([
{ ...rootTeamCollectionListCasted[7] },
{ ...rootTeamCollectionListCasted[8] },
{ ...rootTeamCollectionListCasted[9] },
{ ...rootTeamCollectionList[7] },
{ ...rootTeamCollectionList[8] },
{ ...rootTeamCollectionList[9] },
]);
});
@@ -666,7 +467,6 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
rootTeamCollection.teamID,
'ab',
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE);
@@ -681,27 +481,11 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
rootTeamCollection.teamID,
'abcd',
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(result).toEqualLeft(TEAM_NOT_OWNER);
});
test('should throw TEAM_COLL_DATA_INVALID when parent TeamCollection does not belong to the team', async () => {
// isOwnerCheck
mockPrisma.teamCollection.findFirstOrThrow.mockResolvedValueOnce(
rootTeamCollection,
);
const result = await teamCollectionService.createCollection(
rootTeamCollection.teamID,
'abcd',
'{',
rootTeamCollection.id,
);
expect(result).toEqualLeft(TEAM_COLL_DATA_INVALID);
});
test('should successfully create a new root TeamCollection with valid inputs', async () => {
// isOwnerCheck
mockPrisma.teamCollection.findFirstOrThrow.mockResolvedValueOnce(
@@ -715,10 +499,9 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
rootTeamCollection.teamID,
'abcdefg',
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(result).toEqualRight(rootTeamCollectionsCasted);
expect(result).toEqualRight(rootTeamCollection);
});
test('should successfully create a new child TeamCollection with valid inputs', async () => {
@@ -734,10 +517,9 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
childTeamCollection.teamID,
childTeamCollection.title,
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(result).toEqualRight(childTeamCollectionCasted);
expect(result).toEqualRight(childTeamCollection);
});
test('should send pubsub message to "team_coll/<teamID>/coll_added" if child TeamCollection is created successfully', async () => {
@@ -753,13 +535,11 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
childTeamCollection.teamID,
childTeamCollection.title,
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollection.teamID}/coll_added`,
childTeamCollectionCasted,
childTeamCollection,
);
});
@@ -776,13 +556,11 @@ describe('createCollection', () => {
const result = await teamCollectionService.createCollection(
rootTeamCollection.teamID,
'abcdefg',
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.id,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${rootTeamCollection.teamID}/coll_added`,
rootTeamCollectionsCasted,
rootTeamCollection,
);
});
});
@@ -812,7 +590,7 @@ describe('renameCollection', () => {
'NewTitle',
);
expect(result).toEqualRight({
...rootTeamCollectionsCasted,
...rootTeamCollection,
title: 'NewTitle',
});
});
@@ -850,7 +628,7 @@ describe('renameCollection', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${rootTeamCollection.teamID}/coll_updated`,
{
...rootTeamCollectionsCasted,
...rootTeamCollection,
title: 'NewTitle',
},
);
@@ -1057,8 +835,9 @@ describe('moveCollection', () => {
null,
);
expect(result).toEqualRight({
...childTeamCollectionCasted,
...childTeamCollection,
parentID: null,
orderIndex: 2,
});
});
@@ -1114,8 +893,9 @@ describe('moveCollection', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollection.teamID}/coll_moved`,
{
...childTeamCollectionCasted,
...childTeamCollection,
parentID: null,
orderIndex: 2,
},
);
});
@@ -1154,8 +934,9 @@ describe('moveCollection', () => {
childTeamCollection_2.id,
);
expect(result).toEqualRight({
...rootTeamCollectionsCasted,
parentID: childTeamCollection_2Casted.id,
...rootTeamCollection,
parentID: childTeamCollection_2.id,
orderIndex: 1,
});
});
@@ -1195,8 +976,9 @@ describe('moveCollection', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollection_2.teamID}/coll_moved`,
{
...rootTeamCollectionsCasted,
parentID: childTeamCollection_2Casted.id,
...rootTeamCollection,
parentID: childTeamCollection_2.id,
orderIndex: 1,
},
);
});
@@ -1235,8 +1017,9 @@ describe('moveCollection', () => {
childTeamCollection_2.id,
);
expect(result).toEqualRight({
...childTeamCollectionCasted,
parentID: childTeamCollection_2Casted.id,
...childTeamCollection,
parentID: childTeamCollection_2.id,
orderIndex: 1,
});
});
@@ -1276,8 +1059,9 @@ describe('moveCollection', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollection.teamID}/coll_moved`,
{
...childTeamCollectionCasted,
parentID: childTeamCollection_2Casted.id,
...childTeamCollection,
parentID: childTeamCollection_2.id,
orderIndex: 1,
},
);
});
@@ -1373,7 +1157,7 @@ describe('updateCollectionOrder', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollectionList[4].teamID}/coll_order_updated`,
{
collection: rootTeamCollectionListCasted[4],
collection: rootTeamCollectionList[4],
nextCollection: null,
},
);
@@ -1454,8 +1238,8 @@ describe('updateCollectionOrder', () => {
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${childTeamCollectionList[2].teamID}/coll_order_updated`,
{
collection: childTeamCollectionListCasted[4],
nextCollection: childTeamCollectionListCasted[2],
collection: childTeamCollectionList[4],
nextCollection: childTeamCollectionList[2],
},
);
});
@@ -1521,7 +1305,7 @@ describe('importCollectionsFromJSON', () => {
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${rootTeamCollection.teamID}/coll_added`,
rootTeamCollectionsCasted,
rootTeamCollection,
);
});
});
@@ -1640,7 +1424,7 @@ describe('replaceCollectionsWithJSON', () => {
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${rootTeamCollection.teamID}/coll_added`,
rootTeamCollectionsCasted,
rootTeamCollection,
);
});
});
@@ -1677,64 +1461,4 @@ describe('totalCollectionsInTeam', () => {
});
});
describe('updateTeamCollection', () => {
test('should throw TEAM_COLL_SHORT_TITLE if title is invalid', async () => {
const result = await teamCollectionService.updateTeamCollection(
rootTeamCollection.id,
JSON.stringify(rootTeamCollection.data),
'de',
);
expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE);
});
test('should throw TEAM_COLL_DATA_INVALID is collection data is invalid', async () => {
const result = await teamCollectionService.updateTeamCollection(
rootTeamCollection.id,
'{',
rootTeamCollection.title,
);
expect(result).toEqualLeft(TEAM_COLL_DATA_INVALID);
});
test('should throw TEAM_COLL_NOT_FOUND is collectionID is invalid', async () => {
mockPrisma.teamCollection.update.mockRejectedValueOnce('RecordNotFound');
const result = await teamCollectionService.updateTeamCollection(
'invalid_id',
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.title,
);
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
});
test('should successfully update a collection', async () => {
mockPrisma.teamCollection.update.mockResolvedValueOnce(rootTeamCollection);
const result = await teamCollectionService.updateTeamCollection(
rootTeamCollection.id,
JSON.stringify({ foo: 'bar' }),
'new_title',
);
expect(result).toEqualRight({
data: JSON.stringify({ foo: 'bar' }),
title: 'new_title',
...rootTeamCollectionsCasted,
});
});
test('should send pubsub message to "team_coll/<teamID>/coll_updated" if TeamCollection is updated successfully', async () => {
mockPrisma.teamCollection.update.mockResolvedValueOnce(rootTeamCollection);
const result = await teamCollectionService.updateTeamCollection(
rootTeamCollection.id,
JSON.stringify(rootTeamCollection.data),
rootTeamCollection.title,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll/${rootTeamCollection.teamID}/coll_updated`,
rootTeamCollectionsCasted,
);
});
});
//ToDo: write test cases for exportCollectionsToJSON

View File

@@ -13,7 +13,6 @@ import {
TEAM_COLL_IS_PARENT_COLL,
TEAM_COL_SAME_NEXT_COLL,
TEAM_COL_REORDERING_FAILED,
TEAM_COLL_DATA_INVALID,
} from '../errors';
import { PubSubService } from '../pubsub/pubsub.service';
import { isValidLength } from 'src/utils';
@@ -70,7 +69,6 @@ export class TeamCollectionService {
this.generatePrismaQueryObjForFBCollFolder(f, teamID, index + 1),
),
},
data: folder.data ?? undefined,
};
}
@@ -120,7 +118,6 @@ export class TeamCollectionService {
name: collection.right.title,
folders: childrenCollectionObjects,
requests: requests.map((x) => x.request),
data: JSON.stringify(collection.right.data),
};
return E.right(result);
@@ -201,11 +198,8 @@ export class TeamCollectionService {
),
);
teamCollections.forEach((collection) =>
this.pubsub.publish(
`team_coll/${destTeamID}/coll_added`,
this.cast(collection),
),
teamCollections.forEach((x) =>
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
);
return E.right(true);
@@ -274,11 +268,8 @@ export class TeamCollectionService {
),
);
teamCollections.forEach((collections) =>
this.pubsub.publish(
`team_coll/${destTeamID}/coll_added`,
this.cast(collections),
),
teamCollections.forEach((x) =>
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
);
return E.right(true);
@@ -286,17 +277,11 @@ export class TeamCollectionService {
/**
* Typecast a database TeamCollection to a TeamCollection model
*
* @param teamCollection database TeamCollection
* @returns TeamCollection model
*/
private cast(teamCollection: DBTeamCollection): TeamCollection {
return <TeamCollection>{
id: teamCollection.id,
title: teamCollection.title,
parentID: teamCollection.parentID,
data: !teamCollection.data ? null : JSON.stringify(teamCollection.data),
};
return <TeamCollection>{ ...teamCollection };
}
/**
@@ -339,7 +324,7 @@ export class TeamCollectionService {
});
if (!teamCollection) return null;
return !teamCollection.parent ? null : this.cast(teamCollection.parent);
return teamCollection.parent;
}
/**
@@ -350,12 +335,12 @@ export class TeamCollectionService {
* @param take Number of items we want returned
* @returns A list of child collections
*/
async getChildrenOfCollection(
getChildrenOfCollection(
collectionID: string,
cursor: string | null,
take: number,
) {
const res = await this.prisma.teamCollection.findMany({
return this.prisma.teamCollection.findMany({
where: {
parentID: collectionID,
},
@@ -366,12 +351,6 @@ export class TeamCollectionService {
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
const childCollections = res.map((teamCollection) =>
this.cast(teamCollection),
);
return childCollections;
}
/**
@@ -387,7 +366,7 @@ export class TeamCollectionService {
cursor: string | null,
take: number,
) {
const res = await this.prisma.teamCollection.findMany({
return this.prisma.teamCollection.findMany({
where: {
teamID,
parentID: null,
@@ -399,12 +378,6 @@ export class TeamCollectionService {
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
const teamCollections = res.map((teamCollection) =>
this.cast(teamCollection),
);
return teamCollections;
}
/**
@@ -497,7 +470,6 @@ export class TeamCollectionService {
async createCollection(
teamID: string,
title: string,
data: string | null = null,
parentTeamCollectionID: string | null,
) {
const isTitleValid = isValidLength(title, this.TITLE_LENGTH);
@@ -509,13 +481,6 @@ export class TeamCollectionService {
if (O.isNone(isOwner)) return E.left(TEAM_NOT_OWNER);
}
if (data === '') return E.left(TEAM_COLL_DATA_INVALID);
if (data) {
const jsonReq = stringToJson(data);
if (E.isLeft(jsonReq)) return E.left(TEAM_COLL_DATA_INVALID);
data = jsonReq.right;
}
const isParent = parentTeamCollectionID
? {
connect: {
@@ -533,23 +498,18 @@ export class TeamCollectionService {
},
},
parent: isParent,
data: data ?? undefined,
orderIndex: !parentTeamCollectionID
? (await this.getRootCollectionsCount(teamID)) + 1
: (await this.getChildCollectionsCount(parentTeamCollectionID)) + 1,
},
});
this.pubsub.publish(
`team_coll/${teamID}/coll_added`,
this.cast(teamCollection),
);
this.pubsub.publish(`team_coll/${teamID}/coll_added`, teamCollection);
return E.right(this.cast(teamCollection));
}
/**
* @deprecated Use updateTeamCollection method instead
* Update the title of a TeamCollection
*
* @param collectionID The Collection ID
@@ -572,10 +532,10 @@ export class TeamCollectionService {
this.pubsub.publish(
`team_coll/${updatedTeamCollection.teamID}/coll_updated`,
this.cast(updatedTeamCollection),
updatedTeamCollection,
);
return E.right(this.cast(updatedTeamCollection));
return E.right(updatedTeamCollection);
} catch (error) {
return E.left(TEAM_COLL_NOT_FOUND);
}
@@ -734,8 +694,8 @@ export class TeamCollectionService {
* @returns An Option of boolean, is parent or not
*/
private async isParent(
collection: DBTeamCollection,
destCollection: DBTeamCollection,
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
@@ -1011,49 +971,4 @@ export class TeamCollectionService {
const teamCollectionsCount = this.prisma.teamCollection.count();
return teamCollectionsCount;
}
/**
* Update Team Collection details
*
* @param collectionID Collection ID
* @param collectionData new header data in a JSONified string form
* @param newTitle New title of the collection
* @returns Updated TeamCollection
*/
async updateTeamCollection(
collectionID: string,
collectionData: string = null,
newTitle: string = null,
) {
try {
if (newTitle != null) {
const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE);
}
if (collectionData === '') return E.left(TEAM_COLL_DATA_INVALID);
if (collectionData) {
const jsonReq = stringToJson(collectionData);
if (E.isLeft(jsonReq)) return E.left(TEAM_COLL_DATA_INVALID);
collectionData = jsonReq.right;
}
const updatedTeamCollection = await this.prisma.teamCollection.update({
where: { id: collectionID },
data: {
data: collectionData ?? undefined,
title: newTitle ?? undefined,
},
});
this.pubsub.publish(
`team_coll/${updatedTeamCollection.teamID}/coll_updated`,
this.cast(updatedTeamCollection),
);
return E.right(this.cast(updatedTeamCollection));
} catch (e) {
return E.left(TEAM_COLL_NOT_FOUND);
}
}
}

View File

@@ -1,5 +1,15 @@
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,
@@ -9,10 +19,6 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors';
import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/**
* A guard which checks whether the caller of a GQL Operation
@@ -27,31 +33,50 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
canActivate(context: ExecutionContext): Promise<boolean> {
return pipe(
TE.Do,
const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('requiredRoles', () =>
pipe(
getAnnotatedRequiredRoles(this.reflector, context),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
),
),
const { user } = gqlExecCtx.getContext().req;
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
TE.bindW('user', () =>
pipe(
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const { id } = gqlExecCtx.getArgs<{ id: string }>();
if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
TE.bindW('envID', () =>
pipe(
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
const teamEnvironment =
await this.teamEnvironmentService.getTeamEnvironment(id);
if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
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),
),
),
),
),
const member = await this.teamService.getTeamMember(
teamEnvironment.right.teamID,
user.uid,
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
TE.map(({ membership, requiredRoles }) =>
requiredRoles.includes(membership.role),
),
return requireRoles.includes(member.role);
TE.getOrElse(throwErr),
)();
}
}

View File

@@ -1,41 +0,0 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
@ArgsType()
export class CreateTeamEnvironmentArgs {
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team',
})
teamID: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}
@ArgsType()
export class UpdateTeamEnvironmentArgs {
@Field(() => ID, {
name: 'id',
description: 'ID of the Team Environment',
})
id: string;
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}

View File

@@ -13,11 +13,6 @@ 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';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment')
@@ -34,18 +29,29 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async createTeamEnvironment(
@Args() args: CreateTeamEnvironmentArgs,
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> {
const teamEnvironment =
await this.teamEnvironmentsService.createTeamEnvironment(
args.name,
args.teamID,
args.variables,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
return this.teamEnvironmentsService.createTeamEnvironment(
name,
teamID,
variables,
)();
}
@Mutation(() => Boolean, {
@@ -53,7 +59,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async deleteTeamEnvironment(
deleteTeamEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -61,12 +67,10 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<boolean> {
const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
id,
);
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
return pipe(
this.teamEnvironmentsService.deleteTeamEnvironment(id),
TE.getOrElse(throwErr),
)();
}
@Mutation(() => TeamEnvironment, {
@@ -75,19 +79,28 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async updateTeamEnvironment(
@Args()
args: UpdateTeamEnvironmentArgs,
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> {
const updatedTeamEnvironment =
await this.teamEnvironmentsService.updateTeamEnvironment(
args.id,
args.name,
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
return pipe(
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
TE.getOrElse(throwErr),
)();
}
@Mutation(() => TeamEnvironment, {
@@ -95,7 +108,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async deleteAllVariablesFromTeamEnvironment(
deleteAllVariablesFromTeamEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -103,13 +116,10 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<TeamEnvironment> {
const teamEnvironment =
await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
id,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
return pipe(
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
TE.getOrElse(throwErr),
)();
}
@Mutation(() => TeamEnvironment, {
@@ -117,7 +127,7 @@ export class TeamEnvironmentsResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async createDuplicateEnvironment(
createDuplicateEnvironment(
@Args({
name: 'id',
description: 'ID of the Team Environment',
@@ -125,12 +135,10 @@ export class TeamEnvironmentsResolver {
})
id: string,
): Promise<TeamEnvironment> {
const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
id,
);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
return pipe(
this.teamEnvironmentsService.createDuplicateEnvironment(id),
TE.getOrElse(throwErr),
)();
}
/* Subscriptions */

View File

@@ -2,11 +2,7 @@ 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 {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
const mockPrisma = mockDeep<PrismaService>();
@@ -35,81 +31,125 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => {
test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
test('queries the db with the id', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: '123',
},
}),
);
expect(result).toEqualRight(teamEnvironment);
});
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
test('requests prisma to reject the query promise if not found', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
rejectOnNotFound: true,
}),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
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 successfully create and return a new team environment given valid inputs', async () => {
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).toEqualRight({
...teamEnvironment,
expect(result).toEqual(<TeamEnvironment>{
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
});
});
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.createTeamEnvironment(
'12',
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
);
test('should reject if given team ID is invalid', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
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.mockResolvedValue(teamEnvironment);
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`,
{
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
result,
);
});
});
describe('deleteTeamEnvironment', () => {
test('should successfully delete a TeamEnvironment with a valid ID', async () => {
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);
});
@@ -119,7 +159,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid',
);
)();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
@@ -129,7 +169,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id,
);
)();
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`,
@@ -142,7 +182,7 @@ describe('TeamEnvironmentsService', () => {
});
describe('updateVariablesInTeamEnvironment', () => {
test('should successfully add new variable to a team environment', async () => {
test('should add new variable to a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: 'value' }],
@@ -152,7 +192,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }]),
);
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -160,7 +200,7 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should successfully add new variable to already existing list of variables in a team environment', async () => {
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' }],
@@ -170,7 +210,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
);
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -178,7 +218,7 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should successfully edit existing variables in a team environment', async () => {
test('should edit existing variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment,
variables: [{ key: '1234' }],
@@ -188,7 +228,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: '1234' }]),
);
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -196,7 +236,22 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should successfully edit name of an existing team environment', async () => {
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' }],
@@ -206,7 +261,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: '123' }]),
);
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
@@ -214,24 +269,14 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
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);
});
@@ -243,7 +288,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{ key: 'value' }]),
);
)();
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`,
@@ -256,13 +301,13 @@ describe('TeamEnvironmentsService', () => {
});
describe('deleteAllVariablesFromTeamEnvironment', () => {
test('should successfully delete all variables in a team environment', async () => {
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,
@@ -270,13 +315,13 @@ describe('TeamEnvironmentsService', () => {
});
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
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);
});
@@ -287,7 +332,7 @@ describe('TeamEnvironmentsService', () => {
const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id,
);
)();
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`,
@@ -300,58 +345,56 @@ describe('TeamEnvironmentsService', () => {
});
describe('createDuplicateEnvironment', () => {
test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
test('should duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment,
);
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
id: 'newid',
...teamEnvironment,
id: 'newid',
});
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
);
)();
expect(result).toEqualRight(<TeamEnvironment>{
id: 'newid',
...teamEnvironment,
id: 'newid',
variables: JSON.stringify(teamEnvironment.variables),
});
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValue(
'NotFoundError',
);
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.findFirstOrThrow.mockResolvedValueOnce(
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment,
);
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
id: 'newid',
...teamEnvironment,
id: 'newid',
});
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
);
)();
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`,
{
id: 'newid',
...teamEnvironment,
id: 'newid',
variables: JSON.stringify([{}]),
},
);

View File

@@ -1,14 +1,15 @@
import { Injectable } from '@nestjs/common';
import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
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,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
@Injectable()
export class TeamEnvironmentsService {
constructor(
@@ -16,217 +17,219 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService,
) {}
TITLE_LENGTH = 3;
/**
* TeamEnvironments are saved in the DB in the following way
* [{ key: value }, { key: value },....]
*
*/
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
}
/**
* Get details of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
});
const createdTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${createdTeamEnvironment.teamID}/created`,
createdTeamEnvironment,
getTeamEnvironment(id: string) {
return TO.tryCatch(() =>
this.prisma.teamEnvironment.findFirst({
where: { id },
rejectOnNotFound: true,
}),
);
return E.right(createdTeamEnvironment);
}
/**
* Delete a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: {
id: id,
},
});
const deletedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
deletedTeamEnvironment,
);
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
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),
};
}),
);
}
/**
* Update a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
});
const updatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
updatedTeamEnvironment,
);
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
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),
);
}
/**
* Clear contents of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
});
const teamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${teamEnvironment.teamID}/updated`,
teamEnvironment,
);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
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),
},
),
);
}
/**
* Create a duplicate of a existing TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirstOrThrow({
where: {
id: id,
},
});
const result = await this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
});
const duplicatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
duplicatedTeamEnvironment,
);
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
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),
},
),
);
}
/**
* Fetch all TeamEnvironments of a team.
*
* @param teamID teamID of new TeamEnvironment
* @returns List of TeamEnvironments
*/
async fetchAllTeamEnvironments(teamID: string) {
const result = await this.prisma.teamEnvironment.findMany({
where: {
teamID: teamID,
},
});
const teamEnvironments = result.map((item) => {
return this.cast(item);
});
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),
},
),
);
}
return teamEnvironments;
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),
},
),
),
);
}
/**

View File

@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
description: 'Returns all Team Environments for the given Team',
})
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
}
}

View File

@@ -1,20 +0,0 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { TeamMemberRole } from 'src/team/team.model';
@ArgsType()
export class CreateTeamInvitationArgs {
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team ID to invite from',
})
teamID: string;
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
inviteeEmail: string;
@Field(() => TeamMemberRole, {
name: 'inviteeRole',
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole;
}

View File

@@ -12,10 +12,15 @@ 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 E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
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';
@@ -31,8 +36,6 @@ 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';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation)
@@ -76,8 +79,8 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists',
})
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
async teamInvitation(
@GqlUser() user: AuthUser,
teamInvitation(
@GqlUser() user: User,
@Args({
name: 'inviteID',
description: 'ID of the Team Invitation to lookup',
@@ -85,11 +88,17 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<TeamInvitation> {
const teamInvitation = await this.teamInvitationService.getInvitation(
inviteID,
);
if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
return teamInvitation.value;
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, {
@@ -97,19 +106,56 @@ export class TeamInvitationResolver {
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
async createTeamInvitation(
@GqlUser() user: AuthUser,
@Args() args: CreateTeamInvitationArgs,
): Promise<TeamInvitation> {
const teamInvitation = await this.teamInvitationService.createInvitation(
user,
args.teamID,
args.inviteeEmail,
args.inviteeRole,
);
createTeamInvitation(
@GqlUser()
user: User,
if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
return teamInvitation.right;
@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, {
@@ -117,7 +163,7 @@ export class TeamInvitationResolver {
})
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
async revokeTeamInvitation(
revokeTeamInvitation(
@Args({
name: 'inviteID',
type: () => ID,
@@ -125,19 +171,19 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<true> {
const isRevoked = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
return true;
return pipe(
this.teamInvitationService.revokeInvitation(inviteID),
TE.map(() => true as const),
TE.getOrElse(throwErr),
)();
}
@Mutation(() => TeamMember, {
description: 'Accept an Invitation',
})
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
async acceptTeamInvitation(
@GqlUser() user: AuthUser,
acceptTeamInvitation(
@GqlUser() user: User,
@Args({
name: 'inviteID',
type: () => ID,
@@ -145,12 +191,10 @@ export class TeamInvitationResolver {
})
inviteID: string,
): Promise<TeamMember> {
const teamMember = await this.teamInvitationService.acceptInvitation(
inviteID,
user,
);
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
return pipe(
this.teamInvitationService.acceptInvitation(inviteID, user),
TE.getOrElse(throwErr),
)();
}
// Subscriptions

View File

@@ -1,25 +1,24 @@
import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
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 { TeamInvitation as DBTeamInvitation } from '@prisma/client';
import { TeamMember, TeamMemberRole } from 'src/team/team.model';
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 {
INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_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';
import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable()
export class TeamInvitationService {
@@ -30,221 +29,245 @@ export class TeamInvitationService {
private readonly mailerService: MailerService,
private readonly pubsub: PubSubService,
) {}
/**
* Cast a DBTeamInvitation to a TeamInvitation
* @param dbTeamInvitation database TeamInvitation
* @returns TeamInvitation model
*/
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return {
...dbTeamInvitation,
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
};
) {
this.getInvitation = this.getInvitation.bind(this);
}
/**
* Get the team invite
* @param inviteID invite id
* @returns an Option of team invitation or none
*/
async getInvitation(inviteID: string) {
try {
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
id: inviteID,
},
});
return O.some(this.cast(dbInvitation));
} catch (e) {
return O.none;
}
}
/**
* Get the team invite for an invitee with email and teamID.
* @param inviteeEmail invitee email
* @param teamID team id
* @returns an Either of team invitation for the invitee or error
*/
async getTeamInviteByEmailAndTeamID(inviteeEmail: string, teamID: string) {
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
try {
const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
teamID_inviteeEmail: {
inviteeEmail: inviteeEmail,
teamID: teamID,
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
return pipe(
() =>
this.prisma.teamInvitation.findUnique({
where: {
id: inviteID,
},
},
});
return E.right(teamInvite);
} catch (e) {
return E.left(TEAM_INVITE_NO_INVITE_FOUND);
}
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
TO.map((x) => x as TeamInvitation),
);
}
/**
* Create a team invitation
* @param creator creator of the invitation
* @param teamID team id
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
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,
) {
// validate email
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
return pipe(
// Perform all validation checks
TE.sequenceArray([
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// team ID should valid
const team = await this.teamService.getTeamWithID(teamID);
if (!team) return E.left(TEAM_INVALID_ID);
// 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),
),
// invitation creator should be a TeamMember
const isTeamMember = await this.teamService.getTeamMember(
team.id,
creator.uid,
// 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),
);
if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
}
// Checking to see if the invitee is already part of the team or not
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
if (O.isSome(inviteeUser)) {
// invitee should not already a member
const isTeamMember = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
revokeInvitation(inviteID: string) {
return pipe(
// Make sure invite exists
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
// check invitee already invited earlier or not
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
inviteeEmail,
team.id,
// 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),
);
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
}
// create the invitation
const dbInvitation = await this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
});
getAllInvitationsInTeam(team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findMany({
where: {
teamID: team.id,
},
}),
T.map((x) => x as TeamInvitation[]),
);
}
await this.mailerService.sendEmail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
acceptInvitation(inviteID: string, acceptedBy: User) {
return pipe(
TE.Do,
const invitation = this.cast(dbInvitation);
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
// First get the invitation
TE.bindW('invitation', () =>
pipe(
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
return E.right(invitation);
// 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),
);
}
/**
* Revoke a team invitation
* @param inviteID invite id
* @returns an Either of true or error message
*/
async revokeInvitation(inviteID: string) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// delete the invite
await this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
});
this.pubsub.publish(
`team/${invitation.value.teamID}/invite_removed`,
invitation.value.id,
);
return E.right(true);
}
/**
* Accept a team invitation
* @param inviteID invite id
* @param acceptedBy user who accepted the invitation
* @returns an Either of team member or error message
*/
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// make sure the user is not already a member of the team
const teamMemberInvitee = await this.teamService.getTeamMember(
invitation.value.teamID,
acceptedBy.uid,
);
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
// make sure the user is the same as the invitee
if (
acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// add the user to the team
let teamMember: TeamMember;
try {
teamMember = await this.teamService.addMemberToTeam(
invitation.value.teamID,
acceptedBy.uid,
invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// delete the invite
await this.revokeInvitation(inviteID);
return E.right(teamMember);
}
/**
* Fetch all team invitations for a given team.
* Fetch the count invitations for a given team.
* @param teamID team id
* @returns array of team invitations for a team
* @returns a count team invitations for a team
*/
async getTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({
async getAllTeamInvitations(teamID: string) {
const invitations = await this.prisma.teamInvitation.findMany({
where: {
teamID: teamID,
},
});
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations;
}
}

View File

@@ -1,21 +1,21 @@
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_MEMBER_NOT_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';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor(
@@ -24,30 +24,48 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL context
const gqlExecCtx = GqlExecutionContext.create(context);
return pipe(
TE.Do,
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
// 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),
),
),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.bindW('user', ({ gqlCtx }) =>
pipe(
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Fetch team member details of this user
const teamMember = await this.teamService.getTeamMember(
invitation.value.teamID,
user.uid,
);
TE.bindW('userMember', ({ invite, user }) =>
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
),
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
if (teamMember.role !== TeamMemberRole.OWNER)
throwErr(TEAM_NOT_REQUIRED_ROLE);
TE.chainW(
TE.fromPredicate(
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
() => TEAM_NOT_REQUIRED_ROLE,
),
),
return true;
TE.fold(
(err) => throwErr(err),
() => T.of(true),
),
)();
}
}

View File

@@ -1,23 +1,20 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql';
import {
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable()
export class TeamInviteViewerGuard implements CanActivate {
constructor(
@@ -26,32 +23,50 @@ export class TeamInviteViewerGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL context
const gqlExecCtx = GqlExecutionContext.create(context);
return pipe(
TE.Do,
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get GQL Context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
// Get user
TE.bindW('user', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
// 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(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the user and the invite email match, else if user is a team member
if (
user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
) {
const teamMember = await this.teamService.getTeamMember(
invitation.value.teamID,
user.uid,
);
// Check if the user and the invite email match, else if we can resolver the user as a team member
// any better solution ?
TE.chainW(({ user, invite }) =>
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
? TE.of(true)
: pipe(
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
TE.map(() => true),
),
),
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
}
TE.mapLeft((e) =>
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e,
),
return true;
TE.fold(throwErr, () => T.of(true)),
)();
}
}

View File

@@ -1,7 +1,11 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
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 { User } from 'src/user/user.model';
import {
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -20,26 +24,44 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL Context
const gqlExecCtx = GqlExecutionContext.create(context);
return pipe(
TE.Do,
// Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get execution context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
// Get user
TE.bindW('user', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID);
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
// Get invite
TE.bindW('invite', ({ gqlCtx }) =>
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
if (
user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
) {
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
}
// Check if the emails match
TE.chainW(
TE.fromPredicate(
({ user, invite }) => user.email === invite.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
return true;
// Fold it to a promise
TE.fold(throwErr, () => T.of(true)),
)();
}
}

View File

@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
complexity: 10,
})
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getTeamInvitations(team.id);
return this.teamInviteService.getAllInvitationsInTeam(team)();
}
}

View File

@@ -42,7 +42,6 @@ const teamCollection: DbTeamCollection = {
id: 'team-coll-1',
parentID: null,
teamID: team.id,
data: {},
title: 'Team Collection 1',
orderIndex: 1,
createdOn: new Date(),

View File

@@ -2,7 +2,7 @@ import { HttpStatus } from '@nestjs/common';
/**
** Custom interface to handle errors specific to Auth module
** Since its REST we need to return the HTTP status code along with the error message
** Since its REST we need to return HTTP status code along with error message
*/
export type AuthError = {
message: string;

View File

@@ -1,8 +1,6 @@
// This interface defines how data will be received from the app when we are importing Hoppscotch collections
export interface CollectionFolder {
id?: string;
folders: CollectionFolder[];
requests: any[];
name: string;
data?: string;
}

View File

@@ -6,13 +6,6 @@ import { PaginationArgs } from 'src/types/input-types.args';
export class CreateRootUserCollectionArgs {
@Field({ name: 'title', description: 'Title of the new user collection' })
title: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}
@ArgsType()
export class CreateChildUserCollectionArgs {
@@ -24,13 +17,6 @@ export class CreateChildUserCollectionArgs {
description: 'ID of the parent to the new user collection',
})
parentUserCollectionID: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}
@ArgsType()
@@ -109,26 +95,3 @@ export class ImportUserCollectionsFromJSONArgs {
})
parentCollectionID?: string;
}
@ArgsType()
export class UpdateUserCollectionsArgs {
@Field(() => ID, {
name: 'userCollectionID',
description: 'ID of the user collection',
})
userCollectionID: string;
@Field({
name: 'newTitle',
description: 'The updated title of the user collection',
nullable: true,
})
newTitle: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}

View File

@@ -30,7 +30,6 @@ import {
MoveUserCollectionArgs,
RenameUserCollectionsArgs,
UpdateUserCollectionArgs,
UpdateUserCollectionsArgs,
} from './input-type.args';
import { ReqType } from 'src/types/RequestTypes';
import * as E from 'fp-ts/Either';
@@ -143,13 +142,7 @@ export class UserCollectionResolver {
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return <UserCollection>{
...userCollection.right,
userID: userCollection.right.userUid,
data: !userCollection.right.data
? null
: JSON.stringify(userCollection.right.data),
};
return userCollection.right;
}
@Query(() => UserCollectionExportJSONData, {
@@ -198,7 +191,6 @@ export class UserCollectionResolver {
await this.userCollectionService.createUserCollection(
user,
args.title,
args.data,
null,
ReqType.REST,
);
@@ -220,7 +212,6 @@ export class UserCollectionResolver {
await this.userCollectionService.createUserCollection(
user,
args.title,
args.data,
null,
ReqType.GQL,
);
@@ -241,7 +232,6 @@ export class UserCollectionResolver {
await this.userCollectionService.createUserCollection(
user,
args.title,
args.data,
args.parentUserCollectionID,
ReqType.GQL,
);
@@ -262,7 +252,6 @@ export class UserCollectionResolver {
await this.userCollectionService.createUserCollection(
user,
args.title,
args.data,
args.parentUserCollectionID,
ReqType.REST,
);
@@ -370,26 +359,6 @@ export class UserCollectionResolver {
return importedCollection.right;
}
@Mutation(() => UserCollection, {
description: 'Update a UserCollection',
})
@UseGuards(GqlAuthGuard)
async updateUserCollection(
@GqlUser() user: AuthUser,
@Args() args: UpdateUserCollectionsArgs,
) {
const updatedUserCollection =
await this.userCollectionService.updateUserCollection(
args.newTitle,
args.data,
args.userCollectionID,
user.uid,
);
if (E.isLeft(updatedUserCollection)) throwErr(updatedUserCollection.left);
return updatedUserCollection.right;
}
// Subscriptions
@Subscription(() => UserCollection, {
description: 'Listen for User Collection Creation',

View File

@@ -12,7 +12,6 @@ import {
USER_NOT_FOUND,
USER_NOT_OWNER,
USER_COLL_INVALID_JSON,
USER_COLL_DATA_INVALID,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthUser } from 'src/types/AuthUser';
@@ -44,12 +43,8 @@ export class UserCollectionService {
*/
private cast(collection: UserCollection) {
return <UserCollectionModel>{
id: collection.id,
title: collection.title,
type: collection.type,
parentID: collection.parentID,
...collection,
userID: collection.userUid,
data: !collection.data ? null : JSON.stringify(collection.data),
};
}
@@ -151,7 +146,7 @@ export class UserCollectionService {
},
});
return !parent ? null : this.cast(parent);
return parent;
}
/**
@@ -169,7 +164,7 @@ export class UserCollectionService {
take: number,
type: ReqType,
) {
const res = await this.prisma.userCollection.findMany({
return this.prisma.userCollection.findMany({
where: {
parentID: collectionID,
type: type,
@@ -181,12 +176,6 @@ export class UserCollectionService {
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
const childCollections = res.map((childCollection) =>
this.cast(childCollection),
);
return childCollections;
}
/**
@@ -222,20 +211,12 @@ export class UserCollectionService {
async createUserCollection(
user: AuthUser,
title: string,
data: string | null = null,
parentUserCollectionID: string | null,
type: ReqType,
) {
const isTitleValid = isValidLength(title, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE);
if (data === '') return E.left(USER_COLL_DATA_INVALID);
if (data) {
const jsonReq = stringToJson(data);
if (E.isLeft(jsonReq)) return E.left(USER_COLL_DATA_INVALID);
data = jsonReq.right;
}
// If creating a child collection
if (parentUserCollectionID !== null) {
const parentCollection = await this.getUserCollection(
@@ -270,19 +251,15 @@ export class UserCollectionService {
},
},
parent: isParent,
data: data ?? undefined,
orderIndex: !parentUserCollectionID
? (await this.getRootCollectionsCount(user.uid)) + 1
: (await this.getChildCollectionsCount(parentUserCollectionID)) + 1,
},
});
await this.pubsub.publish(
`user_coll/${user.uid}/created`,
this.cast(userCollection),
);
await this.pubsub.publish(`user_coll/${user.uid}/created`, userCollection);
return E.right(this.cast(userCollection));
return E.right(userCollection);
}
/**
@@ -299,7 +276,7 @@ export class UserCollectionService {
take: number,
type: ReqType,
) {
const res = await this.prisma.userCollection.findMany({
return this.prisma.userCollection.findMany({
where: {
userUid: user.uid,
parentID: null,
@@ -312,12 +289,6 @@ export class UserCollectionService {
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
const userCollections = res.map((childCollection) =>
this.cast(childCollection),
);
return userCollections;
}
/**
@@ -336,7 +307,7 @@ export class UserCollectionService {
take: number,
type: ReqType,
) {
const res = await this.prisma.userCollection.findMany({
return this.prisma.userCollection.findMany({
where: {
userUid: user.uid,
parentID: userCollectionID,
@@ -346,16 +317,9 @@ export class UserCollectionService {
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
const childCollections = res.map((childCollection) =>
this.cast(childCollection),
);
return childCollections;
}
/**
* @deprecated Use updateUserCollection method instead
* Update the title of a UserCollection
*
* @param newTitle The new title of collection
@@ -387,10 +351,10 @@ export class UserCollectionService {
this.pubsub.publish(
`user_coll/${updatedUserCollection.userUid}/updated`,
this.cast(updatedUserCollection),
updatedUserCollection,
);
return E.right(this.cast(updatedUserCollection));
return E.right(updatedUserCollection);
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
@@ -627,10 +591,10 @@ export class UserCollectionService {
this.pubsub.publish(
`user_coll/${collection.right.userUid}/moved`,
this.cast(updatedCollection.right),
updatedCollection.right,
);
return E.right(this.cast(updatedCollection.right));
return E.right(updatedCollection.right);
}
// destCollectionID != null i.e move into another collection
@@ -678,10 +642,10 @@ export class UserCollectionService {
this.pubsub.publish(
`user_coll/${collection.right.userUid}/moved`,
this.cast(updatedCollection.right),
updatedCollection.right,
);
return E.right(this.cast(updatedCollection.right));
return E.right(updatedCollection.right);
}
/**
@@ -882,7 +846,6 @@ export class UserCollectionService {
...(x.request as Record<string, unknown>), // type casting x.request of type Prisma.JSONValue to an object to enable spread
};
}),
data: JSON.stringify(collection.right.data),
};
return E.right(result);
@@ -955,7 +918,6 @@ export class UserCollectionService {
...(x.request as Record<string, unknown>), // type casting x.request of type Prisma.JSONValue to an object to enable spread
};
}),
data: JSON.stringify(parentCollection.right.data),
}),
collectionType: parentCollection.right.type,
});
@@ -1009,7 +971,6 @@ export class UserCollectionService {
this.generatePrismaQueryObj(f, userID, index + 1, reqType),
),
},
data: folder.data ?? undefined,
};
}
@@ -1079,63 +1040,10 @@ export class UserCollectionService {
),
);
userCollections.forEach((collection) =>
this.pubsub.publish(`user_coll/${userID}/created`, this.cast(collection)),
userCollections.forEach((x) =>
this.pubsub.publish(`user_coll/${userID}/created`, x),
);
return E.right(true);
}
/**
* Update a UserCollection
*
* @param newTitle The new title of collection
* @param userCollectionID The Collection Id
* @param userID The User UID
* @returns An Either of the updated UserCollection
*/
async updateUserCollection(
newTitle: string = null,
collectionData: string | null = null,
userCollectionID: string,
userID: string,
) {
if (collectionData === '') return E.left(USER_COLL_DATA_INVALID);
if (collectionData) {
const jsonReq = stringToJson(collectionData);
if (E.isLeft(jsonReq)) return E.left(USER_COLL_DATA_INVALID);
collectionData = jsonReq.right;
}
if (newTitle != null) {
const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE);
}
// Check to see is the collection belongs to the user
const isOwner = await this.isOwnerCheck(userCollectionID, userID);
if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER);
try {
const updatedUserCollection = await this.prisma.userCollection.update({
where: {
id: userCollectionID,
},
data: {
data: collectionData ?? undefined,
title: newTitle ?? undefined,
},
});
this.pubsub.publish(
`user_coll/${updatedUserCollection.userUid}/updated`,
this.cast(updatedUserCollection),
);
return E.right(this.cast(updatedUserCollection));
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
}
}

View File

@@ -13,12 +13,6 @@ export class UserCollection {
})
title: string;
@Field({
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
@Field(() => ReqType, {
description: 'Type of the user collection',
})

Some files were not shown because too many files have changed in this diff Show More