├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── ci_backend.yml │ └── ci_frontend.yml ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile.backend ├── README.md ├── apps ├── backend │ ├── .eslintcache │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── codegen.yml │ ├── package.json │ ├── prisma │ │ ├── migrations │ │ │ ├── 20210815142751_init │ │ │ │ └── migration.sql │ │ │ ├── 20210825080026_add_uid_to_user │ │ │ │ └── migration.sql │ │ │ ├── 20210826085232_user_role │ │ │ │ └── migration.sql │ │ │ ├── 20210908060800_add_completed_to_todo │ │ │ │ └── migration.sql │ │ │ ├── 20210911131122_prisma_v3 │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ └── schema.prisma │ ├── src │ │ ├── api │ │ │ └── graphql │ │ │ │ ├── generated │ │ │ │ ├── graphql.ts │ │ │ │ └── index.ts │ │ │ │ ├── resolvers │ │ │ │ ├── resolvers.ts │ │ │ │ ├── todoResolver.ts │ │ │ │ └── userResolvers.ts │ │ │ │ └── typeDefs.ts │ │ ├── common │ │ │ ├── error.ts │ │ │ ├── result.ts │ │ │ └── useCase.ts │ │ ├── context.ts │ │ ├── envelopPlugins.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── auth0 │ │ │ │ └── rules │ │ │ │ │ └── setRolesToUser.js │ │ │ └── useAuthHelper.ts │ │ ├── modules │ │ │ ├── todo │ │ │ │ ├── ITodoRepository.ts │ │ │ │ ├── TodoRepository.ts │ │ │ │ └── todoMappers.ts │ │ │ └── user │ │ │ │ ├── IUserRepository.ts │ │ │ │ ├── UserMapper.ts │ │ │ │ └── UserRepository.ts │ │ ├── useCases │ │ │ ├── todoUseCase.ts │ │ │ └── userUseCase.ts │ │ └── utils │ │ │ ├── useOwnerCheck.ts │ │ │ └── validationHelper.ts │ └── tsconfig.json └── frontend │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── codegen.yml │ ├── graphql.config.js │ ├── jest.config.js │ ├── jest.setup.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── components │ │ ├── form │ │ │ ├── TextArea.tsx │ │ │ └── TextField.tsx │ │ ├── general │ │ │ └── DevNote.tsx │ │ ├── index.ts │ │ ├── navigation │ │ │ └── Navbar.tsx │ │ └── util │ │ │ ├── Portal.tsx │ │ │ └── UrqlClientProvider.tsx │ ├── contexts │ │ └── currentUser.tsx │ ├── generated │ │ ├── gql.ts │ │ ├── graphql.ts │ │ ├── index.ts │ │ └── schema.graphql │ ├── mocks │ │ ├── factories │ │ │ ├── factory.ts │ │ │ ├── todo.ts │ │ │ └── user.ts │ │ ├── handlers.ts │ │ └── server.ts │ ├── pages │ │ ├── _app.page.tsx │ │ ├── all-todos │ │ │ ├── AllTodoList.tsx │ │ │ └── index.page.tsx │ │ ├── index.page.tsx │ │ ├── onboarding │ │ │ └── index.page.tsx │ │ ├── profile │ │ │ └── index.page.tsx │ │ ├── signin │ │ │ └── index.page.tsx │ │ ├── signout │ │ │ └── index.page.tsx │ │ ├── signup │ │ │ └── index.page.tsx │ │ ├── todos │ │ │ ├── CreateTodoModal.tsx │ │ │ ├── TodoItem.tsx │ │ │ ├── TodoList.tsx │ │ │ ├── index.page.tsx │ │ │ └── index.test.tsx │ │ └── users │ │ │ ├── UserList.tsx │ │ │ └── index.page.tsx │ ├── styles │ │ └── globals.css │ └── utils │ │ ├── form.test.ts │ │ ├── form.ts │ │ ├── fromObject.ts │ │ └── test-util.tsx │ ├── tailwind.config.js │ └── tsconfig.json ├── docker-compose.yml ├── package.json ├── packages ├── config │ ├── eslint-next.js │ ├── eslint-preset.js │ ├── eslint-server.js │ └── package.json ├── tailwind-config │ ├── package.json │ └── tailwind.config.js ├── tsconfig │ ├── base.json │ ├── expojs.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json ├── ui │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── components │ │ │ ├── Avatar.tsx │ │ │ ├── Button.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── Portal.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── Stack.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── styles.css │ │ └── util.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── tsup.config.ts └── validation-schema │ ├── codegen.ts │ ├── package.json │ ├── src │ ├── generated │ │ └── graphql.ts │ ├── index.ts │ └── zodSchema.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json └── turbo.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/docker/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://root:root@db:3306/fga" 2 | 3 | AUTH0_CLIENT_ID= 4 | AUTH0_DOMAIN= 5 | AUTH0_AUDIENCE= 6 | GRAPHQL_END_POINT=http://localhost:5001/graphql -------------------------------------------------------------------------------- /.github/workflows/ci_backend.yml: -------------------------------------------------------------------------------- 1 | name: CI BE 2 | on: push 3 | 4 | jobs: 5 | test: 6 | name: Link, Type check, Build 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: '16' 17 | 18 | - uses: pnpm/action-setup@v2 19 | name: Install pnpm 20 | id: pnpm-install 21 | with: 22 | version: 7 23 | run_install: false 24 | 25 | - name: Get pnpm store directory 26 | id: pnpm-cache 27 | shell: bash 28 | run: | 29 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 30 | 31 | - uses: actions/cache@v3 32 | name: Setup pnpm cache 33 | with: 34 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 35 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | restore-keys: | 37 | ${{ runner.os }}-pnpm-store- 38 | 39 | - name: Prune 40 | run: npx turbo prune --scope=backend 41 | 42 | - name: Turbo Cache 43 | id: turbo-cache 44 | uses: actions/cache@v3 45 | with: 46 | path: out/node_modules/.cache/turbo # workaround see: https://github.com/vercel/turborepo/issues/451 47 | key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} 48 | restore-keys: | 49 | turbo-${{ github.job }}-${{ github.ref_name }}- 50 | 51 | - name: Install dependencies 52 | working-directory: ./out 53 | run: pnpm install 54 | 55 | - name: Build dependencies 56 | working-directory: ./out 57 | run: pnpm run build:backend^... 58 | 59 | - name: Lint 60 | working-directory: ./out 61 | run: pnpm run lint:backend 62 | 63 | - name: Type-check 64 | working-directory: ./out 65 | run: pnpm run type-check:backend 66 | 67 | - name: Build 68 | working-directory: ./out 69 | run: pnpm run build:backend 70 | -------------------------------------------------------------------------------- /.github/workflows/ci_frontend.yml: -------------------------------------------------------------------------------- 1 | name: CI FE 2 | on: push 3 | 4 | jobs: 5 | test: 6 | name: Lint, Type check, Test 7 | runs-on: ubuntu-latest 8 | # defaults: 9 | # run: 10 | # working-directory: ./frontend 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup Node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '16' 19 | 20 | - uses: pnpm/action-setup@v2 21 | name: Install pnpm 22 | id: pnpm-install 23 | with: 24 | version: 7 25 | run_install: false 26 | 27 | - name: Get pnpm store directory 28 | id: pnpm-cache 29 | shell: bash 30 | run: | 31 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 32 | 33 | - uses: actions/cache@v3 34 | name: Setup pnpm cache 35 | with: 36 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 37 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pnpm-store- 40 | 41 | - name: Prune 42 | run: npx turbo prune --scope=frontend 43 | 44 | - name: Turbo Cache 45 | id: turbo-cache 46 | uses: actions/cache@v3 47 | with: 48 | path: out/node_modules/.cache/turbo # workaround see: https://github.com/vercel/turborepo/issues/451 49 | key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} 50 | restore-keys: | 51 | turbo-${{ github.job }}-${{ github.ref_name }}- 52 | 53 | - name: Install dependencies 54 | working-directory: ./out 55 | run: pnpm install 56 | 57 | - name: Build dependencies 58 | working-directory: ./out 59 | run: pnpm run build:frontend^... 60 | 61 | - name: Lint 62 | working-directory: ./out 63 | run: pnpm run lint:frontend 64 | 65 | - name: Type-check frontend 66 | working-directory: ./out 67 | run: pnpm run type-check:frontend 68 | 69 | - name: Tests (Jest) 70 | working-directory: ./out 71 | run: pnpm run test:frontend 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | 3 | **/package-lock.json 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .DS_Store 7 | 8 | .env 9 | .envrc 10 | **/tmp 11 | 12 | .turbo 13 | 14 | build/** 15 | **/dist/** 16 | .next/** 17 | 18 | docker/db/data/ 19 | 20 | .pnpm-store/ 21 | 22 | *.tsbuildinfo -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.15.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/generated/ 2 | apps/backend/src/lib/auth0/rules/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "jsxBracketSameLine": false, 6 | "trailingComma": "es5", 7 | "semi": false 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "typescript.tsdk": "node_modules/typescript/lib", // Use workspace version of TypeScript 6 | "editor.formatOnSave": true, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "graphql-config.load.filePath": "./frontend/graphql.config.js", 9 | "cSpell.words": ["fastify", "graphiql"], 10 | "eslint.workingDirectories": ["./frontend", "./backend"], 11 | "files.watcherExclude": { 12 | "**/.git/objects/**": true, 13 | "**/.git/subtree-cache/**": true, 14 | "**/node_modules/*/**": true, 15 | "**/.hg/store/**": true, 16 | "**/graphql/**": true, 17 | "**/ui/dist/index.d.ts": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile.backend: -------------------------------------------------------------------------------- 1 | FROM node:16 as pruner 2 | RUN apt update && \ 3 | apt install git 4 | RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm 5 | 6 | RUN pnpm add -g turbo 7 | WORKDIR "/app" 8 | RUN pwd 9 | 10 | # Prune the workspace for the backend aapp 11 | COPY .git ./.git 12 | COPY . . 13 | RUN pwd 14 | RUN turbo prune --scope=backend --docker 15 | 16 | # Add pruned lockfile and package.json's of the pruned subworkspace 17 | FROM pruner as builder 18 | WORKDIR "/app" 19 | COPY --from=pruner /app/out/json/ . 20 | COPY --from=pruner /app/out/pnpm-lock ./pnpm-lock.yaml 21 | 22 | # Install only the deps needed to build the target 23 | RUN pnpm install --frozen-lockfile --prod 24 | COPY --from=pruner /app/.git ./.git 25 | COPY --from=pruner /app/out/full/ . 26 | RUN turbo run build --filter=backend 27 | RUN pwd 28 | RUN ls 29 | 30 | # Copy source code of pruned subworkspace and build 31 | FROM node:16-alpine 32 | WORKDIR "/app" 33 | EXPOSE 4000 34 | COPY --from=builder /app/apps/backend/dist/ . 35 | COPY --from=pruner /app/out/json/ . 36 | COPY --from=pruner /app/out/pnpm-lock ./pnpm-lock.yaml 37 | RUN pnpm install --production 38 | CMD node ./apps/backend/dist/index.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fullstack-graphql-app 2 | 3 | An opinionated fullstack GraphQL monorepo Boilerplate using modern tech stack. 4 | 5 | ## Concepts 6 | 7 | **🛡Type-safe** 8 | 9 | - graphql-code-generator 10 | - prisma 11 | - ts-pattern (for type-safe error handling) 12 | - and I code in a type-safe way and don't choose a library that doesn't have a good TypeScript support. 13 | 14 | **🛠Customizable** 15 | 16 | - envelop (plugin system for GraphQL) 17 | - urql (highty customizable GraphQL Client) 18 | 19 | **📈Simple but scalable** 20 | 21 | - a bit flavor of [clean architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) in backend. 22 | 23 | **Ever evolving** 24 | 25 | - I constantly maintain this repository. 26 | - TypeScript, React, GraphQL are ever evolving world too. 27 | - In that sense, any tech stack might be replaced in the future. 28 | 29 | ## Tech Stack 30 | 31 | Common 32 | 33 | - TypeScript 34 | - Turborepo 35 | - pnpm 36 | - GraphQL 37 | - graphql-codegen 38 | 39 | Backend 40 | 41 | - Prisma 42 | - fastify 43 | - envelop 44 | - graphql-yoga 2.0 45 | - graphiql 46 | - ts-pattern 47 | - mysql 48 | 49 | Frontend 50 | 51 | - React 52 | - Next.js 53 | - urql 54 | - tailwindcss 55 | - radix ui 56 | - shadcn/ui 57 | - React Hook Form 58 | 59 | Authentication 60 | 61 | - Auth0 62 | 63 | Testing 64 | 65 | - testing-library 66 | - jest 67 | - msw 68 | 69 | # Getting Started 70 | 71 | ## Setting up auth0 72 | 73 | As this project uses auth0 for authentication, you need to setup auth0 to make everything work. If you don't have any auth0 account, then sign up and create account. 74 | 75 | You need to create API, Application (Single Page Application) in the auth0 console. In Application, go to Application URIs section in the middle of the settings page and specify `http://localhost:3000` to Allowed Callback URLs, Allowed Logout URLs, Allowed Web Origins, Allowed Origins (CORS). 76 | 77 | Once you have set up API and Application, collect credentials below which will be used in your application: 78 | 79 | - Client Id: Your Auth0 application's Client ID. can be found on the Application setting page. 80 | - Domain: Your Auth0 application's Domain. can be found on the Application setting page. 81 | - Audience: API Identifier for an access token. can be found on the API setting page. 82 | 83 | ### Add roles to a user using Rules 84 | 85 | You can manage role-based authorization with auth0 Rules, which is a mechanism that allows us to run some code when user register an account on auth0. 86 | 87 | To do so, got to the Auth0 dashboard and create a new Rule (which can be found in Auth Pipeline on the side menu). Then fill the field with [example code](https://github.com/taneba/fullstack-graphql-app/blob/main/apps/backend/src/lib/auth0/rules/setRolesToUser.js) (Note that you should specify your own audience to namespace e.g. `const namespace = 'https://api.fullstack-graphql-app.com'`). And finally name the rule whatever you like. 88 | 89 | After attached the rule, auth0 now add role to the user after a user registered the account on auth0. 90 | 91 | ```ts 92 | const { loginWithRedirect } = useAuth0() 93 | 94 | // Here "ADMIN" will be sent to auth0 and the rule put the role to jwt token. 95 | loginWithRedirect({ 96 | role: 'ADMIN', 97 | }) 98 | ``` 99 | 100 | ### Configure environment variables 101 | 102 | In the root directory, Specify .env and .env.localhost file with the following environment variables: 103 | 104 | .env 105 | 106 | ``` 107 | DATABASE_URL="mysql://fga:fga@db:3306/fga" 108 | AUTH0_CLIENT_ID= 109 | AUTH0_DOMAIN= 110 | AUTH0_AUDIENCE= 111 | GRAPHQL_END_POINT=http://localhost:5001/graphql 112 | ``` 113 | 114 | .env.localhost 115 | 116 | ``` 117 | DATABASE_URL="mysql://fga:fga@localhost:3306/fga" 118 | AUTH0_CLIENT_ID= 119 | AUTH0_DOMAIN= 120 | AUTH0_AUDIENCE= 121 | GRAPHQL_END_POINT=http://localhost:5001/graphql 122 | ``` 123 | 124 | And in the frontend root directory, Specify .env.local file with the following environment variables: 125 | 126 | ``` 127 | NEXT_PUBLIC_AUTH0_CLIENT_ID= 128 | NEXT_PUBLIC_AUTH0_DOMAIN= 129 | NEXT_PUBLIC_AUTH0_AUDIENCE= 130 | NEXT_PUBLIC_GRAPHQL_END_POINT=http://localhost:5001/graphql 131 | ``` 132 | 133 | ## Backend 134 | 135 | ### install deps 136 | 137 | ``` 138 | pnpm install 139 | ``` 140 | 141 | ### start server 142 | 143 | ``` 144 | docker-compose up 145 | ``` 146 | 147 | [Graphql Playground](https://github.com/graphql/graphql-playground) will start on localhost:5001 148 | 149 | ### scripts 150 | 151 | The scripts you might frequently use: 152 | 153 | - **`db:migration:generate`**: Generates migration file (not apply automatically). Run this whenever you change your database schema. 154 | - **`db:migration:run`**: Runs generated migration file. Run this after you generate the migration file. 155 | - **`prisma-gen`**: Generates the Prisma client 156 | - **`prisma-studio`**: Starts Prisma Studio on localhost:5555 where you can inspect your local development database. 157 | 158 | ### connect to your mysql database 159 | 160 | ```sh 161 | docker exec -it backend_db_1 mysql -u root -p 162 | 163 | mysql> use fga 164 | ``` 165 | 166 | ## Frontend 167 | 168 | ### start 169 | 170 | Run the command below. which uses turborepo cli internally and it runs shared `ui` package's watch script as well. 171 | 172 | ```sh 173 | pnpm run dev 174 | ``` 175 | 176 | ## workflow 177 | 178 | ### Update GraphQL API 179 | 180 | **1. Edit Schema file** 181 | 182 | path: apps/backend/src/api/graphql/typeDefs.ts 183 | 184 | **2. run graphql-codegen to generate files based on the latest schema** 185 | 186 | ```sh 187 | pnpm run generate:graphql 188 | ``` 189 | 190 | this will run `turbo run codgen:graphql`, which runs all codegen:graphql in each package. 191 | -------------------------------------------------------------------------------- /apps/backend/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/resolvers.ts":"1","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/todoResolver.ts":"2","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/userResolvers.ts":"3","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/typeDefs.ts":"4","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/error.ts":"5","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/result.ts":"6","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/useCase.ts":"7","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/context.ts":"8","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/getEnveloped.ts":"9","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/index.ts":"10","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/ITodoRepository.ts":"11","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/TodoRepository.ts":"12","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/TodoValidator.ts":"13","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/todoMappers.ts":"14","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/IUserRepository.ts":"15","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/UserMapper.ts":"16","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/UserRepository.ts":"17","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/useCases/todoUseCase.ts":"18","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/useCases/userUseCase.ts":"19","/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/utils/useOwnerCheck.ts":"20"},{"size":578,"mtime":1631436450134,"results":"21","hashOfConfig":"22"},{"size":2242,"mtime":1634262584328,"results":"23","hashOfConfig":"22"},{"size":1245,"mtime":1634262584329,"results":"24","hashOfConfig":"22"},{"size":1184,"mtime":1634262584330,"results":"25","hashOfConfig":"22"},{"size":828,"mtime":1634262584330,"results":"26","hashOfConfig":"22"},{"size":1062,"mtime":1645493927523,"results":"27","hashOfConfig":"22"},{"size":246,"mtime":1631800658662,"results":"28","hashOfConfig":"22"},{"size":1688,"mtime":1634262584332,"results":"29","hashOfConfig":"22"},{"size":3072,"mtime":1634262584333,"results":"30","hashOfConfig":"22"},{"size":3155,"mtime":1645547128495,"results":"31","hashOfConfig":"22"},{"size":401,"mtime":1631698409293,"results":"32","hashOfConfig":"22"},{"size":1107,"mtime":1631698417767,"results":"33","hashOfConfig":"22"},{"size":8,"mtime":1629170307908,"results":"34","hashOfConfig":"22"},{"size":459,"mtime":1634262584353,"results":"35","hashOfConfig":"22"},{"size":415,"mtime":1631698360042,"results":"36","hashOfConfig":"22"},{"size":325,"mtime":1633601665108,"results":"37","hashOfConfig":"22"},{"size":1077,"mtime":1631698378243,"results":"38","hashOfConfig":"22"},{"size":2204,"mtime":1634262584353,"results":"39","hashOfConfig":"22"},{"size":2440,"mtime":1634262584354,"results":"40","hashOfConfig":"22"},{"size":2542,"mtime":1634262584354,"results":"41","hashOfConfig":"22"},{"filePath":"42","messages":"43","suppressedMessages":"44","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1cdmti9",{"filePath":"45","messages":"46","suppressedMessages":"47","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"48","messages":"49","suppressedMessages":"50","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"51","messages":"52","suppressedMessages":"53","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"54","messages":"55","suppressedMessages":"56","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"57","messages":"58","suppressedMessages":"59","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"60","messages":"61","suppressedMessages":"62","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"63","messages":"64","suppressedMessages":"65","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"66","messages":"67","suppressedMessages":"68","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"69","messages":"70","suppressedMessages":"71","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"72","messages":"73","suppressedMessages":"74","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"75","messages":"76","suppressedMessages":"77","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"78","messages":"79","suppressedMessages":"80","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"81","messages":"82","suppressedMessages":"83","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"84","messages":"85","suppressedMessages":"86","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"87","messages":"88","suppressedMessages":"89","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"90","messages":"91","suppressedMessages":"92","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"93","messages":"94","suppressedMessages":"95","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"96","messages":"97","suppressedMessages":"98","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"99","messages":"100","suppressedMessages":"101","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/resolvers.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/todoResolver.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/resolvers/userResolvers.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/api/graphql/typeDefs.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/error.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/result.ts",["102","103","104"],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/common/useCase.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/context.ts",["105"],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/getEnveloped.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/index.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/ITodoRepository.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/TodoRepository.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/TodoValidator.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/todo/todoMappers.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/IUserRepository.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/UserMapper.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/modules/user/UserRepository.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/useCases/todoUseCase.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/useCases/userUseCase.ts",[],[],"/Users/nakamurayoshihiro/projects/fullstack-graphql-app/apps/backend/src/utils/useOwnerCheck.ts",["106","107"],[],{"ruleId":"108","severity":1,"message":"109","line":22,"column":46,"nodeType":"110","messageId":"111","endLine":22,"endColumn":49,"suggestions":"112"},{"ruleId":"108","severity":1,"message":"109","line":23,"column":30,"nodeType":"110","messageId":"111","endLine":23,"endColumn":33,"suggestions":"113"},{"ruleId":"108","severity":1,"message":"109","line":23,"column":45,"nodeType":"110","messageId":"111","endLine":23,"endColumn":48,"suggestions":"114"},{"ruleId":"108","severity":1,"message":"109","line":57,"column":69,"nodeType":"110","messageId":"111","endLine":57,"endColumn":72,"suggestions":"115"},{"ruleId":"116","severity":1,"message":"117","line":25,"column":25,"nodeType":"118","messageId":"119","endLine":25,"endColumn":32},{"ruleId":"108","severity":1,"message":"109","line":30,"column":49,"nodeType":"110","messageId":"111","endLine":30,"endColumn":52,"suggestions":"120"},"@typescript-eslint/no-explicit-any","Unexpected any. Specify a different type.","TSAnyKeyword","unexpectedAny",["121","122"],["123","124"],["125","126"],["127","128"],"@typescript-eslint/no-non-null-assertion","Forbidden non-null assertion.","TSNonNullExpression","noNonNull",["129","130"],{"messageId":"131","fix":"132","desc":"133"},{"messageId":"134","fix":"135","desc":"136"},{"messageId":"131","fix":"137","desc":"133"},{"messageId":"134","fix":"138","desc":"136"},{"messageId":"131","fix":"139","desc":"133"},{"messageId":"134","fix":"140","desc":"136"},{"messageId":"131","fix":"141","desc":"133"},{"messageId":"134","fix":"142","desc":"136"},{"messageId":"131","fix":"143","desc":"133"},{"messageId":"134","fix":"144","desc":"136"},"suggestUnknown",{"range":"145","text":"146"},"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct.","suggestNever",{"range":"145","text":"147"},"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of.",{"range":"148","text":"146"},{"range":"148","text":"147"},{"range":"149","text":"146"},{"range":"149","text":"147"},{"range":"150","text":"146"},{"range":"150","text":"147"},{"range":"151","text":"146"},{"range":"151","text":"147"},[531,534],"unknown","never",[594,597],[609,612],[1266,1269],[875,878]] -------------------------------------------------------------------------------- /apps/backend/.eslintignore: -------------------------------------------------------------------------------- 1 | **/lib/ 2 | **/generated/ -------------------------------------------------------------------------------- /apps/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('config/eslint-server'), 3 | } 4 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | .env.localhost 5 | docker/db/data/ 6 | dist/ -------------------------------------------------------------------------------- /apps/backend/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: './src/api/graphql/typeDefs.ts' 3 | documents: null 4 | generates: 5 | src/api/graphql/generated/graphql.ts: 6 | plugins: 7 | - 'typescript' 8 | - 'typescript-resolvers' 9 | config: 10 | enumsAsConst: true 11 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "tsx watch src/index.ts", 7 | "dev:old": "nodemon 'src/index.ts' --exec 'ts-node -r tsconfig-paths/register'", 8 | "db:migration:generate": "dotenv -e .env.localhost prisma migrate dev --create-only", 9 | "db:migration:run": "dotenv -e .env.localhost prisma migrate dev", 10 | "db:reset": "dotenv -e .env.localhost prisma migrate reset", 11 | "db:pull": "dotenv -e .env.localhost prisma db pull", 12 | "codegen:graphql": "graphql-codegen --config codegen.yml", 13 | "codegen:prisma": "dotenv -e .env.localhost prisma generate", 14 | "prisma-studio": "dotenv -e .env.localhost prisma studio", 15 | "type-check": "tsc --noEmit", 16 | "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", 17 | "lint": "eslint --fix --cache --ext .ts,.tsx src", 18 | "postinstall": "prisma generate" 19 | }, 20 | "dependencies": { 21 | "@envelop/auth0": "4.0.6", 22 | "@envelop/core": "3.0.6", 23 | "@envelop/depth-limit": "2.0.6", 24 | "@envelop/generic-auth": "5.0.6", 25 | "@envelop/rate-limiter": "4.0.6", 26 | "@fastify/compress": "6.2.1", 27 | "@fastify/cors": "8.2.1", 28 | "@fastify/helmet": "^9.1.0", 29 | "@graphql-tools/schema": "9.0.18", 30 | "@graphql-tools/utils": "^9.1.4", 31 | "@prisma/client": "4.8.1", 32 | "@types/got": "9.6.12", 33 | "@types/jsonwebtoken": "8.5.9", 34 | "@types/node": "16.18.24", 35 | "fastify": "4.13.0", 36 | "got": "11.8.6", 37 | "graphql": "16.6.0", 38 | "graphql-tools": "8.3.20", 39 | "graphql-yoga": "3.9.1", 40 | "jsonwebtoken": "8.5.1", 41 | "jwks-rsa": "2.1.5", 42 | "prisma": "4.8.1", 43 | "ts-pattern": "^4.0.5", 44 | "tsconfig": "workspace:*", 45 | "typescript": "4.9.5", 46 | "validation-schema": "workspace:*", 47 | "zod": "3.20.6" 48 | }, 49 | "devDependencies": { 50 | "@graphql-codegen/cli": "2.16.5", 51 | "@graphql-codegen/typescript-resolvers": "2.7.13", 52 | "@graphql-codegen/visitor-plugin-common": "2.13.8", 53 | "@graphql-yoga/render-graphiql": "3.6.1", 54 | "chalk": "4.1.2", 55 | "config": "workspace:*", 56 | "dotenv-cli": "6.0.0", 57 | "nodemon": "2.0.22", 58 | "tsc-alias": "1.8.5", 59 | "tsconfig-paths": "4.1.2", 60 | "tsx": "3.12.6" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/20210815142751_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `todo` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `updated_at` DATETIME(3) NOT NULL, 6 | `title` VARCHAR(255) NOT NULL, 7 | `content` VARCHAR(191), 8 | `author_id` INTEGER NOT NULL, 9 | 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- CreateTable 14 | CREATE TABLE `user` ( 15 | `id` INTEGER NOT NULL AUTO_INCREMENT, 16 | `email` VARCHAR(191) NOT NULL, 17 | `name` VARCHAR(191), 18 | 19 | UNIQUE INDEX `user.email_unique`(`email`), 20 | PRIMARY KEY (`id`) 21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 22 | 23 | -- AddForeignKey 24 | ALTER TABLE `todo` ADD FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 25 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/20210825080026_add_uid_to_user/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[uid]` on the table `user` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `user` ADD COLUMN `uid` VARCHAR(191); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX `user.uid_unique` ON `user`(`uid`); 12 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/20210826085232_user_role/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` ADD COLUMN `role` ENUM('USER', 'ADMIN') NOT NULL DEFAULT 'USER'; 3 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/20210908060800_add_completed_to_todo/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `todo` ADD COLUMN `completed` BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/20210911131122_prisma_v3/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE `todo` DROP FOREIGN KEY `todo_ibfk_1`; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE `todo` ADD CONSTRAINT `todo_author_id_fkey` FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 6 | 7 | -- RenameIndex 8 | ALTER TABLE `user` RENAME INDEX `user.email_unique` TO `user_email_key`; 9 | 10 | -- RenameIndex 11 | ALTER TABLE `user` RENAME INDEX `user.uid_unique` TO `user_uid_key`; 12 | -------------------------------------------------------------------------------- /apps/backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /apps/backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "mysql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Todo { 14 | id Int @id @default(autoincrement()) 15 | createdAt DateTime @default(now()) @map("created_at") 16 | updatedAt DateTime @updatedAt @map("updated_at") 17 | title String @db.VarChar(255) 18 | content String? 19 | author User @relation(fields: [authorId], references: [id]) 20 | authorId Int @map("author_id") 21 | completed Boolean @default(false) 22 | 23 | @@map("todo") 24 | } 25 | 26 | model User { 27 | id Int @id @default(autoincrement()) 28 | uid String? @unique 29 | email String @unique 30 | name String? 31 | role Role @default(USER) 32 | todos Todo[] 33 | 34 | @@map("user") 35 | } 36 | 37 | enum Role { 38 | USER 39 | ADMIN 40 | } 41 | -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from 'graphql'; 2 | export type Maybe = T | null; 3 | export type InputMaybe = Maybe; 4 | export type Exact = { [K in keyof T]: T[K] }; 5 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 6 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 7 | export type RequireFields = Omit & { [P in K]-?: NonNullable }; 8 | /** All built-in and custom scalars, mapped to their actual values */ 9 | export type Scalars = { 10 | ID: string; 11 | String: string; 12 | Boolean: boolean; 13 | Int: number; 14 | Float: number; 15 | }; 16 | 17 | export type Mutation = { 18 | __typename?: 'Mutation'; 19 | completeTodo?: Maybe; 20 | saveTodo?: Maybe; 21 | saveUser?: Maybe; 22 | }; 23 | 24 | 25 | export type MutationCompleteTodoArgs = { 26 | id: Scalars['ID']; 27 | }; 28 | 29 | 30 | export type MutationSaveTodoArgs = { 31 | todo: TodoInput; 32 | }; 33 | 34 | 35 | export type MutationSaveUserArgs = { 36 | user: UserInput; 37 | }; 38 | 39 | export type ProfileResult = User | UserNotFound; 40 | 41 | export type Query = { 42 | __typename?: 'Query'; 43 | allTodos: Array; 44 | allUsers: Array; 45 | currentUser?: Maybe; 46 | getProfile?: Maybe; 47 | time: Scalars['Int']; 48 | todo?: Maybe; 49 | todosByCurrentUser: Array; 50 | }; 51 | 52 | 53 | export type QueryTodoArgs = { 54 | id: Scalars['ID']; 55 | }; 56 | 57 | export const Role = { 58 | Admin: 'ADMIN', 59 | User: 'USER' 60 | } as const; 61 | 62 | export type Role = typeof Role[keyof typeof Role]; 63 | export type Todo = { 64 | __typename?: 'Todo'; 65 | author?: Maybe; 66 | authorId: Scalars['String']; 67 | completed: Scalars['Boolean']; 68 | content?: Maybe; 69 | createdAt?: Maybe; 70 | id: Scalars['ID']; 71 | title: Scalars['String']; 72 | updatedAt?: Maybe; 73 | }; 74 | 75 | export type TodoInput = { 76 | content?: InputMaybe; 77 | title: Scalars['String']; 78 | }; 79 | 80 | export type User = { 81 | __typename?: 'User'; 82 | email: Scalars['String']; 83 | id: Scalars['ID']; 84 | name?: Maybe; 85 | role: Role; 86 | }; 87 | 88 | export type UserInput = { 89 | name: Scalars['String']; 90 | }; 91 | 92 | export type UserNotFound = { 93 | __typename?: 'UserNotFound'; 94 | message: Scalars['String']; 95 | role: Role; 96 | }; 97 | 98 | 99 | 100 | export type ResolverTypeWrapper = Promise | T; 101 | 102 | 103 | export type ResolverWithResolve = { 104 | resolve: ResolverFn; 105 | }; 106 | export type Resolver = ResolverFn | ResolverWithResolve; 107 | 108 | export type ResolverFn = ( 109 | parent: TParent, 110 | args: TArgs, 111 | context: TContext, 112 | info: GraphQLResolveInfo 113 | ) => Promise | TResult; 114 | 115 | export type SubscriptionSubscribeFn = ( 116 | parent: TParent, 117 | args: TArgs, 118 | context: TContext, 119 | info: GraphQLResolveInfo 120 | ) => AsyncIterable | Promise>; 121 | 122 | export type SubscriptionResolveFn = ( 123 | parent: TParent, 124 | args: TArgs, 125 | context: TContext, 126 | info: GraphQLResolveInfo 127 | ) => TResult | Promise; 128 | 129 | export interface SubscriptionSubscriberObject { 130 | subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; 131 | resolve?: SubscriptionResolveFn; 132 | } 133 | 134 | export interface SubscriptionResolverObject { 135 | subscribe: SubscriptionSubscribeFn; 136 | resolve: SubscriptionResolveFn; 137 | } 138 | 139 | export type SubscriptionObject = 140 | | SubscriptionSubscriberObject 141 | | SubscriptionResolverObject; 142 | 143 | export type SubscriptionResolver = 144 | | ((...args: any[]) => SubscriptionObject) 145 | | SubscriptionObject; 146 | 147 | export type TypeResolveFn = ( 148 | parent: TParent, 149 | context: TContext, 150 | info: GraphQLResolveInfo 151 | ) => Maybe | Promise>; 152 | 153 | export type IsTypeOfResolverFn = (obj: T, context: TContext, info: GraphQLResolveInfo) => boolean | Promise; 154 | 155 | export type NextResolverFn = () => Promise; 156 | 157 | export type DirectiveResolverFn = ( 158 | next: NextResolverFn, 159 | parent: TParent, 160 | args: TArgs, 161 | context: TContext, 162 | info: GraphQLResolveInfo 163 | ) => TResult | Promise; 164 | 165 | /** Mapping between all available schema types and the resolvers types */ 166 | export type ResolversTypes = { 167 | Boolean: ResolverTypeWrapper; 168 | ID: ResolverTypeWrapper; 169 | Int: ResolverTypeWrapper; 170 | Mutation: ResolverTypeWrapper<{}>; 171 | ProfileResult: ResolversTypes['User'] | ResolversTypes['UserNotFound']; 172 | Query: ResolverTypeWrapper<{}>; 173 | Role: Role; 174 | String: ResolverTypeWrapper; 175 | Todo: ResolverTypeWrapper; 176 | TodoInput: TodoInput; 177 | User: ResolverTypeWrapper; 178 | UserInput: UserInput; 179 | UserNotFound: ResolverTypeWrapper; 180 | }; 181 | 182 | /** Mapping between all available schema types and the resolvers parents */ 183 | export type ResolversParentTypes = { 184 | Boolean: Scalars['Boolean']; 185 | ID: Scalars['ID']; 186 | Int: Scalars['Int']; 187 | Mutation: {}; 188 | ProfileResult: ResolversParentTypes['User'] | ResolversParentTypes['UserNotFound']; 189 | Query: {}; 190 | String: Scalars['String']; 191 | Todo: Todo; 192 | TodoInput: TodoInput; 193 | User: User; 194 | UserInput: UserInput; 195 | UserNotFound: UserNotFound; 196 | }; 197 | 198 | export type AuthDirectiveArgs = { 199 | role?: Role; 200 | }; 201 | 202 | export type AuthDirectiveResolver = DirectiveResolverFn; 203 | 204 | export type IsOwnerDirectiveArgs = { 205 | ownerField?: Maybe; 206 | }; 207 | 208 | export type IsOwnerDirectiveResolver = DirectiveResolverFn; 209 | 210 | export type MutationResolvers = { 211 | completeTodo?: Resolver, ParentType, ContextType, RequireFields>; 212 | saveTodo?: Resolver, ParentType, ContextType, RequireFields>; 213 | saveUser?: Resolver, ParentType, ContextType, RequireFields>; 214 | }; 215 | 216 | export type ProfileResultResolvers = { 217 | __resolveType: TypeResolveFn<'User' | 'UserNotFound', ParentType, ContextType>; 218 | }; 219 | 220 | export type QueryResolvers = { 221 | allTodos?: Resolver, ParentType, ContextType>; 222 | allUsers?: Resolver, ParentType, ContextType>; 223 | currentUser?: Resolver, ParentType, ContextType>; 224 | getProfile?: Resolver, ParentType, ContextType>; 225 | time?: Resolver; 226 | todo?: Resolver, ParentType, ContextType, RequireFields>; 227 | todosByCurrentUser?: Resolver, ParentType, ContextType>; 228 | }; 229 | 230 | export type TodoResolvers = { 231 | author?: Resolver, ParentType, ContextType>; 232 | authorId?: Resolver; 233 | completed?: Resolver; 234 | content?: Resolver, ParentType, ContextType>; 235 | createdAt?: Resolver, ParentType, ContextType>; 236 | id?: Resolver; 237 | title?: Resolver; 238 | updatedAt?: Resolver, ParentType, ContextType>; 239 | __isTypeOf?: IsTypeOfResolverFn; 240 | }; 241 | 242 | export type UserResolvers = { 243 | email?: Resolver; 244 | id?: Resolver; 245 | name?: Resolver, ParentType, ContextType>; 246 | role?: Resolver; 247 | __isTypeOf?: IsTypeOfResolverFn; 248 | }; 249 | 250 | export type UserNotFoundResolvers = { 251 | message?: Resolver; 252 | role?: Resolver; 253 | __isTypeOf?: IsTypeOfResolverFn; 254 | }; 255 | 256 | export type Resolvers = { 257 | Mutation?: MutationResolvers; 258 | ProfileResult?: ProfileResultResolvers; 259 | Query?: QueryResolvers; 260 | Todo?: TodoResolvers; 261 | User?: UserResolvers; 262 | UserNotFound?: UserNotFoundResolvers; 263 | }; 264 | 265 | export type DirectiveResolvers = { 266 | auth?: AuthDirectiveResolver; 267 | isOwner?: IsOwnerDirectiveResolver; 268 | }; 269 | -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/generated/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export * as gql from './graphql' -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/resolvers/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { GraphqlServerContext } from '../../../context' 2 | import * as gql from '../generated/graphql' 3 | import { 4 | todoMutationResolvers, 5 | todoQueryResolvers, 6 | todoResolvers, 7 | } from './todoResolver' 8 | import { 9 | ProfileResult, 10 | userMutationResolvers, 11 | userQueryResolvers, 12 | } from './userResolvers' 13 | 14 | const resolvers: gql.Resolvers = { 15 | Query: { 16 | time: () => Math.floor(Date.now() / 1000), 17 | ...todoQueryResolvers, 18 | ...userQueryResolvers, 19 | }, 20 | Mutation: { 21 | ...todoMutationResolvers, 22 | ...userMutationResolvers, 23 | }, 24 | Todo: todoResolvers, 25 | ProfileResult: ProfileResult, 26 | } 27 | 28 | export default resolvers 29 | -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/resolvers/todoResolver.ts: -------------------------------------------------------------------------------- 1 | import { match } from 'ts-pattern' 2 | import { TodoInputSchema } from 'validation-schema' 3 | 4 | import { handleAppError } from '~/common/error' 5 | import { whenIsErr, whenIsOk } from '~/common/result' 6 | import { validateParams } from '~/utils/validationHelper' 7 | 8 | import { GraphqlServerContext } from '../../../context' 9 | import { TodoMapper } from '../../../modules/todo/todoMappers' 10 | import { UserMapper } from '../../../modules/user/UserMapper' 11 | import * as gql from '../generated/graphql' 12 | 13 | export const todoQueryResolvers: gql.QueryResolvers = { 14 | allTodos: async (_, params, ctx) => { 15 | const result = await ctx.useCase.todo.findAll() 16 | return match(result) 17 | .with(whenIsErr, handleAppError) 18 | .with(whenIsOk, ({ value }) => TodoMapper.toGqlCollection(value)) 19 | .exhaustive() 20 | }, 21 | todosByCurrentUser: async (_, params, ctx) => { 22 | const result = await ctx.useCase.todo.findByCurrentUser() 23 | return match(result) 24 | .with(whenIsErr, handleAppError) 25 | .with(whenIsOk, ({ value }) => TodoMapper.toGqlCollection(value)) 26 | .exhaustive() 27 | }, 28 | todo: async (_, params, ctx) => { 29 | const result = await ctx.useCase.todo.findById(Number(params.id)) 30 | return match(result) 31 | .with(whenIsErr, handleAppError) 32 | .with(whenIsOk, ({ value }) => TodoMapper.toGql(value)) 33 | .exhaustive() 34 | }, 35 | } 36 | 37 | export const todoResolvers: gql.TodoResolvers = { 38 | author: async (parent, params, ctx) => { 39 | const result = await ctx.useCase.user.findByTodoId(Number(parent.id)) 40 | return match(result) 41 | .with(whenIsErr, handleAppError) 42 | .with(whenIsOk, ({ value }) => UserMapper.toGql(value)) 43 | .exhaustive() 44 | }, 45 | } 46 | 47 | export const todoMutationResolvers: gql.MutationResolvers = 48 | { 49 | saveTodo: async (_, params, ctx) => { 50 | validateParams(params, TodoInputSchema) 51 | 52 | const result = await ctx.useCase.todo.save(params.todo) 53 | return match(result) 54 | .with(whenIsErr, handleAppError) 55 | .with(whenIsOk, ({ value }) => { 56 | return TodoMapper.toGql(value) 57 | }) 58 | .exhaustive() 59 | }, 60 | completeTodo: async (_, params, ctx) => { 61 | const result = await ctx.useCase.todo.markAsCompleted(Number(params.id)) 62 | return match(result) 63 | .with(whenIsErr, handleAppError) 64 | .with(whenIsOk, ({ value }) => TodoMapper.toGql(value)) 65 | .exhaustive() 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/resolvers/userResolvers.ts: -------------------------------------------------------------------------------- 1 | import { match } from 'ts-pattern' 2 | import { UserInputSchema } from 'validation-schema' 3 | 4 | import { handleAppError } from '~/common/error' 5 | import { whenIsErr, whenIsOk } from '~/common/result' 6 | import { validateParams } from '~/utils/validationHelper' 7 | 8 | import { GraphqlServerContext } from '../../../context' 9 | import { UserMapper } from '../../../modules/user/UserMapper' 10 | import * as gql from '../generated/graphql' 11 | 12 | export const userQueryResolvers: gql.QueryResolvers = { 13 | allUsers: async (_, params, ctx) => { 14 | const result = await ctx.useCase.user.findAll() 15 | return match(result) 16 | .with(whenIsErr, handleAppError) 17 | .with(whenIsOk, ({ value }) => UserMapper.toGqlCollection(value)) 18 | .exhaustive() 19 | }, 20 | currentUser: async (_, params, ctx) => { 21 | const result = await ctx.useCase.user.findCurrentUser() 22 | return match(result) 23 | .with(whenIsErr, handleAppError) 24 | .with(whenIsOk, ({ value }) => UserMapper.toGql(value)) 25 | .exhaustive() 26 | }, 27 | getProfile: async (_, params, ctx) => { 28 | const result = await ctx.useCase.user.findCurrentUser() 29 | const NotFoundResponse: gql.UserNotFound = { 30 | __typename: 'UserNotFound', 31 | message: 'user not found', 32 | role: 'USER', 33 | } 34 | 35 | return match(result) 36 | .with(whenIsErr, (result) => 37 | match(result) 38 | .with({ error: 'RESOURCE_NOT_FOUND' }, () => { 39 | return NotFoundResponse 40 | }) 41 | .otherwise(handleAppError) 42 | ) 43 | .with(whenIsOk, ({ value }) => UserMapper.toGql(value)) 44 | .exhaustive() 45 | }, 46 | } 47 | 48 | export const ProfileResult: gql.ProfileResultResolvers = { 49 | __resolveType(obj) { 50 | return match(obj) 51 | .with({ __typename: 'User' }, () => 'User' as const) 52 | .with({ __typename: 'UserNotFound' }, () => 'UserNotFound' as const) 53 | .otherwise(() => null) 54 | }, 55 | } 56 | 57 | export const userMutationResolvers: gql.MutationResolvers = 58 | { 59 | saveUser: async (_, params, ctx) => { 60 | validateParams(params, UserInputSchema) 61 | const result = await ctx.useCase.user.save(params.user) 62 | return match(result) 63 | .with(whenIsErr, handleAppError) 64 | .with(whenIsOk, ({ value }) => UserMapper.toGql(value)) 65 | .exhaustive() 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /apps/backend/src/api/graphql/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql' 2 | 3 | export const schema = buildSchema(/* GraphQL */ ` 4 | enum Role { 5 | ADMIN 6 | USER 7 | } 8 | 9 | directive @auth(role: Role! = USER) on FIELD_DEFINITION 10 | directive @isOwner(ownerField: String) on FIELD_DEFINITION | OBJECT 11 | # TODO: directive @isOwnerOrHasRole(type: String, roles: [String]) on QUERY | MUTATION | FIELD_DEFINITION 12 | 13 | type Query { 14 | # health check 15 | time: Int! 16 | allTodos: [Todo!]! @auth(role: ADMIN) 17 | todosByCurrentUser: [Todo!]! @auth 18 | todo(id: ID!): Todo @auth 19 | allUsers: [User!]! @auth 20 | currentUser: User @auth 21 | getProfile: ProfileResult @auth 22 | } 23 | 24 | union ProfileResult = User | UserNotFound 25 | 26 | type UserNotFound { 27 | message: String! 28 | role: Role! 29 | } 30 | 31 | type Mutation { 32 | saveTodo(todo: TodoInput!): Todo @auth 33 | saveUser(user: UserInput!): User 34 | completeTodo(id: ID!): Todo @auth 35 | } 36 | 37 | type Todo @isOwner(ownerField: "authorId") { 38 | id: ID! 39 | createdAt: String 40 | updatedAt: String 41 | title: String! 42 | content: String 43 | author: User 44 | authorId: String! 45 | completed: Boolean! 46 | } 47 | 48 | input TodoInput { 49 | title: String! 50 | content: String 51 | } 52 | 53 | type User { 54 | id: ID! 55 | email: String! @isOwner(ownerField: "id") # private 56 | name: String 57 | role: Role! 58 | } 59 | 60 | input UserInput { 61 | name: String! 62 | } 63 | `) 64 | -------------------------------------------------------------------------------- /apps/backend/src/common/error.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import { match } from 'ts-pattern' 3 | 4 | import { err } from './result' 5 | 6 | export const AppError = { 7 | database: 'DATABASE', 8 | validation: 'VALIDATION', 9 | resourceNotFound: 'RESOURCE_NOT_FOUND', 10 | auth0: 'AUTH0', 11 | userNotFound: 'USER_NOT_FOUND', 12 | } as const 13 | 14 | export type AppErrorType = typeof AppError[keyof typeof AppError] 15 | 16 | export const handleAppError = (result: ReturnType) => 17 | match(result) 18 | .with({ error: 'DATABASE' }, () => { 19 | throw new GraphQLError('database error') 20 | }) 21 | .with({ error: 'RESOURCE_NOT_FOUND' }, () => { 22 | throw new GraphQLError('resource not found error') 23 | }) 24 | 25 | .with({ error: 'AUTH0' }, () => { 26 | throw new GraphQLError('auth0 error') 27 | }) 28 | .with({ error: 'VALIDATION' }, () => { 29 | throw new GraphQLError('validation error') 30 | }) 31 | .with({ error: 'USER_NOT_FOUND' }, () => { 32 | throw new GraphQLError('user not found error') 33 | }) 34 | .exhaustive() 35 | 36 | export class GqlError extends GraphQLError { 37 | constructor(message: string, extensions?: Record) { 38 | super( 39 | message, 40 | undefined, 41 | undefined, 42 | undefined, 43 | undefined, 44 | undefined, 45 | extensions 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/backend/src/common/result.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import { match, P } from 'ts-pattern' 3 | 4 | import { AppErrorType } from './error' 5 | 6 | type Ok = Readonly<{ type: 'ok'; value: T }> 7 | 8 | type Err = Readonly<{ type: 'error'; error: E }> 9 | 10 | export type Result = Ok | Err 11 | 12 | export const ok = (value: T): Ok => ({ 13 | type: 'ok', 14 | value: value, 15 | }) 16 | 17 | export const err = (error: E): Err => ({ 18 | type: 'error', 19 | error: error, 20 | }) 21 | 22 | export const matchResult = >(result: T) => 23 | match(result).with, unknown, any>( 24 | { type: 'error', error: 'DATABASE' }, 25 | () => { 26 | throw new GraphQLError('database error') 27 | } 28 | ) 29 | 30 | export const whenIsErr = { type: 'error' } as const 31 | export const whenIsOk = { type: 'ok' } as const 32 | -------------------------------------------------------------------------------- /apps/backend/src/common/useCase.ts: -------------------------------------------------------------------------------- 1 | import { GraphqlServerContext } from '../context' 2 | 3 | export type UseCaseContext = Pick< 4 | GraphqlServerContext, 5 | 'prisma' | 'auth0' | 'authToken' | 'currentUser' 6 | > 7 | 8 | export class UseCase { 9 | constructor(protected readonly ctx: UseCaseContext) {} 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/src/context.ts: -------------------------------------------------------------------------------- 1 | import { DefaultContext } from '@envelop/core' 2 | import { PrismaClient, User } from '@prisma/client' 3 | import chalk from 'chalk' 4 | 5 | import { UseCaseContext } from './common/useCase' 6 | import { TodoRepository } from './modules/todo/TodoRepository' 7 | import { UserRepository } from './modules/user/UserRepository' 8 | import { TodoUseCase } from './useCases/todoUseCase' 9 | import { UserUseCase } from './useCases/userUseCase' 10 | 11 | export const prisma = new PrismaClient({ 12 | log: [ 13 | { 14 | emit: 'event', 15 | level: 'query', 16 | }, 17 | { 18 | emit: 'stdout', 19 | level: 'error', 20 | }, 21 | { 22 | emit: 'stdout', 23 | level: 'info', 24 | }, 25 | { 26 | emit: 'stdout', 27 | level: 'warn', 28 | }, 29 | ], 30 | }) 31 | 32 | prisma.$on('query', (e) => { 33 | console.log(chalk.magenta('Query: ') + chalk.bold(e.query)) 34 | console.log('Duration: ' + e.duration + 'ms') 35 | }) 36 | 37 | export interface GraphqlServerContext extends DefaultContext { 38 | prisma: PrismaClient 39 | useCase: { 40 | todo: TodoUseCase 41 | user: UserUseCase 42 | } 43 | auth0?: { 44 | sub: string 45 | iss: string 46 | aud: string[] 47 | iat: number 48 | exp: number 49 | azp: string 50 | scope: string 51 | } 52 | authToken?: string 53 | currentUser: User | null 54 | } 55 | 56 | export function createContext( 57 | ctx: Pick & { req: any } 58 | ): GraphqlServerContext { 59 | const { auth0, currentUser, req } = ctx 60 | const authToken = req.headers.authorization 61 | const useCaseContext: UseCaseContext = { 62 | prisma, 63 | auth0, 64 | currentUser, 65 | authToken, 66 | } 67 | console.log('authToken???', authToken) 68 | 69 | return { 70 | prisma, 71 | useCase: { 72 | todo: new TodoUseCase(useCaseContext, new TodoRepository(prisma)), 73 | user: new UserUseCase(useCaseContext, new UserRepository(prisma)), 74 | }, 75 | authToken, 76 | userRole: 'User', 77 | currentUser, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /apps/backend/src/envelopPlugins.ts: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@envelop/auth0' 2 | import { 3 | useErrorHandler, 4 | useExtendContext, 5 | useMaskedErrors, 6 | useSchema, 7 | } from '@envelop/core' 8 | import { useDepthLimit } from '@envelop/depth-limit' 9 | import { 10 | ResolveUserFn, 11 | useGenericAuth, 12 | ValidateUserFn, 13 | } from '@envelop/generic-auth' 14 | import { makeExecutableSchema } from '@graphql-tools/schema' 15 | import { User } from '@prisma/client' 16 | import { EnumValueNode, GraphQLError } from 'graphql' 17 | import { Plugin } from 'graphql-yoga' 18 | import { TokenExpiredError } from 'jsonwebtoken' 19 | 20 | import { Role } from './api/graphql/generated/graphql' 21 | import resolvers from './api/graphql/resolvers/resolvers' 22 | import { schema } from './api/graphql/typeDefs' 23 | import { GqlError } from './common/error' 24 | import { createContext, GraphqlServerContext, prisma } from './context' 25 | import { UserRepository } from './modules/user/UserRepository' 26 | import { useOwnerCheck } from './utils/useOwnerCheck' 27 | 28 | const executableSchema = makeExecutableSchema({ 29 | typeDefs: schema, 30 | resolvers: resolvers, 31 | }) 32 | 33 | const resolveUserFn: ResolveUserFn = async ( 34 | context 35 | ) => { 36 | try { 37 | const uid = context.auth0?.sub 38 | if (!uid) { 39 | throw new Error('not authenticated') 40 | } 41 | const userRepository = new UserRepository(prisma) 42 | const user = await userRepository.findByUid(uid) 43 | return user 44 | } catch (e) { 45 | console.error('Failed to get user', e) 46 | 47 | return null 48 | } 49 | } 50 | 51 | const validateUserFn: ValidateUserFn = ({ 52 | user, 53 | fieldAuthDirectiveNode, 54 | }) => { 55 | if (!user) { 56 | throw new GqlError('request not authenticated', { 57 | code: 'NOT_AUTHENTICATED', 58 | }) 59 | } else { 60 | if (!fieldAuthDirectiveNode?.arguments) { 61 | return 62 | } 63 | 64 | const valueNode = fieldAuthDirectiveNode.arguments.find( 65 | (arg) => arg.name.value === 'role' 66 | )?.value as EnumValueNode | undefined 67 | 68 | if (valueNode) { 69 | const role = valueNode.value as Role 70 | if (role !== user.role) { 71 | throw new GqlError('request not authorized', { 72 | code: 'NOT_AUTHORIZED', 73 | }) 74 | } 75 | } 76 | } 77 | } 78 | 79 | export const envelopPlugins = [ 80 | useSchema(executableSchema), 81 | useAuth0({ 82 | domain: process.env.AUTH0_DOMAIN || '', 83 | audience: process.env.AUTH0_AUDIENCE || '', 84 | headerName: 'authorization', 85 | preventUnauthenticatedAccess: false, 86 | extendContextField: 'auth0', 87 | tokenType: 'Bearer', 88 | onError: (e) => { 89 | if (e instanceof TokenExpiredError) { 90 | throw new GqlError('jwt expired', { 91 | code: 'TOKEN_EXPIRED', 92 | }) 93 | } else { 94 | console.log('error on useAuth0', e) 95 | throw e 96 | } 97 | }, 98 | }), 99 | useGenericAuth({ 100 | resolveUserFn: resolveUserFn, 101 | validateUser: validateUserFn, 102 | mode: 'protect-granular', 103 | }), 104 | useExtendContext(createContext), // should be after auth0 so that createContext callback can access to auth0 context 105 | useOwnerCheck(), 106 | useMaskedErrors(), 107 | useErrorHandler((error: unknown) => { 108 | console.log('ERROR: ' + JSON.stringify(error)) 109 | }), 110 | useDepthLimit({ 111 | maxDepth: 10, 112 | }), 113 | ] 114 | -------------------------------------------------------------------------------- /apps/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import fastifyCompress from '@fastify/compress' 2 | import fastifyCors from '@fastify/cors' 3 | import fastifyHelmet from '@fastify/helmet' 4 | import { renderGraphiQL } from '@graphql-yoga/render-graphiql' 5 | import fastify, { FastifyReply, FastifyRequest } from 'fastify' 6 | // import { createServer } from '@graphql-yoga/node' 7 | import { createYoga } from 'graphql-yoga' 8 | 9 | import { schema } from './api/graphql/typeDefs' 10 | import { envelopPlugins } from './envelopPlugins' 11 | 12 | const clientUrl = 'http://localhost:3000' // should be replaced with env variable 13 | 14 | const app = fastify({ logger: true }) 15 | 16 | app.register(fastifyCors, { 17 | origin: clientUrl, 18 | }) 19 | app.register(fastifyHelmet, { 20 | contentSecurityPolicy: false, 21 | }) 22 | app.register(fastifyCompress) 23 | 24 | const yoga = createYoga<{ 25 | req: FastifyRequest 26 | reply: FastifyReply 27 | }>({ 28 | plugins: envelopPlugins, 29 | // Integrate Fastify logger 30 | logging: { 31 | debug: (...args) => args.forEach((arg) => app.log.debug(arg)), 32 | info: (...args) => args.forEach((arg) => app.log.info(arg)), 33 | warn: (...args) => args.forEach((arg) => app.log.warn(arg)), 34 | error: (...args) => args.forEach((arg) => app.log.error(arg)), 35 | }, 36 | graphiql: 37 | process.env.NODE_ENV !== 'production' 38 | ? { 39 | defaultQuery: /* GraphQL */ ` 40 | query { 41 | time # health check 42 | } 43 | `, 44 | } 45 | : false, 46 | }) 47 | 48 | app.route({ 49 | url: '/graphql', 50 | method: ['GET', 'POST', 'OPTIONS'], 51 | handler: async (req, reply) => { 52 | // Second parameter adds Fastify's `req` and `reply` to the GraphQL Context 53 | const response = await yoga.handleNodeRequest(req, { 54 | req, 55 | reply, 56 | }) 57 | response.headers.forEach((value, key) => { 58 | reply.header(key, value) 59 | }) 60 | 61 | reply.status(response.status) 62 | reply.send(response.body) 63 | 64 | return reply 65 | }, 66 | }) 67 | 68 | const start = async () => { 69 | try { 70 | app.listen( 71 | { 72 | port: 5001, 73 | host: '0.0.0.0', 74 | }, 75 | () => { 76 | console.log( 77 | 'graphql server is running on http://localhost:5001/graphql' 78 | ) 79 | } 80 | ) 81 | } catch (err) { 82 | app.log.error(err) 83 | process.exit(1) 84 | } 85 | } 86 | 87 | start() 88 | -------------------------------------------------------------------------------- /apps/backend/src/lib/auth0/rules/setRolesToUser.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | // this code only runs on the Auth0 rules 3 | // Do not import or require any modules here. 4 | function setRolesToUser(user, context, callback) { 5 | const namespace = ""; 6 | 7 | // if the user signed up already or is using a refresh token, just assign app_metadata.roles to the authtoken 8 | if ( 9 | context.stats.loginsCount > 1 || 10 | context.protocol === "oauth2-refresh-token" || 11 | context.protocol === "redirect-callback" || 12 | context.request.query.prompt === "none" 13 | ) { 14 | context.accessToken[namespace + "/roles"] = user.app_metadata.roles; 15 | return callback(null, user, context); 16 | } 17 | 18 | user.app_metadata = user.app_metadata || {}; 19 | 20 | const addRolesToUser = function (context) { 21 | const role = context.request.query.role; 22 | if (role === "ADMIN") { 23 | return ["ADMIN"]; 24 | } else { 25 | return ["USER"]; 26 | } 27 | }; 28 | 29 | const roles = addRolesToUser(context); 30 | 31 | user.app_metadata.roles = roles; 32 | auth0.users 33 | .updateAppMetadata(user.user_id, user.app_metadata) 34 | .then(function () { 35 | context.idToken[namespace] = user.app_metadata.roles; 36 | context.accessToken[namespace + "/roles"] = user.app_metadata.roles; 37 | callback(null, user, context); 38 | }) 39 | .catch(function (err) { 40 | callback(err); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /apps/backend/src/lib/useAuthHelper.ts: -------------------------------------------------------------------------------- 1 | import { DefaultContext, Plugin } from '@envelop/core' 2 | import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' 3 | import { 4 | defaultFieldResolver, 5 | GraphQLResolveInfo, 6 | GraphQLSchema, 7 | } from 'graphql' 8 | 9 | import { GqlError } from '~/common/error' 10 | 11 | const schemaCache = new WeakMap() 12 | 13 | function defaultErrorHandler(info: GraphQLResolveInfo) { 14 | throw new GqlError( 15 | 'request not authorized' + `, operation: ${info.fieldName}`, 16 | { 17 | code: 'NOT_AUTHORIZED', 18 | } 19 | ) 20 | } 21 | 22 | export function useAuthHelper( 23 | options?: 24 | | { 25 | userContextField?: 'currentUser' | string 26 | userIdField?: 'id' | string 27 | onError?: () => void 28 | } 29 | | undefined 30 | ): Plugin { 31 | const userContextField = options?.userContextField || 'currentUser' 32 | const userIdField = options?.userIdField || 'id' 33 | const onError = options?.onError || defaultErrorHandler 34 | return { 35 | onSchemaChange(ctx) { 36 | let schema = schemaCache.get(ctx.schema) 37 | if (!schema) { 38 | schema = applyOwnerCheckDirective( 39 | ctx.schema, 40 | userContextField, 41 | userIdField, 42 | onError 43 | ) 44 | schemaCache.set(ctx.schema, schema) 45 | } 46 | ctx.replaceSchema(schema!) 47 | }, 48 | } 49 | } 50 | 51 | const typeDirectiveArgumentMaps: Record = {} 52 | 53 | function applyOwnerCheckDirective( 54 | schema: GraphQLSchema, 55 | userContextField: string, 56 | userIdField: string, 57 | onError: (info: GraphQLResolveInfo) => void 58 | ): GraphQLSchema { 59 | return mapSchema(schema, { 60 | [MapperKind.TYPE]: (type) => { 61 | const ownerDirective = getDirective(schema, type, 'isOwner')?.[0] 62 | if (ownerDirective) { 63 | typeDirectiveArgumentMaps[type.name] = ownerDirective 64 | } 65 | return undefined 66 | }, 67 | [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { 68 | const ownerDirective = 69 | getDirective(schema, fieldConfig, 'isOwner')?.[0] ?? 70 | typeDirectiveArgumentMaps[typeName] 71 | if (ownerDirective && fieldConfig) { 72 | return { 73 | ...fieldConfig, 74 | async resolve(source, args, context, info) { 75 | const { resolve = defaultFieldResolver } = fieldConfig 76 | const result = await resolve(source, args, context, info) 77 | 78 | const currentUser = (context as DefaultContext)[ 79 | userContextField 80 | ] as Record 81 | const ownerField: string | undefined = ownerDirective.ownerField 82 | if (!ownerField) { 83 | console.log('ownerField is not placed') 84 | throw new GqlError('something went wrong') 85 | } 86 | const isOwner = 87 | String(source[ownerField]) === String(currentUser[userIdField]) 88 | if (!isOwner) { 89 | onError(info) 90 | } 91 | 92 | return result 93 | }, 94 | } 95 | } 96 | 97 | return fieldConfig 98 | }, 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /apps/backend/src/modules/todo/ITodoRepository.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, Todo } from '@prisma/client' 2 | 3 | export interface ITodoRepository { 4 | save( 5 | todo: Prisma.TodoCreateWithoutAuthorInput, 6 | authorId: number 7 | ): Promise 8 | findById(id: number): Promise 9 | findAll(): Promise 10 | findByUserId(id: number): Promise 11 | edit(todo: Prisma.TodoUpdateInput, id: Todo['id']): Promise 12 | delete(id: number): Promise 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/src/modules/todo/TodoRepository.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient, Todo } from '@prisma/client' 2 | 3 | import { ITodoRepository } from './ITodoRepository' 4 | 5 | export class TodoRepository implements ITodoRepository { 6 | constructor(private readonly prisma: PrismaClient) {} 7 | 8 | async save(todo: Prisma.TodoCreateWithoutAuthorInput, authorId: number) { 9 | return await this.prisma.todo.create({ 10 | data: { 11 | title: todo.title, 12 | content: todo.content, 13 | author: { 14 | connect: { 15 | id: authorId, 16 | }, 17 | }, 18 | }, 19 | }) 20 | } 21 | 22 | async findById(id: number) { 23 | return this.prisma.todo.findUnique({ 24 | where: { id }, 25 | }) 26 | } 27 | 28 | async delete(id: number) { 29 | return await this.prisma.todo.delete({ 30 | where: { id }, 31 | }) 32 | } 33 | 34 | async edit(todo: Prisma.TodoUpdateInput, id: Todo['id']) { 35 | return await this.prisma.todo.update({ 36 | where: { id: id }, 37 | data: todo, 38 | }) 39 | } 40 | 41 | async findAll() { 42 | return await this.prisma.todo.findMany() 43 | } 44 | 45 | async findByUserId(id: number) { 46 | return await this.prisma.todo.findMany({ 47 | where: { 48 | authorId: id, 49 | }, 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/backend/src/modules/todo/todoMappers.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from '@prisma/client' 2 | 3 | import { gql } from '~/api/graphql/generated' 4 | 5 | export class TodoMapper { 6 | public static toGql(todo: Todo): gql.Todo { 7 | return { 8 | ...todo, 9 | id: todo.id.toString(), 10 | createdAt: todo.createdAt?.toString(), 11 | updatedAt: todo.updatedAt?.toString(), 12 | authorId: todo.authorId.toString(), 13 | } 14 | } 15 | 16 | public static toGqlCollection(todos: Todo[]): gql.Todo[] { 17 | return todos.map(this.toGql) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/backend/src/modules/user/IUserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, User } from '@prisma/client' 2 | 3 | export interface IUserRepository { 4 | save(user: Prisma.UserCreateInput): Promise 5 | findById(id: number): Promise 6 | findAll(): Promise> 7 | edit(user: Prisma.UserUpdateInput, id: User['id']): Promise 8 | delete(id: number): Promise 9 | findByTodoId(id: number): Promise 10 | findByUid(uid: string): Promise 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/modules/user/UserMapper.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client' 2 | 3 | import { gql } from '~/api/graphql/generated' 4 | 5 | export class UserMapper { 6 | public static toGql(user: User): gql.User { 7 | return { 8 | ...user, 9 | id: user.id.toString(), 10 | __typename: 'User', 11 | } 12 | } 13 | 14 | public static toGqlCollection(user: User[]): gql.User[] { 15 | return user.map(this.toGql) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend/src/modules/user/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient, User } from '@prisma/client' 2 | 3 | import { IUserRepository } from './IUserRepository' 4 | 5 | export class UserRepository implements IUserRepository { 6 | constructor(private readonly prisma: PrismaClient) {} 7 | 8 | async save(user: Prisma.UserCreateWithoutTodosInput) { 9 | return await this.prisma.user.create({ 10 | data: { 11 | name: user.name, 12 | email: user.email, 13 | uid: user.uid, 14 | }, 15 | }) 16 | } 17 | 18 | async findById(id: number) { 19 | return await this.prisma.user.findUnique({ 20 | where: { id }, 21 | }) 22 | } 23 | 24 | async delete(id: number) { 25 | return await this.prisma.user.delete({ 26 | where: { id }, 27 | }) 28 | } 29 | 30 | async edit(user: Prisma.UserUpdateInput, id: User['id']) { 31 | return await this.prisma.user.update({ 32 | where: { id: id }, 33 | data: user, 34 | }) 35 | } 36 | 37 | async findAll() { 38 | return await this.prisma.user.findMany() 39 | } 40 | 41 | async findByTodoId(id: number) { 42 | return await this.prisma.todo.findUnique({ where: { id } }).author() 43 | } 44 | 45 | async findByUid(uid: string) { 46 | return await this.prisma.user.findUnique({ where: { uid } }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/backend/src/useCases/todoUseCase.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from '@prisma/client' 2 | 3 | import { TodoInput } from '~/api/graphql/generated/graphql' 4 | 5 | import { err, ok, Result } from '../common/result' 6 | import { UseCase, UseCaseContext } from '../common/useCase' 7 | import { ITodoRepository } from '../modules/todo/ITodoRepository' 8 | 9 | export class TodoUseCase extends UseCase { 10 | public constructor( 11 | ctx: UseCaseContext, 12 | private readonly todoRepository: ITodoRepository 13 | ) { 14 | super(ctx) 15 | } 16 | 17 | public async save( 18 | todo: TodoInput 19 | ): Promise> { 20 | const currentUserId = this.ctx.currentUser?.id 21 | if (!currentUserId) { 22 | return err('USER_NOT_FOUND') 23 | } 24 | 25 | try { 26 | const result = await this.todoRepository.save( 27 | { 28 | title: todo.title, 29 | content: todo.content, 30 | }, 31 | currentUserId 32 | ) 33 | return ok(result) 34 | } catch (error) { 35 | return err('DATABASE') 36 | } 37 | } 38 | 39 | public async findAll(): Promise> { 40 | try { 41 | const result = await this.todoRepository.findAll() 42 | return ok(result) 43 | } catch (error) { 44 | return err('DATABASE') 45 | } 46 | } 47 | 48 | public async findByCurrentUser(): Promise< 49 | Result 50 | > { 51 | const currentUserId = this.ctx.currentUser?.id 52 | if (!currentUserId) { 53 | return err('USER_NOT_FOUND') 54 | } 55 | try { 56 | const result = await this.todoRepository.findByUserId(currentUserId) 57 | return ok(result) 58 | } catch (error) { 59 | console.log(error) 60 | return err('DATABASE') 61 | } 62 | } 63 | 64 | public async findById( 65 | id: number 66 | ): Promise> { 67 | try { 68 | const result = await this.todoRepository.findById(id) 69 | if (!result) { 70 | return err('RESOURCE_NOT_FOUND') 71 | } 72 | return ok(result) 73 | } catch (error) { 74 | return err('DATABASE') 75 | } 76 | } 77 | 78 | public async markAsCompleted(id: number): Promise> { 79 | try { 80 | const result = await this.todoRepository.edit( 81 | { 82 | completed: true, 83 | }, 84 | id 85 | ) 86 | return ok(result) 87 | } catch (error) { 88 | return err('DATABASE') 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /apps/backend/src/useCases/userUseCase.ts: -------------------------------------------------------------------------------- 1 | import { Todo, User } from '@prisma/client' 2 | import got from 'got' 3 | 4 | import { UserInput } from '~/api/graphql/generated/graphql' 5 | import { err, ok, Result } from '~/common/result' 6 | 7 | import { UseCase, UseCaseContext } from '../common/useCase' 8 | import { IUserRepository } from '../modules/user/IUserRepository' 9 | 10 | export class UserUseCase extends UseCase { 11 | public constructor( 12 | ctx: UseCaseContext, 13 | private readonly userRepository: IUserRepository 14 | ) { 15 | super(ctx) 16 | } 17 | 18 | public async save( 19 | user: UserInput 20 | ): Promise> { 21 | const auth0UserInfo = await got<{ email: string }>( 22 | `https://${process.env.AUTH0_DOMAIN}/userinfo`, 23 | { 24 | headers: { 25 | Authorization: this.ctx.authToken, 26 | 'Content-type': 'application/json', 27 | }, 28 | } 29 | ) 30 | .json<{ email: string }>() 31 | .catch((e) => { 32 | console.log(e) 33 | return null 34 | }) 35 | 36 | if (!auth0UserInfo) { 37 | console.log('failed to get auth0 user info') 38 | return err('AUTH0') 39 | } 40 | 41 | const uid = this.ctx.auth0?.sub 42 | 43 | if (!uid) { 44 | console.log('failed to get uid from auth0') 45 | return err('AUTH0') 46 | } 47 | try { 48 | const result = await this.userRepository.save({ 49 | email: auth0UserInfo.email, 50 | name: user.name, 51 | uid: uid, 52 | }) 53 | return ok(result) 54 | } catch (error) { 55 | return err('DATABASE') 56 | } 57 | } 58 | 59 | public async findCurrentUser(): Promise< 60 | Result 61 | > { 62 | const currentUserId = this.ctx.currentUser?.id 63 | if (!currentUserId) { 64 | return err('RESOURCE_NOT_FOUND') 65 | } 66 | const result = await this.userRepository.findById(currentUserId) 67 | if (!result) { 68 | return err('RESOURCE_NOT_FOUND') 69 | } 70 | return ok(result) 71 | } 72 | 73 | public async findAll(): Promise> { 74 | try { 75 | const result = await this.userRepository.findAll() 76 | return ok(result) 77 | } catch (error) { 78 | return err('DATABASE') 79 | } 80 | } 81 | 82 | public async findById( 83 | id: User['id'] 84 | ): Promise> { 85 | const result = await this.userRepository.findById(id) 86 | if (!result) { 87 | return err('RESOURCE_NOT_FOUND') 88 | } 89 | return ok(result) 90 | } 91 | 92 | public async findByTodoId( 93 | id: Todo['id'] 94 | ): Promise> { 95 | try { 96 | const result = await this.userRepository.findByTodoId(id) 97 | if (!result) { 98 | return err('RESOURCE_NOT_FOUND') 99 | } 100 | return ok(result) 101 | } catch (error) { 102 | return err('DATABASE') 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /apps/backend/src/utils/useOwnerCheck.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from '@envelop/core' 2 | import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' 3 | import { User } from '@prisma/client' 4 | import { defaultFieldResolver, GraphQLSchema } from 'graphql' 5 | 6 | import { GqlError } from '~/common/error' 7 | import { GraphqlServerContext } from '~/context' 8 | 9 | const schemaCache = new WeakMap() 10 | 11 | export function useOwnerCheck( 12 | options?: 13 | | { 14 | userContextField?: 'currentUser' | string 15 | } 16 | | undefined 17 | ): Plugin { 18 | const userContextField = options?.userContextField || 'currentUser' 19 | return { 20 | onSchemaChange(ctx) { 21 | let schema = schemaCache.get(ctx.schema) 22 | if (!schema) { 23 | schema = applyOwnerCheckDirective(ctx.schema, userContextField) 24 | schemaCache.set(ctx.schema, schema) 25 | } 26 | ctx.replaceSchema(schema!) 27 | }, 28 | } 29 | } 30 | 31 | const typeDirectiveArgumentMaps: Record = {} 32 | 33 | function applyOwnerCheckDirective( 34 | schema: GraphQLSchema, 35 | userContextField: string 36 | ): GraphQLSchema { 37 | return mapSchema(schema, { 38 | [MapperKind.TYPE]: (type) => { 39 | const ownerDirective = getDirective(schema, type, 'isOwner')?.[0] 40 | if (ownerDirective) { 41 | typeDirectiveArgumentMaps[type.name] = ownerDirective 42 | } 43 | return undefined 44 | }, 45 | [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { 46 | const ownerDirective = 47 | getDirective(schema, fieldConfig, 'isOwner')?.[0] ?? 48 | typeDirectiveArgumentMaps[typeName] 49 | if (ownerDirective && fieldConfig) { 50 | return { 51 | ...fieldConfig, 52 | async resolve(source, args, context, info) { 53 | const { resolve = defaultFieldResolver } = fieldConfig 54 | const result = await resolve(source, args, context, info) 55 | 56 | const currentUser = (context as GraphqlServerContext)[ 57 | userContextField 58 | ] as User 59 | const ownerField: string | undefined = ownerDirective.ownerField 60 | if (!ownerField) { 61 | console.log('ownerField is not placed') 62 | throw new GqlError('something went wrong') 63 | } 64 | const isOwner = 65 | String(source[ownerField]) === String(currentUser.id) 66 | if (!isOwner) { 67 | throw new GqlError( 68 | 'request not authorized' + `, operation: ${info.fieldName}`, 69 | { 70 | code: 'NOT_AUTHORIZED', 71 | } 72 | ) 73 | } 74 | 75 | return result 76 | }, 77 | } 78 | } 79 | 80 | return fieldConfig 81 | }, 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /apps/backend/src/utils/validationHelper.ts: -------------------------------------------------------------------------------- 1 | import { ZodObject } from 'zod' 2 | 3 | import { GqlError } from '~/common/error' 4 | 5 | export const validateParams = (params: unknown, zodSchema: ZodObject) => { 6 | const result = zodSchema.safeParse(params) 7 | 8 | if (result.success) { 9 | return 10 | } else { 11 | throw new GqlError('validation error', { 12 | code: 'VALIDATION_ERROR', 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "module": "CommonJS", 6 | "baseUrl": ".", 7 | "paths": { 8 | "~/*": ["src/*"] 9 | }, 10 | "allowJs": true, 11 | "alwaysStrict": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "noImplicitAny": true, 15 | "lib": ["es2020"], 16 | "outDir": "./dist", 17 | "rootDir": "./src", 18 | "forceConsistentCasingInFileNames": true, 19 | "typeRoots": ["types", "node_modules/@types", "node_modules/types"] 20 | }, 21 | "exclude": ["**/tmp/", "node_modules/**/*", "**/dist/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /apps/frontend/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_AUTH0_CLIENT_ID= 2 | NEXT_PUBLIC_AUTH0_DOMAIN= 3 | NEXT_PUBLIC_AUTH0_AUDIENCE= 4 | NEXT_PUBLIC_GRAPHQL_END_POINT= -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const base = require('config/eslint-next') 3 | 4 | module.exports = { 5 | ...base, 6 | rules: { 7 | ...base.rules, 8 | 'react/no-unknown-property': [ 9 | 2, 10 | { 11 | ignore: ['jsx', 'global'], 12 | }, 13 | ], 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # lint 37 | .eslintcache 38 | 39 | # editor config 40 | .idea/ 41 | 42 | # storybook 43 | /storybook-static 44 | 45 | manifest.json 46 | -------------------------------------------------------------------------------- /apps/frontend/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: '../backend/src/api/graphql/typeDefs.ts' 3 | documents: 4 | - './src/**/!(*.d).{ts,tsx}' 5 | - './src/**/*.graphql' 6 | 7 | generates: 8 | src/generated/: 9 | preset: gql-tag-operations-preset 10 | # plugins: 11 | # - typescript 12 | # - typescript-operations 13 | # - typed-document-node 14 | 15 | src/generated/schema.graphql: 16 | plugins: 17 | - schema-ast 18 | config: 19 | includeDirectives: true 20 | -------------------------------------------------------------------------------- /apps/frontend/graphql.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: 'src/generated/schema.graphql', 3 | documents: ['src/**/*.{graphql,js,ts,jsx,tsx}'], 4 | } 5 | -------------------------------------------------------------------------------- /apps/frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | testMatch: [ 4 | '**/__tests__/**/*.+(ts|tsx|js)', 5 | '**/?(*.)+(spec|test).+(ts|tsx|js)', 6 | ], 7 | testEnvironment: 'jsdom', 8 | setupFilesAfterEnv: ['./jest.setup.ts'], 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 10 | transform: { 11 | '^.+\\.(ts|tsx)$': 'ts-jest', 12 | }, 13 | preset: 'ts-jest', 14 | moduleNameMapper: { 15 | '^~/(.*)$': '/src/$1', 16 | '^ui$': '/node_modules/ui/src/index.tsx', 17 | '^validation-schema$': 18 | '/node_modules/validation-schema/src/index.ts', 19 | }, 20 | moduleDirectories: ['node_modules', ''], 21 | modulePathIgnorePatterns: [ 22 | '/test/__fixtures__', 23 | '/node_modules', 24 | '/dist', 25 | ], 26 | globals: { 27 | 'ts-jest': { 28 | tsconfig: { 29 | jsx: 'react-jsx', 30 | }, 31 | useEsm: true, 32 | }, 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'isomorphic-unfetch' 2 | import '@testing-library/jest-dom/extend-expect' 3 | 4 | import { resetFactoryIds } from '~/mocks/factories/factory' 5 | import { server } from '~/mocks/server' 6 | 7 | process.env = { 8 | ...process.env, 9 | NEXT_PUBLIC_GRAPHQL_END_POINT: 'http://localhost:4000/dev/graphql', 10 | } 11 | 12 | jest.mock('@auth0/auth0-react', () => { 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | const React = require('react') 15 | return { 16 | Auth0Provider: ({ children }: any) => children, 17 | withAuthenticationRequired: (component: any) => component, 18 | useAuth0: () => { 19 | return { 20 | isAuthenticated: true, 21 | getAccessTokenSilently: React.useCallback(async () => 'TEST_TOKEN', []), 22 | isLoading: false, 23 | } 24 | }, 25 | } 26 | }) 27 | 28 | // Establish API mocking before all tests. 29 | beforeAll(() => { 30 | server.listen() 31 | }) 32 | 33 | // Reset any request handlers that we may add during the tests, 34 | // so they don't affect other tests. 35 | afterEach(() => { 36 | resetFactoryIds() 37 | server.resetHandlers() 38 | }) 39 | 40 | // Clean up after the tests are finished. 41 | afterAll(() => server.close()) 42 | -------------------------------------------------------------------------------- /apps/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 3 | enabled: process.env.ANALYZE === 'true', 4 | }) 5 | 6 | /** @type {import('next').NextConfig} */ 7 | const config = { 8 | pageExtensions: ['page.tsx', 'api.ts'], 9 | // transpilePackages: ['validation-schema'], 10 | } 11 | 12 | module.exports = withBundleAnalyzer(config) 13 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev & pnpm run codegen:watch", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint --fix --cache --ext .ts,.tsx src", 10 | "type-check": "tsc --noEmit", 11 | "codegen:grqphql": "graphql-codegen --config codegen.yml", 12 | "codegen:watch": "graphql-codegen --watch --config codegen.yml", 13 | "test": "NODE_ENV=test jest --config ./jest.config.js", 14 | "test:watch": "NODE_ENV=test jest --watch --config ./jest.config.js", 15 | "bundle-analyzer": "ANALYZE=true pnpm run build" 16 | }, 17 | "dependencies": { 18 | "@auth0/auth0-react": "1.12.1", 19 | "@heroicons/react": "1.0.6", 20 | "@hookform/resolvers": "^2.9.10", 21 | "@next/font": "^13.1.6", 22 | "@radix-ui/colors": "0.1.8", 23 | "@radix-ui/react-avatar": "1.0.2", 24 | "@radix-ui/react-dialog": "1.0.3", 25 | "@radix-ui/react-icons": "1.3.0", 26 | "@radix-ui/react-portal": "1.0.2", 27 | "autoprefixer": "^10.4.13", 28 | "graphql": "16.6.0", 29 | "graphql-anywhere": "4.2.8", 30 | "next": "13.3.1", 31 | "postcss": "^8.4.21", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "react-hook-form": "7.43.9", 35 | "spinners-react": "1.0.7", 36 | "tailwindcss": "3.2.7", 37 | "ts-pattern": "3.3.5", 38 | "typescript": "4.9.5", 39 | "ui": "workspace:*", 40 | "urql": "2.2.3", 41 | "validation-schema": "workspace:*", 42 | "wonka": "4.0.15", 43 | "zod": "3.20.6" 44 | }, 45 | "devDependencies": { 46 | "@graphql-codegen/cli": "2.16.5", 47 | "@graphql-codegen/gql-tag-operations-preset": "1.7.4", 48 | "@graphql-codegen/schema-ast": "2.6.1", 49 | "@graphql-codegen/typed-document-node": "2.3.13", 50 | "@graphql-codegen/typescript": "2.8.8", 51 | "@graphql-codegen/typescript-operations": "2.5.13", 52 | "@graphql-codegen/typescript-urql": "3.7.3", 53 | "@graphql-typed-document-node/core": "3.1.2", 54 | "@next/bundle-analyzer": "12.3.4", 55 | "@testing-library/jest-dom": "5.16.5", 56 | "@testing-library/react": "13.4.0", 57 | "@testing-library/user-event": "14.4.3", 58 | "@types/jest": "27.5.2", 59 | "@types/node": "16.18.24", 60 | "@types/react": "18.0.38", 61 | "@types/react-dom": "18.0.11", 62 | "@types/styled-components": "5.1.26", 63 | "@types/testing-library__jest-dom": "^5.14.5", 64 | "babel-jest": "27.5.1", 65 | "config": "workspace:*", 66 | "graphql-playground-html": "1.6.30", 67 | "graphql-typescript-integration": "1.2.1", 68 | "isomorphic-unfetch": "3.1.0", 69 | "jest": "27.5.1", 70 | "msw": "0.44.2", 71 | "prettier-plugin-tailwindcss": "^0.2.2", 72 | "react-is": "18.2.0", 73 | "react-test-renderer": "18.2.0", 74 | "tailwind-config": "workspace:*", 75 | "ts-jest": "27.1.5", 76 | "tsconfig": "workspace:*" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /apps/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/components/form/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { TextareaHTMLAttributes } from 'react' 2 | import React from 'react' 3 | import { cn } from 'ui' 4 | 5 | interface Props extends TextareaHTMLAttributes { 6 | label?: string 7 | className?: string 8 | } 9 | 10 | export const TextArea = React.forwardRef( 11 | function TextArea({ className, ...restProps }: Props, ref) { 12 | return ( 13 |