Moved api into it's own folder

This commit is contained in:
ireic
2019-12-03 15:38:36 +01:00
parent 5a08433830
commit 84f0897c45
36 changed files with 21 additions and 12 deletions

40
api/.eslintrc.json Normal file
View File

@@ -0,0 +1,40 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"sourceType": "module",
"ecmaVersion": 8
},
"plugins": ["@typescript-eslint"],
"env": {
"node": true
},
"extends": [
"airbnb-base",
"plugin:import/typescript",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:prettier/recommended",
"prettier/@typescript-eslint"
],
"rules": {
"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,
"@typescript-eslint/no-explicit-any": 0,
"import/prefer-default-export": 0,
"import/no-cycle": 0
},
"settings": {
// Allows us to use absolute imports within codebase
"import/resolver": {
"node": {
"moduleDirectory": ["node_modules", "src/"]
}
}
}
}

1
api/.prettierignore Normal file
View File

@@ -0,0 +1 @@
tsconfig.json

5
api/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

1
api/README.md Normal file
View File

@@ -0,0 +1 @@
# Jira clone API

4634
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
api/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "jira_api",
"author": "Ivor Reic",
"license": "ISC",
"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": {
"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": {
"@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",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-prettier": "^3.1.1",
"husky": "^3.1.0",
"lint-staged": "^9.4.3",
"nodemon": "^2.0.0",
"prettier": "^1.19.1",
"ts-node": "^8.5.2",
"typescript": "^3.7.2"
},
"_moduleDirectories": [
"src"
],
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"prettier --write",
"git add"
]
}
}

View 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',
}

View File

@@ -0,0 +1,5 @@
export enum ProjectCategory {
SOFTWARE = 'software',
MARKETING = 'marketing',
BUSINESS = 'business',
}

View 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(
'/authentication/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;

View 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;

View 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;

View 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;

View 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;

View 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();

View 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;

View 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;

View 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;

View 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, user: User): Promise<Comment> =>
createEntity(Comment, {
body: "Be nice to each other! Don't be mean to each other!",
issue,
user,
});
const seedGuestUserEntities = async (user: User): Promise<void> => {
const project = await seedProject(user);
const issues = await seedIssues(project);
await seedComments(issues[issues.length - 1], project.users[0]);
};
export default seedGuestUserEntities;

View 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;

88
api/src/entities/Issue.ts Normal file
View File

@@ -0,0 +1,88 @@
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;
@Column({ type: 'integer', nullable: true })
timeSpent: number | null;
@Column({ type: 'integer', nullable: true })
timeRemaining: 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;

View 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
api/src/entities/User.ts Normal file
View 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;

View 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';

View 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);
}
};
};

View 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);
}
}

View 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
api/src/errors/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './customErrors';
export { catchErrors } from './asyncCatch';

56
api/src/index.ts Normal file
View File

@@ -0,0 +1,56 @@
import 'module-alias/register';
import 'dotenv/config';
import 'reflect-metadata';
import express from 'express';
import cors from 'cors';
import createDatabaseConnection from 'database/connection';
import { authenticateUser } from 'middleware/authentication';
import authenticationRoutes from 'controllers/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('/', authenticationRoutes);
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();

View 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
api/src/types/env.d.ts vendored Normal file
View 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
api/src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare namespace Express {
export interface Response {
respond: (data: any) => void;
}
export interface Request {
currentUser: import('entities').User;
}
}

View 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
api/src/utils/typeorm.ts Normal file
View 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';
type EntityConstructor = typeof Project | typeof User | typeof Issue | typeof Comment;
type EntityInstance = Project | User | Issue | Comment;
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;
};

View 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;

68
api/tsconfig.json Normal file
View File

@@ -0,0 +1,68 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./build", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
"removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"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": 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": 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 */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./src",
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"exclude": ["node_modules"],
"include": ["./src/**/*.ts"]
}