Compare commits
164 Commits
feat/subpa
...
hotfix/req
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9ea0cbc7 | ||
|
|
8844d52f5f | ||
|
|
772c1e65e5 | ||
|
|
dce396c164 | ||
|
|
07e8af7947 | ||
|
|
e69d5a6253 | ||
|
|
6d66d12a9e | ||
|
|
439cd82c88 | ||
|
|
6dbaf524ce | ||
|
|
68e439d1a4 | ||
|
|
8deba7a28e | ||
|
|
7ec8659381 | ||
|
|
3611cac241 | ||
|
|
919579b1da | ||
|
|
4798d7bbbd | ||
|
|
a0c6b22641 | ||
|
|
de8929ab18 | ||
|
|
55a94bdccc | ||
|
|
faab1d20fd | ||
|
|
bd406616ec | ||
|
|
6827e97ec5 | ||
|
|
10d2048975 | ||
|
|
291f18591e | ||
|
|
342532c9b1 | ||
|
|
cf039c482a | ||
|
|
ded2725116 | ||
|
|
9c6754c70f | ||
|
|
4bd54b12cd | ||
|
|
ed6e9b6954 | ||
|
|
dfdd44b4ed | ||
|
|
fc34871dae | ||
|
|
45b532747e | ||
|
|
de4635df23 | ||
|
|
41bad1f3dc | ||
|
|
ecca3d2032 | ||
|
|
47226be6d0 | ||
|
|
6a0e73fdec | ||
|
|
672ee69b2c | ||
|
|
b359650d96 | ||
|
|
c0fae79678 | ||
|
|
5bcc38e36b | ||
|
|
00862eb192 | ||
|
|
16803acb26 | ||
|
|
3911c9cd1f | ||
|
|
0028f6e878 | ||
|
|
0ba33ec187 | ||
|
|
3482743782 | ||
|
|
d7cdeb796a | ||
|
|
3d6adcc39d | ||
|
|
aab76f1358 | ||
|
|
a28a576c41 | ||
|
|
0d0ad7a2f8 | ||
|
|
1df9de44b7 | ||
|
|
4cba03e53f | ||
|
|
9e1466a877 | ||
|
|
b81ccb4ee3 | ||
|
|
27d0a7c437 | ||
|
|
aca96dd5f2 | ||
|
|
c0dbcc901f | ||
|
|
ba52c8cc37 | ||
|
|
d1f6f40ef8 | ||
|
|
99f5070f71 | ||
|
|
cd371fc9d4 | ||
|
|
59fef248c0 | ||
|
|
286fcd2bb0 | ||
|
|
b2d98f7b66 | ||
|
|
c6c220091a | ||
|
|
8f503479b6 | ||
|
|
54d8378ccf | ||
|
|
0df194f9c5 | ||
|
|
ddf7eb6ad6 | ||
|
|
7db7b9b068 | ||
|
|
3d25ef48d1 | ||
|
|
4f138beb8a | ||
|
|
3d7a76bced | ||
|
|
74359ea74e | ||
|
|
a694d3f7eb | ||
|
|
58a9514b67 | ||
|
|
a75bfa9d9e | ||
|
|
7374a35b41 | ||
|
|
5ad8f6c2ce | ||
|
|
f28298afe7 | ||
|
|
56c6e8c643 | ||
|
|
1b36de4fa3 | ||
|
|
2f773bec79 | ||
|
|
d3e04c59cc | ||
|
|
5179cf59a4 | ||
|
|
fad31a47ee | ||
|
|
72c71ddbd4 | ||
|
|
a0f5ebee39 | ||
|
|
f93558324f | ||
|
|
d80e6c01c8 | ||
|
|
06f0f1c91b | ||
|
|
9b870f876a | ||
|
|
cf8b5975ac | ||
|
|
93082c3816 | ||
|
|
d66537ac34 | ||
|
|
fc4c15e52d | ||
|
|
b521604b66 | ||
|
|
9bc81a6d67 | ||
|
|
c47e2e7767 | ||
|
|
5209c0a8ca | ||
|
|
47e009267b | ||
|
|
f3edd001d7 | ||
|
|
a8cc569786 | ||
|
|
3ae49ca483 | ||
|
|
37e6497e88 | ||
|
|
b522ae9e05 | ||
|
|
62b11fcec8 | ||
|
|
51ebb57623 | ||
|
|
ff5c2ba51c | ||
|
|
6abc0e6071 | ||
|
|
957641fb0f | ||
|
|
a55f214102 | ||
|
|
ebf90207e5 | ||
|
|
4ac8a117ef | ||
|
|
c1bc430ee6 | ||
|
|
9201aa7d7d | ||
|
|
87395a4553 | ||
|
|
6063c633ee | ||
|
|
7481feb366 | ||
|
|
bdfa14fa54 | ||
|
|
0a61ec2bfe | ||
|
|
2bf0106aa2 | ||
|
|
ab7c29d228 | ||
|
|
d9c75ed79e | ||
|
|
6fa722df7b | ||
|
|
18864bfecf | ||
|
|
95754cb2b4 | ||
|
|
ed2a461dc5 | ||
|
|
8d5a456dbd | ||
|
|
2528bbb92f | ||
|
|
259cd48dbb | ||
|
|
b43531f200 | ||
|
|
26da3e18a9 | ||
|
|
bb4b640e58 | ||
|
|
1cc845e17d | ||
|
|
60bfb6fe2c | ||
|
|
144d14ab5b | ||
|
|
8f1ca6e282 | ||
|
|
a93758c6b7 | ||
|
|
1829c088cc | ||
|
|
ee1425d0dd | ||
|
|
24ae090916 | ||
|
|
a3aa9b68fc | ||
|
|
50f475334e | ||
|
|
7b18526f24 | ||
|
|
23afc201a1 | ||
|
|
b1982d74a6 | ||
|
|
e93a37c711 | ||
|
|
8d7509cdea | ||
|
|
e24d0ce605 | ||
|
|
f5d2e4f11f | ||
|
|
de725337d6 | ||
|
|
9d1d369f37 | ||
|
|
2bd925d441 | ||
|
|
bb8dc6f7eb | ||
|
|
be3e5ba7e7 | ||
|
|
663134839f | ||
|
|
736f83a70c | ||
|
|
05d2175f43 | ||
|
|
97bd808431 | ||
|
|
a13c2fd4c1 | ||
|
|
16044b5840 |
@@ -12,8 +12,8 @@ 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"
|
||||
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||
|
||||
# Google Auth Config
|
||||
GOOGLE_CLIENT_ID="************************************************"
|
||||
@@ -59,3 +59,6 @@ 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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -81,10 +81,7 @@ web_modules/
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
14
.vscode/extensions.json
vendored
14
.vscode/extensions.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.iconify",
|
||||
"vue.volar",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig",
|
||||
"csstools.postcss",
|
||||
"folke.vscode-monorepo-workspace"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
@@ -239,7 +239,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
|
||||
|
||||
📦 **Add-ons:** Official add-ons for hoppscotch.
|
||||
|
||||
- **[Hoppscotch CLI](https://github.com/hoppscotch/hopp-cli)** - Command-line interface for Hoppscotch.
|
||||
- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-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.
|
||||
|
||||
|
||||
19
aio-multiport-setup.Caddyfile
Normal file
19
aio-multiport-setup.Caddyfile
Normal file
@@ -0,0 +1,19 @@
|
||||
: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
|
||||
}
|
||||
37
aio-subpath-access.Caddyfile
Normal file
37
aio-subpath-access.Caddyfile
Normal file
@@ -0,0 +1,37 @@
|
||||
: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} /
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
:3000 {
|
||||
try_files {path} /
|
||||
root * /site/selfhost-web
|
||||
file_server
|
||||
}
|
||||
|
||||
:3100 {
|
||||
try_files {path} /
|
||||
root * /site/sh-admin
|
||||
file_server
|
||||
}
|
||||
@@ -49,7 +49,8 @@ execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
|
||||
|
||||
fs.rmSync("build.env")
|
||||
|
||||
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
||||
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) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
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=3170
|
||||
- PORT=8080
|
||||
volumes:
|
||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3180:80"
|
||||
- "3170:3170"
|
||||
|
||||
# The main hoppscotch app. This will be hosted at port 3000
|
||||
@@ -42,7 +43,8 @@ services:
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3000:8080"
|
||||
- "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
|
||||
@@ -58,11 +60,13 @@ services:
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3100:8080"
|
||||
- "3280:80"
|
||||
- "3100:3100"
|
||||
|
||||
# The service that spins up all 3 services at once in one container
|
||||
hoppscotch-aio:
|
||||
container_name: hoppscotch-aio
|
||||
restart: unless-stopped
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
@@ -76,6 +80,7 @@ services:
|
||||
- "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
|
||||
@@ -107,7 +112,7 @@ services:
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||
context: .
|
||||
target: prod
|
||||
target: dev
|
||||
env_file:
|
||||
- ./.env
|
||||
restart: always
|
||||
@@ -117,7 +122,7 @@ services:
|
||||
- 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:
|
||||
|
||||
16
package.json
16
package.json
@@ -23,17 +23,21 @@
|
||||
"./packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.2.3",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@commitlint/cli": "16.3.0",
|
||||
"@commitlint/config-conventional": "16.2.4",
|
||||
"@hoppscotch/ui": "0.1.0",
|
||||
"@types/node": "17.0.27",
|
||||
"cross-env": "^7.0.3",
|
||||
"http-server": "^14.1.1",
|
||||
"husky": "^7.0.4",
|
||||
"cross-env": "7.0.3",
|
||||
"http-server": "14.1.1",
|
||||
"husky": "7.0.4",
|
||||
"lint-staged": "12.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vue": "3.3.9"
|
||||
},
|
||||
"packageExtensions": {
|
||||
"httpsnippet@^3.0.1": {
|
||||
"httpsnippet@3.0.1": {
|
||||
"peerDependencies": {
|
||||
"ajv": "6.12.3"
|
||||
}
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.9.2",
|
||||
"@lezer/highlight": "1.1.4",
|
||||
"@lezer/lr": "^1.3.13"
|
||||
"@codemirror/language": "6.10.1",
|
||||
"@lezer/highlight": "1.2.0",
|
||||
"@lezer/lr": "1.3.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.5.1",
|
||||
"mocha": "^9.2.2",
|
||||
"rollup": "^3.29.3",
|
||||
"rollup-plugin-dts": "^6.0.2",
|
||||
"rollup-plugin-ts": "^3.4.5",
|
||||
"typescript": "^5.2.2"
|
||||
"@lezer/generator": "1.5.1",
|
||||
"mocha": "9.2.2",
|
||||
"rollup": "3.29.4",
|
||||
"rollup-plugin-dts": "6.0.2",
|
||||
"rollup-plugin-ts": "3.4.5",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/dioc/.gitignore
vendored
24
packages/dioc/.gitignore
vendored
@@ -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?
|
||||
@@ -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>
|
||||
```
|
||||
2
packages/dioc/index.d.ts
vendored
2
packages/dioc/index.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/main.d.ts"
|
||||
export * from "./dist/main.d.ts"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./container"
|
||||
export * from "./service"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 ?"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
packages/dioc/testing.d.ts
vendored
2
packages/dioc/testing.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/testing.d.ts"
|
||||
export * from "./dist/testing.d.ts"
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
|
||||
}
|
||||
})
|
||||
2
packages/dioc/vue.d.ts
vendored
2
packages/dioc/vue.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/vue.d.ts"
|
||||
export * from "./dist/vue.d.ts"
|
||||
3
packages/hoppscotch-backend/backend.Caddyfile
Normal file
3
packages/hoppscotch-backend/backend.Caddyfile
Normal file
@@ -0,0 +1,3 @@
|
||||
:80 :3170 {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2023.8.3",
|
||||
"version": "2024.3.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -24,79 +24,83 @@
|
||||
"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",
|
||||
"argon2": "^0.30.3",
|
||||
"bcrypt": "^5.1.0",
|
||||
"cookie": "^0.5.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.3",
|
||||
"fp-ts": "^2.13.1",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-query-complexity": "^0.12.0",
|
||||
"graphql-redis-subscriptions": "^2.6.0",
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"io-ts": "^2.2.16",
|
||||
"luxon": "^3.2.1",
|
||||
"nodemailer": "^6.9.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-microsoft": "^1.0.0",
|
||||
"prisma": "^4.16.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.6.0"
|
||||
"@apollo/server": "4.9.5",
|
||||
"@nestjs-modules/mailer": "1.9.1",
|
||||
"@nestjs/apollo": "12.0.9",
|
||||
"@nestjs/common": "10.2.7",
|
||||
"@nestjs/config": "3.1.1",
|
||||
"@nestjs/core": "10.2.7",
|
||||
"@nestjs/graphql": "12.0.9",
|
||||
"@nestjs/jwt": "10.1.1",
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.7",
|
||||
"@nestjs/schedule": "4.0.1",
|
||||
"@nestjs/throttler": "5.0.1",
|
||||
"@prisma/client": "5.8.1",
|
||||
"argon2": "0.30.3",
|
||||
"bcrypt": "5.1.0",
|
||||
"cookie": "0.5.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cron": "3.1.6",
|
||||
"express": "4.18.2",
|
||||
"express-session": "1.17.3",
|
||||
"fp-ts": "2.13.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-query-complexity": "0.12.0",
|
||||
"graphql-redis-subscriptions": "2.6.0",
|
||||
"graphql-subscriptions": "2.0.0",
|
||||
"handlebars": "4.7.7",
|
||||
"io-ts": "2.2.16",
|
||||
"luxon": "3.2.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"passport": "0.6.0",
|
||||
"passport-github2": "0.1.12",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"passport-microsoft": "1.0.0",
|
||||
"posthog-node": "3.6.3",
|
||||
"prisma": "5.8.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",
|
||||
"@relmify/jest-fp-ts": "^2.0.2",
|
||||
"@types/argon2": "^0.15.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/luxon": "^3.2.0",
|
||||
"@types/node": "^18.11.10",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-github2": "^1.2.5",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jest": "^29.4.1",
|
||||
"jest-mock-extended": "^3.0.1",
|
||||
"@nestjs/cli": "10.2.1",
|
||||
"@nestjs/schematics": "10.0.3",
|
||||
"@nestjs/testing": "10.2.7",
|
||||
"@relmify/jest-fp-ts": "2.0.2",
|
||||
"@types/argon2": "0.15.0",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie": "0.5.1",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/jest": "29.4.0",
|
||||
"@types/luxon": "3.2.0",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/passport-github2": "1.2.5",
|
||||
"@types/passport-google-oauth20": "2.0.11",
|
||||
"@types/passport-jwt": "3.0.8",
|
||||
"@types/passport-microsoft": "0.0.0",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.45.0",
|
||||
"@typescript-eslint/parser": "5.45.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"jest": "29.4.1",
|
||||
"jest-mock-extended": "3.0.1",
|
||||
"jwt": "link:@types/nestjs/jwt",
|
||||
"prettier": "^2.8.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.2",
|
||||
"prettier": "2.8.4",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "6.3.2",
|
||||
"ts-jest": "29.0.5",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-loader": "9.4.2",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.3"
|
||||
"typescript": "4.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -117,4 +121,4 @@
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE
|
||||
"TeamCollection"
|
||||
ADD
|
||||
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE
|
||||
"TeamRequest"
|
||||
ADD
|
||||
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamCollection_textSearch_idx" ON "TeamCollection" USING GIN (titleSearch);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamRequest_textSearch_idx" ON "TeamRequest" USING GIN (titleSearch);
|
||||
@@ -41,30 +41,31 @@ model TeamInvitation {
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
@@ -74,7 +75,8 @@ model Shortcode {
|
||||
creatorUid String?
|
||||
User User? @relation(fields: [creatorUid], references: [uid])
|
||||
createdOn DateTime @default(now())
|
||||
updatedOn DateTime @updatedAt @default(now())
|
||||
updatedOn DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
}
|
||||
|
||||
@@ -195,6 +197,7 @@ 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)
|
||||
@@ -206,3 +209,12 @@ enum TeamMemberRole {
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
|
||||
model InfraConfig {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
value String?
|
||||
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
66
packages/hoppscotch-backend/prod_run.mjs
Normal file
66
packages/hoppscotch-backend/prod_run.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -4,25 +4,27 @@ import { AdminService } from './admin.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MailerModule } from '../mailer/mailer.module';
|
||||
import { TeamModule } from '../team/team.module';
|
||||
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
||||
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from '../team-request/team-request.module';
|
||||
import { InfraResolver } from './infra.resolver';
|
||||
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
|
||||
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PubSubModule,
|
||||
UserModule,
|
||||
MailerModule,
|
||||
TeamModule,
|
||||
TeamInvitationModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
ShortcodeModule,
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [InfraResolver, AdminResolver, AdminService],
|
||||
exports: [AdminService],
|
||||
|
||||
@@ -27,9 +27,7 @@ import {
|
||||
} 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';
|
||||
import { UserDeletionResult } from 'src/user/user.model';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Admin)
|
||||
@@ -49,203 +47,6 @@ export class AdminResolver {
|
||||
return admin;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all admin users in infra',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async admins() {
|
||||
const admins = await this.adminService.fetchAdmins();
|
||||
return admins;
|
||||
}
|
||||
@ResolveField(() => User, {
|
||||
description: 'Returns a user info by UID',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
@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',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsers(
|
||||
@Parent() admin: Admin,
|
||||
@Args() args: PaginationArgs,
|
||||
): Promise<AuthUser[]> {
|
||||
const users = await this.adminService.fetchUsers(args.cursor, args.take);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [InvitedUser], {
|
||||
description: 'Returns a list of all the invited users',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
|
||||
const users = await this.adminService.fetchInvitedUsers();
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [Team], {
|
||||
description: 'Returns a list of all the teams in the infra',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async allTeams(
|
||||
@Parent() admin: Admin,
|
||||
@Args() args: PaginationArgs,
|
||||
): Promise<Team[]> {
|
||||
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
|
||||
return teams;
|
||||
}
|
||||
@ResolveField(() => Team, {
|
||||
description: 'Returns a team info by ID when requested by Admin',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async teamInfo(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which info to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<Team> {
|
||||
const team = await this.adminService.getTeamInfo(teamID);
|
||||
if (E.isLeft(team)) throwErr(team.left);
|
||||
return team.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the members in a team',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async membersCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
nullable: false,
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
|
||||
return teamMembersCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored collections in a team',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async collectionCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
|
||||
return teamCollCount;
|
||||
}
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored requests in a team',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async requestCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
|
||||
return teamReqCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored environments in a team',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async environmentCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const envsCount = await this.adminService.environmentCountInTeam(teamID);
|
||||
return envsCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => [TeamInvitation], {
|
||||
description: 'Return all the pending invitations in a team',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async pendingInvitationCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
) {
|
||||
const invitations = await this.adminService.pendingInvitationCountInTeam(
|
||||
teamID,
|
||||
);
|
||||
return invitations;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Users in organization',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async usersCount() {
|
||||
return this.adminService.getUsersCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Teams in organization',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async teamsCount() {
|
||||
return this.adminService.getTeamsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Collections in organization',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async teamCollectionsCount() {
|
||||
return this.adminService.getTeamCollectionsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Requests in organization',
|
||||
deprecationReason: 'Use `infra` query instead',
|
||||
})
|
||||
async teamRequestsCount() {
|
||||
return this.adminService.getTeamRequestsCount();
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => InvitedUser, {
|
||||
@@ -269,8 +70,26 @@ export class AdminResolver {
|
||||
return invitedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke a user invites by invitee emails',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeUserInvitationsByAdmin(
|
||||
@Args({
|
||||
name: 'inviteeEmails',
|
||||
description: 'Invitee Emails',
|
||||
type: () => [String],
|
||||
})
|
||||
inviteeEmails: string[],
|
||||
): Promise<boolean> {
|
||||
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
|
||||
if (E.isLeft(invite)) throwErr(invite.left);
|
||||
return invite.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete an user account from infra',
|
||||
deprecationReason: 'Use removeUsersByAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserByAdmin(
|
||||
@@ -281,12 +100,33 @@ export class AdminResolver {
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const invitedUser = await this.adminService.removeUserAccount(userUID);
|
||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
||||
return invitedUser.right;
|
||||
const removedUser = await this.adminService.removeUserAccount(userUID);
|
||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
||||
return removedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => [UserDeletionResult], {
|
||||
description: 'Delete user accounts from infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUsersByAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<UserDeletionResult[]> {
|
||||
const deletionResults = await this.adminService.removeUserAccounts(
|
||||
userUIDs,
|
||||
);
|
||||
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
|
||||
return deletionResults.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Make user an admin',
|
||||
deprecationReason: 'Use makeUsersAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async makeUserAdmin(
|
||||
@@ -302,8 +142,51 @@ export class AdminResolver {
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Make users an admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async makeUsersAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Update user display name',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async updateUserDisplayNameByAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
@Args({
|
||||
name: 'displayName',
|
||||
description: 'users display name',
|
||||
})
|
||||
displayName: string,
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.updateUserDisplayName(
|
||||
userUID,
|
||||
displayName,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove user as admin',
|
||||
deprecationReason: 'Use demoteUsersByAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserAsAdmin(
|
||||
@@ -319,6 +202,23 @@ export class AdminResolver {
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove users as admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async demoteUsersByAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Team, {
|
||||
description:
|
||||
'Create a new team by providing the user uid to nominate as Team owner',
|
||||
@@ -443,6 +343,23 @@ export class AdminResolver {
|
||||
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, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AdminService } from './admin.service';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { InvitedUsers } from '@prisma/client';
|
||||
import { InvitedUsers, User as DbUser } from '@prisma/client';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||
@@ -13,8 +13,15 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import {
|
||||
DUPLICATE_EMAIL,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_INVITATION_DELETION_FAILED,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import * as E from 'fp-ts/Either';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -25,6 +32,8 @@ const mockTeamRequestService = mockDeep<TeamRequestService>();
|
||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||
const mockMailerService = mockDeep<MailerService>();
|
||||
const mockShortcodeService = mockDeep<ShortcodeService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
|
||||
const adminService = new AdminService(
|
||||
mockUserService,
|
||||
@@ -36,6 +45,8 @@ const adminService = new AdminService(
|
||||
mockPubSub as any,
|
||||
mockPrisma as any,
|
||||
mockMailerService,
|
||||
mockShortcodeService,
|
||||
mockConfigService,
|
||||
);
|
||||
|
||||
const invitedUsers: InvitedUsers[] = [
|
||||
@@ -52,20 +63,87 @@ const invitedUsers: InvitedUsers[] = [
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const dbAdminUsers: DbUser[] = [
|
||||
{
|
||||
uid: 'uid 1',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: true,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
uid: 'uid 2',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: true,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
createdOn: new Date(),
|
||||
},
|
||||
];
|
||||
const dbNonAminUser: DbUser = {
|
||||
uid: 'uid 3',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: false,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
createdOn: new Date(),
|
||||
};
|
||||
|
||||
describe('AdminService', () => {
|
||||
describe('fetchInvitedUsers', () => {
|
||||
test('should resolve right and return an array of invited users', async () => {
|
||||
test('should resolve right and apply pagination correctly', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||
// @ts-ignore
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers();
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
|
||||
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
|
||||
...paginationArgs,
|
||||
orderBy: {
|
||||
invitedOn: 'desc',
|
||||
},
|
||||
where: {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: [dbAdminUsers[0].email],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
test('should resolve right and return an array of invited users', async () => {
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||
// @ts-ignore
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
expect(results).toEqual(invitedUsers);
|
||||
});
|
||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers();
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +206,58 @@ describe('AdminService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeUserInvitations', () => {
|
||||
test('should resolve left and return error if email not invited', async () => {
|
||||
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await adminService.revokeUserInvitations([
|
||||
'test@gmail.com',
|
||||
]);
|
||||
|
||||
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
|
||||
});
|
||||
|
||||
test('should resolve right and return deleted invitee email', async () => {
|
||||
const adminUid = 'adminUid';
|
||||
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const result = await adminService.revokeUserInvitations([
|
||||
invitedUsers[0].inviteeEmail,
|
||||
]);
|
||||
|
||||
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUsersAsAdmin', () => {
|
||||
test('should resolve right and make admins to users', async () => {
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
return expect(
|
||||
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
|
||||
).toEqualRight(true);
|
||||
});
|
||||
|
||||
test('should resolve left and return error if only one admin in the infra', async () => {
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
return expect(
|
||||
await adminService.demoteUsersByAdmin(
|
||||
dbAdminUsers.map((user) => user.uid),
|
||||
),
|
||||
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsersCount', () => {
|
||||
test('should return count of all users in the organization', async () => {
|
||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
||||
|
||||
@@ -6,13 +6,16 @@ import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { validateEmail } from '../utils';
|
||||
import {
|
||||
ADMIN_CAN_NOT_BE_DELETED,
|
||||
DUPLICATE_EMAIL,
|
||||
EMAIL_FAILED,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_INVITATION_DELETION_FAILED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
@@ -24,6 +27,10 @@ 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';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { UserDeletionResult } from 'src/user/user.model';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
@@ -37,6 +44,8 @@ export class AdminService {
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly shortcodeService: ShortcodeService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -44,12 +53,30 @@ export class AdminService {
|
||||
* @param cursorID Users uid
|
||||
* @param take number of users to fetch
|
||||
* @returns an Either of array of user or error
|
||||
* @deprecated use fetchUsersV2 instead
|
||||
*/
|
||||
async fetchUsers(cursorID: string, take: number) {
|
||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the users in the infra.
|
||||
* @param searchString search on users displayName or email
|
||||
* @param paginationOption pagination options
|
||||
* @returns an Either of array of user or error
|
||||
*/
|
||||
async fetchUsersV2(
|
||||
searchString: string,
|
||||
paginationOption: OffsetPaginationArgs,
|
||||
) {
|
||||
const allUsers = await this.userService.fetchAllUsersV2(
|
||||
searchString,
|
||||
paginationOption,
|
||||
);
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to join the infra.
|
||||
* @param adminUID Admin's UID
|
||||
@@ -77,7 +104,7 @@ export class AdminService {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
magicLink: `${process.env.VITE_BASE_URL}`,
|
||||
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -106,14 +133,68 @@ export class AdminService {
|
||||
return E.right(invitedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the display name of a user
|
||||
* @param userUid Who's display name is being updated
|
||||
* @param displayName New display name of the user
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async updateUserDisplayName(userUid: string, displayName: string) {
|
||||
const updatedUser = await this.userService.updateUserDisplayName(
|
||||
userUid,
|
||||
displayName,
|
||||
);
|
||||
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke infra level user invitations
|
||||
* @param inviteeEmails Invitee's emails
|
||||
* @param adminUid Admin Uid
|
||||
* @returns an Either of boolean or error string
|
||||
*/
|
||||
async revokeUserInvitations(inviteeEmails: string[]) {
|
||||
try {
|
||||
await this.prisma.invitedUsers.deleteMany({
|
||||
where: {
|
||||
inviteeEmail: { in: inviteeEmails },
|
||||
},
|
||||
});
|
||||
return E.right(true);
|
||||
} catch (error) {
|
||||
return E.left(USER_INVITATION_DELETION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of invited users by the admin.
|
||||
* @returns an Either of array of `InvitedUser` object or error
|
||||
*/
|
||||
async fetchInvitedUsers() {
|
||||
const invitedUsers = await this.prisma.invitedUsers.findMany();
|
||||
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
|
||||
const userEmailObjs = await this.prisma.user.findMany({
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const users: InvitedUser[] = invitedUsers.map(
|
||||
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
|
||||
take: paginationOption.take,
|
||||
skip: paginationOption.skip,
|
||||
orderBy: {
|
||||
invitedOn: 'desc',
|
||||
},
|
||||
where: {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: userEmailObjs.map((user) => user.email),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const users: InvitedUser[] = pendingInvitedUsers.map(
|
||||
(user) => <InvitedUser>{ ...user },
|
||||
);
|
||||
|
||||
@@ -333,6 +414,7 @@ export class AdminService {
|
||||
* Remove a user account by UID
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use removeUserAccounts instead
|
||||
*/
|
||||
async removeUserAccount(userUid: string) {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
@@ -345,10 +427,73 @@ export class AdminService {
|
||||
return E.right(delUser.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user (not Admin) accounts by UIDs
|
||||
* @param userUIDs User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserAccounts(userUIDs: string[]) {
|
||||
const userDeleteResult: UserDeletionResult[] = [];
|
||||
|
||||
// step 1: fetch all users
|
||||
const allUsersList = await this.userService.findUsersByIds(userUIDs);
|
||||
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
|
||||
|
||||
// step 2: admin user can not be deleted without removing admin status/role
|
||||
allUsersList.forEach((user) => {
|
||||
if (user.isAdmin) {
|
||||
userDeleteResult.push({
|
||||
userUID: user.uid,
|
||||
isDeleted: false,
|
||||
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
|
||||
let deletedUserEmails: string[] = [];
|
||||
|
||||
// step 3: delete non-admin users
|
||||
const deletionPromises = nonAdminUsers.map((user) => {
|
||||
return this.userService
|
||||
.deleteUserByUID(user)()
|
||||
.then((res) => {
|
||||
if (E.isLeft(res)) {
|
||||
return {
|
||||
userUID: user.uid,
|
||||
isDeleted: false,
|
||||
errorMessage: res.left,
|
||||
} as UserDeletionResult;
|
||||
}
|
||||
|
||||
deletedUserEmails.push(user.email);
|
||||
return {
|
||||
userUID: user.uid,
|
||||
isDeleted: true,
|
||||
errorMessage: null,
|
||||
} as UserDeletionResult;
|
||||
});
|
||||
});
|
||||
const promiseResult = await Promise.allSettled(deletionPromises);
|
||||
|
||||
// step 4: revoke all the invites sent to the deleted users
|
||||
await this.revokeUserInvitations(deletedUserEmails);
|
||||
|
||||
// step 5: return the result
|
||||
promiseResult.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
userDeleteResult.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
return E.right(userDeleteResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a user an admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use makeUsersAdmin instead
|
||||
*/
|
||||
async makeUserAdmin(userUID: string) {
|
||||
const admin = await this.userService.makeAdmin(userUID);
|
||||
@@ -356,10 +501,22 @@ export class AdminService {
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make users to admin
|
||||
* @param userUid User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async makeUsersAdmin(userUIDs: string[]) {
|
||||
const isUpdated = await this.userService.makeAdmins(userUIDs);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user as admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use demoteUsersByAdmin instead
|
||||
*/
|
||||
async removeUserAsAdmin(userUID: string) {
|
||||
const adminUsers = await this.userService.fetchAdminUsers();
|
||||
@@ -370,6 +527,26 @@ export class AdminService {
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove users as admin
|
||||
* @param userUIDs User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async demoteUsersByAdmin(userUIDs: string[]) {
|
||||
const adminUsers = await this.userService.fetchAdminUsers();
|
||||
|
||||
const remainingAdmins = adminUsers.filter(
|
||||
(adminUser) => !userUIDs.includes(adminUser.uid),
|
||||
);
|
||||
if (remainingAdmins.length < 1) {
|
||||
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
}
|
||||
|
||||
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
return E.right(isUpdated.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Users in org
|
||||
* @returns number of users in the org
|
||||
@@ -432,4 +609,35 @@ export class AdminService {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class RESTAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Mutation,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { Infra } from './infra.model';
|
||||
import { AdminService } from './admin.service';
|
||||
@@ -10,16 +17,31 @@ 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 {
|
||||
OffsetPaginationArgs,
|
||||
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';
|
||||
import { InfraConfig } from 'src/infra-config/infra-config.model';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
import {
|
||||
EnableAndDisableSSOArgs,
|
||||
InfraConfigArgs,
|
||||
} from 'src/infra-config/input-args';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from 'src/infra-config/helper';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Infra)
|
||||
export class InfraResolver {
|
||||
constructor(private adminService: AdminService) {}
|
||||
constructor(
|
||||
private adminService: AdminService,
|
||||
private infraConfigService: InfraConfigService,
|
||||
) {}
|
||||
|
||||
@Query(() => Infra, {
|
||||
description: 'Fetch details of the Infrastructure',
|
||||
@@ -58,6 +80,7 @@ export class InfraResolver {
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all the users in infra',
|
||||
deprecationReason: 'Use allUsersV2 instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
|
||||
@@ -65,11 +88,33 @@ export class InfraResolver {
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all the users in infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsersV2(
|
||||
@Args({
|
||||
name: 'searchString',
|
||||
nullable: true,
|
||||
description: 'Search on users displayName or email',
|
||||
})
|
||||
searchString: string,
|
||||
@Args() paginationOption: OffsetPaginationArgs,
|
||||
): Promise<AuthUser[]> {
|
||||
const users = await this.adminService.fetchUsersV2(
|
||||
searchString,
|
||||
paginationOption,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [InvitedUser], {
|
||||
description: 'Returns a list of all the invited users',
|
||||
})
|
||||
async invitedUsers(): Promise<InvitedUser[]> {
|
||||
const users = await this.adminService.fetchInvitedUsers();
|
||||
async invitedUsers(
|
||||
@Args() args: OffsetPaginationArgs,
|
||||
): Promise<InvitedUser[]> {
|
||||
const users = await this.adminService.fetchInvitedUsers(args);
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -202,4 +247,116 @@ export class InfraResolver {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => [InfraConfig], {
|
||||
description: 'Retrieve configuration details for the instance',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async infraConfigs(
|
||||
@Args({
|
||||
name: 'configNames',
|
||||
type: () => [InfraConfigEnum],
|
||||
description: 'Configs to fetch',
|
||||
})
|
||||
names: InfraConfigEnum[],
|
||||
) {
|
||||
const infraConfigs = await this.infraConfigService.getMany(names);
|
||||
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
|
||||
return infraConfigs.right;
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'Allowed Auth Provider list',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
allowedAuthProviders() {
|
||||
return this.infraConfigService.getAllowedAuthProviders();
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => [InfraConfig], {
|
||||
description: 'Update Infra Configs',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async updateInfraConfigs(
|
||||
@Args({
|
||||
name: 'infraConfigs',
|
||||
type: () => [InfraConfigArgs],
|
||||
description: 'InfraConfigs to update',
|
||||
})
|
||||
infraConfigs: InfraConfigArgs[],
|
||||
) {
|
||||
const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
|
||||
if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
|
||||
return updatedRes.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or disable analytics collection',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async toggleAnalyticsCollection(
|
||||
@Args({
|
||||
name: 'status',
|
||||
type: () => ServiceStatus,
|
||||
description: 'Toggle analytics collection',
|
||||
})
|
||||
analyticsCollectionStatus: ServiceStatus,
|
||||
) {
|
||||
const res = await this.infraConfigService.toggleAnalyticsCollection(
|
||||
analyticsCollectionStatus,
|
||||
);
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return res.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Reset Infra Configs with default values (.env)',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async resetInfraConfigs() {
|
||||
const resetRes = await this.infraConfigService.reset();
|
||||
if (E.isLeft(resetRes)) throwErr(resetRes.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or Disable SSO for login/signup',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async enableAndDisableSSO(
|
||||
@Args({
|
||||
name: 'providerInfo',
|
||||
type: () => [EnableAndDisableSSOArgs],
|
||||
description: 'SSO provider and status',
|
||||
})
|
||||
providerInfo: EnableAndDisableSSOArgs[],
|
||||
) {
|
||||
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
|
||||
providerInfo,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,51 +20,71 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
|
||||
import { COOKIES_NOT_FOUND } from './errors';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigModule } from './infra-config/infra-config.module';
|
||||
import { loadInfraConfiguration } from './infra-config/helper';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { PosthogModule } from './posthog/posthog.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
playground: process.env.PRODUCTION !== 'true',
|
||||
autoSchemaFile: true,
|
||||
installSubscriptionHandlers: true,
|
||||
subscriptions: {
|
||||
'subscriptions-transport-ws': {
|
||||
path: '/graphql',
|
||||
onConnect: (_, websocket) => {
|
||||
try {
|
||||
const cookies = subscriptionContextCookieParser(
|
||||
websocket.upgradeReq.headers.cookie,
|
||||
);
|
||||
|
||||
return {
|
||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||
cause: new Error(COOKIES_NOT_FOUND),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
context: ({ req, res, connection }) => ({
|
||||
req,
|
||||
res,
|
||||
connection,
|
||||
}),
|
||||
driver: ApolloDriver,
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [async () => loadInfraConfiguration()],
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: +process.env.RATE_LIMIT_TTL,
|
||||
limit: +process.env.RATE_LIMIT_MAX,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
playground: configService.get('PRODUCTION') !== 'true',
|
||||
autoSchemaFile: true,
|
||||
installSubscriptionHandlers: true,
|
||||
subscriptions: {
|
||||
'subscriptions-transport-ws': {
|
||||
path: '/graphql',
|
||||
onConnect: (_, websocket) => {
|
||||
try {
|
||||
const cookies = subscriptionContextCookieParser(
|
||||
websocket.upgradeReq.headers.cookie,
|
||||
);
|
||||
return {
|
||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||
cause: new Error(COOKIES_NOT_FOUND),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
context: ({ req, res, connection }) => ({
|
||||
req,
|
||||
res,
|
||||
connection,
|
||||
}),
|
||||
};
|
||||
},
|
||||
]),
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => [
|
||||
{
|
||||
ttl: +configService.get('RATE_LIMIT_TTL'),
|
||||
limit: +configService.get('RATE_LIMIT_MAX'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
MailerModule.register(),
|
||||
UserModule,
|
||||
AuthModule,
|
||||
AuthModule.register(),
|
||||
AdminModule,
|
||||
UserSettingsModule,
|
||||
UserEnvironmentsModule,
|
||||
@@ -77,6 +97,9 @@ import { AppController } from './app.controller';
|
||||
TeamInvitationModule,
|
||||
UserCollectionModule,
|
||||
ShortcodeModule,
|
||||
InfraConfigModule,
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
@@ -19,23 +18,29 @@ 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 { AuthProvider, authCookieHandler, authProviderCheck } 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';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('providers')
|
||||
async getAuthProviders() {
|
||||
const providers = await this.authService.getAuthProviders();
|
||||
return { providers };
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate magic-link auth for a users email
|
||||
@@ -45,8 +50,14 @@ export class AuthController {
|
||||
@Body() authData: SignInMagicDto,
|
||||
@Query('origin') origin: string,
|
||||
) {
|
||||
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.EMAIL,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
const deviceIdToken = await this.authService.signInMagicLink(
|
||||
authData.email,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { MailerModule } from 'src/mailer/mailer.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@@ -12,25 +11,55 @@ import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
isInfraConfigTablePopulated,
|
||||
loadInfraConfiguration,
|
||||
} from 'src/infra-config/helper';
|
||||
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
UserModule,
|
||||
MailerModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RTJwtStrategy,
|
||||
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, RTJwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export class AuthModule {
|
||||
static async register() {
|
||||
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
|
||||
if (!isInfraConfigPopulated) {
|
||||
return { module: AuthModule };
|
||||
}
|
||||
|
||||
const env = await loadInfraConfiguration();
|
||||
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;
|
||||
|
||||
const providers = [
|
||||
...(authProviderCheck(AuthProvider.GOOGLE, allowedAuthProviders)
|
||||
? [GoogleStrategy]
|
||||
: []),
|
||||
...(authProviderCheck(AuthProvider.GITHUB, allowedAuthProviders)
|
||||
? [GithubStrategy]
|
||||
: []),
|
||||
...(authProviderCheck(AuthProvider.MICROSOFT, allowedAuthProviders)
|
||||
? [MicrosoftStrategy]
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
module: AuthModule,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,26 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockUser = mockDeep<UserService>();
|
||||
const mockJWT = mockDeep<JwtService>();
|
||||
const mockMailer = mockDeep<MailerService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
const mockInfraConfigService = mockDeep<InfraConfigService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
|
||||
const authService = new AuthService(
|
||||
mockUser,
|
||||
mockPrisma,
|
||||
mockJWT,
|
||||
mockMailer,
|
||||
mockConfigService,
|
||||
mockInfraConfigService,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
@@ -91,6 +102,8 @@ describe('signInMagicLink', () => {
|
||||
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
||||
// create new entry in VerificationToken table
|
||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
|
||||
mockConfigService.get.mockReturnValue('3');
|
||||
|
||||
const result = await authService.signInMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
RefreshTokenPayload,
|
||||
} from 'src/types/AuthTokens';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||
import { VerificationToken } from '@prisma/client';
|
||||
import { Origin } from './helper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -36,6 +38,8 @@ export class AuthService {
|
||||
private prismaService: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly configService: ConfigService,
|
||||
private infraConfigService: InfraConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -46,10 +50,12 @@ export class AuthService {
|
||||
*/
|
||||
private async generateMagicLinkTokens(user: AuthUser) {
|
||||
const salt = await bcrypt.genSalt(
|
||||
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
|
||||
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
|
||||
);
|
||||
const expiresOn = DateTime.now()
|
||||
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
|
||||
.plus({
|
||||
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toISO()
|
||||
.toString();
|
||||
|
||||
@@ -95,13 +101,13 @@ export class AuthService {
|
||||
*/
|
||||
private async generateRefreshToken(userUid: string) {
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
iss: process.env.VITE_BASE_URL,
|
||||
iss: this.configService.get('VITE_BASE_URL'),
|
||||
sub: userUid,
|
||||
aud: [process.env.VITE_BASE_URL],
|
||||
aud: [this.configService.get('VITE_BASE_URL')],
|
||||
};
|
||||
|
||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
||||
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
|
||||
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
|
||||
});
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
@@ -111,7 +117,7 @@ export class AuthService {
|
||||
userUid,
|
||||
);
|
||||
if (E.isLeft(updatedUser))
|
||||
return E.left(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: updatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -127,9 +133,9 @@ export class AuthService {
|
||||
*/
|
||||
async generateAuthTokens(userUid: string) {
|
||||
const accessTokenPayload: AccessTokenPayload = {
|
||||
iss: process.env.VITE_BASE_URL,
|
||||
iss: this.configService.get('VITE_BASE_URL'),
|
||||
sub: userUid,
|
||||
aud: [process.env.VITE_BASE_URL],
|
||||
aud: [this.configService.get('VITE_BASE_URL')],
|
||||
};
|
||||
|
||||
const refreshToken = await this.generateRefreshToken(userUid);
|
||||
@@ -137,7 +143,7 @@ export class AuthService {
|
||||
|
||||
return E.right(<AuthTokens>{
|
||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
||||
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
|
||||
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
|
||||
}),
|
||||
refresh_token: refreshToken.right,
|
||||
});
|
||||
@@ -218,14 +224,14 @@ export class AuthService {
|
||||
let url: string;
|
||||
switch (origin) {
|
||||
case Origin.ADMIN:
|
||||
url = process.env.VITE_ADMIN_URL;
|
||||
url = this.configService.get('VITE_ADMIN_URL');
|
||||
break;
|
||||
case Origin.APP:
|
||||
url = process.env.VITE_BASE_URL;
|
||||
url = this.configService.get('VITE_BASE_URL');
|
||||
break;
|
||||
default:
|
||||
// if origin is invalid by default set URL to Hoppscotch-App
|
||||
url = process.env.VITE_BASE_URL;
|
||||
url = this.configService.get('VITE_BASE_URL');
|
||||
}
|
||||
|
||||
await this.mailerService.sendEmail(email, {
|
||||
@@ -249,7 +255,7 @@ export class AuthService {
|
||||
*/
|
||||
async verifyMagicLinkTokens(
|
||||
magicLinkIDTokens: VerifyMagicDto,
|
||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
||||
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||
magicLinkIDTokens,
|
||||
);
|
||||
@@ -367,7 +373,7 @@ export class AuthService {
|
||||
if (usersCount === 1) {
|
||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||
if (E.isLeft(elevatedUser))
|
||||
return E.left(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -377,4 +383,8 @@ export class AuthService {
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: false });
|
||||
}
|
||||
|
||||
getAuthProviders() {
|
||||
return this.infraConfigService.getAllowedAuthProviders();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.GITHUB))
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.GITHUB,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.GOOGLE))
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.GOOGLE,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftSSOGuard
|
||||
extends AuthGuard('microsoft')
|
||||
implements CanActivate
|
||||
{
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.MICROSOFT))
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.MICROSOFT,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({
|
||||
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { 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 { ConfigService } from '@nestjs/config';
|
||||
|
||||
enum AuthTokenType {
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
@@ -24,15 +24,6 @@ export enum AuthProvider {
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
|
||||
/**
|
||||
* This function allows throw to be used as an expression
|
||||
* @param errMessage Message present in the error message
|
||||
*/
|
||||
export function throwHTTPErr(errorData: AuthError): never {
|
||||
const { message, statusCode } = errorData;
|
||||
throw new HttpException(message, statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and returns the cookies in the response object on successful authentication
|
||||
* @param res Express Response Object
|
||||
@@ -45,15 +36,17 @@ export const authCookieHandler = (
|
||||
redirect: boolean,
|
||||
redirectUrl: string | null,
|
||||
) => {
|
||||
const configService = new ConfigService();
|
||||
|
||||
const currentTime = DateTime.now();
|
||||
const accessTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
|
||||
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
const refreshTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
|
||||
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
|
||||
@@ -75,10 +68,12 @@ export const authCookieHandler = (
|
||||
}
|
||||
|
||||
// check to see if redirectUrl is a whitelisted url
|
||||
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
|
||||
const whitelistedOrigins = configService
|
||||
.get('WHITELISTED_ORIGINS')
|
||||
.split(',');
|
||||
if (!whitelistedOrigins.includes(redirectUrl))
|
||||
// if it is not redirect by default to REDIRECT_URL
|
||||
redirectUrl = process.env.REDIRECT_URL;
|
||||
redirectUrl = configService.get('REDIRECT_URL');
|
||||
|
||||
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
||||
};
|
||||
@@ -112,13 +107,16 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
|
||||
* @param provider Provider we want to check the presence of
|
||||
* @returns Boolean if provider specified is present or not
|
||||
*/
|
||||
export function authProviderCheck(provider: string) {
|
||||
export function authProviderCheck(
|
||||
provider: string,
|
||||
VITE_ALLOWED_AUTH_PROVIDERS: 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) =>
|
||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
||||
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||
provider.trim().toUpperCase(),
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -5,18 +5,20 @@ import { AuthService } from '../auth.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class GithubStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
||||
scope: [process.env.GITHUB_SCOPE],
|
||||
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.GITHUB_SCOPE')],
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,18 +5,20 @@ import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { AuthService } from '../auth.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
scope: process.env.GOOGLE_SCOPE.split(','),
|
||||
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
|
||||
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
|
||||
passReqToCallback: true,
|
||||
store: true,
|
||||
});
|
||||
|
||||
@@ -15,10 +15,14 @@ import {
|
||||
INVALID_ACCESS_TOKEN,
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private usersService: UserService) {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => {
|
||||
@@ -29,7 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
return ATCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,21 @@ import { AuthService } from '../auth.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: process.env.MICROSOFT_CLIENT_ID,
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
|
||||
scope: [process.env.MICROSOFT_SCOPE],
|
||||
tenant: process.env.MICROSOFT_TENANT,
|
||||
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
|
||||
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ import {
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(private usersService: UserService) {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => {
|
||||
@@ -28,7 +32,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
return RTCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
||||
export const ONLY_ONE_ADMIN_ACCOUNT =
|
||||
'admin/only_one_admin_account_found' as const;
|
||||
|
||||
/**
|
||||
* Admin user can not be deleted
|
||||
* To delete the admin user, first make the Admin user a normal user
|
||||
* (AdminService)
|
||||
*/
|
||||
export const ADMIN_CAN_NOT_BE_DELETED =
|
||||
'admin/admin_can_not_be_deleted' as const;
|
||||
|
||||
/**
|
||||
* Token Authorization failed (Check 'Authorization' Header)
|
||||
* (GqlAuthGuard)
|
||||
@@ -28,6 +36,13 @@ export const JSON_INVALID = 'json_invalid';
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||
|
||||
/**
|
||||
* Auth Provider not specified
|
||||
* (Auth)
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_CONFIGURED =
|
||||
'auth/provider_not_configured_correctly';
|
||||
|
||||
/**
|
||||
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||
*/
|
||||
@@ -92,6 +107,13 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
|
||||
*/
|
||||
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
||||
|
||||
/**
|
||||
* User invite deletion failure error due to invitation not found
|
||||
* (AdminService)
|
||||
*/
|
||||
export const USER_INVITATION_DELETION_FAILED =
|
||||
'user/invitation_deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* Teams not found
|
||||
* (TeamsService)
|
||||
@@ -206,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
|
||||
export const TEAM_COL_SAME_NEXT_COLL =
|
||||
'team_coll/collection_and_next_collection_are_same';
|
||||
|
||||
/**
|
||||
* Team Collection search failed
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
|
||||
|
||||
/**
|
||||
* Team Collection Re-Ordering Failed
|
||||
* (TeamCollectionService)
|
||||
@@ -254,6 +282,20 @@ 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;
|
||||
|
||||
/**
|
||||
* Team Collection parent tree generation failed
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
|
||||
'team_coll/team_coll_parent_tree_generation_failed';
|
||||
|
||||
/**
|
||||
* Tried to perform an action on a request that doesn't accept their member role level
|
||||
* (GqlRequestTeamMemberGuard)
|
||||
@@ -279,6 +321,19 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
||||
*/
|
||||
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
|
||||
|
||||
/**
|
||||
* Team Request search failed
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
|
||||
|
||||
/**
|
||||
* Team Request parent tree generation failed
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
|
||||
'team_req/team_req_parent_tree_generation_failed';
|
||||
|
||||
/**
|
||||
* No Postmark Sender Email defined
|
||||
* (AuthService)
|
||||
@@ -585,6 +640,13 @@ 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)
|
||||
@@ -630,3 +692,61 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
|
||||
*/
|
||||
export const SHORTCODE_PROPERTIES_NOT_FOUND =
|
||||
'shortcode/properties_not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not found
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config update failed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not listed for onModuleInit creation
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_LISTED =
|
||||
'infra_config/properly_not_listed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config reset failed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config invalid input for Config variable
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
|
||||
|
||||
/**
|
||||
* Infra Config service (auth provider/mailer/audit logs) not configured
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
|
||||
'infra_config/service_not_configured' as const;
|
||||
|
||||
/**
|
||||
* Infra Config update/fetch operation not allowed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
|
||||
'infra_config/operation_not_allowed';
|
||||
|
||||
/**
|
||||
* Error message for when the database table does not exist
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const DATABASE_TABLE_NOT_EXIST =
|
||||
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
|
||||
|
||||
/**
|
||||
* PostHog client is not initialized
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
||||
|
||||
245
packages/hoppscotch-backend/src/infra-config/helper.ts
Normal file
245
packages/hoppscotch-backend/src/infra-config/helper.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
import {
|
||||
AUTH_PROVIDER_NOT_CONFIGURED,
|
||||
DATABASE_TABLE_NOT_EXIST,
|
||||
} from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export enum ServiceStatus {
|
||||
ENABLE = 'ENABLE',
|
||||
DISABLE = 'DISABLE',
|
||||
}
|
||||
|
||||
const AuthProviderConfigurations = {
|
||||
[AuthProvider.GOOGLE]: [
|
||||
InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
InfraConfigEnum.GOOGLE_SCOPE,
|
||||
],
|
||||
[AuthProvider.GITHUB]: [
|
||||
InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
InfraConfigEnum.GITHUB_SCOPE,
|
||||
],
|
||||
[AuthProvider.MICROSOFT]: [
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]: [
|
||||
InfraConfigEnum.MAILER_SMTP_URL,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Load environment variables from the database and set them in the process
|
||||
*
|
||||
* @Description Fetch the 'infra_config' table from the database and return it as an object
|
||||
* (ConfigModule will set the environment variables in the process)
|
||||
*/
|
||||
export async function loadInfraConfiguration() {
|
||||
try {
|
||||
const prisma = new PrismaService();
|
||||
|
||||
const infraConfigs = await prisma.infraConfig.findMany();
|
||||
|
||||
let environmentObject: Record<string, any> = {};
|
||||
infraConfigs.forEach((infraConfig) => {
|
||||
environmentObject[infraConfig.name] = infraConfig.value;
|
||||
});
|
||||
|
||||
return { INFRA: environmentObject };
|
||||
} catch (error) {
|
||||
// Prisma throw error if 'Can't reach at database server' OR 'Table does not exist'
|
||||
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
|
||||
return { INFRA: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the default values from .env file and return them as an array
|
||||
* @returns Array of default infra configs
|
||||
*/
|
||||
export async function getDefaultInfraConfigs(): Promise<
|
||||
{ name: InfraConfigEnum; value: string }[]
|
||||
> {
|
||||
const prisma = new PrismaService();
|
||||
|
||||
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
|
||||
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_URL,
|
||||
value: process.env.MAILER_SMTP_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
value: process.env.MAILER_ADDRESS_FROM,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: process.env.GOOGLE_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
value: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
value: process.env.GOOGLE_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_SCOPE,
|
||||
value: process.env.GOOGLE_SCOPE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
value: process.env.GITHUB_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
value: process.env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
value: process.env.GITHUB_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_SCOPE,
|
||||
value: process.env.GITHUB_SCOPE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
value: process.env.MICROSOFT_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
value: process.env.MICROSOFT_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
value: process.env.MICROSOFT_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
value: process.env.MICROSOFT_SCOPE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_TENANT,
|
||||
value: process.env.MICROSOFT_TENANT,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
value: getConfiguredSSOProviders(),
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
value: false.toString(),
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.ANALYTICS_USER_ID,
|
||||
value: generateAnalyticsUserId(),
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
|
||||
},
|
||||
];
|
||||
|
||||
return infraConfigDefaultObjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if 'infra_config' table is loaded with all entries
|
||||
* @returns boolean
|
||||
*/
|
||||
export async function isInfraConfigTablePopulated(): Promise<boolean> {
|
||||
const prisma = new PrismaService();
|
||||
try {
|
||||
const dbInfraConfigs = await prisma.infraConfig.findMany();
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
|
||||
const propsRemainingToInsert = infraConfigDefaultObjs.filter(
|
||||
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
|
||||
);
|
||||
|
||||
if (propsRemainingToInsert.length > 0) {
|
||||
console.log(
|
||||
'Infra Config table is not populated with all entries. Populating now...',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the app after 5 seconds
|
||||
* (Docker will re-start the app)
|
||||
*/
|
||||
export function stopApp() {
|
||||
console.log('Stopping app in 5 seconds...');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Stopping app now...');
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured SSO providers
|
||||
* @returns Array of configured SSO providers
|
||||
*/
|
||||
export function getConfiguredSSOProviders() {
|
||||
const allowedAuthProviders: string[] =
|
||||
process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',');
|
||||
let configuredAuthProviders: string[] = [];
|
||||
|
||||
const addProviderIfConfigured = (provider) => {
|
||||
const configParameters: string[] = AuthProviderConfigurations[provider];
|
||||
|
||||
const isConfigured = configParameters.every((configParameter) => {
|
||||
return process.env[configParameter];
|
||||
});
|
||||
|
||||
if (isConfigured) configuredAuthProviders.push(provider);
|
||||
};
|
||||
|
||||
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
|
||||
|
||||
if (configuredAuthProviders.length === 0) {
|
||||
throwErr(AUTH_PROVIDER_NOT_CONFIGURED);
|
||||
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
|
||||
const unConfiguredAuthProviders = allowedAuthProviders.filter(
|
||||
(provider) => {
|
||||
return !configuredAuthProviders.includes(provider);
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`${unConfiguredAuthProviders.join(
|
||||
',',
|
||||
)} SSO auth provider(s) are not configured properly. Do configure them from Admin Dashboard.`,
|
||||
);
|
||||
}
|
||||
|
||||
return configuredAuthProviders.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hashed valued for analytics
|
||||
* @returns Generated hashed value
|
||||
*/
|
||||
export function generateAnalyticsUserId() {
|
||||
const hashedUserID = randomBytes(20).toString('hex');
|
||||
return hashedUserID;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'site', version: '1' })
|
||||
export class SiteController {
|
||||
constructor(private infraConfigService: InfraConfigService) {}
|
||||
|
||||
@Get('setup')
|
||||
@UseGuards(JwtAuthGuard, RESTAdminGuard)
|
||||
async fetchSetupInfo() {
|
||||
const status = await this.infraConfigService.get(
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
);
|
||||
|
||||
if (E.isLeft(status))
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: status.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
return status.right;
|
||||
}
|
||||
|
||||
@Put('setup')
|
||||
@UseGuards(JwtAuthGuard, RESTAdminGuard)
|
||||
async setSetupAsComplete() {
|
||||
const res = await this.infraConfigService.update(
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
false.toString(),
|
||||
false,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: res.left,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from './helper';
|
||||
|
||||
@ObjectType()
|
||||
export class InfraConfig {
|
||||
@Field({
|
||||
description: 'Infra Config Name',
|
||||
})
|
||||
name: InfraConfigEnum;
|
||||
|
||||
@Field({
|
||||
description: 'Infra Config Value',
|
||||
})
|
||||
value: string;
|
||||
}
|
||||
|
||||
registerEnumType(InfraConfigEnum, {
|
||||
name: 'InfraConfigEnum',
|
||||
});
|
||||
|
||||
registerEnumType(AuthProvider, {
|
||||
name: 'AuthProvider',
|
||||
});
|
||||
|
||||
registerEnumType(ServiceStatus, {
|
||||
name: 'ServiceStatus',
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { SiteController } from './infra-config.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [InfraConfigService],
|
||||
exports: [InfraConfigService],
|
||||
controllers: [SiteController],
|
||||
})
|
||||
export class InfraConfigModule {}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import {
|
||||
INFRA_CONFIG_NOT_FOUND,
|
||||
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
|
||||
INFRA_CONFIG_UPDATE_FAILED,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as helper from './helper';
|
||||
import { InfraConfig as dbInfraConfig } from '@prisma/client';
|
||||
import { InfraConfig } from './infra-config.model';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const infraConfigService = new InfraConfigService(
|
||||
mockPrisma,
|
||||
mockConfigService,
|
||||
);
|
||||
|
||||
const INITIALIZED_DATE_CONST = new Date();
|
||||
const dbInfraConfigs: dbInfraConfig[] = [
|
||||
{
|
||||
id: '3',
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: 'abcdefghijkl',
|
||||
active: true,
|
||||
createdOn: INITIALIZED_DATE_CONST,
|
||||
updatedOn: INITIALIZED_DATE_CONST,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
value: 'google',
|
||||
active: true,
|
||||
createdOn: INITIALIZED_DATE_CONST,
|
||||
updatedOn: INITIALIZED_DATE_CONST,
|
||||
},
|
||||
];
|
||||
const infraConfigs: InfraConfig[] = [
|
||||
{
|
||||
name: dbInfraConfigs[0].name as InfraConfigEnum,
|
||||
value: dbInfraConfigs[0].value,
|
||||
},
|
||||
{
|
||||
name: dbInfraConfigs[1].name as InfraConfigEnum,
|
||||
value: dbInfraConfigs[1].value,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
});
|
||||
|
||||
describe('InfraConfigService', () => {
|
||||
describe('update', () => {
|
||||
it('should update the infra config without backend server restart', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
|
||||
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
|
||||
const result = await infraConfigService.update(name, value);
|
||||
|
||||
expect(helper.stopApp).not.toHaveBeenCalled();
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should update the infra config with backend server restart', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
|
||||
|
||||
const result = await infraConfigService.update(name, value, true);
|
||||
|
||||
expect(helper.stopApp).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should update the infra config', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
|
||||
|
||||
const result = await infraConfigService.update(name, value);
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should pass correct params to prisma update', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
|
||||
|
||||
await infraConfigService.update(name, value);
|
||||
|
||||
expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({
|
||||
where: { name },
|
||||
data: { value },
|
||||
});
|
||||
expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the infra config update failed', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockRejectedValueOnce('null');
|
||||
|
||||
const result = await infraConfigService.update(name, value);
|
||||
expect(result).toEqualLeft(INFRA_CONFIG_UPDATE_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should get the infra config', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
const result = await infraConfigService.get(name);
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should pass correct params to prisma findUnique', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
|
||||
await infraConfigService.get(name);
|
||||
|
||||
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { name },
|
||||
});
|
||||
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the infra config does not exist', async () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
|
||||
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
|
||||
|
||||
const result = await infraConfigService.get(name);
|
||||
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMany', () => {
|
||||
it('should throw error if any disallowed names are provided', async () => {
|
||||
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
|
||||
const result = await infraConfigService.getMany(disallowedNames);
|
||||
|
||||
expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
|
||||
});
|
||||
it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => {
|
||||
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
|
||||
|
||||
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
|
||||
disallowedNames.includes(dbConfig.name as InfraConfigEnum),
|
||||
);
|
||||
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
|
||||
dbInfraConfigResponses,
|
||||
);
|
||||
|
||||
const result = await infraConfigService.getMany(disallowedNames, false);
|
||||
|
||||
expect(result).toEqualRight(
|
||||
infraConfigs.filter((i) => disallowedNames.includes(i.name)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return right with infraConfigs if Prisma query succeeds', async () => {
|
||||
const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID];
|
||||
|
||||
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
|
||||
allowedNames.includes(dbConfig.name as InfraConfigEnum),
|
||||
);
|
||||
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
|
||||
dbInfraConfigResponses,
|
||||
);
|
||||
|
||||
const result = await infraConfigService.getMany(allowedNames);
|
||||
expect(result).toEqualRight(
|
||||
infraConfigs.filter((i) => allowedNames.includes(i.name)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InfraConfig } from './infra-config.model';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfig as DBInfraConfig } from '@prisma/client';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import {
|
||||
AUTH_PROVIDER_NOT_SPECIFIED,
|
||||
DATABASE_TABLE_NOT_EXIST,
|
||||
INFRA_CONFIG_INVALID_INPUT,
|
||||
INFRA_CONFIG_NOT_FOUND,
|
||||
INFRA_CONFIG_RESET_FAILED,
|
||||
INFRA_CONFIG_UPDATE_FAILED,
|
||||
INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
|
||||
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
|
||||
} from 'src/errors';
|
||||
import {
|
||||
throwErr,
|
||||
validateSMTPEmail,
|
||||
validateSMTPUrl,
|
||||
validateUrl,
|
||||
} from 'src/utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ServiceStatus, getDefaultInfraConfigs, stopApp } from './helper';
|
||||
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
|
||||
@Injectable()
|
||||
export class InfraConfigService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
|
||||
EXCLUDE_FROM_UPDATE_CONFIGS = [
|
||||
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
InfraConfigEnum.ANALYTICS_USER_ID,
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
];
|
||||
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
|
||||
EXCLUDE_FROM_FETCH_CONFIGS = [
|
||||
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
InfraConfigEnum.ANALYTICS_USER_ID,
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
];
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initializeInfraConfigTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the 'infra_config' table with values from .env
|
||||
* @description This function create rows 'infra_config' in very first time (only once)
|
||||
*/
|
||||
async initializeInfraConfigTable() {
|
||||
try {
|
||||
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
|
||||
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
|
||||
const dbInfraConfigs = await this.prisma.infraConfig.findMany();
|
||||
const propsToInsert = infraConfigDefaultObjs.filter(
|
||||
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
|
||||
);
|
||||
|
||||
if (propsToInsert.length > 0) {
|
||||
await this.prisma.infraConfig.createMany({ data: propsToInsert });
|
||||
stopApp();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'P1001') {
|
||||
// Prisma error code for 'Can't reach at database server'
|
||||
// We're not throwing error here because we want to allow the app to run 'pnpm install'
|
||||
} else if (error.code === 'P2021') {
|
||||
// Prisma error code for 'Table does not exist'
|
||||
throwErr(DATABASE_TABLE_NOT_EXIST);
|
||||
} else {
|
||||
throwErr(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database InfraConfig to a InfraConfig model
|
||||
* @param dbInfraConfig database InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
cast(dbInfraConfig: DBInfraConfig) {
|
||||
return <InfraConfig>{
|
||||
name: dbInfraConfig.name,
|
||||
value: dbInfraConfig.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the InfraConfigs as map
|
||||
* @returns InfraConfig map
|
||||
*/
|
||||
async getInfraConfigsMap() {
|
||||
const infraConfigs = await this.prisma.infraConfig.findMany();
|
||||
const infraConfigMap: Record<string, string> = {};
|
||||
infraConfigs.forEach((config) => {
|
||||
infraConfigMap[config.name] = config.value;
|
||||
});
|
||||
return infraConfigMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update InfraConfig by name
|
||||
* @param name Name of the InfraConfig
|
||||
* @param value Value of the InfraConfig
|
||||
* @param restartEnabled If true, restart the app after updating the InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
|
||||
const isValidate = this.validateEnvValues([{ name, value }]);
|
||||
if (E.isLeft(isValidate)) return E.left(isValidate.left);
|
||||
|
||||
try {
|
||||
const infraConfig = await this.prisma.infraConfig.update({
|
||||
where: { name },
|
||||
data: { value },
|
||||
});
|
||||
|
||||
if (restartEnabled) stopApp();
|
||||
|
||||
return E.right(this.cast(infraConfig));
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_UPDATE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update InfraConfigs by name
|
||||
* @param infraConfigs InfraConfigs to update
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async updateMany(infraConfigs: InfraConfigArgs[]) {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
|
||||
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
const isValidate = this.validateEnvValues(infraConfigs);
|
||||
if (E.isLeft(isValidate)) return E.left(isValidate.left);
|
||||
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
await tx.infraConfig.update({
|
||||
where: { name: infraConfigs[i].name },
|
||||
data: { value: infraConfigs[i].value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stopApp();
|
||||
|
||||
return E.right(infraConfigs);
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_UPDATE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service is configured or not
|
||||
* @param service Service can be Auth Provider, Mailer, Audit Log etc.
|
||||
* @param configMap Map of all the infra configs
|
||||
* @returns Either true or false
|
||||
*/
|
||||
isServiceConfigured(
|
||||
service: AuthProvider,
|
||||
configMap: Record<string, string>,
|
||||
) {
|
||||
switch (service) {
|
||||
case AuthProvider.GOOGLE:
|
||||
return (
|
||||
configMap.GOOGLE_CLIENT_ID &&
|
||||
configMap.GOOGLE_CLIENT_SECRET &&
|
||||
configMap.GOOGLE_CALLBACK_URL &&
|
||||
configMap.GOOGLE_SCOPE
|
||||
);
|
||||
case AuthProvider.GITHUB:
|
||||
return (
|
||||
configMap.GITHUB_CLIENT_ID &&
|
||||
configMap.GITHUB_CLIENT_SECRET &&
|
||||
configMap.GITHUB_CALLBACK_URL &&
|
||||
configMap.GITHUB_SCOPE
|
||||
);
|
||||
case AuthProvider.MICROSOFT:
|
||||
return (
|
||||
configMap.MICROSOFT_CLIENT_ID &&
|
||||
configMap.MICROSOFT_CLIENT_SECRET &&
|
||||
configMap.MICROSOFT_CALLBACK_URL &&
|
||||
configMap.MICROSOFT_SCOPE &&
|
||||
configMap.MICROSOFT_TENANT
|
||||
);
|
||||
case AuthProvider.EMAIL:
|
||||
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or Disable Analytics Collection
|
||||
*
|
||||
* @param status Status to enable or disable
|
||||
* @returns Boolean of status of analytics collection
|
||||
*/
|
||||
async toggleAnalyticsCollection(status: ServiceStatus) {
|
||||
const isUpdated = await this.update(
|
||||
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
status === ServiceStatus.ENABLE ? 'true' : 'false',
|
||||
);
|
||||
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
return E.right(isUpdated.right.value === 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or Disable SSO for login/signup
|
||||
* @param provider Auth Provider to enable or disable
|
||||
* @param status Status to enable or disable
|
||||
* @returns Either true or an error
|
||||
*/
|
||||
async enableAndDisableSSO(providerInfo: EnableAndDisableSSOArgs[]) {
|
||||
const allowedAuthProviders = this.configService
|
||||
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
|
||||
.split(',');
|
||||
|
||||
let updatedAuthProviders = allowedAuthProviders;
|
||||
|
||||
const infraConfigMap = await this.getInfraConfigsMap();
|
||||
|
||||
providerInfo.forEach(({ provider, status }) => {
|
||||
if (status === ServiceStatus.ENABLE) {
|
||||
const isConfigured = this.isServiceConfigured(provider, infraConfigMap);
|
||||
if (!isConfigured) {
|
||||
throwErr(INFRA_CONFIG_SERVICE_NOT_CONFIGURED);
|
||||
}
|
||||
updatedAuthProviders.push(provider);
|
||||
} else if (status === ServiceStatus.DISABLE) {
|
||||
updatedAuthProviders = updatedAuthProviders.filter(
|
||||
(p) => p !== provider,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
updatedAuthProviders = [...new Set(updatedAuthProviders)];
|
||||
|
||||
if (updatedAuthProviders.length === 0) {
|
||||
return E.left(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||
}
|
||||
|
||||
const isUpdated = await this.update(
|
||||
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
updatedAuthProviders.join(','),
|
||||
true,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get InfraConfig by name
|
||||
* @param name Name of the InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async get(name: InfraConfigEnum) {
|
||||
try {
|
||||
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
|
||||
where: { name },
|
||||
});
|
||||
|
||||
return E.right(this.cast(infraConfig));
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get InfraConfigs by names
|
||||
* @param names Names of the InfraConfigs
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) {
|
||||
if (checkDisallowedKeys) {
|
||||
// Check if the names are allowed to fetch by client
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i]))
|
||||
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const infraConfigs = await this.prisma.infraConfig.findMany({
|
||||
where: { name: { in: names } },
|
||||
});
|
||||
|
||||
return E.right(infraConfigs.map((p) => this.cast(p)));
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed auth providers for login/signup
|
||||
* @returns string[]
|
||||
*/
|
||||
getAllowedAuthProviders() {
|
||||
return this.configService
|
||||
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
|
||||
.split(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all the InfraConfigs to their default values (from .env)
|
||||
*/
|
||||
async reset() {
|
||||
try {
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
|
||||
await this.prisma.infraConfig.deleteMany({
|
||||
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
|
||||
});
|
||||
|
||||
// Hardcode t
|
||||
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
|
||||
(obj) => obj.name !== InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
);
|
||||
await this.prisma.infraConfig.createMany({
|
||||
data: [
|
||||
...updatedInfraConfigDefaultObjs,
|
||||
{
|
||||
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
value: 'true',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stopApp();
|
||||
|
||||
return E.right(true);
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_RESET_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the values of the InfraConfigs
|
||||
*/
|
||||
validateEnvValues(
|
||||
infraConfigs: {
|
||||
name: InfraConfigEnum;
|
||||
value: string;
|
||||
}[],
|
||||
) {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
switch (infraConfigs[i].name) {
|
||||
case InfraConfigEnum.MAILER_SMTP_URL:
|
||||
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
|
||||
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_ADDRESS_FROM:
|
||||
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
|
||||
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GOOGLE_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
|
||||
if (!validateUrl(infraConfigs[i].value))
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GOOGLE_SCOPE:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GITHUB_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GITHUB_CLIENT_SECRET:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GITHUB_CALLBACK_URL:
|
||||
if (!validateUrl(infraConfigs[i].value))
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GITHUB_SCOPE:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
|
||||
if (!validateUrl(infraConfigs[i].value))
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_SCOPE:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_TENANT:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
}
|
||||
30
packages/hoppscotch-backend/src/infra-config/input-args.ts
Normal file
30
packages/hoppscotch-backend/src/infra-config/input-args.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from './helper';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
|
||||
@InputType()
|
||||
export class InfraConfigArgs {
|
||||
@Field(() => InfraConfigEnum, {
|
||||
description: 'Infra Config Name',
|
||||
})
|
||||
name: InfraConfigEnum;
|
||||
|
||||
@Field({
|
||||
description: 'Infra Config Value',
|
||||
})
|
||||
value: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class EnableAndDisableSSOArgs {
|
||||
@Field(() => AuthProvider, {
|
||||
description: 'Auth Provider',
|
||||
})
|
||||
provider: AuthProvider;
|
||||
|
||||
@Field(() => ServiceStatus, {
|
||||
description: 'Auth Provider Status',
|
||||
})
|
||||
status: ServiceStatus;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||
import { MailerService } from './mailer.service';
|
||||
@@ -7,24 +7,42 @@ import {
|
||||
MAILER_FROM_ADDRESS_UNDEFINED,
|
||||
MAILER_SMTP_URL_UNDEFINED,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { loadInfraConfiguration } from 'src/infra-config/helper';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
NestMailerModule.forRoot({
|
||||
transport:
|
||||
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
||||
defaults: {
|
||||
from:
|
||||
process.env.MAILER_ADDRESS_FROM ??
|
||||
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
||||
},
|
||||
template: {
|
||||
dir: __dirname + '/templates',
|
||||
adapter: new HandlebarsAdapter(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
imports: [],
|
||||
providers: [MailerService],
|
||||
exports: [MailerService],
|
||||
})
|
||||
export class MailerModule {}
|
||||
export class MailerModule {
|
||||
static async register() {
|
||||
const env = await loadInfraConfiguration();
|
||||
|
||||
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
|
||||
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
|
||||
|
||||
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
|
||||
const config = new ConfigService();
|
||||
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
|
||||
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
|
||||
}
|
||||
|
||||
return {
|
||||
module: MailerModule,
|
||||
imports: [
|
||||
NestMailerModule.forRoot({
|
||||
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
||||
defaults: {
|
||||
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
||||
},
|
||||
template: {
|
||||
dir: __dirname + '/templates',
|
||||
adapter: new HandlebarsAdapter(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export class MailerService {
|
||||
): string {
|
||||
switch (mailDesc.template) {
|
||||
case 'team-invitation':
|
||||
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
|
||||
return `A user has invited you to join a team workspace in Hoppscotch`;
|
||||
|
||||
case 'user-invitation':
|
||||
return 'Sign in to Hoppscotch';
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
color: #3869D4;
|
||||
}
|
||||
|
||||
a.nohighlight {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
@@ -458,7 +464,7 @@
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
<h1>Hi there,</h1>
|
||||
<p>{{invitee}} with {{invite_team_name}} has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
|
||||
<p><a class="nohighlight" name="invitee" href="#">{{invitee}}</a> with <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a> has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
@@ -484,7 +490,7 @@
|
||||
Welcome aboard, <br />
|
||||
Your friends at Hoppscotch
|
||||
</p>
|
||||
<p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
|
||||
<p><strong>P.S.</strong> If you don't associate with <a class="nohighlight" name="invitee" href="#">{{invitee}}</a> or <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a>, just ignore this email.</p>
|
||||
<!-- Sub copy -->
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
|
||||
@@ -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,25 @@
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: #3869D4;
|
||||
}
|
||||
|
||||
|
||||
a.nohighlight {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
td {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
@@ -47,13 +53,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
@@ -61,7 +67,7 @@
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
@@ -69,7 +75,7 @@
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
@@ -77,12 +83,12 @@
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
@@ -91,25 +97,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 +130,7 @@
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
.button--green {
|
||||
background-color: #22BC66;
|
||||
border-top: 10px solid #22BC66;
|
||||
@@ -132,7 +138,7 @@
|
||||
border-bottom: 10px solid #22BC66;
|
||||
border-left: 18px solid #22BC66;
|
||||
}
|
||||
|
||||
|
||||
.button--red {
|
||||
background-color: #FF6136;
|
||||
border-top: 10px solid #FF6136;
|
||||
@@ -140,7 +146,7 @@
|
||||
border-bottom: 10px solid #FF6136;
|
||||
border-left: 18px solid #FF6136;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
@@ -148,21 +154,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 +177,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 +212,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 +247,7 @@
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
|
||||
.purchase_content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -250,50 +256,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 +309,7 @@
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F2F4F6;
|
||||
}
|
||||
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -313,16 +319,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 +337,7 @@
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -340,7 +346,7 @@
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
@@ -350,7 +356,7 @@
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
@@ -360,11 +366,11 @@
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.email-footer p {
|
||||
color: #A8AAAF;
|
||||
}
|
||||
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
@@ -374,25 +380,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,
|
||||
|
||||
@@ -6,18 +6,24 @@ import { VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
import { emitGQLSchemaFile } from './gql-schema';
|
||||
import { checkEnvironmentAuthProvider } from './utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
async function bootstrap() {
|
||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||
console.log(`Port: ${process.env.PORT}`);
|
||||
|
||||
checkEnvironmentAuthProvider();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
console.log(`Running in production: ${configService.get('PRODUCTION')}`);
|
||||
console.log(`Port: ${configService.get('PORT')}`);
|
||||
|
||||
checkEnvironmentAuthProvider(
|
||||
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
|
||||
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
);
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET,
|
||||
secret: configService.get('SESSION_SECRET'),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -28,18 +34,18 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
if (process.env.PRODUCTION === 'false') {
|
||||
if (configService.get('PRODUCTION') === 'false') {
|
||||
console.log('Enabling CORS with development settings');
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||
origin: configService.get('WHITELISTED_ORIGINS').split(','),
|
||||
credentials: true,
|
||||
});
|
||||
} else {
|
||||
console.log('Enabling CORS with production settings');
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||
origin: configService.get('WHITELISTED_ORIGINS').split(','),
|
||||
credentials: true,
|
||||
});
|
||||
}
|
||||
@@ -47,7 +53,13 @@ async function bootstrap() {
|
||||
type: VersioningType.URI,
|
||||
});
|
||||
app.use(cookieParser());
|
||||
await app.listen(process.env.PORT || 3170);
|
||||
await app.listen(configService.get('PORT') || 3170);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.info('SIGTERM signal received');
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
|
||||
if (!process.env.GENERATE_GQL_SCHEMA) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PosthogService } from './posthog.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [PosthogService],
|
||||
})
|
||||
export class PosthogModule {}
|
||||
58
packages/hoppscotch-backend/src/posthog/posthog.service.ts
Normal file
58
packages/hoppscotch-backend/src/posthog/posthog.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PostHog } from 'posthog-node';
|
||||
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CronJob } from 'cron';
|
||||
import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
@Injectable()
|
||||
export class PosthogService {
|
||||
private postHogClient: PostHog;
|
||||
private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private schedulerRegistry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
|
||||
console.log('Initializing PostHog');
|
||||
this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
|
||||
host: 'https://eu.posthog.com',
|
||||
});
|
||||
|
||||
// Schedule the cron job only if analytics collection is allowed
|
||||
this.scheduleCronJob();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleCronJob() {
|
||||
const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
|
||||
await this.capture();
|
||||
});
|
||||
|
||||
this.schedulerRegistry.addCronJob('captureAnalytics', job);
|
||||
job.start();
|
||||
}
|
||||
|
||||
async capture() {
|
||||
if (!this.postHogClient) {
|
||||
throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
|
||||
}
|
||||
|
||||
this.postHogClient.capture({
|
||||
distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
|
||||
event: 'sh_instance',
|
||||
properties: {
|
||||
type: 'COMMUNITY',
|
||||
total_user_count: await this.prismaService.user.count(),
|
||||
total_workspace_count: await this.prismaService.team.count(),
|
||||
version: this.configService.get('npm_package_version'),
|
||||
},
|
||||
});
|
||||
console.log('Sent event to PostHog');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { User } from 'src/user/user.model';
|
||||
|
||||
@ObjectType()
|
||||
export class Shortcode {
|
||||
@@ -23,3 +24,46 @@ export class Shortcode {
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Shortcode } from './shortcode.model';
|
||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
||||
import { ShortcodeService } from './shortcode.service';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
@@ -19,6 +19,7 @@ import { AuthUser } from '../types/AuthUser';
|
||||
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)
|
||||
@@ -121,7 +122,7 @@ export class ShortcodeResolver {
|
||||
@Args({
|
||||
name: 'code',
|
||||
type: () => ID,
|
||||
description: 'The shortcode to resolve',
|
||||
description: 'The shortcode to remove',
|
||||
})
|
||||
code: string,
|
||||
) {
|
||||
|
||||
@@ -1,12 +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_NOT_FOUND,
|
||||
SHORTCODE_PROPERTIES_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { Shortcode } from './shortcode.model';
|
||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
||||
import { ShortcodeService } from './shortcode.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
@@ -97,6 +98,35 @@ const shortcodes = [
|
||||
},
|
||||
];
|
||||
|
||||
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 () => {
|
||||
@@ -441,4 +471,103 @@ describe('ShortcodeService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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: shortcodesWithUserEmail[0].id,
|
||||
request: JSON.stringify(shortcodesWithUserEmail[0].request),
|
||||
properties: JSON.stringify(
|
||||
shortcodesWithUserEmail[0].embedProperties,
|
||||
),
|
||||
createdOn: shortcodesWithUserEmail[0].createdOn,
|
||||
creator: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: shortcodesWithUserEmail[1].id,
|
||||
request: JSON.stringify(shortcodesWithUserEmail[1].request),
|
||||
properties: JSON.stringify(
|
||||
shortcodesWithUserEmail[1].embedProperties,
|
||||
),
|
||||
createdOn: shortcodesWithUserEmail[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SHORTCODE_PROPERTIES_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { UserDataHandler } from 'src/user/user.data.handler';
|
||||
import { Shortcode } from './shortcode.model';
|
||||
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model';
|
||||
import { Shortcode as DBShortCode } from '@prisma/client';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
@@ -180,7 +180,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a ShortCode
|
||||
* Delete a ShortCode created by User of uid
|
||||
*
|
||||
* @param shortcode ShortCode
|
||||
* @param uid User Uid
|
||||
@@ -223,6 +223,26 @@ 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
|
||||
@@ -263,4 +283,57 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal file
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Type of data returned from the query to obtain all search results
|
||||
export type SearchQueryReturnType = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'collection' | 'request';
|
||||
method?: string;
|
||||
};
|
||||
|
||||
// Type of data returned from the query to obtain all parents
|
||||
export type ParentTreeQueryReturnType = {
|
||||
id: string;
|
||||
parentID: string;
|
||||
title: string;
|
||||
};
|
||||
@@ -14,6 +14,13 @@ 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()
|
||||
@@ -26,6 +33,13 @@ 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()
|
||||
@@ -33,12 +47,14 @@ 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;
|
||||
}
|
||||
@@ -98,3 +114,26 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { TeamCollectionService } from './team-collection.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'team-collection', version: '1' })
|
||||
export class TeamCollectionController {
|
||||
constructor(private readonly teamCollectionService: TeamCollectionService) {}
|
||||
|
||||
@Get('search/:teamID/:searchQuery')
|
||||
@RequiresTeamRole(
|
||||
TeamMemberRole.VIEWER,
|
||||
TeamMemberRole.EDITOR,
|
||||
TeamMemberRole.OWNER,
|
||||
)
|
||||
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
|
||||
async searchByTitle(
|
||||
@Param('searchQuery') searchQuery: string,
|
||||
@Param('teamID') teamID: string,
|
||||
@Query('take') take: string,
|
||||
@Query('skip') skip: string,
|
||||
) {
|
||||
const res = await this.teamCollectionService.searchByTitle(
|
||||
searchQuery,
|
||||
teamID,
|
||||
parseInt(take),
|
||||
parseInt(skip),
|
||||
);
|
||||
if (E.isLeft(res)) throwHTTPErr(res.left);
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,17 @@ 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()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe
|
||||
import { TeamModule } from '../team/team.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
import { TeamCollectionController } from './team-collection.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
|
||||
@@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
GqlCollectionTeamMemberGuard,
|
||||
],
|
||||
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
|
||||
controllers: [TeamCollectionController],
|
||||
})
|
||||
export class TeamCollectionModule {}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
MoveTeamCollectionArgs,
|
||||
RenameTeamCollectionArgs,
|
||||
ReplaceTeamCollectionArgs,
|
||||
UpdateTeamCollectionArgs,
|
||||
UpdateTeamCollectionOrderArgs,
|
||||
} from './input-type.args';
|
||||
import * as E from 'fp-ts/Either';
|
||||
@@ -141,7 +142,14 @@ export class TeamCollectionResolver {
|
||||
);
|
||||
|
||||
if (E.isLeft(teamCollections)) throwErr(teamCollections.left);
|
||||
return teamCollections.right;
|
||||
return <TeamCollection>{
|
||||
id: teamCollections.right.id,
|
||||
title: teamCollections.right.title,
|
||||
parentID: teamCollections.right.parentID,
|
||||
data: !teamCollections.right.data
|
||||
? null
|
||||
: JSON.stringify(teamCollections.right.data),
|
||||
};
|
||||
}
|
||||
|
||||
// Mutations
|
||||
@@ -155,6 +163,7 @@ export class TeamCollectionResolver {
|
||||
const teamCollection = await this.teamCollectionService.createCollection(
|
||||
args.teamID,
|
||||
args.title,
|
||||
args.data,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -230,6 +239,7 @@ export class TeamCollectionResolver {
|
||||
const teamCollection = await this.teamCollectionService.createCollection(
|
||||
team.right.id,
|
||||
args.childTitle,
|
||||
args.data,
|
||||
args.collectionID,
|
||||
);
|
||||
|
||||
@@ -239,6 +249,7 @@ export class TeamCollectionResolver {
|
||||
|
||||
@Mutation(() => TeamCollection, {
|
||||
description: 'Rename a collection',
|
||||
deprecationReason: 'Switch to updateTeamCollection mutation instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
@@ -303,6 +314,23 @@ 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, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import {
|
||||
TEAM_COLL_DATA_INVALID,
|
||||
TEAM_COLL_DEST_SAME,
|
||||
TEAM_COLL_INVALID_JSON,
|
||||
TEAM_COLL_IS_PARENT_COLL,
|
||||
@@ -17,6 +18,7 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
||||
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';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -51,35 +53,60 @@ 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,
|
||||
@@ -87,11 +114,20 @@ 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,
|
||||
@@ -102,6 +138,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 2,
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -111,6 +149,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 3,
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -119,6 +159,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
id: 'bre3',
|
||||
orderIndex: 4,
|
||||
parentID: null,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -129,6 +171,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 5,
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -139,6 +183,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
data: {},
|
||||
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
},
|
||||
@@ -148,6 +194,8 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
data: {},
|
||||
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
},
|
||||
@@ -156,6 +204,7 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 8,
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -165,6 +214,7 @@ const rootTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 9,
|
||||
parentID: null,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -175,17 +225,83 @@ 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,
|
||||
@@ -195,6 +311,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 2,
|
||||
parentID: rootTeamCollection.id,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -204,6 +322,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
orderIndex: 3,
|
||||
parentID: rootTeamCollection.id,
|
||||
title: 'Root Collection 1',
|
||||
data: {},
|
||||
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
@@ -212,6 +332,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '567',
|
||||
orderIndex: 4,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -221,6 +343,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '123',
|
||||
orderIndex: 5,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -230,6 +354,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '678',
|
||||
orderIndex: 6,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -239,6 +365,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '789',
|
||||
orderIndex: 7,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -248,6 +376,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '890',
|
||||
orderIndex: 8,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -257,6 +387,7 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '012',
|
||||
orderIndex: 9,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -266,6 +397,8 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
id: '0bhu',
|
||||
orderIndex: 10,
|
||||
parentID: rootTeamCollection.id,
|
||||
data: {},
|
||||
|
||||
title: 'Root Collection 1',
|
||||
teamID: team.id,
|
||||
createdOn: currentTime,
|
||||
@@ -273,6 +406,75 @@ 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();
|
||||
@@ -311,7 +513,7 @@ describe('getParentOfCollection', () => {
|
||||
const result = await teamCollectionService.getParentOfCollection(
|
||||
childTeamCollection.id,
|
||||
);
|
||||
expect(result).toEqual(rootTeamCollection);
|
||||
expect(result).toEqual(rootTeamCollectionsCasted);
|
||||
});
|
||||
|
||||
test('should return null successfully for a root collection with valid collectionID', async () => {
|
||||
@@ -347,7 +549,7 @@ describe('getChildrenOfCollection', () => {
|
||||
null,
|
||||
10,
|
||||
);
|
||||
expect(result).toEqual(childTeamCollectionList);
|
||||
expect(result).toEqual(childTeamCollectionListCasted);
|
||||
});
|
||||
|
||||
test('should return a list of 3 child collections successfully with cursor being equal to the 7th item in the list', async () => {
|
||||
@@ -363,9 +565,9 @@ describe('getChildrenOfCollection', () => {
|
||||
10,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ ...childTeamCollectionList[7] },
|
||||
{ ...childTeamCollectionList[8] },
|
||||
{ ...childTeamCollectionList[9] },
|
||||
{ ...childTeamCollectionListCasted[7] },
|
||||
{ ...childTeamCollectionListCasted[8] },
|
||||
{ ...childTeamCollectionListCasted[9] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -392,7 +594,7 @@ describe('getTeamRootCollections', () => {
|
||||
null,
|
||||
10,
|
||||
);
|
||||
expect(result).toEqual(rootTeamCollectionList);
|
||||
expect(result).toEqual(rootTeamCollectionListCasted);
|
||||
});
|
||||
|
||||
test('should return a list of 3 root collections successfully with cursor being equal to the 7th item in the list', async () => {
|
||||
@@ -408,9 +610,9 @@ describe('getTeamRootCollections', () => {
|
||||
10,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ ...rootTeamCollectionList[7] },
|
||||
{ ...rootTeamCollectionList[8] },
|
||||
{ ...rootTeamCollectionList[9] },
|
||||
{ ...rootTeamCollectionListCasted[7] },
|
||||
{ ...rootTeamCollectionListCasted[8] },
|
||||
{ ...rootTeamCollectionListCasted[9] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -464,6 +666,7 @@ describe('createCollection', () => {
|
||||
const result = await teamCollectionService.createCollection(
|
||||
rootTeamCollection.teamID,
|
||||
'ab',
|
||||
JSON.stringify(rootTeamCollection.data),
|
||||
rootTeamCollection.id,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE);
|
||||
@@ -478,11 +681,27 @@ 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(
|
||||
@@ -496,9 +715,10 @@ describe('createCollection', () => {
|
||||
const result = await teamCollectionService.createCollection(
|
||||
rootTeamCollection.teamID,
|
||||
'abcdefg',
|
||||
JSON.stringify(rootTeamCollection.data),
|
||||
rootTeamCollection.id,
|
||||
);
|
||||
expect(result).toEqualRight(rootTeamCollection);
|
||||
expect(result).toEqualRight(rootTeamCollectionsCasted);
|
||||
});
|
||||
|
||||
test('should successfully create a new child TeamCollection with valid inputs', async () => {
|
||||
@@ -514,9 +734,10 @@ describe('createCollection', () => {
|
||||
const result = await teamCollectionService.createCollection(
|
||||
childTeamCollection.teamID,
|
||||
childTeamCollection.title,
|
||||
JSON.stringify(rootTeamCollection.data),
|
||||
rootTeamCollection.id,
|
||||
);
|
||||
expect(result).toEqualRight(childTeamCollection);
|
||||
expect(result).toEqualRight(childTeamCollectionCasted);
|
||||
});
|
||||
|
||||
test('should send pubsub message to "team_coll/<teamID>/coll_added" if child TeamCollection is created successfully', async () => {
|
||||
@@ -532,11 +753,13 @@ 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`,
|
||||
childTeamCollection,
|
||||
childTeamCollectionCasted,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -553,11 +776,13 @@ 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`,
|
||||
rootTeamCollection,
|
||||
rootTeamCollectionsCasted,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -587,7 +812,7 @@ describe('renameCollection', () => {
|
||||
'NewTitle',
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
...rootTeamCollection,
|
||||
...rootTeamCollectionsCasted,
|
||||
title: 'NewTitle',
|
||||
});
|
||||
});
|
||||
@@ -625,7 +850,7 @@ describe('renameCollection', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${rootTeamCollection.teamID}/coll_updated`,
|
||||
{
|
||||
...rootTeamCollection,
|
||||
...rootTeamCollectionsCasted,
|
||||
title: 'NewTitle',
|
||||
},
|
||||
);
|
||||
@@ -832,9 +1057,8 @@ describe('moveCollection', () => {
|
||||
null,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
...childTeamCollection,
|
||||
...childTeamCollectionCasted,
|
||||
parentID: null,
|
||||
orderIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -890,9 +1114,8 @@ describe('moveCollection', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${childTeamCollection.teamID}/coll_moved`,
|
||||
{
|
||||
...childTeamCollection,
|
||||
...childTeamCollectionCasted,
|
||||
parentID: null,
|
||||
orderIndex: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -931,9 +1154,8 @@ describe('moveCollection', () => {
|
||||
childTeamCollection_2.id,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
...rootTeamCollection,
|
||||
parentID: childTeamCollection_2.id,
|
||||
orderIndex: 1,
|
||||
...rootTeamCollectionsCasted,
|
||||
parentID: childTeamCollection_2Casted.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -973,9 +1195,8 @@ describe('moveCollection', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${childTeamCollection_2.teamID}/coll_moved`,
|
||||
{
|
||||
...rootTeamCollection,
|
||||
parentID: childTeamCollection_2.id,
|
||||
orderIndex: 1,
|
||||
...rootTeamCollectionsCasted,
|
||||
parentID: childTeamCollection_2Casted.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1014,9 +1235,8 @@ describe('moveCollection', () => {
|
||||
childTeamCollection_2.id,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
...childTeamCollection,
|
||||
parentID: childTeamCollection_2.id,
|
||||
orderIndex: 1,
|
||||
...childTeamCollectionCasted,
|
||||
parentID: childTeamCollection_2Casted.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1056,9 +1276,8 @@ describe('moveCollection', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${childTeamCollection.teamID}/coll_moved`,
|
||||
{
|
||||
...childTeamCollection,
|
||||
parentID: childTeamCollection_2.id,
|
||||
orderIndex: 1,
|
||||
...childTeamCollectionCasted,
|
||||
parentID: childTeamCollection_2Casted.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1154,7 +1373,7 @@ describe('updateCollectionOrder', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${childTeamCollectionList[4].teamID}/coll_order_updated`,
|
||||
{
|
||||
collection: rootTeamCollectionList[4],
|
||||
collection: rootTeamCollectionListCasted[4],
|
||||
nextCollection: null,
|
||||
},
|
||||
);
|
||||
@@ -1235,8 +1454,8 @@ describe('updateCollectionOrder', () => {
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${childTeamCollectionList[2].teamID}/coll_order_updated`,
|
||||
{
|
||||
collection: childTeamCollectionList[4],
|
||||
nextCollection: childTeamCollectionList[2],
|
||||
collection: childTeamCollectionListCasted[4],
|
||||
nextCollection: childTeamCollectionListCasted[2],
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1302,7 +1521,7 @@ describe('importCollectionsFromJSON', () => {
|
||||
);
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${rootTeamCollection.teamID}/coll_added`,
|
||||
rootTeamCollection,
|
||||
rootTeamCollectionsCasted,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1421,7 +1640,7 @@ describe('replaceCollectionsWithJSON', () => {
|
||||
);
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_coll/${rootTeamCollection.teamID}/coll_added`,
|
||||
rootTeamCollection,
|
||||
rootTeamCollectionsCasted,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1458,4 +1677,64 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TeamCollection } from './team-collection.model';
|
||||
import {
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
TEAM_COLL_IS_PARENT_COLL,
|
||||
TEAM_COL_SAME_NEXT_COLL,
|
||||
TEAM_COL_REORDERING_FAILED,
|
||||
TEAM_COLL_DATA_INVALID,
|
||||
TEAM_REQ_SEARCH_FAILED,
|
||||
TEAM_COL_SEARCH_FAILED,
|
||||
TEAM_REQ_PARENT_TREE_GEN_FAILED,
|
||||
TEAM_COLL_PARENT_TREE_GEN_FAILED,
|
||||
} from '../errors';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { isValidLength } from 'src/utils';
|
||||
@@ -21,6 +26,9 @@ import * as O from 'fp-ts/Option';
|
||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
|
||||
@Injectable()
|
||||
export class TeamCollectionService {
|
||||
@@ -69,6 +77,7 @@ export class TeamCollectionService {
|
||||
this.generatePrismaQueryObjForFBCollFolder(f, teamID, index + 1),
|
||||
),
|
||||
},
|
||||
data: folder.data ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +127,7 @@ 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);
|
||||
@@ -198,8 +208,11 @@ export class TeamCollectionService {
|
||||
),
|
||||
);
|
||||
|
||||
teamCollections.forEach((x) =>
|
||||
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
|
||||
teamCollections.forEach((collection) =>
|
||||
this.pubsub.publish(
|
||||
`team_coll/${destTeamID}/coll_added`,
|
||||
this.cast(collection),
|
||||
),
|
||||
);
|
||||
|
||||
return E.right(true);
|
||||
@@ -268,8 +281,11 @@ export class TeamCollectionService {
|
||||
),
|
||||
);
|
||||
|
||||
teamCollections.forEach((x) =>
|
||||
this.pubsub.publish(`team_coll/${destTeamID}/coll_added`, x),
|
||||
teamCollections.forEach((collections) =>
|
||||
this.pubsub.publish(
|
||||
`team_coll/${destTeamID}/coll_added`,
|
||||
this.cast(collections),
|
||||
),
|
||||
);
|
||||
|
||||
return E.right(true);
|
||||
@@ -277,11 +293,17 @@ 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>{ ...teamCollection };
|
||||
return <TeamCollection>{
|
||||
id: teamCollection.id,
|
||||
title: teamCollection.title,
|
||||
parentID: teamCollection.parentID,
|
||||
data: !teamCollection.data ? null : JSON.stringify(teamCollection.data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,7 +346,7 @@ export class TeamCollectionService {
|
||||
});
|
||||
if (!teamCollection) return null;
|
||||
|
||||
return teamCollection.parent;
|
||||
return !teamCollection.parent ? null : this.cast(teamCollection.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,12 +357,12 @@ export class TeamCollectionService {
|
||||
* @param take Number of items we want returned
|
||||
* @returns A list of child collections
|
||||
*/
|
||||
getChildrenOfCollection(
|
||||
async getChildrenOfCollection(
|
||||
collectionID: string,
|
||||
cursor: string | null,
|
||||
take: number,
|
||||
) {
|
||||
return this.prisma.teamCollection.findMany({
|
||||
const res = await this.prisma.teamCollection.findMany({
|
||||
where: {
|
||||
parentID: collectionID,
|
||||
},
|
||||
@@ -351,6 +373,12 @@ export class TeamCollectionService {
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
});
|
||||
|
||||
const childCollections = res.map((teamCollection) =>
|
||||
this.cast(teamCollection),
|
||||
);
|
||||
|
||||
return childCollections;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +394,7 @@ export class TeamCollectionService {
|
||||
cursor: string | null,
|
||||
take: number,
|
||||
) {
|
||||
return this.prisma.teamCollection.findMany({
|
||||
const res = await this.prisma.teamCollection.findMany({
|
||||
where: {
|
||||
teamID,
|
||||
parentID: null,
|
||||
@@ -378,6 +406,12 @@ export class TeamCollectionService {
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
});
|
||||
|
||||
const teamCollections = res.map((teamCollection) =>
|
||||
this.cast(teamCollection),
|
||||
);
|
||||
|
||||
return teamCollections;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,6 +504,7 @@ export class TeamCollectionService {
|
||||
async createCollection(
|
||||
teamID: string,
|
||||
title: string,
|
||||
data: string | null = null,
|
||||
parentTeamCollectionID: string | null,
|
||||
) {
|
||||
const isTitleValid = isValidLength(title, this.TITLE_LENGTH);
|
||||
@@ -481,6 +516,13 @@ 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: {
|
||||
@@ -498,18 +540,23 @@ 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`, teamCollection);
|
||||
this.pubsub.publish(
|
||||
`team_coll/${teamID}/coll_added`,
|
||||
this.cast(teamCollection),
|
||||
);
|
||||
|
||||
return E.right(this.cast(teamCollection));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use updateTeamCollection method instead
|
||||
* Update the title of a TeamCollection
|
||||
*
|
||||
* @param collectionID The Collection ID
|
||||
@@ -532,10 +579,10 @@ export class TeamCollectionService {
|
||||
|
||||
this.pubsub.publish(
|
||||
`team_coll/${updatedTeamCollection.teamID}/coll_updated`,
|
||||
updatedTeamCollection,
|
||||
this.cast(updatedTeamCollection),
|
||||
);
|
||||
|
||||
return E.right(updatedTeamCollection);
|
||||
return E.right(this.cast(updatedTeamCollection));
|
||||
} catch (error) {
|
||||
return E.left(TEAM_COLL_NOT_FOUND);
|
||||
}
|
||||
@@ -694,8 +741,8 @@ export class TeamCollectionService {
|
||||
* @returns An Option of boolean, is parent or not
|
||||
*/
|
||||
private async isParent(
|
||||
collection: TeamCollection,
|
||||
destCollection: TeamCollection,
|
||||
collection: DBTeamCollection,
|
||||
destCollection: DBTeamCollection,
|
||||
): 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
|
||||
@@ -971,4 +1018,311 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TeamCollections and TeamRequests by title
|
||||
*
|
||||
* @param searchQuery The search query
|
||||
* @param teamID The Team ID
|
||||
* @param take Number of items we want returned
|
||||
* @param skip Number of items we want to skip
|
||||
* @returns An Either of the search results
|
||||
*/
|
||||
async searchByTitle(
|
||||
searchQuery: string,
|
||||
teamID: string,
|
||||
take = 10,
|
||||
skip = 0,
|
||||
) {
|
||||
// Fetch all collections and requests that match the search query
|
||||
const searchResults: SearchQueryReturnType[] = [];
|
||||
|
||||
const matchedCollections = await this.searchCollections(
|
||||
searchQuery,
|
||||
teamID,
|
||||
take,
|
||||
skip,
|
||||
);
|
||||
if (E.isLeft(matchedCollections))
|
||||
return E.left(<RESTError>{
|
||||
message: matchedCollections.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
searchResults.push(...matchedCollections.right);
|
||||
|
||||
const matchedRequests = await this.searchRequests(
|
||||
searchQuery,
|
||||
teamID,
|
||||
take,
|
||||
skip,
|
||||
);
|
||||
if (E.isLeft(matchedRequests))
|
||||
return E.left(<RESTError>{
|
||||
message: matchedRequests.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
searchResults.push(...matchedRequests.right);
|
||||
|
||||
// Generate the parent tree for searchResults
|
||||
const searchResultsWithTree: CollectionSearchNode[] = [];
|
||||
|
||||
for (let i = 0; i < searchResults.length; i++) {
|
||||
const fetchedParentTree = await this.fetchParentTree(searchResults[i]);
|
||||
if (E.isLeft(fetchedParentTree))
|
||||
return E.left(<RESTError>{
|
||||
message: fetchedParentTree.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
searchResultsWithTree.push({
|
||||
type: searchResults[i].type,
|
||||
title: searchResults[i].title,
|
||||
method: searchResults[i].method,
|
||||
id: searchResults[i].id,
|
||||
path: !fetchedParentTree
|
||||
? []
|
||||
: ([fetchedParentTree.right] as CollectionSearchNode[]),
|
||||
});
|
||||
}
|
||||
|
||||
return E.right({ data: searchResultsWithTree });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TeamCollections by title
|
||||
*
|
||||
* @param searchQuery The search query
|
||||
* @param teamID The Team ID
|
||||
* @param take Number of items we want returned
|
||||
* @param skip Number of items we want to skip
|
||||
* @returns An Either of the search results
|
||||
*/
|
||||
private async searchCollections(
|
||||
searchQuery: string,
|
||||
teamID: string,
|
||||
take: number,
|
||||
skip: number,
|
||||
) {
|
||||
const query = Prisma.sql`
|
||||
select id,title,'collection' AS type
|
||||
from "TeamCollection"
|
||||
where "TeamCollection"."teamID"=${teamID}
|
||||
and titlesearch @@ to_tsquery(${searchQuery})
|
||||
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
|
||||
limit ${take}
|
||||
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
|
||||
`;
|
||||
try {
|
||||
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
|
||||
return E.right(res);
|
||||
} catch (error) {
|
||||
return E.left(TEAM_COL_SEARCH_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TeamRequests by title
|
||||
*
|
||||
* @param searchQuery The search query
|
||||
* @param teamID The Team ID
|
||||
* @param take Number of items we want returned
|
||||
* @param skip Number of items we want to skip
|
||||
* @returns An Either of the search results
|
||||
*/
|
||||
private async searchRequests(
|
||||
searchQuery: string,
|
||||
teamID: string,
|
||||
take: number,
|
||||
skip: number,
|
||||
) {
|
||||
const query = Prisma.sql`
|
||||
select id,title,request->>'method' as method,'request' AS type
|
||||
from "TeamRequest"
|
||||
where "TeamRequest"."teamID"=${teamID}
|
||||
and titlesearch @@ to_tsquery(${searchQuery})
|
||||
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
|
||||
limit ${take}
|
||||
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
|
||||
return E.right(res);
|
||||
} catch (error) {
|
||||
return E.left(TEAM_REQ_SEARCH_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the parent tree of a search result
|
||||
*
|
||||
* @param searchResult The search result for which we want to generate the parent tree
|
||||
* @returns The parent tree of the search result
|
||||
*/
|
||||
private async fetchParentTree(searchResult: SearchQueryReturnType) {
|
||||
return searchResult.type === 'collection'
|
||||
? await this.fetchCollectionParentTree(searchResult.id)
|
||||
: await this.fetchRequestParentTree(searchResult.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the parent tree of a collection
|
||||
*
|
||||
* @param id The ID of the collection
|
||||
* @returns The parent tree of the collection
|
||||
*/
|
||||
private async fetchCollectionParentTree(id: string) {
|
||||
try {
|
||||
const query = Prisma.sql`
|
||||
WITH RECURSIVE collection_tree AS (
|
||||
SELECT tc.id, tc."parentID", tc.title
|
||||
FROM "TeamCollection" AS tc
|
||||
JOIN "TeamCollection" AS tr ON tc.id = tr."parentID"
|
||||
WHERE tr.id = ${id}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT parent.id, parent."parentID", parent.title
|
||||
FROM "TeamCollection" AS parent
|
||||
JOIN collection_tree AS ct ON parent.id = ct."parentID"
|
||||
)
|
||||
SELECT * FROM collection_tree;
|
||||
`;
|
||||
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
|
||||
query,
|
||||
);
|
||||
|
||||
const collectionParentTree = this.generateParentTree(res);
|
||||
return E.right(collectionParentTree);
|
||||
} catch (error) {
|
||||
E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the parent tree from the collections
|
||||
*
|
||||
* @param parentCollections The parent collections
|
||||
* @returns The parent tree of the parent collections
|
||||
*/
|
||||
private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) {
|
||||
function findChildren(id) {
|
||||
const collection = parentCollections.filter((item) => item.id === id)[0];
|
||||
if (collection.parentID == null) {
|
||||
return {
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
type: 'collection',
|
||||
path: [],
|
||||
};
|
||||
}
|
||||
|
||||
const res = {
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
type: 'collection',
|
||||
path: findChildren(collection.parentID),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
if (parentCollections.length > 0) {
|
||||
if (parentCollections[0].parentID == null) {
|
||||
return {
|
||||
id: parentCollections[0].id,
|
||||
title: parentCollections[0].title,
|
||||
type: 'collection',
|
||||
path: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: parentCollections[0].id,
|
||||
title: parentCollections[0].title,
|
||||
type: 'collection',
|
||||
path: findChildren(parentCollections[0].parentID),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the parent tree of a request
|
||||
*
|
||||
* @param id The ID of the request
|
||||
* @returns The parent tree of the request
|
||||
*/
|
||||
private async fetchRequestParentTree(id: string) {
|
||||
try {
|
||||
const query = Prisma.sql`
|
||||
WITH RECURSIVE request_collection_tree AS (
|
||||
SELECT tc.id, tc."parentID", tc.title
|
||||
FROM "TeamCollection" AS tc
|
||||
JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID"
|
||||
WHERE tr.id = ${id}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT parent.id, parent."parentID", parent.title
|
||||
FROM "TeamCollection" AS parent
|
||||
JOIN request_collection_tree AS ct ON parent.id = ct."parentID"
|
||||
)
|
||||
SELECT * FROM request_collection_tree;
|
||||
|
||||
`;
|
||||
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
|
||||
query,
|
||||
);
|
||||
|
||||
const requestParentTree = this.generateParentTree(res);
|
||||
return E.right(requestParentTree);
|
||||
} catch (error) {
|
||||
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerModule } from 'src/mailer/mailer.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
||||
import { TeamModule } from 'src/team/team.module';
|
||||
@@ -12,7 +11,7 @@ import { TeamInviteeGuard } from './team-invitee.guard';
|
||||
import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TeamModule, PubSubModule, UserModule, MailerModule],
|
||||
imports: [PrismaModule, TeamModule, PubSubModule, UserModule],
|
||||
providers: [
|
||||
TeamInvitationService,
|
||||
TeamInvitationResolver,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { validateEmail } from '../utils';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInvitationService {
|
||||
@@ -28,8 +29,8 @@ export class TeamInvitationService {
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly mailerService: MailerService,
|
||||
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -150,7 +151,9 @@ export class TeamInvitationService {
|
||||
template: 'team-invitation',
|
||||
variables: {
|
||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
||||
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
|
||||
action_url: `${this.configService.get('VITE_BASE_URL')}/join-team?id=${
|
||||
dbInvitation.id
|
||||
}`,
|
||||
invite_team_name: team.name,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const teamCollection: DbTeamCollection = {
|
||||
id: 'team-coll-1',
|
||||
parentID: null,
|
||||
teamID: team.id,
|
||||
data: {},
|
||||
title: 'Team Collection 1',
|
||||
orderIndex: 1,
|
||||
createdOn: new Date(),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { TeamService } from '../../team/team.service';
|
||||
import { TeamMemberRole } from '../../team/team.model';
|
||||
import {
|
||||
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_NO_TEAM_ID,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
TEAM_NOT_REQUIRED_ROLE,
|
||||
} from 'src/errors';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class RESTTeamMemberGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requireRoles = this.reflector.get<TeamMemberRole[]>(
|
||||
'requiresTeamRole',
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!requireRoles)
|
||||
throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 });
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const { user } = request;
|
||||
if (user == undefined)
|
||||
throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 });
|
||||
|
||||
const teamID = request.params.teamID;
|
||||
if (!teamID)
|
||||
throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 });
|
||||
|
||||
const teamMember = await this.teamService.getTeamMember(teamID, user.uid);
|
||||
if (!teamMember)
|
||||
throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 });
|
||||
|
||||
if (requireRoles.includes(teamMember.role)) return true;
|
||||
|
||||
throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Response type of results from the search query
|
||||
export type CollectionSearchNode = {
|
||||
/** Encodes the hierarchy of where the node is **/
|
||||
path: CollectionSearchNode[];
|
||||
} & (
|
||||
| {
|
||||
type: 'request';
|
||||
title: string;
|
||||
method: string;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'collection';
|
||||
title: string;
|
||||
id: string;
|
||||
}
|
||||
);
|
||||
26
packages/hoppscotch-backend/src/types/InfraConfig.ts
Normal file
26
packages/hoppscotch-backend/src/types/InfraConfig.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export enum InfraConfigEnum {
|
||||
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
|
||||
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
|
||||
|
||||
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
|
||||
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
|
||||
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
|
||||
GOOGLE_SCOPE = 'GOOGLE_SCOPE',
|
||||
|
||||
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
|
||||
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
|
||||
GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL',
|
||||
GITHUB_SCOPE = 'GITHUB_SCOPE',
|
||||
|
||||
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
|
||||
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
|
||||
MICROSOFT_CALLBACK_URL = 'MICROSOFT_CALLBACK_URL',
|
||||
MICROSOFT_SCOPE = 'MICROSOFT_SCOPE',
|
||||
MICROSOFT_TENANT = 'MICROSOFT_TENANT',
|
||||
|
||||
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
|
||||
|
||||
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
|
||||
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
|
||||
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
** Custom interface to handle errors specific to Auth module
|
||||
** Custom interface to handle errors for REST modules such as Auth, Admin modules
|
||||
** Since its REST we need to return the HTTP status code along with the error message
|
||||
*/
|
||||
export type AuthError = {
|
||||
export type RESTError = {
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
@@ -17,3 +17,21 @@ export class PaginationArgs {
|
||||
})
|
||||
take: number;
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
@InputType()
|
||||
export class OffsetPaginationArgs {
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 0,
|
||||
description: 'Number of items to skip',
|
||||
})
|
||||
skip: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 10,
|
||||
description: 'Number of items to fetch',
|
||||
})
|
||||
take: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ 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 {
|
||||
@@ -17,6 +24,13 @@ 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()
|
||||
@@ -95,3 +109,26 @@ 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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MoveUserCollectionArgs,
|
||||
RenameUserCollectionsArgs,
|
||||
UpdateUserCollectionArgs,
|
||||
UpdateUserCollectionsArgs,
|
||||
} from './input-type.args';
|
||||
import { ReqType } from 'src/types/RequestTypes';
|
||||
import * as E from 'fp-ts/Either';
|
||||
@@ -142,7 +143,13 @@ export class UserCollectionResolver {
|
||||
);
|
||||
|
||||
if (E.isLeft(userCollection)) throwErr(userCollection.left);
|
||||
return userCollection.right;
|
||||
return <UserCollection>{
|
||||
...userCollection.right,
|
||||
userID: userCollection.right.userUid,
|
||||
data: !userCollection.right.data
|
||||
? null
|
||||
: JSON.stringify(userCollection.right.data),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => UserCollectionExportJSONData, {
|
||||
@@ -191,6 +198,7 @@ export class UserCollectionResolver {
|
||||
await this.userCollectionService.createUserCollection(
|
||||
user,
|
||||
args.title,
|
||||
args.data,
|
||||
null,
|
||||
ReqType.REST,
|
||||
);
|
||||
@@ -212,6 +220,7 @@ export class UserCollectionResolver {
|
||||
await this.userCollectionService.createUserCollection(
|
||||
user,
|
||||
args.title,
|
||||
args.data,
|
||||
null,
|
||||
ReqType.GQL,
|
||||
);
|
||||
@@ -232,6 +241,7 @@ export class UserCollectionResolver {
|
||||
await this.userCollectionService.createUserCollection(
|
||||
user,
|
||||
args.title,
|
||||
args.data,
|
||||
args.parentUserCollectionID,
|
||||
ReqType.GQL,
|
||||
);
|
||||
@@ -252,6 +262,7 @@ export class UserCollectionResolver {
|
||||
await this.userCollectionService.createUserCollection(
|
||||
user,
|
||||
args.title,
|
||||
args.data,
|
||||
args.parentUserCollectionID,
|
||||
ReqType.REST,
|
||||
);
|
||||
@@ -359,6 +370,26 @@ 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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -43,8 +44,12 @@ export class UserCollectionService {
|
||||
*/
|
||||
private cast(collection: UserCollection) {
|
||||
return <UserCollectionModel>{
|
||||
...collection,
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
type: collection.type,
|
||||
parentID: collection.parentID,
|
||||
userID: collection.userUid,
|
||||
data: !collection.data ? null : JSON.stringify(collection.data),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ export class UserCollectionService {
|
||||
},
|
||||
});
|
||||
|
||||
return parent;
|
||||
return !parent ? null : this.cast(parent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,7 +169,7 @@ export class UserCollectionService {
|
||||
take: number,
|
||||
type: ReqType,
|
||||
) {
|
||||
return this.prisma.userCollection.findMany({
|
||||
const res = await this.prisma.userCollection.findMany({
|
||||
where: {
|
||||
parentID: collectionID,
|
||||
type: type,
|
||||
@@ -176,6 +181,12 @@ export class UserCollectionService {
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
});
|
||||
|
||||
const childCollections = res.map((childCollection) =>
|
||||
this.cast(childCollection),
|
||||
);
|
||||
|
||||
return childCollections;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,12 +222,20 @@ 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(
|
||||
@@ -251,15 +270,19 @@ 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`, userCollection);
|
||||
await this.pubsub.publish(
|
||||
`user_coll/${user.uid}/created`,
|
||||
this.cast(userCollection),
|
||||
);
|
||||
|
||||
return E.right(userCollection);
|
||||
return E.right(this.cast(userCollection));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +299,7 @@ export class UserCollectionService {
|
||||
take: number,
|
||||
type: ReqType,
|
||||
) {
|
||||
return this.prisma.userCollection.findMany({
|
||||
const res = await this.prisma.userCollection.findMany({
|
||||
where: {
|
||||
userUid: user.uid,
|
||||
parentID: null,
|
||||
@@ -289,6 +312,12 @@ export class UserCollectionService {
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
});
|
||||
|
||||
const userCollections = res.map((childCollection) =>
|
||||
this.cast(childCollection),
|
||||
);
|
||||
|
||||
return userCollections;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +336,7 @@ export class UserCollectionService {
|
||||
take: number,
|
||||
type: ReqType,
|
||||
) {
|
||||
return this.prisma.userCollection.findMany({
|
||||
const res = await this.prisma.userCollection.findMany({
|
||||
where: {
|
||||
userUid: user.uid,
|
||||
parentID: userCollectionID,
|
||||
@@ -317,9 +346,16 @@ 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
|
||||
@@ -351,10 +387,10 @@ export class UserCollectionService {
|
||||
|
||||
this.pubsub.publish(
|
||||
`user_coll/${updatedUserCollection.userUid}/updated`,
|
||||
updatedUserCollection,
|
||||
this.cast(updatedUserCollection),
|
||||
);
|
||||
|
||||
return E.right(updatedUserCollection);
|
||||
return E.right(this.cast(updatedUserCollection));
|
||||
} catch (error) {
|
||||
return E.left(USER_COLL_NOT_FOUND);
|
||||
}
|
||||
@@ -591,10 +627,10 @@ export class UserCollectionService {
|
||||
|
||||
this.pubsub.publish(
|
||||
`user_coll/${collection.right.userUid}/moved`,
|
||||
updatedCollection.right,
|
||||
this.cast(updatedCollection.right),
|
||||
);
|
||||
|
||||
return E.right(updatedCollection.right);
|
||||
return E.right(this.cast(updatedCollection.right));
|
||||
}
|
||||
|
||||
// destCollectionID != null i.e move into another collection
|
||||
@@ -642,10 +678,10 @@ export class UserCollectionService {
|
||||
|
||||
this.pubsub.publish(
|
||||
`user_coll/${collection.right.userUid}/moved`,
|
||||
updatedCollection.right,
|
||||
this.cast(updatedCollection.right),
|
||||
);
|
||||
|
||||
return E.right(updatedCollection.right);
|
||||
return E.right(this.cast(updatedCollection.right));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -846,6 +882,7 @@ 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);
|
||||
@@ -918,6 +955,7 @@ 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,
|
||||
});
|
||||
@@ -971,6 +1009,7 @@ export class UserCollectionService {
|
||||
this.generatePrismaQueryObj(f, userID, index + 1, reqType),
|
||||
),
|
||||
},
|
||||
data: folder.data ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1040,10 +1079,63 @@ export class UserCollectionService {
|
||||
),
|
||||
);
|
||||
|
||||
userCollections.forEach((x) =>
|
||||
this.pubsub.publish(`user_coll/${userID}/created`, x),
|
||||
userCollections.forEach((collection) =>
|
||||
this.pubsub.publish(`user_coll/${userID}/created`, this.cast(collection)),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ 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',
|
||||
})
|
||||
|
||||
@@ -56,3 +56,22 @@ export enum SessionType {
|
||||
registerEnumType(SessionType, {
|
||||
name: 'SessionType',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class UserDeletionResult {
|
||||
@Field(() => ID, {
|
||||
description: 'UID of the user',
|
||||
})
|
||||
userUID: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Flag to determine if user deletion was successful or not',
|
||||
})
|
||||
isDeleted: Boolean;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Error message if user deletion was not successful',
|
||||
})
|
||||
errorMessage: String;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JSON_INVALID, USER_NOT_FOUND } from 'src/errors';
|
||||
import { JSON_INVALID, USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
@@ -176,6 +176,26 @@ describe('UserService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUsersByIds', () => {
|
||||
test('should successfully return users given valid user UIDs', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
|
||||
const result = await userService.findUsersByIds([
|
||||
'123344',
|
||||
'5555',
|
||||
'6666',
|
||||
]);
|
||||
expect(result).toEqual(users);
|
||||
});
|
||||
|
||||
test('should return empty array of users given a invalid user UIDs', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await userService.findUsersByIds(['sdcvbdbr']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserViaMagicLink', () => {
|
||||
test('should successfully create user and account for magic-link given valid inputs', async () => {
|
||||
mockPrisma.user.create.mockResolvedValueOnce(user);
|
||||
@@ -414,6 +434,54 @@ describe('UserService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserDisplayName', () => {
|
||||
test('should resolve right and update user display name', async () => {
|
||||
const newDisplayName = 'New Name';
|
||||
mockPrisma.user.update.mockResolvedValueOnce({
|
||||
...user,
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
|
||||
const result = await userService.updateUserDisplayName(
|
||||
user.uid,
|
||||
newDisplayName,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
...user,
|
||||
displayName: newDisplayName,
|
||||
currentGQLSession: JSON.stringify(user.currentGQLSession),
|
||||
currentRESTSession: JSON.stringify(user.currentRESTSession),
|
||||
});
|
||||
});
|
||||
test('should resolve right and publish user updated subscription', async () => {
|
||||
const newDisplayName = 'New Name';
|
||||
mockPrisma.user.update.mockResolvedValueOnce({
|
||||
...user,
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
|
||||
await userService.updateUserDisplayName(user.uid, user.displayName);
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`user/${user.uid}/updated`,
|
||||
{
|
||||
...user,
|
||||
displayName: newDisplayName,
|
||||
currentGQLSession: JSON.stringify(user.currentGQLSession),
|
||||
currentRESTSession: JSON.stringify(user.currentRESTSession),
|
||||
},
|
||||
);
|
||||
});
|
||||
test('should resolve left and error when invalid user uid is passed', async () => {
|
||||
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
|
||||
|
||||
const result = await userService.updateUserDisplayName(
|
||||
'invalidUserUid',
|
||||
user.displayName,
|
||||
);
|
||||
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllUsers', () => {
|
||||
test('should resolve right and return 20 users when cursor is null', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
@@ -435,6 +503,36 @@ describe('UserService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllUsersV2', () => {
|
||||
test('should resolve right and return first 20 users when searchString is null', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
|
||||
const result = await userService.fetchAllUsersV2(null, {
|
||||
take: 20,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result).toEqual(users);
|
||||
});
|
||||
test('should resolve right and return next 20 users when searchString is provided', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
|
||||
const result = await userService.fetchAllUsersV2('.com', {
|
||||
take: 20,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result).toEqual(users);
|
||||
});
|
||||
test('should resolve left and return an empty array when users not found', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await userService.fetchAllUsersV2('Unknown entry', {
|
||||
take: 20,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAdminUsers', () => {
|
||||
test('should return a list of admin users', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers);
|
||||
@@ -556,4 +654,17 @@ describe('UserService', () => {
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUsersAsAdmin', () => {
|
||||
test('should resolve right and return true for valid user UIDs', async () => {
|
||||
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 1 });
|
||||
const result = await userService.removeUsersAsAdmin(['123344']);
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
test('should resolve right and return false for invalid user UIDs', async () => {
|
||||
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
const result = await userService.removeUsersAsAdmin(['123344']);
|
||||
expect(result).toEqualLeft(USERS_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user