diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa6162 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +

A simplified Jira clone built with React and Node

+ +
Auto formatted with Prettier, tested with Cypress 🎗
+ +

+ Visit the live app | + View client | + View API +

+ +![Tech logos](https://i.ibb.co/w4Y9K8Z/tech.jpg) + +![App screenshot](https://i.ibb.co/HDwwh6L/jira-clone-optimized.jpg) + +## Setting up development environment + +1. Install postgreSQL if you don't have it already and create a database named `jira_development`. +2. `git clone https://github.com/oldboyxx/jira_clone.git` +3. Create an empty `.env` file in `/api`, copy `/api/.env.example` contents into it, and fill in your database username and password. +4. `npm run install-dependencies` +5. `cd api && npm start` +6. `cd client && npm start` in another terminal tab +7. App should now be running on `http://localhost:8080/` + +## Running cypress end-to-end tests + +1. Set up development environment +2. Create a database named `jira_test` and start the api with `cd api && npm run start:test` +3. `cd client && npm run test:cypress` + +
+ +

+ Visit the live app | + View client | + View API +

diff --git a/api/README.md b/api/README.md index e643c1b..1c105a6 100644 --- a/api/README.md +++ b/api/README.md @@ -1 +1,40 @@ -# Jira clone API +# Jira clone API built with Node/TypeScript + +The API codebase is fairly simple and should be easy enough to understand. + +### Project structure + +
+ + +| File or folder | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.ts` | The entry file. This is where we setup middleware, attach routes, initialize database and express. | +| `src/routes.ts` | This is where we define all routes, both public and private. | +| `src/constants` | Constants are values that never change and are used in multiple places across the codebase. | +| `src/controllers` | Controllers listen to client's requests and work with entities and the database to fetch, add, update, or delete data. | +| `src/database` | Database related code and seeds go here. | +| `src/entities` | This is where we put TypeORM entities, you could think of them as models. We define columns, relations, validations for each database entity. | +| `src/errors` | This is where we define custom errors. The `catchErrors` function helps us avoid repetitive `try/catch` blocks within controllers. | +| `src/middleware` | Middleware functions can modify request and response objects, end the request-response cycle, etc. For example `authenticateUser` method verifies the authorization token and attaches `currentUser` to the request object. | +| `src/serializers` | Serializers transform the data fetched from the database before it's sent to the client. | +| `src/utils` | Utility(helper) functions that are used in multiple places across the codebase. For example `utils/typeorm.ts` functions help us validate data and avoid writing repetitive code. | + + +
+ +### What's missing? + +There are features missing from this showcase API which should exist in a real product: + +#### Migrations + +We're currently using TypeORM's `synchronize` feature which auto creates the database schema on every application launch. It's fine to do this in a showcase product or during early development while the product is not used by anyone, but before going live with a real product, we should [introduce migrations](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md). + +#### Proper authentication system + +We currently auto create an auth token and seed a project with issues and users for anyone who visits the API without valid credentials. In a real product we'd want to implement a proper [email and password authentication system](https://www.google.com/search?q=email+and+password+authentication+node+js&oq=email+and+password+authentication+node+js). + +#### Unit/Integration tests + +This API is currently tested by the Client through [end-to-end Cypress tests](https://github.com/oldboyxx/jira_clone/tree/master/client/cypress/integration). That's good enough for a relatively simple application such as this, even if it was a real product. However, as the API grows in complexity, it might be wise to start writing additional API-specific unit/integration tests. diff --git a/api/package.json b/api/package.json index f07e59f..b20b5ce 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,7 @@ "license": "MIT", "scripts": { "start": "nodemon --exec ts-node --files src/index.ts", - "start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' && npm start", + "start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' npm start", "start:production": "cross-env NODE_ENV=production pm2 start node -- -r ./tsconfig-paths.js build/index.js", "build": "cd src && tsc", "pre-commit": "lint-staged" diff --git a/api/src/controllers/authentication.ts b/api/src/controllers/authentication.ts index cd10d01..beff132 100644 --- a/api/src/controllers/authentication.ts +++ b/api/src/controllers/authentication.ts @@ -1,19 +1,10 @@ -import express from 'express'; - import { catchErrors } from 'errors'; import { signToken } from 'utils/authToken'; -import createGuestAccount from 'database/createGuestAccount'; +import createAccount from 'database/createGuestAccount'; -const router = express.Router(); - -router.post( - '/authentication/guest', - catchErrors(async (_req, res) => { - const user = await createGuestAccount(); - res.respond({ - authToken: signToken({ sub: user.id }), - }); - }), -); - -export default router; +export const createGuestAccount = catchErrors(async (_req, res) => { + const user = await createAccount(); + res.respond({ + authToken: signToken({ sub: user.id }), + }); +}); diff --git a/api/src/controllers/comments.ts b/api/src/controllers/comments.ts index 3b4087c..21ef28f 100644 --- a/api/src/controllers/comments.ts +++ b/api/src/controllers/comments.ts @@ -1,33 +1,18 @@ -import express from 'express'; - import { Comment } from 'entities'; import { catchErrors } from 'errors'; import { updateEntity, deleteEntity, createEntity } from 'utils/typeorm'; -const router = express.Router(); +export const create = catchErrors(async (req, res) => { + const comment = await createEntity(Comment, req.body); + res.respond({ comment }); +}); -router.post( - '/comments', - catchErrors(async (req, res) => { - const comment = await createEntity(Comment, req.body); - res.respond({ comment }); - }), -); +export const update = catchErrors(async (req, res) => { + const comment = await updateEntity(Comment, req.params.commentId, req.body); + res.respond({ comment }); +}); -router.put( - '/comments/:commentId', - catchErrors(async (req, res) => { - const comment = await updateEntity(Comment, req.params.commentId, req.body); - res.respond({ comment }); - }), -); - -router.delete( - '/comments/:commentId', - catchErrors(async (req, res) => { - const comment = await deleteEntity(Comment, req.params.commentId); - res.respond({ comment }); - }), -); - -export default router; +export const remove = catchErrors(async (req, res) => { + const comment = await deleteEntity(Comment, req.params.commentId); + res.respond({ comment }); +}); diff --git a/api/src/controllers/issues.ts b/api/src/controllers/issues.ts index b83edff..958626f 100644 --- a/api/src/controllers/issues.ts +++ b/api/src/controllers/issues.ts @@ -1,66 +1,47 @@ -import express from 'express'; - import { Issue } from 'entities'; import { catchErrors } from 'errors'; import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm'; -const router = express.Router(); +export const getProjectIssues = catchErrors(async (req, res) => { + const { projectId } = req.currentUser; + const { searchTerm } = req.query; -router.get( - '/issues', - catchErrors(async (req, res) => { - const { projectId } = req.currentUser; - const { searchTerm } = req.query; + let whereSQL = 'issue.projectId = :projectId'; - let whereSQL = 'issue.projectId = :projectId'; + if (searchTerm) { + whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)'; + } - if (searchTerm) { - whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)'; - } + const issues = await Issue.createQueryBuilder('issue') + .select() + .where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` }) + .getMany(); - const issues = await Issue.createQueryBuilder('issue') - .select() - .where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` }) - .getMany(); + res.respond({ issues }); +}); - res.respond({ issues }); - }), -); +export const getIssueWithUsersAndComments = catchErrors(async (req, res) => { + const issue = await findEntityOrThrow(Issue, req.params.issueId, { + relations: ['users', 'comments', 'comments.user'], + }); + res.respond({ issue }); +}); -router.get( - '/issues/:issueId', - catchErrors(async (req, res) => { - const issue = await findEntityOrThrow(Issue, req.params.issueId, { - relations: ['users', 'comments', 'comments.user'], - }); - res.respond({ issue }); - }), -); +export const create = catchErrors(async (req, res) => { + const listPosition = await calculateListPosition(req.body); + const issue = await createEntity(Issue, { ...req.body, listPosition }); + res.respond({ issue }); +}); -router.post( - '/issues', - catchErrors(async (req, res) => { - const listPosition = await calculateListPosition(req.body); - const issue = await createEntity(Issue, { ...req.body, listPosition }); - res.respond({ issue }); - }), -); +export const update = catchErrors(async (req, res) => { + const issue = await updateEntity(Issue, req.params.issueId, req.body); + res.respond({ issue }); +}); -router.put( - '/issues/:issueId', - catchErrors(async (req, res) => { - const issue = await updateEntity(Issue, req.params.issueId, req.body); - res.respond({ issue }); - }), -); - -router.delete( - '/issues/:issueId', - catchErrors(async (req, res) => { - const issue = await deleteEntity(Issue, req.params.issueId); - res.respond({ issue }); - }), -); +export const remove = catchErrors(async (req, res) => { + const issue = await deleteEntity(Issue, req.params.issueId); + res.respond({ issue }); +}); const calculateListPosition = async ({ projectId, status }: Issue): Promise => { const issues = await Issue.find({ projectId, status }); @@ -72,5 +53,3 @@ const calculateListPosition = async ({ projectId, status }: Issue): Promise { + const project = await findEntityOrThrow(Project, req.currentUser.projectId, { + relations: ['users', 'issues'], + }); + res.respond({ + project: { + ...project, + issues: project.issues.map(issuePartial), + }, + }); +}); -router.get( - '/project', - catchErrors(async (req, res) => { - const project = await findEntityOrThrow(Project, req.currentUser.projectId, { - relations: ['users', 'issues'], - }); - res.respond({ - project: { - ...project, - issues: project.issues.map(issuePartial), - }, - }); - }), -); - -router.put( - '/project', - catchErrors(async (req, res) => { - const project = await updateEntity(Project, req.currentUser.projectId, req.body); - res.respond({ project }); - }), -); - -export default router; +export const update = catchErrors(async (req, res) => { + const project = await updateEntity(Project, req.currentUser.projectId, req.body); + res.respond({ project }); +}); diff --git a/api/src/controllers/test.ts b/api/src/controllers/test.ts index bb3733d..be3a93e 100644 --- a/api/src/controllers/test.ts +++ b/api/src/controllers/test.ts @@ -1,28 +1,16 @@ -import express from 'express'; - import { catchErrors } from 'errors'; import { signToken } from 'utils/authToken'; -import resetDatabase from 'database/resetDatabase'; +import resetTestDatabase from 'database/resetDatabase'; import createTestAccount from 'database/createTestAccount'; -const router = express.Router(); +export const resetDatabase = catchErrors(async (_req, res) => { + await resetTestDatabase(); + res.respond(true); +}); -router.delete( - '/test/reset-database', - catchErrors(async (_req, res) => { - await resetDatabase(); - res.respond(true); - }), -); - -router.post( - '/test/create-account', - catchErrors(async (_req, res) => { - const user = await createTestAccount(); - res.respond({ - authToken: signToken({ sub: user.id }), - }); - }), -); - -export default router; +export const createAccount = catchErrors(async (_req, res) => { + const user = await createTestAccount(); + res.respond({ + authToken: signToken({ sub: user.id }), + }); +}); diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 6d5f57e..e08e45f 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -1,14 +1,5 @@ -import express from 'express'; - import { catchErrors } from 'errors'; -const router = express.Router(); - -router.get( - '/currentUser', - catchErrors((req, res) => { - res.respond({ currentUser: req.currentUser }); - }), -); - -export default router; +export const getCurrentUser = catchErrors((req, res) => { + res.respond({ currentUser: req.currentUser }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index fa18eed..b8ac2da 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,15 +5,12 @@ import express from 'express'; import cors from 'cors'; import createDatabaseConnection from 'database/createConnection'; +import { addRespondToResponse } from 'middleware/response'; import { authenticateUser } from 'middleware/authentication'; -import authenticationRoutes from 'controllers/authentication'; -import commentsRoutes from 'controllers/comments'; -import issuesRoutes from 'controllers/issues'; -import projectsRoutes from 'controllers/projects'; -import testRoutes from 'controllers/test'; -import usersRoutes from 'controllers/users'; +import { handleError } from 'middleware/errors'; import { RouteNotFoundError } from 'errors'; -import { errorHandler } from 'errors/errorHandler'; + +import { attachPublicRoutes, attachPrivateRoutes } from './routes'; const establishDatabaseConnection = async (): Promise => { try { @@ -31,28 +28,16 @@ const initializeExpress = (): void => { app.use(express.json()); app.use(express.urlencoded()); - app.use((_req, res, next) => { - res.respond = (data): void => { - res.status(200).send(data); - }; - next(); - }); + app.use(addRespondToResponse); - if (process.env.NODE_ENV === 'test') { - app.use('/', testRoutes); - } - - app.use('/', authenticationRoutes); + attachPublicRoutes(app); app.use('/', authenticateUser); - app.use('/', commentsRoutes); - app.use('/', issuesRoutes); - app.use('/', projectsRoutes); - app.use('/', usersRoutes); + attachPrivateRoutes(app); app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl))); - app.use(errorHandler); + app.use(handleError); app.listen(PORT, () => console.log(`App listening on port ${PORT}`)); }; diff --git a/api/src/errors/errorHandler.ts b/api/src/middleware/errors.ts similarity index 86% rename from api/src/errors/errorHandler.ts rename to api/src/middleware/errors.ts index 3f2d670..09ab675 100644 --- a/api/src/errors/errorHandler.ts +++ b/api/src/middleware/errors.ts @@ -3,7 +3,7 @@ import { pick } from 'lodash'; import { CustomError } from 'errors'; -export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => { +export const handleError: ErrorRequestHandler = (error, _req, res, _next) => { console.error(error); const isErrorSafeForClient = error instanceof CustomError; diff --git a/api/src/middleware/response.ts b/api/src/middleware/response.ts new file mode 100644 index 0000000..200007e --- /dev/null +++ b/api/src/middleware/response.ts @@ -0,0 +1,8 @@ +import { RequestHandler } from 'express'; + +export const addRespondToResponse: RequestHandler = (_req, res, next) => { + res.respond = (data): void => { + res.status(200).send(data); + }; + next(); +}; diff --git a/api/src/routes.ts b/api/src/routes.ts new file mode 100644 index 0000000..f3bfeae --- /dev/null +++ b/api/src/routes.ts @@ -0,0 +1,32 @@ +import * as authentication from 'controllers/authentication'; +import * as comments from 'controllers/comments'; +import * as issues from 'controllers/issues'; +import * as projects from 'controllers/projects'; +import * as test from 'controllers/test'; +import * as users from 'controllers/users'; + +export const attachPublicRoutes = (app: any): void => { + if (process.env.NODE_ENV === 'test') { + app.delete('/test/reset-database', test.resetDatabase); + app.post('/test/create-account', test.createAccount); + } + + app.post('/authentication/guest', authentication.createGuestAccount); +}; + +export const attachPrivateRoutes = (app: any): void => { + app.post('/comments', comments.create); + app.put('/comments/:commentId', comments.update); + app.delete('/comments/:commentId', comments.remove); + + app.get('/issues', issues.getProjectIssues); + app.get('/issues/:issueId', issues.getIssueWithUsersAndComments); + app.post('/issues', issues.create); + app.put('/issues/:issueId', issues.update); + app.delete('/issues/:issueId', issues.remove); + + app.get('/project', projects.getProjectWithUsersAndIssues); + app.put('/project', projects.update); + + app.get('/currentUser', users.getCurrentUser); +}; diff --git a/client/README.md b/client/README.md index e69de29..22aff04 100644 --- a/client/README.md +++ b/client/README.md @@ -0,0 +1,16 @@ +# Project structure + +I've used this architecture on multiple larger projects in the past and it performed really well. + +There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules. + +The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module. + +| File or folder | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. | +| `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. | +| `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. | +| `src/Auth` | Authentication module | +| `src/Project` | Project module | +| `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. | diff --git a/client/cypress/integration/authentication.spec.js b/client/cypress/integration/authentication.spec.js index 4ed29a8..9ceb1bd 100644 --- a/client/cypress/integration/authentication.spec.js +++ b/client/cypress/integration/authentication.spec.js @@ -16,6 +16,6 @@ describe('Authentication', () => { .should('be.a', 'string') .and('not.be.empty'); - cy.get(testid`list-issue`).should('have.length', 7); + cy.get(testid`list-issue`).should('have.length', 8); }); });