├── .github ├── dependabot.yml └── workflows │ ├── backend.yml │ └── frontend.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── api ├── .eslintrc.js ├── .prettierrc ├── .vscode │ └── settings.json ├── nest-cli.json ├── ormconfig.json ├── package-lock.json ├── package.json ├── src │ ├── app.module.ts │ ├── authz │ │ ├── authenticated-request.interface.ts │ │ ├── authz.constants.ts │ │ ├── authz.module.ts │ │ ├── gql-user.decorator.ts │ │ ├── has-permissions.decorator.ts │ │ ├── has-permissions.guard.spec.ts │ │ ├── has-permissions.guard.ts │ │ ├── jwt-auth.guard.spec.ts │ │ ├── jwt-auth.guard.ts │ │ ├── jwt-payload.interface.ts │ │ ├── jwt.strategy.spec.ts │ │ ├── jwt.strategy.ts │ │ ├── permission-type.enum.ts │ │ └── user-principal.interface.ts │ ├── config │ │ ├── auth0.config.spec.ts │ │ ├── auth0.config.ts │ │ ├── db.config.spec.ts │ │ └── db.config.ts │ ├── database │ │ ├── database.module.ts │ │ ├── entity │ │ │ ├── comment.entity.ts │ │ │ ├── post.entity.ts │ │ │ └── user.entity.ts │ │ ├── posts-data-initializer.ts │ │ └── repository │ │ │ ├── comment.repository.spec.ts │ │ │ ├── comment.repository.ts │ │ │ ├── post.repository.spec.ts │ │ │ ├── post.repository.ts │ │ │ ├── user.repository.spec.ts │ │ │ └── user.repository.ts │ ├── gql │ │ ├── dataloaders │ │ │ ├── posts.loaders.spec.ts │ │ │ └── posts.loaders.ts │ │ ├── directives │ │ │ └── upper-case.directive.ts │ │ ├── dto │ │ │ ├── comment.input.ts │ │ │ ├── post.input.ts │ │ │ └── posts.arg.ts │ │ ├── filters │ │ │ └── http.exception.filter.ts │ │ ├── gql-api.module.ts │ │ ├── plugins │ │ │ └── logging.plugin.ts │ │ ├── resolvers │ │ │ ├── post-not-found.error.ts │ │ │ ├── posts.resolver.spec.ts │ │ │ ├── posts.resolver.ts │ │ │ ├── user-not-found.error.ts │ │ │ ├── users.resolver.spec.ts │ │ │ └── users.resolver.ts │ │ ├── scalars │ │ │ ├── date.scalar.spec.ts │ │ │ └── date.scalar.ts │ │ ├── service │ │ │ ├── posts.service.spec.ts │ │ │ ├── posts.service.ts │ │ │ ├── users.service.spec.ts │ │ │ └── users.service.ts │ │ └── types │ │ │ ├── comment.model.spec.ts │ │ │ ├── comment.model.ts │ │ │ ├── post.model.spec.ts │ │ │ ├── post.model.ts │ │ │ ├── update-result.model.ts │ │ │ └── user.model.ts │ ├── main.ts │ └── schema.gql ├── test │ ├── app.e2e-spec.ts │ ├── db.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── codecov.yml ├── commitlint.config.js ├── docker-compose.yml ├── docs └── code-first.md ├── package-lock.json ├── package.json └── ui ├── .browserslistrc ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── angular.json ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── spec.ts ├── plugins │ └── index.ts ├── support │ ├── commands.ts │ └── index.ts └── tsconfig.json ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── jest-global-mocks.ts ├── jest.config.js ├── nginx └── nginx.conf ├── package-lock.json ├── package.json ├── proxy.conf.json ├── setup-jest.ts ├── src ├── app │ ├── admin │ │ ├── admin-routing.module.ts │ │ └── admin.module.ts │ ├── app-routing.module.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.ts │ ├── app.module.ts │ ├── core │ │ ├── core.module.ts │ │ └── graphql.module.ts │ ├── error │ │ ├── error-routing.module.ts │ │ ├── error.component.html │ │ ├── error.component.ts │ │ └── error.module.ts │ ├── home │ │ ├── home-content │ │ │ ├── home-content.component.css │ │ │ ├── home-content.component.html │ │ │ └── home-content.component.ts │ │ ├── home-routing.module.ts │ │ ├── home.component.css │ │ ├── home.component.html │ │ ├── home.component.ts │ │ └── home.module.ts │ ├── posts │ │ ├── edit-post │ │ │ ├── edit-post.component.css │ │ │ ├── edit-post.component.html │ │ │ ├── edit-post.component.spec.ts │ │ │ └── edit-post.component.ts │ │ ├── new-post │ │ │ ├── new-post.component.css │ │ │ ├── new-post.component.html │ │ │ ├── new-post.component.spec.ts │ │ │ └── new-post.component.ts │ │ ├── post-details │ │ │ ├── post-details.component.css │ │ │ ├── post-details.component.html │ │ │ ├── post-details.component.spec.ts │ │ │ └── post-details.component.ts │ │ ├── post-list │ │ │ ├── post-list.component.css │ │ │ ├── post-list.component.html │ │ │ ├── post-list.component.spec.ts │ │ │ └── post-list.component.ts │ │ ├── posts-routing.module.ts │ │ ├── posts.module.ts │ │ └── shared │ │ │ ├── comment.model.ts │ │ │ ├── post-details-resolve.ts │ │ │ ├── post-form │ │ │ ├── post-form.component.css │ │ │ ├── post-form.component.html │ │ │ ├── post-form.component.spec.ts │ │ │ └── post-form.component.ts │ │ │ ├── post.model.ts │ │ │ ├── post.service.spec.ts │ │ │ ├── post.service.ts │ │ │ └── user.model.ts │ ├── profile │ │ ├── profile-routing.module.ts │ │ ├── profile.component.css │ │ ├── profile.component.html │ │ ├── profile.component.ts │ │ └── profile.module.ts │ └── shared │ │ ├── components │ │ ├── auth-nav │ │ │ ├── auth-nav.component.html │ │ │ └── auth-nav.component.ts │ │ ├── authentication-button │ │ │ ├── authentication-button.component.html │ │ │ └── authentication-button.component.ts │ │ ├── footer │ │ │ ├── footer.component.html │ │ │ └── footer.component.ts │ │ ├── loading │ │ │ ├── loading.component.html │ │ │ └── loading.component.ts │ │ ├── login-button │ │ │ ├── login-button.component.html │ │ │ └── login-button.component.ts │ │ ├── logout-button │ │ │ ├── logout-button.component.html │ │ │ └── logout-button.component.ts │ │ ├── main-nav │ │ │ ├── main-nav.component.css │ │ │ ├── main-nav.component.html │ │ │ └── main-nav.component.ts │ │ ├── nav-bar │ │ │ ├── nav-bar.component.css │ │ │ ├── nav-bar.component.html │ │ │ └── nav-bar.component.ts │ │ └── signup-button │ │ │ ├── signup-button.component.html │ │ │ └── signup-button.component.ts │ │ └── shared.module.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts └── styles.css ├── test-config.helper.ts ├── tsconfig.app.json ├── tsconfig.eslint.json ├── tsconfig.json └── tsconfig.spec.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: npm 8 | directory: "/api" 9 | schedule: 10 | interval: "weekly" 11 | #schedule: 12 | # interval: "daily" 13 | # time: "21:00" 14 | open-pull-requests-limit: 10 15 | # Increase the version requirements 16 | # only when required 17 | versioning-strategy: increase-if-necessary 18 | reviewers: 19 | - "hantsy" 20 | assignees: 21 | - "hantsy" 22 | labels: 23 | - "dependencies" 24 | - "npm" 25 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: backend 2 | 3 | on: 4 | push: 5 | paths: 6 | - "api/**" 7 | branches: 8 | - master 9 | - release/* 10 | pull_request: 11 | paths: 12 | - "api/**" 13 | types: 14 | - opened 15 | - synchronize 16 | - reopened 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Setup NodeJS 25 | uses: actions/setup-node@v2.4.1 26 | with: 27 | node-version: "16" 28 | 29 | - name: Cache Node.js modules 30 | uses: actions/cache@v2.1.6 31 | with: 32 | # npm cache files are stored in `~/.npm` on Linux/macOS 33 | path: ~/.npm 34 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.OS }}-node- 37 | ${{ runner.OS }}- 38 | 39 | # install dependencies and build the project 40 | - name: Build 41 | run: | 42 | cd ./api 43 | npm install --registry=https://registry.npmjs.org 44 | npm run build 45 | npm run test:cov 46 | # Upload testing reports 47 | bash <(curl -s https://codecov.io/bash) -F api 48 | env: 49 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 50 | 51 | e2e: 52 | needs: 53 | - build 54 | runs-on: ubuntu-latest 55 | services: 56 | db: 57 | image: postgres 58 | env: 59 | POSTGRES_PASSWORD: password 60 | POSTGRES_DB: blogdb 61 | POSTGRES_USER: user 62 | ports: 63 | - 5432:5432 64 | # Set health checks to wait until postgres has started 65 | options: >- 66 | --health-cmd pg_isready 67 | --health-interval 10s 68 | --health-timeout 5s 69 | --health-retries 5 70 | steps: 71 | - uses: actions/checkout@v2 72 | - name: Setup NodeJS 73 | uses: actions/setup-node@v2.4.1 74 | with: 75 | node-version: "15" 76 | 77 | - name: Cache Node.js modules 78 | uses: actions/cache@v2.1.6 79 | with: 80 | # npm cache files are stored in `~/.npm` on Linux/macOS 81 | path: ~/.npm 82 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 83 | restore-keys: | 84 | ${{ runner.OS }}-node- 85 | ${{ runner.OS }}- 86 | - name: Build and Run E2E tests 87 | run: | 88 | json=$(curl --request POST \ 89 | --url https://dev-ese8241b.us.auth0.com/oauth/token \ 90 | --header 'content-type: application/json' \ 91 | --data '{"client_id":"${{ secrets.CLIENT_ID}}","client_secret":"${{secrets.CLIENT_SECRET}}","audience":"https://hantsy.github.io/api","grant_type":"client_credentials"}') 92 | export TOKEN=$( jq -r ".access_token" <<<"$json" ) 93 | echo "token: $TOKEN" 94 | cd ./api 95 | npm install --registry=https://registry.npmjs.org 96 | npm run test:e2e -- --runInBand --forceExit --detectOpenHandles -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: frontend 2 | 3 | on: 4 | push: 5 | paths: 6 | - "ui/**" 7 | branches: 8 | - master 9 | - release/* 10 | pull_request: 11 | paths: 12 | - "ui/**" 13 | types: 14 | - opened 15 | - synchronize 16 | - reopened 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Setup NodeJS 25 | uses: actions/setup-node@v2.4.1 26 | with: 27 | node-version: "15" 28 | 29 | - uses: actions/cache@v2 30 | with: 31 | path: ~/.npm 32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node- 35 | - name: Build 36 | run: | 37 | cd ./ui 38 | npm install 39 | npm run build:prod 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | dist/ 37 | node_modules/ 38 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | echo "check git commit message." 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # npm test 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [], 7 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 8 | "unwantedRecommendations": [] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS GraphQL Sample 2 | 3 | ![frontend](https://github.com/hantsy/nestjs-graphql-sample/workflows/frontend/badge.svg) 4 | ![backend](https://github.com/hantsy/nestjs-graphql-sample/workflows/backend/badge.svg) 5 | [![codecov](https://codecov.io/gh/hantsy/nestjs-graphql-sample/branch/master/graph/badge.svg)](https://codecov.io/gh/hantsy/nestjs-graphql-sample) 6 | 7 | A NestJS GraphQL sample project, including: 8 | 9 | * Code first Nestjs/GraphQl development 10 | * TypeORM with Postgres 11 | * Passport/Jwt authentication with auth0.net IDP service 12 | * Fully testing codes with Jest, jest-mock-extended, ts-mockito, etc. 13 | * Github actions for continuous testing, code coverage report, docker image building, etc. 14 | 15 | ## Docs 16 | 17 | 18 | 19 | 20 | ## Build 21 | ### Install dependencies 22 | 23 | ```bash 24 | $ npm install 25 | ``` 26 | 27 | ### Running the app 28 | 29 | ```bash 30 | # development 31 | $ npm run start 32 | 33 | # watch mode 34 | $ npm run start:dev 35 | 36 | # production mode 37 | $ npm run start:prod 38 | ``` 39 | 40 | ### Test 41 | 42 | ```bash 43 | # unit tests 44 | $ npm run test 45 | 46 | # e2e tests 47 | $ npm run test:e2e 48 | 49 | # test coverage 50 | $ npm run test:cov 51 | ``` 52 | 53 | ## Resources 54 | * [NestJS GraphQL chapter](https://docs.nestjs.com/graphql/quick-start) 55 | * [The Anatomy of a GraphQL Query](https://www.apollographql.com/blog/the-anatomy-of-a-graphql-query-6dffa9e9e747/) 56 | * [Developing a Secure API with NestJS: Managing Identity](https://auth0.com/blog/developing-a-secure-api-with-nestjs-adding-authorization/) 57 | * [Developing a Secure API with NestJS: Managing Roles](https://auth0.com/blog/developing-a-secure-api-with-nestjs-adding-role-based-access-control/) 58 | -------------------------------------------------------------------------------- /api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | ignorePatterns: ['.eslintrc.js'], 20 | rules: { 21 | '@typescript-eslint/interface-name-prefix': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/explicit-module-boundary-types': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.endOfLine": "auto" 3 | } 4 | -------------------------------------------------------------------------------- /api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /api/ormconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "username": "user", 6 | "password": "password", 7 | "database": "blogdb", 8 | "entities": ["dist/**/*.entity{.ts,.js}"], 9 | "synchronize": true 10 | } 11 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-graphql-sample", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@graphql-tools/utils": "^7.10.0", 25 | "@nestjs/common": "^8.0.0", 26 | "@nestjs/config": "^1.0.0", 27 | "@nestjs/core": "^8.0.0", 28 | "@nestjs/graphql": "^9.0.4", 29 | "@nestjs/passport": "^8.0.0", 30 | "@nestjs/platform-express": "^8.0.0", 31 | "@nestjs/typeorm": "^8.0.2", 32 | "apollo-server": "~3.5.0", 33 | "apollo-server-express": "~3.5.0", 34 | "apollo-server-plugin-base": "^3.2.0", 35 | "class-transformer": "^0.5.0", 36 | "class-validator": "^0.13.1", 37 | "dataloader": "^2.0.0", 38 | "graphql-subscriptions": "^2.0.0", 39 | "jwks-rsa": "^2.0.4", 40 | "passport": "^0.4.1", 41 | "passport-jwt": "^4.0.0", 42 | "pg": "^8.7.1", 43 | "reflect-metadata": "^0.1.13", 44 | "rimraf": "^3.0.2", 45 | "rxjs": "^7.2.0" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/cli": "^15.0.0", 49 | "@commitlint/config-conventional": "^15.0.0", 50 | "@golevelup/nestjs-testing": "^0.1.2", 51 | "@nestjs/cli": "^8.0.0", 52 | "@nestjs/schematics": "^8.0.0", 53 | "@nestjs/testing": "^8.0.0", 54 | "@types/express": "^4.17.13", 55 | "@types/jest": "^27.0.1", 56 | "@types/node": "^16.0.0", 57 | "@types/passport-jwt": "^3.0.6", 58 | "@types/supertest": "^2.0.11", 59 | "@typescript-eslint/eslint-plugin": "^5.0.0", 60 | "@typescript-eslint/parser": "^5.1.0", 61 | "eslint": "^8.0.0", 62 | "eslint-config-prettier": "^8.3.0", 63 | "eslint-plugin-prettier": "^4.0.0", 64 | "jest": "27.3.1", 65 | "jest-mock-extended": "^2.0.1", 66 | "prettier": "^2.3.2", 67 | "supertest": "^6.1.3", 68 | "ts-jest": "^27.0.3", 69 | "ts-loader": "^9.2.3", 70 | "ts-node": "^10.0.0", 71 | "tsconfig-paths": "^3.10.1", 72 | "typescript": "^4.3.5" 73 | }, 74 | "jest": { 75 | "moduleFileExtensions": [ 76 | "js", 77 | "json", 78 | "ts" 79 | ], 80 | "rootDir": "src", 81 | "testRegex": ".*\\.spec\\.ts$", 82 | "transform": { 83 | "^.+\\.(t|j)s$": "ts-jest" 84 | }, 85 | "collectCoverageFrom": [ 86 | "**/*.(t|j)s" 87 | ], 88 | "coverageDirectory": "../coverage", 89 | "testEnvironment": "node" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigType } from '@nestjs/config'; 3 | import { GraphQLModule } from '@nestjs/graphql'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { GraphQLError, GraphQLFormattedError } from 'graphql'; 6 | import { join } from 'path'; 7 | import { AuthzModule } from './authz/authz.module'; 8 | import dbConfig from './config/db.config'; 9 | import { DatabaseModule } from './database/database.module'; 10 | import { GqlApiModule } from './gql/gql-api.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forRoot({ ignoreEnvFile: true }), 15 | TypeOrmModule.forRootAsync({ 16 | imports: [ConfigModule.forFeature(dbConfig)], 17 | useFactory: async (cfg: ConfigType) => 18 | // getConnectionOptions is used to load config from ormconfig.json file. 19 | // 20 | // Object.assign(await getConnectionOptions(), { 21 | // autoLoadEntities: true, 22 | // logging: true, 23 | // }), 24 | 25 | { 26 | const options = Boolean(cfg.url) 27 | ? { 28 | type: cfg.type, 29 | url: cfg.url, 30 | } 31 | : { 32 | type: cfg.type, 33 | host: cfg.host, 34 | port: cfg.port, 35 | database: cfg.database, 36 | username: cfg.username, 37 | password: cfg.password, 38 | }; 39 | 40 | return Object.assign(options, { 41 | entities: ['dist/**/*.entity{.ts,.js}'], 42 | autoLoadEntities: true, 43 | synchronize: true, 44 | logging: true, 45 | }) as any; 46 | }, 47 | 48 | inject: [dbConfig.KEY], 49 | }), 50 | GraphQLModule.forRoot({ 51 | installSubscriptionHandlers: true, 52 | debug: true, 53 | playground: true, //show playgroud 54 | autoSchemaFile: join(process.cwd(), 'src/schema.gql'), 55 | // in memory 56 | //autoSchemaFile: true, 57 | sortSchema: true, 58 | buildSchemaOptions: { 59 | //fieldMiddleware: [loggerMiddleware], 60 | //dateScalarMode: 'timestamp', // by default, GraphQLISODateTime (e.g. 2019-12-03T09:54:33Z) 61 | numberScalarMode: 'integer', //default, it is float. 62 | }, 63 | schemaDirectives: { 64 | //upper: UpperCaseDirective, 65 | }, 66 | // fieldResolverEnhancers: ['guards'], 67 | context: ({ req, res }) => ({ 68 | req, 69 | res, 70 | //batchAuthorsLoader: batchAuthorsLoader(usersService), 71 | }), 72 | formatError: (error: GraphQLError) => { 73 | //console.log('GraphQLError::', JSON.stringify(error)); 74 | const graphQLFormattedError: GraphQLFormattedError = { 75 | message: 76 | error?.extensions?.exception?.message || error?.message || '', 77 | }; 78 | return graphQLFormattedError; 79 | }, 80 | }), 81 | DatabaseModule, 82 | AuthzModule, 83 | GqlApiModule, 84 | ], 85 | providers: [], 86 | controllers: [], 87 | }) 88 | export class AppModule {} 89 | -------------------------------------------------------------------------------- /api/src/authz/authenticated-request.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { UserPrincipal } from './user-principal.interface'; 3 | export interface AuthenticatedRequest extends Request { 4 | readonly user: UserPrincipal; 5 | } 6 | -------------------------------------------------------------------------------- /api/src/authz/authz.constants.ts: -------------------------------------------------------------------------------- 1 | export const HAS_PERMISSIONS_KEY = 'has-permissions'; 2 | -------------------------------------------------------------------------------- /api/src/authz/authz.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import auth0Config from '../config/auth0.config'; 5 | import { JwtStrategy } from './jwt.strategy'; 6 | 7 | @Module({ 8 | imports: [ 9 | ConfigModule.forFeature(auth0Config), 10 | PassportModule.register({ defaultStrategy: 'jwt' }), 11 | ], 12 | providers: [JwtStrategy], 13 | exports: [PassportModule], 14 | }) 15 | export class AuthzModule {} 16 | -------------------------------------------------------------------------------- /api/src/authz/gql-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { UserPrincipal } from './user-principal.interface'; 4 | 5 | export const GqlUser = createParamDecorator( 6 | (data: unknown, ctx: ExecutionContext) => { 7 | const context = GqlExecutionContext.create(ctx).getContext(); 8 | const { req } = context; 9 | return req?.user as UserPrincipal; 10 | }, 11 | ); 12 | -------------------------------------------------------------------------------- /api/src/authz/has-permissions.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { HAS_PERMISSIONS_KEY } from './authz.constants'; 3 | import { PermissionType } from './permission-type.enum'; 4 | 5 | export const HasPermissions = (...args: PermissionType[]) => 6 | SetMetadata(HAS_PERMISSIONS_KEY, args); 7 | -------------------------------------------------------------------------------- /api/src/authz/has-permissions.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; 4 | import { GqlExecutionContext } from '@nestjs/graphql'; 5 | import { mock, mockClear } from 'jest-mock-extended'; 6 | import { HAS_PERMISSIONS_KEY } from './authz.constants'; 7 | import { HasPermissionsGuard } from './has-permissions.guard'; 8 | import { PermissionType } from './permission-type.enum'; 9 | 10 | // more mocking examples of `createMock`('@golevelup/ts-jest') and `ts-mockito`. 11 | // see: https://github.com/hantsy/nestjs-sample/blob/master/src/auth/guard/roles.guard.spec.ts 12 | describe('HasPermissionsGuard(mocking interface with jest-mock-extended)', () => { 13 | let guard: HasPermissionsGuard; 14 | const reflecter = mock(); 15 | 16 | beforeEach(() => { 17 | guard = new HasPermissionsGuard(reflecter); 18 | }); 19 | 20 | afterEach(() => { 21 | mockClear(reflecter); 22 | }); 23 | 24 | it('should be defined', () => { 25 | expect(guard).toBeDefined(); 26 | }); 27 | 28 | it('should skip(return true) if the `HasPermissions` decorator is not set', async () => { 29 | const context = mock(); 30 | context.getHandler.mockReturnValue({} as any); 31 | reflecter.get 32 | .mockReturnValue([]) 33 | .calledWith(HAS_PERMISSIONS_KEY, context.getHandler()); 34 | 35 | const result = await guard.canActivate(context); 36 | 37 | expect(result).toBeTruthy(); 38 | expect(reflecter.get).toBeCalledTimes(1); 39 | }); 40 | 41 | it('should return true if the `HasPermissions` decorator is set', async () => { 42 | const context = mock(); 43 | context.getHandler.mockReturnValue({} as any); 44 | 45 | const mockedCreate = jest 46 | .fn() 47 | .mockImplementation((ctx: ExecutionContext) => { 48 | return { 49 | getContext: jest.fn().mockReturnValue({ 50 | req: { 51 | user: { permissions: [PermissionType.WRITE_POSTS] } as any, 52 | } as any, 53 | }), 54 | } as any; 55 | }); 56 | GqlExecutionContext.create = mockedCreate; 57 | 58 | reflecter.get 59 | .mockReturnValue([PermissionType.WRITE_POSTS]) 60 | .calledWith(HAS_PERMISSIONS_KEY, context.getHandler()); 61 | 62 | const result = await guard.canActivate(context); 63 | 64 | expect(result).toBeTruthy(); 65 | expect(reflecter.get).toBeCalledTimes(1); 66 | }); 67 | 68 | it('should return false if the `HasPermissions` decorator is set but permission is not allowed', async () => { 69 | const context = mock(); 70 | context.getHandler.mockReturnValue({} as any); 71 | 72 | const mockedCreate = jest 73 | .fn() 74 | .mockImplementation((ctx: ExecutionContext) => { 75 | return { 76 | getContext: jest.fn().mockReturnValue({ 77 | req: { 78 | user: { permissions: [PermissionType.WRITE_POSTS] } as any, 79 | } as any, 80 | }), 81 | } as any; 82 | }); 83 | GqlExecutionContext.create = mockedCreate; 84 | 85 | //but requires DELETE_POSTS permission 86 | reflecter.get 87 | .mockReturnValue([PermissionType.DELETE_POSTS]) 88 | .calledWith(HAS_PERMISSIONS_KEY, context.getHandler()); 89 | 90 | const result = await guard.canActivate(context); 91 | 92 | expect(result).toBeFalsy(); 93 | expect(reflecter.get).toBeCalledTimes(1); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /api/src/authz/has-permissions.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { Observable } from 'rxjs'; 5 | import { AuthenticatedRequest } from './authenticated-request.interface'; 6 | import { HAS_PERMISSIONS_KEY } from './authz.constants'; 7 | import { PermissionType } from './permission-type.enum'; 8 | 9 | @Injectable() 10 | export class HasPermissionsGuard implements CanActivate { 11 | constructor(private readonly reflector: Reflector) {} 12 | canActivate( 13 | context: ExecutionContext, 14 | ): boolean | Promise | Observable { 15 | const routePermissions = this.reflector.get( 16 | HAS_PERMISSIONS_KEY, 17 | context.getHandler(), 18 | ); 19 | if (!routePermissions || routePermissions.length == 0) { 20 | return true; 21 | } 22 | console.log('route requires permissions:', routePermissions); 23 | const { user } = GqlExecutionContext.create(context).getContext() 24 | .req as AuthenticatedRequest; 25 | const { permissions } = user; 26 | console.log('has permissions:', permissions); 27 | return permissions && permissions.some((r) => routePermissions.includes(r)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/src/authz/jwt-auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/nestjs-testing'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { JwtAuthGuard } from './jwt-auth.guard'; 5 | 6 | describe('LocalAuthGuard', () => { 7 | let guard: JwtAuthGuard; 8 | beforeEach(() => { 9 | guard = new JwtAuthGuard(); 10 | }); 11 | 12 | it('should be defined', () => { 13 | expect(guard).toBeDefined(); 14 | }); 15 | 16 | it('should return true for `canActivate`', async () => { 17 | AuthGuard('jwt').prototype.canActivate = jest.fn(() => 18 | Promise.resolve(true), 19 | ); 20 | AuthGuard('jwt').prototype.logIn = jest.fn(() => Promise.resolve()); 21 | expect( 22 | await guard.canActivate(createMock()), 23 | ).toBeTruthy(); 24 | }); 25 | 26 | it('handleRequest: error', async () => { 27 | const error = { name: 'test', message: 'error' } as Error; 28 | 29 | try { 30 | guard.handleRequest(error, {}, {}); 31 | } catch (e) { 32 | //console.log(e); 33 | expect(e).toEqual(error); 34 | } 35 | }); 36 | 37 | it('handleRequest', async () => { 38 | expect( 39 | await guard.handleRequest(undefined, { username: 'hantsy' }, undefined), 40 | ).toEqual({ username: 'hantsy' }); 41 | }); 42 | 43 | it('handleRequest: Unauthorized', async () => { 44 | try { 45 | guard.handleRequest(undefined, undefined, undefined); 46 | } catch (e) { 47 | // console.log(e); 48 | expect(e).toBeDefined(); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /api/src/authz/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; 7 | import { GqlExecutionContext } from '@nestjs/graphql'; 8 | import { AuthGuard } from '@nestjs/passport'; 9 | import { Observable } from 'rxjs'; 10 | 11 | @Injectable() 12 | export class JwtAuthGuard extends AuthGuard('jwt') { 13 | canActivate( 14 | context: ExecutionContext, 15 | ): boolean | Promise | Observable { 16 | // Add your custom authentication logic here 17 | // for example, call super.logIn(request) to establish a session. 18 | //return super.canActivate(context); 19 | const ctx = GqlExecutionContext.create(context); 20 | const { req } = ctx.getContext(); 21 | return super.canActivate(new ExecutionContextHost([req])); 22 | } 23 | 24 | handleRequest(err, user, info) { 25 | // You can throw an exception based on either "info" or "err" arguments 26 | if (err || !user) { 27 | throw err || new UnauthorizedException(); 28 | } 29 | return user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/src/authz/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | sub: string; 3 | email?: string; 4 | name?: string; 5 | permissions?: string[]; 6 | } 7 | -------------------------------------------------------------------------------- /api/src/authz/jwt.strategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { mock } from 'jest-mock-extended'; 4 | import auth0Config from '../config/auth0.config'; 5 | import { JwtStrategy } from './jwt.strategy'; 6 | import { PermissionType } from './permission-type.enum'; 7 | 8 | describe('JwtStrategy', () => { 9 | let strategy: JwtStrategy; 10 | let config: ConfigType; 11 | beforeEach(async () => { 12 | const app: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | JwtStrategy, 15 | { 16 | provide: auth0Config.KEY, 17 | useValue: { 18 | audience: 'http://api', 19 | issuerUri: 'http://test/', 20 | }, 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | strategy = app.get(JwtStrategy); 26 | config = app.get>(auth0Config.KEY); 27 | }); 28 | 29 | describe('validate', () => { 30 | it('should return user principal if user and password is provided ', async () => { 31 | expect(config.audience).toBe('http://api'); 32 | expect(config.issuerUri).toBe('http://test/'); 33 | const user = await strategy.validate({ 34 | sub: 'testsub', 35 | email: 'test@example.com', 36 | permissions: ['write:posts'], 37 | }); 38 | expect(user.email).toEqual('test@example.com'); 39 | expect(user.permissions).toEqual(['write:posts']); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('JwtStrategy(call supper)', () => { 45 | let local; 46 | let parentMock; 47 | 48 | beforeEach(() => { 49 | local = Object.getPrototypeOf(JwtStrategy); 50 | parentMock = jest.fn(); 51 | Object.setPrototypeOf(JwtStrategy, parentMock); 52 | }); 53 | 54 | afterEach(() => { 55 | Object.setPrototypeOf(JwtStrategy, local); 56 | }); 57 | 58 | it('should call constructor', () => { 59 | const config = mock>(); 60 | config.audience = 'http://api'; 61 | config.issuerUri = 'http://test/'; 62 | new JwtStrategy(config); 63 | expect(parentMock.mock.calls.length).toBe(1); 64 | 65 | expect(parentMock.mock.calls[0][0].jwtFromRequest).toBeDefined(); 66 | expect(parentMock.mock.calls[0][0].ignoreExpiration).toBeFalsy(); 67 | expect(parentMock.mock.calls[0][0].audience).toBe('http://api'); 68 | expect(parentMock.mock.calls[0][0].issuer).toBe('http://test/'); 69 | expect(parentMock.mock.calls[0][0].secretOrKeyProvider).toBeDefined(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /api/src/authz/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | // src/authz/jwt.strategy.ts 2 | 3 | import { Injectable } from '@nestjs/common'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import { passportJwtSecret } from 'jwks-rsa'; 7 | import { Inject } from '@nestjs/common'; 8 | import { ConfigType } from '@nestjs/config'; 9 | import auth0Config from '../config/auth0.config'; 10 | import { JwtPayload } from './jwt-payload.interface'; 11 | import { UserPrincipal } from './user-principal.interface'; 12 | import { PermissionType } from './permission-type.enum'; 13 | 14 | @Injectable() 15 | export class JwtStrategy extends PassportStrategy(Strategy) { 16 | constructor(@Inject(auth0Config.KEY) config: ConfigType) { 17 | super({ 18 | secretOrKeyProvider: passportJwtSecret({ 19 | cache: true, 20 | rateLimit: true, 21 | jwksRequestsPerMinute: 5, 22 | jwksUri: `${config.issuerUri}.well-known/jwks.json`, 23 | }), 24 | 25 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 26 | audience: config.audience, 27 | issuer: config.issuerUri, 28 | algorithms: ['RS256'], 29 | }); 30 | } 31 | 32 | validate(payload: JwtPayload): UserPrincipal { 33 | console.log('jwt payload:', JSON.stringify(payload)); 34 | return { 35 | userId: payload.sub, 36 | email: payload.email, 37 | name: payload.name, 38 | permissions: payload.permissions.map((p) => p), 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /api/src/authz/permission-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PermissionType { 2 | READ_POSTS = 'read:posts', 3 | WRITE_POSTS = 'write:posts', 4 | DELETE_POSTS = 'delete:posts', 5 | } 6 | // cast value to enum 7 | // see:https://dev.to/unhurried/typescript-implement-valueof-method-in-enums-2gme 8 | // and https://stackoverflow.com/questions/62821682/how-can-i-cast-a-string-to-an-enum-in-typescript 9 | -------------------------------------------------------------------------------- /api/src/authz/user-principal.interface.ts: -------------------------------------------------------------------------------- 1 | import { PermissionType } from './permission-type.enum'; 2 | export interface UserPrincipal { 3 | userId: string; 4 | email?: string; 5 | name?: string; 6 | permissions?: PermissionType[]; 7 | } 8 | -------------------------------------------------------------------------------- /api/src/config/auth0.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigType } from '@nestjs/config'; 2 | import { TestingModule, Test } from '@nestjs/testing'; 3 | import auth0Config from './auth0.config'; 4 | 5 | describe('auth0Config', () => { 6 | let config: ConfigType; 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | imports: [ConfigModule.forFeature(auth0Config)], 10 | }).compile(); 11 | 12 | config = module.get>(auth0Config.KEY); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(auth0Config).toBeDefined(); 17 | }); 18 | 19 | it('should contains audience and issuerUri', async () => { 20 | expect(config.audience).toBe('https://hantsy.github.io/api'); 21 | expect(config.issuerUri).toBe('https://dev-ese8241b.us.auth0.com/'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /api/src/config/auth0.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('auth0', () => ({ 4 | audience: process.env.AUTH0_AUDIENCE || 'https://hantsy.github.io/api', 5 | issuerUri: 6 | process.env.AUTH0_ISSUER_URI || 'https://dev-ese8241b.us.auth0.com/', 7 | })); 8 | -------------------------------------------------------------------------------- /api/src/config/db.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigType } from '@nestjs/config'; 2 | import { TestingModule, Test } from '@nestjs/testing'; 3 | import dbConfig from './db.config'; 4 | 5 | describe('dbConfig', () => { 6 | let config: ConfigType; 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | imports: [ConfigModule.forFeature(dbConfig)], 10 | }).compile(); 11 | 12 | config = module.get>(dbConfig.KEY); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(dbConfig).toBeDefined(); 17 | }); 18 | 19 | it('should contains type, host, port, database, username, password', async () => { 20 | expect(config.type).toBe('postgres'); 21 | expect(config.url).toBe('postgres://user:password@localhost/blogdb'); 22 | // expect(config.host).toBe('localhost'); 23 | // expect(config.port).toBe(5432); 24 | // expect(config.database).toBe('blogdb'); 25 | // expect(config.username).toBe('user'); 26 | // expect(config.password).toBe('password'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /api/src/config/db.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | export default registerAs( 3 | 'db', 4 | () => 5 | ({ 6 | type: process.env.DB_TYPE || 'postgres', 7 | url: process.env.DB_URL || 'postgres://user:password@localhost/blogdb', 8 | // host: process.env.DB_HOST || 'localhost', 9 | // port: process.env.DB_PORT || 5432, 10 | // database: process.env.DB_NAME || 'blogdb', 11 | // username: process.env.DB_USERNAME || 'user', 12 | // password: process.env.DB_PASSWORD || 'password', 13 | } as { 14 | type: string; 15 | url?: string; 16 | host?: string; 17 | port?: number; 18 | database?: string; 19 | username?: string; 20 | password?: string; 21 | }), 22 | ); 23 | -------------------------------------------------------------------------------- /api/src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { PostsDataInitializer } from './posts-data-initializer'; 4 | import { CommentRepository } from './repository/comment.repository'; 5 | import { PostRepository } from './repository/post.repository'; 6 | import { UserRepository } from './repository/user.repository'; 7 | 8 | @Module({ 9 | imports: [ 10 | TypeOrmModule.forFeature([ 11 | PostRepository, 12 | UserRepository, 13 | CommentRepository, 14 | ]), 15 | ], 16 | exports: [TypeOrmModule], 17 | providers: [PostsDataInitializer], 18 | }) 19 | export class DatabaseModule {} 20 | -------------------------------------------------------------------------------- /api/src/database/entity/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | RelationId, 8 | PrimaryGeneratedColumn, 9 | } from 'typeorm'; 10 | import { PostEntity } from './post.entity'; 11 | import { UserEntity } from './user.entity'; 12 | 13 | @Entity({ name: 'comments' }) 14 | export class CommentEntity { 15 | @PrimaryGeneratedColumn('uuid') 16 | id: string; 17 | 18 | @Column() 19 | content: string; 20 | 21 | @ManyToOne((type) => PostEntity, (p) => p.comments) 22 | @JoinColumn({ name: 'post_id' }) 23 | post: PostEntity; 24 | 25 | @RelationId((comment: CommentEntity) => comment.post) 26 | postId?: string; 27 | 28 | @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) 29 | createdAt: Date; 30 | } 31 | -------------------------------------------------------------------------------- /api/src/database/entity/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | RelationId, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import { CommentEntity } from './comment.entity'; 13 | import { UserEntity } from './user.entity'; 14 | 15 | @Entity({ name: 'posts' }) 16 | export class PostEntity { 17 | @PrimaryGeneratedColumn('uuid') 18 | id?: string; 19 | 20 | @Column() 21 | title: string; 22 | 23 | @Column({ nullable: true }) 24 | content?: string; 25 | 26 | @OneToMany((type) => CommentEntity, (comment) => comment.post, { 27 | cascade: true, 28 | }) 29 | comments?: Promise; 30 | 31 | @ManyToOne((type) => UserEntity, { nullable: true }) 32 | @JoinColumn({ name: 'author_id' }) 33 | author?: UserEntity; 34 | 35 | @RelationId((post: PostEntity) => post.author) 36 | authorId?: string; 37 | 38 | @CreateDateColumn({ name: 'created_at', type: 'timestamp', nullable: true }) 39 | createdAt?: Date; 40 | 41 | @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) 42 | updatedAt?: Date; 43 | } 44 | -------------------------------------------------------------------------------- /api/src/database/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryColumn } from 'typeorm'; 2 | 3 | @Entity({ name: 'users' }) 4 | export class UserEntity { 5 | @PrimaryColumn('varchar', { 6 | name: 'id', 7 | }) 8 | id: string; 9 | 10 | @Column({ nullable: true, unique: true, default: 'admin@example.com' }) 11 | email?: string; 12 | 13 | @Column({ nullable: true, default: 'admin' }) 14 | name?: string; 15 | 16 | // @OneToMany((type) => PostEntity, (post) => post.author, { 17 | // cascade: false, 18 | // }) 19 | // posts?: Promise; 20 | } 21 | -------------------------------------------------------------------------------- /api/src/database/posts-data-initializer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { EntityManager } from 'typeorm'; 3 | import { PostEntity } from './entity/post.entity'; 4 | import { PostRepository } from './repository/post.repository'; 5 | import { UserEntity } from './entity/user.entity'; 6 | import { CommentEntity } from './entity/comment.entity'; 7 | 8 | @Injectable() 9 | export class PostsDataInitializer implements OnModuleInit { 10 | private data: any[] = [ 11 | { 12 | title: 'Generate a NestJS project', 13 | content: 'content', 14 | }, 15 | { 16 | title: 'Create GrapQL APIs', 17 | content: 'content', 18 | }, 19 | { 20 | title: 'Connect to Postgres via TypeORM', 21 | content: 'content', 22 | }, 23 | ]; 24 | 25 | constructor( 26 | private readonly postRepository: PostRepository, 27 | private readonly manager: EntityManager, 28 | ) {} 29 | 30 | async onModuleInit(): Promise { 31 | await this.manager.transaction(async (mgr) => { 32 | // NOTE: you must perform all database operations using the given manager instance 33 | // it's a special instance of EntityManager working with this transaction 34 | // and don't forget to await things here 35 | 36 | const commentDel = await mgr.delete(CommentEntity, {}); 37 | console.log('comments deleted: ', commentDel.affected); 38 | 39 | const del = await mgr.delete(PostEntity, {}); 40 | console.log('posts deleted: ', del.affected); 41 | 42 | const userDel = await mgr.delete(UserEntity, {}); 43 | console.log('users deleted: ', userDel.affected); 44 | 45 | const user = new UserEntity(); 46 | 47 | Object.assign(user, { 48 | id: 'test@id', 49 | email: 'hantsy@gmail.com', 50 | name: 'hantsy', 51 | }); 52 | 53 | await mgr.save(user); 54 | await Promise.all( 55 | this.data.map(async (d) => { 56 | const p = new PostEntity(); 57 | Object.assign(p, d); 58 | p.author = user; 59 | 60 | const c = new CommentEntity(); 61 | c.content = 'test comment at:' + new Date(); 62 | p.comments = Promise.resolve([c]); 63 | await mgr.save(p); 64 | }), 65 | ); 66 | }); 67 | 68 | const post = new PostEntity(); 69 | post.title = 'test title'; 70 | post.content = 'test content'; 71 | const user = new UserEntity(); 72 | Object.assign(user, { 73 | id: 'test@id2', 74 | email: 'hantsy2@gmail.com', 75 | name: 'hantsy2', 76 | }); 77 | this.manager.save(user); 78 | post.author = user; 79 | const comment = new CommentEntity(); 80 | comment.content = 'test comment'; 81 | post.comments = Promise.resolve([comment]); 82 | const saved = await this.postRepository.save(post); 83 | console.log('saved from repository: ', JSON.stringify(saved)); 84 | 85 | const savedPosts = await this.postRepository.find({ 86 | relations: ['comments', 'author'], 87 | }); 88 | console.log('saved:', JSON.stringify(savedPosts)); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /api/src/database/repository/comment.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Repository, EntityManager, SelectQueryBuilder } from 'typeorm'; 3 | import { PostEntity } from '../entity/post.entity'; 4 | import { CommentRepository } from './comment.repository'; 5 | import { PostRepository } from './post.repository'; 6 | 7 | describe('CommentRepository', () => { 8 | let commentRepository: CommentRepository; 9 | let manager: EntityManager; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | CommentRepository, 15 | { provide: EntityManager, useValue: { find: jest.fn() } }, 16 | ], 17 | }).compile(); 18 | 19 | commentRepository = module.get(CommentRepository); 20 | manager = module.get(EntityManager); 21 | }); 22 | 23 | describe('findByPostId', () => { 24 | it('should return comments by post id', async () => { 25 | const postId = 'postId'; 26 | const posts = [ 27 | { content: 'test conent' }, 28 | { content: 'test conent' }, 29 | ]; 30 | 31 | const queryBuilderSpy = jest 32 | .spyOn(Repository.prototype, 'createQueryBuilder') 33 | .mockReturnValue(SelectQueryBuilder.prototype); 34 | const whereSpy = jest 35 | .spyOn(SelectQueryBuilder.prototype, 'where') 36 | .mockReturnThis(); 37 | const setParameterSpy = jest 38 | .spyOn(SelectQueryBuilder.prototype, 'setParameter') 39 | .mockReturnThis(); 40 | // the same goes for setParameter, skip and take methods 41 | const getManySpy = jest 42 | .spyOn(SelectQueryBuilder.prototype, 'getMany') 43 | .mockResolvedValue(posts); 44 | 45 | const foundPosts = await commentRepository.findByPostId(postId); 46 | expect(foundPosts).toEqual(posts); 47 | expect(whereSpy).toHaveBeenCalledWith('comment.post.id=:id'); 48 | expect(setParameterSpy).toHaveBeenCalledWith('id', postId); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /api/src/database/repository/comment.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { CommentEntity } from '../entity/comment.entity'; 3 | 4 | @EntityRepository(CommentEntity) 5 | export class CommentRepository extends Repository { 6 | // 7 | findByPostId(id: string): Promise { 8 | return this.createQueryBuilder('comment') 9 | .where('comment.post.id=:id') 10 | .setParameter('id', id) 11 | .getMany(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /api/src/database/repository/post.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Repository, EntityManager, SelectQueryBuilder } from 'typeorm'; 3 | import { PostEntity } from '../entity/post.entity'; 4 | import { PostRepository } from './post.repository'; 5 | 6 | describe('PostRepository', () => { 7 | let postRepository: PostRepository; 8 | let manager: EntityManager; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | PostRepository, 14 | { provide: EntityManager, useValue: { find: jest.fn() } }, 15 | ], 16 | }).compile(); 17 | 18 | postRepository = module.get(PostRepository); 19 | manager = module.get(EntityManager); 20 | }); 21 | 22 | describe('findByAuthor', () => { 23 | it('should return posts by author', async () => { 24 | const author = 'testid'; 25 | const posts = [ 26 | { title: 'test title', content: 'test conent' }, 27 | { title: 'test title', content: 'test conent' }, 28 | ]; 29 | 30 | Object.defineProperty(Repository.prototype, 'manager', { 31 | value: manager, 32 | }); 33 | 34 | const findSpy = jest.spyOn(manager, 'find').mockResolvedValue(posts); 35 | 36 | const foundPosts = await postRepository.findByAuthor(author); 37 | expect(foundPosts).toEqual(posts); 38 | expect(findSpy).toHaveBeenCalledWith(PostEntity, { 39 | author: { id: author }, 40 | }); 41 | }); 42 | }); 43 | 44 | describe('findAll', () => { 45 | it('should return posts by search creteria', async () => { 46 | const keyword = 'test'; 47 | const posts = [ 48 | { title: 'test title', content: 'test conent' }, 49 | { title: 'test title', content: 'test conent' }, 50 | ]; 51 | 52 | const queryBuilderSpy = jest 53 | .spyOn(Repository.prototype, 'createQueryBuilder') 54 | .mockReturnValue(SelectQueryBuilder.prototype); 55 | const whereSpy = jest 56 | .spyOn(SelectQueryBuilder.prototype, 'where') 57 | .mockReturnThis(); 58 | const setParameterSpy = jest 59 | .spyOn(SelectQueryBuilder.prototype, 'setParameter') 60 | .mockReturnThis(); 61 | const skipSpy = jest 62 | .spyOn(SelectQueryBuilder.prototype, 'skip') 63 | .mockReturnThis(); 64 | const takeSpy = jest 65 | .spyOn(SelectQueryBuilder.prototype, 'take') 66 | .mockReturnThis(); 67 | // the same goes for setParameter, skip and take methods 68 | const getManySpy = jest 69 | .spyOn(SelectQueryBuilder.prototype, 'getMany') 70 | .mockResolvedValue(posts); 71 | 72 | const foundPosts = await postRepository.findAll(keyword, 0, 10); 73 | expect(foundPosts).toEqual(posts); 74 | expect(whereSpy).toHaveBeenCalledWith('p.title like :q or p.content like :q'); 75 | expect(setParameterSpy).toHaveBeenCalledWith('q', '%' + keyword + '%'); 76 | expect(skipSpy).toHaveBeenCalledWith(0); 77 | expect(takeSpy).toHaveBeenCalledWith(10); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /api/src/database/repository/post.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { PostEntity } from '../entity/post.entity'; 3 | 4 | @EntityRepository(PostEntity) 5 | export class PostRepository extends Repository { 6 | findAll(q: string, offset: number, limit: number): Promise { 7 | return this.createQueryBuilder('p') 8 | .where('p.title like :q or p.content like :q') 9 | .setParameter('q', '%' + q + '%') 10 | .skip(offset) 11 | .take(limit) 12 | .getMany(); 13 | } 14 | 15 | findByAuthor(id: string): Promise { 16 | return this.manager.find(PostEntity, { author: { id: id } }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/src/database/repository/user.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Repository } from 'typeorm'; 3 | import { UserRepository } from './user.repository'; 4 | 5 | describe('UserRepository', () => { 6 | let userRepository: UserRepository; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [UserRepository], 11 | }).compile(); 12 | 13 | userRepository = module.get(UserRepository); 14 | }); 15 | 16 | describe('findByEmail', () => { 17 | it('should return found user', async () => { 18 | const email = 'email'; 19 | const user = { 20 | email, 21 | }; 22 | const findOneSpy = jest 23 | .spyOn(Repository.prototype, 'findOne') 24 | .mockResolvedValue(user); 25 | 26 | const foundUser = await userRepository.findByEmail(email); 27 | expect(foundUser).toEqual(user); 28 | expect(findOneSpy).toHaveBeenCalledWith(user); 29 | }); 30 | }); 31 | }); -------------------------------------------------------------------------------- /api/src/database/repository/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { UserEntity } from '../entity/user.entity'; 3 | 4 | @EntityRepository(UserEntity) 5 | export class UserRepository extends Repository { 6 | findByEmail(email: string): Promise { 7 | return this.findOne({ email: email }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /api/src/gql/dataloaders/posts.loaders.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { of } from 'rxjs'; 3 | 4 | import { PostsService } from '../service/posts.service'; 5 | import { UsersService } from '../service/users.service'; 6 | import { Comment } from '../types/comment.model'; 7 | import { User } from '../types/user.model'; 8 | import PostsLoaders from './posts.loaders'; 9 | 10 | describe('PostsLoaders', () => { 11 | let loaders: PostsLoaders; 12 | let posts: PostsService; 13 | let users: UsersService; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | PostsLoaders, 19 | { 20 | provide: PostsService, 21 | useValue: { 22 | new: jest.fn(), 23 | constructor: jest.fn(), 24 | findCommentsByPostIds: jest.fn(), 25 | }, 26 | }, 27 | { 28 | provide: UsersService, 29 | useValue: { 30 | new: jest.fn(), 31 | constructor: jest.fn(), 32 | findByIds: jest.fn(), 33 | }, 34 | }, 35 | ], 36 | }).compile(); 37 | 38 | 39 | // resovle a REQUEST scoped component 40 | loaders = await module.resolve(PostsLoaders); 41 | posts = module.get(PostsService); 42 | users = module.get(UsersService); 43 | }); 44 | 45 | it('defined', () => { 46 | expect(loaders).toBeDefined(); 47 | }); 48 | 49 | it('loadAuthors', async () => { 50 | const data = [ 51 | { id: '1', email: 'user@example.com' }, 52 | { id: '2', email: 'user@example.com' }, 53 | ] as User[]; 54 | const findByIdsSpy = jest.spyOn(users, 'findByIds').mockReturnValue(of(data)); 55 | const loadedUsers = await loaders.loadAuthors.load("1"); 56 | expect(loadedUsers).toEqual( { id: '1', email: 'user@example.com' }); 57 | expect(findByIdsSpy).toHaveBeenCalled(); 58 | }); 59 | 60 | it('loadComments', async () => { 61 | const data = [ 62 | { id: '1', postId:'2', content:'test content' }, 63 | { id: '2', postId:'2', content: 'test content 2' }, 64 | ] as Comment[]; 65 | const findCommentsByPostIdSpy = jest.spyOn(posts, 'findCommentsByPostIds').mockReturnValue(of(data)); 66 | const loadedData = await loaders.loadComments.load("2"); 67 | expect(loadedData).toEqual( data); 68 | expect(findCommentsByPostIdSpy).toHaveBeenCalled(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /api/src/gql/dataloaders/posts.loaders.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from '@nestjs/common'; 2 | import { UsersService } from '../service/users.service'; 3 | import * as DataLoader from 'dataloader'; 4 | import { map } from 'rxjs/operators'; 5 | import { lastValueFrom } from 'rxjs'; 6 | import { PostsService } from '../service/posts.service'; 7 | 8 | @Injectable({ scope: Scope.REQUEST }) 9 | export default class PostsLoaders { 10 | constructor( 11 | private postService: PostsService, 12 | private usersService: UsersService, 13 | ) {} 14 | 15 | public readonly loadAuthors = new DataLoader((authorIds: string[]) => { 16 | console.log('dataloaders: ', authorIds); 17 | return lastValueFrom( 18 | this.usersService.findByIds(authorIds).pipe( 19 | map((users) => new Map(users.map((user) => [user.id, user]))), 20 | map((usersMap) => { 21 | return authorIds.map((authorId) => usersMap.get(authorId)); 22 | }), 23 | ), 24 | ); 25 | }); 26 | 27 | public readonly loadComments = new DataLoader((postIds: string[]) => { 28 | console.log('dataloaders: ', postIds); 29 | return lastValueFrom( 30 | this.postService.findCommentsByPostIds(postIds).pipe( 31 | map((comments) => { 32 | return postIds.map((postId) => 33 | comments.filter((c) => c.postId == postId), 34 | ); 35 | }), 36 | ), 37 | ); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /api/src/gql/directives/upper-case.directive.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from '@graphql-tools/utils'; 2 | import { defaultFieldResolver, GraphQLField } from 'graphql'; 3 | 4 | export class UpperCaseDirective extends SchemaDirectiveVisitor { 5 | visitFieldDefinition(field: GraphQLField) { 6 | const { resolve = defaultFieldResolver } = field; 7 | field.resolve = async function(...args) { 8 | const result = await resolve.apply(this, args); 9 | if (typeof result === 'string') { 10 | return result.toUpperCase(); 11 | } 12 | return result; 13 | }; 14 | } 15 | } -------------------------------------------------------------------------------- /api/src/gql/dto/comment.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { IsNotEmpty, MinLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class CommentInput { 6 | @Field() 7 | postId: string; 8 | 9 | @Field() 10 | @IsNotEmpty() 11 | @MinLength(10) 12 | content: string; 13 | } 14 | -------------------------------------------------------------------------------- /api/src/gql/dto/post.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { IsNotEmpty, MinLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class PostInput { 6 | @Field({ nullable: true }) 7 | id?: string; 8 | 9 | @Field() 10 | @IsNotEmpty() 11 | title: string; 12 | 13 | @Field() 14 | @IsNotEmpty() 15 | @MinLength(10) 16 | content: string; 17 | } 18 | -------------------------------------------------------------------------------- /api/src/gql/dto/posts.arg.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, Int } from '@nestjs/graphql'; 2 | import { Max, Min } from 'class-validator'; 3 | 4 | @ArgsType() 5 | export class PostsArgs { 6 | @Field((type) => String) 7 | keyword = ''; 8 | 9 | @Field((type) => Int) 10 | @Min(0) 11 | skip = 0; 12 | 13 | @Field((type) => Int) 14 | @Min(1) 15 | @Max(50) 16 | take = 25; 17 | } 18 | -------------------------------------------------------------------------------- /api/src/gql/filters/http.exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; 2 | import { GqlArgumentsHost, GqlExceptionFilter } from '@nestjs/graphql'; 3 | import { ApolloError } from 'apollo-server-errors'; 4 | 5 | @Catch(HttpException) 6 | export class HttpExceptionFilter implements GqlExceptionFilter { 7 | catch(exception: HttpException, host: ArgumentsHost) { 8 | const gqlHost = GqlArgumentsHost.create(host); 9 | //console.log('gqlHost:', gqlHost); 10 | return exception; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api/src/gql/gql-api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PostsResolver } from './resolvers/posts.resolver'; 3 | import { PostsService } from './service/posts.service'; 4 | import { DatabaseModule } from '../database/database.module'; 5 | import { PubSub } from 'graphql-subscriptions'; 6 | import { UsersResolver } from './resolvers/users.resolver'; 7 | import { UsersService } from './service/users.service'; 8 | import PostsLoaders from './dataloaders/posts.loaders'; 9 | import { DateScalar } from './scalars/date.scalar'; 10 | import { LoggingPlugin } from './plugins/logging.plugin'; 11 | 12 | @Module({ 13 | imports: [DatabaseModule], 14 | providers: [ 15 | { 16 | provide: PubSub, 17 | useValue: new PubSub(), 18 | }, 19 | PostsResolver, 20 | UsersResolver, 21 | UsersService, 22 | PostsService, 23 | PostsLoaders, 24 | // DateScalar, LoggingPlugin 25 | ], 26 | exports: [], 27 | }) 28 | export class GqlApiModule {} 29 | -------------------------------------------------------------------------------- /api/src/gql/plugins/logging.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from '@nestjs/graphql'; 2 | import { 3 | ApolloServerPlugin, 4 | GraphQLRequestListener, 5 | } from 'apollo-server-plugin-base'; 6 | 7 | @Plugin() 8 | export class LoggingPlugin implements ApolloServerPlugin { 9 | async requestDidStart(): Promise { 10 | console.log('Request started'); 11 | return { 12 | async willSendResponse() { 13 | console.log('Will send response'); 14 | }, 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/post-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-errors'; 2 | export class PostNotFoundError extends ApolloError { 3 | postId: string; 4 | constructor(id: string) { 5 | super('Post:' + id + ' was not found', 'POST_NOT_FOUND'); 6 | this.postId = id; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/posts.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PubSub } from 'graphql-subscriptions'; 3 | import { of, lastValueFrom } from 'rxjs'; 4 | import { Comment } from '../types/comment.model'; 5 | import { Post } from '../types/post.model'; 6 | import { PostsResolver } from './posts.resolver'; 7 | import { PostsService } from '../service/posts.service'; 8 | import PostsLoaders from '../dataloaders/posts.loaders'; 9 | import { PostInput } from '../dto/post.input'; 10 | import { UsersService } from '../service/users.service'; 11 | import { any, mock } from 'jest-mock-extended'; 12 | import DataLoader from 'dataloader'; 13 | import { User } from '../types/user.model'; 14 | 15 | describe('PostsResolver', () => { 16 | let resolver: PostsResolver; 17 | let loaders: PostsLoaders; 18 | let posts: PostsService; 19 | let pubSub: PubSub; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | PostsResolver, 25 | { 26 | provide: PostsService, 27 | useValue: { 28 | new: jest.fn(), 29 | constructor: jest.fn(), 30 | findOne: jest.fn(), 31 | createPost: jest.fn(), 32 | addComment: jest.fn(), 33 | findAll: jest.fn(), 34 | findByAuthor: jest.fn(), 35 | findCommentsByPostIds: jest.fn(), 36 | }, 37 | }, 38 | { 39 | provide: UsersService, 40 | useValue: { 41 | new: jest.fn(), 42 | constructor: jest.fn(), 43 | findOne: jest.fn(), 44 | }, 45 | }, 46 | { 47 | provide: PostsLoaders, 48 | useValue: { 49 | new: jest.fn(), 50 | constructor: jest.fn(), 51 | loadComments: jest.fn(), 52 | }, 53 | }, 54 | { 55 | provide: PubSub, 56 | useValue: { 57 | publish: jest.fn(), 58 | asyncIterator: jest.fn(), 59 | }, 60 | }, 61 | ], 62 | }).compile(); 63 | 64 | resolver = module.get(PostsResolver); 65 | loaders = await module.resolve(PostsLoaders); 66 | posts = module.get(PostsService); 67 | pubSub = module.get(PubSub); 68 | }); 69 | 70 | it('should be defined', () => { 71 | expect(resolver).toBeDefined(); 72 | expect(posts).toBeDefined(); 73 | }); 74 | 75 | it('should resolve all posts', async () => { 76 | const data = [ 77 | { 78 | id: '1', 79 | title: 'post 1', 80 | content: 'content 1', 81 | }, 82 | { 83 | id: '2', 84 | title: 'post 2', 85 | content: 'content 2', 86 | }, 87 | ]; 88 | 89 | jest.spyOn(posts, 'findAll').mockReturnValue(of(data)); 90 | const result = await lastValueFrom( 91 | resolver.getAllPosts({ keyword: 'test', skip: 0, take: 20 }), 92 | ); 93 | console.log('result: ' + JSON.stringify(result)); 94 | expect(result).toBeDefined(); 95 | expect(result.length).toBe(2); 96 | expect(posts.findAll).toHaveBeenCalledTimes(1); 97 | expect(posts.findAll).toBeCalledWith({ 98 | keyword: 'test', 99 | skip: 0, 100 | take: 20, 101 | }); 102 | }); 103 | 104 | it('should create post ', async () => { 105 | const data = { 106 | id: '1', 107 | title: 'post 1', 108 | content: 'content 1', 109 | } as Post; 110 | const inputData = { 111 | id: '1', 112 | title: 'post 1', 113 | content: 'content 1', 114 | } as PostInput; 115 | jest.spyOn(posts, 'createPost').mockReturnValue(of(data)); 116 | 117 | const result = await lastValueFrom( 118 | resolver.createPost({ userId: 'test' }, inputData), 119 | ); 120 | expect(result).toBeDefined(); 121 | expect(result.id).toBe('1'); 122 | }); 123 | 124 | it('should create comment of post ', async () => { 125 | const data = { 126 | id: '1', 127 | content: 'test comment', 128 | post: { 129 | id: '1', 130 | } as Post, 131 | } as Comment; 132 | const postsAddCommentSpy = jest 133 | .spyOn(posts, 'addComment') 134 | .mockReturnValue(of(data)); 135 | const pubSubPublishSpy = jest.spyOn(pubSub, 'publish'); 136 | 137 | const result = await lastValueFrom( 138 | resolver.addComment({ postId: '1', content: 'test comment' }), 139 | ); 140 | expect(result).toBeDefined(); 141 | expect(result.id).toBe('1'); 142 | expect(postsAddCommentSpy).toBeCalled(); 143 | expect(pubSubPublishSpy).toBeCalled(); 144 | }); 145 | 146 | it('should subscribe comment ', async () => { 147 | const pubSubPublishSpy = jest.spyOn(pubSub, 'asyncIterator'); 148 | 149 | resolver.addCommentHandler(); 150 | expect(pubSubPublishSpy).toBeCalled(); 151 | }); 152 | 153 | it('should resolve author by post id', async () => { 154 | const data = { id: '1', email: 'user@example.com' } as User; 155 | 156 | Object.defineProperty(loaders, 'loadAuthors', { 157 | value: { load: jest.fn().mockResolvedValue(data) }, 158 | }); 159 | 160 | Object.defineProperty(resolver, 'postsLoaders', { 161 | value: loaders, 162 | }); 163 | 164 | const result = await resolver.getAuthor({ id: '2' } as Post); 165 | console.log('result: ' + JSON.stringify(result)); 166 | expect(result).toEqual(data); 167 | }); 168 | it('should resolve all comments by post id', async () => { 169 | const data = [ 170 | { 171 | id: '1', 172 | content: 'test comment', 173 | postId: '2', 174 | }, 175 | { 176 | id: '2', 177 | content: 'test comment', 178 | postId: '2', 179 | }, 180 | ] as Comment[]; 181 | 182 | // const findCommentsOfPostSpy = jest 183 | // .spyOn(posts, 'findCommentsByPostIds') 184 | // .mockReturnValue(of(data)); 185 | 186 | // const findCommentsOfPostSpy = jest 187 | // .spyOn(loaders, 'loadComments') 188 | // .mockResolvedValue({ load: jest.fn().mockResolvedValue(data) }); 189 | 190 | Object.defineProperty(loaders, 'loadComments', { 191 | value: { load: jest.fn().mockResolvedValue(data) }, 192 | }); 193 | 194 | Object.defineProperty(resolver, 'postsLoaders', { 195 | value: loaders, 196 | }); 197 | 198 | const result = await resolver.comments({ id: '2' } as Post); 199 | console.log('result: ' + JSON.stringify(result)); 200 | expect(result).toEqual(data); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/posts.resolver.ts: -------------------------------------------------------------------------------- 1 | import { ParseUUIDPipe, UseGuards } from '@nestjs/common'; 2 | import { 3 | Args, 4 | Mutation, 5 | Parent, 6 | Query, 7 | ResolveField, 8 | Resolver, 9 | Subscription, 10 | } from '@nestjs/graphql'; 11 | import { PubSub } from 'graphql-subscriptions'; 12 | import { Observable } from 'rxjs'; 13 | import { tap, throwIfEmpty } from 'rxjs/operators'; 14 | import { GqlUser } from '../../authz/gql-user.decorator'; 15 | import { HasPermissions } from '../../authz/has-permissions.decorator'; 16 | import { HasPermissionsGuard } from '../../authz/has-permissions.guard'; 17 | import { JwtAuthGuard } from '../../authz/jwt-auth.guard'; 18 | import { PermissionType } from '../../authz/permission-type.enum'; 19 | import { UserPrincipal } from '../../authz/user-principal.interface'; 20 | import { CommentInput } from '../dto/comment.input'; 21 | import { PostInput } from '../dto/post.input'; 22 | import { PostsArgs } from '../dto/posts.arg'; 23 | import { Comment } from '../types/comment.model'; 24 | import { Post } from '../types/post.model'; 25 | import PostsLoaders from '../dataloaders/posts.loaders'; 26 | import { PostsService } from '../service/posts.service'; 27 | import { PostNotFoundError } from './post-not-found.error'; 28 | import { User } from '../types/user.model'; 29 | 30 | @Resolver((of) => Post) 31 | export class PostsResolver { 32 | constructor( 33 | private readonly postsService: PostsService, 34 | private readonly postsLoaders: PostsLoaders, 35 | private readonly pubSub: PubSub, 36 | ) {} 37 | 38 | // @Query(() => [Post]) 39 | // async posts( 40 | // @Info() info: GraphQLResolveInfo 41 | // ) { 42 | // const parsedInfo = parseResolveInfo(info) as ResolveTree; 43 | // const simplifiedInfo = simplifyParsedResolveInfoFragmentWithType( 44 | // parsedInfo, 45 | // info.returnType 46 | // ); 47 | 48 | // const posts = 'author' in simplifiedInfo.fields 49 | // ? await this.postsService.getPostsWithAuthors() 50 | // : await this.postsService.getPosts(); 51 | 52 | // return posts.items; 53 | // } 54 | 55 | @Query((returns) => Post) 56 | getPostById(@Args('postId', ParseUUIDPipe) id: string): Observable { 57 | return this.postsService 58 | .findById(id) 59 | .pipe(throwIfEmpty(() => new PostNotFoundError(id))); 60 | } 61 | 62 | @Query((returns) => [Post]) 63 | getAllPosts(@Args() postsArg: PostsArgs): Observable { 64 | return this.postsService.findAll(postsArg); 65 | } 66 | 67 | @ResolveField('author', (of) => User) 68 | async getAuthor(@Parent() post: Post): Promise { 69 | const { authorId } = post; 70 | console.log('resovle author field:', authorId); 71 | return await this.postsLoaders.loadAuthors.load(authorId); 72 | } 73 | 74 | // @ResolveField((of) => [Comment]) 75 | // public comments(@Parent() post: Post): Observable { 76 | // return this.postsService.findCommentsOfPost(post.id); 77 | // } 78 | 79 | @ResolveField((of) => [Comment]) 80 | async comments(@Parent() post: Post): Promise { 81 | return await this.postsLoaders.loadComments.load(post.id); 82 | } 83 | 84 | @Mutation((returns) => Post) 85 | @UseGuards(JwtAuthGuard, HasPermissionsGuard) 86 | @HasPermissions(PermissionType.WRITE_POSTS) 87 | createPost( 88 | @GqlUser() user: UserPrincipal, 89 | @Args('createPostInput') data: PostInput, 90 | ): Observable { 91 | return this.postsService.createPost(user.userId, data); 92 | } 93 | 94 | @Mutation((returns) => Comment) 95 | @UseGuards(JwtAuthGuard, HasPermissionsGuard) 96 | @HasPermissions(PermissionType.WRITE_POSTS) 97 | addComment( 98 | @Args('commentInput', ParseUUIDPipe) commentInput: CommentInput, 99 | ): Observable { 100 | return this.postsService 101 | .addComment(commentInput.postId, commentInput.content) 102 | .pipe( 103 | tap((c) => this.pubSub.publish('commentAdded', { commentAdded: c })), 104 | ); 105 | } 106 | 107 | @Subscription((returns) => Comment, { name: 'commentAdded' }) 108 | addCommentHandler() { 109 | return this.pubSub.asyncIterator('commentAdded'); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/user-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-errors'; 2 | export class UserNotFoundError extends ApolloError { 3 | postId: string; 4 | constructor(id: string) { 5 | super('User:' + id + ' was not found', 'POST_NOT_FOUND'); 6 | this.postId = id; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/users.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { lastValueFrom, of } from 'rxjs'; 3 | import { Post } from '../types/post.model'; 4 | import { PostsService } from '../service/posts.service'; 5 | import { User } from '../types/user.model'; 6 | import { UsersResolver } from './users.resolver'; 7 | import { UsersService } from '../service/users.service'; 8 | 9 | describe('UsersResolver', () => { 10 | let resolver: UsersResolver; 11 | let users: UsersService; 12 | let posts: PostsService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | UsersResolver, 18 | { 19 | provide: UsersService, 20 | useValue: { 21 | new: jest.fn(), 22 | constructor: jest.fn(), 23 | findById: jest.fn(), 24 | }, 25 | }, 26 | { 27 | provide: PostsService, 28 | useValue: { 29 | new: jest.fn(), 30 | constructor: jest.fn(), 31 | findByAuthor: jest.fn(), 32 | }, 33 | }, 34 | ], 35 | }).compile(); 36 | 37 | resolver = module.get(UsersResolver); 38 | users = module.get(UsersService); 39 | posts = module.get(PostsService); 40 | }); 41 | 42 | it('should be defined', () => { 43 | expect(resolver).toBeDefined(); 44 | expect(users).toBeDefined(); 45 | expect(posts).toBeDefined(); 46 | }); 47 | 48 | it('should resolve one user by id', async () => { 49 | const data = { 50 | id: '1', 51 | firstName: 'test firstName', 52 | lastName: 'test lastName', 53 | email: 'hantsy@example.com', 54 | }; 55 | 56 | jest.spyOn(users, 'findById').mockReturnValue(of(data)); 57 | const result = await lastValueFrom(resolver.getUserById('1')); 58 | console.log('result:', JSON.stringify(result)); 59 | 60 | expect(result).toBeDefined(); 61 | expect(result.id).toBe('1'); 62 | }); 63 | 64 | it('should resolve posts by author', async () => { 65 | const data = [ 66 | { 67 | id: '1', 68 | title: 'post 1', 69 | content: 'content 1', 70 | }, 71 | { 72 | id: '2', 73 | title: 'post 2', 74 | content: 'content 2', 75 | }, 76 | ]; 77 | 78 | jest.spyOn(posts, 'findByAuthor').mockReturnValue(of(data as Post[])); 79 | const result = await lastValueFrom(resolver.posts({ id: '1' } as User)); 80 | console.log('result: ' + JSON.stringify(result)); 81 | expect(result).toBeDefined(); 82 | expect(result.length).toBe(2); 83 | expect(posts.findByAuthor).toHaveBeenCalledTimes(1); 84 | expect(posts.findByAuthor).toBeCalledWith('1'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /api/src/gql/resolvers/users.resolver.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards } from '@nestjs/common'; 2 | import { 3 | Args, 4 | Directive, 5 | Mutation, 6 | Parent, 7 | Query, 8 | ResolveField, 9 | Resolver, 10 | } from '@nestjs/graphql'; 11 | import { Observable } from 'rxjs'; 12 | import { map, throwIfEmpty } from 'rxjs/operators'; 13 | import { GqlUser } from '../../authz/gql-user.decorator'; 14 | import { JwtAuthGuard } from '../../authz/jwt-auth.guard'; 15 | import { UserPrincipal } from '../../authz/user-principal.interface'; 16 | import { Post } from '../../gql/types/post.model'; 17 | import { User } from '../../gql/types/user.model'; 18 | import { UpdateUserResult } from '../types/update-result.model'; 19 | import { PostsService } from '../service/posts.service'; 20 | import { UsersService } from '../service/users.service'; 21 | import { UserNotFoundError } from './user-not-found.error'; 22 | 23 | @Resolver((of) => User) 24 | export class UsersResolver { 25 | constructor( 26 | private readonly usersService: UsersService, 27 | private readonly postsService: PostsService, 28 | ) {} 29 | 30 | @Directive( 31 | '@deprecated(reason: "This query will be removed in the next version")', 32 | ) 33 | @Query((returns) => User, { name: 'author' }) 34 | getUserById(@Args('userId') id: string): Observable { 35 | return this.usersService 36 | .findById(id) 37 | .pipe(throwIfEmpty(() => new UserNotFoundError(id))); 38 | } 39 | 40 | @ResolveField((of) => [Post]) 41 | public posts(@Parent() user: User): Observable { 42 | return this.postsService.findByAuthor(user.id); 43 | } 44 | 45 | @Mutation((returns) => UpdateUserResult) 46 | @UseGuards(JwtAuthGuard) 47 | updateUser(@GqlUser() user: UserPrincipal): Observable { 48 | console.log('gql user:', user); 49 | const { userId, email, name } = user; 50 | return this.usersService.update({ id: userId, email, name }).pipe( 51 | map((b) => ({ 52 | success: b, 53 | })), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/src/gql/scalars/date.scalar.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DateScalar } from './date.scalar'; 3 | import { Kind, IntValueNode} from 'graphql'; 4 | 5 | describe('DateScalar', () => { 6 | let dateScalar: DateScalar; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [DateScalar], 11 | }).compile(); 12 | 13 | dateScalar = module.get(DateScalar); 14 | }); 15 | 16 | it('DateScalar is defined', async () => { 17 | expect(dateScalar).toBeDefined(); 18 | }); 19 | 20 | it('parseValue', async () => { 21 | const date = new Date(); 22 | const dateNumber= date.getTime(); 23 | console.log( "date in nubmer: %d " , dateNumber); 24 | 25 | const parsedDate= dateScalar.parseValue(dateNumber) 26 | expect(parsedDate).toEqual(date); 27 | }); 28 | 29 | it('serialize', async () => { 30 | const date = new Date(); 31 | const dateNumber= date.getTime(); 32 | console.log( "date in nubmer: %d " , dateNumber); 33 | 34 | const searilizedDate= dateScalar.serialize(date) 35 | expect(searilizedDate).toEqual(dateNumber); 36 | }); 37 | 38 | it('parseLiteral', async () => { 39 | const date = new Date(); 40 | const dateIsoString = date.toISOString(); 41 | console.log( "date in ISO string: %s " , dateIsoString); 42 | 43 | const astNode = {kind: Kind.INT, value: dateIsoString} as IntValueNode; 44 | const parsedDate= dateScalar.parseLiteral(astNode); 45 | 46 | expect(parsedDate).toEqual(date); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /api/src/gql/scalars/date.scalar.ts: -------------------------------------------------------------------------------- 1 | import { Scalar, CustomScalar } from '@nestjs/graphql'; 2 | import { Kind, ValueNode } from 'graphql'; 3 | 4 | @Scalar('Date', (type) => Date) 5 | export class DateScalar implements CustomScalar { 6 | description = 'Date custom scalar type'; 7 | 8 | parseValue(value: number): Date { 9 | return new Date(value); // value from the client 10 | } 11 | 12 | serialize(value: Date): number { 13 | return value.getTime(); // value sent to the client 14 | } 15 | 16 | parseLiteral(ast: ValueNode): Date { 17 | if (ast.kind === Kind.INT) { 18 | return new Date(ast.value); 19 | } 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/src/gql/service/posts.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { lastValueFrom } from 'rxjs'; 3 | import { CommentEntity } from '../../database/entity/comment.entity'; 4 | import { PostEntity } from '../../database/entity/post.entity'; 5 | import { CommentRepository } from '../../database/repository/comment.repository'; 6 | import { PostRepository } from '../../database/repository/post.repository'; 7 | import { PostsService } from './posts.service'; 8 | 9 | describe('PostsService', () => { 10 | let service: PostsService; 11 | let comments: CommentRepository; 12 | let posts: PostRepository; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | PostsService, 18 | { 19 | provide: PostRepository, 20 | useValue: { 21 | new: jest.fn(), 22 | constructor: jest.fn(), 23 | findOne: jest.fn(), 24 | save: jest.fn(), 25 | findAll: jest.fn(), 26 | findByAuthor: jest.fn(), 27 | }, 28 | }, 29 | { 30 | provide: CommentRepository, 31 | useValue: { 32 | new: jest.fn(), 33 | constructor: jest.fn(), 34 | findOne: jest.fn(), 35 | save: jest.fn(), 36 | findByPostId: jest.fn(), 37 | }, 38 | }, 39 | ], 40 | }).compile(); 41 | 42 | service = module.get(PostsService); 43 | comments = module.get(CommentRepository); 44 | posts = module.get(PostRepository); 45 | }); 46 | 47 | it('should be defined', () => { 48 | expect(service).toBeDefined(); 49 | expect(comments).toBeDefined(); 50 | expect(posts).toBeDefined(); 51 | }); 52 | 53 | it('should find all posts', async () => { 54 | const data = [ 55 | { 56 | id: '1', 57 | title: 'post 1', 58 | content: 'content 1', 59 | }, 60 | { 61 | id: '2', 62 | title: 'post 2', 63 | content: 'content 2', 64 | }, 65 | ]; 66 | 67 | const entities = data.map((d) => { 68 | const e = new PostEntity(); 69 | Object.assign(e, d); 70 | return e; 71 | }); 72 | 73 | jest 74 | .spyOn(posts, 'findAll') 75 | .mockImplementationOnce((keyword: any, skip: any, take: any) => { 76 | return Promise.resolve(entities); 77 | }); 78 | const result = await lastValueFrom( 79 | service.findAll({ keyword: 'test', skip: 0, take: 20 }), 80 | ); 81 | console.log('result: ' + JSON.stringify(result)); 82 | expect(result).toBeDefined(); 83 | expect(result.length).toBe(2); 84 | expect(posts.findAll).toHaveBeenCalledTimes(1); 85 | expect(posts.findAll).toBeCalledWith('test', 0, 20); 86 | }); 87 | it('should find posts by author', async () => { 88 | const data = [ 89 | { 90 | id: '1', 91 | title: 'post 1', 92 | content: 'content 1', 93 | }, 94 | { 95 | id: '2', 96 | title: 'post 2', 97 | content: 'content 2', 98 | }, 99 | ]; 100 | 101 | const entities = data.map((d) => { 102 | const e = new PostEntity(); 103 | Object.assign(e, d); 104 | return e; 105 | }); 106 | 107 | jest.spyOn(posts, 'findByAuthor').mockImplementationOnce((autor: any) => { 108 | return Promise.resolve(entities); 109 | }); 110 | const result = await lastValueFrom(service.findByAuthor('test')); 111 | console.log('result: ' + JSON.stringify(result)); 112 | expect(result).toBeDefined(); 113 | expect(result.length).toBe(2); 114 | expect(posts.findByAuthor).toHaveBeenCalledTimes(1); 115 | expect(posts.findByAuthor).toBeCalledWith('test'); 116 | }); 117 | it('should find one post by id', async () => { 118 | const data = { 119 | id: '1', 120 | title: 'post 1', 121 | content: 'content 1', 122 | }; 123 | 124 | jest.spyOn(posts, 'findOne').mockResolvedValue(data); 125 | const result = await lastValueFrom(service.findById('1')); 126 | console.log('result:', JSON.stringify(result)); 127 | 128 | expect(result).toBeDefined(); 129 | expect(result.id).toBe('1'); 130 | }); 131 | 132 | it('should create post ', async () => { 133 | const data = { 134 | id: '1', 135 | title: 'post 1', 136 | content: 'content 1', 137 | }; 138 | jest.spyOn(posts, 'save').mockResolvedValue(data); 139 | 140 | const result = await lastValueFrom(service.createPost('test', data)); 141 | expect(result).toBeDefined(); 142 | expect(result.id).toBe('1'); 143 | }); 144 | 145 | it('should create comment of post ', async () => { 146 | const data = { 147 | id: '1', 148 | content: 'test comment', 149 | post: { 150 | id: '1', 151 | }, 152 | }; 153 | jest.spyOn(comments, 'save').mockResolvedValue(data as CommentEntity); 154 | 155 | const result = await lastValueFrom(service.addComment('1', 'test comment')); 156 | expect(result).toBeDefined(); 157 | expect(result.id).toBe('1'); 158 | }); 159 | 160 | it('should find all comments of post', async () => { 161 | const data = [ 162 | { 163 | id: '1', 164 | content: 'test comment', 165 | post: { 166 | id: '1', 167 | }, 168 | }, 169 | { 170 | id: '2', 171 | content: 'test comment', 172 | post: { 173 | id: '2', 174 | }, 175 | }, 176 | ]; 177 | 178 | jest.spyOn(comments, 'findByPostId').mockImplementationOnce((id: any) => { 179 | return Promise.resolve(data as CommentEntity[]); 180 | }); 181 | const result = await lastValueFrom(service.findCommentsByPostId('1')); 182 | console.log('result: ' + JSON.stringify(result)); 183 | expect(result).toBeDefined(); 184 | expect(result.length).toBe(2); 185 | expect(comments.findByPostId).toHaveBeenCalledTimes(1); 186 | expect(comments.findByPostId).toBeCalledWith('1'); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /api/src/gql/service/posts.service.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, from, Observable, of } from 'rxjs'; 2 | import { map, switchMap, tap } from 'rxjs/operators'; 3 | import { In } from 'typeorm'; 4 | import { Injectable } from '@nestjs/common'; 5 | 6 | import { PostEntity } from '../../database/entity/post.entity'; 7 | import { CommentRepository } from '../../database/repository/comment.repository'; 8 | import { PostRepository } from '../../database/repository/post.repository'; 9 | import { PostInput } from '../dto/post.input'; 10 | import { PostsArgs } from '../dto/posts.arg'; 11 | import { Comment } from '../types/comment.model'; 12 | import { Post } from '../types/post.model'; 13 | import { CommentEntity } from '../../database/entity/comment.entity'; 14 | 15 | @Injectable() 16 | export class PostsService { 17 | constructor( 18 | private readonly postRepository: PostRepository, 19 | private readonly commentRepository: CommentRepository, 20 | ) {} 21 | 22 | findById(id: string): Observable { 23 | return from(this.postRepository.findOne(id)).pipe( 24 | switchMap((p) => (p ? of(p) : EMPTY)), 25 | map((e, idx) => this.mapAsModel(e)), 26 | ); 27 | } 28 | 29 | findAll(args: PostsArgs): Observable { 30 | return from( 31 | this.postRepository.findAll(args.keyword, args.skip, args.take), 32 | ).pipe( 33 | //tap((e) => console.log('tap:', JSON.stringify(e))), 34 | map((entities, idx) => this.mapAsModelArray(entities)), 35 | // tap((e) => console.log(e)), 36 | ); 37 | } 38 | 39 | createPost(authorId: string, data: PostInput): Observable { 40 | return from( 41 | this.postRepository.save({ 42 | id: data.id, 43 | title: data.title, 44 | content: data.content, 45 | author: { 46 | id: authorId, 47 | }, 48 | }), 49 | ).pipe(map((e, idx) => this.mapAsModel(e))); 50 | } 51 | 52 | findByAuthor(id: string): Observable { 53 | return from(this.postRepository.findByAuthor(id)).pipe( 54 | map((e, idx) => this.mapAsModelArray(e)), 55 | ); 56 | } 57 | 58 | findCommentsByPostId(id: string): Observable { 59 | return from(this.commentRepository.findByPostId(id)).pipe( 60 | map((e, idx) => 61 | e.map((c) => { 62 | return { id: c.id, content: c.content } as Comment; 63 | }), 64 | ), 65 | ); 66 | } 67 | 68 | findCommentsByPostIds(ids: string[]): Observable { 69 | return from( 70 | this.commentRepository.find({ 71 | where: { postId: In(ids) }, 72 | }), 73 | ).pipe( 74 | map((e, idx) => 75 | e.map((c) => { 76 | return { id: c.id, postId: c.postId, content: c.content } as Comment; 77 | }), 78 | ), 79 | ); 80 | } 81 | 82 | addComment(id: string, comment: string): Observable { 83 | const entity = new CommentEntity(); 84 | Object.assign(entity, { 85 | content: comment, 86 | postId: id, 87 | }); 88 | return from(this.commentRepository.save(entity)).pipe( 89 | map((c) => { 90 | return { id: c.id, content: c.content } as Comment; 91 | }), 92 | ); 93 | } 94 | 95 | private mapAsModel(e: PostEntity): Post { 96 | return { 97 | id: e.id, 98 | title: e.title, 99 | content: e.content, 100 | createdAt: e.createdAt, 101 | updatedAt: e.updatedAt, 102 | authorId: e.authorId, 103 | }; 104 | } 105 | 106 | private mapAsModelArray(entities: PostEntity[]): Post[] { 107 | return entities.map((e) => { 108 | return { 109 | id: e.id, 110 | title: e.title, 111 | content: e.content, 112 | createdAt: e.createdAt, 113 | updatedAt: e.updatedAt, 114 | authorId: e.authorId, 115 | }; 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /api/src/gql/service/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { lastValueFrom } from 'rxjs'; 3 | import { UserRepository } from '../../database/repository/user.repository'; 4 | import { UsersService } from './users.service'; 5 | 6 | describe('UsersService', () => { 7 | let service: UsersService; 8 | let users: UserRepository; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | UsersService, 14 | { 15 | provide: UserRepository, 16 | useValue: { 17 | new: jest.fn(), 18 | constructor: jest.fn(), 19 | findOne: jest.fn(), 20 | save: jest.fn(), 21 | findAll: jest.fn(), 22 | findByAuthor: jest.fn(), 23 | }, 24 | }, 25 | ], 26 | }).compile(); 27 | 28 | service = module.get(UsersService); 29 | users = module.get(UserRepository); 30 | }); 31 | 32 | it('should be defined', () => { 33 | expect(service).toBeDefined(); 34 | expect(users).toBeDefined(); 35 | }); 36 | 37 | it('should find one user by id', async () => { 38 | const data = { 39 | id: '1', 40 | firstName: 'test firstName', 41 | lastName: 'test lastName', 42 | email: 'hantsy@example.com', 43 | }; 44 | 45 | jest.spyOn(users, 'findOne').mockResolvedValue(data); 46 | const result = await lastValueFrom(service.findById('1')); 47 | console.log('result:', JSON.stringify(result)); 48 | 49 | expect(result).toBeDefined(); 50 | expect(result.id).toBe('1'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /api/src/gql/service/users.service.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, from, Observable, of } from 'rxjs'; 2 | import { map, switchMap, tap } from 'rxjs/operators'; 3 | import { In, InsertResult } from 'typeorm'; 4 | 5 | import { Injectable } from '@nestjs/common'; 6 | 7 | import { UserEntity } from '../../database/entity/user.entity'; 8 | import { UserRepository } from '../../database/repository/user.repository'; 9 | import { User } from '../types/user.model'; 10 | 11 | @Injectable() 12 | export class UsersService { 13 | constructor(private readonly userRepository: UserRepository) {} 14 | 15 | update(user: { 16 | id: string; 17 | email: string; 18 | name: string; 19 | }): Observable { 20 | //const updateStr = ["name"].map(key => `"${key}" = EXCLUDED."${key}"`).join(","); 21 | //.onConflict(`("email") DO UPDATE SET ${updateStr}`) 22 | 23 | const result: Promise = this.userRepository 24 | .createQueryBuilder() 25 | .insert() 26 | .into(UserEntity) 27 | .values(user) 28 | //.onConflict(`("email") DO NOTHING`) 29 | .orUpdate(['name'], ['email']) 30 | .execute(); 31 | 32 | return from(result).pipe( 33 | tap((r) => console.log(r)), 34 | map((r) => r.raw != undefined), 35 | ); 36 | } 37 | 38 | findById(id: string): Observable { 39 | return from(this.userRepository.findOne(id)).pipe( 40 | switchMap((u) => (u ? of(u) : EMPTY)), 41 | map((e, idx) => { 42 | return { 43 | id: e.id, 44 | name: e.name, 45 | email: e.email, 46 | } as User; 47 | }), 48 | ); 49 | } 50 | 51 | findByIds(ids: string[]): Observable { 52 | return from( 53 | this.userRepository.find({ 54 | where: { id: In(ids) }, 55 | }), 56 | ).pipe( 57 | map((ua) => { 58 | return ua.map((u) => { 59 | const { id, email, name } = u; 60 | return { id, email, name } as User; 61 | }); 62 | }), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /api/src/gql/types/comment.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from './comment.model'; 2 | 3 | describe('CommentModel', () => { 4 | it('should be defined', () => { 5 | expect(new Comment()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /api/src/gql/types/comment.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | import { Post } from './post.model'; 3 | 4 | @ObjectType() 5 | export class Comment { 6 | @Field((type) => ID) 7 | id: string; 8 | 9 | @Field() 10 | content: string; 11 | 12 | @Field() 13 | postId: string; 14 | 15 | @Field((type) => Post) 16 | post: Post; 17 | } 18 | -------------------------------------------------------------------------------- /api/src/gql/types/post.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { Post } from './post.model'; 2 | 3 | describe('PostModel', () => { 4 | it('should be defined', () => { 5 | expect(new Post()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /api/src/gql/types/post.model.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; 2 | import { User } from './user.model'; 3 | import { Comment } from './comment.model'; 4 | 5 | // @ObjectType() 6 | // @Directive('@key(fields: "id")') 7 | // export class Post { 8 | // @Field((type) => ID) 9 | // id: number; 10 | 11 | // @Field() 12 | // title: string; 13 | 14 | // @Field((type) => Int) 15 | // authorId: number; 16 | 17 | // @Field((type) => User) 18 | // user?: User; 19 | // } 20 | 21 | @ObjectType() 22 | export class Post { 23 | @Field((type) => ID) 24 | id?: string; 25 | 26 | @Directive('@upper') 27 | @Field() 28 | title: string; 29 | 30 | @Field({ nullable: true }) 31 | content?: string; 32 | 33 | @Field({ nullable: true }) 34 | createdAt?: Date; 35 | 36 | @Field({ nullable: true }) 37 | updatedAt?: Date; 38 | 39 | @Field((type) => [Comment], { nullable: true }) 40 | comments?: Comment[]; 41 | 42 | @Field((type) => User, { nullable: true }) 43 | author?: User; 44 | 45 | @Field({ nullable: true }) 46 | authorId?: string; 47 | } 48 | -------------------------------------------------------------------------------- /api/src/gql/types/update-result.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class UpdateUserResult { 5 | @Field() 6 | success: boolean; 7 | 8 | @Field() 9 | message?: string; 10 | } 11 | -------------------------------------------------------------------------------- /api/src/gql/types/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | import { Post } from './post.model'; 3 | 4 | @ObjectType() 5 | export class User { 6 | @Field((type) => ID) 7 | id?: string; 8 | 9 | @Field({ nullable: true }) 10 | name?: string; 11 | 12 | @Field({ nullable: true }) 13 | email?: string; 14 | 15 | @Field((type) => [Post], { nullable: true }) 16 | posts?: Post[]; 17 | } 18 | -------------------------------------------------------------------------------- /api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { HttpExceptionFilter } from './gql/filters/http.exception.filter'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | app.useGlobalPipes( 9 | new ValidationPipe({ 10 | disableErrorMessages: true, 11 | }), 12 | ); 13 | 14 | app.useGlobalFilters(new HttpExceptionFilter()); 15 | await app.listen(3000); 16 | } 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /api/src/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type Comment { 6 | content: String! 7 | id: ID! 8 | post: Post! 9 | } 10 | 11 | input CommentInput { 12 | content: String! 13 | postId: String! 14 | } 15 | 16 | """ 17 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. 18 | """ 19 | scalar DateTime 20 | 21 | type Mutation { 22 | addComment(commentInput: CommentInput!): Comment! 23 | createPost(createPostInput: PostInput!): Post! 24 | updateUser: UpdateUserResult! 25 | } 26 | 27 | type Post { 28 | author: User! 29 | authorId: String 30 | comments: [Comment!] 31 | content: String 32 | createdAt: DateTime 33 | id: ID! 34 | title: String! 35 | updatedAt: DateTime 36 | } 37 | 38 | input PostInput { 39 | content: String! 40 | id: String 41 | title: String! 42 | } 43 | 44 | type Query { 45 | author(userId: String!): User! 46 | getAllPosts(keyword: String = "", skip: Int = 0, take: Int = 25): [Post!]! 47 | getPostById(postId: String!): Post! 48 | } 49 | 50 | type Subscription { 51 | commentAdded: Comment! 52 | } 53 | 54 | type UpdateUserResult { 55 | message: String! 56 | success: Boolean! 57 | } 58 | 59 | type User { 60 | email: String 61 | id: ID! 62 | name: String 63 | posts: [Post!] 64 | } 65 | -------------------------------------------------------------------------------- /api/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { HttpExceptionFilter } from '../src/common/filters/http.exception.filter'; 6 | import { Post } from 'src/gql/types/post.model'; 7 | const gql = '/graphql'; 8 | 9 | describe('application (e2e)', () => { 10 | let app: INestApplication; 11 | 12 | beforeAll(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | imports: [AppModule], 15 | }).compile(); 16 | 17 | app = moduleFixture.createNestApplication(); 18 | app.useGlobalFilters(new HttpExceptionFilter()); 19 | await app.init(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await app.close(); 24 | }); 25 | 26 | // it('/ (GET)', () => { 27 | // return request(app.getHttpServer()) 28 | // .get('/') 29 | // .expect(200) 30 | // .expect('Hello World!'); 31 | // }); 32 | 33 | // GraphQL endpoints 34 | describe(gql, () => { 35 | describe('posts operations(without token) ', () => { 36 | it('query getAllPosts', async () => { 37 | const res = await request(app.getHttpServer()) 38 | .post(gql) 39 | .send({ 40 | query: `query{ 41 | getAllPosts{ 42 | id 43 | title 44 | content 45 | } 46 | }`, 47 | }); 48 | 49 | expect(res.status).toBe(200); 50 | expect(res.body.data.getAllPosts.length).toEqual(4); 51 | }); 52 | 53 | it('mutation createPost', async () => { 54 | const res = await request(app.getHttpServer()) 55 | .post(gql) 56 | .send({ 57 | query: `mutation($createPostInput:PostInput!){ 58 | createPost(createPostInput:$createPostInput){ 59 | id 60 | title 61 | } 62 | }`, 63 | variables: { 64 | createPostInput: { 65 | title: 'test title', 66 | content: 'test content of our title', 67 | }, 68 | }, 69 | }); 70 | console.log('res: ', JSON.stringify(res.body)); 71 | expect(res.status).toBe(200); 72 | expect(res.body.data).toBeDefined(); 73 | expect(res.body.errors[0].message).toBe('Unauthorized'); 74 | }); 75 | }); 76 | 77 | describe('posts operations(with token)', () => { 78 | const token = 79 | process.env.TOKEN || 80 | 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlYzM1lvNzk5cC1XeFI2NHpJZ29QMyJ9.eyJpc3MiOiJodHRwczovL2Rldi1lc2U4MjQxYi51cy5hdXRoMC5jb20vIiwic3ViIjoiSUVYVjJNYkFpdUVrVjBKN3VmSDBCcXEyYTJZSUYzaDFAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vaGFudHN5LmdpdGh1Yi5pby9hcGkiLCJpYXQiOjE2MjkxODQ5MTksImV4cCI6MTYyOTI3MTMxOSwiYXpwIjoiSUVYVjJNYkFpdUVrVjBKN3VmSDBCcXEyYTJZSUYzaDEiLCJzY29wZSI6InJlYWQ6cG9zdHMgd3JpdGU6cG9zdHMgZGVsZXRlOnBvc3RzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIiwicGVybWlzc2lvbnMiOlsicmVhZDpwb3N0cyIsIndyaXRlOnBvc3RzIiwiZGVsZXRlOnBvc3RzIl19.VbaJysKCEpQusjrfv03kIZPeYJJf2U7DgQhY-_AH3yBXaMWv40h2mm4Vw-4tgtyqBJt8DbDFtuL6XMf4xjusoDgMuHsbz9N1Kh7J_O1ONznm7DFoPEP_dh4JY7hghzjxQFFlR3PIMNEmO6nJ8Nnm6XglfafunasQyMZHfZ2mRfe-1x0LykmyGwFbcI7r4HWJ2h02vOFuFAHkq0lzJZ-i48zjYuQ2VS79vSkQNk-fe28Next7Iq7IOpGUqpn6nvL5upiYPTWknh8yrP2P5EFpAy9gRQeTRwM-H67kbZq0jX5RzXEGKLqP6Y5rss0PV6dDfqivL1Od4zUE11AMkVqUuA'; 81 | beforeAll(() => { 82 | console.log('token:', token); 83 | }); 84 | 85 | it('mutation createPost', async () => { 86 | //sync user data to users 87 | const updateUserRes = await request(app.getHttpServer()) 88 | .post(gql) 89 | .set('authorization', 'Bearer ' + token) 90 | .send({ 91 | query: `mutation{ 92 | updateUser{ 93 | success 94 | } 95 | }`, 96 | }); 97 | console.log('updateUser data:', JSON.stringify(updateUserRes.body)); 98 | expect(updateUserRes.body.data.updateUser.success).toBeTruthy(); 99 | 100 | const res = await request(app.getHttpServer()) 101 | .post(gql) 102 | .set('authorization', 'Bearer ' + token) 103 | .send({ 104 | query: `mutation($createPostInput:PostInput!){ 105 | createPost(createPostInput:$createPostInput){ 106 | id 107 | title 108 | } 109 | }`, 110 | variables: { 111 | createPostInput: { 112 | title: 'test title', 113 | content: 'test content of our title', 114 | }, 115 | }, 116 | }); 117 | console.log('createPost data:', JSON.stringify(res.body.data)); 118 | console.log('createPost errors:', JSON.stringify(res.body.errors)); 119 | const postId = (res.body.data.createPost as Post).id; 120 | console.log('created post id:', postId); 121 | expect(res.status).toBe(200); 122 | expect(postId).not.toBeNull(); 123 | 124 | const cres = await request(app.getHttpServer()) 125 | .post(gql) 126 | .set('authorization', 'Bearer ' + token) 127 | .send({ 128 | query: `mutation addCommentToPost($commentInput:CommentInput!){ 129 | addComment(commentInput:$commentInput){ 130 | id 131 | content 132 | } 133 | }`, 134 | variables: { 135 | commentInput: { 136 | postId: postId, 137 | content: 'test comment of our title', 138 | }, 139 | }, 140 | }); 141 | 142 | console.log('addComment data:', JSON.stringify(cres.body.data)); 143 | console.log('addComment errors:', JSON.stringify(cres.body.errors)); 144 | const cid = cres.body.data.addComment.id; 145 | expect(cres.status).toBe(200); 146 | expect(cid).not.toBeNull(); 147 | 148 | const pres = await request(app.getHttpServer()) 149 | .post(gql) 150 | .send({ 151 | query: `query($id:String!) { 152 | getPostById(postId: $id) { 153 | id 154 | title 155 | content 156 | comments{ 157 | id 158 | } 159 | } 160 | }`, 161 | variables: { 162 | id: postId, 163 | }, 164 | }); 165 | 166 | console.log('getPostById data:', JSON.stringify(pres.body.data)); 167 | expect(pres.status).toBe(200); 168 | expect(pres.body.data.getPostById.comments[0].id).toEqual(cid); 169 | }); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /api/test/db.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppModule } from '../src/app.module'; 3 | import { CommentRepository } from '../src/database/repository/comment.repository'; 4 | import { PostRepository } from '../src/database/repository/post.repository'; 5 | import { UserRepository } from '../src/database/repository/user.repository'; 6 | import { UserEntity } from '../src/database/entity/user.entity'; 7 | import { PostEntity } from '../src/database/entity/post.entity'; 8 | import { CommentEntity } from '../src/database/entity/comment.entity'; 9 | import { PostsDataInitializer } from '../src/database/posts-data-initializer'; 10 | 11 | describe('Database integration test', () => { 12 | let posts: PostRepository; 13 | let comments: CommentRepository; 14 | let users: UserRepository; 15 | let init: PostsDataInitializer; 16 | 17 | beforeEach(async () => { 18 | const app: TestingModule = await Test.createTestingModule({ 19 | imports: [AppModule], 20 | }).compile(); 21 | posts = app.get(PostRepository); 22 | comments = app.get(CommentRepository); 23 | users = app.get(UserRepository); 24 | 25 | // call this initializer to reset the data 26 | init = app.get(PostsDataInitializer); 27 | 28 | await init.onModuleInit(); 29 | }); 30 | 31 | it('smoke tests for repositories', async () => { 32 | const user = new UserEntity(); 33 | Object.assign(user, { 34 | id: 'e2etest@id', 35 | email: 'e2e@example.com', 36 | }); 37 | 38 | const saved = await users.save(user); 39 | expect(saved).toBeDefined(); 40 | console.log('saved user:', saved); 41 | 42 | const byEmail = await users.findByEmail('e2e@example.com'); 43 | expect(byEmail).toBeDefined(); 44 | expect(saved.id).toEqual(byEmail.id); 45 | 46 | const post = new PostEntity(); 47 | Object.assign(post, { 48 | title: 'e2e test', 49 | content: 'e2e content', 50 | author: saved, 51 | }); 52 | 53 | const savedPost = await posts.save(post); 54 | expect(savedPost).toBeDefined(); 55 | const postsByAuthor = await posts.findByAuthor(saved.id); 56 | expect(postsByAuthor.length).toEqual(1); 57 | expect(postsByAuthor[0].id).toEqual(savedPost.id); 58 | 59 | const comment = new CommentEntity(); 60 | Object.assign(comment, { 61 | post: savedPost, 62 | content: 'e2e content', 63 | }); 64 | 65 | const savedComment = await comments.save(comment); 66 | expect(savedComment).toBeDefined(); 67 | const commnentsByPostId = await comments.findByPostId(savedPost.id); 68 | expect(commnentsByPostId.length).toEqual(1); 69 | expect(commnentsByPostId[0].id).toEqual(savedComment.id); 70 | 71 | //clean the resouses. 72 | // comments.remove(savedComment); 73 | // posts.remove(savedPost); 74 | // users.remove(saved); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /api/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - '**/*.module.*' 3 | - '**/*initializer.*' 4 | - '**/*main.*' 5 | 6 | # Setting coverage targets per flag 7 | coverage: 8 | status: 9 | project: 10 | # default: 11 | # target: 90% #overall project/ repo coverage 12 | ui: 13 | target: 60% 14 | flags: 15 | - ui 16 | api: 17 | target: 80% 18 | flags: 19 | - api 20 | 21 | # adding Flags to your `layout` configuration to show up in the PR comment 22 | comment: 23 | layout: 'reach, diff, flags, files' 24 | behavior: default 25 | require_changes: false 26 | require_base: yes 27 | require_head: yes 28 | branches: null 29 | 30 | # New root YAML section = `flags:` 31 | # This is where you would define every flag from your 32 | # uploader, and update when new Flags added 33 | flags: 34 | ui: 35 | paths: 36 | - ui/src 37 | carryforward: false #default -- false 38 | api: 39 | paths: 40 | - api/src 41 | carryforward: true 42 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' # specify docker-compose version 2 | 3 | services: 4 | db: 5 | image: postgres 6 | ports: 7 | - '5432:5432' 8 | restart: always 9 | environment: 10 | POSTGRES_PASSWORD: password 11 | POSTGRES_DB: blogdb 12 | POSTGRES_USER: user 13 | volumes: 14 | - ./data:/var/lib/postgresql 15 | - ./pg-initdb.d:/docker-entrypoint-initdb.d 16 | -------------------------------------------------------------------------------- /docs/code-first.md: -------------------------------------------------------------------------------- 1 | # Code First 2 | 3 | ```bash 4 | $ npm i @nestjs/graphql graphql-tools graphql apollo-server-express 5 | ``` 6 | 7 | > If using Fastify, instead of installing `apollo-server-express`, you should install `apollo-server-fastify`. 8 | 9 | // create module post, common 10 | 11 | ```bash 12 | nest g mo posts 13 | nest g mo common 14 | 15 | nest g class posts/post.model 16 | nest g class posts/comment.model 17 | 18 | nest g mo authors 19 | nest g class authors/author.model 20 | 21 | nest g service posts/posts --flat 22 | nest g service authors/authors --flat 23 | ``` 24 | 25 | $ curl http://localhost:3000/graphql \ 26 | -H "Content-Type:application/json" \ 27 | -X POST \ 28 | -d '{"query": "{getAllPosts{id title content}}" }' 29 | > {"data":{"getAllPosts":[{"id":"c4a9830e-4544-4f4b-9b9a-56c767487a92","title":"GENERATE A NESTJS PROJECT","content":"content"},{"id":"79fae7e0-1f34-473f-9d5d-6a538924d478","title":"CREATE GRAPQL APIS","content":"content"},{"id":"8c1f7996-fbfc-4285-a99f-b791c03e187d","title":"CONNECT TO POSTGRES VIA TYPEORM","content":"content"},{"id":"783b7f83-f8d4-46a8-9628-8ed41e0037be","title":"TEST TITLE","content":"test content"}]}} 30 | 31 | 32 | 33 | $ curl http://localhost:3000/graphql -H "Content-Type:application/json" -X POST -d '{"query": "{getAllPosts{id title content, comments{ content }}}" }' 34 | {"data":{"getAllPosts":[{"id":"c4a9830e-4544-4f4b-9b9a-56c767487a92","title":"GENERATE A NESTJS PROJECT","content":"content","comments":[]},{"id":"79fae7e0-1f34-473f-9d5d-6a538924d478","title":"CREATE GRAPQL APIS","content":"content","comments":[]},{"id":"8c1f7996-fbfc-4285-a99f-b791c03e187d","title":"CONNECT TO POSTGRES VIA TYPEORM","content":"content","comments":[]},{"id":"783b7f83-f8d4-46a8-9628-8ed41e0037be","title":"TEST TITLE","content":"test content","comments":[{"content":"test comment"}]}]}} 35 | 36 | $ curl http://localhost:3000/graphql \ 37 | > -H "Content-Type:application/json" \ 38 | > -X POST \ 39 | > -d '{ 40 | > "query": "mutation($createPostInput:CreatePostInput!){createPost(createPostInput:$createPostInput){ id, title }}", 41 | > "variables": { 42 | > "createPostInput": { 43 | > "title": "test title", 44 | > "content": "test content of our title" 45 | > } 46 | > } 47 | > }' 48 | > {"data":{"createPost":{"id":"b5847f62-2917-44ae-8891-cf6f06505565","title":"TEST TITLE"}}} 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-graphql-sample", 3 | "version": "1.0.0", 4 | "description": "![Compile and build](https://github.com/hantsy/nestjs-graphql-sample/workflows/Build/badge.svg)\r ![Run e2e testing](https://github.com/hantsy/nestjs-graphql-sample/workflows/e2e/badge.svg)\r [![codecov](https://codecov.io/gh/hantsy/nestjs-graphql-sample/branch/master/graph/badge.svg)](https://codecov.io/gh/hantsy/nestjs-graphql-sample)", 5 | "main": "commitlint.config.js", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/hantsy/nestjs-graphql-sample.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/hantsy/nestjs-graphql-sample/issues" 20 | }, 21 | "homepage": "https://github.com/hantsy/nestjs-graphql-sample#readme", 22 | "devDependencies": { 23 | "@commitlint/config-conventional": "^13.1.0", 24 | "commitlint": "^13.1.0" 25 | }, 26 | "dependencies": { 27 | "husky": "^7.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ui/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | //https://github.com/angular-eslint/angular-eslint/blob/master/packages/integration-tests/fixtures/v1123-multi-project-manual-config/.eslintrc.json 2 | { 3 | "root": true, 4 | "ignorePatterns": [ 5 | "projects/**/*" 6 | ], 7 | "overrides": [ 8 | { 9 | "files": [ 10 | "*.ts" 11 | ], 12 | "parserOptions": { 13 | "project": [ 14 | "tsconfig.eslint.json" 15 | ], 16 | "createDefaultProgram": false 17 | }, 18 | "extends": [ 19 | "eslint:recommended", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:@angular-eslint/recommended", 22 | "plugin:@angular-eslint/recommended--extra", 23 | "plugin:@angular-eslint/template/process-inline-templates" 24 | ], 25 | "rules": { 26 | "@angular-eslint/component-selector": [ 27 | "error", 28 | { 29 | "prefix": "app", 30 | "style": "kebab-case", 31 | "type": "element" 32 | } 33 | ], 34 | "@angular-eslint/directive-selector": [ 35 | "error", 36 | { 37 | "prefix": "app", 38 | "style": "camelCase", 39 | "type": "attribute" 40 | } 41 | ] 42 | } 43 | }, 44 | { 45 | "files": ["*.html"], 46 | "extends": ["plugin:@angular-eslint/template/recommended"], 47 | "rules": { 48 | "@angular-eslint/template/conditional-complexity": [ 49 | "error", { "maxComplexity": 4 }], 50 | "@angular-eslint/template/cyclomatic-complexity": [ 51 | "error", { "maxComplexity": 5 }], 52 | "@angular-eslint/template/no-duplicate-attributes": "error", 53 | "@angular-eslint/template/use-track-by-function": "error" 54 | } 55 | }, 56 | { 57 | "files": ["*.html"], 58 | "excludedFiles": ["*inline-template-*.component.html"], 59 | "extends": ["plugin:prettier/recommended"], 60 | "rules": { 61 | "prettier/prettier": ["error", { "parser": "angular" }] 62 | } 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | junit.xml 48 | -------------------------------------------------------------------------------- /ui/Dockerfile: -------------------------------------------------------------------------------- 1 | # Set nginx base image 2 | FROM nginx:alpine-perl 3 | 4 | LABEL maintainer="Hantsy Bai" 5 | 6 | EXPOSE 80 7 | ## Remove default Nginx website 8 | RUN rm -rf /usr/share/nginx/html/* 9 | 10 | RUN rm /etc/nginx/conf.d/default.conf 11 | 12 | ADD ./nginx/nginx.conf /etc/nginx/nginx.conf 13 | 14 | ADD ./dist/ui /usr/share/nginx/html 15 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Client Application built by Angular CLI 2 | 3 | 4 | ## Build 5 | 6 | ```bash 7 | ng serve # serve app at `http://localhost:4200/` 8 | ng test # execute the unit tests 9 | ng e2e # execute the end-to-end tests 10 | ng build 11 | ``` 12 | ## Develop 13 | 14 | ```bash 15 | ng generate component component-name # generate a new component. 16 | ng generate directive|pipe|service|class|guard|interface|enum|module 17 | ``` 18 | 19 | Use ESLint instead of tslint. 20 | ``` 21 | ng add @angular-eslint/schematics 22 | ``` 23 | 24 | Add Jest. 25 | 26 | ```bash 27 | ng add @briebug/jest-schematic # a combination toolkit of @angular-builders/jest and jest-preset-angular 28 | ``` 29 | 30 | Add Cypress. 31 | 32 | ```bash 33 | ng add @cypress/schematic # @briebug/jest-schematic is merged into the official cypress repository. 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /ui/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "cli": { 4 | "analytics": false, 5 | "defaultCollection": "@angular-eslint/schematics" 6 | }, 7 | "version": 1, 8 | "newProjectRoot": "projects", 9 | "projects": { 10 | "ui": { 11 | "projectType": "application", 12 | "schematics": { 13 | "@schematics/angular:application": { 14 | "strict": true 15 | } 16 | }, 17 | "root": "", 18 | "sourceRoot": "src", 19 | "prefix": "app", 20 | "architect": { 21 | "build": { 22 | "builder": "@angular-devkit/build-angular:browser", 23 | "options": { 24 | "outputPath": "dist/ui", 25 | "index": "src/index.html", 26 | "main": "src/main.ts", 27 | "polyfills": "src/polyfills.ts", 28 | "tsConfig": "tsconfig.app.json", 29 | "aot": true, 30 | "assets": [ 31 | "src/favicon.ico", 32 | "src/assets" 33 | ], 34 | "styles": [ 35 | "./node_modules/bootstrap/dist/css/bootstrap.min.css", 36 | "./node_modules/bootstrap-icons/font/bootstrap-icons.css", 37 | "src/styles.css" 38 | ], 39 | "scripts": [ 40 | "./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" 41 | ] 42 | }, 43 | "configurations": { 44 | "production": { 45 | "fileReplacements": [ 46 | { 47 | "replace": "src/environments/environment.ts", 48 | "with": "src/environments/environment.prod.ts" 49 | } 50 | ], 51 | "optimization": true, 52 | "outputHashing": "all", 53 | "sourceMap": false, 54 | "namedChunks": false, 55 | "extractLicenses": true, 56 | "vendorChunk": false, 57 | "buildOptimizer": true, 58 | "budgets": [ 59 | { 60 | "type": "initial", 61 | "maximumWarning": "500kb", 62 | "maximumError": "1mb" 63 | }, 64 | { 65 | "type": "anyComponentStyle", 66 | "maximumWarning": "2kb", 67 | "maximumError": "4kb" 68 | } 69 | ] 70 | } 71 | } 72 | }, 73 | "serve": { 74 | "builder": "@angular-devkit/build-angular:dev-server", 75 | "options": { 76 | "browserTarget": "ui:build" 77 | }, 78 | "configurations": { 79 | "production": { 80 | "browserTarget": "ui:build:production" 81 | } 82 | } 83 | }, 84 | "extract-i18n": { 85 | "builder": "@angular-devkit/build-angular:extract-i18n", 86 | "options": { 87 | "browserTarget": "ui:build" 88 | } 89 | }, 90 | "lint": { 91 | "builder": "@angular-eslint/builder:lint", 92 | "options": { 93 | "lintFilePatterns": [ 94 | "src/**/*.ts", 95 | "src/**/*.html" 96 | ] 97 | } 98 | }, 99 | "test": { 100 | "builder": "@angular-builders/jest:run", 101 | "options": { 102 | "verbose": true, 103 | "globalMocks": [ 104 | "getComputedStyle", 105 | "doctype" 106 | ], 107 | "no-cache": true, 108 | "reporters": [ 109 | "jest-junit" 110 | ] 111 | } 112 | }, 113 | "e2e": { 114 | "builder": "@cypress/schematic:cypress", 115 | "options": { 116 | "devServerTarget": "ui:serve", 117 | "watch": true, 118 | "headless": false 119 | }, 120 | "configurations": { 121 | "production": { 122 | "devServerTarget": "ui:serve:production" 123 | } 124 | } 125 | }, 126 | "cypress-run": { 127 | "builder": "@cypress/schematic:cypress", 128 | "options": { 129 | "devServerTarget": "ui:serve" 130 | }, 131 | "configurations": { 132 | "production": { 133 | "devServerTarget": "ui:serve:production" 134 | } 135 | } 136 | }, 137 | "cypress-open": { 138 | "builder": "@cypress/schematic:cypress", 139 | "options": { 140 | "watch": true, 141 | "headless": false 142 | } 143 | } 144 | } 145 | } 146 | }, 147 | "defaultProject": "ui" 148 | } -------------------------------------------------------------------------------- /ui/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "integrationFolder": "cypress/integration", 3 | "supportFile": "cypress/support/index.ts", 4 | "videosFolder": "cypress/videos", 5 | "screenshotsFolder": "cypress/screenshots", 6 | "pluginsFile": "cypress/plugins/index.ts", 7 | "fixturesFolder": "cypress/fixtures", 8 | "baseUrl": "http://localhost:4200" 9 | } -------------------------------------------------------------------------------- /ui/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /ui/cypress/integration/spec.ts: -------------------------------------------------------------------------------- 1 | describe('My First Test', () => { 2 | it('Visits the initial project page', () => { 3 | cy.visit('/') 4 | cy.contains('Welcome') 5 | cy.contains('sandbox app is running!') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress 2 | // For more info, visit https://on.cypress.io/plugins-api 3 | module.exports = (on, config) => {} 4 | -------------------------------------------------------------------------------- /ui/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example namespace declaration will help 3 | // with Intellisense and code completion in your 4 | // IDE or Text Editor. 5 | // *********************************************** 6 | // declare namespace Cypress { 7 | // interface Chainable { 8 | // customCommand(param: any): typeof customCommand; 9 | // } 10 | // } 11 | // 12 | // function customCommand(param: any): void { 13 | // console.warn(param); 14 | // } 15 | // 16 | // NOTE: You can use it like so: 17 | // Cypress.Commands.add('customCommand', customCommand); 18 | // 19 | // *********************************************** 20 | // This example commands.js shows you how to 21 | // create various custom commands and overwrite 22 | // existing commands. 23 | // 24 | // For more comprehensive examples of custom 25 | // commands please read more here: 26 | // https://on.cypress.io/custom-commands 27 | // *********************************************** 28 | // 29 | // 30 | // -- This is a parent command -- 31 | // Cypress.Commands.add("login", (email, password) => { ... }) 32 | // 33 | // 34 | // -- This is a child command -- 35 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 36 | // 37 | // 38 | // -- This is a dual command -- 39 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 40 | // 41 | // 42 | // -- This will overwrite an existing command -- 43 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 44 | -------------------------------------------------------------------------------- /ui/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // When a command from ./commands is ready to use, import with `import './commands'` syntax 17 | // import './commands'; 18 | -------------------------------------------------------------------------------- /ui/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "types": ["cypress"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | SELENIUM_PROMISE_MANAGER: false, 20 | baseUrl: 'http://localhost:4200/', 21 | framework: 'jasmine', 22 | jasmineNodeOpts: { 23 | showColors: true, 24 | defaultTimeoutInterval: 30000, 25 | print: function() {} 26 | }, 27 | onPrepare() { 28 | require('ts-node').register({ 29 | project: require('path').join(__dirname, './tsconfig.json') 30 | }); 31 | jasmine.getEnv().addReporter(new SpecReporter({ 32 | spec: { 33 | displayStacktrace: StacktraceOption.PRETTY 34 | } 35 | })); 36 | } 37 | }; -------------------------------------------------------------------------------- /ui/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { browser, logging } from 'protractor'; 2 | import { AppPage } from './app.po'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', async () => { 12 | await page.navigateTo(); 13 | expect(await page.getTitleText()).toEqual('ui app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(expect.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | async navigateTo(): Promise { 5 | return browser.get(browser.baseUrl); 6 | } 7 | 8 | async getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ui/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ui/jest-global-mocks.ts: -------------------------------------------------------------------------------- 1 | /* global mocks for jsdom */ 2 | const mock = () => { 3 | let storage: { [key: string]: string } = {}; 4 | return { 5 | getItem: (key: string) => (key in storage ? storage[key] : null), 6 | setItem: (key: string, value: string) => (storage[key] = value || ''), 7 | removeItem: (key: string) => delete storage[key], 8 | clear: () => (storage = {}), 9 | }; 10 | }; 11 | 12 | Object.defineProperty(window, 'localStorage', { value: mock() }); 13 | Object.defineProperty(window, 'sessionStorage', { value: mock() }); 14 | Object.defineProperty(window, 'getComputedStyle', { 15 | value: () => ['-webkit-appearance'], 16 | }); 17 | 18 | Object.defineProperty(document.body.style, 'transform', { 19 | value: () => { 20 | return { 21 | enumerable: true, 22 | configurable: true, 23 | }; 24 | }, 25 | }); 26 | 27 | /* output shorter and more meaningful Zone error stack traces */ 28 | // Error.stackTraceLimit = 2; 29 | 30 | -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '@core/(.*)': '/src/app/core/$1', 4 | }, 5 | preset: 'jest-preset-angular', 6 | setupFilesAfterEnv: ['/setup-jest.ts'], 7 | }; 8 | -------------------------------------------------------------------------------- /ui/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # see: https://hackerbox.io/articles/dockerised-nginx-env-vars/ to set env in nginx.conf 2 | 3 | user nginx; 4 | worker_processes 1; 5 | error_log /var/log/nginx/error.log warn; 6 | pid /var/run/nginx.pid; 7 | load_module modules/ngx_http_perl_module.so; 8 | 9 | env BACKEND_API_URL; 10 | 11 | events { 12 | worker_connections 1024; 13 | } 14 | 15 | http { 16 | include /etc/nginx/mime.types; 17 | sendfile on; 18 | 19 | perl_set $baseApiUrl 'sub { return $ENV{"BACKEND_API_URL"}; }'; 20 | 21 | server { 22 | listen 80; 23 | server_name localhost; 24 | resolver 127.0.0.11 ipv6=off; 25 | resolver_timeout 1s; 26 | 27 | root /usr/share/nginx/html; 28 | index index.html index.htm; 29 | include /etc/nginx/mime.types; 30 | 31 | location /api { 32 | proxy_http_version 1.1; 33 | proxy_set_header Upgrade $http_upgrade; 34 | proxy_set_header Connection "upgrade"; 35 | proxy_set_header Host $host; 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_set_header X-Forwarded-Proto $scheme; 39 | 40 | rewrite ^/api/(.*) /$1 break; 41 | proxy_pass ${baseApiUrl}; 42 | } 43 | 44 | location /test { 45 | return 200 'the environment variable contains: ${baseApiUrl}'; 46 | } 47 | 48 | location / { 49 | try_files $uri $uri/ /index.html; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --proxy-config proxy.conf.json", 7 | "build": "ng build", 8 | "build:prod": "ng build --prod", 9 | "test": "ng test", 10 | "test:watch": "jest --watch", 11 | "test:ci": "jest --runInBand", 12 | "lint": "ng lint", 13 | "e2e": "ng e2e", 14 | "cypress:open": "cypress open", 15 | "cypress:run": "cypress run" 16 | }, 17 | "private": true, 18 | "dependencies": { 19 | "@angular/animations": "~12.2.12", 20 | "@angular/common": "~12.2.12", 21 | "@angular/compiler": "~12.2.12", 22 | "@angular/core": "~12.2.12", 23 | "@angular/forms": "~12.2.12", 24 | "@angular/localize": "~12.2.12", 25 | "@angular/platform-browser": "~12.2.12", 26 | "@angular/platform-browser-dynamic": "~12.2.12", 27 | "@angular/router": "~12.2.12", 28 | "@apollo/client": "^3.4.11", 29 | "@auth0/auth0-angular": "^1.6.1", 30 | "@briebug/jest-schematic": "^3.1.0", 31 | "apollo-angular": "^2.6.0", 32 | "bootstrap": "^5.1.1", 33 | "bootstrap-icons": "^1.5.0", 34 | "graphql": "^15.5.3", 35 | "rxjs": "^6.6.0", 36 | "tslib": "^2.3.1", 37 | "zone.js": "~0.11.4" 38 | }, 39 | "devDependencies": { 40 | "@angular-builders/jest": "latest", 41 | "@angular-devkit/build-angular": "~12.2.12", 42 | "@angular-eslint/builder": "~12.6.1", 43 | "@angular-eslint/eslint-plugin": "~12.4.1", 44 | "@angular-eslint/eslint-plugin-template": "~12.6.1", 45 | "@angular-eslint/schematics": "~12.6.1", 46 | "@angular-eslint/template-parser": "~12.6.1", 47 | "@angular/cli": "~12.2.12", 48 | "@angular/compiler-cli": "~12.2.12", 49 | "@cypress/schematic": "^1.5.3", 50 | "@types/jasmine": "~3.9.0", 51 | "@types/jest": "^27.0.2", 52 | "@types/node": "^16.9.1", 53 | "@typescript-eslint/eslint-plugin": "^4.31.0", 54 | "@typescript-eslint/parser": "^4.31.0", 55 | "cypress": "latest", 56 | "eslint": "^7.32.0", 57 | "jasmine-core": "^3.9.0", 58 | "jasmine-spec-reporter": "~7.0.0", 59 | "jest": "^27.3.1", 60 | "jest-junit": "^13.0.0", 61 | "jest-preset-angular": "^10.1.0", 62 | "karma-coverage": "~2.0.3", 63 | "protractor": "~7.0.0", 64 | "ts-jest": "27.0.5", 65 | "ts-node": "~10.2.1", 66 | "typescript": "~4.3.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ui/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/graphql": { 3 | "target": "http://localhost:3000", 4 | "changeOrigin": false, 5 | "secure": false, 6 | "logLevel": "debug", 7 | "pathRewrite": { "^/graphql": "/graphql" } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | import './jest-global-mocks'; 3 | -------------------------------------------------------------------------------- /ui/src/app/admin/admin-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | const routes: Routes = []; 5 | 6 | @NgModule({ 7 | imports: [RouterModule.forChild(routes)], 8 | exports: [RouterModule] 9 | }) 10 | export class AdminRoutingModule { } 11 | -------------------------------------------------------------------------------- /ui/src/app/admin/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { AdminRoutingModule } from './admin-routing.module'; 5 | 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [ 10 | CommonModule, 11 | AdminRoutingModule 12 | ] 13 | }) 14 | export class AdminModule { } 15 | -------------------------------------------------------------------------------- /ui/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | // src/app/app-routing.module.ts 2 | 3 | import { NgModule } from '@angular/core'; 4 | import { Routes, RouterModule } from '@angular/router'; 5 | 6 | import { AuthGuard } from '@auth0/auth0-angular'; 7 | import { PostsModule } from './posts/posts.module'; 8 | 9 | const routes: Routes = [ 10 | { path: '', redirectTo: '/home', pathMatch: 'full' }, 11 | { 12 | path: 'profile', 13 | loadChildren: () => 14 | import('./profile/profile.module').then((m) => m.ProfileModule), 15 | canActivate: [AuthGuard], 16 | }, 17 | { 18 | path: 'posts', 19 | loadChildren: () => 20 | import('./posts/posts.module').then((m) => m.PostsModule), 21 | }, 22 | { 23 | path: 'admin', 24 | loadChildren: () => 25 | import('./admin/admin.module').then((m) => m.AdminModule), 26 | canActivate: [AuthGuard], 27 | }, 28 | //{ path: '**', component:PageNotFoundComponent} 29 | ]; 30 | 31 | @NgModule({ 32 | imports: [RouterModule.forRoot(routes)], 33 | exports: [RouterModule], 34 | }) 35 | export class AppRoutingModule {} 36 | -------------------------------------------------------------------------------- /ui/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | router-outlet { 2 | margin: 0px; 3 | display: flex; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 |
7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /ui/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { AuthService } from '@auth0/auth0-angular'; 3 | import { Apollo, gql } from 'apollo-angular'; 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.css'], 8 | }) 9 | export class AppComponent implements OnInit { 10 | constructor(public apollo: Apollo, public auth: AuthService) {} 11 | 12 | ngOnInit(): void { 13 | this.auth.isAuthenticated$.subscribe((authenticated) => { 14 | if (authenticated) { 15 | const syncUserInfo = gql` 16 | mutation { 17 | updateUser { 18 | success 19 | } 20 | } 21 | `; 22 | this.apollo 23 | .mutate({ 24 | mutation: syncUserInfo, 25 | }) 26 | .subscribe(({ data }) => { 27 | console.log('done refreshed user info:', data); 28 | }); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | // Import the module from the SDK 6 | import { AuthHttpInterceptor, AuthModule } from '@auth0/auth0-angular'; 7 | import { AppRoutingModule } from './app-routing.module'; 8 | import { AppComponent } from './app.component'; 9 | import { SharedModule } from './shared/shared.module'; 10 | import { CoreModule } from './core/core.module'; 11 | import { HomeModule } from './home/home.module'; 12 | import { GraphQLModule } from './core/graphql.module'; 13 | 14 | @NgModule({ 15 | declarations: [AppComponent], 16 | imports: [ 17 | BrowserModule, 18 | BrowserAnimationsModule, 19 | CoreModule, 20 | SharedModule, 21 | // Import the module into the application, with configuration 22 | AuthModule.forRoot({ 23 | domain: 'dev-ese8241b.us.auth0.com', 24 | clientId: 'xwulkQN219vK2LU9MKowCo0HQLRi0WQU', 25 | audience: 'https://hantsy.github.io/api', 26 | scope: 'openid profile email read:posts write:posts', 27 | // The AuthHttpInterceptor configuration 28 | httpInterceptor: { 29 | allowedList: [ 30 | // Attach access tokens to any calls to '/api' (exact match) 31 | //'/api', 32 | // Attach access tokens to any calls that start with '/api/' 33 | '/graphql/*', 34 | // Match anything starting with /api/posts, but also specify the audience and scope the attached 35 | // access token must have 36 | // { 37 | // uri: '/api/posts/*', 38 | // httpMethod: 'get', 39 | // tokenOptions: { 40 | // audience: 'https://hantsy.github.io/api', 41 | // scope: 'read:posts', 42 | // }, 43 | // }, 44 | // { 45 | // uri: '/api/posts/*', 46 | // httpMethod: 'post', 47 | // tokenOptions: { 48 | // audience: 'https://hantsy.github.io/api', 49 | // scope: 'write:posts', 50 | // }, 51 | // }, 52 | // { 53 | // uri: '/api/posts/*', 54 | // httpMethod: 'put', 55 | // tokenOptions: { 56 | // audience: 'https://hantsy.github.io/api', 57 | // scope: 'write:posts', 58 | // }, 59 | // }, 60 | // { 61 | // uri: '/api/posts/*', 62 | // httpMethod: 'delete', 63 | // tokenOptions: { 64 | // audience: 'https://hantsy.github.io/api', 65 | // scope: 'delete:posts', 66 | // }, 67 | // }, 68 | // // Using an absolute URI 69 | // { 70 | // uri: 'https://dev-ese8241b.us.auth0.com/api/v2/users', 71 | // tokenOptions: { 72 | // audience: 'https://hantsy.github.io/api', 73 | // scope: 'read:users', 74 | // }, 75 | // }, 76 | ], 77 | }, 78 | }), 79 | HomeModule, 80 | AppRoutingModule, 81 | ], 82 | providers: [ 83 | { 84 | provide: HTTP_INTERCEPTORS, 85 | useClass: AuthHttpInterceptor, 86 | multi: true, 87 | }, 88 | ], 89 | bootstrap: [AppComponent], 90 | }) 91 | export class AppModule {} 92 | -------------------------------------------------------------------------------- /ui/src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, Optional, SkipSelf } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { RouterModule } from '@angular/router'; 5 | import { GraphQLModule } from './graphql.module'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [CommonModule, HttpClientModule, RouterModule, GraphQLModule], 10 | exports: [CommonModule, HttpClientModule, RouterModule, GraphQLModule], 11 | }) 12 | export class CoreModule { 13 | // Prevent reimport of the CoreModule 14 | constructor(@Optional() @SkipSelf() parentModule: CoreModule) { 15 | if (parentModule) { 16 | throw new Error( 17 | 'CoreModule is already loaded. Import it in the AppModule only' 18 | ); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/app/core/graphql.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Apollo, APOLLO_OPTIONS } from 'apollo-angular'; 3 | import { HttpLink } from 'apollo-angular/http'; 4 | import { InMemoryCache, ApolloLink } from '@apollo/client/core'; 5 | import { setContext } from '@apollo/client/link/context'; 6 | import { environment } from '../../environments/environment'; 7 | import { AuthService } from '@auth0/auth0-angular'; 8 | 9 | const uri = environment.baseApiUrl; // <-- add the URL of the GraphQL server here 10 | export function createApollo(httpLink: HttpLink, authService: AuthService) { 11 | const basic = setContext((operation, context) => ({ 12 | headers: { 13 | Accept: 'charset=utf-8', 14 | }, 15 | })); 16 | 17 | const auth = setContext(async (operation, context) => { 18 | const token = await authService.getAccessTokenSilently().toPromise(); 19 | 20 | if (token === null) { 21 | return {}; 22 | } else { 23 | return { 24 | headers: { 25 | Authorization: `Bearer ${token}`, 26 | }, 27 | }; 28 | } 29 | }); 30 | 31 | const link = ApolloLink.from([basic, auth, httpLink.create({ uri })]); 32 | const cache = new InMemoryCache(); 33 | 34 | return { 35 | link, 36 | cache, 37 | }; 38 | } 39 | 40 | @NgModule({ 41 | providers: [ 42 | { 43 | provide: APOLLO_OPTIONS, 44 | useFactory: createApollo, 45 | deps: [HttpLink, AuthService], 46 | }, 47 | ], 48 | }) 49 | export class GraphQLModule {} 50 | -------------------------------------------------------------------------------- /ui/src/app/error/error-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { ErrorComponent } from './error.component'; 4 | 5 | const routes: Routes = [{ path: 'error', component: ErrorComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | providers: [], 11 | }) 12 | export class ErrorRoutingModule {} 13 | -------------------------------------------------------------------------------- /ui/src/app/error/error.component.html: -------------------------------------------------------------------------------- 1 |

