├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── actions │ ├── cache-app │ │ └── action.yml │ └── setup-docker │ │ └── action.yml │ └── build-test.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── apps └── api │ ├── .dockerignore │ ├── .env.ci │ ├── .env.example │ ├── .eslintrc.json │ ├── Dockerfile │ ├── docker-compose.app.yml │ ├── docker-compose.yml │ ├── jest.config.js │ ├── jest.integration.config.js │ ├── prisma │ ├── migrations │ │ ├── 20210218222150_ulid_generate │ │ │ └── migration.sql │ │ ├── 20210218222151_updated_at_func │ │ │ └── migration.sql │ │ ├── 20210826015831_users │ │ │ └── migration.sql │ │ ├── 20210909234239_shows │ │ │ └── migration.sql │ │ ├── 20210912233529_role_grants │ │ │ └── migration.sql │ │ ├── 20210913221618_episodes │ │ │ └── migration.sql │ │ ├── 20210913222122_episode_show_id │ │ │ └── migration.sql │ │ ├── 20210922045017_after_these_messages │ │ │ └── migration.sql │ │ ├── 20210923192719_role_triggers │ │ │ └── migration.sql │ │ ├── 20211020152538_migrate_indexes │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── project.json │ ├── scripts │ └── codegen-schema.ts │ ├── src │ ├── app.controller.ts │ ├── app.module.ts │ ├── assets │ │ └── .gitkeep │ └── main.ts │ ├── test │ ├── episodes.integration.ts │ ├── events.integration.ts │ ├── profiles.integration.ts │ ├── shows.integration.ts │ └── users.integration.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.test.json ├── babel.config.json ├── codegen.yaml ├── jest.config.js ├── jest.preset.js ├── jest.setup.ts ├── libs ├── authn │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── authn.module.ts │ │ ├── authn.types.ts │ │ ├── authn.utils.ts │ │ ├── jwt.decorators.ts │ │ ├── jwt.guard.ts │ │ ├── jwt.strategy.ts │ │ └── socket-jwt.guard.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── authz │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── __tests__ │ │ │ ├── ability.factory.test.ts │ │ │ └── authz.guard.test.ts │ │ ├── ability.factory.ts │ │ ├── authz-test.module.ts │ │ ├── authz.decorators.ts │ │ ├── authz.guard.ts │ │ ├── authz.module.ts │ │ ├── authz.types.ts │ │ ├── authz.utils.ts │ │ └── socket-authz.guard.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── events │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── __tests__ │ │ │ ├── channel.service.test.ts │ │ │ └── event.gateway.test.ts │ │ ├── channel.service.ts │ │ ├── event.types.ts │ │ ├── events.gateway.ts │ │ └── events.module.ts │ ├── test │ │ └── factories │ │ │ └── events.factory.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── graphql │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ └── schema.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── roles │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── roles.module.ts │ │ ├── roles.service.ts │ │ └── roles.types.ts │ ├── test │ │ └── factories │ │ │ └── role-grant.factory.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── shows │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── __tests__ │ │ │ ├── show.rules.test.ts │ │ │ ├── shows.resolver.test.ts │ │ │ └── shows.service.test.ts │ │ ├── episodes │ │ │ ├── __tests__ │ │ │ │ ├── episode.rules.test.ts │ │ │ │ ├── episodes.resolver.test.ts │ │ │ │ └── episodes.service.test.ts │ │ │ ├── episode-mutations.model.ts │ │ │ ├── episode-queries.model.ts │ │ │ ├── episode.model.ts │ │ │ ├── episode.roles.ts │ │ │ ├── episode.rules.ts │ │ │ ├── episodes.module.ts │ │ │ ├── episodes.resolver.ts │ │ │ └── episodes.service.ts │ │ ├── show-mutations.model.ts │ │ ├── show-queries.model.ts │ │ ├── show.model.ts │ │ ├── show.roles.ts │ │ ├── show.rules.ts │ │ ├── show.utils.ts │ │ ├── shows.module.ts │ │ ├── shows.resolver.ts │ │ └── shows.service.ts │ ├── test │ │ └── factories │ │ │ ├── episodes.factory.ts │ │ │ └── show.factory.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── users │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ │ ├── __tests__ │ │ │ ├── user.rules.test.ts │ │ │ ├── users.resolver.test.ts │ │ │ └── users.service.test.ts │ │ ├── profiles │ │ │ ├── __tests__ │ │ │ │ ├── profiles.resolver.test.ts │ │ │ │ └── profiles.service.test.ts │ │ │ ├── profile-mutations.model.ts │ │ │ ├── profile-queries.model.ts │ │ │ ├── profile.model.ts │ │ │ ├── profile.rules.ts │ │ │ ├── profile.utils.ts │ │ │ ├── profiles.module.ts │ │ │ ├── profiles.resolver.ts │ │ │ └── profiles.service.ts │ │ ├── user-mutations.model.ts │ │ ├── user.decorators.ts │ │ ├── user.model.ts │ │ ├── user.rules.ts │ │ ├── user.types.ts │ │ ├── users.module.ts │ │ ├── users.resolver.ts │ │ └── users.service.ts │ ├── test │ │ └── factories │ │ │ ├── profile.factory.ts │ │ │ └── user.factory.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json └── utils │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── project.json │ ├── src │ ├── __tests__ │ │ └── pagination.test.ts │ ├── config │ │ ├── config.default.ts │ │ ├── config.module.ts │ │ └── config.types.ts │ ├── health │ │ ├── health.controller.ts │ │ └── health.module.ts │ ├── injection.ts │ ├── pagination.ts │ ├── prisma.ts │ └── types.ts │ ├── test │ ├── events.ts │ ├── graphql.ts │ └── oauth2.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.test.json ├── nx.json ├── package.json ├── tsconfig.base.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Start by ignoring everything 2 | * 3 | 4 | # Package management 5 | !package.json 6 | !yarn.lock 7 | !package-lock.json 8 | !.yarnrc 9 | !.npmrc 10 | 11 | # dist files 12 | !dist 13 | !node_modules/.prisma/client 14 | node_modules/.prisma/client/query-engine-* 15 | !node_modules/.prisma/client/query-engine-linux-musl-* 16 | 17 | # Configs 18 | !.eslintrc.js 19 | !.eslintignore 20 | !.prettierignore 21 | !.prettierrc 22 | !nest-cli.json 23 | !tsconfig.base.json 24 | !workspace.json 25 | !nx.json 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx", "@typescript-eslint", "prettier", "filenames"], 5 | "parser": "@typescript-eslint/parser", 6 | "env": { 7 | "node": true, 8 | "es6": true, 9 | "jest": true 10 | }, 11 | "overrides": [ 12 | { 13 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 14 | "rules": { 15 | "@nrwl/nx/enforce-module-boundaries": [ 16 | "error", 17 | { 18 | "enforceBuildableLibDependency": true, 19 | "allow": [], 20 | "depConstraints": [ 21 | { 22 | "sourceTag": "*", 23 | "onlyDependOnLibsWithTags": ["*"] 24 | } 25 | ] 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.ts", "*.tsx"], 32 | "extends": [ 33 | "plugin:@nrwl/nx/typescript", 34 | "plugin:@typescript-eslint/recommended", 35 | "plugin:prettier/recommended" 36 | ], 37 | "parserOptions": { 38 | "project": "./tsconfig.*?.json" 39 | }, 40 | "rules": { 41 | "@typescript-eslint/camelcase": ["off"], 42 | "@typescript-eslint/explicit-module-boundary-types": ["off"], 43 | "@typescript-eslint/no-unused-vars": [ 44 | "error", 45 | {"varsIgnorePattern": "^_", "argsIgnorePattern": "^_"} 46 | ], 47 | "@typescript-eslint/no-namespace": ["off"], 48 | "filenames/no-index": ["error"] 49 | } 50 | }, 51 | { 52 | "files": ["*.js", "*.jsx"], 53 | "extends": [ 54 | "plugin:@nrwl/nx/javascript", 55 | "plugin:@typescript-eslint/recommended", 56 | "plugin:prettier/recommended" 57 | ], 58 | "rules": {} 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/actions/cache-app/action.yml: -------------------------------------------------------------------------------- 1 | name: Cache App 2 | description: 3 | Cache the Node.js npm node_modules directory and the dist directory based on a hash of 4 | the yarn.lock. 5 | 6 | runs: 7 | using: composite 8 | steps: 9 | - name: Cache node_modules 10 | uses: actions/cache@v2 11 | with: 12 | path: node_modules 13 | key: node-cache-${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} 14 | restore-keys: | 15 | node-cache-${{ runner.os }}-modules- 16 | 17 | - name: Cache dist files 18 | uses: actions/cache@v2 19 | with: 20 | path: dist 21 | key: cache-dist-${{ hashFiles('yarn.lock') }}-${{ github.sha }} 22 | restore-keys: | 23 | cache-dist-${{ hashFiles('yarn.lock') }}- 24 | -------------------------------------------------------------------------------- /.github/workflows/actions/setup-docker/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Docker 2 | description: Setup Docker buildx and cache the Docker container based on the Github sha hash. 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Cache Docker layers 8 | uses: actions/cache@v2 9 | with: 10 | path: /tmp/.buildx-cache 11 | key: ${{ runner.os }}-buildx-${{ github.sha }} 12 | restore-keys: | 13 | ${{ runner.os }}-buildx- 14 | 15 | - name: Set up Docker Buildx 16 | id: buildx 17 | uses: docker/setup-buildx-action@v1 18 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: build-test 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '**.md' 7 | push: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - '**.md' 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | ref: ${{ github.event.pull_request.head.ref }} 21 | fetch-depth: 0 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: 14.x 27 | cache: yarn 28 | 29 | - uses: ./.github/workflows/actions/cache-app 30 | 31 | - run: yarn --frozen-lockfile --ignore-scripts --prefer-offline 32 | - run: git fetch --no-tags --prune --depth=5 origin main 33 | - run: yarn nx codegen api # Generate Prisma first 34 | - run: yarn nx run-many --target codegen --all 35 | - run: yarn nx affected --target test --base origin/main --parallel 36 | - run: yarn nx affected --target lint --base origin/main --parallel 37 | - run: yarn nx run-many --target build --all --prod --parallel 38 | 39 | test: 40 | runs-on: ubuntu-latest 41 | needs: build 42 | steps: 43 | - uses: actions/checkout@v2 44 | 45 | - name: Setup Node.js 46 | uses: actions/setup-node@v2 47 | with: 48 | node-version: 14.x 49 | cache: yarn 50 | 51 | - uses: ./.github/workflows/actions/cache-app 52 | - uses: ./.github/workflows/actions/setup-docker 53 | 54 | - name: Run docker-compose 55 | run: yarn docker:api --env-file .env.ci up -d 56 | 57 | - run: yarn db:deploy 58 | - run: yarn integration:ci 59 | env: 60 | OAUTH2_CLIENT_ID: ${{ secrets.OAUTH2_CLIENT_ID }} 61 | OAUTH2_CLIENT_SECRET: ${{ secrets.OAUTH2_CLIENT_SECRET }} 62 | TEST_USER: ${{ secrets.TEST_USER }} 63 | TEST_PASS: ${{ secrets.TEST_PASS }} 64 | TEST_ALT_USER: ${{ secrets.TEST_ALT_USER }} 65 | TEST_ALT_PASS: ${{ secrets.TEST_ALT_PASS }} 66 | 67 | package: 68 | runs-on: ubuntu-latest 69 | needs: test 70 | steps: 71 | - uses: actions/checkout@v2 72 | - uses: ./.github/workflows/actions/cache-app 73 | - uses: ./.github/workflows/actions/setup-docker 74 | 75 | - name: Docker build 76 | id: docker_build 77 | uses: docker/build-push-action@v2 78 | with: 79 | context: ./ 80 | file: ./apps/api/Dockerfile 81 | push: false 82 | tags: caster-api:latest 83 | 84 | - name: Image digest 85 | run: echo ${{ steps.docker_build.outputs.digest }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /out-tsc 6 | 7 | # dependencies 8 | node_modules 9 | 10 | # IDEs and editors 11 | /.idea 12 | .project 13 | .classpath 14 | .c9/ 15 | *.launch 16 | .settings/ 17 | *.sublime-workspace 18 | 19 | # IDE - VSCode 20 | .vscode/* 21 | !.vscode/settings.json 22 | !.vscode/tasks.json 23 | !.vscode/launch.json 24 | !.vscode/extensions.json 25 | 26 | # misc 27 | /connect.lock 28 | /coverage 29 | /.nyc_output 30 | /libpeerconnection.log 31 | npm-debug.log 32 | yarn-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # dotenv environment variable files 42 | .env* 43 | !.env.example 44 | !.env.ci 45 | 46 | # terraform files 47 | .terraform 48 | .terraform.lock.hcl 49 | terraform.tfstate 50 | terraform.tfstate.backup 51 | 52 | # GraphQL Codegen Files 53 | schema.graphql 54 | schema.json 55 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "pre-push": "yarn ts:check" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": [ 3 | "yarn format:write" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "bracketSpacing": false 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.DS_Store": true, 4 | "**/.git": true 5 | }, 6 | "editor.formatOnPaste": true, 7 | "editor.formatOnSave": true, 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "terraform-ls.rootModules": ["terraform"] 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Brandon Konkle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/api/.dockerignore: -------------------------------------------------------------------------------- 1 | # Start by ignoring everything 2 | * 3 | 4 | # Package management 5 | !package.json 6 | !yarn.lock 7 | !package-lock.json 8 | !.yarnrc 9 | !.npmrc 10 | 11 | # source files 12 | !src/ 13 | 14 | # Configs 15 | !.eslintrc.js 16 | !.eslintignore 17 | !.prettierignore 18 | !tsconfig.app.json 19 | !tsconfig.json 20 | -------------------------------------------------------------------------------- /apps/api/.env.ci: -------------------------------------------------------------------------------- 1 | DATABASE_HOSTNAME=localhost 2 | DATABASE_USERNAME=caster 3 | DATABASE_PASSWORD=caster 4 | DATABASE_NAME=caster_test 5 | DATABASE_PORT=1701 6 | DATABASE_URL=postgresql://caster:caster@localhost:1701/caster_test 7 | 8 | REDIS_URL=localhost:6379 9 | 10 | OAUTH2_URL=https://caster-api-dev.us.auth0.com 11 | OAUTH2_AUDIENCE=localhost 12 | -------------------------------------------------------------------------------- /apps/api/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_HOSTNAME=localhost 2 | DATABASE_USERNAME=caster 3 | DATABASE_PASSWORD=caster 4 | DATABASE_NAME=caster 5 | DATABASE_PORT=1701 6 | DATABASE_URL=postgresql://caster:caster@localhost:1701/caster 7 | 8 | OAUTH2_URL=https://your-domain.us.auth0.com 9 | OAUTH2_AUDIENCE=localhost 10 | OAUTH2_CLIENT_ID=yourclientidgoeshere 11 | OAUTH2_CLIENT_SECRET=yourclientsecretgoeshere 12 | 13 | TEST_USER=test-user@email.com 14 | TEST_PASS=testpassword 15 | TEST_ALT_USER=test-alt-user@email.com 16 | TEST_ALT_PASS=testaltpassword 17 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | ARG DUMB_INIT=1.2.5 3 | EXPOSE 4000 4 | EXPOSE 9229 5 | 6 | ARG NODE_ENV=production 7 | ENV NODE_ENV=${NODE_ENV} 8 | 9 | WORKDIR /usr/src/app 10 | 11 | COPY yarn.lock . 12 | COPY ./dist/apps/api . 13 | COPY ./node_modules/.prisma/client ./node_modules/.prisma/client 14 | 15 | RUN chown node:node /usr/src/app && \ 16 | yarn config set registry https://registry.npmjs.org && \ 17 | # Install the dependencies in the generated package.json file 18 | yarn --frozen-lockfile --ignore-scripts --prefer-offline && \ 19 | # Dependencies Nest.js needs that aren’t explicitly used 20 | yarn add reflect-metadata tslib rxjs @nestjs/platform-express json5 21 | 22 | ADD https://github.com/Yelp/dumb-init/releases/download/v${DUMB_INIT}/dumb-init_${DUMB_INIT}_x86_64 /usr/local/bin/dumb-init 23 | 24 | RUN chmod +x /usr/local/bin/dumb-init 25 | 26 | USER node 27 | 28 | ENTRYPOINT ["dumb-init", "--"] 29 | 30 | CMD ["sh", "-c", "node ./main.js"] 31 | -------------------------------------------------------------------------------- /apps/api/docker-compose.app.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | api: 5 | image: caster-api 6 | container_name: caster_api 7 | depends_on: 8 | - postgres 9 | - redis 10 | ports: 11 | - 4000:4000 12 | environment: 13 | DATABASE_URL: postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@postgres:5432/${DATABASE_NAME} 14 | REDIS_URL: redis:6379 15 | -------------------------------------------------------------------------------- /apps/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | container_name: caster_postgres 7 | restart: always 8 | volumes: 9 | - postgres_data:/var/lib/postgresql/data 10 | ports: 11 | - ${DATABASE_PORT}:5432 12 | environment: 13 | POSTGRES_USER: ${DATABASE_USERNAME} 14 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 15 | POSTGRES_DB: ${DATABASE_NAME} 16 | redis: 17 | image: redis 18 | container_name: caster_redis 19 | ports: 20 | - 6379:6379 21 | 22 | volumes: 23 | postgres_data: 24 | -------------------------------------------------------------------------------- /apps/api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'api', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]s$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'js', 'html'], 14 | coverageDirectory: '../../coverage/apps/api', 15 | setupFilesAfterEnv: ['../../jest.setup.ts'], 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/jest.integration.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const baseConfig = require('./jest.config') 3 | 4 | module.exports = { 5 | ...baseConfig, 6 | testMatch: ['**/+(*.)+(integration).[tj]s?(x)'], 7 | } 8 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210218222150_ulid_generate/migration.sql: -------------------------------------------------------------------------------- 1 | -- https://github.com/geckoboard/pgulid 2 | -- pgulid is based on OK Log's Go implementation of the ULID spec 3 | -- 4 | -- https://github.com/oklog/ulid 5 | -- https://github.com/ulid/spec 6 | -- 7 | -- Copyright 2016 The Oklog Authors 8 | -- Licensed under the Apache License, Version 2.0 (the "License"); 9 | -- you may not use this file except in compliance with the License. 10 | -- You may obtain a copy of the License at 11 | -- 12 | -- http://www.apache.org/licenses/LICENSE-2.0 13 | -- 14 | -- Unless required by applicable law or agreed to in writing, software 15 | -- distributed under the License is distributed on an "AS IS" BASIS, 16 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | -- See the License for the specific language governing permissions and 18 | -- limitations under the License. 19 | 20 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 21 | 22 | DROP FUNCTION IF EXISTS ulid_generate; 23 | 24 | CREATE FUNCTION ulid_generate() RETURNS TEXT AS $$ 25 | DECLARE 26 | -- Crockford's Base32 27 | encoding BYTEA = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; 28 | timestamp BYTEA = E'\\000\\000\\000\\000\\000\\000'; 29 | output TEXT = ''; 30 | 31 | unix_time BIGINT; 32 | ulid BYTEA; 33 | BEGIN 34 | -- 6 timestamp bytes 35 | unix_time = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT; 36 | timestamp = SET_BYTE(timestamp, 0, (unix_time >> 40)::BIT(8)::INTEGER); 37 | timestamp = SET_BYTE(timestamp, 1, (unix_time >> 32)::BIT(8)::INTEGER); 38 | timestamp = SET_BYTE(timestamp, 2, (unix_time >> 24)::BIT(8)::INTEGER); 39 | timestamp = SET_BYTE(timestamp, 3, (unix_time >> 16)::BIT(8)::INTEGER); 40 | timestamp = SET_BYTE(timestamp, 4, (unix_time >> 8)::BIT(8)::INTEGER); 41 | timestamp = SET_BYTE(timestamp, 5, unix_time::BIT(8)::INTEGER); 42 | 43 | -- 10 entropy bytes 44 | ulid = timestamp || gen_random_bytes(10); 45 | 46 | -- Encode the timestamp 47 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 0) & 224) >> 5)); 48 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 0) & 31))); 49 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 1) & 248) >> 3)); 50 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 1) & 7) << 2) | ((GET_BYTE(ulid, 2) & 192) >> 6))); 51 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 2) & 62) >> 1)); 52 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 2) & 1) << 4) | ((GET_BYTE(ulid, 3) & 240) >> 4))); 53 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 3) & 15) << 1) | ((GET_BYTE(ulid, 4) & 128) >> 7))); 54 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 4) & 124) >> 2)); 55 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 4) & 3) << 3) | ((GET_BYTE(ulid, 5) & 224) >> 5))); 56 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 5) & 31))); 57 | 58 | -- Encode the entropy 59 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 6) & 248) >> 3)); 60 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 6) & 7) << 2) | ((GET_BYTE(ulid, 7) & 192) >> 6))); 61 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 7) & 62) >> 1)); 62 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 7) & 1) << 4) | ((GET_BYTE(ulid, 8) & 240) >> 4))); 63 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 8) & 15) << 1) | ((GET_BYTE(ulid, 9) & 128) >> 7))); 64 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 9) & 124) >> 2)); 65 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 9) & 3) << 3) | ((GET_BYTE(ulid, 10) & 224) >> 5))); 66 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 10) & 31))); 67 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 11) & 248) >> 3)); 68 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 11) & 7) << 2) | ((GET_BYTE(ulid, 12) & 192) >> 6))); 69 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 12) & 62) >> 1)); 70 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 12) & 1) << 4) | ((GET_BYTE(ulid, 13) & 240) >> 4))); 71 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 13) & 15) << 1) | ((GET_BYTE(ulid, 14) & 128) >> 7))); 72 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 14) & 124) >> 2)); 73 | output = output || CHR(GET_BYTE(encoding, ((GET_BYTE(ulid, 14) & 3) << 3) | ((GET_BYTE(ulid, 15) & 224) >> 5))); 74 | output = output || CHR(GET_BYTE(encoding, (GET_BYTE(ulid, 15) & 31))); 75 | 76 | RETURN output; 77 | END 78 | $$ LANGUAGE plpgsql VOLATILE; 79 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210218222151_updated_at_func/migration.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION sync_updated_at() 2 | RETURNS TRIGGER AS $$ 3 | BEGIN 4 | NEW."updatedAt" = CURRENT_TIMESTAMP; 5 | RETURN NEW; 6 | END; 7 | $$ language 'plpgsql'; 8 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210826015831_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "username" TEXT NOT NULL, 7 | "isActive" BOOLEAN NOT NULL DEFAULT true, 8 | 9 | PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Profile" ( 14 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 15 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "email" TEXT NOT NULL, 18 | "displayName" TEXT, 19 | "picture" TEXT, 20 | "content" JSONB, 21 | "city" TEXT, 22 | "stateProvince" TEXT, 23 | "userId" TEXT, 24 | 25 | PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateIndex 29 | CREATE UNIQUE INDEX "User.username_unique" ON "User"("username"); 30 | 31 | -- CreateIndex 32 | CREATE UNIQUE INDEX "Profile.userId_unique" ON "Profile"("userId"); 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "Profile" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 36 | 37 | CREATE TRIGGER sync_user_updated_at BEFORE UPDATE ON "User" FOR EACH ROW EXECUTE PROCEDURE sync_updated_at(); 38 | 39 | CREATE TRIGGER sync_profile_updated_at BEFORE UPDATE ON "Profile" FOR EACH ROW EXECUTE PROCEDURE sync_updated_at(); 40 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210909234239_shows/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Show" ( 3 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 4 | "title" TEXT NOT NULL, 5 | "summary" TEXT, 6 | "picture" TEXT, 7 | "content" JSONB, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | 11 | PRIMARY KEY ("id") 12 | ); 13 | 14 | CREATE TRIGGER sync_show_updated_at BEFORE UPDATE ON "Show" FOR EACH ROW EXECUTE PROCEDURE sync_updated_at(); 15 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210912233529_role_grants/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "RoleGrant" ( 3 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "roleKey" TEXT NOT NULL, 7 | "profileId" TEXT NOT NULL, 8 | "subjectTable" TEXT NOT NULL, 9 | "subjectId" TEXT NOT NULL, 10 | 11 | PRIMARY KEY ("id") 12 | ); 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "RoleGrant" ADD FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE; 16 | 17 | CREATE TRIGGER sync_role_grant_updated_at BEFORE UPDATE ON "RoleGrant" FOR EACH ROW EXECUTE PROCEDURE sync_updated_at(); 18 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210913221618_episodes/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Episode" ( 3 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "title" TEXT NOT NULL, 7 | "summary" TEXT, 8 | "picture" TEXT, 9 | "content" JSONB, 10 | 11 | PRIMARY KEY ("id") 12 | ); 13 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210913222122_episode_show_id/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `showId` to the `Episode` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Episode" ADD COLUMN "showId" TEXT NOT NULL; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "Episode" ADD FOREIGN KEY ("showId") REFERENCES "Show"("id") ON DELETE CASCADE ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210922045017_after_these_messages/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Message" ( 3 | "id" TEXT NOT NULL DEFAULT ulid_generate(), 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "text" TEXT NOT NULL, 7 | "profileId" TEXT NOT NULL, 8 | "episodeId" TEXT NOT NULL, 9 | 10 | PRIMARY KEY ("id") 11 | ); 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "Message" ADD FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "Message" ADD FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE CASCADE ON UPDATE CASCADE; 18 | 19 | CREATE TRIGGER sync_message_updated_at BEFORE UPDATE ON "Message" FOR EACH ROW EXECUTE PROCEDURE sync_updated_at(); 20 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20210923192719_role_triggers/migration.sql: -------------------------------------------------------------------------------- 1 | -- Create a trigger that validates the subjectTable based on information_schema 2 | CREATE OR REPLACE FUNCTION check_role_grant() RETURNS trigger as $$ 3 | BEGIN 4 | IF EXISTS ( 5 | SELECT 1 6 | FROM information_schema.tables 7 | WHERE table_schema='public' 8 | AND table_type='BASE TABLE' 9 | AND table_name=NEW."subjectTable" 10 | ) THEN 11 | RETURN NEW; 12 | END IF; 13 | 14 | RAISE EXCEPTION 'subjectTable must match an existing table_name'; 15 | END; 16 | $$ LANGUAGE plpgsql; 17 | 18 | CREATE TRIGGER on_create_role_grant 19 | BEFORE INSERT OR UPDATE ON "RoleGrant" 20 | FOR EACH ROW EXECUTE PROCEDURE check_role_grant(); 21 | 22 | -- Profiles 23 | 24 | -- Delete any RoleGrants that match the Profile id 25 | CREATE OR REPLACE FUNCTION on_delete_profile() RETURNS trigger AS $$ 26 | BEGIN 27 | DELETE FROM "RoleGrant" WHERE "profileId" = OLD.id; 28 | 29 | RETURN OLD; 30 | END; 31 | $$ LANGUAGE plpgsql; 32 | 33 | -- Whenever a Profile is deleted, remove all the associated RoleGrants as well 34 | CREATE TRIGGER on_delete_profile 35 | BEFORE DELETE ON "Profile" 36 | FOR EACH ROW EXECUTE PROCEDURE on_delete_profile(); 37 | 38 | -- Shows 39 | 40 | -- Delete any RoleGrants that match the Show id 41 | CREATE OR REPLACE FUNCTION on_delete_show() RETURNS trigger AS $$ 42 | BEGIN 43 | DELETE FROM "RoleGrant" 44 | WHERE "subjectId" = OLD.id 45 | AND "subjectTable" = 'Show'; 46 | 47 | RETURN OLD; 48 | END; 49 | $$ LANGUAGE plpgsql; 50 | 51 | -- Whenever a Show is deleted, remove all the associated RoleGrants as well 52 | CREATE TRIGGER on_delete_show 53 | BEFORE DELETE ON "Show" 54 | FOR EACH ROW EXECUTE PROCEDURE on_delete_show(); 55 | 56 | -- Episodes 57 | 58 | -- Delete any RoleGrants that match the Episode id 59 | CREATE OR REPLACE FUNCTION on_delete_episode() RETURNS trigger AS $$ 60 | BEGIN 61 | DELETE FROM "RoleGrant" 62 | WHERE "subjectId" = OLD.id 63 | AND "subjectTable" = 'Episode'; 64 | 65 | RETURN OLD; 66 | END; 67 | $$ LANGUAGE plpgsql; 68 | 69 | -- Whenever an Episode is deleted, remove all the associated RoleGrants as well 70 | CREATE TRIGGER on_delete_episode 71 | BEFORE DELETE ON "Episode" 72 | FOR EACH ROW EXECUTE PROCEDURE on_delete_episode(); 73 | 74 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20211020152538_migrate_indexes/migration.sql: -------------------------------------------------------------------------------- 1 | -- RenameIndex 2 | ALTER INDEX "Profile.userId_unique" RENAME TO "Profile_userId_key"; 3 | 4 | -- RenameIndex 5 | ALTER INDEX "User.username_unique" RENAME TO "User_username_key"; 6 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /apps/api/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | binaryTargets = ["native", "linux-musl"] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL") 9 | } 10 | 11 | model User { 12 | id String @id @default(dbgenerated("ulid_generate()")) 13 | createdAt DateTime @default(now()) 14 | updatedAt DateTime @updatedAt 15 | username String @unique 16 | isActive Boolean @default(true) 17 | profile Profile? 18 | } 19 | 20 | model Profile { 21 | id String @id @default(dbgenerated("ulid_generate()")) 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | email String 25 | displayName String? 26 | picture String? 27 | content Json? 28 | city String? 29 | stateProvince String? 30 | user User? @relation(fields: [userId], references: [id]) 31 | userId String? @unique 32 | roleGrants RoleGrant[] 33 | messages Message[] 34 | } 35 | 36 | model RoleGrant { 37 | id String @id @default(dbgenerated("ulid_generate()")) 38 | createdAt DateTime @default(now()) 39 | updatedAt DateTime @updatedAt 40 | roleKey String 41 | profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 42 | profileId String 43 | subjectTable String 44 | subjectId String 45 | } 46 | 47 | model Show { 48 | id String @id @default(dbgenerated("ulid_generate()")) 49 | createdAt DateTime @default(now()) 50 | updatedAt DateTime @updatedAt 51 | title String 52 | summary String? 53 | picture String? 54 | content Json? 55 | episodes Episode[] 56 | } 57 | 58 | model Episode { 59 | id String @id @default(dbgenerated("ulid_generate()")) 60 | createdAt DateTime @default(now()) 61 | updatedAt DateTime @updatedAt 62 | show Show @relation(fields: [showId], references: [id], onDelete: Cascade) 63 | showId String 64 | title String 65 | summary String? 66 | picture String? 67 | content Json? 68 | messages Message[] 69 | } 70 | 71 | model Message { 72 | id String @id @default(dbgenerated("ulid_generate()")) 73 | createdAt DateTime @default(now()) 74 | updatedAt DateTime @updatedAt 75 | text String 76 | profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 77 | profileId String 78 | episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade) 79 | episodeId String 80 | } 81 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/api", 3 | "sourceRoot": "apps/api/src", 4 | "projectType": "application", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:build", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/apps/api", 11 | "main": "apps/api/src/main.ts", 12 | "tsConfig": "apps/api/tsconfig.app.json", 13 | "assets": ["apps/api/src/assets"], 14 | "generatePackageJson": true 15 | }, 16 | "configurations": { 17 | "production": { 18 | "optimization": true, 19 | "extractLicenses": true, 20 | "inspect": false 21 | } 22 | } 23 | }, 24 | "package": { 25 | "builder": "@nrwl/workspace:run-commands", 26 | "options": { 27 | "commands": [ 28 | "nx run-many --target codegen --all --parallel", 29 | "nx build api", 30 | "docker build -f ./apps/api/Dockerfile . -t caster-api" 31 | ], 32 | "parallel": false 33 | } 34 | }, 35 | "serve": { 36 | "executor": "@nrwl/node:execute", 37 | "options": { 38 | "buildTarget": "api:build" 39 | } 40 | }, 41 | "lint": { 42 | "executor": "@nrwl/linter:eslint", 43 | "options": { 44 | "lintFilePatterns": ["apps/api/**/*.ts"] 45 | } 46 | }, 47 | "codegen": { 48 | "executor": "@nrwl/workspace:run-commands", 49 | "options": { 50 | "cwd": "apps/api", 51 | "command": "npx prisma generate --schema prisma/schema.prisma" 52 | } 53 | }, 54 | "test": { 55 | "executor": "@nrwl/jest:jest", 56 | "outputs": ["coverage/apps/api"], 57 | "options": { 58 | "jestConfig": "apps/api/jest.config.js", 59 | "passWithNoTests": true 60 | } 61 | }, 62 | "integration": { 63 | "executor": "@nrwl/jest:jest", 64 | "outputs": ["coverage/apps/api-int"], 65 | "options": { 66 | "jestConfig": "apps/api/jest.integration.config.js", 67 | "runInBand": true 68 | } 69 | }, 70 | "db-migrate": { 71 | "executor": "@nrwl/workspace:run-commands", 72 | "options": { 73 | "cwd": "apps/api", 74 | "command": "npx prisma migrate dev --preview-feature" 75 | } 76 | }, 77 | "db-deploy": { 78 | "executor": "@nrwl/workspace:run-commands", 79 | "options": { 80 | "cwd": "apps/api", 81 | "command": "npx prisma migrate deploy --preview-feature" 82 | } 83 | }, 84 | "db-reset": { 85 | "executor": "@nrwl/workspace:run-commands", 86 | "options": { 87 | "cwd": "apps/api", 88 | "command": "npx prisma migrate reset --preview-feature --force" 89 | } 90 | }, 91 | "docker-up": { 92 | "executor": "@nrwl/workspace:run-commands", 93 | "options": { 94 | "cwd": "apps/api", 95 | "command": "yarn docker:api up -d" 96 | } 97 | }, 98 | "docker-down": { 99 | "executor": "@nrwl/workspace:run-commands", 100 | "options": { 101 | "cwd": "apps/api", 102 | "command": "yarn docker:api down" 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /apps/api/scripts/codegen-schema.ts: -------------------------------------------------------------------------------- 1 | import {NestFactory} from '@nestjs/core' 2 | import {GraphQLSchemaBuilderModule, GraphQLSchemaFactory} from '@nestjs/graphql' 3 | import {printSchema} from 'graphql' 4 | import {writeFile} from 'fs/promises' 5 | 6 | import {EpisodesResolver} from '@caster/shows/episodes/episodes.resolver' 7 | import {ShowsResolver} from '@caster/shows/shows.resolver' 8 | import {ProfilesResolver} from '@caster/users/profiles/profiles.resolver' 9 | import {UsersResolver} from '@caster/users/users.resolver' 10 | import {join} from 'path' 11 | 12 | const prefix = ( 13 | str: string 14 | ) => `# ------------------------------------------------------ 15 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 16 | # ------------------------------------------------------ 17 | 18 | ${str}` 19 | 20 | /** 21 | * Generate the schema.graphql file at the project root. 22 | */ 23 | async function main() { 24 | const app = await NestFactory.create(GraphQLSchemaBuilderModule) 25 | await app.init() 26 | 27 | const gqlSchemaFactory = app.get(GraphQLSchemaFactory) 28 | const schema = await gqlSchemaFactory.create([ 29 | UsersResolver, 30 | ProfilesResolver, 31 | ShowsResolver, 32 | EpisodesResolver, 33 | ]) 34 | 35 | await writeFile( 36 | join(__dirname, '..', '..', '..', 'schema.graphql'), 37 | prefix(printSchema(schema)) 38 | ) 39 | } 40 | 41 | if (require.main === module) { 42 | main().catch((err) => { 43 | console.error(err) 44 | 45 | process.exit(1) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /apps/api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller, Post} from '@nestjs/common' 2 | 3 | @Controller() 4 | export class AppController { 5 | @Post() 6 | async index() { 7 | return {pong: true} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path' 2 | import {Module, Logger} from '@nestjs/common' 3 | import {ScheduleModule} from '@nestjs/schedule' 4 | import {GraphQLModule} from '@nestjs/graphql' 5 | import {PrismaModule} from 'nestjs-prisma' 6 | 7 | import {AuthnModule} from '@caster/authn/authn.module' 8 | import {ConfigModule} from '@caster/utils/config/config.module' 9 | import {HealthModule} from '@caster/utils/health/health.module' 10 | import {UsersModule} from '@caster/users/users.module' 11 | import {UserRules} from '@caster/users/user.rules' 12 | import {ProfilesModule} from '@caster/users/profiles/profiles.module' 13 | import {ProfileRules} from '@caster/users/profiles/profile.rules' 14 | import {ShowsModule} from '@caster/shows/shows.module' 15 | import {ShowRules} from '@caster/shows/show.rules' 16 | import {ShowRoles} from '@caster/shows/show.roles' 17 | import {EpisodesModule} from '@caster/shows/episodes/episodes.module' 18 | import {EpisodeRules} from '@caster/shows/episodes/episode.rules' 19 | import {EpisodeRoles} from '@caster/shows/episodes/episode.roles' 20 | import {AuthzModule} from '@caster/authz/authz.module' 21 | import {RolesModule} from '@caster/roles/roles.module' 22 | import {EventsModule} from '@caster/events/events.module' 23 | 24 | import {AppController} from './app.controller' 25 | 26 | const env = process.env.NODE_ENV || 'production' 27 | const isDev = env === 'development' 28 | const isTest = env === 'test' 29 | 30 | @Module({ 31 | imports: [ 32 | AuthnModule, 33 | 34 | ConfigModule, 35 | 36 | HealthModule, 37 | 38 | ScheduleModule.forRoot(), 39 | 40 | PrismaModule.forRoot({ 41 | isGlobal: true, 42 | prismaServiceOptions: { 43 | prismaOptions: {log: isTest ? ['warn'] : ['info']}, 44 | explicitConnect: true, 45 | }, 46 | }), 47 | 48 | GraphQLModule.forRoot({ 49 | debug: isDev, 50 | autoSchemaFile: join(process.cwd(), 'schema.graphql'), 51 | context: ({req}) => ({req}), 52 | }), 53 | 54 | EventsModule.forRoot(), 55 | 56 | AuthzModule.forRoot({ 57 | rules: [UserRules, ProfileRules, ShowRules, EpisodeRules], 58 | }), 59 | 60 | RolesModule.forRoot({ 61 | roles: [...ShowRoles.roles, ...EpisodeRoles.roles], 62 | permissions: [...ShowRoles.permissions, ...EpisodeRoles.permissions], 63 | }), 64 | 65 | UsersModule, 66 | 67 | ProfilesModule, 68 | 69 | ShowsModule, 70 | 71 | EpisodesModule, 72 | ], 73 | 74 | providers: [Logger], 75 | 76 | controllers: [AppController], 77 | }) 78 | export class AppModule {} 79 | -------------------------------------------------------------------------------- /apps/api/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkonkle/nestjs-example-caster-api/8a12fb8b0d3bfc034e807298de361460e5b640b8/apps/api/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import chalk from 'chalk' 3 | import morgan from 'morgan' 4 | import {INestApplication, ValidationPipe, Logger} from '@nestjs/common' 5 | import {NestFactory} from '@nestjs/core' 6 | 7 | import {AppModule} from './app.module' 8 | 9 | export async function init(): Promise { 10 | const {NODE_ENV} = process.env 11 | 12 | const app = await NestFactory.create(AppModule) 13 | 14 | const environment = NODE_ENV || 'production' 15 | const isDev = environment === 'development' 16 | const isTest = environment === 'test' 17 | 18 | if (isDev) { 19 | app.use(morgan('dev')) 20 | } else if (!isTest) { 21 | app.use(morgan('combined')) 22 | } 23 | 24 | app.use(bodyParser.json({limit: '50mb'})) 25 | app.enableCors() 26 | app.useGlobalPipes(new ValidationPipe({disableErrorMessages: !isDev})) 27 | 28 | return app 29 | } 30 | 31 | async function bootstrap(): Promise { 32 | const {PORT} = process.env 33 | 34 | const app = await init() 35 | 36 | const port = PORT || '3000' 37 | 38 | const logger = new Logger('Caster') 39 | 40 | app.startAllMicroservices() 41 | 42 | await app.listen(Number(port), () => { 43 | logger.log( 44 | chalk.cyan( 45 | `Started at: ${chalk.green(`http://localhost:${chalk.yellow(port)}`)}` 46 | ) 47 | ) 48 | 49 | logger.log( 50 | chalk.cyan( 51 | `GraphQL at: ${chalk.green( 52 | `http://localhost:${chalk.yellow(port)}/graphql` 53 | )}` 54 | ) 55 | ) 56 | }) 57 | } 58 | 59 | bootstrap() 60 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "emitDecoratorMetadata": true, 8 | "target": "es2015" 9 | }, 10 | "exclude": [ 11 | "test/**/*", 12 | "**/__tests__/**/*", 13 | "**/*.integration.ts", 14 | "**/*.test.ts" 15 | ], 16 | "include": ["**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["test/**/*", "**/*.test.ts", "**/*.integration.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "babelrcRoots": ["*"] 3 | } 4 | -------------------------------------------------------------------------------- /codegen.yaml: -------------------------------------------------------------------------------- 1 | schema: 2 | - schema.graphql 3 | generates: 4 | libs/graphql/src/schema.ts: 5 | hooks: 6 | afterOneFileWrite: 7 | - prettier --write 8 | plugins: 9 | - add: 10 | content: '/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any */' 11 | - add: 12 | content: '// WARNING: This file is automatically generated. Do not edit.' 13 | - add: 14 | content: import {Prisma} from '@prisma/client' 15 | - typescript 16 | - typescript-resolvers 17 | config: 18 | useIndexSignature: true 19 | noSchemaStitching: true 20 | scalars: 21 | DateTime: Date 22 | JSON: Prisma.JsonValue 23 | schema.json: 24 | plugins: 25 | - introspection 26 | config: 27 | minify: true 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | '/apps/api', 4 | '/libs/utils', 5 | '/libs/users', 6 | '/libs/graphql', 7 | '/libs/shows', 8 | '/libs/authz', 9 | '/libs/authn', 10 | '/libs/roles', 11 | '/libs/events', 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nrwl/jest/preset') 2 | 3 | module.exports = { 4 | ...nxPreset, 5 | globals: { 6 | ...nxPreset.globals, 7 | 'ts-jest': { 8 | diagnostics: { 9 | warnOnly: true, 10 | }, 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import 'reflect-metadata' 3 | 4 | process.env.NODE_ENV = 'test' 5 | 6 | dotenv.config({path: 'apps/api/.env'}) 7 | -------------------------------------------------------------------------------- /libs/authn/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/authn/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/authn/README.md: -------------------------------------------------------------------------------- 1 | # authn 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test authn` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/authn/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'authn', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/authn', 15 | } 16 | -------------------------------------------------------------------------------- /libs/authn/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/authn", 3 | "sourceRoot": "libs/authn/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/authn/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/authn"], 15 | "options": { 16 | "jestConfig": "libs/authn/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/authn/src/authn.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | import {PassportModule} from '@nestjs/passport' 3 | 4 | import {ConfigModule} from '@caster/utils/config/config.module' 5 | 6 | import {JwtStrategy} from './jwt.strategy' 7 | 8 | @Module({ 9 | imports: [PassportModule.register({defaultStrategy: 'jwt'}), ConfigModule], 10 | providers: [JwtStrategy], 11 | exports: [JwtStrategy], 12 | }) 13 | export class AuthnModule {} 14 | -------------------------------------------------------------------------------- /libs/authn/src/authn.types.ts: -------------------------------------------------------------------------------- 1 | import {Request} from 'express' 2 | import {GraphQLExtensionStack} from 'graphql-extensions' 3 | 4 | export interface JWT { 5 | jti: string // JWT id 6 | iss?: string // issuer 7 | aud?: string | string[] // audience 8 | sub?: string // subject 9 | iat?: number // issued at 10 | exp?: number // expires in 11 | nbf?: number // not before 12 | } 13 | 14 | export interface JwtRequest extends Request { 15 | jwt?: JWT 16 | } 17 | 18 | export interface JwtContext { 19 | _extensionStack: GraphQLExtensionStack 20 | req: JwtRequest 21 | } 22 | -------------------------------------------------------------------------------- /libs/authn/src/authn.utils.ts: -------------------------------------------------------------------------------- 1 | import {ExecutionContext, InternalServerErrorException} from '@nestjs/common' 2 | import {GqlExecutionContext} from '@nestjs/graphql' 3 | 4 | import {JWT, JwtRequest, JwtContext} from './authn.types' 5 | 6 | /** 7 | * Get the Request from the ExecutionContext in either GraphQL or REST contexts. 8 | */ 9 | export const getRequest = (context: ExecutionContext): JwtRequest => { 10 | const req: JwtRequest | undefined = context.switchToHttp()?.getRequest() 11 | if (req) { 12 | return req 13 | } 14 | 15 | const gqlCtx: JwtContext = GqlExecutionContext.create(context).getContext() 16 | if (gqlCtx.req) { 17 | return gqlCtx.req 18 | } 19 | 20 | throw new InternalServerErrorException( 21 | 'Unable to find JwtRequest from ExecutionContext' 22 | ) 23 | } 24 | 25 | /** 26 | * Return a boolean indicating whether a jwt is present on the request. 27 | */ 28 | export const isAuthenticated = (req: JwtRequest): boolean => Boolean(req.jwt) 29 | 30 | /** 31 | * Return the jwt parameter on requests if present. 32 | */ 33 | export const getJwt = (req: JwtRequest): JWT | undefined => req.jwt 34 | 35 | /** 36 | * Return the jwt sub parameter on requests if present. 37 | */ 38 | export const getUsername = (req: JwtRequest): string | undefined => req.jwt?.sub 39 | -------------------------------------------------------------------------------- /libs/authn/src/jwt.decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | UnauthorizedException, 5 | } from '@nestjs/common' 6 | 7 | import {getJwt, getRequest, getUsername, isAuthenticated} from './authn.utils' 8 | 9 | /** 10 | * Return a boolean indicating whether a user is present on the request. 11 | */ 12 | export const IsAuthenticated = createParamDecorator( 13 | (_options: unknown, ctx: ExecutionContext) => { 14 | const req = getRequest(ctx) 15 | 16 | return isAuthenticated(req) 17 | } 18 | ) 19 | 20 | /** 21 | * Return the jwt object if present, optionally requiring it. 22 | */ 23 | export const Jwt = createParamDecorator( 24 | (options: {require?: true} = {}, ctx: ExecutionContext) => { 25 | const req = getRequest(ctx) 26 | const jwt = getJwt(req) 27 | 28 | if (options.require && !jwt) { 29 | throw new UnauthorizedException() 30 | } 31 | 32 | return jwt 33 | } 34 | ) 35 | 36 | /** 37 | * Require and return the user sub parameter on requests. 38 | */ 39 | export const Username = createParamDecorator( 40 | (options: {require?: true} = {}, ctx: ExecutionContext) => { 41 | const req = getRequest(ctx) 42 | const username = getUsername(req) 43 | 44 | if (options.require && !username) { 45 | throw new UnauthorizedException() 46 | } 47 | 48 | return username 49 | } 50 | ) 51 | -------------------------------------------------------------------------------- /libs/authn/src/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import {ExecutionContext, Injectable, Optional} from '@nestjs/common' 2 | import {AuthGuard, AuthModuleOptions} from '@nestjs/passport' 3 | import {isObservable} from 'rxjs' 4 | 5 | import {getRequest} from './authn.utils' 6 | import {JWT, JwtRequest} from './authn.types' 7 | 8 | /** 9 | * Extends the JWT AuthGuard to allow anonymous requests and move the annotation to req.jwt. 10 | */ 11 | @Injectable() 12 | export class JwtGuard extends AuthGuard('jwt') { 13 | constructor(@Optional() protected readonly options?: AuthModuleOptions) { 14 | super(options) 15 | } 16 | 17 | getRequest(context: ExecutionContext): JwtRequest { 18 | return getRequest(context) 19 | } 20 | 21 | async canActivate(context: ExecutionContext): Promise { 22 | const canActivate = super.canActivate(context) 23 | 24 | // The canActivate method needs to be run in order to annotate the `user` property on the 25 | // request, but we need to intercept failures in order to allow anonymous requests. 26 | let success: boolean | undefined 27 | try { 28 | success = isObservable(canActivate) 29 | ? await canActivate.toPromise() 30 | : await canActivate 31 | } catch (error) { 32 | return true 33 | } 34 | 35 | if (!success) { 36 | return true 37 | } 38 | 39 | const request = this.getRequest(context) 40 | 41 | // Move the `user` property to the `jwt` property, because we want to populate the User object later 42 | request.jwt = request.user as JWT 43 | delete request.user 44 | 45 | return true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /libs/authn/src/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import {Inject, Injectable} from '@nestjs/common' 2 | import {PassportStrategy} from '@nestjs/passport' 3 | import {ExtractJwt, Strategy} from 'passport-jwt' 4 | import {passportJwtSecret} from 'jwks-rsa' 5 | 6 | import {Config} from '@caster/utils/config/config.types' 7 | 8 | import {JWT} from './authn.types' 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor(@Inject(Config) readonly config: Config) { 13 | super({ 14 | secretOrKeyProvider: passportJwtSecret({ 15 | cache: true, 16 | rateLimit: true, 17 | jwksRequestsPerMinute: 5, 18 | jwksUri: `${config.get('auth.url')}/.well-known/jwks.json`, 19 | }), 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | audience: config.get('auth.audience'), 22 | issuer: `${config.get('auth.url')}/`, 23 | algorithms: ['RS256'], 24 | }) 25 | } 26 | 27 | validate(payload: JWT): JWT { 28 | return payload 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /libs/authn/src/socket-jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import {Socket} from 'socket.io' 2 | import {Request} from 'express' 3 | import {ExecutionContext, Injectable} from '@nestjs/common' 4 | import {WsException} from '@nestjs/websockets' 5 | 6 | import {JwtGuard} from './jwt.guard' 7 | 8 | /** 9 | * Handle WebSockets by populating the headers from the handshake 10 | */ 11 | @Injectable() 12 | export class SocketJwtGuard extends JwtGuard { 13 | async canActivate(context: ExecutionContext): Promise { 14 | const request: Socket | Request = context.switchToHttp()?.getRequest() 15 | 16 | const socketReq = request as Socket 17 | const httpReq = request as Request 18 | 19 | if (socketReq.handshake?.headers) { 20 | httpReq.headers = { 21 | ...(httpReq.headers || {}), 22 | ...socketReq.handshake.headers, 23 | } 24 | } 25 | 26 | try { 27 | return await super.canActivate(context) 28 | } catch (error) { 29 | throw new WsException((error as Error).message) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /libs/authn/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/authn/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/authn/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/authz/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/authz/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "rules": { 5 | "@nrwl/nx/enforce-module-boundaries": [ 6 | "error", 7 | { 8 | "allow": ["@caster/users"] 9 | } 10 | ] 11 | }, 12 | "overrides": [ 13 | { 14 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.ts", "*.tsx"], 19 | "rules": {} 20 | }, 21 | { 22 | "files": ["*.js", "*.jsx"], 23 | "rules": {} 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /libs/authz/README.md: -------------------------------------------------------------------------------- 1 | # authz 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test authz` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/authz/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'authz', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/authz', 15 | } 16 | -------------------------------------------------------------------------------- /libs/authz/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/authz", 3 | "sourceRoot": "libs/authz/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/authz/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/authz"], 15 | "options": { 16 | "jestConfig": "libs/authz/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/authz/src/__tests__/ability.factory.test.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import {Test} from '@nestjs/testing' 3 | import {subject} from '@casl/ability' 4 | import {mockDeep} from 'jest-mock-extended' 5 | 6 | import {ProfileFactory} from '@caster/users/test/factories/profile.factory' 7 | import {UserFactory} from '@caster/users/test/factories/user.factory' 8 | import {UserWithProfile} from '@caster/users/user.types' 9 | 10 | import {AbilityFactory} from '../ability.factory' 11 | import {Action, RuleEnhancer, Rules} from '../authz.types' 12 | 13 | describe('AbilityFactory', () => { 14 | let factory: AbilityFactory 15 | 16 | const profile = ProfileFactory.make() 17 | const user = UserFactory.make({profile}) as UserWithProfile 18 | 19 | const enhancer = mockDeep() 20 | 21 | beforeAll(async () => { 22 | const testModule = await Test.createTestingModule({ 23 | providers: [AbilityFactory, {provide: Rules, useValue: [enhancer]}], 24 | }).compile() 25 | 26 | factory = testModule.get(AbilityFactory) 27 | }) 28 | 29 | describe('createForUser()', () => { 30 | it('iterates over the registered enhancers', async () => { 31 | enhancer.forUser.mockImplementationOnce(async (_, {can}) => { 32 | can(Action.Manage, 'User', {id: user.id}) 33 | }) 34 | 35 | const ability = await factory.createForUser(user) 36 | 37 | expect(enhancer.forUser).toBeCalledTimes(1) 38 | expect(enhancer.forUser).toBeCalledWith( 39 | user, 40 | expect.objectContaining({ 41 | can: expect.any(Function), 42 | cannot: expect.any(Function), 43 | }) 44 | ) 45 | 46 | // Ensure that the ability is initialized correctly 47 | expect(ability.can(Action.Manage, subject('User', user))).toBe(true) 48 | expect(ability.cannot(Action.Manage, subject('User', user))).toBe(false) 49 | 50 | expect( 51 | ability.can( 52 | Action.Manage, 53 | subject('User', {...user, id: faker.datatype.uuid()}) 54 | ) 55 | ).toBe(false) 56 | 57 | expect( 58 | ability.cannot( 59 | Action.Manage, 60 | subject('User', {...user, id: faker.datatype.uuid()}) 61 | ) 62 | ).toBe(true) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /libs/authz/src/__tests__/authz.guard.test.ts: -------------------------------------------------------------------------------- 1 | import {ExecutionContext} from '@nestjs/common' 2 | import {Reflector} from '@nestjs/core' 3 | import {Test} from '@nestjs/testing' 4 | import {mockDeep} from 'jest-mock-extended' 5 | 6 | import {UserFactory} from '@caster/users/test/factories/user.factory' 7 | import {UserWithProfile} from '@caster/users/user.types' 8 | import {UsersService} from '@caster/users/users.service' 9 | 10 | import {AbilityFactory} from '../ability.factory' 11 | import {AuthzGuard} from '../authz.guard' 12 | import {ALLOW_ANONYMOUS, AppAbility, AuthRequest} from '../authz.types' 13 | 14 | describe('AuthzGuard', () => { 15 | let guard: AuthzGuard 16 | 17 | const ability = mockDeep() 18 | const reflector = mockDeep() 19 | const service = mockDeep() 20 | const factory = mockDeep() 21 | const request = mockDeep({ 22 | jwt: { 23 | sub: undefined, 24 | }, 25 | }) 26 | const handler = jest.fn() 27 | 28 | const httpContext = mockDeep>({ 29 | getRequest: jest.fn(), 30 | }) 31 | 32 | const gqlCtx = {} 33 | const gqlInfo = {} 34 | const context = mockDeep({ 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | getArgs: () => [{}, {}, gqlCtx, gqlInfo] as any, 37 | }) 38 | 39 | const user = UserFactory.make() as UserWithProfile 40 | 41 | beforeAll(async () => { 42 | const testModule = await Test.createTestingModule({ 43 | providers: [ 44 | AuthzGuard, 45 | {provide: Reflector, useValue: reflector}, 46 | {provide: UsersService, useValue: service}, 47 | {provide: AbilityFactory, useValue: factory}, 48 | ], 49 | }).compile() 50 | 51 | guard = testModule.get(AuthzGuard) 52 | }) 53 | 54 | beforeEach(() => { 55 | jest.clearAllMocks() 56 | 57 | context.getClass.mockReturnValue(AuthzGuard) 58 | context.getHandler.mockReturnValue(handler) 59 | 60 | context.switchToHttp.mockReturnValue(httpContext) 61 | httpContext.getRequest.mockReturnValue(request) 62 | }) 63 | 64 | describe('canActivate()', () => { 65 | it('rejects anonymous requests by default', async () => { 66 | await expect(guard.canActivate(context)).rejects.toThrowError( 67 | 'Unauthorized' 68 | ) 69 | 70 | expect(reflector.getAllAndOverride).toBeCalledTimes(1) 71 | expect(reflector.getAllAndOverride).toBeCalledWith(ALLOW_ANONYMOUS, [ 72 | handler, 73 | AuthzGuard, 74 | ]) 75 | }) 76 | 77 | it('allows anonymous requests when decorated with @AllowAnonymous()', async () => { 78 | reflector.getAllAndOverride.mockReturnValueOnce(true) 79 | 80 | const result = await guard.canActivate(context) 81 | 82 | expect(reflector.getAllAndOverride).toBeCalledTimes(1) 83 | 84 | expect(result).toEqual(true) 85 | }) 86 | 87 | it('allows authenticated requests and adds context', async () => { 88 | const jwt = {sub: user.username} 89 | const authReq = {...request, jwt} 90 | 91 | httpContext.getRequest.mockReturnValueOnce(authReq) 92 | service.getByUsername.mockResolvedValueOnce(user) 93 | factory.createForUser.mockResolvedValueOnce(ability) 94 | 95 | const result = await guard.canActivate(context) 96 | 97 | expect(reflector.getAllAndOverride).toBeCalledTimes(1) 98 | expect(httpContext.getRequest).toBeCalledTimes(1) 99 | 100 | expect(service.getByUsername).toBeCalledTimes(1) 101 | expect(service.getByUsername).toBeCalledWith(user.username) 102 | 103 | expect(factory.createForUser).toBeCalledTimes(1) 104 | expect(factory.createForUser).toBeCalledWith(user) 105 | 106 | expect(authReq.user).toEqual(user) 107 | expect(authReq.ability).toEqual(ability) 108 | expect(authReq.censor).toEqual(expect.any(Function)) 109 | 110 | expect(result).toEqual(true) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /libs/authz/src/ability.factory.ts: -------------------------------------------------------------------------------- 1 | import {User, Profile} from '@prisma/client' 2 | import {AbilityBuilder} from '@casl/ability' 3 | import {Inject, Injectable} from '@nestjs/common' 4 | 5 | import {AppAbility, Rules, RuleEnhancer} from './authz.types' 6 | 7 | /** 8 | * Create Casl ability instances for the App 9 | */ 10 | @Injectable() 11 | export class AbilityFactory { 12 | constructor(@Inject(Rules) private readonly enhancers: RuleEnhancer[]) {} 13 | 14 | /** 15 | * Iterate over the registered RuleEnhancers to add rules to the Casl ability instance for a User 16 | */ 17 | async createForUser( 18 | user?: User & {profile: Profile | null} 19 | ): Promise { 20 | const {can, cannot, build} = new AbilityBuilder(AppAbility) 21 | 22 | // Run through each registered enhancer serially 23 | for (const enhancer of this.enhancers) { 24 | await enhancer.forUser(user, {can, cannot}) 25 | } 26 | 27 | return build() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libs/authz/src/authz-test.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | import {mockDeep} from 'jest-mock-extended' 3 | 4 | import {AbilityFactory} from './ability.factory' 5 | 6 | const mockAbilityFactory = { 7 | provide: AbilityFactory, 8 | useFactory: () => mockDeep(), 9 | } 10 | 11 | @Module({ 12 | providers: [mockAbilityFactory], 13 | exports: [mockAbilityFactory], 14 | }) 15 | export class AuthzTestModule {} 16 | -------------------------------------------------------------------------------- /libs/authz/src/authz.decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | SetMetadata, 5 | UnauthorizedException, 6 | } from '@nestjs/common' 7 | 8 | import {ALLOW_ANONYMOUS, AllowAnonymousMetadata} from './authz.types' 9 | import {getRequest} from './authz.utils' 10 | 11 | /** 12 | * Return the request's ability object. 13 | */ 14 | export const Ability = createParamDecorator( 15 | (_options: undefined, ctx: ExecutionContext) => { 16 | const req = getRequest(ctx) 17 | const ability = req.ability 18 | 19 | if (ability) { 20 | return ability 21 | } 22 | 23 | throw new UnauthorizedException() 24 | } 25 | ) 26 | 27 | /** 28 | * Return the request's censor function. 29 | */ 30 | export const Censor = createParamDecorator( 31 | (_options: undefined, ctx: ExecutionContext) => { 32 | const req = getRequest(ctx) 33 | const censor = req.censor 34 | 35 | if (censor) { 36 | return censor 37 | } 38 | 39 | throw new UnauthorizedException() 40 | } 41 | ) 42 | 43 | /** 44 | * Allow unauthenticated requests or requests without a valid User object. 45 | */ 46 | export const AllowAnonymous = () => 47 | SetMetadata(ALLOW_ANONYMOUS, true) 48 | -------------------------------------------------------------------------------- /libs/authz/src/authz.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | forwardRef, 5 | Inject, 6 | Injectable, 7 | UnauthorizedException, 8 | } from '@nestjs/common' 9 | import {Reflector} from '@nestjs/core' 10 | 11 | import {getUsername} from '@caster/authn/authn.utils' 12 | import {UsersService} from '@caster/users/users.service' 13 | 14 | import {AbilityFactory} from './ability.factory' 15 | import { 16 | ALLOW_ANONYMOUS, 17 | AllowAnonymousMetadata, 18 | CensorFields, 19 | } from './authz.types' 20 | import {censorFields, getRequest} from './authz.utils' 21 | 22 | @Injectable() 23 | export class AuthzGuard implements CanActivate { 24 | constructor( 25 | protected readonly reflector: Reflector, 26 | @Inject(forwardRef(() => UsersService)) 27 | private readonly users: UsersService, 28 | private readonly ability: AbilityFactory 29 | ) {} 30 | 31 | getRequest(context: ExecutionContext) { 32 | return getRequest(context) 33 | } 34 | 35 | async canActivate(context: ExecutionContext): Promise { 36 | const allowAnonymous = 37 | this.reflector.getAllAndOverride( 38 | ALLOW_ANONYMOUS, 39 | [context.getHandler(), context.getClass()] 40 | ) 41 | 42 | const handleAnonymous = async () => { 43 | if (allowAnonymous) { 44 | // Annotate an anonymous ability on the request 45 | request.ability = await this.ability.createForUser() 46 | request.censor = censorFields(request.ability) as CensorFields 47 | 48 | return true 49 | } 50 | 51 | throw new UnauthorizedException() 52 | } 53 | 54 | const request = this.getRequest(context) 55 | 56 | const username = getUsername(request) 57 | if (!username) { 58 | return handleAnonymous() 59 | } 60 | 61 | const user = await this.users.getByUsername(username) 62 | if (!user) { 63 | return handleAnonymous() 64 | } 65 | 66 | // Annotate the user object and the user's abilities on the request 67 | request.user = user 68 | request.ability = await this.ability.createForUser(user) 69 | 70 | // Annotate the censor function based on the ability 71 | request.censor = censorFields(request.ability) as CensorFields 72 | 73 | return true 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /libs/authz/src/authz.module.ts: -------------------------------------------------------------------------------- 1 | import {DynamicModule, Module} from '@nestjs/common' 2 | import {Class} from 'type-fest' 3 | 4 | import {UsersModule} from '@caster/users/users.module' 5 | 6 | import {AbilityFactory} from './ability.factory' 7 | import {Rules, RuleEnhancer} from './authz.types' 8 | 9 | export interface ModuleOptions { 10 | rules: Class[] 11 | } 12 | 13 | /** 14 | * AuthzModule takes a set of rule enhancers which apply Casl "can" and "cannot" expressions 15 | * to the Casl Ability. Use the `forRoot` dynamic module at the top level of your app to supply 16 | * the enhancers. 17 | */ 18 | @Module({ 19 | imports: [UsersModule], 20 | providers: [AbilityFactory], 21 | exports: [UsersModule, AbilityFactory], 22 | }) 23 | export class AuthzModule { 24 | static forRoot({rules: ruleClasses}: ModuleOptions): DynamicModule { 25 | return { 26 | global: true, 27 | module: AuthzModule, 28 | providers: [ 29 | ...ruleClasses, 30 | { 31 | provide: Rules, 32 | useFactory: (...rules: RuleEnhancer[]) => rules, 33 | inject: ruleClasses, 34 | }, 35 | ], 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /libs/authz/src/authz.types.ts: -------------------------------------------------------------------------------- 1 | import {User, Profile, RoleGrant, Show, Episode, Message} from '@prisma/client' 2 | import {AbilityBuilder, AbilityClass} from '@casl/ability' 3 | import {PermittedFieldsOptions} from '@casl/ability/extra' 4 | import {PrismaAbility, Subjects} from '@casl/prisma' 5 | 6 | import {InjectionToken} from '@caster/utils/injection' 7 | import {JwtContext, JwtRequest} from '@caster/authn/authn.types' 8 | import {UserWithProfile} from '@caster/users/user.types' 9 | 10 | /** 11 | * Abilities for the App, based on Prisma Entities 12 | */ 13 | 14 | export const Action = { 15 | Create: 'create', 16 | Read: 'read', 17 | Update: 'update', 18 | Delete: 'delete', 19 | Manage: 'manage', 20 | } as const 21 | export type Action = typeof Action[keyof typeof Action] 22 | 23 | export type AppSubjects = Subjects<{ 24 | User: User 25 | Profile: Profile 26 | RoleGrant: RoleGrant 27 | Show: Show 28 | Episode: Episode 29 | Message: Message 30 | }> 31 | 32 | export type AppAbility = PrismaAbility<[string, AppSubjects]> 33 | export const AppAbility = PrismaAbility as AbilityClass 34 | 35 | /** 36 | * Rule Enhancers (used to add Casl ability rules to the AppAbility) 37 | */ 38 | 39 | export type RuleBuilder = Pick, 'can' | 'cannot'> 40 | 41 | export interface RuleEnhancer { 42 | forUser( 43 | user: (User & {profile: Profile | null}) | undefined, 44 | builder: RuleBuilder 45 | ): Promise 46 | } 47 | 48 | /** 49 | * The Injection Token to provide Rule Enhancers 50 | */ 51 | export const Rules: InjectionToken = 'AUTHZ_CASL_RULES' 52 | 53 | /** 54 | * Custom JWT Request and Context objects with the metadata added to the Request. 55 | */ 56 | 57 | export interface AuthRequest extends JwtRequest { 58 | user?: UserWithProfile 59 | ability?: AppAbility 60 | censor?: ( 61 | subject: T, 62 | fieldOptions: PermittedFieldsOptions, 63 | action?: Action 64 | ) => T 65 | } 66 | 67 | export interface AuthContext extends JwtContext { 68 | req: AuthRequest 69 | } 70 | 71 | /** 72 | * Limits the returned object to the permittedFieldsOf the subject based on the ability 73 | */ 74 | export type CensorFields = NonNullable 75 | 76 | /** 77 | * Set metadata indicating that this route should be public. 78 | */ 79 | export const ALLOW_ANONYMOUS = 'auth:allow-anonymous' 80 | export type AllowAnonymousMetadata = boolean 81 | -------------------------------------------------------------------------------- /libs/authz/src/authz.utils.ts: -------------------------------------------------------------------------------- 1 | import {pick} from 'lodash' 2 | import {permittedFieldsOf, PermittedFieldsOptions} from '@casl/ability/extra' 3 | import {ExecutionContext, InternalServerErrorException} from '@nestjs/common' 4 | import {GqlExecutionContext} from '@nestjs/graphql' 5 | 6 | import { 7 | Action, 8 | AppAbility, 9 | AppSubjects, 10 | AuthRequest, 11 | AuthContext, 12 | } from './authz.types' 13 | 14 | /** 15 | * Return the given object with only permitted fields based on the Casl ability 16 | */ 17 | export const censorFields = 18 | (ability: AppAbility) => 19 | ( 20 | subject: T, 21 | fieldOptions: PermittedFieldsOptions, 22 | action: Action = 'read' 23 | ): T => { 24 | const fields = permittedFieldsOf(ability, action, subject, fieldOptions) 25 | 26 | return pick(subject, fields) as T 27 | } 28 | 29 | /** 30 | * Get the Request from the ExecutionContext in either GraphQL or REST contexts. 31 | */ 32 | export const getRequest = (context: ExecutionContext): AuthRequest => { 33 | const req: AuthRequest | undefined = context.switchToHttp()?.getRequest() 34 | if (req) { 35 | return req 36 | } 37 | 38 | const gqlCtx: AuthContext = GqlExecutionContext.create(context).getContext() 39 | if (gqlCtx.req) { 40 | return gqlCtx.req 41 | } 42 | 43 | throw new InternalServerErrorException( 44 | 'Unable to find AuthRequest from ExecutionContext' 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /libs/authz/src/socket-authz.guard.ts: -------------------------------------------------------------------------------- 1 | import {ExecutionContext, Injectable} from '@nestjs/common' 2 | import {WsException} from '@nestjs/websockets' 3 | 4 | import {AuthzGuard} from './authz.guard' 5 | 6 | /** 7 | * Wrap exceptions in a new WsException instance. 8 | */ 9 | @Injectable() 10 | export class SocketAuthzGuard extends AuthzGuard { 11 | async canActivate(context: ExecutionContext): Promise { 12 | try { 13 | return await super.canActivate(context) 14 | } catch (error) { 15 | throw new WsException((error as Error).message) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /libs/authz/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/authz/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/authz/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/events/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/events/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/events/README.md: -------------------------------------------------------------------------------- 1 | # events 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test events` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/events/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'events', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/events', 15 | } 16 | -------------------------------------------------------------------------------- /libs/events/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/events", 3 | "sourceRoot": "libs/events/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/events/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/events"], 15 | "options": { 16 | "jestConfig": "libs/events/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/events/src/__tests__/event.gateway.test.ts: -------------------------------------------------------------------------------- 1 | import {LoggerService} from '@nestjs/common' 2 | import {WsException} from '@nestjs/websockets' 3 | import {Test} from '@nestjs/testing' 4 | import {mockDeep, mockFn} from 'jest-mock-extended' 5 | import {Socket} from 'socket.io' 6 | 7 | import {Action, AppAbility, CensorFields} from '@caster/authz/authz.types' 8 | import {AbilityFactory} from '@caster/authz/ability.factory' 9 | import {UsersService} from '@caster/users/users.service' 10 | import {ProfileFactory} from '@caster/users/test/factories/profile.factory' 11 | import {UserFactory} from '@caster/users/test/factories/user.factory' 12 | import {UserWithProfile} from '@caster/users/user.types' 13 | 14 | import { 15 | makeClientRegisterEvent, 16 | makeMessageSend, 17 | } from '../../test/factories/events.factory' 18 | import {ChannelService} from '../channel.service' 19 | import {EventsGateway} from '../events.gateway' 20 | 21 | describe('EventsGateway', () => { 22 | const service = mockDeep() 23 | const logger = mockDeep() 24 | const censor = mockFn() 25 | const ability = mockDeep() 26 | const abilityFactory = mockDeep() 27 | const socket = mockDeep() 28 | const users = mockDeep() 29 | 30 | const username = 'test-username' 31 | const email = 'test@email.com' 32 | 33 | const profile = ProfileFactory.make({email}) 34 | const user = UserFactory.make({ 35 | username, 36 | profileId: profile.id, 37 | profile, 38 | }) as UserWithProfile 39 | 40 | let gateway: EventsGateway 41 | 42 | beforeAll(async () => { 43 | const testModule = await Test.createTestingModule({ 44 | providers: [ 45 | EventsGateway, 46 | {provide: ChannelService, useValue: service}, 47 | {provide: UsersService, useValue: users}, 48 | {provide: AbilityFactory, useValue: abilityFactory}, 49 | ], 50 | }) 51 | .setLogger(logger) 52 | .compile() 53 | 54 | gateway = testModule.get(EventsGateway) 55 | }) 56 | 57 | afterEach(() => { 58 | jest.clearAllMocks() 59 | }) 60 | 61 | describe('clientRegister()', () => { 62 | it('registers a new subscriber to the Redis channel for the given Episode', async () => { 63 | const event = makeClientRegisterEvent() 64 | 65 | await gateway.clientRegister(event, ability, censor, socket) 66 | 67 | expect(ability.cannot).toBeCalledTimes(1) 68 | expect(ability.cannot).toBeCalledWith(Action.Read, { 69 | episodeId: event.episodeId, 70 | }) 71 | 72 | expect(service.registerClient).toBeCalledTimes(1) 73 | expect(service.registerClient).toBeCalledWith(event, censor, socket) 74 | }) 75 | 76 | it('rejects unauthorized events', async () => { 77 | const event = makeClientRegisterEvent() 78 | 79 | ability.cannot.mockReturnValueOnce(true) 80 | 81 | await expect( 82 | gateway.clientRegister(event, ability, censor, socket) 83 | ).rejects.toThrowError(new WsException('Forbidden')) 84 | 85 | expect(ability.cannot).toBeCalledTimes(1) 86 | expect(service.registerClient).not.toBeCalled() 87 | }) 88 | 89 | it('rejects failed registration attempts', async () => { 90 | const event = makeClientRegisterEvent() 91 | 92 | service.registerClient.mockRejectedValueOnce(new Error('test-error')) 93 | 94 | await expect( 95 | gateway.clientRegister(event, ability, censor, socket) 96 | ).rejects.toThrowError(new WsException('test-error')) 97 | 98 | expect(ability.cannot).toBeCalledTimes(1) 99 | expect(service.registerClient).toBeCalledTimes(1) 100 | }) 101 | }) 102 | 103 | describe('messageSend()', () => { 104 | it('sends a message from the Websocket client', async () => { 105 | const event = makeMessageSend() 106 | 107 | await gateway.messageSend(event, ability, user) 108 | 109 | expect(ability.cannot).toBeCalledTimes(1) 110 | expect(ability.cannot).toBeCalledWith(Action.Create, { 111 | episodeId: event.episodeId, 112 | profileId: profile.id, 113 | }) 114 | 115 | expect(service.sendMessage).toBeCalledTimes(1) 116 | expect(service.sendMessage).toBeCalledWith(event, profile.id) 117 | }) 118 | 119 | it('rejects requests without a user profile', async () => { 120 | const event = makeMessageSend() 121 | 122 | const userWithoutProfile = {...user, profile: null} 123 | 124 | await expect( 125 | gateway.messageSend(event, ability, userWithoutProfile) 126 | ).rejects.toThrowError(new WsException('No User Profile found')) 127 | 128 | expect(ability.cannot).not.toBeCalled() 129 | expect(service.sendMessage).not.toBeCalled() 130 | }) 131 | 132 | it('rejects unauthorized requests', async () => { 133 | const event = makeMessageSend() 134 | 135 | ability.cannot.mockReturnValueOnce(true) 136 | 137 | await expect( 138 | gateway.messageSend(event, ability, user) 139 | ).rejects.toThrowError(new WsException('Forbidden')) 140 | 141 | expect(ability.cannot).toBeCalledTimes(1) 142 | expect(service.sendMessage).not.toBeCalled() 143 | }) 144 | 145 | it('rejects failed send attempts', async () => { 146 | const event = makeMessageSend() 147 | 148 | service.sendMessage.mockRejectedValueOnce(new Error('test-error')) 149 | 150 | await expect( 151 | gateway.messageSend(event, ability, user) 152 | ).rejects.toThrowError(new WsException('test-error')) 153 | 154 | expect(ability.cannot).toBeCalledTimes(1) 155 | expect(service.sendMessage).toBeCalledTimes(1) 156 | }) 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /libs/events/src/channel.service.ts: -------------------------------------------------------------------------------- 1 | import {Redis} from 'ioredis' 2 | import {Socket} from 'socket.io' 3 | import {Inject, Logger} from '@nestjs/common' 4 | import {subject} from '@casl/ability' 5 | 6 | import {ProfilesService} from '@caster/users/profiles/profiles.service' 7 | import {fieldOptions} from '@caster/users/profiles/profile.utils' 8 | import {CensorFields} from '@caster/authz/authz.types' 9 | 10 | import { 11 | ChatMessage, 12 | ClientRegister, 13 | EventTypes, 14 | MessageContext, 15 | MessageReceive, 16 | MessageSend, 17 | Publisher, 18 | Subscriber, 19 | } from './event.types' 20 | 21 | export class ChannelService { 22 | private readonly logger = new Logger(ChannelService.name) 23 | 24 | constructor( 25 | @Inject(Publisher) private readonly publisher: Redis, 26 | @Inject(Subscriber) private readonly subscriber: Redis, 27 | private readonly profiles: ProfilesService 28 | ) {} 29 | 30 | registerClient = async ( 31 | event: ClientRegister, 32 | censor: CensorFields, 33 | socket: Socket 34 | ): Promise => { 35 | const context = {episodeId: event.episodeId, censor, socket} 36 | 37 | this.subscriber.on('message', this.handleMessage(context)) 38 | 39 | this.subscriber.subscribe(`ep:${event.episodeId}`, (err, _count) => { 40 | if (err) { 41 | this.logger.error( 42 | `Error during Redis subscribe for profileId - ${event.profileId}, channel - "ep:${event.episodeId}"`, 43 | err 44 | ) 45 | 46 | return 47 | } 48 | }) 49 | 50 | // Acknowledge that the client was registered 51 | socket.emit(EventTypes.ClientRegistered, event) 52 | } 53 | 54 | sendMessage = async (event: MessageSend, profileId: string) => { 55 | const chatMessage: ChatMessage = { 56 | sender: {profileId: profileId}, 57 | text: event.text, 58 | } 59 | 60 | this.publisher.publish(`ep:${event.episodeId}`, JSON.stringify(chatMessage)) 61 | } 62 | 63 | /** 64 | * Handle Redis pub/sub events for the given WebSocket client. 65 | */ 66 | handleMessage = 67 | (context: MessageContext) => 68 | async ( 69 | channel: string | Buffer, 70 | message: string | Buffer 71 | ): Promise => { 72 | const {episodeId, censor, socket} = context 73 | const {text, sender}: Partial = JSON.parse(`${message}`) 74 | 75 | const senderProfile = 76 | sender && (await this.profiles.get(sender.profileId)) 77 | 78 | if (!senderProfile) { 79 | this.logger.error( 80 | `Error: Cannot find Profile for message received on channel "${channel}" - ${message}` 81 | ) 82 | 83 | return 84 | } 85 | 86 | if (!text) { 87 | this.logger.error( 88 | `Error: Message received on channel "${channel}" with no text - ${message}` 89 | ) 90 | 91 | return 92 | } 93 | 94 | const censoredSender = censor( 95 | subject('Profile', senderProfile), 96 | fieldOptions 97 | ) 98 | 99 | const receiveEvent: MessageReceive = { 100 | episodeId, 101 | sender: censoredSender, 102 | text, 103 | } 104 | 105 | socket.emit(EventTypes.MessageReceive, receiveEvent) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /libs/events/src/event.types.ts: -------------------------------------------------------------------------------- 1 | import {Redis} from 'ioredis' 2 | import {Socket} from 'socket.io' 3 | 4 | import {CensorFields} from '@caster/authz/authz.types' 5 | import {ProfileWithUser} from '@caster/users/profiles/profile.utils' 6 | import {InjectionToken} from '@caster/utils/injection' 7 | 8 | export const EventTypes = { 9 | ClientRegister: 'client-register', 10 | ClientRegistered: 'client-registered', 11 | Ping: 'ping', 12 | MessageSend: 'message-send', 13 | MessageReceive: 'message-receive', 14 | } as const 15 | export type EventTypes = typeof EventTypes[keyof typeof EventTypes] 16 | 17 | export interface ClientRegister { 18 | episodeId: string 19 | profileId: string 20 | } 21 | 22 | export interface MessageSend { 23 | episodeId: string 24 | text: string 25 | } 26 | 27 | export interface MessageReceive { 28 | episodeId: string 29 | sender: ProfileWithUser 30 | text: string 31 | } 32 | 33 | /** 34 | * Redis Event Bus 35 | */ 36 | export const Publisher: InjectionToken = 'EVENTS_IOREDIS_PUBLISHER' 37 | 38 | export const Subscriber: InjectionToken = 'EVENTS_IOREDIS_SUBSCRIBER' 39 | 40 | export interface MessageContext { 41 | episodeId: string 42 | censor: CensorFields 43 | socket: Socket 44 | } 45 | 46 | /** 47 | * A chat message send on a Redis channel 48 | */ 49 | export interface ChatMessage { 50 | sender: { 51 | profileId: string 52 | } 53 | text: string 54 | } 55 | -------------------------------------------------------------------------------- /libs/events/src/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import {subject} from '@casl/ability' 2 | import {Message} from '@prisma/client' 3 | import { 4 | ConnectedSocket, 5 | MessageBody, 6 | OnGatewayConnection, 7 | OnGatewayInit, 8 | SubscribeMessage, 9 | WebSocketGateway, 10 | WsException, 11 | } from '@nestjs/websockets' 12 | import {Logger, UseGuards} from '@nestjs/common' 13 | import {Socket} from 'socket.io' 14 | 15 | import {SocketJwtGuard} from '@caster/authn/socket-jwt.guard' 16 | import {Ability, Censor} from '@caster/authz/authz.decorators' 17 | import {SocketAuthzGuard} from '@caster/authz/socket-authz.guard' 18 | import {Action, AppAbility, CensorFields} from '@caster/authz/authz.types' 19 | import {RequestUser} from '@caster/users/user.decorators' 20 | import {UserWithProfile} from '@caster/users/user.types' 21 | 22 | import {ClientRegister, EventTypes, MessageSend} from './event.types' 23 | import {ChannelService} from './channel.service' 24 | 25 | @WebSocketGateway() 26 | @UseGuards(SocketJwtGuard, SocketAuthzGuard) 27 | export class EventsGateway implements OnGatewayInit, OnGatewayConnection { 28 | private readonly logger = new Logger(EventsGateway.name) 29 | 30 | constructor(private readonly service: ChannelService) {} 31 | 32 | @SubscribeMessage(EventTypes.ClientRegister) 33 | async clientRegister( 34 | @MessageBody() event: ClientRegister, 35 | @Ability() ability: AppAbility, 36 | @Censor() censor: CensorFields, 37 | @ConnectedSocket() socket: Socket 38 | ) { 39 | // Check for authorization 40 | if ( 41 | ability.cannot( 42 | Action.Read, 43 | subject('Message', { 44 | episodeId: event.episodeId, 45 | } as Message) 46 | ) 47 | ) { 48 | throw new WsException('Forbidden') 49 | } 50 | 51 | try { 52 | await this.service.registerClient(event, censor, socket) 53 | } catch (error) { 54 | throw new WsException((error as Error).message) 55 | } 56 | } 57 | 58 | @SubscribeMessage(EventTypes.MessageSend) 59 | async messageSend( 60 | @MessageBody() event: MessageSend, 61 | @Ability() ability: AppAbility, 62 | @RequestUser({require: true}) user: UserWithProfile 63 | ) { 64 | if (!user.profile) { 65 | throw new WsException('No User Profile found') 66 | } 67 | 68 | // Check for authorization 69 | if ( 70 | ability.cannot( 71 | Action.Create, 72 | subject('Message', { 73 | episodeId: event.episodeId, 74 | profileId: user.profile.id, 75 | } as Message) 76 | ) 77 | ) { 78 | throw new WsException('Forbidden') 79 | } 80 | 81 | try { 82 | await this.service.sendMessage(event, user.profile.id) 83 | } catch (error) { 84 | throw new WsException((error as Error).message) 85 | } 86 | } 87 | 88 | afterInit() { 89 | this.logger.log('WebSocket initialized') 90 | } 91 | 92 | handleConnection(socket: Socket) { 93 | this.logger.log(`Connection: ${socket.id}`) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /libs/events/src/events.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConsoleLogger, 3 | DynamicModule, 4 | FactoryProvider, 5 | Logger, 6 | Module, 7 | } from '@nestjs/common' 8 | import Redis from 'ioredis' 9 | 10 | import {ProfilesModule} from '@caster/users/profiles/profiles.module' 11 | import {Config} from '@caster/utils/config/config.types' 12 | 13 | import {EventsGateway} from './events.gateway' 14 | import {Publisher, Subscriber} from './event.types' 15 | import {ChannelService} from './channel.service' 16 | 17 | export interface EventsOptions { 18 | url?: string 19 | } 20 | 21 | export interface RedisSettings { 22 | publisher?: boolean 23 | } 24 | 25 | /** 26 | * A Redis factory provider that can act as either a Cubscriber or a Publisher 27 | */ 28 | const redisProvider = ( 29 | options: EventsOptions = {}, 30 | settings: RedisSettings = {} 31 | ): FactoryProvider => ({ 32 | provide: settings.publisher ? Publisher : Subscriber, 33 | useFactory: (config: Config) => 34 | new Redis(options.url || config.get('redis.url')), 35 | inject: [Config], 36 | }) 37 | 38 | /** 39 | * WebSocket event handling via the EventsGateway 40 | */ 41 | @Module({ 42 | imports: [ProfilesModule], 43 | providers: [ 44 | {provide: Logger, useClass: ConsoleLogger}, 45 | EventsGateway, 46 | ChannelService, 47 | ], 48 | }) 49 | export class EventsModule { 50 | static forRoot(options: EventsOptions = {}): DynamicModule { 51 | return { 52 | module: EventsModule, 53 | providers: [ 54 | redisProvider(options), 55 | redisProvider(options, {publisher: true}), 56 | ], 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /libs/events/test/factories/events.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import { 4 | ClientRegister, 5 | MessageReceive, 6 | MessageSend, 7 | } from '@caster/events/event.types' 8 | import {UserFactory} from '@caster/users/test/factories/user.factory' 9 | import {ProfileFactory} from '@caster/users/test/factories/profile.factory' 10 | import {toNullProps} from '@caster/utils/types' 11 | 12 | export const makeClientRegisterEvent = ( 13 | overrides?: Partial 14 | ): ClientRegister => { 15 | return { 16 | episodeId: faker.datatype.uuid(), 17 | profileId: faker.datatype.uuid(), 18 | ...overrides, 19 | } 20 | } 21 | 22 | export const makeMessageSend = ( 23 | overrides?: Partial 24 | ): MessageSend => { 25 | return { 26 | episodeId: faker.datatype.uuid(), 27 | text: faker.lorem.paragraphs(2), 28 | ...overrides, 29 | } 30 | } 31 | 32 | export const makeMessageReceive = ( 33 | overrides?: Partial 34 | ): MessageReceive => { 35 | const profile = ProfileFactory.make() 36 | 37 | const sender = overrides?.sender ?? { 38 | ...profile, 39 | 40 | // Fix the email field, since it may be undefined on the Model but not the DB entity 41 | email: profile.email ?? 'temp', 42 | 43 | user: UserFactory.make(), 44 | } 45 | 46 | return { 47 | episodeId: faker.datatype.uuid(), 48 | sender: toNullProps(sender), 49 | text: faker.lorem.paragraphs(2), 50 | ...overrides, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /libs/events/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/events/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/events/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/graphql/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/graphql/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/graphql/README.md: -------------------------------------------------------------------------------- 1 | # graphql 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test graphql` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/graphql/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'graphql', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/graphql', 15 | setupFilesAfterEnv: ['../../jest.setup.ts'], 16 | } 17 | -------------------------------------------------------------------------------- /libs/graphql/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/graphql", 3 | "sourceRoot": "libs/graphql/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/graphql/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/graphql"], 15 | "options": { 16 | "jestConfig": "libs/graphql/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | }, 20 | "codegen": { 21 | "executor": "@nrwl/workspace:run-commands", 22 | "options": { 23 | "commands": [ 24 | "yarn ts --project apps/api/tsconfig.json apps/api/scripts/codegen-schema.ts", 25 | "npx graphql-codegen" 26 | ], 27 | "parallel": false 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /libs/graphql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/graphql/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/graphql/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/roles/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/roles/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/roles/README.md: -------------------------------------------------------------------------------- 1 | # roles 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test roles` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/roles/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'roles', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/roles', 15 | } 16 | -------------------------------------------------------------------------------- /libs/roles/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/roles", 3 | "sourceRoot": "libs/roles/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/roles/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/roles"], 15 | "options": { 16 | "jestConfig": "libs/roles/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/roles/src/roles.module.ts: -------------------------------------------------------------------------------- 1 | import {DynamicModule, Module} from '@nestjs/common' 2 | 3 | import {RolesService} from './roles.service' 4 | import {Role, Permission, Roles, Permissions} from './roles.types' 5 | 6 | export interface ModuleOptions { 7 | roles: Role[] 8 | permissions: Permission[] 9 | } 10 | 11 | @Module({ 12 | providers: [RolesService], 13 | exports: [RolesService], 14 | }) 15 | export class RolesModule { 16 | static forRoot({roles, permissions}: ModuleOptions): DynamicModule { 17 | return { 18 | global: true, 19 | module: RolesModule, 20 | providers: [ 21 | { 22 | provide: Permissions, 23 | useValue: permissions, 24 | }, 25 | { 26 | provide: Roles, 27 | useValue: roles, 28 | }, 29 | ], 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /libs/roles/src/roles.types.ts: -------------------------------------------------------------------------------- 1 | import {InjectionToken} from '@caster/utils/injection' 2 | 3 | /** 4 | * Permissions 5 | */ 6 | 7 | export interface Permission { 8 | key: string 9 | name: string 10 | description: string 11 | } 12 | 13 | /** 14 | * Roles 15 | */ 16 | export interface Role { 17 | key: string 18 | name: string 19 | description: string 20 | permissions: Permission[] 21 | } 22 | 23 | export const Roles: InjectionToken = 'AUTHZ_ROLES' 24 | 25 | export const Permissions: InjectionToken = 'AUTHZ_PERMISSIONS' 26 | -------------------------------------------------------------------------------- /libs/roles/test/factories/role-grant.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import {RoleGrant} from '@prisma/client' 3 | 4 | export const make = (overrides?: Partial | null): RoleGrant => { 5 | return { 6 | id: faker.datatype.uuid(), 7 | createdAt: faker.date.recent(), 8 | updatedAt: faker.date.recent(), 9 | roleKey: faker.datatype.uuid(), 10 | profileId: faker.datatype.uuid(), 11 | subjectTable: faker.random.word(), 12 | subjectId: faker.datatype.uuid(), 13 | ...overrides, 14 | } 15 | } 16 | 17 | export const RoleGrantFactory = {make} 18 | -------------------------------------------------------------------------------- /libs/roles/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/roles/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/roles/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/shows/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/shows/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/shows/README.md: -------------------------------------------------------------------------------- 1 | # shows 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test shows` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/shows/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'shows', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/shows', 15 | setupFilesAfterEnv: ['../../jest.setup.ts'], 16 | } 17 | -------------------------------------------------------------------------------- /libs/shows/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/shows", 3 | "sourceRoot": "libs/shows/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/shows/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/shows"], 15 | "options": { 16 | "jestConfig": "libs/shows/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/shows/src/__tests__/show.rules.test.ts: -------------------------------------------------------------------------------- 1 | import {mockDeep} from 'jest-mock-extended' 2 | import {Test} from '@nestjs/testing' 3 | import {AbilityBuilder, subject} from '@casl/ability' 4 | import {Episode, Show} from '@prisma/client' 5 | 6 | import {Action, AppAbility} from '@caster/authz/authz.types' 7 | import {RolesService} from '@caster/roles/roles.service' 8 | import {UserWithProfile} from '@caster/users/user.types' 9 | import {UserFactory} from '@caster/users/test/factories/user.factory' 10 | import {ProfileFactory} from '@caster/users/test/factories/profile.factory' 11 | import {RoleGrantFactory} from '@caster/roles/test/factories/role-grant.factory' 12 | 13 | import {ShowFactory} from '../../test/factories/show.factory' 14 | import {EpisodeFactory} from '../../test/factories/episodes.factory' 15 | import {Update, Delete, ManageEpisodes, ManageRoles} from '../show.roles' 16 | import {ShowRules} from '../show.rules' 17 | 18 | describe('ShowRules', () => { 19 | let rules: ShowRules 20 | 21 | const builder = new AbilityBuilder(AppAbility) 22 | const roles = mockDeep() 23 | 24 | const profile = ProfileFactory.make() 25 | const user = UserFactory.make({ 26 | profileId: profile.id, 27 | profile, 28 | }) as UserWithProfile 29 | 30 | beforeAll(async () => { 31 | const testModule = await Test.createTestingModule({ 32 | providers: [{provide: RolesService, useValue: roles}, ShowRules], 33 | }).compile() 34 | 35 | rules = testModule.get(ShowRules) 36 | }) 37 | 38 | beforeEach(() => { 39 | jest.clearAllMocks() 40 | }) 41 | 42 | describe('forUser()', () => { 43 | it('allows everyone to read', async () => { 44 | await rules.forUser(undefined, builder) 45 | 46 | expect(roles.getPermissionsForTable).not.toBeCalled() 47 | 48 | const ability = builder.build() 49 | 50 | expect(ability.can(Action.Read, 'Show')).toBe(true) 51 | }) 52 | 53 | it('allows authenticated users to create', async () => { 54 | roles.getPermissionsForTable.mockResolvedValueOnce({}) 55 | 56 | await rules.forUser(user, builder) 57 | 58 | const ability = builder.build() 59 | 60 | expect(roles.getPermissionsForTable).toBeCalledTimes(1) 61 | expect(roles.getPermissionsForTable).toBeCalledWith(profile.id, 'Show') 62 | 63 | expect(ability.can(Action.Create, 'Show')).toBe(true) 64 | }) 65 | 66 | it('supports the Admin role', async () => { 67 | const showId = 'test-show-id' 68 | const show = ShowFactory.make({id: showId}) as Show 69 | const episode = EpisodeFactory.make({showId}) as Episode 70 | 71 | roles.getPermissionsForTable.mockResolvedValueOnce({ 72 | [showId]: [Update, Delete, ManageEpisodes, ManageRoles], 73 | }) 74 | 75 | await rules.forUser(user, builder) 76 | 77 | expect(roles.getPermissionsForTable).toBeCalledTimes(1) 78 | 79 | const ability = builder.build() 80 | 81 | expect(ability.can(Action.Update, subject('Show', show))) 82 | expect(ability.can(Action.Delete, subject('Show', show))) 83 | expect(ability.can(Action.Manage, subject('Episode', episode))) 84 | expect( 85 | ability.can( 86 | Action.Manage, 87 | subject( 88 | 'RoleGrant', 89 | RoleGrantFactory.make({subjectTable: 'Show', subjectId: showId}) 90 | ) 91 | ) 92 | ) 93 | }) 94 | 95 | it('allows users with a profile to manage episodes they are authorized to', async () => { 96 | const showId = 'test-show-id' 97 | const show = ShowFactory.make({id: showId}) as Show 98 | const episode = EpisodeFactory.make({showId}) as Episode 99 | 100 | roles.getPermissionsForTable.mockResolvedValueOnce({ 101 | [showId]: [Update, ManageEpisodes], 102 | }) 103 | 104 | await rules.forUser(user, builder) 105 | 106 | expect(roles.getPermissionsForTable).toBeCalledTimes(1) 107 | 108 | const ability = builder.build() 109 | 110 | expect(ability.can(Action.Update, subject('Show', show))) 111 | expect(ability.can(Action.Update, subject('Episode', episode))) 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /libs/shows/src/__tests__/shows.service.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {Show as PrismaShow} from '@prisma/client' 3 | import {mockDeep} from 'jest-mock-extended' 4 | import {PrismaService} from 'nestjs-prisma' 5 | 6 | import {ShowFactory} from '../../test/factories/show.factory' 7 | import {CreateShowInput, UpdateShowInput} from '../show-mutations.model' 8 | import {ShowsService} from '../shows.service' 9 | 10 | describe('ShowsService', () => { 11 | let service: ShowsService 12 | 13 | const prisma = mockDeep() 14 | 15 | const show = ShowFactory.make() 16 | 17 | beforeAll(async () => { 18 | const testModule = await Test.createTestingModule({ 19 | providers: [{provide: PrismaService, useValue: prisma}, ShowsService], 20 | }).compile() 21 | 22 | service = testModule.get(ShowsService) 23 | }) 24 | 25 | afterEach(() => { 26 | jest.resetAllMocks() 27 | }) 28 | 29 | describe('get()', () => { 30 | it('uses Prisma to find the first matching Show by id', async () => { 31 | prisma.show.findFirst.mockResolvedValueOnce(show as PrismaShow) 32 | 33 | const result = await service.get(show.id) 34 | 35 | expect(prisma.show.findFirst).toBeCalledTimes(1) 36 | expect(prisma.show.findFirst).toBeCalledWith( 37 | expect.objectContaining({ 38 | where: {id: show.id}, 39 | }) 40 | ) 41 | 42 | expect(result).toEqual(show) 43 | }) 44 | }) 45 | 46 | describe('getMany()', () => { 47 | it('uses Prisma to find many Shows', async () => { 48 | prisma.show.count.mockResolvedValueOnce(1) 49 | prisma.show.findMany.mockResolvedValueOnce([show as PrismaShow]) 50 | 51 | const expected = { 52 | data: [show], 53 | count: 1, 54 | total: 1, 55 | page: 1, 56 | pageCount: 1, 57 | } 58 | 59 | const result = await service.getMany({ 60 | where: {title: show.title}, 61 | orderBy: { 62 | id: 'asc', 63 | }, 64 | }) 65 | 66 | expect(prisma.show.findMany).toBeCalledTimes(1) 67 | expect(prisma.show.findMany).toBeCalledWith( 68 | expect.objectContaining({ 69 | where: {title: show.title}, 70 | orderBy: { 71 | id: 'asc', 72 | }, 73 | }) 74 | ) 75 | 76 | expect(result).toEqual(expected) 77 | }) 78 | }) 79 | 80 | describe('create()', () => { 81 | it('uses Prisma to create a new Show', async () => { 82 | prisma.show.create.mockResolvedValueOnce(show as PrismaShow) 83 | 84 | const input: CreateShowInput = {title: show.title} 85 | 86 | const result = await service.create(input) 87 | 88 | expect(prisma.show.create).toBeCalledTimes(1) 89 | expect(prisma.show.create).toBeCalledWith( 90 | expect.objectContaining({ 91 | data: input, 92 | }) 93 | ) 94 | 95 | expect(result).toEqual(show) 96 | }) 97 | }) 98 | 99 | describe('update()', () => { 100 | it('uses Prisma to update an existing Show', async () => { 101 | prisma.show.update.mockResolvedValueOnce(show as PrismaShow) 102 | 103 | const input: UpdateShowInput = {title: 'Test Title'} 104 | 105 | const result = await service.update(show.id, input) 106 | 107 | expect(prisma.show.update).toBeCalledTimes(1) 108 | expect(prisma.show.update).toBeCalledWith( 109 | expect.objectContaining({ 110 | where: {id: show.id}, 111 | data: input, 112 | }) 113 | ) 114 | 115 | expect(result).toEqual(show) 116 | }) 117 | }) 118 | 119 | describe('delete()', () => { 120 | it('uses Prisma to delete an existing Show', async () => { 121 | prisma.show.delete.mockResolvedValueOnce(show as PrismaShow) 122 | 123 | const result = await service.delete(show.id) 124 | 125 | expect(prisma.show.delete).toBeCalledTimes(1) 126 | expect(prisma.show.delete).toBeCalledWith( 127 | expect.objectContaining({ 128 | where: {id: show.id}, 129 | }) 130 | ) 131 | 132 | expect(result).toEqual(show) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/__tests__/episode.rules.test.ts: -------------------------------------------------------------------------------- 1 | import {mockDeep} from 'jest-mock-extended' 2 | import {Test} from '@nestjs/testing' 3 | 4 | import {Action, RuleBuilder} from '@caster/authz/authz.types' 5 | import {RolesService} from '@caster/roles/roles.service' 6 | 7 | import {EpisodeRules} from '../episode.rules' 8 | 9 | describe('EpisodeRules', () => { 10 | let rules: EpisodeRules 11 | 12 | const roles = mockDeep() 13 | 14 | const builder = mockDeep() 15 | 16 | beforeAll(async () => { 17 | const testModule = await Test.createTestingModule({ 18 | providers: [{provide: RolesService, useValue: roles}, EpisodeRules], 19 | }).compile() 20 | 21 | rules = testModule.get(EpisodeRules) 22 | }) 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks() 26 | }) 27 | 28 | describe('forUser()', () => { 29 | it('allows everyone to read', async () => { 30 | await rules.forUser(undefined, builder) 31 | 32 | expect(roles.getPermissionsForTable).not.toBeCalled() 33 | 34 | expect(builder.can).toBeCalledTimes(1) 35 | expect(builder.can).toBeCalledWith(Action.Read, 'Episode') 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/__tests__/episodes.service.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {Episode as PrismaEpisode} from '@prisma/client' 3 | import {mockDeep} from 'jest-mock-extended' 4 | import {PrismaService} from 'nestjs-prisma' 5 | 6 | import {ShowFactory} from '../../../test/factories/show.factory' 7 | import {EpisodeFactory} from '../../../test/factories/episodes.factory' 8 | import { 9 | CreateEpisodeInput, 10 | UpdateEpisodeInput, 11 | } from '../episode-mutations.model' 12 | import {EpisodesService} from '../episodes.service' 13 | 14 | describe('EpisodesService', () => { 15 | let service: EpisodesService 16 | 17 | const prisma = mockDeep() 18 | 19 | const show = ShowFactory.make() 20 | const episode = EpisodeFactory.make({showId: show.id, show}) 21 | 22 | beforeAll(async () => { 23 | const testModule = await Test.createTestingModule({ 24 | providers: [{provide: PrismaService, useValue: prisma}, EpisodesService], 25 | }).compile() 26 | 27 | service = testModule.get(EpisodesService) 28 | }) 29 | 30 | afterEach(() => { 31 | jest.resetAllMocks() 32 | }) 33 | 34 | describe('get()', () => { 35 | it('uses Prisma to find the first matching Episode by id', async () => { 36 | prisma.episode.findFirst.mockResolvedValueOnce(episode as PrismaEpisode) 37 | 38 | const result = await service.get(episode.id) 39 | 40 | expect(prisma.episode.findFirst).toBeCalledTimes(1) 41 | expect(prisma.episode.findFirst).toBeCalledWith( 42 | expect.objectContaining({ 43 | where: {id: episode.id}, 44 | }) 45 | ) 46 | 47 | expect(result).toEqual(episode) 48 | }) 49 | }) 50 | 51 | describe('getMany()', () => { 52 | it('uses Prisma to find many Episodes', async () => { 53 | prisma.episode.count.mockResolvedValueOnce(1) 54 | prisma.episode.findMany.mockResolvedValueOnce([episode as PrismaEpisode]) 55 | 56 | const expected = { 57 | data: [episode], 58 | count: 1, 59 | total: 1, 60 | page: 1, 61 | pageCount: 1, 62 | } 63 | 64 | const result = await service.getMany({ 65 | where: {title: episode.title}, 66 | orderBy: { 67 | id: 'asc', 68 | }, 69 | }) 70 | 71 | expect(prisma.episode.findMany).toBeCalledTimes(1) 72 | expect(prisma.episode.findMany).toBeCalledWith( 73 | expect.objectContaining({ 74 | where: {title: episode.title}, 75 | orderBy: { 76 | id: 'asc', 77 | }, 78 | }) 79 | ) 80 | 81 | expect(result).toEqual(expected) 82 | }) 83 | }) 84 | 85 | describe('create()', () => { 86 | it('uses Prisma to create a new Episode', async () => { 87 | prisma.episode.create.mockResolvedValueOnce(episode as PrismaEpisode) 88 | 89 | const input: CreateEpisodeInput = {title: episode.title, showId: show.id} 90 | 91 | const result = await service.create(input) 92 | 93 | expect(prisma.episode.create).toBeCalledTimes(1) 94 | expect(prisma.episode.create).toBeCalledWith( 95 | expect.objectContaining({ 96 | data: input, 97 | }) 98 | ) 99 | 100 | expect(result).toEqual(episode) 101 | }) 102 | }) 103 | 104 | describe('update()', () => { 105 | it('uses Prisma to update an existing Episode', async () => { 106 | prisma.episode.update.mockResolvedValueOnce(episode as PrismaEpisode) 107 | 108 | const input: UpdateEpisodeInput = {title: 'Test Title'} 109 | 110 | const result = await service.update(episode.id, input) 111 | 112 | expect(prisma.episode.update).toBeCalledTimes(1) 113 | expect(prisma.episode.update).toBeCalledWith( 114 | expect.objectContaining({ 115 | where: {id: episode.id}, 116 | data: input, 117 | }) 118 | ) 119 | 120 | expect(result).toEqual(episode) 121 | }) 122 | }) 123 | 124 | describe('delete()', () => { 125 | it('uses Prisma to delete an existing Episode', async () => { 126 | prisma.episode.delete.mockResolvedValueOnce(episode as PrismaEpisode) 127 | 128 | const result = await service.delete(episode.id) 129 | 130 | expect(prisma.episode.delete).toBeCalledTimes(1) 131 | expect(prisma.episode.delete).toBeCalledWith( 132 | expect.objectContaining({ 133 | where: {id: episode.id}, 134 | }) 135 | ) 136 | 137 | expect(result).toEqual(episode) 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episode-mutations.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, InputType, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {Episode} from './episode.model' 6 | 7 | @InputType() 8 | export class CreateEpisodeInput { 9 | @Field() 10 | title!: string 11 | 12 | @Field({nullable: true}) 13 | summary?: string 14 | 15 | @Field({nullable: true}) 16 | picture?: string 17 | 18 | @Field(() => GraphQLTypeJson, {nullable: true}) 19 | content?: Prisma.JsonValue 20 | 21 | @Field() 22 | showId!: string 23 | } 24 | 25 | @InputType() 26 | export class UpdateEpisodeInput { 27 | @Field({nullable: true}) 28 | title?: string 29 | 30 | @Field({nullable: true}) 31 | summary?: string 32 | 33 | @Field({nullable: true}) 34 | picture?: string 35 | 36 | @Field(() => GraphQLTypeJson, {nullable: true}) 37 | content?: Prisma.JsonValue 38 | 39 | @Field({nullable: true}) 40 | showId?: string 41 | } 42 | 43 | @ObjectType() 44 | export class MutateEpisodeResult { 45 | @Field(() => Episode, {nullable: true}) 46 | episode?: Episode 47 | } 48 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episode-queries.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Field, 3 | ID, 4 | InputType, 5 | Int, 6 | ObjectType, 7 | registerEnumType, 8 | } from '@nestjs/graphql' 9 | 10 | import {Episode} from './episode.model' 11 | 12 | @ObjectType() 13 | export class EpisodesPage { 14 | @Field(() => [Episode]) 15 | data!: Episode[] 16 | 17 | @Field(() => Int) 18 | count!: number 19 | 20 | @Field(() => Int) 21 | total!: number 22 | 23 | @Field(() => Int) 24 | page!: number 25 | 26 | @Field(() => Int) 27 | pageCount!: number 28 | } 29 | 30 | @InputType() 31 | export class EpisodeCondition { 32 | @Field(() => ID, {nullable: true}) 33 | id?: string 34 | 35 | @Field({nullable: true}) 36 | title?: string 37 | 38 | @Field({nullable: true}) 39 | summary?: string 40 | 41 | @Field({nullable: true}) 42 | picture?: string 43 | 44 | @Field({nullable: true}) 45 | showId?: string 46 | 47 | @Field(() => Date, {nullable: true}) 48 | createdAt?: Date 49 | 50 | @Field(() => Date, {nullable: true}) 51 | updatedAt?: Date 52 | } 53 | 54 | export enum EpisodesOrderBy { 55 | ID_ASC = 'ID_ASC', 56 | ID_DESC = 'ID_DESC', 57 | TITLE_ASC = 'TITLE_ASC', 58 | TITLE_DESC = 'TITLE_DESC', 59 | SUMMARY_ASC = 'SUMMARY_ASC', 60 | SUMMARY_DESC = 'SUMMARY_DESC', 61 | CREATED_AT_ASC = 'CREATED_AT_ASC', 62 | CREATED_AT_DESC = 'CREATED_AT_DESC', 63 | UPDATED_AT_ASC = 'UPDATED_AT_ASC', 64 | UPDATED_AT_DESC = 'UPDATED_AT_DESC', 65 | } 66 | 67 | registerEnumType(EpisodesOrderBy, { 68 | name: 'EpisodesOrderBy', 69 | }) 70 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episode.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, ID, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {Show} from '../show.model' 6 | 7 | @ObjectType() 8 | export class Episode { 9 | @Field(() => ID) 10 | id!: string 11 | 12 | @Field(() => String) 13 | title!: string 14 | 15 | @Field(() => String, {nullable: true}) 16 | summary?: string | null 17 | 18 | @Field(() => String, {nullable: true}) 19 | picture?: string | null 20 | 21 | @Field(() => GraphQLTypeJson, {nullable: true}) 22 | content?: Prisma.JsonValue 23 | 24 | @Field(() => String, {nullable: true}) 25 | showId?: string | null 26 | 27 | @Field(() => Show, {nullable: true}) 28 | show?: Show | null 29 | 30 | @Field() 31 | createdAt!: Date 32 | 33 | @Field() 34 | updatedAt!: Date 35 | } 36 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episode.roles.ts: -------------------------------------------------------------------------------- 1 | import {Permission, Role} from '@caster/roles/roles.types' 2 | 3 | /** 4 | * Permissions 5 | */ 6 | 7 | export const Chat: Permission = { 8 | key: 'EPISODE_CHAT', 9 | name: 'Episode Chat', 10 | description: 'Chat about an Episode', 11 | } 12 | 13 | export const ReadChat: Permission = { 14 | key: 'EPISODE_READ_CHAT', 15 | name: 'Read Episode Chat', 16 | description: 'Read chat Messages for an Episode', 17 | } 18 | 19 | /** 20 | * Roles 21 | */ 22 | 23 | export const Reader: Role = { 24 | key: 'EPISODE_READER', 25 | name: 'Episode Reader', 26 | description: 'Able to read chat Messages about a particular episode', 27 | permissions: [ReadChat], 28 | } 29 | 30 | export const Guest: Role = { 31 | key: 'EPISODE_GUEST', 32 | name: 'Episode Guest', 33 | description: 'Able to Chat about a particular episode', 34 | permissions: [Chat, ReadChat], 35 | } 36 | 37 | /** 38 | * Index 39 | */ 40 | 41 | export const EpisodeRoles = { 42 | roles: [Reader, Guest], 43 | permissions: [Chat, ReadChat], 44 | } 45 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episode.rules.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | 3 | import {Action, RuleBuilder, RuleEnhancer} from '@caster/authz/authz.types' 4 | import {RolesService} from '@caster/roles/roles.service' 5 | import {UserWithProfile} from '@caster/users/user.types' 6 | 7 | import {Chat, ReadChat} from './episode.roles' 8 | 9 | @Injectable() 10 | export class EpisodeRules implements RuleEnhancer { 11 | constructor(private readonly roles: RolesService) {} 12 | 13 | async forUser( 14 | user: UserWithProfile | undefined, 15 | {can}: RuleBuilder 16 | ): Promise { 17 | // Anonymous 18 | can(Action.Read, 'Episode') 19 | 20 | if (!user) { 21 | return 22 | } 23 | 24 | // Authenticated 25 | const profileId = user.profile?.id 26 | if (!profileId) { 27 | return 28 | } 29 | 30 | // Pull all the Permissions for this Profile in the Episode table 31 | const episodePermissions = await this.roles.getPermissionsForTable( 32 | profileId, 33 | 'Episode' 34 | ) 35 | 36 | // Iterate over the episodeIds returned and build rules for each Episode 37 | Object.keys(episodePermissions).forEach((episodeId) => { 38 | episodePermissions[episodeId].forEach((permission) => { 39 | switch (permission.key) { 40 | case Chat.key: 41 | return can(Action.Manage, 'Message', {episodeId, profileId}) 42 | case ReadChat.key: 43 | return can(Action.Read, 'Message', {episodeId}) 44 | } 45 | }) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episodes.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | 3 | import {EpisodesResolver} from './episodes.resolver' 4 | import {EpisodesService} from './episodes.service' 5 | 6 | @Module({ 7 | providers: [EpisodesResolver, EpisodesService], 8 | exports: [EpisodesService], 9 | }) 10 | export class EpisodesModule {} 11 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episodes.resolver.ts: -------------------------------------------------------------------------------- 1 | import {Episode} from '@prisma/client' 2 | import { 3 | BadRequestException, 4 | ForbiddenException, 5 | NotFoundException, 6 | UseGuards, 7 | } from '@nestjs/common' 8 | import {Args, ID, Int, Mutation, Query, Resolver} from '@nestjs/graphql' 9 | import {subject} from '@casl/ability' 10 | 11 | import {JwtGuard} from '@caster/authn/jwt.guard' 12 | import {Ability, AllowAnonymous} from '@caster/authz/authz.decorators' 13 | import {AuthzGuard} from '@caster/authz/authz.guard' 14 | import {AppAbility} from '@caster/authz/authz.types' 15 | import {UserWithProfile} from '@caster/users/user.types' 16 | import {RequestUser} from '@caster/users/user.decorators' 17 | import {fromOrderByInput} from '@caster/utils/prisma' 18 | 19 | import {Episode as EpisodeModel} from './episode.model' 20 | import {EpisodesService} from './episodes.service' 21 | import { 22 | EpisodeCondition, 23 | EpisodesOrderBy, 24 | EpisodesPage, 25 | } from './episode-queries.model' 26 | import { 27 | CreateEpisodeInput, 28 | UpdateEpisodeInput, 29 | MutateEpisodeResult, 30 | } from './episode-mutations.model' 31 | 32 | @Resolver(() => EpisodeModel) 33 | @UseGuards(JwtGuard, AuthzGuard) 34 | export class EpisodesResolver { 35 | constructor(private readonly service: EpisodesService) {} 36 | 37 | @Query(() => EpisodeModel, {nullable: true}) 38 | @AllowAnonymous() 39 | async getEpisode( 40 | @Args('id', {type: () => ID}) id: string 41 | ): Promise { 42 | const episode = await this.service.get(id) 43 | 44 | if (episode) { 45 | return episode 46 | } 47 | } 48 | 49 | @Query(() => EpisodesPage) 50 | @AllowAnonymous() 51 | async getManyEpisodes( 52 | @Args('where', {nullable: true}) where?: EpisodeCondition, 53 | @Args('orderBy', {nullable: true, type: () => [EpisodesOrderBy]}) 54 | orderBy?: EpisodesOrderBy[], 55 | @Args('pageSize', {type: () => Int, nullable: true}) pageSize?: number, 56 | @Args('page', {type: () => Int, nullable: true}) page?: number 57 | ): Promise { 58 | return this.service.getMany({ 59 | where, 60 | orderBy: fromOrderByInput(orderBy), 61 | pageSize, 62 | page, 63 | }) 64 | } 65 | 66 | @Mutation(() => MutateEpisodeResult) 67 | async createEpisode( 68 | @Args('input') input: CreateEpisodeInput, 69 | @RequestUser({require: true}) user: UserWithProfile, 70 | @Ability() ability: AppAbility 71 | ): Promise { 72 | if (!user.profile?.id) { 73 | throw new BadRequestException('User object did not come with a Profile') 74 | } 75 | 76 | if (ability.cannot('create', subject('Episode', input as Episode))) { 77 | throw new ForbiddenException() 78 | } 79 | 80 | const episode = await this.service.create(input) 81 | 82 | return {episode} 83 | } 84 | 85 | @Mutation(() => MutateEpisodeResult) 86 | async updateEpisode( 87 | @Args('id', {type: () => ID}) id: string, 88 | @Args('input') input: UpdateEpisodeInput, 89 | @Ability() ability: AppAbility 90 | ): Promise { 91 | const existing = await this.getExisting(id) 92 | 93 | if (ability.cannot('update', subject('Episode', existing))) { 94 | throw new ForbiddenException() 95 | } 96 | 97 | const episode = await this.service.update(id, input) 98 | 99 | return {episode} 100 | } 101 | 102 | @Mutation(() => Boolean) 103 | async deleteEpisode( 104 | @Args('id', {type: () => ID}) id: string, 105 | @Ability() ability: AppAbility 106 | ): Promise { 107 | const existing = await this.getExisting(id) 108 | 109 | if (ability.cannot('delete', subject('Episode', existing))) { 110 | throw new ForbiddenException() 111 | } 112 | 113 | await this.service.delete(id) 114 | 115 | return true 116 | } 117 | 118 | private getExisting = async (id: string) => { 119 | const existing = await this.service.get(id) 120 | if (!existing) { 121 | throw new NotFoundException() 122 | } 123 | 124 | return existing 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /libs/shows/src/episodes/episodes.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | import {Prisma, Episode} from '@prisma/client' 3 | import {PrismaService} from 'nestjs-prisma' 4 | 5 | import { 6 | getOffset, 7 | ManyResponse, 8 | paginateResponse, 9 | } from '@caster/utils/pagination' 10 | import {fixJsonInput, toUndefinedProps} from '@caster/utils/types' 11 | 12 | import {CreateEpisodeInput, UpdateEpisodeInput} from './episode-mutations.model' 13 | 14 | @Injectable() 15 | export class EpisodesService { 16 | constructor(private readonly prisma: PrismaService) {} 17 | 18 | async get(id: string): Promise { 19 | return this.prisma.episode.findFirst({ 20 | where: {id}, 21 | }) 22 | } 23 | 24 | async getMany(options: { 25 | where: Prisma.EpisodeWhereInput | undefined 26 | orderBy: Prisma.EpisodeOrderByWithRelationInput | undefined 27 | pageSize?: number 28 | page?: number 29 | }): Promise> { 30 | const {pageSize, page, ...rest} = options 31 | 32 | const total = await this.prisma.episode.count(rest) 33 | const profiles = await this.prisma.episode.findMany({ 34 | ...rest, 35 | ...getOffset(pageSize, page), 36 | }) 37 | 38 | return paginateResponse(profiles, { 39 | total, 40 | pageSize, 41 | page, 42 | }) 43 | } 44 | 45 | async create(input: CreateEpisodeInput): Promise { 46 | return this.prisma.episode.create({ 47 | data: toUndefinedProps(input), 48 | }) 49 | } 50 | 51 | async update(id: string, input: UpdateEpisodeInput): Promise { 52 | return this.prisma.episode.update({ 53 | where: {id}, 54 | data: fixJsonInput(input), 55 | }) 56 | } 57 | 58 | async delete(id: string): Promise { 59 | return this.prisma.episode.delete({ 60 | where: {id}, 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/shows/src/show-mutations.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, InputType, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {Show} from './show.model' 6 | 7 | @InputType() 8 | export class CreateShowInput { 9 | @Field() 10 | title!: string 11 | 12 | @Field({nullable: true}) 13 | summary?: string 14 | 15 | @Field({nullable: true}) 16 | picture?: string 17 | 18 | @Field(() => GraphQLTypeJson, {nullable: true}) 19 | content?: Prisma.JsonValue 20 | } 21 | 22 | @InputType() 23 | export class UpdateShowInput { 24 | @Field({nullable: true}) 25 | title?: string 26 | 27 | @Field({nullable: true}) 28 | summary?: string 29 | 30 | @Field({nullable: true}) 31 | picture?: string 32 | 33 | @Field(() => GraphQLTypeJson, {nullable: true}) 34 | content?: Prisma.JsonValue 35 | } 36 | 37 | @ObjectType() 38 | export class MutateShowResult { 39 | @Field(() => Show, {nullable: true}) 40 | show?: Show 41 | } 42 | -------------------------------------------------------------------------------- /libs/shows/src/show-queries.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Field, 3 | ID, 4 | InputType, 5 | Int, 6 | ObjectType, 7 | registerEnumType, 8 | } from '@nestjs/graphql' 9 | 10 | import {Show} from './show.model' 11 | 12 | @ObjectType() 13 | export class ShowsPage { 14 | @Field(() => [Show]) 15 | data!: Show[] 16 | 17 | @Field(() => Int) 18 | count!: number 19 | 20 | @Field(() => Int) 21 | total!: number 22 | 23 | @Field(() => Int) 24 | page!: number 25 | 26 | @Field(() => Int) 27 | pageCount!: number 28 | } 29 | 30 | @InputType() 31 | export class ShowCondition { 32 | @Field(() => ID, {nullable: true}) 33 | id?: string 34 | 35 | @Field({nullable: true}) 36 | title?: string 37 | 38 | @Field({nullable: true}) 39 | summary?: string 40 | 41 | @Field({nullable: true}) 42 | picture?: string 43 | 44 | @Field(() => Date, {nullable: true}) 45 | createdAt?: Date 46 | 47 | @Field(() => Date, {nullable: true}) 48 | updatedAt?: Date 49 | } 50 | 51 | export enum ShowsOrderBy { 52 | ID_ASC = 'ID_ASC', 53 | ID_DESC = 'ID_DESC', 54 | TITLE_ASC = 'TITLE_ASC', 55 | TITLE_DESC = 'TITLE_DESC', 56 | SUMMARY_ASC = 'SUMMARY_ASC', 57 | SUMMARY_DESC = 'SUMMARY_DESC', 58 | CREATED_AT_ASC = 'CREATED_AT_ASC', 59 | CREATED_AT_DESC = 'CREATED_AT_DESC', 60 | UPDATED_AT_ASC = 'UPDATED_AT_ASC', 61 | UPDATED_AT_DESC = 'UPDATED_AT_DESC', 62 | } 63 | 64 | registerEnumType(ShowsOrderBy, { 65 | name: 'ShowsOrderBy', 66 | }) 67 | -------------------------------------------------------------------------------- /libs/shows/src/show.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, ID, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | @ObjectType() 6 | export class Show { 7 | @Field(() => ID) 8 | id!: string 9 | 10 | @Field() 11 | title!: string 12 | 13 | @Field(() => String, {nullable: true}) 14 | summary?: string | null 15 | 16 | @Field(() => String, {nullable: true}) 17 | picture?: string | null 18 | 19 | @Field(() => GraphQLTypeJson, {nullable: true}) 20 | content?: Prisma.JsonValue | null 21 | 22 | @Field() 23 | createdAt!: Date 24 | 25 | @Field() 26 | updatedAt!: Date 27 | } 28 | -------------------------------------------------------------------------------- /libs/shows/src/show.roles.ts: -------------------------------------------------------------------------------- 1 | import {Permission, Role} from '@caster/roles/roles.types' 2 | 3 | /** 4 | * Permissions 5 | */ 6 | 7 | export const Update: Permission = { 8 | key: 'SHOW_UPDATE', 9 | name: 'Update Show', 10 | description: 'Update details about a particular Show', 11 | } 12 | 13 | export const Delete: Permission = { 14 | key: 'SHOW_DELETE', 15 | name: 'Delete Show', 16 | description: 'Delete a particular Show', 17 | } 18 | 19 | export const ManageEpisodes: Permission = { 20 | key: 'SHOW_MANAGE_EPISODES', 21 | name: 'Manage Show Episodes', 22 | description: 'Create, update, and delete any Episodes for this Show', 23 | } 24 | 25 | export const ManageRoles: Permission = { 26 | key: 'SHOW_MANAGE_ROLES', 27 | name: 'Manage Show Roles', 28 | description: 'Grant or revoke User Roles for a particular Show', 29 | } 30 | 31 | /** 32 | * Roles 33 | */ 34 | 35 | export const Manager: Role = { 36 | key: 'SHOW_MANAGER', 37 | name: 'Show Manager', 38 | description: 'Able to update existing Shows and manage Episodes', 39 | permissions: [Update, ManageEpisodes], 40 | } 41 | 42 | export const Admin: Role = { 43 | key: 'SHOW_ADMIN', 44 | name: 'Show Admin', 45 | description: 'Able to fully control a particular Show', 46 | permissions: [...Manager.permissions, Delete, ManageRoles], 47 | } 48 | 49 | /** 50 | * Index 51 | */ 52 | 53 | export const ShowRoles = { 54 | roles: [Manager, Admin], 55 | permissions: [Update, Delete, ManageEpisodes, ManageRoles], 56 | } 57 | -------------------------------------------------------------------------------- /libs/shows/src/show.rules.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | 3 | import {Action, RuleBuilder, RuleEnhancer} from '@caster/authz/authz.types' 4 | import {RolesService} from '@caster/roles/roles.service' 5 | import {UserWithProfile} from '@caster/users/user.types' 6 | 7 | import {Update, Delete, ManageEpisodes, ManageRoles} from './show.roles' 8 | 9 | @Injectable() 10 | export class ShowRules implements RuleEnhancer { 11 | constructor(private readonly roles: RolesService) {} 12 | 13 | async forUser( 14 | user: UserWithProfile | undefined, 15 | {can}: RuleBuilder 16 | ): Promise { 17 | // Anonymous 18 | can(Action.Read, 'Show') 19 | 20 | if (!user) { 21 | return 22 | } 23 | 24 | // Authenticated 25 | can(Action.Create, 'Show') 26 | 27 | const profileId = user.profile?.id 28 | if (!profileId) { 29 | return 30 | } 31 | 32 | // Pull all the Permissions for this Profile in the Show table 33 | const showPermissions = await this.roles.getPermissionsForTable( 34 | profileId, 35 | 'Show' 36 | ) 37 | 38 | // Iterate over the showIds returned and build rules for each Show 39 | Object.keys(showPermissions).forEach((showId) => { 40 | showPermissions[showId].forEach((permission) => { 41 | switch (permission.key) { 42 | case Update.key: 43 | return can(Action.Update, 'Show', {id: showId}) 44 | case Delete.key: 45 | return can(Action.Delete, 'Show', {id: showId}) 46 | case ManageEpisodes.key: 47 | return can(Action.Manage, 'Episode', {showId}) 48 | case ManageRoles.key: 49 | return can(Action.Manage, 'RoleGrant', { 50 | subjectTable: 'Show', 51 | subjectId: showId, 52 | }) 53 | } 54 | }) 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /libs/shows/src/show.utils.ts: -------------------------------------------------------------------------------- 1 | import {Prisma} from '@prisma/client' 2 | 3 | import {ShowCondition} from './show-queries.model' 4 | import {UpdateShowInput} from './show-mutations.model' 5 | 6 | const requiredFields = ['id', 'createdAt', 'updatedAt', 'title'] as const 7 | 8 | export const fromShowCondition = ( 9 | where?: ShowCondition 10 | ): Prisma.ShowWhereInput | undefined => { 11 | if (!where) { 12 | return undefined 13 | } 14 | 15 | /** 16 | * These required fields cannot be set to `null`, they can only be `undefined` in order for Prisma 17 | * to ignore them. Force them to `undefined` if they are `null`. 18 | */ 19 | return requiredFields.reduce( 20 | (memo, field) => ({...memo, [field]: memo[field] || undefined}), 21 | where as Prisma.ShowWhereInput 22 | ) 23 | } 24 | 25 | export const fromShowInput = ( 26 | input: UpdateShowInput 27 | ): Prisma.ShowUpdateInput => { 28 | // Convert any `null`s in required fields to `undefined`s, for compatibility with Prisma 29 | return requiredFields.reduce((memo, field) => { 30 | return {...memo, [field]: memo[field] || undefined} 31 | }, input as Prisma.ShowUpdateInput) 32 | } 33 | -------------------------------------------------------------------------------- /libs/shows/src/shows.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | 3 | import {ShowsResolver} from './shows.resolver' 4 | import {ShowsService} from './shows.service' 5 | 6 | @Module({ 7 | providers: [ShowsResolver, ShowsService], 8 | exports: [ShowsService], 9 | }) 10 | export class ShowsModule {} 11 | -------------------------------------------------------------------------------- /libs/shows/src/shows.resolver.ts: -------------------------------------------------------------------------------- 1 | import {Show} from '@prisma/client' 2 | import { 3 | BadRequestException, 4 | ForbiddenException, 5 | NotFoundException, 6 | UseGuards, 7 | } from '@nestjs/common' 8 | import {Args, ID, Int, Mutation, Query, Resolver} from '@nestjs/graphql' 9 | import {subject} from '@casl/ability' 10 | 11 | import {JwtGuard} from '@caster/authn/jwt.guard' 12 | import {AllowAnonymous, Ability} from '@caster/authz/authz.decorators' 13 | import {AuthzGuard} from '@caster/authz/authz.guard' 14 | import {AppAbility} from '@caster/authz/authz.types' 15 | import {RolesService} from '@caster/roles/roles.service' 16 | import {RequestUser} from '@caster/users/user.decorators' 17 | import {UserWithProfile} from '@caster/users/user.types' 18 | import {fromOrderByInput} from '@caster/utils/prisma' 19 | 20 | import {Show as ShowModel} from './show.model' 21 | import {ShowsService} from './shows.service' 22 | import {Admin} from './show.roles' 23 | import {ShowCondition, ShowsOrderBy, ShowsPage} from './show-queries.model' 24 | import { 25 | CreateShowInput, 26 | UpdateShowInput, 27 | MutateShowResult, 28 | } from './show-mutations.model' 29 | 30 | @Resolver(() => ShowModel) 31 | @UseGuards(JwtGuard, AuthzGuard) 32 | export class ShowsResolver { 33 | constructor( 34 | private readonly service: ShowsService, 35 | private readonly roles: RolesService 36 | ) {} 37 | 38 | @Query(() => ShowModel, {nullable: true}) 39 | @AllowAnonymous() 40 | async getShow( 41 | @Args('id', {type: () => ID}) id: string 42 | ): Promise { 43 | const show = await this.service.get(id) 44 | 45 | if (show) { 46 | return show 47 | } 48 | } 49 | 50 | @Query(() => ShowsPage) 51 | @AllowAnonymous() 52 | async getManyShows( 53 | @Args('where', {nullable: true}) where?: ShowCondition, 54 | @Args('orderBy', {nullable: true, type: () => [ShowsOrderBy]}) 55 | orderBy?: ShowsOrderBy[], 56 | @Args('pageSize', {type: () => Int, nullable: true}) pageSize?: number, 57 | @Args('page', {type: () => Int, nullable: true}) page?: number 58 | ): Promise { 59 | return this.service.getMany({ 60 | where, 61 | orderBy: fromOrderByInput(orderBy), 62 | pageSize, 63 | page, 64 | }) 65 | } 66 | 67 | @Mutation(() => MutateShowResult) 68 | async createShow( 69 | @Args('input') input: CreateShowInput, 70 | @RequestUser({require: true}) user: UserWithProfile, 71 | @Ability() ability: AppAbility 72 | ): Promise { 73 | if (!user.profile?.id) { 74 | throw new BadRequestException('User object did not come with a Profile') 75 | } 76 | 77 | if (ability.cannot('create', subject('Show', input as Show))) { 78 | throw new ForbiddenException() 79 | } 80 | 81 | const show = await this.service.create(input) 82 | 83 | // Grant the Admin role to the creator 84 | await this.roles.grantRoles( 85 | user.profile?.id, 86 | {table: 'Show', id: show.id}, 87 | [Admin.key] 88 | ) 89 | 90 | return {show} 91 | } 92 | 93 | @Mutation(() => MutateShowResult) 94 | async updateShow( 95 | @Args('id', {type: () => ID}) id: string, 96 | @Args('input') input: UpdateShowInput, 97 | @Ability() ability: AppAbility 98 | ): Promise { 99 | const existing = await this.getExisting(id) 100 | 101 | if (ability.cannot('update', subject('Show', existing))) { 102 | throw new ForbiddenException() 103 | } 104 | 105 | const show = await this.service.update(id, input) 106 | 107 | return {show} 108 | } 109 | 110 | @Mutation(() => Boolean) 111 | async deleteShow( 112 | @Args('id', {type: () => ID}) id: string, 113 | @Ability() ability: AppAbility 114 | ): Promise { 115 | const existing = await this.getExisting(id) 116 | 117 | if (ability.cannot('delete', subject('Show', existing))) { 118 | throw new ForbiddenException() 119 | } 120 | 121 | await this.service.delete(id) 122 | 123 | return true 124 | } 125 | 126 | private getExisting = async (id: string) => { 127 | const existing = await this.service.get(id) 128 | if (!existing) { 129 | throw new NotFoundException() 130 | } 131 | 132 | return existing 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /libs/shows/src/shows.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | import {Prisma, Show} from '@prisma/client' 3 | import {PrismaService} from 'nestjs-prisma' 4 | 5 | import { 6 | getOffset, 7 | ManyResponse, 8 | paginateResponse, 9 | } from '@caster/utils/pagination' 10 | import {fixJsonInput, toUndefinedProps} from '@caster/utils/types' 11 | 12 | import {CreateShowInput, UpdateShowInput} from './show-mutations.model' 13 | 14 | @Injectable() 15 | export class ShowsService { 16 | constructor(private readonly prisma: PrismaService) {} 17 | 18 | async get(id: string): Promise { 19 | return this.prisma.show.findFirst({ 20 | where: {id}, 21 | }) 22 | } 23 | 24 | async getMany(options: { 25 | where: Prisma.ShowWhereInput | undefined 26 | orderBy: Prisma.ShowOrderByWithRelationInput | undefined 27 | pageSize?: number 28 | page?: number 29 | }): Promise> { 30 | const {pageSize, page, ...rest} = options 31 | 32 | const total = await this.prisma.show.count(rest) 33 | const profiles = await this.prisma.show.findMany({ 34 | ...rest, 35 | ...getOffset(pageSize, page), 36 | }) 37 | 38 | return paginateResponse(profiles, { 39 | total, 40 | pageSize, 41 | page, 42 | }) 43 | } 44 | 45 | async create(input: CreateShowInput): Promise { 46 | return this.prisma.show.create({ 47 | data: toUndefinedProps(input), 48 | }) 49 | } 50 | 51 | async update(id: string, input: UpdateShowInput): Promise { 52 | return this.prisma.show.update({ 53 | where: {id}, 54 | data: fixJsonInput(input), 55 | }) 56 | } 57 | 58 | async delete(id: string): Promise { 59 | return this.prisma.show.delete({ 60 | where: {id}, 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/shows/test/factories/episodes.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import {Episode} from '../../src/episodes/episode.model' 4 | import {CreateEpisodeInput} from '../../src/episodes/episode-mutations.model' 5 | 6 | export const makeCreateInput = ( 7 | overrides?: Partial 8 | ): CreateEpisodeInput => { 9 | return { 10 | title: faker.hacker.noun(), 11 | summary: faker.lorem.paragraphs(1), 12 | picture: faker.image.imageUrl(), 13 | content: {}, 14 | showId: faker.datatype.uuid(), 15 | ...overrides, 16 | } 17 | } 18 | 19 | export const make = (overrides?: Partial): Episode => { 20 | return { 21 | id: faker.datatype.uuid(), 22 | createdAt: faker.date.recent(), 23 | updatedAt: faker.date.recent(), 24 | ...makeCreateInput(overrides as Partial), 25 | ...overrides, 26 | } 27 | } 28 | 29 | export const EpisodeFactory = {make, makeCreateInput} 30 | -------------------------------------------------------------------------------- /libs/shows/test/factories/show.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import {Show} from '../../src/show.model' 4 | import {CreateShowInput} from '../../src/show-mutations.model' 5 | 6 | export const makeCreateInput = ( 7 | overrides?: Partial 8 | ): CreateShowInput => { 9 | return { 10 | title: faker.hacker.noun(), 11 | summary: faker.lorem.paragraphs(1), 12 | picture: faker.image.imageUrl(), 13 | content: {}, 14 | ...overrides, 15 | } 16 | } 17 | 18 | export const make = (overrides?: Partial): Show => { 19 | return { 20 | id: faker.datatype.uuid(), 21 | createdAt: faker.date.recent(), 22 | updatedAt: faker.date.recent(), 23 | ...makeCreateInput(overrides as Partial), 24 | ...overrides, 25 | } 26 | } 27 | 28 | export const ShowFactory = {make, makeCreateInput} 29 | -------------------------------------------------------------------------------- /libs/shows/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/shows/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/shows/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/users/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/users/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "rules": { 5 | "@nrwl/nx/enforce-module-boundaries": [ 6 | "error", 7 | { 8 | "allow": ["@caster/authz"] 9 | } 10 | ] 11 | }, 12 | "overrides": [ 13 | { 14 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.ts", "*.tsx"], 19 | "rules": {} 20 | }, 21 | { 22 | "files": ["*.js", "*.jsx"], 23 | "rules": {} 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /libs/users/README.md: -------------------------------------------------------------------------------- 1 | # users 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test users` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/users/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'users', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/users', 15 | setupFilesAfterEnv: ['../../jest.setup.ts'], 16 | } 17 | -------------------------------------------------------------------------------- /libs/users/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/users", 3 | "sourceRoot": "libs/users/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/users/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/users"], 15 | "options": { 16 | "jestConfig": "libs/users/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/users/src/__tests__/user.rules.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {AbilityBuilder, subject} from '@casl/ability' 3 | 4 | import {Action, AppAbility} from '@caster/authz/authz.types' 5 | 6 | import {ProfileFactory} from '../../test/factories/profile.factory' 7 | import {UserFactory} from '../../test/factories/user.factory' 8 | import {UserRules} from '../user.rules' 9 | import {UserWithProfile} from '../user.types' 10 | 11 | describe('UserRules', () => { 12 | let rules: UserRules 13 | 14 | const profile = ProfileFactory.make() 15 | const user = UserFactory.make({profile}) as UserWithProfile 16 | 17 | beforeAll(async () => { 18 | const testModule = await Test.createTestingModule({ 19 | providers: [UserRules], 20 | }).compile() 21 | 22 | rules = testModule.get(UserRules) 23 | }) 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks() 27 | }) 28 | 29 | describe('forUser()', () => { 30 | it('adds rules for authenticated users', async () => { 31 | const builder = new AbilityBuilder(AppAbility) 32 | 33 | await rules.forUser(user, builder) 34 | 35 | const ability = builder.build() 36 | 37 | expect(ability.can(Action.Create, subject('User', user))).toBe(true) 38 | 39 | expect( 40 | ability.can( 41 | Action.Create, 42 | subject('User', {...user, username: 'other-username'}) 43 | ) 44 | ).toBe(false) 45 | 46 | expect(ability.can(Action.Read, subject('User', user))).toBe(true) 47 | 48 | expect( 49 | ability.can( 50 | Action.Read, 51 | subject('User', {...user, username: 'other-username'}) 52 | ) 53 | ).toBe(false) 54 | 55 | expect(ability.can(Action.Update, subject('User', user))).toBe(true) 56 | 57 | expect( 58 | ability.can( 59 | Action.Update, 60 | subject('User', {...user, username: 'other-username'}) 61 | ) 62 | ).toBe(false) 63 | }) 64 | 65 | it("doesn't add rules for anonymous users", async () => { 66 | const builder = new AbilityBuilder(AppAbility) 67 | 68 | await rules.forUser(undefined, builder) 69 | 70 | const ability = builder.build() 71 | 72 | expect(ability.can(Action.Create, subject('User', user))).toBe(false) 73 | expect(ability.can(Action.Read, subject('User', user))).toBe(false) 74 | expect(ability.can(Action.Update, subject('User', user))).toBe(false) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /libs/users/src/__tests__/users.resolver.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {mockDeep} from 'jest-mock-extended' 3 | 4 | import {AbilityFactory} from '@caster/authz/ability.factory' 5 | import {AppAbility} from '@caster/authz/authz.types' 6 | 7 | import {UserFactory} from '../../test/factories/user.factory' 8 | import {UsersResolver} from '../users.resolver' 9 | import {UsersService} from '../users.service' 10 | import {UserWithProfile} from '../user.types' 11 | 12 | describe('UsersResolver', () => { 13 | let resolver: UsersResolver 14 | 15 | const service = mockDeep() 16 | const abilityFactory = mockDeep() 17 | const ability = mockDeep() 18 | 19 | // Default to "true" 20 | ability.can.mockReturnValue(true) 21 | 22 | const username = 'test-username' 23 | const user = UserFactory.make({username}) as UserWithProfile 24 | 25 | beforeAll(async () => { 26 | const testModule = await Test.createTestingModule({ 27 | providers: [ 28 | {provide: UsersService, useValue: service}, 29 | {provide: AbilityFactory, useValue: abilityFactory}, 30 | UsersResolver, 31 | ], 32 | }).compile() 33 | 34 | resolver = testModule.get(UsersResolver) 35 | }) 36 | 37 | afterEach(() => { 38 | jest.clearAllMocks() 39 | }) 40 | 41 | describe('getCurrentUser()', () => { 42 | it('uses the RequestUser decorator to find the User by username', async () => { 43 | const result = await resolver.getCurrentUser(username, user) 44 | 45 | expect(result).toEqual(user) 46 | }) 47 | }) 48 | 49 | describe('getOrCreateCurrentUser()', () => { 50 | it('uses the RequestUsers decorator to get a User if one is found for the given username', async () => { 51 | const result = await resolver.getOrCreateCurrentUser({}, username, user) 52 | 53 | expect(service.create).not.toBeCalled() 54 | 55 | expect(result).toEqual({user}) 56 | }) 57 | 58 | it('uses the UsersService to create a User if none is found for the given username', async () => { 59 | service.create.mockResolvedValueOnce(user) 60 | 61 | await resolver.getOrCreateCurrentUser({}, username) 62 | 63 | expect(service.create).toBeCalledTimes(1) 64 | expect(service.create).toBeCalledWith({username}) 65 | }) 66 | }) 67 | 68 | describe('updateCurrentUser()', () => { 69 | it('uses the UsersService to update an existing User', async () => { 70 | const input = {isActive: false} 71 | 72 | service.update.mockResolvedValueOnce(user) 73 | 74 | const result = await resolver.updateCurrentUser(input, user) 75 | 76 | expect(service.update).toBeCalledTimes(1) 77 | expect(service.update).toBeCalledWith(user.id, input) 78 | 79 | expect(result).toEqual({user}) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /libs/users/src/__tests__/users.service.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {mockDeep} from 'jest-mock-extended' 3 | import {PrismaService} from 'nestjs-prisma' 4 | 5 | import {UserFactory} from '../../test/factories/user.factory' 6 | import {CreateUserInput, UpdateUserInput} from '../user-mutations.model' 7 | import {UsersService} from '../users.service' 8 | 9 | type Input = CreateUserInput & {username: string} 10 | 11 | describe('UsersService', () => { 12 | let service: UsersService 13 | 14 | const prisma = mockDeep() 15 | 16 | const username = 'test-username' 17 | const user = UserFactory.make({username}) 18 | 19 | beforeAll(async () => { 20 | const testModule = await Test.createTestingModule({ 21 | providers: [{provide: PrismaService, useValue: prisma}, UsersService], 22 | }).compile() 23 | 24 | service = testModule.get(UsersService) 25 | }) 26 | 27 | afterEach(() => { 28 | jest.resetAllMocks() 29 | }) 30 | 31 | describe('get()', () => { 32 | it('uses Prisma to find the first matching User by id', async () => { 33 | prisma.user.findFirst.mockResolvedValueOnce(user) 34 | 35 | const result = await service.get(user.id) 36 | 37 | expect(prisma.user.findFirst).toBeCalledTimes(1) 38 | expect(prisma.user.findFirst).toBeCalledWith( 39 | expect.objectContaining({ 40 | where: {id: user.id}, 41 | }) 42 | ) 43 | 44 | expect(result).toEqual(user) 45 | }) 46 | }) 47 | 48 | describe('getByUsername()', () => { 49 | it('uses Prisma to find the first matching User by username', async () => { 50 | prisma.user.findFirst.mockResolvedValueOnce(user) 51 | 52 | const result = await service.getByUsername(username) 53 | 54 | expect(prisma.user.findFirst).toBeCalledTimes(1) 55 | expect(prisma.user.findFirst).toBeCalledWith( 56 | expect.objectContaining({ 57 | where: {username}, 58 | }) 59 | ) 60 | 61 | expect(result).toEqual(user) 62 | }) 63 | }) 64 | 65 | describe('create()', () => { 66 | it('uses Prisma to create a new User', async () => { 67 | prisma.user.create.mockResolvedValueOnce(user) 68 | 69 | const input: Input = {username} 70 | 71 | const result = await service.create(input) 72 | 73 | expect(prisma.user.create).toBeCalledTimes(1) 74 | expect(prisma.user.create).toBeCalledWith( 75 | expect.objectContaining({ 76 | data: input, 77 | }) 78 | ) 79 | 80 | expect(result).toEqual(user) 81 | }) 82 | 83 | it('creates the Profile inline if input is provided', async () => { 84 | prisma.user.create.mockResolvedValueOnce(user) 85 | 86 | const input: Input = { 87 | username, 88 | profile: {email: 'test@email.com'}, 89 | } 90 | 91 | await service.create(input) 92 | 93 | expect(prisma.user.create).toBeCalledTimes(1) 94 | expect(prisma.user.create).toBeCalledWith( 95 | expect.objectContaining({ 96 | data: { 97 | ...input, 98 | profile: {create: input.profile}, 99 | }, 100 | }) 101 | ) 102 | }) 103 | }) 104 | 105 | describe('update()', () => { 106 | it('uses Prisma to update an existing User', async () => { 107 | prisma.user.update.mockResolvedValueOnce(user) 108 | 109 | const input: UpdateUserInput = {isActive: false} 110 | 111 | const result = await service.update(user.id, input) 112 | 113 | expect(prisma.user.update).toBeCalledTimes(1) 114 | expect(prisma.user.update).toBeCalledWith( 115 | expect.objectContaining({ 116 | where: {id: user.id}, 117 | data: input, 118 | }) 119 | ) 120 | 121 | expect(result).toEqual(user) 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /libs/users/src/profiles/__tests__/profiles.service.test.ts: -------------------------------------------------------------------------------- 1 | import {Test} from '@nestjs/testing' 2 | import {Profile as PrismaProfile} from '@prisma/client' 3 | import {mockDeep} from 'jest-mock-extended' 4 | import {PrismaService} from 'nestjs-prisma' 5 | 6 | import {UserFactory} from '../../../test/factories/user.factory' 7 | import {ProfileFactory} from '../../../test/factories/profile.factory' 8 | import { 9 | CreateProfileInput, 10 | UpdateProfileInput, 11 | } from '../profile-mutations.model' 12 | import {ProfilesService} from '../profiles.service' 13 | 14 | describe('ProfilesService', () => { 15 | let service: ProfilesService 16 | 17 | const prisma = mockDeep() 18 | 19 | const username = 'test-username' 20 | const email = 'test@email.com' 21 | const user = UserFactory.make({username}) 22 | const profile = ProfileFactory.make({user, userId: user.id, email}) 23 | 24 | beforeAll(async () => { 25 | const testModule = await Test.createTestingModule({ 26 | providers: [{provide: PrismaService, useValue: prisma}, ProfilesService], 27 | }).compile() 28 | 29 | service = testModule.get(ProfilesService) 30 | }) 31 | 32 | afterEach(() => { 33 | jest.resetAllMocks() 34 | }) 35 | 36 | describe('get()', () => { 37 | it('uses Prisma to find the first matching Profile by id', async () => { 38 | prisma.profile.findFirst.mockResolvedValueOnce(profile as PrismaProfile) 39 | 40 | const result = await service.get(profile.id) 41 | 42 | expect(prisma.profile.findFirst).toBeCalledTimes(1) 43 | expect(prisma.profile.findFirst).toBeCalledWith( 44 | expect.objectContaining({ 45 | where: {id: profile.id}, 46 | }) 47 | ) 48 | 49 | expect(result).toEqual(profile) 50 | }) 51 | }) 52 | 53 | describe('getMany()', () => { 54 | it('uses Prisma to find many Profiles', async () => { 55 | prisma.profile.count.mockResolvedValueOnce(1) 56 | prisma.profile.findMany.mockResolvedValueOnce([profile as PrismaProfile]) 57 | 58 | const expected = { 59 | data: [profile], 60 | count: 1, 61 | total: 1, 62 | page: 1, 63 | pageCount: 1, 64 | } 65 | 66 | const result = await service.getMany({ 67 | where: {email}, 68 | orderBy: { 69 | id: 'asc', 70 | }, 71 | }) 72 | 73 | expect(prisma.profile.findMany).toBeCalledTimes(1) 74 | expect(prisma.profile.findMany).toBeCalledWith( 75 | expect.objectContaining({ 76 | where: {email}, 77 | orderBy: { 78 | id: 'asc', 79 | }, 80 | }) 81 | ) 82 | 83 | expect(result).toEqual(expected) 84 | }) 85 | }) 86 | 87 | describe('create()', () => { 88 | it('uses Prisma to create a new Profile', async () => { 89 | prisma.profile.create.mockResolvedValueOnce(profile as PrismaProfile) 90 | 91 | const input: CreateProfileInput = {email, userId: user.id} 92 | 93 | const result = await service.create(input) 94 | 95 | expect(prisma.profile.create).toBeCalledTimes(1) 96 | expect(prisma.profile.create).toBeCalledWith( 97 | expect.objectContaining({ 98 | data: { 99 | ...input, 100 | userId: undefined, 101 | user: { 102 | connect: {id: user.id}, 103 | }, 104 | }, 105 | }) 106 | ) 107 | 108 | expect(result).toEqual(profile) 109 | }) 110 | }) 111 | 112 | describe('update()', () => { 113 | it('uses Prisma to update an existing Profile', async () => { 114 | prisma.profile.update.mockResolvedValueOnce(profile as PrismaProfile) 115 | 116 | const input: UpdateProfileInput = {displayName: 'Test Display Name'} 117 | 118 | const result = await service.update(profile.id, input) 119 | 120 | expect(prisma.profile.update).toBeCalledTimes(1) 121 | expect(prisma.profile.update).toBeCalledWith( 122 | expect.objectContaining({ 123 | where: {id: profile.id}, 124 | data: input, 125 | }) 126 | ) 127 | 128 | expect(result).toEqual(profile) 129 | }) 130 | }) 131 | 132 | describe('delete()', () => { 133 | it('uses Prisma to delete an existing Profile', async () => { 134 | prisma.profile.delete.mockResolvedValueOnce(profile as PrismaProfile) 135 | 136 | const result = await service.delete(profile.id) 137 | 138 | expect(prisma.profile.delete).toBeCalledTimes(1) 139 | expect(prisma.profile.delete).toBeCalledWith( 140 | expect.objectContaining({ 141 | where: {id: profile.id}, 142 | }) 143 | ) 144 | 145 | expect(result).toEqual(profile) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profile-mutations.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, InputType, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {Profile} from './profile.model' 6 | 7 | @InputType() 8 | export class CreateProfileInput { 9 | @Field() 10 | email!: string 11 | 12 | @Field({nullable: true}) 13 | displayName?: string 14 | 15 | @Field({nullable: true}) 16 | picture?: string 17 | 18 | @Field(() => GraphQLTypeJson, {nullable: true}) 19 | content?: Prisma.JsonValue 20 | 21 | @Field() 22 | userId!: string 23 | } 24 | 25 | @InputType() 26 | export class UpdateProfileInput { 27 | @Field({nullable: true}) 28 | email?: string 29 | 30 | @Field({nullable: true}) 31 | displayName?: string 32 | 33 | @Field({nullable: true}) 34 | picture?: string 35 | 36 | @Field(() => GraphQLTypeJson, {nullable: true}) 37 | content?: Prisma.JsonValue 38 | 39 | @Field({nullable: true}) 40 | userId?: string 41 | } 42 | 43 | @ObjectType() 44 | export class MutateProfileResult { 45 | @Field(() => Profile, {nullable: true}) 46 | profile?: Profile 47 | } 48 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profile-queries.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Field, 3 | ID, 4 | InputType, 5 | Int, 6 | ObjectType, 7 | registerEnumType, 8 | } from '@nestjs/graphql' 9 | 10 | import {Profile} from './profile.model' 11 | 12 | @ObjectType() 13 | export class ProfilesPage { 14 | @Field(() => [Profile]) 15 | data!: Profile[] 16 | 17 | @Field(() => Int) 18 | count!: number 19 | 20 | @Field(() => Int) 21 | total!: number 22 | 23 | @Field(() => Int) 24 | page!: number 25 | 26 | @Field(() => Int) 27 | pageCount!: number 28 | } 29 | 30 | @InputType() 31 | export class ProfileCondition { 32 | @Field(() => ID, {nullable: true}) 33 | id?: string 34 | 35 | @Field({nullable: true}) 36 | email?: string 37 | 38 | @Field({nullable: true}) 39 | displayName?: string 40 | 41 | @Field({nullable: true}) 42 | picture?: string 43 | 44 | @Field(() => ID, {nullable: true}) 45 | userId?: string 46 | 47 | @Field(() => Date, {nullable: true}) 48 | createdAt?: Date 49 | 50 | @Field(() => Date, {nullable: true}) 51 | updatedAt?: Date 52 | } 53 | 54 | export enum ProfilesOrderBy { 55 | ID_ASC = 'ID_ASC', 56 | ID_DESC = 'ID_DESC', 57 | EMAIL_ASC = 'EMAIL_ASC', 58 | EMAIL_DESC = 'EMAIL_DESC', 59 | DISPLAY_NAME_ASC = 'DISPLAY_NAME_ASC', 60 | DISPLAY_NAME_DESC = 'DISPLAY_NAME_DESC', 61 | CREATED_AT_ASC = 'CREATED_AT_ASC', 62 | CREATED_AT_DESC = 'CREATED_AT_DESC', 63 | UPDATED_AT_ASC = 'UPDATED_AT_ASC', 64 | UPDATED_AT_DESC = 'UPDATED_AT_DESC', 65 | } 66 | 67 | registerEnumType(ProfilesOrderBy, { 68 | name: 'ProfilesOrderBy', 69 | }) 70 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profile.model.ts: -------------------------------------------------------------------------------- 1 | import GraphQLTypeJson from 'graphql-type-json' 2 | import {Field, ID, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {User} from '../user.model' 6 | 7 | @ObjectType() 8 | export class Profile { 9 | @Field(() => ID) 10 | id!: string 11 | 12 | // Nullable because this field may be censored for unauthorized users 13 | @Field(() => String, {nullable: true}) 14 | email?: string | null 15 | 16 | @Field(() => String, {nullable: true}) 17 | displayName?: string | null 18 | 19 | @Field(() => String, {nullable: true}) 20 | picture?: string | null 21 | 22 | @Field(() => GraphQLTypeJson, {nullable: true}) 23 | content?: Prisma.JsonValue 24 | 25 | @Field(() => String, {nullable: true}) 26 | city?: string | null 27 | 28 | @Field(() => String, {nullable: true}) 29 | stateProvince?: string | null 30 | 31 | @Field(() => String, {nullable: true}) 32 | userId?: string | null 33 | 34 | @Field(() => User, {nullable: true}) 35 | user?: User | null 36 | 37 | @Field() 38 | createdAt!: Date 39 | 40 | @Field() 41 | updatedAt!: Date 42 | } 43 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profile.rules.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | 3 | import {Action, RuleBuilder, RuleEnhancer} from '@caster/authz/authz.types' 4 | import {UserWithProfile} from '../user.types' 5 | 6 | @Injectable() 7 | export class ProfileRules implements RuleEnhancer { 8 | async forUser(user: UserWithProfile | undefined, {can, cannot}: RuleBuilder) { 9 | // Anonymous 10 | can(Action.Read, 'Profile') 11 | cannot(Action.Read, 'Profile', ['email', 'userId', 'user']) 12 | 13 | if (user) { 14 | // Same user 15 | can(Action.Manage, 'Profile', {userId: user.id}) 16 | cannot(Action.Update, 'Profile', ['userId', 'user']) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profile.utils.ts: -------------------------------------------------------------------------------- 1 | import {Prisma, Profile, User} from '@prisma/client' 2 | import {PermittedFieldsOptions} from '@casl/ability/extra' 3 | 4 | import {AppAbility} from '@caster/authz/authz.types' 5 | 6 | export type ProfileWithUser = Profile & {user: User | null} 7 | 8 | export const isOwner = (profile: ProfileWithUser, username?: string) => 9 | username && profile.user && username === profile.user.username 10 | 11 | export const censoredFields = ['email', 'userId', 'user'] as const 12 | export type CensoredProfile = Omit 13 | 14 | export const fieldOptions: PermittedFieldsOptions = { 15 | // Provide the list of all fields that should be revealed if the rule doesn't specify fields 16 | fieldsFrom: (rule) => 17 | // Add the 'user' field in manually because it's on the model rather than the DB entity 18 | rule.fields || [...Object.values(Prisma.ProfileScalarFieldEnum), 'user'], 19 | } 20 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profiles.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | 3 | import {ProfilesResolver} from './profiles.resolver' 4 | import {ProfilesService} from './profiles.service' 5 | 6 | @Module({ 7 | providers: [ProfilesResolver, ProfilesService], 8 | exports: [ProfilesService], 9 | }) 10 | export class ProfilesModule {} 11 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profiles.resolver.ts: -------------------------------------------------------------------------------- 1 | import {Profile} from '@prisma/client' 2 | import {ForbiddenException, NotFoundException, UseGuards} from '@nestjs/common' 3 | import {Args, ID, Int, Mutation, Query, Resolver} from '@nestjs/graphql' 4 | import {subject} from '@casl/ability' 5 | 6 | import {fromOrderByInput} from '@caster/utils/prisma' 7 | import {JwtGuard} from '@caster/authn/jwt.guard' 8 | import {Ability, AllowAnonymous, Censor} from '@caster/authz/authz.decorators' 9 | import {AppAbility, CensorFields} from '@caster/authz/authz.types' 10 | import {AuthzGuard} from '@caster/authz/authz.guard' 11 | 12 | import {Profile as ProfileModel} from './profile.model' 13 | import {ProfilesService} from './profiles.service' 14 | import {fieldOptions} from './profile.utils' 15 | import { 16 | ProfileCondition, 17 | ProfilesOrderBy, 18 | ProfilesPage, 19 | } from './profile-queries.model' 20 | import { 21 | CreateProfileInput, 22 | UpdateProfileInput, 23 | MutateProfileResult, 24 | } from './profile-mutations.model' 25 | 26 | @Resolver(() => ProfileModel) 27 | @UseGuards(JwtGuard, AuthzGuard) 28 | export class ProfilesResolver { 29 | constructor(private readonly service: ProfilesService) {} 30 | 31 | @Query(() => ProfileModel, {nullable: true}) 32 | @AllowAnonymous() 33 | async getProfile( 34 | @Args('id', {type: () => ID}) id: string, 35 | @Censor() censor: CensorFields 36 | ): Promise { 37 | const profile = await this.service.get(id) 38 | 39 | if (profile) { 40 | const result = censor(subject('Profile', profile), fieldOptions) 41 | 42 | return result 43 | } 44 | } 45 | 46 | @Query(() => ProfilesPage) 47 | @AllowAnonymous() 48 | async getManyProfiles( 49 | @Censor() censor: CensorFields, 50 | @Args('where', {nullable: true}) where?: ProfileCondition, 51 | @Args('orderBy', {nullable: true, type: () => [ProfilesOrderBy]}) 52 | orderBy?: ProfilesOrderBy[], 53 | @Args('pageSize', {type: () => Int, nullable: true}) pageSize?: number, 54 | @Args('page', {type: () => Int, nullable: true}) page?: number 55 | ): Promise { 56 | const {data, ...rest} = await this.service.getMany({ 57 | where, 58 | orderBy: fromOrderByInput(orderBy), 59 | pageSize, 60 | page, 61 | }) 62 | 63 | const permitted = data.map((profile) => 64 | censor(subject('Profile', profile), fieldOptions) 65 | ) 66 | 67 | return {...rest, data: permitted} 68 | } 69 | 70 | @Mutation(() => MutateProfileResult) 71 | async createProfile( 72 | @Args('input') input: CreateProfileInput, 73 | @Ability() ability: AppAbility 74 | ): Promise { 75 | if (ability.cannot('create', subject('Profile', input as Profile))) { 76 | throw new ForbiddenException() 77 | } 78 | 79 | const profile = await this.service.create(input) 80 | 81 | return {profile} 82 | } 83 | 84 | @Mutation(() => MutateProfileResult) 85 | async updateProfile( 86 | @Args('id', {type: () => ID}) id: string, 87 | @Args('input') input: UpdateProfileInput, 88 | @Ability() ability: AppAbility 89 | ): Promise { 90 | const existing = await this.getExisting(id) 91 | 92 | if (ability.cannot('update', subject('Profile', existing))) { 93 | throw new ForbiddenException() 94 | } 95 | 96 | const profile = await this.service.update(id, input) 97 | 98 | return {profile} 99 | } 100 | 101 | @Mutation(() => Boolean) 102 | async deleteProfile( 103 | @Args('id', {type: () => ID}) id: string, 104 | @Ability() ability: AppAbility 105 | ): Promise { 106 | const existing = await this.getExisting(id) 107 | 108 | if (ability.cannot('delete', subject('Profile', existing))) { 109 | throw new ForbiddenException() 110 | } 111 | 112 | await this.service.delete(id) 113 | 114 | return true 115 | } 116 | 117 | private getExisting = async (id: string) => { 118 | const existing = await this.service.get(id) 119 | if (!existing) { 120 | throw new NotFoundException() 121 | } 122 | 123 | return existing 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /libs/users/src/profiles/profiles.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | import {Prisma} from '@prisma/client' 3 | import {PrismaService} from 'nestjs-prisma' 4 | 5 | import {getOffset, paginateResponse} from '@caster/utils/pagination' 6 | import {fixJsonInput, toUndefinedProps} from '@caster/utils/types' 7 | 8 | import {CreateProfileInput, UpdateProfileInput} from './profile-mutations.model' 9 | import {omit} from 'lodash' 10 | 11 | @Injectable() 12 | export class ProfilesService { 13 | constructor(private readonly prisma: PrismaService) {} 14 | 15 | async get(id: string) { 16 | return this.prisma.profile.findFirst({ 17 | include: {user: true}, 18 | where: {id}, 19 | }) 20 | } 21 | 22 | async getMany(options: { 23 | where: Prisma.ProfileWhereInput | undefined 24 | orderBy: Prisma.ProfileOrderByWithRelationInput | undefined 25 | pageSize?: number 26 | page?: number 27 | }) { 28 | const {pageSize, page, ...rest} = options 29 | 30 | const total = await this.prisma.profile.count(rest) 31 | const profiles = await this.prisma.profile.findMany({ 32 | include: {user: true}, 33 | ...rest, 34 | ...getOffset(pageSize, page), 35 | }) 36 | 37 | return paginateResponse(profiles, { 38 | total, 39 | pageSize, 40 | page, 41 | }) 42 | } 43 | 44 | async create(input: CreateProfileInput) { 45 | return this.prisma.profile.create({ 46 | include: {user: true}, 47 | data: { 48 | ...toUndefinedProps(input), 49 | userId: undefined, 50 | user: { 51 | connect: {id: input.userId}, 52 | }, 53 | }, 54 | }) 55 | } 56 | 57 | async update(id: string, input: UpdateProfileInput) { 58 | const data = input.userId 59 | ? omit( 60 | { 61 | ...input, 62 | user: { 63 | connect: {id: input.userId}, 64 | }, 65 | }, 66 | ['userId'] 67 | ) 68 | : input 69 | 70 | return this.prisma.profile.update({ 71 | include: {user: true}, 72 | where: {id}, 73 | data: fixJsonInput(data), 74 | }) 75 | } 76 | 77 | async delete(id: string) { 78 | return this.prisma.profile.delete({ 79 | include: {user: true}, 80 | where: {id}, 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /libs/users/src/user-mutations.model.ts: -------------------------------------------------------------------------------- 1 | import GraphqlTypeJson from 'graphql-type-json' 2 | import {Field, InputType, ObjectType} from '@nestjs/graphql' 3 | import {Prisma} from '@prisma/client' 4 | 5 | import {User} from './user.model' 6 | 7 | @InputType() 8 | export class CreateUserProfileInput { 9 | @Field() 10 | email!: string 11 | 12 | @Field({nullable: true}) 13 | displayName?: string 14 | 15 | @Field({nullable: true}) 16 | picture?: string 17 | 18 | @Field(() => GraphqlTypeJson, {nullable: true}) 19 | content?: Prisma.JsonValue 20 | } 21 | 22 | @InputType() 23 | export class CreateUserInput { 24 | @Field(() => CreateUserProfileInput, {nullable: true}) 25 | profile?: CreateUserProfileInput 26 | } 27 | 28 | @InputType() 29 | export class UpdateUserInput { 30 | @Field({nullable: true}) 31 | username?: string 32 | 33 | @Field(() => Boolean, {nullable: true}) 34 | isActive?: boolean 35 | } 36 | 37 | @ObjectType() 38 | export class MutateUserResult { 39 | @Field(() => User, {nullable: true}) 40 | user?: User 41 | } 42 | -------------------------------------------------------------------------------- /libs/users/src/user.decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | UnauthorizedException, 5 | } from '@nestjs/common' 6 | 7 | import {getRequest} from '@caster/authz/authz.utils' 8 | 9 | /** 10 | * Return the User object if present, optionally requiring it. 11 | */ 12 | export const RequestUser = createParamDecorator( 13 | (options: {require?: true} = {}, ctx: ExecutionContext) => { 14 | const req = getRequest(ctx) 15 | const user = req.user 16 | 17 | if (user) { 18 | return user 19 | } 20 | 21 | if (options.require) { 22 | throw new UnauthorizedException() 23 | } 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /libs/users/src/user.model.ts: -------------------------------------------------------------------------------- 1 | import {Field, ID, ObjectType} from '@nestjs/graphql' 2 | 3 | import {Profile} from './profiles/profile.model' 4 | 5 | @ObjectType() 6 | export class User { 7 | @Field(() => ID) 8 | id!: string 9 | 10 | @Field() 11 | username!: string 12 | 13 | @Field() 14 | isActive!: boolean 15 | 16 | @Field(() => String, {nullable: true}) 17 | profileId?: string | null 18 | 19 | @Field(() => Profile, {nullable: true}) 20 | profile?: Profile | null 21 | 22 | @Field() 23 | createdAt!: Date 24 | 25 | @Field() 26 | updatedAt!: Date 27 | } 28 | -------------------------------------------------------------------------------- /libs/users/src/user.rules.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | 3 | import {Action, RuleBuilder, RuleEnhancer} from '@caster/authz/authz.types' 4 | 5 | import {UserWithProfile} from './user.types' 6 | 7 | @Injectable() 8 | export class UserRules implements RuleEnhancer { 9 | async forUser(user: UserWithProfile | undefined, {can}: RuleBuilder) { 10 | if (user) { 11 | // Same username 12 | can(Action.Create, 'User', {username: user.username}) 13 | can(Action.Read, 'User', {username: user.username}) 14 | can(Action.Update, 'User', {username: user.username}) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/users/src/user.types.ts: -------------------------------------------------------------------------------- 1 | import {Profile, User} from '@prisma/client' 2 | 3 | export type UserWithProfile = User & {profile: Profile | null} 4 | -------------------------------------------------------------------------------- /libs/users/src/users.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | 3 | import {UsersResolver} from './users.resolver' 4 | import {UsersService} from './users.service' 5 | 6 | @Module({ 7 | providers: [UsersResolver, UsersService], 8 | exports: [UsersService], 9 | }) 10 | export class UsersModule {} 11 | -------------------------------------------------------------------------------- /libs/users/src/users.resolver.ts: -------------------------------------------------------------------------------- 1 | import {Args, Mutation, Query, Resolver} from '@nestjs/graphql' 2 | import {UseGuards} from '@nestjs/common' 3 | 4 | import {Username} from '@caster/authn/jwt.decorators' 5 | import {JwtGuard} from '@caster/authn/jwt.guard' 6 | import {AllowAnonymous} from '@caster/authz/authz.decorators' 7 | import {AuthzGuard} from '@caster/authz/authz.guard' 8 | 9 | import {User} from './user.model' 10 | import { 11 | CreateUserInput, 12 | MutateUserResult, 13 | UpdateUserInput, 14 | } from './user-mutations.model' 15 | import {UsersService} from './users.service' 16 | import {RequestUser} from './user.decorators' 17 | 18 | @Resolver(() => User) 19 | @UseGuards(JwtGuard, AuthzGuard) 20 | export class UsersResolver { 21 | constructor(private readonly service: UsersService) {} 22 | 23 | @Query(() => User, {nullable: true}) 24 | @AllowAnonymous() 25 | async getCurrentUser( 26 | @Username({require: true}) _username: string, 27 | @RequestUser() user?: User 28 | ) { 29 | return user 30 | } 31 | 32 | @Mutation(() => MutateUserResult) 33 | @AllowAnonymous() 34 | async getOrCreateCurrentUser( 35 | @Args('input') input: CreateUserInput, 36 | @Username({require: true}) username: string, 37 | @RequestUser() existing?: User 38 | ) { 39 | if (existing) { 40 | return {user: existing} 41 | } 42 | 43 | const user = await this.service.create({...input, username}) 44 | 45 | return {user} 46 | } 47 | 48 | @Mutation(() => MutateUserResult) 49 | async updateCurrentUser( 50 | @Args('input') input: UpdateUserInput, 51 | @RequestUser({require: true}) existing: User 52 | ) { 53 | const user = await this.service.update(existing.id, input) 54 | 55 | return {user} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /libs/users/src/users.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common' 2 | import {PrismaService} from 'nestjs-prisma' 3 | 4 | import {toUndefinedProps} from '@caster/utils/types' 5 | 6 | import {CreateUserInput, UpdateUserInput} from './user-mutations.model' 7 | 8 | @Injectable() 9 | export class UsersService { 10 | constructor(private readonly prisma: PrismaService) {} 11 | 12 | async get(id: string) { 13 | return this.prisma.user.findFirst({ 14 | include: {profile: true}, 15 | where: {id}, 16 | }) 17 | } 18 | 19 | async getByUsername(username: string) { 20 | return this.prisma.user.findFirst({ 21 | include: {profile: true}, 22 | where: {username}, 23 | }) 24 | } 25 | 26 | async create(input: CreateUserInput & {username: string}) { 27 | return this.prisma.user.create({ 28 | include: {profile: true}, 29 | data: { 30 | ...input, 31 | profile: input.profile 32 | ? { 33 | create: toUndefinedProps(input.profile), 34 | } 35 | : undefined, 36 | }, 37 | }) 38 | } 39 | 40 | async update(id: string, input: UpdateUserInput) { 41 | return this.prisma.user.update({ 42 | include: {profile: true}, 43 | where: {id}, 44 | data: { 45 | username: input.username || undefined, 46 | isActive: input.isActive === null ? undefined : input.isActive, 47 | }, 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /libs/users/test/factories/profile.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import {Profile} from '../../src/profiles/profile.model' 4 | import {CreateProfileInput} from '../../src/profiles/profile-mutations.model' 5 | 6 | export const makeCreateInput = ( 7 | overrides?: Partial 8 | ): CreateProfileInput => { 9 | return { 10 | email: faker.internet.email(), 11 | displayName: faker.name.findName(), 12 | picture: faker.internet.avatar(), 13 | userId: faker.datatype.uuid(), 14 | ...overrides, 15 | } 16 | } 17 | 18 | export const make = (overrides?: Partial): Profile => { 19 | return { 20 | id: faker.datatype.uuid(), 21 | createdAt: faker.date.recent(), 22 | updatedAt: faker.date.recent(), 23 | ...makeCreateInput(overrides as Partial), 24 | ...overrides, 25 | } 26 | } 27 | 28 | export const ProfileFactory = {make, makeCreateInput} 29 | -------------------------------------------------------------------------------- /libs/users/test/factories/user.factory.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import {User} from '../../src/user.model' 4 | import {CreateUserInput} from '../../src/user-mutations.model' 5 | 6 | export const makeCreateInput = ( 7 | overrides?: Partial> 8 | ): Omit => ({ 9 | ...overrides, 10 | }) 11 | 12 | export const make = (overrides?: Partial): User => ({ 13 | id: faker.datatype.uuid(), 14 | username: faker.random.alphaNumeric(10), 15 | createdAt: faker.date.recent(), 16 | updatedAt: faker.date.recent(), 17 | isActive: true, 18 | ...makeCreateInput(overrides), 19 | ...overrides, 20 | }) 21 | 22 | export const UserFactory = {make, makeCreateInput} 23 | -------------------------------------------------------------------------------- /libs/users/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/users/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/users/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/*.test.js", 12 | "**/*.test.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/utils/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", {"useBuiltIns": "usage"}]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/utils/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/utils/README.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test utils` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/utils/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'utils', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.test.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/utils', 15 | setupFilesAfterEnv: ['../../jest.setup.ts'], 16 | } 17 | -------------------------------------------------------------------------------- /libs/utils/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/utils", 3 | "sourceRoot": "libs/utils/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "options": { 9 | "lintFilePatterns": ["libs/utils/**/*.ts"] 10 | } 11 | }, 12 | "test": { 13 | "executor": "@nrwl/jest:jest", 14 | "outputs": ["coverage/libs/utils"], 15 | "options": { 16 | "jestConfig": "libs/utils/jest.config.js", 17 | "passWithNoTests": true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/utils/src/__tests__/pagination.test.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import {paginateResponse} from '../pagination' 3 | 4 | describe('@caster/utils/pagination', () => { 5 | describe('paginateResponse()', () => { 6 | it('returns a ManyResponse with the first argument as the "data"', async () => { 7 | const data = [faker.datatype.uuid(), faker.datatype.uuid()] 8 | const result = paginateResponse(data) 9 | 10 | expect(result).toEqual({ 11 | data, 12 | count: 2, 13 | total: 2, 14 | page: 1, 15 | pageCount: 1, 16 | }) 17 | }) 18 | 19 | it('handles page input', async () => { 20 | const data = [faker.datatype.uuid()] 21 | const result = paginateResponse(data, {page: 4}) 22 | 23 | expect(result).toEqual({ 24 | data, 25 | count: 1, 26 | total: 1, 27 | page: 4, 28 | pageCount: 4, 29 | }) 30 | }) 31 | 32 | it('handles pageSize input', async () => { 33 | const data = [faker.datatype.uuid()] 34 | const result = paginateResponse(data, {pageSize: 20}) 35 | 36 | expect(result).toEqual({ 37 | data, 38 | count: 1, 39 | total: 1, 40 | page: 1, 41 | pageCount: 1, 42 | }) 43 | }) 44 | 45 | it('handles total input', async () => { 46 | const data = [faker.datatype.uuid()] 47 | const result = paginateResponse(data, {total: 200}) 48 | 49 | expect(result).toEqual({ 50 | data, 51 | count: 1, 52 | total: 200, 53 | page: 1, 54 | pageCount: 1, 55 | }) 56 | }) 57 | 58 | it('handles total input when the total is 0', async () => { 59 | const data: unknown[] = [] 60 | const result = paginateResponse(data, { 61 | total: 0, 62 | page: 1, 63 | pageSize: 500, 64 | }) 65 | 66 | expect(result).toEqual({ 67 | data, 68 | count: 0, 69 | total: 0, 70 | page: 1, 71 | pageCount: 1, 72 | }) 73 | }) 74 | 75 | it('handles page & pageSize input', async () => { 76 | const data = [faker.datatype.uuid()] 77 | const result = paginateResponse(data, {page: 4, pageSize: 20}) 78 | 79 | expect(result).toEqual({ 80 | data, 81 | count: 1, 82 | total: 21, 83 | page: 4, 84 | pageCount: 4, 85 | }) 86 | }) 87 | 88 | it('handles pageSize & total input', async () => { 89 | const data = [faker.datatype.uuid()] 90 | const result = paginateResponse(data, {pageSize: 20, total: 80}) 91 | 92 | expect(result).toEqual({ 93 | data, 94 | count: 1, 95 | total: 80, 96 | page: 1, 97 | pageCount: 4, 98 | }) 99 | }) 100 | 101 | it('handles page, pageSize, and total input', async () => { 102 | const data = [faker.datatype.uuid()] 103 | const result = paginateResponse(data, { 104 | page: 2, 105 | pageSize: 20, 106 | total: 80, 107 | }) 108 | 109 | expect(result).toEqual({ 110 | data, 111 | count: 1, 112 | total: 80, 113 | page: 2, 114 | pageCount: 4, 115 | }) 116 | }) 117 | 118 | it('handles empty input', async () => { 119 | const result = paginateResponse(undefined) 120 | 121 | expect(result).toEqual({ 122 | data: [], 123 | count: 0, 124 | total: 0, 125 | page: 1, 126 | pageCount: 1, 127 | }) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /libs/utils/src/config/config.default.ts: -------------------------------------------------------------------------------- 1 | import convict from 'convict' 2 | import json from 'json5' 3 | 4 | import {Schema} from './config.types' 5 | 6 | convict.addParser({extension: 'json', parse: json.parse}) 7 | 8 | export const schema: convict.Schema = { 9 | env: { 10 | doc: 'The application environment.', 11 | format: ['production', 'development', 'test'], 12 | default: 'development', 13 | env: 'NODE_ENV', 14 | }, 15 | port: { 16 | doc: 'The port to bind to.', 17 | format: 'port', 18 | default: 4000, 19 | env: 'PORT', 20 | arg: 'port', 21 | }, 22 | db: { 23 | host: { 24 | doc: 'Database host name/IP', 25 | format: String, 26 | default: 'localhost', 27 | env: 'DATABASE_HOSTNAME', 28 | }, 29 | username: { 30 | doc: 'Database username', 31 | format: String, 32 | default: 'caster', 33 | env: 'DATABASE_USERNAME', 34 | }, 35 | password: { 36 | doc: 'Database password', 37 | format: String, 38 | default: 'caster', 39 | env: 'DATABASE_PASSWORD', 40 | sensitive: true, 41 | }, 42 | name: { 43 | doc: 'Database name', 44 | format: String, 45 | default: 'caster', 46 | env: 'DATABASE_NAME', 47 | }, 48 | port: { 49 | doc: 'Database port', 50 | format: 'port', 51 | default: 1701, 52 | env: 'DATABASE_PORT', 53 | }, 54 | url: { 55 | doc: 'Database url', 56 | format: String, 57 | default: 'postgresql://caster:caster@localhost:1701/caster', 58 | env: 'DATABASE_URL', 59 | arg: 'db-url', 60 | }, 61 | debug: { 62 | doc: 'Database debug logging', 63 | format: Boolean, 64 | default: false, 65 | env: 'DATABASE_DEBUG_LOGGING', 66 | arg: 'db-debug', 67 | }, 68 | pool: { 69 | min: { 70 | doc: 'Database pool min', 71 | format: 'int', 72 | default: null, 73 | env: 'DATABASE_POOL_MIN', 74 | arg: 'db-min', 75 | }, 76 | max: { 77 | doc: 'Database pool max', 78 | format: 'int', 79 | default: null, 80 | env: 'DATABASE_POOL_MAX', 81 | arg: 'db-max', 82 | }, 83 | }, 84 | }, 85 | redis: { 86 | url: { 87 | doc: 'Redis url', 88 | format: String, 89 | default: 'localhost:6379', 90 | env: 'REDIS_URL', 91 | arg: 'redis-url', 92 | }, 93 | }, 94 | auth: { 95 | url: { 96 | doc: 'OAuth2 url', 97 | format: String, 98 | default: 'https://my-domain.us.auth0.com', 99 | env: 'OAUTH2_URL', 100 | }, 101 | audience: { 102 | doc: 'OAuth2 audience', 103 | format: String, 104 | default: 'localhost', 105 | env: 'OAUTH2_AUDIENCE', 106 | }, 107 | client: { 108 | id: { 109 | doc: 'OAuth2 client id', 110 | format: String, 111 | default: null, 112 | env: 'OAUTH2_CLIENT_ID', 113 | }, 114 | secret: { 115 | doc: 'OAuth2 client secret', 116 | format: String, 117 | default: null, 118 | env: 'OAUTH2_CLIENT_SECRET', 119 | sensitive: true, 120 | }, 121 | }, 122 | test: { 123 | user: { 124 | username: { 125 | doc: 'Test user username', 126 | format: String, 127 | default: null, 128 | env: 'TEST_USER', 129 | }, 130 | password: { 131 | doc: 'Test user password', 132 | format: String, 133 | default: null, 134 | env: 'TEST_PASS', 135 | sensitive: true, 136 | }, 137 | }, 138 | alt: { 139 | username: { 140 | doc: 'Alt test user username', 141 | format: String, 142 | default: null, 143 | env: 'TEST_ALT_USER', 144 | }, 145 | password: { 146 | doc: 'Alt test user password', 147 | format: String, 148 | default: null, 149 | env: 'TEST_ALT_PASS', 150 | sensitive: true, 151 | }, 152 | }, 153 | }, 154 | }, 155 | } 156 | 157 | export const defaultConfig = convict(schema) 158 | -------------------------------------------------------------------------------- /libs/utils/src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import {DynamicModule, Global, Module} from '@nestjs/common' 2 | 3 | import {defaultConfig} from './config.default' 4 | import {Config} from './config.types' 5 | 6 | const defaultProvider = { 7 | provide: Config, 8 | useValue: defaultConfig, 9 | } 10 | 11 | @Global() 12 | @Module({ 13 | providers: [defaultProvider], 14 | exports: [defaultProvider], 15 | }) 16 | export class ConfigModule { 17 | static for(config: Config): DynamicModule { 18 | const provider = {provide: Config, useValue: config} 19 | 20 | return { 21 | module: ConfigModule, 22 | providers: [provider], 23 | exports: [provider], 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /libs/utils/src/config/config.types.ts: -------------------------------------------------------------------------------- 1 | import convict from 'convict' 2 | 3 | import {InjectionToken} from '../injection' 4 | 5 | export interface Schema { 6 | env: string 7 | port: number 8 | db: { 9 | host: string 10 | username: string 11 | password: string 12 | name: string 13 | port: number 14 | url: string 15 | debug: boolean 16 | pool: { 17 | min: number | null 18 | max: number | null 19 | } 20 | } 21 | redis: { 22 | url: string 23 | } 24 | auth: { 25 | url: string 26 | audience: string 27 | client: { 28 | id: string | null 29 | secret: string | null 30 | } 31 | test: { 32 | user: { 33 | username: string | null 34 | password: string | null 35 | } 36 | alt: { 37 | username: string | null 38 | password: string | null 39 | } 40 | } 41 | } 42 | } 43 | 44 | export type Config = convict.Config 45 | 46 | export const Config: InjectionToken = 'CONVICT_CONFIG' 47 | -------------------------------------------------------------------------------- /libs/utils/src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller, Get} from '@nestjs/common' 2 | 3 | export interface HealthCheck { 4 | pong: true 5 | } 6 | 7 | @Controller('_health') 8 | export class HealthController { 9 | @Get('ping') 10 | public ping(): HealthCheck { 11 | return {pong: true} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/utils/src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common' 2 | 3 | import {HealthController} from './health.controller' 4 | 5 | @Module({ 6 | controllers: [HealthController], 7 | }) 8 | export class HealthModule {} 9 | -------------------------------------------------------------------------------- /libs/utils/src/injection.ts: -------------------------------------------------------------------------------- 1 | import {Abstract, Type} from '@nestjs/common' 2 | 3 | export type InjectionToken = 4 | | string 5 | | symbol 6 | | Type 7 | | Abstract 8 | -------------------------------------------------------------------------------- /libs/utils/src/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface ManyResponse { 2 | data: Entity[] 3 | count: number 4 | total: number 5 | page: number 6 | pageCount: number 7 | } 8 | 9 | export interface PaginationOptions { 10 | pageSize?: number 11 | page?: number 12 | total?: number 13 | } 14 | 15 | export const paginateResponse = ( 16 | data?: Entity[], 17 | opts: PaginationOptions = {} 18 | ): ManyResponse => { 19 | const skip = 20 | (opts.page && opts.pageSize && Math.floor(opts.page - 1 * opts.pageSize)) ?? 21 | 0 22 | 23 | const count = data?.length ?? 0 24 | const pageCount = 25 | opts.pageSize && opts.total && Math.ceil(opts.total / opts.pageSize) 26 | 27 | const page = 28 | opts.page ?? 29 | ((skip && opts.pageSize && Math.floor(skip / opts.pageSize) + 1) || 1) 30 | 31 | const total = 32 | opts.total ?? 33 | (count + 34 | (pageCount ?? Math.ceil(Math.abs(skip / (opts.pageSize ?? count)))) * 35 | (opts.pageSize ?? count) || 36 | count) 37 | 38 | return { 39 | data: data ?? [], 40 | count, 41 | total, 42 | page, 43 | pageCount: pageCount || page, 44 | } 45 | } 46 | 47 | export const getOffset = (pageSize?: number, page?: number) => { 48 | const skip = 49 | (pageSize && page && page > 1 && (page - 1) * pageSize) || undefined 50 | 51 | return {take: pageSize || undefined, skip} 52 | } 53 | -------------------------------------------------------------------------------- /libs/utils/src/prisma.ts: -------------------------------------------------------------------------------- 1 | import {SelectionSetNode} from 'graphql' 2 | import {JsonObject} from 'type-fest' 3 | import get from 'lodash/get' 4 | import camelCase from 'lodash/camelCase' 5 | 6 | /** 7 | * Return an object indicating possibly nested relationships that should be included in a Prisma 8 | * query. 9 | */ 10 | const fromSelections = ( 11 | selectionSet: SelectionSetNode, 12 | parent?: string 13 | ): JsonObject => 14 | selectionSet.selections.reduce((memo, selection) => { 15 | if (selection.kind !== 'Field') { 16 | return memo 17 | } 18 | 19 | if (!selection.selectionSet) { 20 | if (parent && !memo[parent]) { 21 | return {...memo, [parent]: true} 22 | } 23 | 24 | return memo 25 | } 26 | 27 | if (parent) { 28 | return { 29 | ...memo, 30 | [parent]: { 31 | include: fromSelections(selection.selectionSet, selection.name.value), 32 | }, 33 | } 34 | } 35 | 36 | return { 37 | ...memo, 38 | ...fromSelections(selection.selectionSet, selection.name.value), 39 | } 40 | }, {} as JsonObject) 41 | 42 | /** 43 | * Given a GraphQL SelectionSetNode, a prefix, and a set of paths, derive the include statement for 44 | * Prisma. 45 | * 46 | * Example: 47 | * 48 | * const [includeMyEntity, includeOtherEntity] = includeFromSelections( 49 | * resolveInfo.operation.selectionSet, 50 | * 'myOperation.myField' 51 | * ) 52 | * 53 | */ 54 | export const includeFromSelections = ( 55 | selectionSet: SelectionSetNode, 56 | path: string 57 | ) => { 58 | const include = fromSelections(selectionSet) 59 | 60 | // Translate the path to Prisma's "include" structure 61 | const prismaPath = `${path.split('.').join('.include.')}.include` 62 | 63 | return get(include, prismaPath) 64 | } 65 | 66 | /** 67 | * Given a GraphQL order by input like "DISPLAY_NAME_ASC", return Prisma orderBy input 68 | */ 69 | export const fromOrderByInput = < 70 | T extends Record, 71 | K extends string 72 | >( 73 | orderBy?: K[] 74 | ): T | undefined => { 75 | return orderBy?.reduce((memo, order) => { 76 | const index = order.lastIndexOf('_') 77 | const [field, direction] = [ 78 | camelCase(order.substr(0, index)), 79 | order.substr(index + 1).toLowerCase(), 80 | ] 81 | 82 | return {...memo, [field]: direction} 83 | }, {} as T) 84 | } 85 | -------------------------------------------------------------------------------- /libs/utils/src/types.ts: -------------------------------------------------------------------------------- 1 | import {Prisma} from '@prisma/client' 2 | 3 | /** 4 | * If the given property is undefined, change it to null 5 | */ 6 | export type ToNull = T extends undefined ? null : T 7 | 8 | /** 9 | * For any properties on the object that are undefined, change them to null 10 | */ 11 | export type ToNullProps = { 12 | [K in keyof T]-?: ToNull 13 | } 14 | 15 | /** 16 | * If the given property is null, change it to undefined 17 | */ 18 | export type ToUndefined = T extends null ? undefined : T 19 | 20 | /** 21 | * For any properties on the object that are null, change them to undefined 22 | */ 23 | export type ToUndefinedProps = { 24 | [K in keyof T]-?: ToUndefined 25 | } 26 | 27 | /** 28 | * For any properties on the object that are undefined, change them to null 29 | */ 30 | export const toNullProps = ( 31 | obj: T, 32 | props?: readonly K[] 33 | ) => { 34 | const keys = props ?? (Object.keys(obj) as K[]) 35 | 36 | return keys.reduce( 37 | (memo, key) => { 38 | if (obj[key] === undefined) { 39 | memo[key] = null as ToNull 40 | } 41 | 42 | return memo 43 | }, 44 | {...obj} as ToNullProps 45 | ) 46 | } 47 | 48 | /** 49 | * For any properties on the object that are null, change them to undefined 50 | */ 51 | export const toUndefinedProps = ( 52 | obj: T, 53 | props?: readonly K[] 54 | ) => { 55 | const keys = props ?? (Object.keys(obj) as K[]) 56 | 57 | return keys.reduce( 58 | (memo, key) => { 59 | if (obj[key] === null) { 60 | memo[key] = undefined as ToUndefined 61 | } 62 | 63 | return memo 64 | }, 65 | {...obj} as ToUndefinedProps 66 | ) 67 | } 68 | 69 | /** 70 | * Fix Json input between GraphQL and Prisma 71 | */ 72 | export const fixJsonInput = ( 73 | input: T, 74 | value: 'DbNull' | 'JsonNull' = 'DbNull' 75 | ): T & { 76 | content?: string | number | boolean | Prisma.JsonObject | Prisma.JsonArray 77 | } => ({ 78 | ...input, 79 | // Fix the Json input, because it can be either DbNull or JsonNull 80 | content: input.content === null ? value : input.content, 81 | }) 82 | -------------------------------------------------------------------------------- /libs/utils/test/events.ts: -------------------------------------------------------------------------------- 1 | import range from 'lodash/range' 2 | 3 | const delay = async (timeout: number) => 4 | new Promise((resolve) => setTimeout(resolve, timeout)) 5 | 6 | export const retry = async ( 7 | check: () => Promise, 8 | retries: number, 9 | wait = 100 10 | ) => { 11 | for (const _ of range(retries)) { 12 | await delay(wait) 13 | 14 | const result = await check() 15 | if (result) { 16 | return result 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/utils/test/graphql.ts: -------------------------------------------------------------------------------- 1 | import {Application} from 'express' 2 | import supertest from 'supertest' 3 | 4 | export class GraphQL { 5 | constructor( 6 | private readonly app: Application, 7 | private readonly endpoint = '/graphql' 8 | ) {} 9 | 10 | query = async ( 11 | query: string, 12 | variables?: Record, 13 | options: {warn?: boolean; statusCode?: number; token?: string} = {} 14 | ): Promise<{data: T}> => { 15 | const {warn = true, statusCode = 200, token} = options 16 | 17 | const test = supertest(this.app).post(this.endpoint) 18 | 19 | if (token) { 20 | test.set('Authorization', `Bearer ${token}`) 21 | } 22 | 23 | const response = await test.send({query, variables}) 24 | 25 | if (warn && response.body.errors) { 26 | console.error( 27 | response.body.errors 28 | .map((err: {message: string}) => err.message) 29 | .join('\n\n') 30 | ) 31 | } 32 | 33 | expect(response.status).toEqual(statusCode) 34 | 35 | return response.body 36 | } 37 | 38 | mutation = this.query 39 | } 40 | -------------------------------------------------------------------------------- /libs/utils/test/oauth2.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosError} from 'axios' 2 | 3 | import {Config} from '../src/config/config.types' 4 | import {defaultConfig} from '../src/config/config.default' 5 | 6 | export interface Credentials { 7 | token?: string 8 | username?: string 9 | email?: string 10 | } 11 | 12 | const credentials: Credentials = {} 13 | const altCredentials: Credentials = {} 14 | 15 | export const init = (config: Config = defaultConfig) => { 16 | beforeAll(async () => { 17 | if (!credentials.token) { 18 | try { 19 | const { 20 | data: {access_token: accessToken}, 21 | } = await axios.post<{access_token?: string}>( 22 | `${config.get('auth.url')}/oauth/token`, 23 | { 24 | grant_type: 'password', 25 | username: config.get('auth.test.user.username'), 26 | password: config.get('auth.test.user.password'), 27 | client_id: config.get('auth.client.id'), 28 | client_secret: config.get('auth.client.secret'), 29 | scope: 'openid profile email', 30 | audience: config.get('auth.audience'), 31 | } 32 | ) 33 | credentials.token = accessToken 34 | } catch (err) { 35 | console.error((err as AxiosError).response?.data) 36 | 37 | throw err 38 | } 39 | } 40 | 41 | if (!credentials.username || !credentials.email) { 42 | const { 43 | data: {sub, email}, 44 | } = await axios.get<{sub?: string; email?: string}>( 45 | `${config.get('auth.url')}/userinfo`, 46 | { 47 | headers: {Authorization: `Bearer ${credentials.token}`}, 48 | } 49 | ) 50 | credentials.username = sub 51 | credentials.email = email 52 | } 53 | 54 | if (!altCredentials.token) { 55 | const { 56 | data: {access_token: altAccessToken}, 57 | } = await axios.post<{access_token?: string}>( 58 | `${config.get('auth.url')}/oauth/token`, 59 | { 60 | grant_type: 'password', 61 | username: config.get('auth.test.alt.username'), 62 | password: config.get('auth.test.alt.password'), 63 | client_id: config.get('auth.client.id'), 64 | client_secret: config.get('auth.client.secret'), 65 | scope: 'openid profile email', 66 | audience: config.get('auth.audience'), 67 | } 68 | ) 69 | altCredentials.token = altAccessToken 70 | } 71 | 72 | if (!altCredentials.username || !altCredentials.email) { 73 | const { 74 | data: {sub: altSub, email: altEmail}, 75 | } = await axios.get<{sub?: string; email?: string}>( 76 | `${config.get('auth.url')}/userinfo`, 77 | { 78 | headers: {Authorization: `Bearer ${altCredentials.token}`}, 79 | } 80 | ) 81 | altCredentials.username = altSub 82 | altCredentials.email = altEmail 83 | } 84 | }) 85 | 86 | return { 87 | credentials, 88 | altCredentials, 89 | } 90 | } 91 | 92 | export const OAuth2 = { 93 | init, 94 | } 95 | -------------------------------------------------------------------------------- /libs/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.test.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /libs/utils/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6" 9 | }, 10 | "exclude": ["**/__tests__/**/*", "**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/utils/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "test", 10 | "**/*.test.ts", 11 | "**/*.test.tsx", 12 | "**/*.test.js", 13 | "**/*.test.jsx", 14 | "**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "caster", 3 | "affected": { 4 | "defaultBase": "main" 5 | }, 6 | "cli": { 7 | "defaultCollection": "@nrwl/nest" 8 | }, 9 | "implicitDependencies": { 10 | "package.json": { 11 | "dependencies": "*", 12 | "devDependencies": "*" 13 | }, 14 | ".eslintrc.json": "*" 15 | }, 16 | "tasksRunnerOptions": { 17 | "default": { 18 | "runner": "@nrwl/workspace/tasks-runners/default", 19 | "options": { 20 | "cacheableOperations": ["build", "lint", "test", "e2e", "codegen"] 21 | } 22 | } 23 | }, 24 | "targetDependencies": { 25 | "build": [ 26 | { 27 | "target": "build", 28 | "projects": "dependencies" 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "importHelpers": true, 12 | "target": "es2015", 13 | "module": "esnext", 14 | "lib": ["es2017", "dom"], 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "baseUrl": ".", 18 | "downlevelIteration": true, 19 | "noErrorTruncation": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "preserveConstEnums": true, 25 | "removeComments": true, 26 | "strict": true, 27 | "strictFunctionTypes": true, 28 | "strictNullChecks": true, 29 | "strictPropertyInitialization": true, 30 | "paths": { 31 | "@caster/authn/*": ["libs/authn/src/*"], 32 | "@caster/authz/*": ["libs/authz/src/*"], 33 | "@caster/events/*": ["libs/events/src/*"], 34 | "@caster/graphql/*": ["libs/graphql/src/*"], 35 | "@caster/roles/*": ["libs/roles/src/*"], 36 | "@caster/roles/test/*": ["libs/roles/test/*"], 37 | "@caster/shows/*": ["libs/shows/src/*"], 38 | "@caster/shows/test/*": ["libs/shows/test/*"], 39 | "@caster/users/*": ["libs/users/src/*"], 40 | "@caster/users/test/*": ["libs/users/test/*"], 41 | "@caster/utils/*": ["libs/utils/src/*"], 42 | "@caster/utils/test/*": ["libs/utils/test/*"] 43 | } 44 | }, 45 | "exclude": ["node_modules", "tmp"] 46 | } 47 | --------------------------------------------------------------------------------