├── .gitignore ├── LICENCE ├── README.md ├── netlify.toml ├── package.json ├── packages ├── api │ ├── .env.example │ ├── .turbo │ │ └── turbo-build.log │ ├── README.md │ ├── docker-compose.yml │ ├── package.json │ ├── scripts │ │ └── pg.sh │ ├── src │ │ ├── constants │ │ │ ├── issue.ts │ │ │ └── project.ts │ │ ├── database │ │ │ ├── createConnection.ts │ │ │ ├── createGuestAccount.ts │ │ │ ├── createTestAccount.ts │ │ │ ├── createTestConnection.ts │ │ │ └── resetDatabase.ts │ │ ├── errors │ │ │ ├── asyncCatch.ts │ │ │ ├── customErrors.ts │ │ │ ├── gqlError.ts │ │ │ └── index.ts │ │ ├── gql │ │ │ ├── auth.ts │ │ │ ├── comments.ts │ │ │ ├── index.ts │ │ │ ├── issues.ts │ │ │ ├── plugins │ │ │ │ └── sentry.ts │ │ │ ├── project.ts │ │ │ ├── types.ts │ │ │ └── user.ts │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── errorInterceptor.ts │ │ │ ├── isAuth.ts │ │ │ └── resolveTime.ts │ │ ├── models │ │ │ ├── Comment.ts │ │ │ ├── Issue.ts │ │ │ ├── Project.ts │ │ │ ├── User.ts │ │ │ └── index.ts │ │ ├── types │ │ │ ├── context.ts │ │ │ ├── env.d.ts │ │ │ └── express.d.ts │ │ └── utils │ │ │ ├── authToken.ts │ │ │ ├── javascript.ts │ │ │ ├── typeorm.ts │ │ │ └── validations.ts │ ├── tsconfig.json │ ├── tslint.json │ └── yarn.lock └── web │ ├── .eslintrc.json │ ├── .prettierrc │ ├── .turbo │ └── turbo-build.log │ ├── babel.config.js │ ├── index.html │ ├── jest.config.js │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ ├── _redirects │ ├── favicon.png │ ├── fonts │ │ ├── CircularStd-Black.woff │ │ ├── CircularStd-Black.woff2 │ │ ├── CircularStd-Bold.woff │ │ ├── CircularStd-Bold.woff2 │ │ ├── CircularStd-Book.woff │ │ ├── CircularStd-Book.woff2 │ │ ├── CircularStd-Medium.woff │ │ └── CircularStd-Medium.woff2 │ ├── robots.txt │ └── sprite.xml │ ├── src │ ├── App.vue │ ├── assets │ │ └── img │ │ │ └── mountains.jpg │ ├── auth │ │ └── authenticate.ts │ ├── components │ │ ├── ErrorPage.vue │ │ ├── Loader.vue │ │ ├── Modals │ │ │ ├── Confirm.vue │ │ │ ├── Modal.vue │ │ │ └── Modals.vue │ │ ├── Navigation │ │ │ ├── NavbarLeft.vue │ │ │ ├── Navigation.vue │ │ │ ├── Resizer.vue │ │ │ ├── Sidebar.ts │ │ │ └── Sidebar.vue │ │ ├── Project │ │ │ ├── Filters.vue │ │ │ ├── Issue │ │ │ │ ├── Issue.vue │ │ │ │ ├── IssueCreate │ │ │ │ │ └── IssueCreate.vue │ │ │ │ ├── IssueDetails │ │ │ │ │ ├── AssigneesReporter.vue │ │ │ │ │ ├── Comment.vue │ │ │ │ │ ├── Description.vue │ │ │ │ │ ├── IssueDetails.vue │ │ │ │ │ ├── Priority.vue │ │ │ │ │ ├── Status.vue │ │ │ │ │ ├── Title.vue │ │ │ │ │ └── Type.vue │ │ │ │ └── IssueSearch │ │ │ │ │ ├── IssueSearch.vue │ │ │ │ │ └── SearchResult.vue │ │ │ ├── IssueLoader.vue │ │ │ └── Lists │ │ │ │ ├── List.vue │ │ │ │ └── Lists.vue │ │ └── shared │ │ │ ├── Avatar │ │ │ └── Avatar.vue │ │ │ ├── Breadcrumbs │ │ │ └── Breadcrumbs.vue │ │ │ ├── Button │ │ │ └── Button.vue │ │ │ ├── Icon │ │ │ └── Icon.vue │ │ │ ├── Input │ │ │ └── Input.vue │ │ │ ├── Select │ │ │ ├── Dropdown.vue │ │ │ └── Select.vue │ │ │ ├── TextEditor │ │ │ ├── TextEditor.vue │ │ │ └── editor.ts │ │ │ └── Textarea │ │ │ └── Textarea.vue │ ├── fonts.scss │ ├── graphql │ │ ├── client.ts │ │ └── queries │ │ │ ├── auth.ts │ │ │ ├── comment.ts │ │ │ ├── issue.ts │ │ │ └── project.ts │ ├── hooks │ │ ├── useClipboard.ts │ │ └── useOutsideClick.ts │ ├── main.scss │ ├── main.ts │ ├── plugins │ │ ├── loadSvg.ts │ │ ├── register.ts │ │ ├── tippy.ts │ │ └── toast.ts │ ├── router.ts │ ├── store.ts │ ├── types │ │ ├── comment.ts │ │ ├── filters.ts │ │ ├── index.ts │ │ ├── issue.ts │ │ ├── project.ts │ │ └── user.ts │ ├── utils │ │ ├── authToken.ts │ │ ├── colors.ts │ │ ├── date.ts │ │ ├── dnd.ts │ │ └── eventBus.ts │ ├── views │ │ ├── Board.vue │ │ ├── FullIIssueDetails.vue │ │ ├── Project.vue │ │ └── Settings.vue │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # misc 5 | **/.DS_Store 6 | 7 | # environment config 8 | **/.env 9 | 10 | # production 11 | **/dist 12 | 13 | # Log files 14 | **/npm-debug.log* 15 | **/yarn-debug.log* 16 | **/yarn-error.log* 17 | 18 | # Editor directories and files 19 | **/.idea 20 | **/.vscode 21 | **/*.suo 22 | **/*.ntvs* 23 | **/*.njsproj 24 | **/*.sln 25 | **/*.sw? 26 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2013-present, Yuxi (Evan) You 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | © 2020 GitHub, Inc. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Jira clone built with Vuejs & Nodejs/Graphql 2 | 3 | ## Live Demo 4 | 5 | ![App screenshot](https://i.ibb.co/bvFPbwk/Screenshot-2020-03-24-Jira-Clone.png) 6 | 7 | ## Getting started 🚀 8 | 9 | - `git clone https://github.com/Datlyfe/jira_clone.git` 10 | - Install [postgreSQL](https://www.postgresql.org/) if you don't have it already and create a database ( there is also a docker compose file if you prefer using docker, just run `docker-compose up` inside the backend folder) 11 | - Create an empty `.env` file in `/backend`, copy `/backend/.env.example` contents into it, and fill in your database username and password. 12 | - `pnpm install` 13 | - `npm run dev` 14 | - App should now be running on `http://localhost:5137/` 15 | 16 | ## Inspiration and Why? 🤷‍♀️ 17 | 18 | I'm a Full Stack Web developer and an open source collaborator that loves building things 😉 19 | 20 | This project is basically a clone of another open source Jira clone build in React ( clone of a clone i know 😵) and since im a Vuejs lover i had the idea of rebuilding the app with the same functionalities but using Vuejs instead as i though it would be a great opportunity to explore the new composition api coming to Vue very soon. and since we are doing a full rewrite i added Graphql and Typescript to the mix. 21 | 22 | I believe that this project will be a great example for Vuejs developers in the future transitioning from the old Vuejs Api to the new composition Api or for Vuejs newcomers in general. 23 | 24 | ## Author: Bouaggad Moez ✍️ 25 | 26 | Email : 27 | 28 | Website : 29 | 30 | ## Contributing 31 | 32 | I think the state of the project is good as it is right now feature wise and i will only be fixing bugs if they come up so it you want to contribute in doing so you are very welcome. 33 | 34 | ## Credits 35 | 36 | Inspired by oldboyxx/jira_clone 37 | 38 | ## License 39 | 40 | [MIT](https://opensource.org/licenses/MIT) 41 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "packages/web" 3 | publish = "dist/" 4 | command = "npm run build" 5 | 6 | [[redirects]] 7 | from = "/*" 8 | to = "/index.html" 9 | status = 200 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-clone", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": { 6 | "name": "Moez Bouaggad", 7 | "email": "mrbouaggadmoez@gmail.com", 8 | "url": "https://github.com/datlyfe" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "turbo run build --include-dependencies", 13 | "dev": "turbo run dev --include-dependencies", 14 | "preinstall": "only-allow pnpm" 15 | }, 16 | "devDependencies": { 17 | "only-allow": "^1.1.1", 18 | "turbo": "^1.9.3" 19 | } 20 | } -------------------------------------------------------------------------------- /packages/api/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://user:password@localhost:5432/jira_clone_gql?sslmode=disable 2 | JWT_SECRET=secret123 3 | SENTRY_DSN=https://sentry.io/... 4 | -------------------------------------------------------------------------------- /packages/api/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 | > api@1.0.0 build /Users/moezbouaggad/personal/jira_clone/packages/api 3 | > tsc 4 | 5 | -------------------------------------------------------------------------------- /packages/api/README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | -------------------------------------------------------------------------------- /packages/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | pg_db: 4 | container_name: pg_db 5 | image: "postgres:9.6.17" 6 | restart: always 7 | environment: 8 | POSTGRES_USER: jira_user 9 | POSTGRES_PASSWORD: jira_password 10 | POSTGRES_MULTIPLE_DATABASES: jira_db,jira_test_db 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - ./scripts:/docker-entrypoint-initdb.d 15 | - database-data:/var/lib/postgresql/data/ 16 | 17 | pgweb: 18 | container_name: pgweb 19 | restart: always 20 | image: sosedoff/pgweb 21 | ports: 22 | - "8081:8081" 23 | links: 24 | - pg_db:pg_db 25 | environment: 26 | - DATABASE_URL=postgres://jira_user:jira_password@pg_db:5432/jira_db?sslmode=disable 27 | depends_on: 28 | - pg_db 29 | 30 | volumes: 31 | database-data: 32 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "author": { 6 | "name": "Bouaggad Moez", 7 | "email": "Mrbouaggadmoez@gmail.com" 8 | }, 9 | "scripts": { 10 | "dev": "ts-node-dev --respawn --files --transpile-only --no-notify --ignore-watch node_modules src/index.ts", 11 | "start": "node dist/index.js", 12 | "build": "tsc" 13 | }, 14 | "dependencies": { 15 | "@apollo/server": "^4.3.0", 16 | "@sentry/integrations": "7.56.0", 17 | "@sentry/node": "7.56.0", 18 | "body-parser": "^1.20.1", 19 | "cors": "^2.8.5", 20 | "dotenv": "16.3.1", 21 | "express": "^4.18.2", 22 | "graphql": "^16.7.0", 23 | "jsonwebtoken": "9.0.0", 24 | "module-alias": "2.2.3", 25 | "node-cron": "3.0.2", 26 | "pg": "8.11.0", 27 | "reflect-metadata": "0.1.13", 28 | "striptags": "3.2.0", 29 | "ts-node-dev": "2.0.0", 30 | "type-graphql": "2.0.0-beta.2", 31 | "typeorm": "0.3.17" 32 | }, 33 | "devDependencies": { 34 | "@types/cors": "2.8.13", 35 | "@types/express": "4.17.17", 36 | "@types/graphql": "14.5.0", 37 | "@types/jsonwebtoken": "9.0.2", 38 | "@types/module-alias": "2.0.1", 39 | "@types/node": "20.3.1", 40 | "@types/node-cron": "3.0.7", 41 | "class-validator": "^0.14.0", 42 | "cross-env": "7.0.3", 43 | "typescript": "5.1.3" 44 | } 45 | } -------------------------------------------------------------------------------- /packages/api/scripts/pg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -u 5 | 6 | function create_user_and_database() { 7 | local database=$1 8 | echo " Creating user and database '$database'" 9 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 10 | CREATE USER $database; 11 | CREATE DATABASE $database; 12 | GRANT ALL PRIVILEGES ON DATABASE $database TO $database; 13 | EOSQL 14 | } 15 | 16 | if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then 17 | echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" 18 | for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do 19 | create_user_and_database $db 20 | done 21 | echo "Multiple databases created" 22 | fi 23 | -------------------------------------------------------------------------------- /packages/api/src/constants/issue.ts: -------------------------------------------------------------------------------- 1 | export enum IssueType { 2 | TASK = "task", 3 | BUG = "bug", 4 | STORY = "story" 5 | } 6 | 7 | export enum IssueStatus { 8 | BACKLOG = "backlog", 9 | SELECTED = "selected", 10 | INPROGRESS = "inprogress", 11 | DONE = "done" 12 | } 13 | 14 | export enum IssuePriority { 15 | HIGHEST = "5", 16 | HIGH = "4", 17 | MEDIUM = "3", 18 | LOW = "2", 19 | LOWEST = "1" 20 | } 21 | -------------------------------------------------------------------------------- /packages/api/src/constants/project.ts: -------------------------------------------------------------------------------- 1 | export enum ProjectCategory { 2 | SOFTWARE = "software", 3 | MARKETING = "marketing", 4 | BUSINESS = "business" 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/database/createConnection.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, DataSourceOptions } from "typeorm"; 2 | 3 | import * as entities from "@/models"; 4 | 5 | const commonOptions: DataSourceOptions = { 6 | type: "postgres", 7 | entities: Object.values(entities), 8 | synchronize: true, 9 | }; 10 | 11 | const AppDataSourceOptions: DataSourceOptions = 12 | process.env.NODE_ENV === "production" 13 | ? { 14 | url: process.env.DATABASE_URL, 15 | ...commonOptions, 16 | extra: { 17 | max: 5, 18 | }, 19 | } 20 | : { 21 | url: process.env.DATABASE_URL, 22 | ...commonOptions, 23 | }; 24 | 25 | const createDatabaseConnection = (): Promise => { 26 | const AppDataSource = new DataSource(AppDataSourceOptions); 27 | return AppDataSource.initialize(); 28 | }; 29 | 30 | export default createDatabaseConnection; 31 | -------------------------------------------------------------------------------- /packages/api/src/database/createGuestAccount.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Issue, Project, User } from "@/models"; 2 | import { ProjectCategory } from "@/constants/project"; 3 | import { IssueType, IssueStatus, IssuePriority } from "@/constants/issue"; 4 | import { createEntity } from "@/utils/typeorm"; 5 | 6 | const seedUsers = (): Promise => { 7 | const users = [ 8 | createEntity(User, { 9 | email: "berlin@jira.guest", 10 | name: "Berlin", 11 | avatarUrl: 12 | "https://res.cloudinary.com/datlyfe/image/upload/v1583949061/casa%20del%20papel/berlin_tjeb95.jpg" 13 | }), 14 | createEntity(User, { 15 | email: "profesor@jira.guest", 16 | name: "El Profesor", 17 | avatarUrl: 18 | "https://res.cloudinary.com/datlyfe/image/upload/v1583949197/casa%20del%20papel/profesor_dcwdlt.jpg" 19 | }), 20 | createEntity(User, { 21 | email: "tokyo@jira.guest", 22 | name: "Tokyo", 23 | avatarUrl: 24 | "https://res.cloudinary.com/datlyfe/image/upload/v1583949061/casa%20del%20papel/tokyo_eiij3f.jpg" 25 | }), 26 | createEntity(User, { 27 | email: "denver@jira.guest", 28 | name: "Denver", 29 | avatarUrl: 30 | "https://res.cloudinary.com/datlyfe/image/upload/v1583949061/casa%20del%20papel/denver_wntrkk.jpg" 31 | }) 32 | ]; 33 | return Promise.all(users); 34 | }; 35 | 36 | const seedProject = (users: User[]): Promise => 37 | createEntity(Project, { 38 | name: "Money Heist", 39 | url: "https://www.atlassian.com/software/jira", 40 | description: "The robbery of the Royal Mint of Spain", 41 | category: ProjectCategory.MARKETING, 42 | users 43 | }); 44 | 45 | const seedIssues = (project: Project): Promise => { 46 | const { users } = project; 47 | 48 | const issues = [ 49 | createEntity(Issue, { 50 | title: "The rules and guidlines of the heist", 51 | type: IssueType.STORY, 52 | status: IssueStatus.BACKLOG, 53 | priority: IssuePriority.HIGH, 54 | listPosition: 1, 55 | description: `