error works!

2 | -------------------------------------------------------------------------------- /ui/src/app/error/error.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-error', 5 | templateUrl: './error.component.html', 6 | styles: [ 7 | ] 8 | }) 9 | export class ErrorComponent implements OnInit { 10 | 11 | constructor() { } 12 | 13 | ngOnInit(): void { 14 | console.log("ngOnInit in ErrorComponent."); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/app/error/error.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { SharedModule } from '../shared/shared.module'; 3 | import { ErrorRoutingModule } from './error-routing.module'; 4 | import { ErrorComponent } from './error.component'; 5 | 6 | @NgModule({ 7 | declarations: [ErrorComponent], 8 | imports: [SharedModule, ErrorRoutingModule], 9 | }) 10 | export class ErrorModule {} 11 | -------------------------------------------------------------------------------- /ui/src/app/home/home-content/home-content.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-graphql-sample/a5a092202bda6632d0d8a46d94150b7d6c8b0fee/ui/src/app/home/home-content/home-content.component.css -------------------------------------------------------------------------------- /ui/src/app/home/home-content/home-content.component.html: -------------------------------------------------------------------------------- 1 |
2 |

What can I do next?

3 | 4 |
5 |
6 |
7 | 8 | 9 | Configure other identity providers 10 | 11 |
12 |

