diff --git a/src/database/init.ts b/src/database/init.ts index 553b5a5..fb170a4 100644 --- a/src/database/init.ts +++ b/src/database/init.ts @@ -11,7 +11,15 @@ export default async function databaseInit() { CREATE TABLE IF NOT EXISTS channels( id SERIAL PRIMARY KEY, name VARCHAR(32) UNIQUE NOT NULL, - owner_id SERIAL REFERENCES users + owner_id SERIAL REFERENCES users ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS messages( + id SERIAL PRIMARY KEY, + content VARCHAR(4000) NOT NULL, + channel_id SERIAL REFERENCES channels ON DELETE CASCADE, + author_id SERIAL REFERENCES users ON DELETE CASCADE, + created_at BIGINT ); `)); } \ No newline at end of file diff --git a/src/gateway/gatewaypayloadtype.ts b/src/gateway/gatewaypayloadtype.ts index 65af6b4..b859e18 100644 --- a/src/gateway/gatewaypayloadtype.ts +++ b/src/gateway/gatewaypayloadtype.ts @@ -4,7 +4,11 @@ export enum GatewayPayloadType { Ready, Ping, - ChannelCreate = 1000, + ChannelCreate = 110, ChannelUpdate, - ChannelDelete + ChannelDelete, + + MessageCreate = 120, + MessageUpdate, + MessageDelete } diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 71019e8..7deb6d6 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -55,8 +55,10 @@ function clientUnsubscribeAll(ws: WebSocket) { export function dispatch(channel: string, message: GatewayPayload) { const members = dispatchChannels.get(channel); if (!members) return; - - members.forEach(e => e.send(JSON.stringify(message))); + + members.forEach(e => { + e.send(JSON.stringify(message)); + }); } function closeWithError(ws: WebSocket, { code, message }: { code: number, message: string }) { diff --git a/src/routes/api/v1/channels.ts b/src/routes/api/v1/channels.ts index 0ac0c7b..85a9061 100644 --- a/src/routes/api/v1/channels.ts +++ b/src/routes/api/v1/channels.ts @@ -135,6 +135,11 @@ router.get( authenticateRoute(), param("id").isNumeric(), async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + const { id } = req.params; const result = await query("SELECT id, name, owner_id FROM channels WHERE id = $1", [id]); if (result.rowCount < 1) { @@ -157,4 +162,72 @@ router.get( } ); +router.post( + "/:id/messages", + authenticateRoute(), + param("id").isNumeric(), + body("content").isLength({ min: 1, max: 4000 }), + async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + + const channelId = parseInt(req.params.id); + const { content } = req.body; + const authorId = req.user.id; + const createdAt = Date.now().toString(); + + const result = await query("INSERT INTO messages(content, channel_id, author_id, created_at) VALUES ($1, $2, $3, $4) RETURNING id", [content, channelId, authorId, createdAt]); + if (result.rowCount < 1) { + return res.status(500).json({ + ...errors.GOT_NO_DATABASE_DATA + }); + } + + const returnObject = { + id: result.rows[0].id, + content, + channel_id: channelId, + author_id: authorId, + created_at: createdAt + }; + + dispatch(`channel:${channelId}`, { + t: GatewayPayloadType.MessageCreate, + d: returnObject + }); + + return res.status(201).send(returnObject); + } +); + +router.get( + "/:id/messages", + authenticateRoute(), + param("id").isNumeric(), + async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + + const { before } = req.query; + const channelId = parseInt(req.params.id); + + let finalRows = []; + + if (before) { + const result = await query("SELECT id, content, channel_id, author_id, created_at FROM messages WHERE id < $1 AND channel_id = $2 ORDER BY id DESC LIMIT 50", [before, channelId]); + finalRows = result.rows; + } else { + const result = await query("SELECT id, content, channel_id, author_id, created_at FROM messages WHERE channel_id = $1 ORDER BY id DESC LIMIT 50", [channelId]); + finalRows = result.rows; + } + + return res.status(200).send(finalRows); + } +); + + export default router; \ No newline at end of file diff --git a/src/routes/api/v1/messages.ts b/src/routes/api/v1/messages.ts new file mode 100644 index 0000000..d836c8e --- /dev/null +++ b/src/routes/api/v1/messages.ts @@ -0,0 +1,126 @@ +import express from "express"; +import { body, param, validationResult } from "express-validator"; +import { authenticateRoute } from "../../../auth"; +import { query } from "../../../database"; +import { errors } from "../../../errors"; +import { dispatch } from "../../../gateway"; +import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype"; + +const router = express.Router(); + +router.delete( + "/:id", + authenticateRoute(), + param("id").isNumeric(), + async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + + const id = parseInt(req.params.id); // TODO: ?? + + const permissionCheckResult = await query("SELECT author_id, channel_id FROM messages WHERE id = $1", [id]); + if (permissionCheckResult.rowCount < 1) { + return res.status(404).json({ + ...errors.NOT_FOUND + }); + } + if (permissionCheckResult.rows[0].author_id !== req.user.id) { + return res.status(403).json({ + ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS + }); + } + + const result = await query("DELETE FROM messages WHERE id = $1", [id]); + if (result.rowCount < 1) { + return res.status(500).json({ + ...errors.GOT_NO_DATABASE_DATA + }); + } + + dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, { + t: GatewayPayloadType.MessageDelete, + d: { + id + } + }); + + return res.status(204).send(""); + } +); + +router.put( + "/:id", + authenticateRoute(), + body("content").isLength({ min: 1, max: 4000 }), + param("id").isNumeric(), + async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + + const { content } = req.body; + const id = parseInt(req.params.id); // TODO: ?? + + const permissionCheckResult = await query("SELECT author_id, channel_id, created_at FROM messages WHERE id = $1", [id]); + if (permissionCheckResult.rowCount < 1) { + return res.status(404).json({ + ...errors.NOT_FOUND + }); + } + if (permissionCheckResult.rows[0].author_id !== req.user.id) { + return res.status(403).json({ + ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS + }); + } + + const result = await query("UPDATE messages SET content = $1 WHERE id = $2", [content, id]); + if (result.rowCount < 1) { + return res.status(500).json({ + ...errors.GOT_NO_DATABASE_DATA + }); + } + + const returnObject = { + id, + content, + channel_id: permissionCheckResult.rows[0].channel_id, + author_id: permissionCheckResult.rows[0].author_id, + created_at: permissionCheckResult.rows[0].created_at + }; + + dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, { + t: GatewayPayloadType.MessageUpdate, + d: returnObject + }); + + return res.status(200).send(returnObject); + } +); + +router.get( + "/:id", + authenticateRoute(), + param("id").isNumeric(), + async (req, res) => { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); + } + + const { id } = req.params; + const result = await query("SELECT id, content, channel_id, author_id, created_at FROM messages WHERE id = $1", [id]); + if (result.rowCount < 1) { + return res.status(404).json({ + ...errors.NOT_FOUND + }); + } + + return res.status(200).send(result.rows[0]); + } +); + + +export default router; diff --git a/src/server.ts b/src/server.ts index 099faa3..58d9972 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import { Application, json } from "express"; import usersRouter from "./routes/api/v1/users"; import channelsRouter from "./routes/api/v1/channels"; +import messagesRouter from "./routes/api/v1/messages"; export default function(app: Application) { app.get("/", (req, res) => res.send("hello!")); @@ -8,4 +9,5 @@ export default function(app: Application) { app.use(json()); app.use("/api/v1/users", usersRouter); app.use("/api/v1/channels", channelsRouter); + app.use("/api/v1/messages", messagesRouter); }; diff --git a/test.rest b/test.rest index 0091900..9db53bb 100644 --- a/test.rest +++ b/test.rest @@ -19,13 +19,13 @@ content-type: application/json ### GET http://localhost:3000/api/v1/users/self HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc ### POST http://localhost:3000/api/v1/channels HTTP/1.1 content-type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc { "name": "yet another channel" @@ -35,7 +35,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6M PUT http://localhost:3000/api/v1/channels/5 HTTP/1.1 content-type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc #Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY { @@ -45,15 +45,50 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6M ### DELETE http://localhost:3000/api/v1/channels/1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc #Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY ### GET http://localhost:3000/api/v1/channels/1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc ### GET http://localhost:3000/api/v1/channels HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc + +### + +POST http://localhost:3000/api/v1/channels/5/messages HTTP/1.1 +content-type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc + +{ + "content": "i hate cheese" +} + +### + +GET http://localhost:3000/api/v1/channels/5/messages HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc + +### + +PUT http://localhost:3000/api/v1/messages/2 HTTP/1.1 +content-type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc + +{ + "content": "hello again!" +} + +### + +GET http://localhost:3000/api/v1/messages/2 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc + +### + +DELETE http://localhost:3000/api/v1/messages/2 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NzAwMTE0LCJleHAiOjE2NDk4NzI5MTR9.EOn8MBHZLCxfU5fHc0ZY2x9p3y-_RdD7X915L1B6Ftc