These rules MUST be followed :


  • No Killing: We want to appear to be Just Like Robin Hood, we do not intend to hurt anyone
  • No Names: Everyone is nicknamed after a city. do not share you real names with anyone even me
  • No Personal Relationships: this is good for preventing attachments that might compromise the operation


That's it!

💯💯


`, 56 | estimate: 12, 57 | timeSpent: 11, 58 | reporterId: users[1].id, 59 | project, 60 | users: [users[0], users[2], users[1], users[3]] 61 | }), 62 | createEntity(Issue, { 63 | title: "This is our escape plan", 64 | type: IssueType.TASK, 65 | status: IssueStatus.INPROGRESS, 66 | priority: IssuePriority.LOW, 67 | listPosition: 2, 68 | description: `

⛏️⛏️ We are digging our way out of the bank ⛏️⛏️


Moscow and Denver will take care of digging a tunnel into the safe they are the best in the business.

`, 69 | estimate: 8, 70 | timeSpent: 4, 71 | reporterId: users[1].id, 72 | project, 73 | users: [users[1], users[3]] 74 | }), 75 | createEntity(Issue, { 76 | title: "Print money for a total of 2.4 billion Euros", 77 | type: IssueType.TASK, 78 | status: IssueStatus.INPROGRESS, 79 | priority: IssuePriority.MEDIUM, 80 | listPosition: 3, 81 | description: `

💰💰💰 We are making our own money 💰💰💰

The plan is not just to rob the bank no no no..., we are going to take the employees hostage and make them print 2.4 billion Euros

`, 82 | estimate: 5, 83 | timeSpent: 2, 84 | reporterId: users[1].id, 85 | project, 86 | users: [users[2], users[1], users[3]] 87 | }), 88 | createEntity(Issue, { 89 | title: "Couple hostages tried to escape", 90 | type: IssueType.BUG, 91 | status: IssueStatus.INPROGRESS, 92 | priority: IssuePriority.HIGHEST, 93 | listPosition: 4, 94 | description: `

The Bastards almost killed me 😠😠


