diff --git a/.gitignore b/.gitignore index 331e819..f043384 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules/ npm-debug.log test/*.log dist -.idea \ No newline at end of file +.idea +config.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e86b8..ca6704a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + +- Connection to Postgres Database +- Graphql Schema +- default-config file and generation of config file on startup diff --git a/README.md b/README.md index fe1d0c9..79f8a5f 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ -greenvironment-server +# greenvironment-server + +Server of the greenvironment social network. + +## Install + +You need to install a nodejs runtime to run the greenvironment server. +Then you need to install all requirements. To do so, open a terminal in the +greenvironment project folder and execute "npm i". You can build the project by +executing "gulp" in the terminal. To run the server you need +to execute "node ./dist". diff --git a/gulpfile.js b/gulpfile.js index e474b8c..c1abd43 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -34,9 +34,14 @@ function compileSass() { .pipe(dest('dist/public/stylesheets')); } -task('default', series(clearDist, compileTypescript, minifyJs, compileSass)); +function moveRemaining() { + return src(['src/**/*', '!src/**/*.ts', '!src/**/*.sass', '!src/**/*.js']) + .pipe(dest('dist')); +} + +task('default', series(clearDist, compileTypescript, minifyJs, compileSass, moveRemaining)); task('watch', () => { watch('src/public/stylesheets/sass/**/*.sass', compileSass); watch('**/*.js', minifyJs); watch('**/*.ts', compileTypescript); -}); \ No newline at end of file +}); diff --git a/package-lock.json b/package-lock.json index ae10b13..212f9d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,11 @@ "integrity": "sha512-UoCovaxbJIxagCvVfalfK7YaNhmxj3BQFRQ2RHQKLiu+9wNXhJnlbspsLHt/YQM99IaLUUFJNzCwzc6W0ypMeQ==", "dev": true }, + "@types/js-yaml": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.1.tgz", + "integrity": "sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA==" + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -370,7 +375,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -1776,8 +1780,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esutils": { "version": "2.0.3", @@ -3815,7 +3818,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -5913,8 +5915,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", diff --git a/package.json b/package.json index 2041d5a..ae3c9f0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "typescript": "^3.5.3" }, "dependencies": { + "@types/js-yaml": "^3.12.1", "@types/winston": "^2.4.4", "connect-pg-simple": "^6.0.0", "cookie-parser": "^1.4.4", @@ -50,6 +51,7 @@ "fs-extra": "^8.1.0", "graphql": "^14.4.2", "graphql-import": "^0.7.1", + "js-yaml": "^3.13.1", "pg": "^7.12.1", "socket.io": "^2.2.0", "winston": "^3.2.1" diff --git a/src/app.ts b/src/app.ts index bdb3fa3..3eee246 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,43 @@ import * as express from "express"; import * as http from "http"; import * as socketIo from "socket.io"; - +import {DAO} from "./lib/DAO"; +import globals from "./lib/globals"; class App { public app: express.Application; public io: socketIo.Server; public server: http.Server; + public dao: DAO; constructor() { this.app = express(); this.server = new http.Server(this.app); this.io = socketIo(this.server); + this.dao = new DAO(); + } + + /** + * initializes everything that needs to be initialized asynchronous. + */ + public async init() { + await this.dao.init(); + this.app.all("/", (req, res) => { + res.send("WIP!"); + }); + } + + /** + * Starts the web server. + */ + public start() { + if (globals.config.server.port) { + globals.logger.info(`Starting server...`); + this.app.listen(globals.config.server.port); + globals.logger.info(`Server running on port ${globals.config.server.port}`); + } else { + globals.logger.error("No port specified in the config." + + "Please configure a port in the config.yaml."); + } } } diff --git a/src/default-config.yaml b/src/default-config.yaml new file mode 100644 index 0000000..417ccda --- /dev/null +++ b/src/default-config.yaml @@ -0,0 +1,11 @@ +# database connection info +database: + host: + port: + user: + password: + database: + +# http server configuration +server: + port: 8080 diff --git a/src/index.ts b/src/index.ts index bd59c0e..4e814b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,18 @@ +import * as fsx from "fs-extra"; import App from "./app"; -const app = new App(); +const configPath = "config.yaml"; +const defaultConfig = __dirname + "/default-config.yaml"; + +/** + * async main function wrapper. + */ +(async () => { + if (!(await fsx.pathExists(configPath))) { + await fsx.copy(defaultConfig, configPath); + } + const app = new App(); + await app.init(); + app.start(); +})(); -// TODO: init and start diff --git a/src/lib/DAO.ts b/src/lib/DAO.ts new file mode 100644 index 0000000..6349fa9 --- /dev/null +++ b/src/lib/DAO.ts @@ -0,0 +1,27 @@ +import {Pool} from "pg"; +import globals from "./globals"; +import {QueryHelper} from "./QueryHelper"; + +const config = globals.config; +const tableCreationFile = __dirname + "/../sql/create-tables.sql"; + +export class DAO { + private queryHelper: QueryHelper; + constructor() { + const dbClient: Pool = new Pool({ + database: config.database.database, + host: config.database.host, + password: config.database.password, + port: config.database.port, + user: config.database.user, + }); + this.queryHelper = new QueryHelper(dbClient, tableCreationFile); + } + + /** + * Initializes everything that needs to be initialized asynchronous. + */ + public async init() { + await this.queryHelper.createTables(); + } +} diff --git a/src/lib/QueryHelper.ts b/src/lib/QueryHelper.ts new file mode 100644 index 0000000..3431994 --- /dev/null +++ b/src/lib/QueryHelper.ts @@ -0,0 +1,128 @@ +import * as fsx from "fs-extra"; +import {Pool, PoolClient, QueryConfig, QueryResult} from "pg"; +import globals from "./globals"; + +const logger = globals.logger; + +export class SqlTransaction { + constructor(private client: PoolClient) { + } + + /** + * Begins the transaction. + */ + public async begin() { + return await this.client.query("BEGIN"); + } + + /** + * Commits the transaction + */ + public async commit() { + return await this.client.query("COMMIT"); + } + + /** + * Rolls back the transaction + */ + public async rollback() { + return await this.client.query("ROLLBACK"); + } + + /** + * Executes a query inside the transaction. + * @param query + */ + public async query(query: QueryConfig) { + return await this.client.query(query); + } + + /** + * Releases the client back to the pool. + */ + public async release() { + this.client.release(); + } +} + +export class QueryHelper { + private pool: Pool; + + constructor(pgPool: Pool, private tableCreationFile?: string) { + this.pool = pgPool; + } + + /** + * creates all tables needed if a filepath was given with the constructor + */ + public async createTables() { + if (this.tableCreationFile) { + logger.info("Creating nonexistent tables..."); + const tableSql = await fsx.readFile(this.tableCreationFile, "utf-8"); + await this.query({text: tableSql}); + } + } + + /** + * executes the sql query with values and returns all results. + * @param query + */ + public async all(query: QueryConfig): Promise { + const result = await this.query(query); + return result.rows; + } + + /** + * executes the sql query with values and returns the first result. + * @param query + */ + public async first(query: QueryConfig): Promise { + const result = await this.query(query); + if (result.rows && result.rows.length > 0) { + return result.rows[0]; + } + } + + /** + * Creates a new Transaction to be uses with error handling. + */ + public async createTransaction() { + const client: PoolClient = await this.pool.connect(); + return new SqlTransaction(client); + } + + /** + * Queries the database with error handling. + * @param query - the sql and values to execute + */ + private async query(query: QueryConfig): Promise { + try { + return await this.pool.query(query); + } catch (err) { + logger.debug(`Error on query "${query}".`); + logger.error(`Sql query failed: ${err}`); + logger.verbose(err.stack); + return { + rows: null, + }; + } + } +} + +/** + * Returns the parameterized value sql for inserting + * @param columnCount + * @param rowCount + * @param [offset] + */ +export function buildSqlParameters(columnCount: number, rowCount: number, offset?: number): string { + let sql = ""; + for (let i = 0; i < rowCount; i++) { + sql += "("; + for (let j = 0; j < columnCount; j++) { + sql += `$${(i * columnCount) + j + 1 + offset},`; + } + sql = sql.replace(/,$/, "") + "),"; + } + return sql.replace(/,$/, ""); +} diff --git a/src/lib/globals.ts b/src/lib/globals.ts new file mode 100644 index 0000000..a64aaf0 --- /dev/null +++ b/src/lib/globals.ts @@ -0,0 +1,25 @@ +import * as fsx from "fs-extra"; +import * as yaml from "js-yaml"; +import * as winston from "winston"; + +/** + * Defines global variables to be used. + */ +namespace globals { + export const config = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8")); + export const logger = winston.createLogger({ + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.colorize(), + winston.format.printf(({ level, message, label, timestamp }) => { + return `${timestamp} ${level}: ${message}`; + }), + ), + }), + ], + }); +} + +export default globals; diff --git a/src/public/graphql/schema.graphql b/src/public/graphql/schema.graphql index e69de29..f5feb67 100644 --- a/src/public/graphql/schema.graphql +++ b/src/public/graphql/schema.graphql @@ -0,0 +1,100 @@ +type Query { + "returns the user object for a given user id" + getUser(userId: ID): User + "returns the post object for a post id" + getPost(postId: ID): Post + "returns the chat object for a chat id" + getChat(chatId: ID): ChatRoom + "returns the request object for a request id" + getRequest(requestId: ID): Request + "find a post by the posted date or content" + findPost(first: Int, offset: Int, text: String!, postedDate: String): [Post] + "find a user by user name or handle" + findUser(first: Int, offset: Int, name: String!, handle: String!): [User] +} + +type Mutation { + "Upvote/downvote a Post" + vote(postId: ID!, type: [VoteType!]!): Boolean + "Report the post" + report(postId: ID!): Boolean + "lets you accept a request for a given request id" + acceptRequest(requestId: ID!): Boolean + "send a message in a Chatroom" + sendMessage(chatId: ID!, content: String!): Boolean +} + +"represents a single user account" +type User { + "url for the Profile picture of the User" + profilePicture: String! + "name of the User" + name: String! + "unique identifier name from the User" + handle: String! + "Id of the User" + id: ID! + "the total number of posts the user posted" + numberOfPosts: Int + "returns a given number of posts of a user" + getAllPosts(first: Int=10, offset: Int): [Post] + "creation date of the user account" + joinedDate: String! + "returns chats the user pinned" + pinnedChats: [ChatRoom] + "returns all friends of the user" + friends: [User] + "all request for groupChats/friends/events" + requests: [Request] +} + +"represents a single user post" +type Post { + "returns the path to the posts picture if it has one" + picture: String + "returns the text of the post" + text: String + "upvotes of the Post" + upvotes: Int! + "downvotes of the Post" + downvotes: Int! + "the user that is the author of the Post" + author: User! + "date the post was created" + creationDate: String! + "returns the type of vote the user performed on the post" + alreadyVoted: VoteType + "returns the tags of the post" + tags: [String] +} + +"represents a request of any type" +type Request { + "id of the request" + id: ID! + "type of the request" + requestType: RequestType! +} + +"represents a chatroom" +type ChatRoom { + "the members of the chatroom" + members: [User!] + "return a specfic range of messages posted in the chat" + getMessages(first: Int, offset: Int): [String] + "id of the chat" + id: ID! +} + +"represents the type of vote performed on a post" +enum VoteType { + UPVOTE + DOWNVOTE +} + +"represents the type of request that the user has received" +enum RequestType { + FRIENDREQUEST + GROUPINVITE + EVENTINVITE +} diff --git a/src/sql/create-tables.sql b/src/sql/create-tables.sql new file mode 100644 index 0000000..0d02b2f --- /dev/null +++ b/src/sql/create-tables.sql @@ -0,0 +1,57 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name varchar(128) NOT NULL, + handle varchar(128) UNIQUE NOT NULL, + password varchar(1024) NOT NULL, + email varchar(128) UNIQUE NOT NULL, + greenpoints INTEGER DEFAULT 0, + joined_at TIMESTAMP DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS posts ( + id BIGSERIAL PRIMARY KEY, + upvotes INTEGER DEFAULT 0, + downvotes INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT now(), + content text, + author SERIAL REFERENCES users (id) ON DELETE CASCADE, + type varchar(16) NOT NULL +); + +CREATE TABLE IF NOT EXISTS votes ( + user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, + item_id BIGSERIAL REFERENCES posts (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + time TIMESTAMP, + owner SERIAL REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS event_members ( + event BIGSERIAL REFERENCES events (id), + member SERIAL REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS chats ( + id BIGSERIAL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, + author SERIAL REFERENCES users (id) ON DELETE SET NULL, + content VARCHAR(1024) NOT NULL, + created_at TIMESTAMP DEFAULT now(), + PRIMARY KEY (chat, author, created_at) +); + +CREATE TABLE IF NOT EXISTS chat_members ( + chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, + member SERIAL REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_friends ( + user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, + friend_id SERIAL REFERENCES users (id) ON DELETE CASCADE +); diff --git a/tslint.json b/tslint.json index 21b8db5..78800e0 100644 --- a/tslint.json +++ b/tslint.json @@ -18,11 +18,14 @@ "no-console": { "severity": "warning", "options": ["debug", "info", "log", "time", "timeEnd", "trace"] - } + }, + "no-namespace": false, + "no-internal-module": false, + "max-classes-per-file": false }, "jsRules": { "max-line-length": { "options": [120] } } -} \ No newline at end of file +}