Skip to content
This repository was archived by the owner on Jul 25, 2021. It is now read-only.

Commit 33c1c58

Browse files
authored
feat: add event channels and messages (#41)
* feat!: add EventChannel, refactors event * refactor: channels with single table inheritance * feat: Message entity * feat: add further properties to Message entity * feat: message creation * feat: get message from API * feat: get messages of channel * feat: deleting a message * feat: validate messages * refactor(MessageService): require time for creation * test: createEvent * test: getMessage * test: deleteMessage * test!: getMessages * test: 100% coverage for MessageService * docs: add disclaimer to message fixtures * test: controller createMessage * test: fix typos in message service tests * test: getMessage controller * test: getMessages controller * test: 100% coverage for message controller tests * refactor: use glob pattern for entities, simplify messages pagination * style: remove unused imports, fix lint errors * refactor: use HttpCode enum
1 parent c9d2aa6 commit 33c1c58

15 files changed

Lines changed: 758 additions & 37 deletions

File tree

package-lock.json

Lines changed: 19 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@types/jest": "^26.0.4",
3333
"@types/node": "^14.0.22",
3434
"@types/supertest": "^2.0.10",
35+
"@types/uuid": "^8.0.0",
3536
"@typescript-eslint/eslint-plugin": "^3.6.0",
3637
"@typescript-eslint/parser": "^3.6.0",
3738
"@unicsmcr/eslint-config": "0.0.2",
@@ -40,7 +41,8 @@
4041
"supertest": "^4.0.2",
4142
"ts-jest": "^26.1.1",
4243
"ts-mockito": "^2.6.1",
43-
"typescript": "^3.9.6"
44+
"typescript": "^3.9.6",
45+
"uuid": "^8.3.0"
4446
},
4547
"eslintConfig": {
4648
"extends": "@unicsmcr",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { NextFunction, Request } from 'express';
2+
import { inject, injectable } from 'tsyringe';
3+
import { AuthenticatedResponse } from '../routes/middleware/getUser';
4+
import MessageService from '../services/MessageService';
5+
import { AccountType } from '../entities/User';
6+
import { HttpCode } from '../util/errors';
7+
8+
@injectable()
9+
export class MessageController {
10+
private readonly messageService: MessageService;
11+
12+
public constructor(@inject(MessageService) messageService: MessageService) {
13+
this.messageService = messageService;
14+
}
15+
16+
public async createMessage(req: Request & { params: { channelID: string } }, res: AuthenticatedResponse, next: NextFunction): Promise<void> {
17+
try {
18+
const message = await this.messageService.createMessage({ ...req.body, channelID: req.params.channelID, authorID: res.locals.user.id });
19+
res.json({ message });
20+
} catch (error) {
21+
next(error);
22+
}
23+
}
24+
25+
public async getMessage(req: Request & { params: { channelID: string; messageID: string } }, res: AuthenticatedResponse, next: NextFunction): Promise<void> {
26+
try {
27+
const message = await this.messageService.getMessage({ id: req.params.messageID, channelID: req.params.channelID });
28+
res.json({ message });
29+
} catch (error) {
30+
next(error);
31+
}
32+
}
33+
34+
public async getMessages(req: Request & { params: { channelID: string; page: number } }, res: AuthenticatedResponse, next: NextFunction): Promise<void> {
35+
try {
36+
const messages = await this.messageService.getMessages({
37+
channelID: req.params.channelID,
38+
page: Number(req.query.page),
39+
count: 50
40+
});
41+
res.json({ messages });
42+
} catch (error) {
43+
next(error);
44+
}
45+
}
46+
47+
public async deleteMessage(req: Request & { params: { channelID: string; messageID: string } }, res: AuthenticatedResponse, next: NextFunction): Promise<void> {
48+
try {
49+
await this.messageService.deleteMessage({
50+
id: req.params.messageID,
51+
channelID: req.params.channelID,
52+
authorID: res.locals.user.accountType === AccountType.Admin ? undefined : res.locals.user.id
53+
});
54+
res.status(HttpCode.NoContent).end();
55+
} catch (error) {
56+
next(error);
57+
}
58+
}
59+
}

src/entities/Channel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { PrimaryGeneratedColumn, OneToOne, Entity, TableInheritance, ChildEntity } from 'typeorm';
2+
import { Event } from './Event';
3+
4+
@Entity()
5+
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
6+
export class Channel {
7+
@PrimaryGeneratedColumn('uuid')
8+
public id!: string;
9+
}
10+
11+
@ChildEntity()
12+
export class EventChannel extends Channel {
13+
@OneToOne(() => Event, event => event.channel)
14+
public event!: Event;
15+
}

src/entities/Event.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { PrimaryGeneratedColumn, Entity, Column } from 'typeorm';
2-
import { MaxLength, IsString } from 'class-validator';
1+
import { PrimaryGeneratedColumn, Entity, Column, OneToOne, JoinColumn } from 'typeorm';
2+
import { MaxLength, IsString, IsDate } from 'class-validator';
3+
import { EventChannel } from './Channel';
4+
5+
export interface APIEvent {
6+
id: string;
7+
title: string;
8+
startTime: string;
9+
endTime: string;
10+
description: string;
11+
external: string;
12+
channelID: string;
13+
}
314

415
@Entity()
516
export class Event {
@@ -12,9 +23,11 @@ export class Event {
1223
public title!: string;
1324

1425
@Column('timestamp')
26+
@IsDate()
1527
public startTime!: Date;
1628

1729
@Column('timestamp')
30+
@IsDate()
1831
public endTime!: Date;
1932

2033
@Column()
@@ -25,4 +38,21 @@ export class Event {
2538
@Column()
2639
@IsString()
2740
public external!: string;
41+
42+
@OneToOne(() => EventChannel, channel => channel.event, { nullable: true, eager: true, cascade: true })
43+
@JoinColumn()
44+
public channel!: EventChannel;
45+
46+
public toJSON(): APIEvent {
47+
const { id, title, startTime, endTime, description, external, channel } = this;
48+
return {
49+
id,
50+
title,
51+
startTime: startTime.toISOString(),
52+
endTime: endTime.toISOString(),
53+
description,
54+
external,
55+
channelID: channel.id
56+
};
57+
}
2858
}

src/entities/Message.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Entity, PrimaryGeneratedColumn, ManyToOne, Column } from 'typeorm';
2+
import { Channel } from './Channel';
3+
import { User } from './User';
4+
import { IsDate, IsString, MaxLength, MinLength } from 'class-validator';
5+
6+
export interface APIMessage {
7+
id: string;
8+
channelID: string;
9+
authorID: string;
10+
content: string;
11+
time: string;
12+
}
13+
14+
@Entity()
15+
export default class Message {
16+
@PrimaryGeneratedColumn('uuid')
17+
public id!: string;
18+
19+
@ManyToOne(() => Channel, { eager: true })
20+
public channel!: Channel;
21+
22+
@ManyToOne(() => User, { eager: true })
23+
public author!: User;
24+
25+
@Column()
26+
@IsString()
27+
@MinLength(1, { message: 'A message must be at least 1 character long' })
28+
@MaxLength(400, { message: 'A message can be 400 characters at most' })
29+
public content!: string;
30+
31+
@Column('timestamp')
32+
@IsDate()
33+
public time!: Date;
34+
35+
public toJSON(): APIMessage {
36+
return {
37+
id: this.id,
38+
channelID: this.channel.id,
39+
authorID: this.author.id,
40+
content: this.content,
41+
time: this.time.toISOString()
42+
};
43+
}
44+
}

src/index.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,15 @@ import express, { Router, Response, Request, NextFunction } from 'express';
88

99
import { createConnection } from 'typeorm';
1010
import { getConfig } from './util/config';
11-
import { User } from './entities/User';
1211
import { UserRoutes } from './routes/UserRoutes';
13-
import { EmailConfirmation } from './entities/EmailConfirmation';
1412
import { container } from 'tsyringe';
15-
import Profile from './entities/Profile';
1613
import EmailService from './services/email/EmailService';
1714
import MockEmailService from './services/email/MockEmailService';
1815
import { APIError, HttpCode } from './util/errors';
1916
import { Server as WebSocketServer } from 'ws';
2017
import GatewayController from './controllers/GatewayController';
21-
import { Event } from './entities/Event';
2218
import { EventRoutes } from './routes/EventRoutes';
19+
import { MessageRoutes } from './routes/MessageRoutes';
2320

2421
export function createExpress() {
2522
const app = express();
@@ -38,6 +35,7 @@ export function createExpress() {
3835

3936
container.resolve(UserRoutes).routes(router);
4037
container.resolve(EventRoutes).routes(router);
38+
container.resolve(MessageRoutes).routes(router);
4139

4240
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4341
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
@@ -64,7 +62,7 @@ export async function createDBConnection() {
6462
type: 'postgres',
6563
...getConfig().db, // username, password, host, port, database
6664
entities: [
67-
User, EmailConfirmation, Profile, Event
65+
`${__dirname}/entities/**/*{.js,.ts}`
6866
],
6967
synchronize: true,
7068
logging: false

src/routes/MessageRoutes.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Router } from 'express';
2+
import { inject, injectable } from 'tsyringe';
3+
import { getUser, isVerified } from './middleware';
4+
import { MessageController } from '../controllers/MessageController';
5+
6+
@injectable()
7+
export class MessageRoutes {
8+
private readonly messageController: MessageController;
9+
10+
public constructor(@inject(MessageController) messageController: MessageController) {
11+
this.messageController = messageController;
12+
}
13+
14+
public routes(router: Router): void {
15+
router.post('/channels/:channelID/messages', getUser, isVerified, this.messageController.createMessage.bind(this.messageController));
16+
router.get('/channels/:channelID/messages', getUser, isVerified, this.messageController.getMessages.bind(this.messageController));
17+
router.get('/channels/:channelID/messages/:messageID', getUser, isVerified, this.messageController.getMessage.bind(this.messageController));
18+
router.delete('/channels/:channelID/messages/:messageID', getUser, isVerified, this.messageController.deleteMessage.bind(this.messageController));
19+
}
20+
}

src/services/EventService.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { singleton } from 'tsyringe';
2-
import { Event } from '../entities/Event';
2+
import { Event, APIEvent } from '../entities/Event';
33
import { getRepository, getConnection } from 'typeorm';
44
import { validateOrReject } from 'class-validator';
5+
import { EventChannel } from '../entities/Channel';
56
import { formatValidationErrors, APIError, HttpCode } from '../util/errors';
67

7-
type EventCreationData = Omit<Event, 'id'>;
8+
type EventCreationData = Omit<APIEvent, 'id' | 'channelID'>;
89

910
enum PatchEventError {
1011
IdMissing = 'Event ID missing',
@@ -13,25 +14,28 @@ enum PatchEventError {
1314

1415
@singleton()
1516
export default class EventService {
16-
public async createEvent(data: EventCreationData): Promise<Event> {
17+
public async createEvent(data: EventCreationData): Promise<APIEvent> {
1718
const event = new Event();
1819
const { title, description, startTime, endTime, external } = data;
19-
Object.assign(event, { title, description, startTime, endTime, external });
20+
Object.assign(event, { title, description, startTime: new Date(startTime), endTime: new Date(endTime), external });
2021
await validateOrReject(event).catch(e => Promise.reject(formatValidationErrors(e)));
21-
return getRepository(Event).save(event);
22+
const channel = new EventChannel();
23+
channel.event = event;
24+
event.channel = channel;
25+
return (await getRepository(Event).save(event)).toJSON();
2226
}
2327

24-
public findAll(): Promise<Event[]> {
25-
return getRepository(Event).find();
28+
public async findAll(): Promise<APIEvent[]> {
29+
return (await getRepository(Event).find()).map(event => event.toJSON());
2630
}
2731

28-
public async editEvent(data: Pick<Event, 'id'> & Partial<Event>): Promise<Event> {
32+
public async editEvent(data: Pick<Event, 'id'> & Partial<Event>): Promise<APIEvent> {
2933
return getConnection().transaction(async entityManager => {
3034
if (!data.id) throw new APIError(HttpCode.BadRequest, PatchEventError.IdMissing);
3135
const event = await entityManager.findOneOrFail(Event, data.id).catch(() => Promise.reject(new APIError(HttpCode.BadRequest, PatchEventError.EventNotFound)));
32-
Object.assign(event, data);
36+
Object.assign(event, { ...data, channel: event.channel });
3337
await validateOrReject(event).catch(e => Promise.reject(formatValidationErrors(e)));
34-
return entityManager.save(event);
38+
return (await entityManager.save(event)).toJSON();
3539
});
3640
}
3741
}

0 commit comments

Comments
 (0)