Arturo tried escaping with the help of his secretary Mónica Gaztambide

`, 95 | estimate: 10, 96 | timeSpent: 2, 97 | reporterId: users[0].id, 98 | project, 99 | users: [users[0], users[3]] 100 | }), 101 | createEntity(Issue, { 102 | title: "We are the resistance", 103 | type: IssueType.STORY, 104 | status: IssueStatus.BACKLOG, 105 | priority: IssuePriority.LOW, 106 | listPosition: 5, 107 | description: `

They left us no choice but to come and take what's rightfully ours ✊✊✊


In this world, everything is governed by balance. There’s what you stand to gain and what you stand to lose. And when you think you’ve got nothing to lose, you become over confident.

`, 108 | estimate: 10, 109 | timeSpent: 2, 110 | reporterId: users[1].id, 111 | project, 112 | users: [users[0], users[1], users[3]] 113 | }), 114 | createEntity(Issue, { 115 | title: "Try leaving a comment on this issue.", 116 | type: IssueType.TASK, 117 | status: IssueStatus.DONE, 118 | priority: IssuePriority.MEDIUM, 119 | listPosition: 7, 120 | description: `

Adding comments to an issue is a useful way to record additional detail about an issue, and collaborate with team members. Comments are shown in the Comments section when you view an issue.


  1. Open the issue on which to add your comment.
  2. Click the Add a comment button.
  3. In the Comment text box, type your comment
  4. Click the Save button or the Enter key to save the comment.


`, 121 | estimate: 10, 122 | timeSpent: 2, 123 | reporterId: users[0].id, 124 | project, 125 | users: [users[1]] 126 | }), 127 | createEntity(Issue, { 128 | title: 129 | "Each issue has a single reporter but can have multiple assignees.", 130 | type: IssueType.STORY, 131 | status: IssueStatus.SELECTED, 132 | priority: IssuePriority.HIGH, 133 | listPosition: 6, 134 | description: `

Try assigning Denver to this issue. 🤣 🤣 🤣


