Implemented basic entities, routes, seed scripts

This commit is contained in:
ireic
2019-12-02 01:27:59 +01:00
parent 9edb74c2df
commit 5a08433830
32 changed files with 2340 additions and 118 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

41
src/controllers/issues.ts Normal file
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;

22
src/controllers/users.ts Normal file
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(
'/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;

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): 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
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;

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

4
src/entities/index.ts Normal file
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';

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

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

View File

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

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

23
src/utils/authToken.ts Normal file
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
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';
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
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;

View File

@@ -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 */