From 2d0a6e3433a2e922f2f900b2681818f91a1b1c95 Mon Sep 17 00:00:00 2001 From: Trivernis Date: Tue, 1 Oct 2019 17:45:55 +0200 Subject: [PATCH] Implemented Socket.io - added socket for chat messages - added socket for new requests - added socket for post creation - added socket for created post --- src/lib/InternalEvents.ts | 8 +++ src/lib/dataaccess/ChatMessage.ts | 13 ++++ src/lib/dataaccess/Chatroom.ts | 4 +- src/lib/dataaccess/Post.ts | 13 ++++ src/lib/dataaccess/Request.ts | 14 ++++- src/lib/dataaccess/index.ts | 31 ++++++++-- src/lib/globals.ts | 2 + src/public/graphql/schema.graphql | 3 + src/routes/home.ts | 98 +++++++++++++++++++++++++------ 9 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 src/lib/InternalEvents.ts diff --git a/src/lib/InternalEvents.ts b/src/lib/InternalEvents.ts new file mode 100644 index 0000000..13cf6ab --- /dev/null +++ b/src/lib/InternalEvents.ts @@ -0,0 +1,8 @@ +export enum InternalEvents { + CHATCREATE = "chatCreate", + CHATMESSAGE = "chatMessage", + GQLCHATMESSAGE = "graphqlChatMessage", + REQUESTCREATE = "requestCreate", + POSTCREATE = "postCreate", + GQLPOSTCREATE = "graphqlPostCreate", +} diff --git a/src/lib/dataaccess/ChatMessage.ts b/src/lib/dataaccess/ChatMessage.ts index bbad8ad..4c91a16 100644 --- a/src/lib/dataaccess/ChatMessage.ts +++ b/src/lib/dataaccess/ChatMessage.ts @@ -15,4 +15,17 @@ export class ChatMessage { public htmlContent(): string { return markdown.renderInline(this.content); } + + /** + * Returns resolved and rendered content of the chat message. + */ + public resolvedContent() { + return { + author: this.author.id, + chat: this.chat.id, + content: this.content, + createdAt: this.createdAt, + htmlContent: this.htmlContent(), + }; + } } diff --git a/src/lib/dataaccess/Chatroom.ts b/src/lib/dataaccess/Chatroom.ts index 0787637..e541f06 100644 --- a/src/lib/dataaccess/Chatroom.ts +++ b/src/lib/dataaccess/Chatroom.ts @@ -5,8 +5,10 @@ import {User} from "./User"; export class Chatroom { - constructor(private readonly id: number) { + public namespace: string; + constructor(public readonly id: number) { this.id = Number(id); + this.namespace = `/chat/${id}`; } /** diff --git a/src/lib/dataaccess/Post.ts b/src/lib/dataaccess/Post.ts index 7908f62..e4912c7 100644 --- a/src/lib/dataaccess/Post.ts +++ b/src/lib/dataaccess/Post.ts @@ -11,6 +11,19 @@ export class Post extends DataObject { private $author: number; private $type: string; + /** + * Returns the resolved data of the post. + */ + public async resolvedData() { + await this.loadDataIfNotExists(); + return { + authorId: this.$author, + content: this.$content, + createdAt: this.$createdAt, + id: this.id, + type: this.$type, + }; + } /** * Returns the upvotes of a post. */ diff --git a/src/lib/dataaccess/Request.ts b/src/lib/dataaccess/Request.ts index f161757..7e8f1bc 100644 --- a/src/lib/dataaccess/Request.ts +++ b/src/lib/dataaccess/Request.ts @@ -8,5 +8,17 @@ export class Request { constructor( public readonly sender: User, public readonly receiver: User, - public readonly type: dataaccess.RequestType) {} + public readonly type: dataaccess.RequestType) { + } + + /** + * Returns the resolved request data. + */ + public resolvedData() { + return { + receiverId: this.receiver.id, + senderId: this.sender.id, + type: this.type, + }; + } } diff --git a/src/lib/dataaccess/index.ts b/src/lib/dataaccess/index.ts index 3dccfcd..46a6649 100644 --- a/src/lib/dataaccess/index.ts +++ b/src/lib/dataaccess/index.ts @@ -2,6 +2,7 @@ import {Pool} from "pg"; import {ChatNotFoundError} from "../errors/ChatNotFoundError"; import {UserNotFoundError} from "../errors/UserNotFoundError"; import globals from "../globals"; +import {InternalEvents} from "../InternalEvents"; import {QueryHelper} from "../QueryHelper"; import {ChatMessage} from "./ChatMessage"; import {Chatroom} from "./Chatroom"; @@ -125,7 +126,9 @@ namespace dataaccess { text: "INSERT INTO posts (content, author, type) VALUES ($1, $2, $3) RETURNING *", values: [content, authorId, type], }); - return new Post(result.id, result); + const post = new Post(result.id, result); + globals.internalEmitter.emit(InternalEvents.POSTCREATE, post); + return post; } /** @@ -167,7 +170,9 @@ namespace dataaccess { } finally { transaction.release(); } - return new Chatroom(id); + const chat = new Chatroom(id); + globals.internalEmitter.emit(InternalEvents.CHATCREATE, chat); + return chat; } /** @@ -183,12 +188,28 @@ namespace dataaccess { text: "INSERT INTO chat_messages (chat, author, content) values ($1, $2, $3) RETURNING *", values: [chatId, authorId, content], }); - return new ChatMessage(new User(result.author), chat, result.created_at, result.content); + const message = new ChatMessage(new User(result.author), chat, result.created_at, result.content); + globals.internalEmitter.emit(InternalEvents.CHATMESSAGE, message); + return message; } else { throw new ChatNotFoundError(chatId); } } + /** + * Returns all chats. + */ + export async function getAllChats(): Promise { + const result = await queryHelper.all({ + text: "SELECT id FROM chats;", + }); + const chats = []; + for (const row of result) { + chats.push(new Chatroom(row.id)); + } + return chats; + } + /** * Sends a request to a user. * @param sender @@ -202,7 +223,9 @@ namespace dataaccess { text: "INSERT INTO requests (sender, receiver, type) VALUES ($1, $2, $3) RETURNING *", values: [sender, receiver, type], }); - return new Request(new User(result.sender), new User(result.receiver), result.type); + const request = new Request(new User(result.sender), new User(result.receiver), result.type); + globals.internalEmitter.emit(InternalEvents.REQUESTCREATE, Request); + return request; } /** diff --git a/src/lib/globals.ts b/src/lib/globals.ts index d3df954..cea8e3c 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -5,6 +5,7 @@ * Partly taken from {@link https://github.com/Trivernis/whooshy} */ +import {EventEmitter} from "events"; import * as fsx from "fs-extra"; import * as yaml from "js-yaml"; import * as winston from "winston"; @@ -42,6 +43,7 @@ namespace globals { }), ], }); + export const internalEmitter = new EventEmitter(); cache.on("set", (key) => logger.debug(`Caching '${key}'.`)); cache.on("miss", (key) => logger.debug(`Cache miss for '${key}'`)); cache.on("hit", (key) => logger.debug(`Cache hit for '${key}'`)); diff --git a/src/public/graphql/schema.graphql b/src/public/graphql/schema.graphql index 19a19eb..cefe527 100644 --- a/src/public/graphql/schema.graphql +++ b/src/public/graphql/schema.graphql @@ -189,6 +189,9 @@ type Request { "represents a chatroom" type ChatRoom { + "the socket.io namespace for the chatroom" + namespace: String + "the members of the chatroom" members: [User!] diff --git a/src/routes/home.ts b/src/routes/home.ts index 88ce8b0..614a8f7 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -1,17 +1,22 @@ import {Router} from "express"; import {GraphQLError} from "graphql"; import * as status from "http-status"; -import {Server} from "socket.io"; +import {Namespace, Server} from "socket.io"; import dataaccess from "../lib/dataaccess"; +import {ChatMessage} from "../lib/dataaccess/ChatMessage"; import {Chatroom} from "../lib/dataaccess/Chatroom"; import {Post} from "../lib/dataaccess/Post"; import {Profile} from "../lib/dataaccess/Profile"; +import {Request} from "../lib/dataaccess/Request"; import {User} from "../lib/dataaccess/User"; import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors"; import globals from "../lib/globals"; +import {InternalEvents} from "../lib/InternalEvents"; import {is} from "../lib/regex"; import Route from "../lib/Route"; +const chatRooms: Namespace[] = []; + /** * Class for the home route. */ @@ -30,6 +35,33 @@ class HomeRoute extends Route { */ public async init(io: Server) { this.io = io; + + io.on("connection", (socket) => { + socket.on("postCreate", async (content) => { + if (socket.handshake.session.userId) { + const post = await dataaccess.createPost(content, socket.handshake.session.userId); + io.emit("post", await post.resolvedData()); + } else { + socket.emit("error", "Not logged in!"); + } + }); + globals.internalEmitter.on(InternalEvents.REQUESTCREATE, (request: Request) => { + if (request.receiver.id === socket.handshake.session.userId) { + socket.emit("request", request.resolvedData()); + } + }); + globals.internalEmitter.on(InternalEvents.GQLPOSTCREATE, async (post: Post) => { + socket.emit("post", await post.resolvedData()); + }); + }); + + const chats = await dataaccess.getAllChats(); + for (const chat of chats) { + chatRooms[chat.id] = this.getChatSocketNamespace(chat.id); + } + globals.internalEmitter.on(InternalEvents.CHATCREATE, (chat: Chatroom) => { + chatRooms[chat.id] = this.getChatSocketNamespace(chat.id); + }); } /** @@ -55,7 +87,7 @@ class HomeRoute extends Route { return new NotLoggedInGqlError(); } }, - async getUser({userId, handle}: {userId: number, handle: string}) { + async getUser({userId, handle}: { userId: number, handle: string }) { if (handle) { return await dataaccess.getUserByHandle(handle); } else if (userId) { @@ -65,7 +97,7 @@ class HomeRoute extends Route { return new GraphQLError("No userId or handle provided."); } }, - async getPost({postId}: {postId: number}) { + async getPost({postId}: { postId: number }) { if (postId) { return await dataaccess.getPost(postId); } else { @@ -73,7 +105,7 @@ class HomeRoute extends Route { return new GraphQLError("No postId given."); } }, - async getChat({chatId}: {chatId: number}) { + async getChat({chatId}: { chatId: number }) { if (chatId) { return new Chatroom(chatId); } else { @@ -85,7 +117,7 @@ class HomeRoute extends Route { req.session.cookiesAccepted = true; return true; }, - async login({email, passwordHash}: {email: string, passwordHash: string}) { + async login({email, passwordHash}: { email: string, passwordHash: string }) { if (email && passwordHash) { try { const user = await dataaccess.getUserByLogin(email, passwordHash); @@ -110,7 +142,7 @@ class HomeRoute extends Route { return new NotLoggedInGqlError(); } }, - async register({username, email, passwordHash}: {username: string, email: string, passwordHash: string}) { + async register({username, email, passwordHash}: { username: string, email: string, passwordHash: string }) { if (username && email && passwordHash) { if (!is.email(email)) { res.status(status.BAD_REQUEST); @@ -129,7 +161,7 @@ class HomeRoute extends Route { return new GraphQLError("No username, email or password given."); } }, - async vote({postId, type}: {postId: number, type: dataaccess.VoteType}) { + async vote({postId, type}: { postId: number, type: dataaccess.VoteType }) { if (postId && type) { if (req.session.userId) { return await (new Post(postId)).vote(req.session.userId, type); @@ -142,10 +174,12 @@ class HomeRoute extends Route { return new GraphQLError("No postId or type given."); } }, - async createPost({content}: {content: string}) { + async createPost({content}: { content: string }) { if (content) { if (req.session.userId) { - return await dataaccess.createPost(content, req.session.userId); + const post = await dataaccess.createPost(content, req.session.userId); + globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); + return post; } else { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); @@ -155,7 +189,7 @@ class HomeRoute extends Route { return new GraphQLError("Can't create empty post."); } }, - async deletePost({postId}: {postId: number}) { + async deletePost({postId}: { postId: number }) { if (postId) { const post = new Post(postId); if ((await post.author()).id === req.session.userId) { @@ -168,27 +202,28 @@ class HomeRoute extends Route { return new GraphQLError("No postId given."); } }, - async createChat({members}: {members: number[]}) { + async createChat({members}: { members: number[] }) { if (req.session.userId) { const chatMembers = [req.session.userId]; if (members) { chatMembers.push(...members); } return await dataaccess.createChat(...chatMembers); - } else { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); } }, - async sendMessage({chatId, content}: {chatId: number, content: string}) { + async sendMessage({chatId, content}: { chatId: number, content: string }) { if (!req.session.userId) { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); } if (chatId && content) { try { - return await dataaccess.sendChatMessage(req.session.userId, chatId, content); + const message = await dataaccess.sendChatMessage(req.session.userId, chatId, content); + globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message); + return message; } catch (err) { res.status(status.BAD_REQUEST); return err.graphqlError; @@ -198,7 +233,7 @@ class HomeRoute extends Route { return new GraphQLError("No chatId or content given."); } }, - async sendRequest({receiver, type}: {receiver: number, type: dataaccess.RequestType}) { + async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }) { if (!req.session.userId) { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); @@ -210,7 +245,7 @@ class HomeRoute extends Route { return new GraphQLError("No receiver or type given."); } }, - async denyRequest({sender, type}: {sender: number, type: dataaccess.RequestType}) { + async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }) { if (!req.session.userId) { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); @@ -224,7 +259,7 @@ class HomeRoute extends Route { return new GraphQLError("No sender or type given."); } }, - async acceptRequest({sender, type}: {sender: number, type: dataaccess.RequestType}) { + async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }) { if (!req.session.userId) { res.status(status.UNAUTHORIZED); return new NotLoggedInGqlError(); @@ -245,6 +280,35 @@ class HomeRoute extends Route { }, }; } + + /** + * Returns the namespace socket for a chat socket. + * @param chatId + */ + private getChatSocketNamespace(chatId: number) { + if (chatRooms[chatId]) { + return chatRooms[chatId]; + } + const chatNs = this.io.of(`/chat/${chatId}`); + chatNs.on("connection", (socket) => { + socket.on("chatMessage", async (content) => { + if (socket.handshake.session.userId) { + const userId = socket.handshake.session.userId; + const message = await dataaccess.sendChatMessage(userId, chatId, content); + socket.broadcast.emit("chatMessage", message.resolvedContent()); + socket.emit("chatMessageSent", message.resolvedContent()); + } else { + socket.emit("error", "Not logged in!"); + } + }); + globals.internalEmitter.on(InternalEvents.GQLCHATMESSAGE, (message: ChatMessage) => { + if (message.chat.id === chatId) { + socket.emit("chatMessage", message.resolvedContent()); + } + }); + }); + return chatNs; + } } export default HomeRoute;