`, 135 | estimate: 6, 136 | timeSpent: 3, 137 | reporterId: users[1].id, 138 | project, 139 | users: [users[1], users[2]] 140 | }) 141 | ]; 142 | return Promise.all(issues); 143 | }; 144 | 145 | const seedComments = (issues: Issue[], users: User[]): Promise => { 146 | const comments = [ 147 | createEntity(Comment, { 148 | body: "HAHA HAHA HAHA HAHA...", 149 | issueId: issues[1].id, 150 | userId: users[3].id 151 | }), 152 | createEntity(Comment, { 153 | body: 154 | "The good thing about relationships is that you finally forget how they started.", 155 | issueId: issues[0].id, 156 | userId: users[2].id 157 | }), 158 | createEntity(Comment, { 159 | body: "In the end, love is a good reason for everything to fall apart.", 160 | issueId: issues[0].id, 161 | userId: users[2].id 162 | }), 163 | createEntity(Comment, { 164 | body: "Love can’t be timed. It has to be lived.", 165 | issueId: issues[0].id, 166 | userId: users[0].id 167 | }), 168 | createEntity(Comment, { 169 | body: "We're gonna be RICH 🤑🤑", 170 | issueId: issues[2].id, 171 | userId: users[2].id 172 | }), 173 | createEntity(Comment, { 174 | body: "@Denver execute Monica Gaztambide\nmake an example of her", 175 | issueId: issues[3].id, 176 | userId: users[0].id 177 | }), 178 | createEntity(Comment, { 179 | body: "That's against the rules i can't do that.", 180 | issueId: issues[3].id, 181 | userId: users[3].id 182 | }), 183 | createEntity(Comment, { 184 | body: 185 | "Una mattina mi sono alzato O bella ciao, bella ciao, bella ciao, ciao, ciao", 186 | issueId: issues[4].id, 187 | userId: users[1].id 188 | }), 189 | createEntity(Comment, { 190 | body: 191 | "O partigiano, portami via O bella ciao, bella ciao, bella ciao, ciao, ciao", 192 | issueId: issues[4].id, 193 | userId: users[0].id 194 | }), 195 | createEntity(Comment, { 196 | body: 197 | "There are people who study years to earn a salary, we only go to study for five months", 198 | issueId: issues[5].id, 199 | userId: users[1].id 200 | }), 201 | createEntity(Comment, { 202 | body: 203 | "As in chess, there are times when it is necessary to sacrifice a piece to win", 204 | issueId: issues[6].id, 205 | userId: users[2].id 206 | }) 207 | ]; 208 | return Promise.all(comments); 209 | }; 210 | 211 | const createGuestAccount = async (): Promise => { 212 | const users = await seedUsers(); 213 | const project = await seedProject(users); 214 | const issues = await seedIssues(project); 215 | await seedComments(issues, project.users); 216 | return users[2]; 217 | }; 218 | 219 | export default createGuestAccount; 220 | -------------------------------------------------------------------------------- /packages/api/src/database/createTestAccount.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Issue, Project, User } from "@/models"; 2 | import { ProjectCategory } from "@/constants/project"; 3 | import { IssueType, IssueStatus, IssuePriority } from "@/constants/issue"; 4 | import { createEntity } from "@/utils/typeorm"; 5 | 6 | const seedUsers = (): Promise => { 7 | const users = [ 8 | createEntity(User, { 9 | email: "rick@jira.guest", 10 | name: "Rick Sanchez", 11 | avatarUrl: 12 | "https://res.cloudinary.com/datlyfe/image/upload/v1583417163/rick_morty/rick_abe7oc.jpg" 13 | }), 14 | createEntity(User, { 15 | email: "morty@jira.guest", 16 | name: "Morty Smith", 17 | avatarUrl: 18 | "https://res.cloudinary.com/datlyfe/image/upload/v1583417163/rick_morty/morty_n3zqiz.jpg" 19 | }) 20 | ]; 21 | return Promise.all(users); 22 | }; 23 | 24 | const seedProject = (users: User[]): Promise => 25 | createEntity(Project, { 26 | name: "Project name", 27 | url: "https://www.testurl.com", 28 | description: "Project description", 29 | category: ProjectCategory.SOFTWARE, 30 | users 31 | }); 32 | 33 | const seedIssues = (project: Project): Promise => { 34 | const { users } = project; 35 | 36 | const issues = [ 37 | createEntity(Issue, { 38 | title: "Issue title 1", 39 | type: IssueType.TASK, 40 | status: IssueStatus.BACKLOG, 41 | priority: IssuePriority.LOWEST, 42 | listPosition: 1, 43 | reporterId: users[0].id, 44 | project 45 | }), 46 | createEntity(Issue, { 47 | title: "Issue title 2", 48 | type: IssueType.TASK, 49 | status: IssueStatus.BACKLOG, 50 | priority: IssuePriority.MEDIUM, 51 | listPosition: 2, 52 | estimate: 5, 53 | description: "Issue description 2", 54 | reporterId: users[0].id, 55 | users: [users[0]], 56 | project 57 | }), 58 | createEntity(Issue, { 59 | title: "Issue title 3", 60 | type: IssueType.STORY, 61 | status: IssueStatus.SELECTED, 62 | priority: IssuePriority.HIGH, 63 | listPosition: 3, 64 | estimate: 10, 65 | description: "Issue description 3", 66 | reporterId: users[0].id, 67 | users: [users[0], users[1]], 68 | project 69 | }) 70 | ]; 71 | return Promise.all(issues); 72 | }; 73 | 74 | const seedComments = (issue: Issue, user: User): Promise => 75 | createEntity(Comment, { 76 | body: "Comment body", 77 | issueId: issue.id, 78 | userId: user.id 79 | }); 80 | 81 | const createTestAccount = async (): Promise> => { 82 | const users = await seedUsers(); 83 | const project = await seedProject(users); 84 | const issues = await seedIssues(project); 85 | await seedComments(issues[0], project.users[0]); 86 | return [project, users[0], issues[0]]; 87 | }; 88 | 89 | export default createTestAccount; 90 | -------------------------------------------------------------------------------- /packages/api/src/database/createTestConnection.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Connection, ConnectionOptions } from "typeorm"; 2 | 3 | import * as models from "@/models"; 4 | 5 | const connectionOptions: ConnectionOptions = { 6 | type: "postgres", 7 | host: process.env.DB_HOST, 8 | port: Number(process.env.DB_PORT), 9 | username: process.env.DB_USERNAME, 10 | password: process.env.DB_PASSWORD, 11 | database: process.env.DB_TEST_DATABASE, 12 | entities: Object.values(models), 13 | synchronize: true, 14 | dropSchema: true 15 | }; 16 | 17 | const createTestDatabaseConnection = (): Promise => 18 | createConnection(connectionOptions); 19 | 20 | export default createTestDatabaseConnection; 21 | -------------------------------------------------------------------------------- /packages/api/src/database/resetDatabase.ts: -------------------------------------------------------------------------------- 1 | import { getConnection } from "typeorm"; 2 | import { schedule } from "node-cron"; 3 | 4 | const resetDatabase = async (): Promise => { 5 | const connection = getConnection(); 6 | await connection.dropDatabase(); 7 | await connection.synchronize(); 8 | }; 9 | 10 | export const resetDatabaseJob = () => { 11 | schedule("0 0 * * *", () => { 12 | resetDatabase(); 13 | }); 14 | }; 15 | 16 | export default resetDatabase; 17 | -------------------------------------------------------------------------------- /packages/api/src/errors/asyncCatch.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | 3 | export const catchErrors = (requestHandler: RequestHandler): RequestHandler => { 4 | return async (req, res, next): Promise => { 5 | try { 6 | return await requestHandler(req, res, next); 7 | } catch (error) { 8 | next(error); 9 | } 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/api/src/errors/customErrors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | type ErrorData = { [key: string]: any }; 4 | 5 | export class CustomError extends Error { 6 | constructor( 7 | public message: string = "No message", 8 | public code: string | number = "INTERNAL_ERROR", 9 | public status: number = 500, 10 | public data: ErrorData = {} 11 | ) { 12 | super(); 13 | } 14 | } 15 | 16 | export class RouteNotFoundError extends CustomError { 17 | constructor(originalUrl: string) { 18 | super(`Route '${originalUrl}' does not exist.`, "ROUTE_NOT_FOUND", 404); 19 | } 20 | } 21 | 22 | export class EntityNotFoundError extends CustomError { 23 | constructor(entityName: string) { 24 | super(`${entityName} not found.`, "ENTITY_NOT_FOUND", 404); 25 | } 26 | } 27 | 28 | export class BadUserInputError extends CustomError { 29 | constructor(errorData: ErrorData) { 30 | super("There were validation errors.", "BAD_USER_INPUT", 400, errorData); 31 | } 32 | } 33 | 34 | export class InvalidTokenError extends CustomError { 35 | constructor(message = "Authentication token is invalid.") { 36 | super(message, "INVALID_TOKEN", 401); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/api/src/errors/gqlError.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFormattedError, GraphQLError } from "graphql"; 2 | import { ArgumentValidationError } from "type-graphql"; 3 | import { unwrapResolverError } from "@apollo/server/errors"; 4 | import type { ValidationError as ClassValidatorValidationError } from "class-validator"; 5 | 6 | export function formatError( 7 | formattedError: GraphQLFormattedError, 8 | error: unknown 9 | ): GraphQLFormattedError { 10 | const originalError = unwrapResolverError(error); 11 | 12 | // Log 13 | console.log( 14 | `Server error: ${ 15 | originalError instanceof Error ? originalError.message : originalError 16 | }` 17 | ); 18 | 19 | // Validation 20 | if (originalError instanceof ArgumentValidationError) { 21 | return new ValidationError(originalError.validationErrors); 22 | } 23 | 24 | // Generic 25 | return formattedError; 26 | } 27 | 28 | type IValidationError = Pick< 29 | ClassValidatorValidationError, 30 | "property" | "value" | "constraints" | "children" 31 | >; 32 | 33 | function formatValidationErrors( 34 | validationError: IValidationError 35 | ): IValidationError { 36 | return { 37 | property: validationError.property, 38 | ...(validationError.value && { value: validationError.value }), 39 | ...(validationError.constraints && { 40 | constraints: validationError.constraints, 41 | }), 42 | ...(validationError.children && 43 | validationError.children.length !== 0 && { 44 | children: validationError.children.map((child) => 45 | formatValidationErrors(child) 46 | ), 47 | }), 48 | }; 49 | } 50 | 51 | export class ValidationError extends GraphQLError { 52 | public constructor(validationErrors: ClassValidatorValidationError[]) { 53 | super("Validation Error", { 54 | extensions: { 55 | code: "BAD_USER_INPUT", 56 | validationErrors: validationErrors.map((validationError) => 57 | formatValidationErrors(validationError) 58 | ), 59 | }, 60 | }); 61 | 62 | Object.setPrototypeOf(this, ValidationError.prototype); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/api/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customErrors'; 2 | export { catchErrors } from './asyncCatch'; 3 | -------------------------------------------------------------------------------- /packages/api/src/gql/auth.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, UseMiddleware } from "type-graphql"; 2 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 3 | import createGuestAccount from "@/database/createGuestAccount"; 4 | import { signToken } from "@/utils/authToken"; 5 | 6 | @Resolver() 7 | class AuthResolver { 8 | @UseMiddleware([ErrorInterceptor]) 9 | @Query(() => String) 10 | async createGuestAccount(): Promise { 11 | const user = await createGuestAccount(); 12 | return signToken({ sub: user.id }); 13 | } 14 | } 15 | 16 | export default AuthResolver; 17 | -------------------------------------------------------------------------------- /packages/api/src/gql/comments.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg, UseMiddleware } from "type-graphql"; 2 | import { createEntity, updateEntity, deleteEntity } from "@/utils/typeorm"; 3 | import Comment from "@/models/Comment"; 4 | import { CommentInput } from "@/gql/types"; 5 | import { IsAuth } from "@/middlewares/isAuth"; 6 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 7 | 8 | @Resolver() 9 | class CommentResolver { 10 | @UseMiddleware([IsAuth, ErrorInterceptor]) 11 | @Mutation(() => Comment) 12 | async createComment( 13 | @Arg("comment") commentInput: CommentInput 14 | ): Promise { 15 | return await createEntity(Comment, commentInput); 16 | } 17 | @UseMiddleware([IsAuth, ErrorInterceptor]) 18 | @Mutation(() => Comment) 19 | async updateComment( 20 | @Arg("id") commentId: string, 21 | @Arg("comment") commentInput: CommentInput 22 | ): Promise { 23 | return await updateEntity(Comment, commentId, commentInput); 24 | } 25 | @UseMiddleware([IsAuth, ErrorInterceptor]) 26 | @Mutation(() => Comment) 27 | async deleteComment(@Arg("id") commentId: string): Promise { 28 | return await deleteEntity(Comment, commentId); 29 | } 30 | } 31 | 32 | export default CommentResolver; 33 | -------------------------------------------------------------------------------- /packages/api/src/gql/index.ts: -------------------------------------------------------------------------------- 1 | import { default as AuthResolver } from "./auth"; 2 | import { default as UserResolver } from "./user"; 3 | import { default as CommentResolver } from "./comments"; 4 | import { default as IssueResolver } from "./issues"; 5 | import { default as ProjectResolver } from "./project"; 6 | 7 | export const RESOLVERS = [ 8 | AuthResolver, 9 | UserResolver, 10 | CommentResolver, 11 | IssueResolver, 12 | ProjectResolver, 13 | ] as const; 14 | -------------------------------------------------------------------------------- /packages/api/src/gql/issues.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Arg, 4 | Query, 5 | Int, 6 | Mutation, 7 | UseMiddleware, 8 | Ctx, 9 | } from "type-graphql"; 10 | import { Issue } from "@/models"; 11 | import { 12 | findEntityOrThrow, 13 | createEntity, 14 | updateEntity, 15 | deleteEntity, 16 | } from "@/utils/typeorm"; 17 | import { IsAuth } from "@/middlewares/isAuth"; 18 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 19 | import { IssueCreateInput, IssueUpdateInput } from "@/gql/types"; 20 | import { GQLContext } from "../types/context"; 21 | 22 | const calculateListPosition = async ({ 23 | projectId, 24 | status, 25 | }: Partial): Promise => { 26 | const issues = await Issue.find({ where: { projectId, status } }); 27 | 28 | const listPositions = issues.map(({ listPosition }) => listPosition); 29 | 30 | if (listPositions.length > 0) { 31 | return Math.min(...listPositions) - 1; 32 | } 33 | return 1; 34 | }; 35 | 36 | @Resolver() 37 | class IssueResolver { 38 | @UseMiddleware([IsAuth, ErrorInterceptor]) 39 | @Query(() => [Issue]) 40 | async getProjectIssues( 41 | @Ctx() ctx: GQLContext, 42 | @Arg("searchTerm", () => String, { nullable: true }) 43 | searchTerm: string | null 44 | ): Promise { 45 | const { projectId } = ctx.req.currentUser; 46 | let whereSQL = "issue.projectId = :projectId"; 47 | 48 | if (searchTerm) { 49 | whereSQL += 50 | " AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)"; 51 | } 52 | 53 | const issues = await Issue.createQueryBuilder("issue") 54 | .select() 55 | .where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` }) 56 | .getMany(); 57 | 58 | return issues; 59 | } 60 | 61 | @UseMiddleware([IsAuth, ErrorInterceptor]) 62 | @Query(() => Issue) 63 | async getIssueWithUsersAndComments( 64 | @Arg("issueId", () => Int) issueId: number 65 | ): Promise { 66 | const issue = await findEntityOrThrow(Issue, issueId, { 67 | relations: ["users", "comments", "comments.user"], 68 | }); 69 | 70 | return issue; 71 | } 72 | 73 | @UseMiddleware([IsAuth, ErrorInterceptor]) 74 | @Mutation(() => Issue) 75 | async createIssue( 76 | @Arg("issue") issueInput: IssueCreateInput 77 | ): Promise { 78 | const listPosition = await calculateListPosition(issueInput); 79 | const issue = await createEntity(Issue, { 80 | ...issueInput, 81 | listPosition, 82 | }); 83 | return issue; 84 | } 85 | @UseMiddleware([IsAuth, ErrorInterceptor]) 86 | @Mutation(() => Issue) 87 | async updateIssue( 88 | @Arg("issue") issueInput: IssueUpdateInput, 89 | @Arg("id") issueId: number 90 | ): Promise { 91 | const issue = await updateEntity(Issue, issueId, issueInput); 92 | return issue; 93 | } 94 | @UseMiddleware([IsAuth, ErrorInterceptor]) 95 | @Mutation(() => Boolean) 96 | async deleteIssue(@Arg("id") issueId: number): Promise { 97 | await deleteEntity(Issue, issueId); 98 | return true; 99 | } 100 | } 101 | 102 | export default IssueResolver; 103 | -------------------------------------------------------------------------------- /packages/api/src/gql/plugins/sentry.ts: -------------------------------------------------------------------------------- 1 | import { captureException, withScope } from "@sentry/node"; 2 | import type { ApolloServerPlugin } from "@apollo/server"; 3 | 4 | export const apolloServerSentryPlugin: ApolloServerPlugin = { 5 | async requestDidStart() { 6 | return { 7 | async didEncounterErrors(ctx) { 8 | if (!ctx.operation) { 9 | for (const err of ctx.errors) { 10 | withScope((scope) => { 11 | scope.setExtra("query", ctx.request.query); 12 | captureException(err); 13 | }); 14 | } 15 | return; 16 | } 17 | 18 | for (const err of ctx.errors) { 19 | withScope((scope) => { 20 | scope.setTag("kind", ctx.operation?.operation ?? "unknown"); 21 | 22 | scope.setExtra("query", ctx.request.query); 23 | scope.setExtra("variables", ctx.request.variables); 24 | 25 | if (err.path) { 26 | scope.setLevel("debug"); 27 | scope.addBreadcrumb({ 28 | category: "query-path", 29 | message: err.path.join(" > "), 30 | }); 31 | } 32 | 33 | const transactionId = 34 | ctx.request?.http?.headers.get("x-transaction-id"); 35 | if (transactionId) { 36 | scope.setTransactionName(transactionId); 37 | } 38 | 39 | captureException(err); 40 | }); 41 | } 42 | }, 43 | }; 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /packages/api/src/gql/project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | Ctx, 5 | UseMiddleware, 6 | Mutation, 7 | Arg 8 | } from "type-graphql"; 9 | import { GQLContext } from "@/types/context"; 10 | import { Project } from "@/models"; 11 | import { IsAuth } from "@/middlewares/isAuth"; 12 | import { ResolveTime } from "@/middlewares/resolveTime"; 13 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 14 | import { findEntityOrThrow, updateEntity } from "@/utils/typeorm"; 15 | import { ProjectInput } from "@/gql/types"; 16 | 17 | @Resolver() 18 | class ProjectResolver { 19 | @UseMiddleware([ResolveTime, IsAuth, ErrorInterceptor]) 20 | @Query(() => Project) 21 | async getProjectWithUsersAndIssues(@Ctx() ctx: GQLContext): Promise { 22 | const project = await findEntityOrThrow( 23 | Project, 24 | ctx.req.currentUser.projectId, 25 | { 26 | relations: ["issues", "users"] 27 | } 28 | ); 29 | 30 | return project; 31 | } 32 | 33 | @UseMiddleware([IsAuth, ErrorInterceptor]) 34 | @Mutation(() => Project) 35 | async updateProject( 36 | @Ctx() ctx: GQLContext, 37 | @Arg("project") projectInput: ProjectInput 38 | ): Promise { 39 | const project = await updateEntity( 40 | Project, 41 | ctx.req.currentUser.projectId, 42 | projectInput 43 | ); 44 | 45 | return project; 46 | } 47 | } 48 | 49 | export default ProjectResolver; 50 | -------------------------------------------------------------------------------- /packages/api/src/gql/types.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field, ID, Float } from "type-graphql"; 2 | import { Comment, Issue, Project, User } from "@/models"; 3 | import { IssueType, IssueStatus, IssuePriority } from "@/constants/issue"; 4 | import { ProjectCategory } from "@/constants/project"; 5 | 6 | @InputType() 7 | export class CommentInput implements Partial { 8 | @Field({ nullable: true }) 9 | body: string; 10 | @Field(() => ID, { nullable: true }) 11 | issueId: number; 12 | @Field(() => ID, { nullable: true }) 13 | userId: string; 14 | } 15 | @InputType() 16 | export class ProjectInput implements Partial { 17 | @Field({ nullable: true }) 18 | name: string; 19 | 20 | @Field(() => String, { nullable: true }) 21 | url: string | null; 22 | 23 | @Field(() => String, { nullable: true }) 24 | description: string | null; 25 | 26 | @Field(() => String, { nullable: true }) 27 | category: ProjectCategory; 28 | } 29 | 30 | @InputType() 31 | class UserInput implements Partial { 32 | @Field(() => ID) 33 | id: string; 34 | @Field({ nullable: true }) 35 | name: string; 36 | @Field({ nullable: true }) 37 | avatarUrl: string; 38 | @Field({ nullable: true }) 39 | projectId: number; 40 | } 41 | 42 | @InputType() 43 | export class IssueUpdateInput implements Partial { 44 | @Field({ nullable: true }) 45 | title: string; 46 | @Field(() => String, { nullable: true }) 47 | type: IssueType; 48 | @Field(() => String, { nullable: true }) 49 | status: IssueStatus; 50 | @Field(() => String, { nullable: true }) 51 | priority: IssuePriority; 52 | @Field(() => Float, { nullable: true }) 53 | listPosition: number; 54 | @Field(() => ID, { nullable: true }) 55 | reporterId: string; 56 | @Field(() => ID, { nullable: true }) 57 | projectId: number; 58 | @Field(() => [UserInput], { nullable: true }) 59 | users: User[]; 60 | @Field(() => [ID], { nullable: true }) 61 | userIds: string[]; 62 | @Field(() => String, { nullable: true }) 63 | description: string | null; 64 | } 65 | 66 | @InputType() 67 | export class IssueCreateInput implements Partial { 68 | @Field() 69 | title: string; 70 | @Field(() => String) 71 | type: IssueType; 72 | @Field(() => String) 73 | status: IssueStatus; 74 | @Field(() => String) 75 | priority: IssuePriority; 76 | @Field(() => ID) 77 | reporterId: string; 78 | @Field(() => ID) 79 | projectId: number; 80 | @Field(() => [UserInput]) 81 | users: User[]; 82 | @Field(() => [ID]) 83 | userIds: string[]; 84 | @Field(() => String, { nullable: true }) 85 | description: string | null; 86 | } 87 | 88 | @InputType() 89 | export class UserCreateInput implements Partial { 90 | @Field() 91 | name: string; 92 | @Field() 93 | email: string; 94 | @Field({ defaultValue: "" }) 95 | avatarUrl: string; 96 | } 97 | -------------------------------------------------------------------------------- /packages/api/src/gql/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | UseMiddleware, 5 | Ctx, 6 | Mutation, 7 | Arg, 8 | } from "type-graphql"; 9 | import { IsAuth } from "@/middlewares/isAuth"; 10 | import { GQLContext } from "@/types/context"; 11 | import { User } from "@/models"; 12 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 13 | import { UserCreateInput } from "./types"; 14 | import { createEntity } from "../utils/typeorm"; 15 | 16 | @Resolver() 17 | class UserResolver { 18 | @Query(() => String) 19 | hello(): string { 20 | return "hello World"; 21 | } 22 | @UseMiddleware([IsAuth, ErrorInterceptor]) 23 | @Query(() => User) 24 | currentUser(@Ctx() ctx: GQLContext): User { 25 | return ctx.req.currentUser; 26 | } 27 | 28 | @UseMiddleware([ErrorInterceptor]) 29 | @Mutation(() => User) 30 | async createUser(@Arg("user") userInput: UserCreateInput): Promise { 31 | return await createEntity(User, userInput); 32 | } 33 | } 34 | 35 | export default UserResolver; 36 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | require("module-alias").addAlias("@", __dirname); 2 | import "dotenv/config"; 3 | import "reflect-metadata"; 4 | import http from "http"; 5 | import { init as SentryInit, Handlers as SentryHandlers } from "@sentry/node"; 6 | import { RewriteFrames } from "@sentry/integrations"; 7 | import Express from "express"; 8 | import cors from "cors"; 9 | import { ApolloServer } from "@apollo/server"; 10 | import { expressMiddleware } from "@apollo/server/express4"; 11 | import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; 12 | import { buildSchema } from "type-graphql"; 13 | import createDatabaseConnection from "@/database/createConnection"; 14 | import { RESOLVERS } from "@/gql"; 15 | import { apolloServerSentryPlugin } from "@/gql/plugins/sentry"; 16 | import { formatError } from "@/errors/gqlError"; 17 | 18 | const PORT = process.env.PORT || 5001; 19 | 20 | if (process.env.NODE_ENV === "production") { 21 | SentryInit({ 22 | environment: process.env.APP_ENV, 23 | release: "jira-clone-api", 24 | dsn: process.env.SENTRY_DSN, 25 | integrations: [ 26 | new RewriteFrames({ 27 | root: process.cwd(), 28 | }), 29 | ], 30 | }); 31 | } 32 | 33 | const establishDatabaseConnection = async (): Promise => { 34 | try { 35 | await createDatabaseConnection(); 36 | } catch (error) { 37 | console.log(error); 38 | } 39 | }; 40 | 41 | const initExpressGraphql = async () => { 42 | const schema = await buildSchema({ 43 | resolvers: RESOLVERS, 44 | validate: { forbidUnknownValues: false }, 45 | }).catch((err) => console.log(err)); 46 | 47 | if (!schema) { 48 | throw new Error("Could not build graphql schema"); 49 | } 50 | 51 | const app = Express(); 52 | const httpServer = http.createServer(app); 53 | 54 | const gqlServer = new ApolloServer({ 55 | schema, 56 | formatError, 57 | plugins: [ 58 | ApolloServerPluginDrainHttpServer({ httpServer }), 59 | apolloServerSentryPlugin, 60 | ], 61 | introspection: true, 62 | }); 63 | 64 | await gqlServer.start(); 65 | 66 | app.use(SentryHandlers.requestHandler()); 67 | 68 | app.use( 69 | "/graphql", 70 | cors(), 71 | Express.json(), 72 | expressMiddleware(gqlServer, { 73 | context: async ({ req, res }) => ({ req, res }), 74 | }), 75 | SentryHandlers.errorHandler() 76 | ); 77 | 78 | app.get("/", (_, res) => { 79 | res.json({ server: "jira-clone-api" }); 80 | }); 81 | 82 | httpServer.listen({ port: PORT }, () => { 83 | console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`); 84 | }); 85 | }; 86 | 87 | const bootstrap = async (): Promise => { 88 | await establishDatabaseConnection(); 89 | initExpressGraphql(); 90 | }; 91 | 92 | bootstrap(); 93 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/errorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | import { CustomError } from "@/errors"; 3 | import { GQLContext } from "@/types/context"; 4 | import { pick } from "@/utils/javascript"; 5 | 6 | export const ErrorInterceptor: MiddlewareFn = async (_, next) => { 7 | try { 8 | return await next(); 9 | } catch (error) { 10 | console.error(error); 11 | const isErrorSafeForClient = error instanceof CustomError; 12 | 13 | if (isErrorSafeForClient) { 14 | const { code, message, data, status } = pick( 15 | error as InstanceType, 16 | ["message", "code", "status", "data"] 17 | ); 18 | 19 | throw new CustomError(message, code, status, data); 20 | } 21 | 22 | throw new CustomError( 23 | "Something went wrong, please contact our support.", 24 | "INTERNAL_ERROR", 25 | 500, 26 | {} 27 | ); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/isAuth.ts: -------------------------------------------------------------------------------- 1 | import { verifyToken, getAuthTokenFromRequest } from "@/utils/authToken"; 2 | import { InvalidTokenError } from "@/errors"; 3 | import { User } from "@/models"; 4 | import { MiddlewareFn } from "type-graphql"; 5 | import { GQLContext } from "types/context"; 6 | 7 | export const IsAuth: MiddlewareFn = async ({ context }, next) => { 8 | const token = getAuthTokenFromRequest(context.req); 9 | if (!token) { 10 | throw new InvalidTokenError("Authentication token not found."); 11 | } 12 | const userId = verifyToken(token).sub; 13 | if (!userId) { 14 | throw new InvalidTokenError("Authentication token is invalid."); 15 | } 16 | const user = await User.findOneBy({ id: userId }); 17 | if (!user) { 18 | throw new InvalidTokenError( 19 | "Authentication token is invalid: User not found." 20 | ); 21 | } 22 | context.req.currentUser = user; 23 | return next(); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/resolveTime.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | 3 | export const ResolveTime: MiddlewareFn = async ({ info }, next) => { 4 | const start = Date.now(); 5 | await next(); 6 | const resolveTime = Date.now() - start; 7 | console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/api/src/models/Comment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | Entity 9 | } from "typeorm"; 10 | import is from "@/utils/validations"; 11 | import { User, Issue } from "@/models"; 12 | import { ObjectType, Field, ID } from "type-graphql"; 13 | 14 | @Entity() 15 | @ObjectType() 16 | class Comment extends BaseEntity { 17 | static validations = { 18 | body: [is.required(), is.maxLength(50000)] 19 | }; 20 | 21 | @Field(() => ID) 22 | @PrimaryGeneratedColumn() 23 | id: number; 24 | 25 | @Field() 26 | @Column("text") 27 | body: string; 28 | 29 | @Field() 30 | @CreateDateColumn({ type: "timestamp" }) 31 | createdAt: Date; 32 | 33 | @Field() 34 | @UpdateDateColumn({ type: "timestamp" }) 35 | updatedAt: Date; 36 | 37 | @Field() 38 | @Column("uuid") 39 | userId: string; 40 | 41 | @Field() 42 | @Column("integer") 43 | issueId: number; 44 | 45 | @Field(() => User) 46 | @ManyToOne( 47 | () => User, 48 | user => user.comments 49 | ) 50 | user: User; 51 | 52 | @Field(() => Issue) 53 | @ManyToOne( 54 | () => Issue, 55 | issue => issue.comments, 56 | { onDelete: "CASCADE" } 57 | ) 58 | issue: Issue; 59 | } 60 | 61 | export default Comment; 62 | -------------------------------------------------------------------------------- /packages/api/src/models/Issue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | OneToMany, 9 | ManyToMany, 10 | JoinTable, 11 | RelationId, 12 | BeforeInsert, 13 | BeforeUpdate, 14 | Entity 15 | } from "typeorm"; 16 | import { ObjectType, ID, Field, Int, Float } from "type-graphql"; 17 | import striptags from "striptags"; 18 | import is from "@/utils/validations"; 19 | import { IssueType, IssuePriority, IssueStatus } from "@/constants/issue"; 20 | import { Project, User, Comment } from "@/models"; 21 | 22 | @ObjectType() 23 | @Entity() 24 | class Issue extends BaseEntity { 25 | static validations = { 26 | title: [is.required(), is.maxLength(200)], 27 | type: [is.required(), is.oneOf(Object.values(IssueType))], 28 | status: [is.required(), is.oneOf(Object.values(IssueStatus))], 29 | priority: [is.required(), is.oneOf(Object.values(IssuePriority))], 30 | listPosition: is.required(), 31 | reporterId: is.required() 32 | }; 33 | 34 | @Field(() => ID) 35 | @PrimaryGeneratedColumn() 36 | id: number; 37 | 38 | @Field() 39 | @Column("varchar") 40 | title: string; 41 | 42 | @Field(() => String) 43 | @Column("varchar") 44 | type: IssueType; 45 | 46 | @Field(() => String) 47 | @Column("varchar") 48 | status: IssueStatus; 49 | 50 | @Field(() => String) 51 | @Column("varchar") 52 | priority: IssuePriority; 53 | 54 | @Field(() => Float) 55 | @Column("double precision") 56 | listPosition: number; 57 | 58 | @Field(() => String, { nullable: true }) 59 | @Column("text", { nullable: true }) 60 | description: string | null; 61 | 62 | @Field(() => String, { nullable: true }) 63 | @Column("text", { nullable: true }) 64 | descriptionText: string | null; 65 | 66 | @Field(() => Int, { nullable: true }) 67 | @Column("integer", { nullable: true }) 68 | estimate: number | null; 69 | 70 | @Field(() => Int, { nullable: true }) 71 | @Column("integer", { nullable: true }) 72 | timeSpent: number | null; 73 | 74 | @Field(() => Int, { nullable: true }) 75 | @Column("integer", { nullable: true }) 76 | timeRemaining: number | null; 77 | 78 | @Field() 79 | @CreateDateColumn({ type: "timestamp" }) 80 | createdAt: Date; 81 | 82 | @Field() 83 | @UpdateDateColumn({ type: "timestamp" }) 84 | updatedAt: Date; 85 | 86 | @Field() 87 | @Column("uuid") 88 | reporterId: string; 89 | 90 | @Field(() => Project) 91 | @ManyToOne( 92 | () => Project, 93 | project => project.issues 94 | ) 95 | project: Project; 96 | 97 | @Field() 98 | @Column("integer") 99 | projectId: number; 100 | 101 | @Field(() => [Comment], { defaultValue: [] }) 102 | @OneToMany( 103 | () => Comment, 104 | comment => comment.issue 105 | ) 106 | comments: Comment[]; 107 | 108 | @Field(() => [User]) 109 | @ManyToMany( 110 | () => User, 111 | user => user.issues 112 | ) 113 | @JoinTable() 114 | users: User[]; 115 | 116 | @Field(() => [ID]) 117 | @RelationId((issue: Issue) => issue.users) 118 | userIds: string[]; 119 | 120 | @BeforeInsert() 121 | @BeforeUpdate() 122 | setDescriptionText = (): void => { 123 | if (this.description) { 124 | this.descriptionText = striptags(this.description); 125 | } 126 | }; 127 | } 128 | 129 | export default Issue; 130 | -------------------------------------------------------------------------------- /packages/api/src/models/Project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | UpdateDateColumn, 6 | CreateDateColumn, 7 | OneToMany, 8 | Entity 9 | } from "typeorm"; 10 | import { ObjectType, Field, ID } from "type-graphql"; 11 | import is from "@/utils/validations"; 12 | import { ProjectCategory } from "@/constants/project"; 13 | import { User, Issue } from "@/models"; 14 | 15 | @ObjectType() 16 | @Entity() 17 | class Project extends BaseEntity { 18 | static validations = { 19 | name: [is.required(), is.maxLength(100)], 20 | url: is.url(), 21 | category: [is.required(), is.oneOf(Object.values(ProjectCategory))] 22 | }; 23 | 24 | @Field(() => ID) 25 | @PrimaryGeneratedColumn() 26 | id: number; 27 | 28 | @Field() 29 | @Column("varchar") 30 | name: string; 31 | 32 | @Field(() => String, { nullable: true }) 33 | @Column("varchar", { nullable: true }) 34 | url: string | null; 35 | 36 | @Field(() => String, { nullable: true }) 37 | @Column("text", { nullable: true }) 38 | description: string | null; 39 | 40 | @Field(() => String) 41 | @Column("varchar") 42 | category: ProjectCategory; 43 | 44 | @Field() 45 | @CreateDateColumn({ type: "timestamp" }) 46 | createdAt: Date; 47 | 48 | @Field() 49 | @UpdateDateColumn({ type: "timestamp" }) 50 | updatedAt: Date; 51 | 52 | @Field(() => [Issue], { defaultValue: [] }) 53 | @OneToMany( 54 | () => Issue, 55 | issue => issue.project 56 | ) 57 | issues: Issue[]; 58 | 59 | @Field(() => [User], { defaultValue: [] }) 60 | @OneToMany( 61 | () => User, 62 | user => user.project 63 | ) 64 | users: User[]; 65 | } 66 | 67 | export default Project; 68 | -------------------------------------------------------------------------------- /packages/api/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | OneToMany, 8 | ManyToMany, 9 | ManyToOne, 10 | RelationId, 11 | Entity 12 | } from "typeorm"; 13 | import { ObjectType, Field, ID } from "type-graphql"; 14 | import is from "@/utils/validations"; 15 | import { Issue, Project, Comment } from "@/models"; 16 | 17 | @ObjectType() 18 | @Entity() 19 | class User extends BaseEntity { 20 | static validations = { 21 | name: [is.required(), is.maxLength(100)], 22 | email: [is.required(), is.email(), is.maxLength(200)] 23 | }; 24 | 25 | @Field(() => ID) 26 | @PrimaryGeneratedColumn("uuid") 27 | id: string; 28 | 29 | @Field() 30 | @Column("varchar") 31 | name: string; 32 | 33 | @Field() 34 | @Column("varchar") 35 | email: string; 36 | 37 | @Field() 38 | @Column("varchar", { length: 2000 }) 39 | avatarUrl: string; 40 | 41 | @Field() 42 | @CreateDateColumn({ type: "timestamp" }) 43 | createdAt: Date; 44 | 45 | @Field() 46 | @UpdateDateColumn({ type: "timestamp" }) 47 | updatedAt: Date; 48 | 49 | @Field(() => [Comment]) 50 | @OneToMany( 51 | () => Comment, 52 | comment => comment.user 53 | ) 54 | comments: Comment[]; 55 | 56 | @Field(() => [Issue]) 57 | @ManyToMany( 58 | () => Issue, 59 | issue => issue.users 60 | ) 61 | issues: Issue[]; 62 | 63 | @Field(() => Project) 64 | @ManyToOne( 65 | () => Project, 66 | project => project.users 67 | ) 68 | project: Project; 69 | 70 | @Field() 71 | @RelationId((user: User) => user.project) 72 | projectId: number; 73 | } 74 | 75 | export default User; 76 | -------------------------------------------------------------------------------- /packages/api/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Comment } from "./Comment"; 2 | export { default as Issue } from "./Issue"; 3 | export { default as Project } from "./Project"; 4 | export { default as User } from "./User"; 5 | -------------------------------------------------------------------------------- /packages/api/src/types/context.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | export interface GQLContext { 3 | req: Request; 4 | res: Response; 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | DB_HOST: string; 4 | DB_PORT: string; 5 | DB_USERNAME: string; 6 | DB_PASSWORD: string; 7 | DB_DATABASE: string; 8 | DB_TEST_DATABASE: string; 9 | JWT_SECRET: string; 10 | NODE_ENV: string; 11 | SENTRY_DSN: string; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | currentUser: import("@/models").User; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/utils/authToken.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import jwt, { SignOptions } from "jsonwebtoken"; 3 | import { InvalidTokenError } from "@/errors"; 4 | 5 | var isPlainObject = (value: any) => { 6 | return Object.prototype.toString.call(value) === "[object Object]"; 7 | }; 8 | 9 | export const signToken = (payload: object, options?: SignOptions): string => 10 | jwt.sign(payload, process.env.JWT_SECRET, { 11 | expiresIn: "180 days", 12 | ...options 13 | }); 14 | 15 | export const verifyToken = (token: string): { [key: string]: any } => { 16 | try { 17 | const payload = jwt.verify(token, process.env.JWT_SECRET); 18 | 19 | if (isPlainObject(payload)) { 20 | return payload as { [key: string]: any }; 21 | } 22 | throw new Error(); 23 | } catch (error) { 24 | throw new InvalidTokenError(); 25 | } 26 | }; 27 | 28 | export const getAuthTokenFromRequest = (req: Request): string | null => { 29 | const header = req.get("Authorization") || ""; 30 | const [bearer, token] = header.split(" "); 31 | return bearer === "Bearer" && token ? token : null; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/api/src/utils/javascript.ts: -------------------------------------------------------------------------------- 1 | export const pick = >( 2 | object: T, 3 | keys: string[] 4 | ): Partial => { 5 | return keys.reduce((obj: any, key) => { 6 | if (object && object.hasOwnProperty(key)) { 7 | obj[key] = object[key]; 8 | } 9 | return obj; 10 | }, {}); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/api/src/utils/typeorm.ts: -------------------------------------------------------------------------------- 1 | import { FindOneOptions } from "typeorm/find-options/FindOneOptions"; 2 | 3 | import { Project, User, Issue, Comment } from "@/models"; 4 | import { EntityNotFoundError, BadUserInputError } from "@/errors"; 5 | import { generateErrors } from "@/utils/validations"; 6 | 7 | type EntityConstructor = 8 | | typeof Project 9 | | typeof User 10 | | typeof Issue 11 | | typeof Comment; 12 | type EntityInstance = Project | User | Issue | Comment; 13 | 14 | const entities: { [key: string]: EntityConstructor } = { 15 | Comment, 16 | Issue, 17 | Project, 18 | User, 19 | }; 20 | 21 | export const findEntityOrThrow = async ( 22 | Constructor: T, 23 | id: number | string, 24 | options?: FindOneOptions 25 | ): Promise> => { 26 | const instance = await Constructor.findOne({ where: { id }, ...options }); 27 | if (!instance) { 28 | throw new EntityNotFoundError(Constructor.name); 29 | } 30 | return instance; 31 | }; 32 | 33 | export const validateAndSaveEntity = async ( 34 | instance: T 35 | ): Promise => { 36 | const Constructor = entities[instance.constructor.name]; 37 | 38 | if ("validations" in Constructor) { 39 | const errorFields = generateErrors(instance, Constructor.validations); 40 | 41 | if (Object.keys(errorFields).length > 0) { 42 | throw new BadUserInputError({ fields: errorFields }); 43 | } 44 | } 45 | return instance.save() as Promise; 46 | }; 47 | 48 | export const createEntity = async ( 49 | Constructor: T, 50 | input: Partial> 51 | ): Promise> => { 52 | // @ts-ignore 53 | const instance = Constructor.create(input); 54 | return validateAndSaveEntity(instance as InstanceType); 55 | }; 56 | 57 | export const updateEntity = async ( 58 | Constructor: T, 59 | id: number | string, 60 | input: Partial> 61 | ): Promise> => { 62 | const instance = await findEntityOrThrow(Constructor, id); 63 | Object.assign(instance, input); 64 | return validateAndSaveEntity(instance); 65 | }; 66 | 67 | export const deleteEntity = async ( 68 | Constructor: T, 69 | id: number | string 70 | ): Promise> => { 71 | const instance = await findEntityOrThrow(Constructor, id); 72 | await instance.remove(); 73 | return instance; 74 | }; 75 | -------------------------------------------------------------------------------- /packages/api/src/utils/validations.ts: -------------------------------------------------------------------------------- 1 | type Value = any; 2 | type ErrorMessage = false | string; 3 | type FieldValues = { [key: string]: Value }; 4 | type Validator = (value: Value, fieldValues?: FieldValues) => ErrorMessage; 5 | type FieldValidators = { [key: string]: Validator | Validator[] }; 6 | type FieldErrors = { [key: string]: string }; 7 | 8 | const is = { 9 | match: (testFn: Function, message = "") => ( 10 | value: Value, 11 | fieldValues: FieldValues 12 | ): ErrorMessage => !testFn(value, fieldValues) && message, 13 | 14 | required: () => (value: Value): ErrorMessage => 15 | isNilOrEmptyString(value) && "This field is required", 16 | 17 | minLength: (min: number) => (value: Value): ErrorMessage => 18 | !!value && value.length < min && `Must be at least ${min} characters`, 19 | 20 | maxLength: (max: number) => (value: Value): ErrorMessage => 21 | !!value && value.length > max && `Must be at most ${max} characters`, 22 | 23 | oneOf: (arr: any[]) => (value: Value): ErrorMessage => 24 | !!value && !arr.includes(value) && `Must be one of: ${arr.join(", ")}`, 25 | 26 | notEmptyArray: () => (value: Value): ErrorMessage => 27 | Array.isArray(value) && 28 | value.length === 0 && 29 | "Please add at least one item", 30 | 31 | email: () => (value: Value): ErrorMessage => 32 | !!value && !/.+@.+\..+/.test(value) && "Must be a valid email", 33 | 34 | url: () => (value: Value): ErrorMessage => 35 | !!value && 36 | // eslint-disable-next-line no-useless-escape 37 | !/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test( 38 | value 39 | ) && 40 | "Must be a valid URL" 41 | }; 42 | 43 | const isNilOrEmptyString = (value: Value): boolean => 44 | value === undefined || value === null || value === ""; 45 | 46 | export const generateErrors = ( 47 | fieldValues: FieldValues, 48 | fieldValidators: FieldValidators 49 | ): FieldErrors => { 50 | const fieldErrors: FieldErrors = {}; 51 | 52 | Object.entries(fieldValidators).forEach(([fieldName, validators]) => { 53 | [validators].flat().forEach(validator => { 54 | const errorMessage = validator(fieldValues[fieldName], fieldValues); 55 | 56 | if (errorMessage !== false && !fieldErrors[fieldName]) { 57 | fieldErrors[fieldName] = errorMessage; 58 | } 59 | }); 60 | }); 61 | return fieldErrors; 62 | }; 63 | 64 | export default is; 65 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "removeComments": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictBindCallApply": true, 14 | "strictPropertyInitialization": false, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "baseUrl": "src", 23 | "paths": { 24 | "*": ["./*"], 25 | "@/*": ["./*"] 26 | }, 27 | "types": ["node"], 28 | "allowSyntheticDefaultImports": true, 29 | "esModuleInterop": true, 30 | "experimentalDecorators": true, 31 | "emitDecoratorMetadata": true, 32 | "forceConsistentCasingInFileNames": true 33 | }, 34 | "exclude": ["node_modules"], 35 | "include": ["./src/**/*.ts",] 36 | } 37 | -------------------------------------------------------------------------------- /packages/api/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "member-access": false, 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": false, 10 | "interface-name": false 11 | }, 12 | "rulesDirectory": [] 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:vue/essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "@vue/prettier", 11 | "@vue/prettier/@typescript-eslint" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 2020 15 | }, 16 | "rules": {}, 17 | "overrides": [ 18 | { 19 | "files": [ 20 | "**/__tests__/*.{j,t}s?(x)", 21 | "**/tests/unit/**/*.spec.{j,t}s?(x)" 22 | ], 23 | "env": { 24 | "jest": true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 | > web@1.0.0 build /Users/moezbouaggad/personal/jira_clone/packages/web 3 | > vue-tsc && vite build 4 | 5 | vite v4.3.9 building for production... 6 | transforming... 7 | 8 | warn - The `purge`/`content` options have changed in Tailwind CSS v3.0. 9 | warn - Update your configuration file to eliminate this warning. 10 | warn - https://tailwindcss.com/docs/upgrade-guide#configure-content-sources 11 | ✓ 477 modules transformed. 12 | rendering chunks... 13 | computing gzip size... 14 | dist/index.html 1.31 kB │ gzip: 0.49 kB 15 | dist/assets/mountains-2f37b2ef.jpg 18.82 kB 16 | dist/assets/FullIIssueDetails-1164bd8e.css 0.04 kB │ gzip: 0.06 kB 17 | dist/assets/Settings-949cda7e.css 0.28 kB │ gzip: 0.17 kB 18 | dist/assets/Board-62c35ebb.css 0.56 kB │ gzip: 0.34 kB 19 | dist/assets/index-ce56cd0d.css 50.59 kB │ gzip: 10.09 kB 20 | dist/assets/FullIIssueDetails-48cb7d41.js 0.63 kB │ gzip: 0.42 kB 21 | dist/assets/Project-25b92695.js 1.09 kB │ gzip: 0.66 kB 22 | dist/assets/Settings-460060c3.js 4.94 kB │ gzip: 2.28 kB 23 | dist/assets/Board-39ca9999.js 51.21 kB │ gzip: 17.19 kB 24 | dist/assets/index-a4d3e490.js 629.49 kB │ gzip: 190.56 kB 25 | 26 | (!) Some chunks are larger than 500 kBs after minification. Consider: 27 | - Using dynamic import() to code-split the application 28 | - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks 29 | - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. 30 | ✓ built in 2.52s 31 | -------------------------------------------------------------------------------- /packages/web/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Jira Clone 10 | 17 | 24 | 31 | 38 | 39 | 40 | 41 | 44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /packages/web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel' 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc && vite build", 8 | "tc": "vue-tsc --noEmit" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "^3.7.16", 12 | "@vue/apollo-composable": "^4.0.0-beta.7", 13 | "autosize": "^6.0.1", 14 | "color": "^4.2.3", 15 | "dayjs": "^1.8.21", 16 | "graphql": "^16.6.0", 17 | "graphql-tag": "^2.10.3", 18 | "lodash.omit": "^4.5.0", 19 | "lodash.pick": "^4.4.0", 20 | "lodash.xor": "^4.5.0", 21 | "mitt": "^3.0.0", 22 | "quill": "^1.3.7", 23 | "tailwindcss": "^3.3.2", 24 | "throttle-debounce": "^5.0.0", 25 | "tippy.js": "^6.0.3", 26 | "toastify-js": "^1.7.0", 27 | "vue": "^3.3.4", 28 | "vue-content-loader": "^2.0.1", 29 | "vue-router": "^4.2.2", 30 | "vue3-smooth-dnd": "^0.0.5" 31 | }, 32 | "devDependencies": { 33 | "@types/color": "^3.0.1", 34 | "@types/lodash.omit": "^4.5.6", 35 | "@types/lodash.pick": "^4.4.6", 36 | "@types/lodash.xor": "^4.5.6", 37 | "@types/quill": "^2.0.3", 38 | "@types/throttle-debounce": "^5.0.0", 39 | "@vitejs/plugin-vue": "^4.2.3", 40 | "autoprefixer": "^10.4.14", 41 | "postcss": "^8.4.24", 42 | "prettier": "^2.8.8", 43 | "resize-observer-polyfill": "^1.5.1", 44 | "sass": "^1.63.5", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.3.9", 47 | "vue-tsc": "^1.8.0" 48 | } 49 | } -------------------------------------------------------------------------------- /packages/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /packages/web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /packages/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/favicon.png -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Black.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Black.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Bold.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Bold.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Book.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Book.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Medium.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/public/fonts/CircularStd-Medium.woff2 -------------------------------------------------------------------------------- /packages/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | User-agent: * 3 | Disallow: -------------------------------------------------------------------------------- /packages/web/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 76 | -------------------------------------------------------------------------------- /packages/web/src/assets/img/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/72c7a35b3679a30bd8de0edf10d396bf627b7065/packages/web/src/assets/img/mountains.jpg -------------------------------------------------------------------------------- /packages/web/src/auth/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { createGuestAccount, fetchMe } from '../graphql/queries/auth' 2 | import { apolloClient } from '../graphql/client' 3 | import { storeAuthToken } from '../utils/authToken' 4 | import store from '../store' 5 | 6 | export const authenticate = async () => { 7 | try { 8 | const result = await apolloClient.query<{ createGuestAccount: string }>({ 9 | query: createGuestAccount 10 | }) 11 | const { createGuestAccount: authToken } = result.data 12 | storeAuthToken(authToken) 13 | store.mutations.setIsAuthenticated(true) 14 | const currentUser = await fetchMe() 15 | store.mutations.setCurrentUser(currentUser) 16 | } catch (error) { 17 | // toast.error(error); 18 | console.error(error) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/components/ErrorPage.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | 28 | 41 | -------------------------------------------------------------------------------- /packages/web/src/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 234 | 235 | 240 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Confirm.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 64 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Modal.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 66 | 67 | 110 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Modals.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 127 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/NavbarLeft.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 134 | 135 | 140 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Navigation.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 38 | 39 | 48 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Resizer.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 44 | 45 | 138 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Sidebar.ts: -------------------------------------------------------------------------------- 1 | export const navLinks = [ 2 | { 3 | name: 'Kanban Board', 4 | icon: 'board', 5 | to: { name: 'board' } 6 | }, 7 | { 8 | name: 'Project settings', 9 | icon: 'cog', 10 | to: { name: 'settings' } 11 | }, 12 | { 13 | name: 'Releases', 14 | icon: 'ship' 15 | }, 16 | { 17 | name: 'Issues and filters', 18 | icon: 'filters' 19 | }, 20 | { 21 | name: 'Pages', 22 | icon: 'page' 23 | }, 24 | { 25 | name: 'Reports', 26 | icon: 'report' 27 | }, 28 | { 29 | name: 'Components', 30 | icon: 'component' 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 71 | 72 | 114 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Filters.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 111 | 112 | 120 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/Issue.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 89 | 90 | 101 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/AssigneesReporter.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Comment.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 205 | 206 | 216 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Description.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Priority.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Status.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Title.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 48 | 49 | 54 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Type.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueSearch/IssueSearch.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueSearch/SearchResult.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/IssueLoader.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Lists/List.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 135 | 136 | 143 | 144 | 159 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Lists/Lists.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 108 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 62 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Breadcrumbs/Breadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Button/Button.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 79 | 80 | 157 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Icon/Icon.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Input/Input.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 60 | 61 | 138 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Select/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 272 | 273 | 329 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/TextEditor/TextEditor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 106 | 107 | 145 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/TextEditor/editor.ts: -------------------------------------------------------------------------------- 1 | import { QuillOptionsStatic } from 'quill' 2 | 3 | export const quillConfig: QuillOptionsStatic = { 4 | theme: 'snow', 5 | modules: { 6 | toolbar: [ 7 | ['bold', 'italic', 'underline', 'strike'], 8 | ['blockquote', 'code-block'], 9 | [{ list: 'ordered' }, { list: 'bullet' }], 10 | [{ header: [1, 2, 3, 4, 5, 6, false] }], 11 | [{ color: [] }, { background: [] }], 12 | ['clean'] 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Textarea/Textarea.vue: -------------------------------------------------------------------------------- 1 |