Implemented basic entities, routes, seed scripts
This commit is contained in:
@@ -21,9 +21,13 @@
|
||||
"radix": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"no-await-in-loop": 0,
|
||||
"no-console": 0,
|
||||
"consistent-return": 0,
|
||||
"@typescript-eslint/no-unused-vars": 0,
|
||||
"@typescript-eslint/no-use-before-define": 0,
|
||||
"import/prefer-default-export": 0
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"import/prefer-default-export": 0,
|
||||
"import/no-cycle": 0
|
||||
},
|
||||
"settings": {
|
||||
// Allows us to use absolute imports within codebase
|
||||
|
||||
1473
package-lock.json
generated
1473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -5,12 +5,33 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "nodemon --exec ts-node --files src/index.ts",
|
||||
"db-seed": "nodemon --exec ts-node --files src/database/seeds/development/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"module-alias": "^2.2.2"
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-async-handler": "^1.1.4",
|
||||
"faker": "^4.1.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash": "^4.17.15",
|
||||
"module-alias": "^2.2.2",
|
||||
"pg": "^7.14.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"typeorm": "^0.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^2.8.0",
|
||||
"@typescript-eslint/parser": "^2.8.0",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.2",
|
||||
"@types/faker": "^4.1.7",
|
||||
"@types/jsonapi-serializer": "^3.6.2",
|
||||
"@types/jsonwebtoken": "^8.3.5",
|
||||
"@types/lodash": "^4.14.149",
|
||||
"@types/node": "^12.12.11",
|
||||
"@typescript-eslint/eslint-plugin": "^2.7.0",
|
||||
"@typescript-eslint/parser": "^2.7.0",
|
||||
"eslint": "^6.1.0",
|
||||
"eslint-config-airbnb-base": "^14.0.0",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
@@ -23,9 +44,6 @@
|
||||
"ts-node": "^8.5.2",
|
||||
"typescript": "^3.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "nodemon --exec ts-node --files src/index.ts"
|
||||
},
|
||||
"_moduleDirectories": [
|
||||
"src"
|
||||
],
|
||||
|
||||
20
src/constants/issues.ts
Normal file
20
src/constants/issues.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export enum IssueType {
|
||||
TASK = 'task',
|
||||
BUG = 'bug',
|
||||
STORY = 'story',
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
BACKLOG = 'backlog',
|
||||
SELECTED = 'selected',
|
||||
INPROGRESS = 'inprogress',
|
||||
DONE = 'done',
|
||||
}
|
||||
|
||||
export enum IssuePriority {
|
||||
HIGHEST = '5',
|
||||
HIGH = '4',
|
||||
MEDIUM = '3',
|
||||
LOW = '2',
|
||||
LOWEST = '1',
|
||||
}
|
||||
5
src/constants/projects.ts
Normal file
5
src/constants/projects.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ProjectCategory {
|
||||
SOFTWARE = 'software',
|
||||
MARKETING = 'marketing',
|
||||
BUSINESS = 'business',
|
||||
}
|
||||
41
src/controllers/issues.ts
Normal file
41
src/controllers/issues.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import express from 'express';
|
||||
|
||||
import { Issue } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/issues/:issueId',
|
||||
catchErrors(async (req, res) => {
|
||||
const issue = await findEntityOrThrow(Issue, req.params.issueId);
|
||||
res.respond({ issue });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/issues',
|
||||
catchErrors(async (req, res) => {
|
||||
const issue = await createEntity(Issue, 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 default router;
|
||||
51
src/controllers/projects.ts
Normal file
51
src/controllers/projects.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import express from 'express';
|
||||
|
||||
import { Project } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { findEntityOrThrow, updateEntity, deleteEntity, createEntity } from 'utils/typeorm';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/projects',
|
||||
catchErrors(async (_req, res) => {
|
||||
const projects = await Project.find();
|
||||
res.respond({ projects });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/projects/:projectId',
|
||||
catchErrors(async (req, res) => {
|
||||
const project = await findEntityOrThrow(Project, req.params.projectId, {
|
||||
relations: ['users', 'issues', 'issues.comments'],
|
||||
});
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/projects',
|
||||
catchErrors(async (req, res) => {
|
||||
const project = await createEntity(Project, req.body);
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/projects/:projectId',
|
||||
catchErrors(async (req, res) => {
|
||||
const project = await updateEntity(Project, req.params.projectId, req.body);
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/projects/:projectId',
|
||||
catchErrors(async (req, res) => {
|
||||
const project = await deleteEntity(Project, req.params.projectId);
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
22
src/controllers/users.ts
Normal file
22
src/controllers/users.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import express from 'express';
|
||||
|
||||
import { User } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { createEntity } from 'utils/typeorm';
|
||||
import { signToken } from 'utils/authToken';
|
||||
import seedGuestUserEntities from 'database/seeds/guestUser';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/users/guest',
|
||||
catchErrors(async (req, res) => {
|
||||
const user = await createEntity(User, req.body);
|
||||
await seedGuestUserEntities(user);
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
16
src/database/connection.ts
Normal file
16
src/database/connection.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createConnection, Connection } from 'typeorm';
|
||||
|
||||
import * as entities from 'entities';
|
||||
|
||||
const createDatabaseConnection = (): Promise<Connection> =>
|
||||
createConnection({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST,
|
||||
port: Number(process.env.DB_PORT),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
entities: Object.values(entities),
|
||||
});
|
||||
|
||||
export default createDatabaseConnection;
|
||||
10
src/database/seeds/development/comment.ts
Normal file
10
src/database/seeds/development/comment.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import faker from 'faker';
|
||||
|
||||
import Comment from 'entities/Comment';
|
||||
|
||||
const generateComment = (data: Partial<Comment> = {}): Partial<Comment> => ({
|
||||
body: faker.lorem.paragraph(),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateComment;
|
||||
68
src/database/seeds/development/index.ts
Normal file
68
src/database/seeds/development/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'module-alias/register';
|
||||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
import { times, sample } from 'lodash';
|
||||
|
||||
import { Comment, Issue, Project, User } from 'entities';
|
||||
import createDatabaseConnection from 'database/connection';
|
||||
import { createEntity } from 'utils/typeorm';
|
||||
import generateUser from './user';
|
||||
import generateProject from './project';
|
||||
import generateIssue from './issue';
|
||||
import generateComment from './comment';
|
||||
|
||||
const seedUsers = (): Promise<User[]> => {
|
||||
const users = times(4, () => createEntity(User, generateUser()));
|
||||
return Promise.all(users);
|
||||
};
|
||||
|
||||
const seedProjects = (users: User[]): Promise<Project[]> => {
|
||||
const projects = times(2, () => createEntity(Project, generateProject({ users })));
|
||||
return Promise.all(projects);
|
||||
};
|
||||
|
||||
const seedIssues = (projects: Project[]): Promise<Issue[]> => {
|
||||
const issues = projects
|
||||
.map(project =>
|
||||
times(10, () =>
|
||||
createEntity(
|
||||
Issue,
|
||||
generateIssue({
|
||||
reporterId: (sample(project.users) as User).id,
|
||||
project,
|
||||
users: [sample(project.users) as User],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
return Promise.all(issues);
|
||||
};
|
||||
|
||||
const seedComments = (issues: Issue[]): Promise<Comment[]> => {
|
||||
const comments = issues.map(issue =>
|
||||
createEntity(Comment, generateComment({ issue, user: sample(issue.project.users) })),
|
||||
);
|
||||
return Promise.all(comments);
|
||||
};
|
||||
|
||||
const seedEntities = async (): Promise<void> => {
|
||||
const users = await seedUsers();
|
||||
const projects = await seedProjects(users);
|
||||
const issues = await seedIssues(projects);
|
||||
await seedComments(issues);
|
||||
};
|
||||
|
||||
const initializeSeed = async (): Promise<void> => {
|
||||
try {
|
||||
const Connection = await createDatabaseConnection();
|
||||
await Connection.dropDatabase();
|
||||
await Connection.synchronize();
|
||||
await seedEntities();
|
||||
console.log('Seeding completed!');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeSeed();
|
||||
17
src/database/seeds/development/issue.ts
Normal file
17
src/database/seeds/development/issue.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import faker from 'faker';
|
||||
import { sample, random } from 'lodash';
|
||||
|
||||
import Issue from 'entities/Issue';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
|
||||
const generateIssue = (data: Partial<Issue> = {}): Partial<Issue> => ({
|
||||
title: faker.company.catchPhrase(),
|
||||
type: sample(Object.values(IssueType)),
|
||||
status: sample(Object.values(IssueStatus)),
|
||||
priority: sample(Object.values(IssuePriority)),
|
||||
description: faker.lorem.paragraph(),
|
||||
estimate: random(1, 40),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateIssue;
|
||||
15
src/database/seeds/development/project.ts
Normal file
15
src/database/seeds/development/project.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import faker from 'faker';
|
||||
import { sample } from 'lodash';
|
||||
|
||||
import Project from 'entities/Project';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
|
||||
const generateProject = (data: Partial<Project> = {}): Partial<Project> => ({
|
||||
name: faker.company.companyName(),
|
||||
url: faker.internet.url(),
|
||||
description: faker.lorem.paragraph(),
|
||||
category: sample(Object.values(ProjectCategory)),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateProject;
|
||||
11
src/database/seeds/development/user.ts
Normal file
11
src/database/seeds/development/user.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import faker from 'faker';
|
||||
|
||||
import User from 'entities/User';
|
||||
|
||||
const generateUser = (data: Partial<User> = {}): Partial<User> => ({
|
||||
name: faker.company.companyName(),
|
||||
email: faker.internet.email(),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateUser;
|
||||
106
src/database/seeds/guestUser.ts
Normal file
106
src/database/seeds/guestUser.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Comment, Issue, Project, User } from 'entities';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
import { createEntity } from 'utils/typeorm';
|
||||
|
||||
const seedProject = (user: User): Promise<Project> =>
|
||||
createEntity(Project, {
|
||||
name: 'Project: Hello World',
|
||||
url: 'https://www.atlassian.com/software/jira',
|
||||
description:
|
||||
'Plan, track, and manage your agile and software development projects in Jira. Customize your workflow, collaborate, and release great software.',
|
||||
category: ProjectCategory.SOFTWARE,
|
||||
users: [user],
|
||||
});
|
||||
|
||||
const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
const user = project.users[0];
|
||||
const issues = [
|
||||
createEntity(Issue, {
|
||||
title: 'This is an issue of type: Task.',
|
||||
type: IssueType.TASK,
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.LOWEST,
|
||||
estimate: 8,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
users: [user],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: "Click on an issue to see what's behind it.",
|
||||
type: IssueType.TASK,
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.LOW,
|
||||
description: 'Nothing in particular.',
|
||||
estimate: 40,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'Try dragging and sorting issues.',
|
||||
type: IssueType.BUG,
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 15,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
users: [user],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You can use markdown for issue descriptions.',
|
||||
type: IssueType.STORY,
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.HIGH,
|
||||
description:
|
||||
"#### Colons can be used to align columns.\n\n| Tables | Are | Cool |\n| ------------- |:-------------:| -----:|\n| col 3 is | right-aligned | |\n| col 2 is | centered | |\n| zebra stripes | are neat | |\n\nThe outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.\n\nMarkdown | Less | Pretty\n--- | --- | ---\n*Still* | `renders` | **nicely**\n1 | 2 | 3",
|
||||
estimate: 4,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
users: [user],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You must assign priority from lowest to highest to all issues.',
|
||||
type: IssueType.TASK,
|
||||
status: IssueStatus.SELECTED,
|
||||
priority: IssuePriority.HIGHEST,
|
||||
estimate: 15,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You can assign labels to issues.',
|
||||
type: IssueType.STORY,
|
||||
status: IssueStatus.SELECTED,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 55,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
users: [user],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'Try leaving a comment on this issue.',
|
||||
type: IssueType.TASK,
|
||||
status: IssueStatus.SELECTED,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 12,
|
||||
reporterId: user.id,
|
||||
project,
|
||||
}),
|
||||
];
|
||||
return Promise.all(issues);
|
||||
};
|
||||
|
||||
const seedComments = (issue: Issue): Promise<Comment> =>
|
||||
createEntity(Comment, {
|
||||
body: "Be nice to each other! Don't be mean to each other!",
|
||||
issue,
|
||||
user: issue.users[0],
|
||||
});
|
||||
|
||||
const seedGuestUserEntities = async (user: User): Promise<void> => {
|
||||
const project = await seedProject(user);
|
||||
const issues = await seedIssues(project);
|
||||
await seedComments(issues[issues.length - 1]);
|
||||
};
|
||||
|
||||
export default seedGuestUserEntities;
|
||||
49
src/entities/Comment.ts
Normal file
49
src/entities/Comment.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
RelationId,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
import { Issue, User } from '.';
|
||||
|
||||
@Entity()
|
||||
class Comment extends BaseEntity {
|
||||
static validations = {
|
||||
body: [is.required(), is.maxLength(50000)],
|
||||
};
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column('text')
|
||||
body: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(
|
||||
() => User,
|
||||
user => user.comments,
|
||||
)
|
||||
user: User;
|
||||
|
||||
@RelationId((comment: Comment) => comment.user)
|
||||
userId: number;
|
||||
|
||||
@ManyToOne(
|
||||
() => Issue,
|
||||
issue => issue.comments,
|
||||
)
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
82
src/entities/Issue.ts
Normal file
82
src/entities/Issue.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
RelationId,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
import { Comment, Project, User } from '.';
|
||||
|
||||
@Entity()
|
||||
class Issue extends BaseEntity {
|
||||
static validations = {
|
||||
title: [is.required(), is.maxLength(200)],
|
||||
type: [is.required(), is.oneOf(Object.values(IssueType))],
|
||||
status: [is.required(), is.oneOf(Object.values(IssueStatus))],
|
||||
priority: [is.required(), is.oneOf(Object.values(IssuePriority))],
|
||||
description: is.maxLength(100000),
|
||||
};
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column('varchar')
|
||||
title: string;
|
||||
|
||||
@Column('varchar')
|
||||
type: IssueType;
|
||||
|
||||
@Column('varchar')
|
||||
status: IssueStatus;
|
||||
|
||||
@Column('varchar')
|
||||
priority: IssuePriority;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
estimate: number | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column('integer')
|
||||
reporterId: number;
|
||||
|
||||
@ManyToOne(
|
||||
() => Project,
|
||||
project => project.issues,
|
||||
)
|
||||
project: Project;
|
||||
|
||||
@OneToMany(
|
||||
() => Comment,
|
||||
comment => comment.issue,
|
||||
)
|
||||
comments: Comment[];
|
||||
|
||||
@ManyToMany(
|
||||
() => User,
|
||||
user => user.issues,
|
||||
)
|
||||
@JoinTable()
|
||||
users: User[];
|
||||
|
||||
@RelationId((issue: Issue) => issue.users)
|
||||
userIds: number[];
|
||||
}
|
||||
|
||||
export default Issue;
|
||||
61
src/entities/Project.ts
Normal file
61
src/entities/Project.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
import { Issue, User } from '.';
|
||||
|
||||
@Entity()
|
||||
class Project extends BaseEntity {
|
||||
static validations = {
|
||||
name: [is.required(), is.maxLength(100)],
|
||||
url: is.url(),
|
||||
description: is.maxLength(10000),
|
||||
category: [is.required(), is.oneOf(Object.values(ProjectCategory))],
|
||||
};
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column('varchar')
|
||||
name: string;
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
url: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column('varchar')
|
||||
category: ProjectCategory;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(
|
||||
() => Issue,
|
||||
issue => issue.project,
|
||||
)
|
||||
issues: Issue[];
|
||||
|
||||
@ManyToMany(
|
||||
() => User,
|
||||
user => user.projects,
|
||||
)
|
||||
@JoinTable()
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export default Project;
|
||||
56
src/entities/User.ts
Normal file
56
src/entities/User.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
import { Comment, Issue, Project } from '.';
|
||||
|
||||
@Entity()
|
||||
class User extends BaseEntity {
|
||||
static validations = {
|
||||
name: [is.required(), is.maxLength(100)],
|
||||
email: [is.required(), is.email(), is.maxLength(200)],
|
||||
};
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column('varchar')
|
||||
name: string;
|
||||
|
||||
@Column('varchar')
|
||||
email: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(
|
||||
() => Comment,
|
||||
comment => comment.user,
|
||||
)
|
||||
comments: Comment[];
|
||||
|
||||
@ManyToMany(
|
||||
() => Issue,
|
||||
issue => issue.users,
|
||||
)
|
||||
issues: Issue[];
|
||||
|
||||
@ManyToMany(
|
||||
() => Project,
|
||||
project => project.users,
|
||||
)
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export default User;
|
||||
4
src/entities/index.ts
Normal file
4
src/entities/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Comment } from './Comment';
|
||||
export { default as Issue } from './Issue';
|
||||
export { default as Project } from './Project';
|
||||
export { default as User } from './User';
|
||||
11
src/errors/asyncCatch.ts
Normal file
11
src/errors/asyncCatch.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
export const catchErrors = (requestHandler: RequestHandler): RequestHandler => {
|
||||
return async (req, res, next): Promise<any> => {
|
||||
try {
|
||||
return await requestHandler(req, res, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
38
src/errors/customErrors.ts
Normal file
38
src/errors/customErrors.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
type ErrorData = { [key: string]: any };
|
||||
|
||||
export class CustomError extends Error {
|
||||
constructor(
|
||||
public message: string,
|
||||
public code: string | number = 'INTERNAL_ERROR',
|
||||
public status: number = 500,
|
||||
public data: ErrorData = {},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteNotFoundError extends CustomError {
|
||||
constructor(originalUrl: string) {
|
||||
super(`Route '${originalUrl}' does not exist.`, 'ROUTE_NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotFoundError extends CustomError {
|
||||
constructor(entityName: string) {
|
||||
super(`${entityName} not found.`, 'ENTITY_NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export class BadUserInputError extends CustomError {
|
||||
constructor(errorData: ErrorData) {
|
||||
super('There were validation errors.', 'BAD_USER_INPUT', 400, errorData);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTokenError extends CustomError {
|
||||
constructor(message = 'Authentication token is invalid.') {
|
||||
super(message, 'INVALID_TOKEN', 401);
|
||||
}
|
||||
}
|
||||
21
src/errors/errorHandler.ts
Normal file
21
src/errors/errorHandler.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import { CustomError } from 'errors';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
console.error(error);
|
||||
|
||||
const isErrorSafeForClient = error instanceof CustomError;
|
||||
|
||||
const errorData = isErrorSafeForClient
|
||||
? pick(error, ['message', 'code', 'status', 'data'])
|
||||
: {
|
||||
message: 'Something went wrong, please contact our support.',
|
||||
code: 'INTERNAL_ERROR',
|
||||
status: 500,
|
||||
data: {},
|
||||
};
|
||||
|
||||
res.status(errorData.status).send({ errors: [errorData] });
|
||||
};
|
||||
2
src/errors/index.ts
Normal file
2
src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './customErrors';
|
||||
export { catchErrors } from './asyncCatch';
|
||||
52
src/index.ts
52
src/index.ts
@@ -1,3 +1,53 @@
|
||||
import 'module-alias/register';
|
||||
import 'dotenv/config';
|
||||
import 'reflect-metadata';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
console.log('Hey!');
|
||||
import createDatabaseConnection from 'database/connection';
|
||||
import { authenticateUser } from 'middleware/authentication';
|
||||
import projectsRoutes from 'controllers/projects';
|
||||
import issuesRoutes from 'controllers/issues';
|
||||
import { RouteNotFoundError } from 'errors';
|
||||
import { errorHandler } from 'errors/errorHandler';
|
||||
|
||||
const establishDatabaseConnection = async (): Promise<void> => {
|
||||
try {
|
||||
await createDatabaseConnection();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeExpress = (): void => {
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(cors());
|
||||
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('/', authenticateUser);
|
||||
|
||||
app.use('/', projectsRoutes);
|
||||
app.use('/', issuesRoutes);
|
||||
|
||||
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => console.log(`App listening on port ${PORT}`));
|
||||
};
|
||||
|
||||
const initializeApp = async (): Promise<void> => {
|
||||
await establishDatabaseConnection();
|
||||
initializeExpress();
|
||||
};
|
||||
|
||||
initializeApp();
|
||||
|
||||
25
src/middleware/authentication.ts
Normal file
25
src/middleware/authentication.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
import { verifyToken } from 'utils/authToken';
|
||||
import { findEntityOrThrow } from 'utils/typeorm';
|
||||
import { catchErrors, InvalidTokenError } from 'errors';
|
||||
import { User } from 'entities';
|
||||
|
||||
const getAuthTokenFromRequest = (req: Request): string | null => {
|
||||
const header = req.get('Authorization') || '';
|
||||
const [bearer, token] = header.split(' ');
|
||||
return bearer === 'Bearer' && token ? token : null;
|
||||
};
|
||||
|
||||
export const authenticateUser = catchErrors(async (req, _res, next) => {
|
||||
const token = getAuthTokenFromRequest(req);
|
||||
if (!token) {
|
||||
throw new InvalidTokenError('Authentication token not found.');
|
||||
}
|
||||
const userId = verifyToken(token).sub;
|
||||
if (!userId) {
|
||||
throw new InvalidTokenError('Authentication token is invalid.');
|
||||
}
|
||||
req.currentUser = await findEntityOrThrow(User, userId);
|
||||
next();
|
||||
});
|
||||
10
src/types/env.d.ts
vendored
Normal file
10
src/types/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
DB_HOST: string;
|
||||
DB_PORT: string;
|
||||
DB_USERNAME: string;
|
||||
DB_PASSWORD: string;
|
||||
DB_DATABASE: string;
|
||||
JWT_SECRET: string;
|
||||
}
|
||||
}
|
||||
8
src/types/express.d.ts
vendored
Normal file
8
src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare namespace Express {
|
||||
export interface Response {
|
||||
respond: (data: any) => void;
|
||||
}
|
||||
export interface Request {
|
||||
currentUser: import('entities').User;
|
||||
}
|
||||
}
|
||||
23
src/utils/authToken.ts
Normal file
23
src/utils/authToken.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { isPlainObject } from 'lodash';
|
||||
|
||||
import { InvalidTokenError } from 'errors';
|
||||
|
||||
export const signToken = (payload: object, options?: SignOptions): string =>
|
||||
jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: '7 days',
|
||||
...options,
|
||||
});
|
||||
|
||||
export const verifyToken = (token: string): { [key: string]: any } => {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
if (isPlainObject(payload)) {
|
||||
return payload as { [key: string]: any };
|
||||
}
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
};
|
||||
62
src/utils/typeorm.ts
Normal file
62
src/utils/typeorm.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FindOneOptions } from 'typeorm/find-options/FindOneOptions';
|
||||
|
||||
import { Project, User, Issue, Comment } from 'entities';
|
||||
import { EntityNotFoundError, BadUserInputError } from 'errors';
|
||||
import { generateErrors } from 'utils/validation';
|
||||
|
||||
export type EntityConstructor = typeof Project | typeof User | typeof Issue | typeof Comment;
|
||||
export type EntityInstance = Project | User | Issue | Comment;
|
||||
|
||||
export const entities: { [key: string]: EntityConstructor } = { Comment, Issue, Project, User };
|
||||
|
||||
export const findEntityOrThrow = async <T extends EntityConstructor>(
|
||||
Constructor: T,
|
||||
id: number | string,
|
||||
options?: FindOneOptions,
|
||||
): Promise<InstanceType<T>> => {
|
||||
const instance = await Constructor.findOne(id, options);
|
||||
if (!instance) {
|
||||
throw new EntityNotFoundError(Constructor.name);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const validateAndSaveEntity = async <T extends EntityInstance>(instance: T): Promise<T> => {
|
||||
const Constructor = entities[instance.constructor.name];
|
||||
|
||||
if ('validations' in Constructor) {
|
||||
const errorFields = generateErrors(instance, Constructor.validations);
|
||||
|
||||
if (Object.keys(errorFields).length > 0) {
|
||||
throw new BadUserInputError({ fields: errorFields });
|
||||
}
|
||||
}
|
||||
return instance.save() as Promise<T>;
|
||||
};
|
||||
|
||||
export const createEntity = async <T extends EntityConstructor>(
|
||||
Constructor: T,
|
||||
input: Partial<InstanceType<T>>,
|
||||
): Promise<InstanceType<T>> => {
|
||||
const instance = Constructor.create(input);
|
||||
return validateAndSaveEntity(instance as InstanceType<T>);
|
||||
};
|
||||
|
||||
export const updateEntity = async <T extends EntityConstructor>(
|
||||
Constructor: T,
|
||||
id: number | string,
|
||||
input: Partial<InstanceType<T>>,
|
||||
): Promise<InstanceType<T>> => {
|
||||
const instance = await findEntityOrThrow(Constructor, id);
|
||||
Object.assign(instance, input);
|
||||
return validateAndSaveEntity(instance);
|
||||
};
|
||||
|
||||
export const deleteEntity = async <T extends EntityConstructor>(
|
||||
Constructor: T,
|
||||
id: number | string,
|
||||
): Promise<InstanceType<T>> => {
|
||||
const instance = await findEntityOrThrow(Constructor, id);
|
||||
await instance.remove();
|
||||
return instance;
|
||||
};
|
||||
59
src/utils/validation.ts
Normal file
59
src/utils/validation.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
type Value = any;
|
||||
type ErrorMessage = false | string;
|
||||
type FieldValues = { [key: string]: Value };
|
||||
type Validator = (value: Value, fieldValues?: FieldValues) => ErrorMessage;
|
||||
type FieldValidators = { [key: string]: Validator | Validator[] };
|
||||
type FieldErrors = { [key: string]: string };
|
||||
|
||||
const is = {
|
||||
match: (testFn: Function, message = '') => (
|
||||
value: Value,
|
||||
fieldValues: FieldValues,
|
||||
): ErrorMessage => !testFn(value, fieldValues) && message,
|
||||
|
||||
required: () => (value: Value): ErrorMessage =>
|
||||
isNilOrEmptyString(value) && 'This field is required',
|
||||
|
||||
minLength: (min: number) => (value: Value): ErrorMessage =>
|
||||
!!value && value.length < min && `Must be at least ${min} characters`,
|
||||
|
||||
maxLength: (max: number) => (value: Value): ErrorMessage =>
|
||||
!!value && value.length > max && `Must be at most ${max} characters`,
|
||||
|
||||
oneOf: (arr: any[]) => (value: Value): ErrorMessage =>
|
||||
!!value && !arr.includes(value) && `Must be one of: ${arr.join(', ')}`,
|
||||
|
||||
notEmptyArray: () => (value: Value): ErrorMessage =>
|
||||
Array.isArray(value) && value.length === 0 && 'Please add at least one item',
|
||||
|
||||
email: () => (value: Value): ErrorMessage =>
|
||||
!!value && !/.+@.+\..+/.test(value) && 'Must be a valid email',
|
||||
|
||||
url: () => (value: Value): ErrorMessage =>
|
||||
!!value &&
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
!/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) &&
|
||||
'Must be a valid URL',
|
||||
};
|
||||
|
||||
const isNilOrEmptyString = (value: Value): boolean =>
|
||||
value === undefined || value === null || value === '';
|
||||
|
||||
export const generateErrors = (
|
||||
fieldValues: FieldValues,
|
||||
fieldValidators: FieldValidators,
|
||||
): FieldErrors => {
|
||||
const fieldErrors: FieldErrors = {};
|
||||
|
||||
Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
|
||||
[validators].flat().forEach(validator => {
|
||||
const errorMessage = validator(fieldValues[fieldName], fieldValues);
|
||||
if (errorMessage !== false && !fieldErrors[fieldName]) {
|
||||
fieldErrors[fieldName] = errorMessage;
|
||||
}
|
||||
});
|
||||
});
|
||||
return fieldErrors;
|
||||
};
|
||||
|
||||
export default is;
|
||||
@@ -28,14 +28,14 @@
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noImplicitReturns": false, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
|
||||
Reference in New Issue
Block a user