-
Notifications
You must be signed in to change notification settings - Fork 275
Завдання Chat App #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Завдання Chat App #140
Changes from all commits
36fa326
542cc18
446e2d9
52b6863
1c7dfcb
ed543fd
46cead8
475c4b0
dd4e565
c66d602
b82c44b
67f7ab4
7ebf747
7fccc5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { EventEmitter } from 'events'; | ||
| import { messageService } from '../services/message.service.js'; | ||
| import { User } from '../models/user.model.js'; | ||
|
|
||
| export const messageEmitter = new EventEmitter(); | ||
|
|
||
| const create = async (req, res) => { | ||
| const { roomId } = req.params; | ||
| const { text, userId } = req.body; | ||
|
|
||
| if (!roomId || !userId || !text || typeof text !== 'string') { | ||
| return res.status(400).json({ message: 'Missing or invalid parameters' }); | ||
| } | ||
|
|
||
| try { | ||
| const newMessage = await messageService.createMessageInRoom( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Be careful: |
||
| text, | ||
| userId, | ||
| roomId, | ||
| ); | ||
|
|
||
| const user = await User.findByPk(userId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're fetching the |
||
|
|
||
| if (!user) { | ||
| return res.status(404).json({ message: 'User not found' }); | ||
| } | ||
|
|
||
| const formattedMessage = { | ||
| author: user.name, | ||
| time: newMessage.createdAt, | ||
| text: newMessage.text, | ||
| }; | ||
|
Comment on lines
+28
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| messageEmitter.emit('message', formattedMessage); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When emitting the message, ensure you emit a canonical DTO like |
||
|
|
||
| res.status(201).json(formattedMessage); | ||
| } catch (err) { | ||
| // eslint-disable-next-line no-console | ||
| console.error('Error creating message:', err); | ||
| res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| }; | ||
|
|
||
| const getMessages = async (req, res) => { | ||
| const { roomId } = req.params; | ||
|
|
||
| if (!roomId) { | ||
| return res.status(400).json({ message: 'Missing roomId' }); | ||
| } | ||
|
|
||
| try { | ||
| const messages = await messageService.getAllMessagesInRoom(roomId); | ||
|
|
||
| const formattedMessages = messages.map((m) => ({ | ||
| author: m.User?.name || 'Unknown', | ||
| time: m.createdAt, | ||
| text: m.text, | ||
| })); | ||
|
Comment on lines
+54
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| res.status(200).json(formattedMessages); | ||
| } catch (err) { | ||
| // eslint-disable-next-line no-console | ||
| console.error('Error getting messages:', err); | ||
| res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| }; | ||
|
|
||
| export const messageController = { | ||
| create, | ||
| getMessages, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { roomsService } from '../services/rooms.service.js'; | ||
|
|
||
| const getAllRooms = async (req, res) => { | ||
| const rooms = await roomsService.getAllRooms(); | ||
|
|
||
| res.status(200).send(rooms); | ||
| }; | ||
|
|
||
| const createRoom = async (req, res) => { | ||
| const { title, userId, description } = req.body; | ||
|
|
||
| if (!title || !userId) { | ||
| return res.sendStatus(404); | ||
|
Comment on lines
+12
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Returning 404 for missing |
||
| } | ||
|
|
||
| await roomsService.createRoom(title, userId, description); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calls to the service (create/update/delete) are not wrapped in try/catch. If the service throws (for example, room not found or DB error), the error will bubble and result in an uncontrolled 500. Wrap these awaits in try/catch and return meaningful statuses (e.g., 201/204 on success, 404 if service reports not found, 500 for unexpected errors). |
||
|
|
||
| res.sendStatus(201); | ||
| }; | ||
|
|
||
| const updateRoom = async (req, res) => { | ||
| const { roomId } = req.params; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const { title, description } = req.body; | ||
|
|
||
| if ((!title && !description) || !roomId) { | ||
| return res.sendStatus(404); | ||
|
Comment on lines
+25
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This validation returns 404 when no |
||
| } | ||
|
|
||
| await roomsService.updateRoom(roomId, title, description); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above: |
||
|
|
||
| res.sendStatus(204); | ||
| }; | ||
|
|
||
| const deleteRoom = async (req, res) => { | ||
| const { roomId } = req.params; | ||
|
|
||
| if (!roomId) { | ||
| return res.sendStatus(400); | ||
| } | ||
|
|
||
| await roomsService.deleteRoom(roomId); | ||
|
|
||
| res.sendStatus(204); | ||
| }; | ||
|
|
||
| export const roomController = { | ||
| getAllRooms, | ||
| createRoom, | ||
| updateRoom, | ||
| deleteRoom, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { User } from '../models/user.js'; | ||
| import { userService } from '../services/user.service.js'; | ||
|
|
||
| const create = async (req, res) => { | ||
| try { | ||
| const { name } = req.body; | ||
|
|
||
| if (!name) { | ||
| return res.status(400).json({ message: 'Name is required' }); | ||
| } | ||
|
|
||
| const isNameExist = await User.findOne({ where: { name } }); | ||
|
|
||
| if (isNameExist) { | ||
| return res.status(409).json({ message: 'User already exists' }); | ||
| } | ||
|
|
||
| await userService.createUser(name); | ||
|
|
||
| return res.status(201).json({ message: 'User was created!' }); | ||
| } catch { | ||
| return res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| }; | ||
|
|
||
| export const userController = { | ||
| create, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import 'dotenv/config'; | ||
| import { Sequelize } from 'sequelize'; | ||
|
|
||
| export const client = new Sequelize({ | ||
| host: process.env.DB_HOST, | ||
| username: process.env.DB_USERNAME, | ||
| password: process.env.DB_PASS, | ||
| database: process.env.DB_DATABASE, | ||
| dialect: 'postgres', | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,83 @@ | ||
| 'use strict'; | ||
| import 'dotenv/config'; | ||
| import express from 'express'; | ||
| import { WebSocketServer } from 'ws'; | ||
| import cors from 'cors'; | ||
| import { userRoute } from './routes/user.route.js'; | ||
| import { roomsRoute } from './routes/rooms.route.js'; | ||
| import { messageRoute } from './routes/message.route.js'; | ||
| import { messageEmitter } from './controllers/message.controller.js'; | ||
| import { messageService } from './services/message.service.js'; | ||
|
|
||
| const PORT = process.env.PORT || 5000; | ||
| const app = express(); | ||
|
|
||
| app.use(cors()); | ||
| app.use(express.json()); | ||
|
|
||
| app.use('/user', userRoute); | ||
| app.use('/rooms', roomsRoute); | ||
| app.use(messageRoute); | ||
|
|
||
| const server = app.listen(PORT); | ||
|
|
||
| const wss = new WebSocketServer({ server }); | ||
| const rooms = {}; | ||
|
|
||
| wss.on('connection', (ws) => { | ||
| ws.on('message', async (rawMessage) => { | ||
| let parsed; | ||
|
|
||
| try { | ||
| parsed = JSON.parse(rawMessage); | ||
| } catch { | ||
| return; | ||
| } | ||
|
|
||
| const { roomId } = parsed; | ||
|
|
||
| if (!roomId || typeof roomId !== 'number') { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This rejects non-numeric room IDs. The Room model uses UUID strings, so this check prevents valid room IDs (UUIDs) from being accepted. Remove the numeric-type requirement or accept string UUIDs instead so clients can join rooms correctly. |
||
| return; | ||
| } | ||
|
|
||
| if (!rooms[roomId]) { | ||
| rooms[roomId] = []; | ||
| } | ||
|
|
||
| if (!rooms[roomId].includes(ws)) { | ||
| rooms[roomId].push(ws); | ||
| } | ||
|
|
||
| const messages = await messageService.getAllMessagesInRoom(roomId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When sending previous messages to the newly-joined socket, ensure |
||
|
|
||
| messages.forEach((msg) => { | ||
| ws.send(JSON.stringify(msg)); | ||
| }); | ||
| }); | ||
|
|
||
| ws.on('close', () => { | ||
| for (const roomId in rooms) { | ||
| rooms[roomId] = rooms[roomId].filter((client) => client !== ws); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| messageEmitter.on('message', async (message) => { | ||
| const { roomId } = message; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You destructure |
||
|
|
||
| if (!roomId || !rooms[roomId]) { | ||
| return; | ||
| } | ||
|
|
||
| const formatted = { | ||
| author: message.author || message.User?.name || 'Unknown', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The formatted message probes |
||
| time: message.time || message.createdAt, | ||
| text: message.text, | ||
| }; | ||
|
|
||
| rooms[roomId].forEach((client) => { | ||
| if (client.readyState === 1) { | ||
| client.send(JSON.stringify(formatted)); | ||
| } | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { DataTypes } from 'sequelize'; | ||
| import { client } from '../db.js'; | ||
| import { User } from './user.js'; | ||
| import { Room } from './room.js'; | ||
|
|
||
| export const Message = client.define( | ||
| 'message', | ||
| { | ||
| id: { | ||
| type: DataTypes.UUID, | ||
| defaultValue: DataTypes.UUIDV4, | ||
| allowNull: false, | ||
| primaryKey: true, | ||
| }, | ||
| text: { | ||
| type: DataTypes.STRING, | ||
| allowNull: false, | ||
| }, | ||
| createdAt: { | ||
| type: DataTypes.DATE, | ||
| defaultValue: DataTypes.NOW, | ||
| allowNull: false, | ||
| }, | ||
| }, | ||
| { | ||
| updatedAt: false, | ||
| tableName: 'messages', | ||
| }, | ||
| ); | ||
|
|
||
| Message.belongsTo(User, { foreignKey: 'userId' }); | ||
| User.hasMany(Message, { foreignKey: 'userId' }); | ||
|
|
||
| Message.belongsTo(Room, { foreignKey: 'roomId' }); | ||
| Room.hasMany(Message, { foreignKey: 'roomId' }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { DataTypes } from 'sequelize'; | ||
| import { client } from '../db.js'; | ||
| import { User } from './user.js'; | ||
|
|
||
| export const Room = client.define('room', { | ||
| id: { | ||
| type: DataTypes.UUID, | ||
| defaultValue: DataTypes.UUIDV4, | ||
| allowNull: false, | ||
| primaryKey: true, | ||
| }, | ||
| title: { | ||
| type: DataTypes.STRING, | ||
| allowNull: false, | ||
| }, | ||
| description: { | ||
| type: DataTypes.STRING, | ||
| allowNull: false, | ||
|
Comment on lines
+16
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding a |
||
| }, | ||
| }); | ||
|
|
||
| Room.belongsTo(User); | ||
| User.hasMany(Room); | ||
|
Comment on lines
+22
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Be explicit about the foreign key to ensure consistent column naming ( |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { DataTypes } from 'sequelize'; | ||
| import { client } from '../db.js'; | ||
|
|
||
| export const User = client.define('user', { | ||
| id: { | ||
| type: DataTypes.UUID, | ||
| defaultValue: DataTypes.UUIDV4, | ||
| allowNull: false, | ||
| primaryKey: true, | ||
| }, | ||
| name: { | ||
| type: DataTypes.STRING, | ||
| unique: true, | ||
| allowNull: false, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import express from 'express'; | ||
| import { messageController } from '../controllers/message.controller.js'; | ||
|
|
||
| export const messageRoute = new express.Router(); | ||
|
|
||
| messageRoute.get('/rooms/:roomId/messages', messageController.getMessages); | ||
| messageRoute.post('/rooms/:roomId/messages', messageController.create); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import express from 'express'; | ||
| import { roomController } from '../controllers/rooms.controller.js'; | ||
|
|
||
| export const roomsRoute = new express.Router(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| roomsRoute.get('/', roomController.getAllRooms); | ||
| roomsRoute.post('/', roomController.createRoom); | ||
| roomsRoute.patch('/:roomId', roomController.updateRoom); | ||
| roomsRoute.delete('/:roomId', roomController.deleteRoom); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import express from 'express'; | ||
| import { userController } from '../controllers/user.controller.js'; | ||
|
|
||
| export const userRoute = new express.Router(); | ||
|
|
||
| userRoute.post('/', userController.create); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { Message } from '../models/message.js'; | ||
| import { User } from '../models/user.js'; | ||
|
|
||
| const getAllMessagesInRoom = async (roomId) => { | ||
| if (!roomId) { | ||
| throw new Error('roomId is required'); | ||
| } | ||
|
|
||
| const messages = await Message.findAll({ | ||
| where: { roomId }, | ||
| include: [{ model: User, attributes: ['name'] }], | ||
| order: [['createdAt', 'ASC']], | ||
| }); | ||
|
|
||
| return messages.map((msg) => ({ | ||
| author: msg.user?.name || 'Unknown', | ||
|
Comment on lines
+15
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When mapping messages you access the included user as |
||
| time: msg.createdAt, | ||
| text: msg.text, | ||
| roomId: msg.roomId, | ||
| })); | ||
| }; | ||
|
|
||
| const createMessageInRoom = async (text, userId, roomId) => { | ||
| if (!text || !userId || !roomId) { | ||
| throw new Error('Missing required fields'); | ||
| } | ||
|
|
||
| const newMessage = await Message.create({ text, userId, roomId }); | ||
|
|
||
| const messageWithUser = await Message.findByPk(newMessage.id, { | ||
| include: [{ model: User, attributes: ['name'] }], | ||
| }); | ||
|
|
||
| return { | ||
| author: messageWithUser.user?.name || 'Unknown', | ||
|
Comment on lines
+34
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After creating a message you fetch |
||
| time: messageWithUser.createdAt, | ||
| text: messageWithUser.text, | ||
| roomId: messageWithUser.roomId, | ||
| }; | ||
| }; | ||
|
|
||
| export const messageService = { | ||
| getAllMessagesInRoom, | ||
| createMessageInRoom, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import path is wrong. The code imports
Userfrom../models/user.model.js, but the actual model file issrc/models/user.js(exported asUser). This will throw a module-not-found error at runtime; change to../models/user.jsor the correct relative path.