13 | Auth0 supports social providers as Facebook, Twitter, Instagram and 14 | 100+, Enterprise providers as Microsoft Office 365, Google Apps, Azure, 15 | and more. You can also use any OAuth2 Authorization Server. 16 |

17 |
18 | 19 |
20 | 21 |
22 |
23 | 24 | 25 | Enable Multi-Factor Authentication 26 | 27 |
28 |

29 | Add an extra layer of security by enabling Multi-factor Authentication, 30 | requiring your users to provide more than one piece of identifying 31 | information. Push notifications, authenticator apps, SMS, and DUO 32 | Security are supported. 33 |

34 |
35 |
36 | 37 |
38 |
39 |
40 | 41 | 42 | Anomaly Detection 43 | 44 |
45 |

46 | Auth0 can detect anomalies and stop malicious attempts to access your 47 | application. Anomaly detection can alert you and your users of 48 | suspicious activity, as well as block further login attempts. 49 |

50 |
51 | 52 |
53 | 54 |
55 |
56 | 57 | 58 | Learn About Rules 59 | 60 |
61 |

62 | Rules are JavaScript functions that execute when a user authenticates to 63 | your application. They run once the authentication process is complete, 64 | and you can use them to customize and extend Auth0's capabilities. 65 |

66 |
67 |
68 |
69 | -------------------------------------------------------------------------------- /ui/src/app/home/home-content/home-content.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | @Component({ 3 | selector: 'app-home-content', 4 | templateUrl: './home-content.component.html', 5 | styleUrls: ['./home-content.component.css'], 6 | }) 7 | export class HomeContentComponent implements OnInit { 8 | constructor() {} 9 | 10 | ngOnInit(): void {} 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/app/home/home-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { HomeComponent } from './home.component'; 4 | 5 | const routes: Routes = [{ path: 'home', component: HomeComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | providers: [], 11 | }) 12 | export class HomeRoutingModule {} 13 | -------------------------------------------------------------------------------- /ui/src/app/home/home.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-graphql-sample/a5a092202bda6632d0d8a46d94150b7d6c8b0fee/ui/src/app/home/home.component.css -------------------------------------------------------------------------------- /ui/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-home', 5 | templateUrl: './home.component.html', 6 | styleUrls: ['./home.component.css'] 7 | }) 8 | export class HomeComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/app/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { HomeComponent } from './home.component'; 3 | import { HomeContentComponent } from './home-content/home-content.component'; 4 | import { SharedModule } from '../shared/shared.module'; 5 | import { HomeRoutingModule } from './home-routing.module'; 6 | 7 | @NgModule({ 8 | declarations: [HomeComponent, HomeContentComponent], 9 | imports: [SharedModule, HomeRoutingModule], 10 | }) 11 | export class HomeModule {} 12 | -------------------------------------------------------------------------------- /ui/src/app/posts/edit-post/edit-post.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | flex-direction: column; 5 | margin: 0px; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/app/posts/edit-post/edit-post.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | {{'edit-post'}} 6 |

7 |

8 | all fields marked with star are required. 9 |

10 |
11 | 12 |
13 | back to {{'post-list'}} 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /ui/src/app/posts/edit-post/edit-post.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { EditPostComponent } from './edit-post.component'; 4 | 5 | describe('EditPostComponent', () => { 6 | let component: EditPostComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ EditPostComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(EditPostComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/posts/edit-post/edit-post.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Router, ActivatedRoute } from '@angular/router'; 3 | import { Post } from '../shared/post.model'; 4 | 5 | @Component({ 6 | selector: 'app-edit-post', 7 | templateUrl: './edit-post.component.html', 8 | styleUrls: ['./edit-post.component.css'], 9 | }) 10 | export class EditPostComponent implements OnInit, OnDestroy { 11 | post: Post = { title: '', content: '' }; 12 | 13 | constructor(private router: Router, private route: ActivatedRoute) {} 14 | 15 | onPostUpdated(event: any) { 16 | console.log('post was updated:' + JSON.stringify(event)); 17 | if (event) { 18 | this.router.navigateByUrl('/posts'); 19 | } 20 | } 21 | 22 | ngOnInit() { 23 | this.post = this.route.snapshot.data['post']; 24 | } 25 | 26 | ngOnDestroy() {} 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/app/posts/new-post/new-post.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | flex-direction: column; 5 | margin: 0px; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/app/posts/new-post/new-post.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | {{'new-post'}} 6 |

7 |

8 | all fields marked with star are required. 9 |

10 |
11 | 12 |
13 | back to {{'post-list'}} 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /ui/src/app/posts/new-post/new-post.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewPostComponent } from './new-post.component'; 4 | 5 | describe('NewPostComponent', () => { 6 | let component: NewPostComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ NewPostComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(NewPostComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/posts/new-post/new-post.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { Subscription } from 'rxjs'; 4 | import { Post } from '../shared/post.model'; 5 | 6 | @Component({ 7 | selector: 'app-new-post', 8 | templateUrl: './new-post.component.html', 9 | styleUrls: ['./new-post.component.css'], 10 | }) 11 | export class NewPostComponent implements OnInit, OnDestroy { 12 | post: Post = { title: '', content: '' }; 13 | sub?: Subscription; 14 | 15 | constructor(private router: Router) {} 16 | 17 | onPostSaved(event: any) { 18 | console.log('post was saved::' + event); 19 | if (event) { 20 | this.router.navigate(['', 'posts']); 21 | } 22 | } 23 | 24 | ngOnInit() { 25 | console.log('calling ngOnInit::NewPostComponent...'); 26 | } 27 | 28 | ngOnDestroy() { 29 | console.log('calling ngOnDestroy::NewPostComponent...'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-details/post-details.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | flex-direction: column; 5 | margin: 0px; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-details/post-details.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | {{post.title}} 6 |

7 |

8 | {{post.author?.id||'unknown'}} • {{post.createdAt|date:'short'}} 9 |

10 |
11 |
12 | {{post.content}} 13 |
14 |
15 | back to {{'post-list'}} 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-details/post-details.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostDetailsComponent } from './post-details.component'; 4 | 5 | describe('PostDetailsComponent', () => { 6 | let component: PostDetailsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostDetailsComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostDetailsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-details/post-details.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | import { ApolloQueryResult } from '@apollo/client/core'; 4 | import { Subscription } from 'rxjs'; 5 | import { map, mergeMap, switchMap } from 'rxjs/operators'; 6 | import { Comment } from '../shared/comment.model'; 7 | import { Post } from '../shared/post.model'; 8 | import { PostService } from '../shared/post.service'; 9 | 10 | @Component({ 11 | selector: 'app-post-details', 12 | templateUrl: './post-details.component.html', 13 | styleUrls: ['./post-details.component.css'], 14 | }) 15 | export class PostDetailsComponent implements OnInit, OnDestroy { 16 | postId?: string; 17 | post: Post = { title: '', content: '' }; 18 | comments: Comment[] = []; 19 | sub?: Subscription; 20 | 21 | constructor( 22 | private postService: PostService, 23 | private router: Router, 24 | private route: ActivatedRoute 25 | ) {} 26 | 27 | ngOnInit() { 28 | console.log('calling ngOnInit::PostDetailsComponent... '); 29 | this.sub = this.route.params 30 | .pipe( 31 | switchMap((params) => { 32 | this.postId = params['id']; 33 | 34 | if (!this.postId) 35 | throw new Error('request parameter post id is required.'); 36 | 37 | return this.postService.getPost(this.postId).pipe( 38 | map(({ error, data }: ApolloQueryResult, index) => { 39 | if (error) throw error; 40 | return data?.getPostById; 41 | }) 42 | ); 43 | }) 44 | ) 45 | .subscribe((res) => { 46 | console.log(res); 47 | this.post = res; 48 | }); 49 | } 50 | 51 | ngOnDestroy() { 52 | if (this.sub) { 53 | this.sub.unsubscribe(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-list/post-list.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-list/post-list.component.html: -------------------------------------------------------------------------------- 1 |
Loading...
2 |
3 |
4 | 9 |
10 | New Post 11 | 12 |
13 |
14 | 15 |
{{error}}
16 |
17 |
18 |
19 |
20 |

{{post.title|uppercase}}

21 |
{{post.author?.id||'unknown'}} • {{post.createdAt|date:'short'}} 22 | 23 | 24 | 25 |
26 |
27 |
28 |
{{post.content}}
29 | 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-list/post-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostListComponent } from './post-list.component'; 4 | 5 | describe('PostListComponent', () => { 6 | let component: PostListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostListComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostListComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/posts/post-list/post-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Post } from '../shared/post.model'; 3 | import { Subscription } from 'rxjs'; 4 | import { PostService } from '../shared/post.service'; 5 | import { Router } from '@angular/router'; 6 | import { ApolloQueryResult } from '@apollo/client/core'; 7 | 8 | @Component({ 9 | selector: 'app-post-list', 10 | templateUrl: './post-list.component.html', 11 | styleUrls: ['./post-list.component.css'], 12 | }) 13 | export class PostListComponent implements OnInit, OnDestroy { 14 | q = null; 15 | posts: Post[] = []; 16 | sub?: Subscription; 17 | error: any; 18 | loading = true; 19 | 20 | constructor(private router: Router, private postService: PostService) {} 21 | 22 | search() { 23 | this.sub = this.postService 24 | .getPosts({ q: this.q }) 25 | .subscribe(({ loading, data, error }: ApolloQueryResult) => { 26 | this.posts = data?.getAllPosts; 27 | this.loading = loading; 28 | this.error = error; 29 | }); 30 | } 31 | 32 | searchByTerm($event: any) { 33 | console.log('search by term:' + $event); 34 | this.updateTerm($event); 35 | this.search(); 36 | } 37 | 38 | updateTerm($event: any) { 39 | console.log('update term:' + $event); 40 | this.q = $event; 41 | } 42 | 43 | clearTerm($event: any) { 44 | console.log('clear term:' + $event); 45 | this.q = null; 46 | } 47 | 48 | addPost() { 49 | this.router.navigate(['', 'posts', 'new']); 50 | } 51 | 52 | ngOnInit() { 53 | console.log('calling ngOnInit::PostListComponent'); 54 | this.search(); 55 | } 56 | 57 | ngOnDestroy() { 58 | console.log('calling ngOnDestroy::PostListComponent'); 59 | if (this.sub) { 60 | this.sub.unsubscribe(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/app/posts/posts-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { PostDetailsComponent } from './post-details/post-details.component'; 4 | import { NewPostComponent } from './new-post/new-post.component'; 5 | import { EditPostComponent } from './edit-post/edit-post.component'; 6 | import { PostListComponent } from './post-list/post-list.component'; 7 | import { PostDetailsResolve } from './shared/post-details-resolve'; 8 | import { AuthGuard } from '@auth0/auth0-angular'; 9 | 10 | const routes: Routes = [ 11 | { path: '', component: PostListComponent }, 12 | { path: 'new', component: NewPostComponent, canActivate: [AuthGuard] }, 13 | { 14 | path: 'edit/:id', 15 | component: EditPostComponent, 16 | canActivate: [AuthGuard], 17 | resolve: { 18 | post: PostDetailsResolve, 19 | }, 20 | }, 21 | { path: 'view/:id', component: PostDetailsComponent }, 22 | ]; 23 | 24 | @NgModule({ 25 | imports: [RouterModule.forChild(routes)], 26 | exports: [RouterModule], 27 | }) 28 | export class PostsRoutingModule {} 29 | -------------------------------------------------------------------------------- /ui/src/app/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { PostsRoutingModule } from './posts-routing.module'; 4 | import { SharedModule } from '../shared/shared.module'; 5 | import { PostListComponent } from './post-list/post-list.component'; 6 | import { PostDetailsComponent } from './post-details/post-details.component'; 7 | import { NewPostComponent } from './new-post/new-post.component'; 8 | import { EditPostComponent } from './edit-post/edit-post.component'; 9 | import { PostFormComponent } from './shared/post-form/post-form.component'; 10 | import { PostService } from './shared/post.service'; 11 | import { PostDetailsResolve } from './shared/post-details-resolve'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | PostListComponent, 16 | PostDetailsComponent, 17 | NewPostComponent, 18 | EditPostComponent, 19 | PostFormComponent, 20 | ], 21 | imports: [SharedModule, PostsRoutingModule], 22 | providers: [PostService, PostDetailsResolve], 23 | }) 24 | export class PostsModule {} 25 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/comment.model.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.model'; 2 | export interface Comment { 3 | id?: string; 4 | content: string; 5 | author?: User; 6 | createdDate?: string; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post-details-resolve.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | Resolve, 4 | ActivatedRouteSnapshot, 5 | RouterStateSnapshot, 6 | } from '@angular/router'; 7 | import { PostService } from './post.service'; 8 | import { Post } from './post.model'; 9 | import { map } from 'rxjs/operators'; 10 | import { ApolloQueryResult } from '@apollo/client/core'; 11 | 12 | @Injectable() 13 | export class PostDetailsResolve implements Resolve { 14 | constructor(private postService: PostService) {} 15 | 16 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 17 | const postId = route.paramMap.get('id'); 18 | let data: Post; 19 | if (!postId) throw Error('post id is required'); 20 | return this.postService.getPost(postId).pipe( 21 | map(({ error, data }: ApolloQueryResult, index) => { 22 | if (error) throw error; 23 | return data?.getPostById; 24 | }) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post-form/post-form.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post-form/post-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 6 |
7 | Post Title is required 8 |
9 |
10 |
11 | 12 | 16 |
17 | Post Content is required 18 |
19 |
20 | At least 10 chars 21 |
22 |
23 |
24 | 26 |
27 | 28 |
29 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post-form/post-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostFormComponent } from './post-form.component'; 4 | 5 | describe('PostFormComponent', () => { 6 | let component: PostFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostFormComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostFormComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post-form/post-form.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | OnInit, 4 | OnDestroy, 5 | Input, 6 | Output, 7 | EventEmitter, 8 | } from '@angular/core'; 9 | import { Router, ActivatedRoute } from '@angular/router'; 10 | import { Subscription } from 'rxjs'; 11 | import { Post } from '../post.model'; 12 | import { PostService } from '../post.service'; 13 | 14 | @Component({ 15 | selector: 'app-post-form', 16 | templateUrl: './post-form.component.html', 17 | styleUrls: ['./post-form.component.css'], 18 | }) 19 | export class PostFormComponent implements OnInit, OnDestroy { 20 | @Input() post: Post = { title: '', content: '' }; 21 | @Output() save: EventEmitter = new EventEmitter(); 22 | private sub?: Subscription; 23 | 24 | constructor(private postService: PostService) {} 25 | 26 | submit() { 27 | const _body = { 28 | title: this.post.title, 29 | content: this.post.content, 30 | } as Post; 31 | 32 | if (this.post.id) { 33 | this.postService.savePost({ ..._body, id: this.post.id }).subscribe( 34 | (data) => { 35 | console.log('updated successfully!'); 36 | this.save.emit(true); 37 | }, 38 | (error) => { 39 | console.log('failed to update:' + JSON.stringify(error)); 40 | this.save.emit(false); 41 | } 42 | ); 43 | } else { 44 | this.postService.savePost(_body).subscribe( 45 | (data) => { 46 | console.log('saved successfully!'); 47 | this.save.emit(true); 48 | }, 49 | (error) => { 50 | console.log('failed to save:' + JSON.stringify(error)); 51 | this.save.emit(false); 52 | } 53 | ); 54 | } 55 | } 56 | 57 | ngOnInit() { 58 | console.log('calling ngOnInit::PostFormComponent...'); 59 | } 60 | 61 | ngOnDestroy() { 62 | console.log('calling ngOnDestroy::PostFormComponent...'); 63 | if (this.sub) { 64 | this.sub.unsubscribe(); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post.model.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.model'; 2 | export interface Post { 3 | id?: string; 4 | slug?: string; 5 | title: string; 6 | content: string; 7 | author?: User; 8 | createdAt?: any; 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PostService } from './post.service'; 4 | 5 | describe('PostService', () => { 6 | let service: PostService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PostService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/post.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ApolloQueryResult, FetchResult } from '@apollo/client/core'; 3 | import { Apollo, gql, QueryRef } from 'apollo-angular'; 4 | import { Observable } from 'rxjs'; 5 | import { Comment } from './comment.model'; 6 | import { Post } from './post.model'; 7 | 8 | @Injectable() 9 | export class PostService { 10 | constructor(private readonly apollo: Apollo) {} 11 | 12 | getPosts(term?: any): Observable> { 13 | // const params: HttpParams = new HttpParams(); 14 | // if (term) { 15 | // Object.keys(term).map((key) => { 16 | // if (term[key]) { 17 | // params.set(key, term[key]); 18 | // } 19 | // }); 20 | // } 21 | 22 | // 23 | // omit search terms now. 24 | const queryGql = gql` 25 | query { 26 | getAllPosts { 27 | id 28 | title 29 | content 30 | createdAt 31 | authorId 32 | author { 33 | id 34 | } 35 | } 36 | } 37 | `; 38 | return this.apollo.watchQuery({ query: queryGql }).valueChanges; 39 | } 40 | 41 | getPost(id: string): Observable> { 42 | const queryGql = gql` 43 | query ($id: String!) { 44 | getPostById(postId: $id) { 45 | id 46 | title 47 | content 48 | authorId 49 | author { 50 | id 51 | } 52 | createdAt 53 | comments { 54 | content 55 | } 56 | } 57 | } 58 | `; 59 | return this.apollo.query({ 60 | query: queryGql, 61 | variables: { 62 | id: id, 63 | }, 64 | }); 65 | } 66 | 67 | savePost(data: Post): Observable> { 68 | const queryGql = gql` 69 | mutation ($createPostInput: PostInput!) { 70 | createPost(createPostInput: $createPostInput) { 71 | id 72 | title 73 | } 74 | } 75 | `; 76 | return this.apollo.mutate({ 77 | mutation: queryGql, 78 | variables: { 79 | createPostInput: { 80 | id: data.id, 81 | title: data.title, 82 | content: data.content, 83 | }, 84 | }, 85 | }); 86 | } 87 | 88 | saveComment( 89 | id: string, 90 | data: Comment 91 | ): Observable> { 92 | const queryGql = gql` 93 | mutation ($commentInput: CommentInput!) { 94 | addComment(commentInput: $commentInput) { 95 | id 96 | content 97 | } 98 | } 99 | `; 100 | return this.apollo.mutate({ 101 | mutation: queryGql, 102 | variables: { 103 | commentInput: { 104 | postId: id, 105 | content: data.content, 106 | }, 107 | }, 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /ui/src/app/posts/shared/user.model.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | email?: string; 4 | name?: string; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/app/profile/profile-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { ProfileComponent } from './profile.component'; 4 | 5 | const routes: Routes = [{ path: '', component: ProfileComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | }) 11 | export class ProfileRoutingModule {} 12 | -------------------------------------------------------------------------------- /ui/src/app/profile/profile.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-graphql-sample/a5a092202bda6632d0d8a46d94150b7d6c8b0fee/ui/src/app/profile/profile.component.css -------------------------------------------------------------------------------- /ui/src/app/profile/profile.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | User's profile picture 7 |
8 |
9 |

{{ user.name }}

10 |

{{ user.email }}

11 |
12 |
13 | 14 |
15 |
{{ profileJson }}
16 |
17 |
18 | -------------------------------------------------------------------------------- /ui/src/app/profile/profile.component.ts: -------------------------------------------------------------------------------- 1 | // src/app/pages/profile/profile.component.ts 2 | 3 | import { Component, OnInit } from '@angular/core'; 4 | import { AuthService } from '@auth0/auth0-angular'; 5 | 6 | @Component({ 7 | selector: 'app-profile', 8 | templateUrl: './profile.component.html', 9 | }) 10 | export class ProfileComponent implements OnInit { 11 | profileJson?: string; 12 | 13 | constructor(public auth: AuthService) {} 14 | 15 | ngOnInit(): void { 16 | this.auth.user$.subscribe( 17 | (profile) => (this.profileJson = JSON.stringify(profile, null, 2)) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/app/profile/profile.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SharedModule } from '../shared/shared.module'; 4 | import { ProfileRoutingModule } from './profile-routing.module'; 5 | import { ProfileComponent } from './profile.component'; 6 | 7 | @NgModule({ 8 | declarations: [ProfileComponent], 9 | imports: [SharedModule, ProfileRoutingModule], 10 | }) 11 | export class ProfileModule {} 12 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/auth-nav/auth-nav.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/auth-nav/auth-nav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-auth-nav', 5 | templateUrl: './auth-nav.component.html', 6 | styles: [ 7 | ] 8 | }) 9 | export class AuthNavComponent implements OnInit { 10 | 11 | constructor() { } 12 | 13 | ngOnInit(): void { 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/authentication-button/authentication-button.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/authentication-button/authentication-button.component.ts: -------------------------------------------------------------------------------- 1 | // src/app/components/authentication-button/authentication-button.component.ts 2 | 3 | import { Component, OnInit } from '@angular/core'; 4 | import { AuthService } from '@auth0/auth0-angular'; 5 | 6 | @Component({ 7 | selector: 'app-authentication-button', 8 | templateUrl: './authentication-button.component.html', 9 | styles: [], 10 | }) 11 | export class AuthenticationButtonComponent implements OnInit { 12 | constructor(public auth: AuthService) {} 13 | 14 | ngOnInit(): void {} 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | The source codes of this sample application is hosted on 5 | 6 | Github 7 | 8 |

9 |
10 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styles: [ 7 | ] 8 | }) 9 | export class FooterComponent implements OnInit { 10 | 11 | constructor() { } 12 | 13 | ngOnInit(): void { 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/loading/loading.component.html: -------------------------------------------------------------------------------- 1 |
2 | Loading... 3 |
4 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/loading/loading.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-loading', 5 | templateUrl: './loading.component.html', 6 | }) 7 | export class LoadingComponent implements OnInit { 8 | loadingImg = 9 | 'https://cdn.auth0.com/blog/auth0-react-sample/assets/loading.svg'; 10 | constructor() {} 11 | 12 | ngOnInit(): void {} 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/login-button/login-button.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/login-button/login-button.component.ts: -------------------------------------------------------------------------------- 1 | // src/app/components/login-button/login-button.component.ts 2 | 3 | import { Component, OnInit } from '@angular/core'; 4 | import { AuthService } from '@auth0/auth0-angular'; 5 | 6 | @Component({ 7 | selector: 'app-login-button', 8 | templateUrl: './login-button.component.html', 9 | styles: [], 10 | }) 11 | export class LoginButtonComponent implements OnInit { 12 | constructor(public auth: AuthService) {} 13 | 14 | ngOnInit(): void {} 15 | 16 | loginWithRedirect(): void { 17 | this.auth.loginWithRedirect(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/logout-button/logout-button.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/logout-button/logout-button.component.ts: -------------------------------------------------------------------------------- 1 | // src/app/components/logout-button/logout-button.component.ts 2 | 3 | import { Component, Inject, OnInit } from '@angular/core'; 4 | import { AuthService } from '@auth0/auth0-angular'; 5 | import { DOCUMENT } from '@angular/common'; 6 | 7 | @Component({ 8 | selector: 'app-logout-button', 9 | templateUrl: './logout-button.component.html', 10 | styles: [], 11 | }) 12 | export class LogoutButtonComponent implements OnInit { 13 | constructor( 14 | public auth: AuthService, 15 | @Inject(DOCUMENT) private doc: Document 16 | ) {} 17 | 18 | ngOnInit(): void {} 19 | 20 | logout(): void { 21 | this.auth.logout({ returnTo: this.doc.location.origin }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/main-nav/main-nav.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | flex: 1; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/main-nav/main-nav.component.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/main-nav/main-nav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-main-nav', 5 | templateUrl: './main-nav.component.html', 6 | styleUrls: ['./main-nav.component.css'], 7 | }) 8 | export class MainNavComponent implements OnInit { 9 | constructor() {} 10 | 11 | ngOnInit(): void {} 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/nav-bar/nav-bar.component.css: -------------------------------------------------------------------------------- 1 | .container { 2 | justify-content: flex-start; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/nav-bar/nav-bar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/nav-bar/nav-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-nav-bar', 5 | templateUrl: './nav-bar.component.html', 6 | styleUrls: ['./nav-bar.component.css'], 7 | }) 8 | export class NavBarComponent implements OnInit { 9 | constructor() {} 10 | 11 | ngOnInit(): void {} 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/signup-button/signup-button.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /ui/src/app/shared/components/signup-button/signup-button.component.ts: -------------------------------------------------------------------------------- 1 | // src/app/components/signup-button/signup-button.component.ts 2 | 3 | import { Component, OnInit } from '@angular/core'; 4 | 5 | import { AuthService } from '@auth0/auth0-angular'; 6 | 7 | @Component({ 8 | selector: 'app-signup-button', 9 | templateUrl: './signup-button.component.html', 10 | }) 11 | export class SignupButtonComponent implements OnInit { 12 | constructor(public auth: AuthService) {} 13 | 14 | ngOnInit(): void {} 15 | 16 | loginWithRedirect(): void { 17 | this.auth.loginWithRedirect({ screen_hint: 'signup' }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { LoginButtonComponent } from './components/login-button/login-button.component'; 4 | import { SignupButtonComponent } from './components/signup-button/signup-button.component'; 5 | import { AuthNavComponent } from './components/auth-nav/auth-nav.component'; 6 | import { AuthenticationButtonComponent } from './components/authentication-button/authentication-button.component'; 7 | import { FooterComponent } from './components/footer/footer.component'; 8 | import { LoadingComponent } from './components/loading/loading.component'; 9 | import { LogoutButtonComponent } from './components/logout-button/logout-button.component'; 10 | import { MainNavComponent } from './components/main-nav/main-nav.component'; 11 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 12 | import { RouterModule } from '@angular/router'; 13 | import { NavBarComponent } from './components/nav-bar/nav-bar.component'; 14 | 15 | const COMPONENTS: any[] = [ 16 | LoginButtonComponent, 17 | SignupButtonComponent, 18 | LogoutButtonComponent, 19 | AuthenticationButtonComponent, 20 | AuthNavComponent, 21 | MainNavComponent, 22 | NavBarComponent, 23 | LoadingComponent, 24 | FooterComponent, 25 | ]; 26 | 27 | const ANGULAR_MODULES: any[] = [FormsModule, ReactiveFormsModule]; 28 | 29 | const OTHER_MODULES: any[] = []; 30 | 31 | @NgModule({ 32 | declarations: [COMPONENTS], 33 | imports: [CommonModule, RouterModule, ANGULAR_MODULES, OTHER_MODULES], 34 | exports: [CommonModule, ANGULAR_MODULES, OTHER_MODULES, COMPONENTS], 35 | }) 36 | export class SharedModule {} 37 | -------------------------------------------------------------------------------- /ui/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-graphql-sample/a5a092202bda6632d0d8a46d94150b7d6c8b0fee/ui/src/assets/.gitkeep -------------------------------------------------------------------------------- /ui/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | baseApiUrl: '/graphql', 4 | }; 5 | -------------------------------------------------------------------------------- /ui/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | baseApiUrl: '/graphql', 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /ui/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-graphql-sample/a5a092202bda6632d0d8a46d94150b7d6c8b0fee/ui/src/favicon.ico -------------------------------------------------------------------------------- /ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ui 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /ui/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. 3 | */ 4 | import '@angular/localize/init'; 5 | /** 6 | * This file includes polyfills needed by Angular and is loaded before the app. 7 | * You can add your own extra polyfills to this file. 8 | * 9 | * This file is divided into 2 sections: 10 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 11 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 12 | * file. 13 | * 14 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 15 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 16 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 17 | * 18 | * Learn more in https://angular.io/guide/browser-support 19 | */ 20 | 21 | /*************************************************************************************************** 22 | * BROWSER POLYFILLS 23 | */ 24 | 25 | /** 26 | * IE11 requires the following for NgClass support on SVG elements 27 | */ 28 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 29 | 30 | /** 31 | * Web Animations `@angular/platform-browser/animations` 32 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 33 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 34 | */ 35 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 36 | 37 | /** 38 | * By default, zone.js will patch all possible macroTask and DomEvents 39 | * user can disable parts of macroTask/DomEvents patch by setting following flags 40 | * because those flags need to be set before `zone.js` being loaded, and webpack 41 | * will put import in the top of bundle, so user need to create a separate file 42 | * in this directory (for example: zone-flags.ts), and put the following flags 43 | * into that file, and then add the following code before importing zone.js. 44 | * import './zone-flags'; 45 | * 46 | * The flags allowed in zone-flags.ts are listed here. 47 | * 48 | * The following flags will work for all browsers. 49 | * 50 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 51 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 52 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 53 | * 54 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 55 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 56 | * 57 | * (window as any).__Zone_enable_cross_context_check = true; 58 | * 59 | */ 60 | 61 | /*************************************************************************************************** 62 | * Zone JS is required by default for Angular itself. 63 | */ 64 | import 'zone.js/dist/zone'; // Included with Angular CLI. 65 | 66 | 67 | /*************************************************************************************************** 68 | * APPLICATION IMPORTS 69 | */ 70 | -------------------------------------------------------------------------------- /ui/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 7 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 8 | sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | overflow-y: scroll; 12 | height: 100%; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 17 | monospace; 18 | } 19 | 20 | .next-steps .fa-link { 21 | margin-right: 5px; 22 | } 23 | 24 | /* Fix for use only flexbox in content area */ 25 | .next-steps .row { 26 | margin-bottom: 0; 27 | } 28 | 29 | .next-steps .col-md-5 { 30 | margin-bottom: 3rem; 31 | } 32 | 33 | @media (max-width: 768px) { 34 | .next-steps .col-md-5 { 35 | margin-bottom: 0; 36 | } 37 | } 38 | 39 | .spinner { 40 | position: absolute; 41 | display: flex; 42 | justify-content: center; 43 | height: 100vh; 44 | width: 100vw; 45 | background-color: white; 46 | top: 0; 47 | bottom: 0; 48 | left: 0; 49 | right: 0; 50 | } 51 | 52 | .result-block-container .result-block { 53 | opacity: 1; 54 | } 55 | 56 | .loading { 57 | display: flex; 58 | min-height: 500px; 59 | align-items: center; 60 | justify-content: center; 61 | } 62 | 63 | .userImg { 64 | border-radius: 100px; 65 | display: block; 66 | height: 100px; 67 | margin: 0 auto; 68 | width: 100px; 69 | } 70 | 71 | .hero .app-logo { 72 | max-width: 10.5rem; 73 | } 74 | -------------------------------------------------------------------------------- /ui/test-config.helper.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | type CompilerOptions = Partial<{ 4 | providers: any[]; 5 | useJit: boolean; 6 | preserveWhitespaces: boolean; 7 | }>; 8 | export type ConfigureFn = (testBed: typeof TestBed) => void; 9 | 10 | export const configureTests = (configure: ConfigureFn, compilerOptions: CompilerOptions = {}) => { 11 | const compilerConfig: CompilerOptions = { 12 | preserveWhitespaces: false, 13 | ...compilerOptions, 14 | }; 15 | 16 | const configuredTestBed = TestBed.configureCompiler(compilerConfig); 17 | 18 | configure(configuredTestBed); 19 | 20 | return configuredTestBed.compileComponents().then(() => configuredTestBed); 21 | }; 22 | -------------------------------------------------------------------------------- /ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /ui/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | // adjust "includes" to what makes sense for you and your project 5 | "src/**/*.ts", 6 | "e2e/**/*.ts" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "sourceMap": true, 11 | "declaration": false, 12 | "downlevelIteration": true, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "target": "es2015", 17 | "module": "es2020", 18 | "lib": ["es2018", "dom", "esnext.asynciterable"], 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "angularCompilerOptions": { 22 | "enableI18nLegacyMessageIdFormat": false, 23 | "strictInjectionParameters": true, 24 | "strictInputAccessModifiers": true, 25 | "strictTemplates": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "esModuleInterop": true, 6 | "outDir": "./out-tsc/spec", 7 | "types": ["jest"], 8 | }, 9 | "files": [ "src/polyfills.ts"], 10 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 11 | } 12 | --------------------------------------------------------------------------------