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 🎗
+
+
+
+
+
+
+
+## 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`
+
+
+
+
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);
});
});