From 9a861edb6a42c9602152b151f25067375b5e7c26 Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Mon, 22 Sep 2025 13:11:45 +0300 Subject: [PATCH 1/6] feat: implement chat using websocket --- src/README.md | 69 +++++++++++++++++++++++++++ src/controllers/message.controller.js | 37 ++++++++++++++ src/index.js | 41 +++++++++++++++- src/models/Message.js | 16 +++++++ src/routes/message.route.js | 10 ++++ src/services/message.service.js | 23 +++++++++ src/utils/db.js | 10 ++++ 7 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/README.md create mode 100644 src/controllers/message.controller.js create mode 100644 src/models/Message.js create mode 100644 src/routes/message.route.js create mode 100644 src/services/message.service.js create mode 100644 src/utils/db.js diff --git a/src/README.md b/src/README.md new file mode 100644 index 000000000..d5e3c12de --- /dev/null +++ b/src/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js new file mode 100644 index 000000000..baa0a3e71 --- /dev/null +++ b/src/controllers/message.controller.js @@ -0,0 +1,37 @@ +import { messageService } from '../services/message.service.js'; + +const getAll = async (req, res) => { + const messages = await messageService.getAll(); + + res.send(messages); +}; + +const add = async (req, res) => { + try { + const messageData = req.body; + const newMessage = await messageService.add(messageData); + res.status(201).send(newMessage); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const deleteMessage = async (req, res) => { + try { + const { id } = req.params; + const deleted = await messageService.deleteMessage(id); + if (deleted) { + res.status(200).send({ message: 'Message deleted successfully' }); + } else { + res.status(404).send({ error: 'Message not found' }); + } + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +export const messageController = { + getAll, + add, + deleteMessage, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7c..49de5f3c5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,40 @@ -'use strict'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { messageRouter } from './routes/message.route.js'; +import { WebSocketServer } from 'ws'; +import { messageService } from './services/message.service.js'; + +const app = express(); +const PORT = process.env.PORT || 3005; + +app.use(cors()); +app.use(express.json()); +app.use(messageRouter); + +const server = app.listen(PORT); +const wss = new WebSocketServer({ server }); + +function broadcastMessage(message) { + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(message)); + } + }); +} + +wss.on('connection', (connection) => { + connection.on('message', async (messageBuffer) => { + try { + const messageData = JSON.parse(messageBuffer.toString()); + const newMessage = await messageService.add(messageData); + + broadcastMessage(newMessage); + } catch (error) { + console.error('Failed to process message:', error); + connection.send( + JSON.stringify({ error: 'Invalid message format or server error.' }), + ); + } + }); +}); diff --git a/src/models/Message.js b/src/models/Message.js new file mode 100644 index 000000000..432b56577 --- /dev/null +++ b/src/models/Message.js @@ -0,0 +1,16 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; + +export const Message = client.define( + 'Message', + { + text: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'messages', + timestamps: false, + }, +); diff --git a/src/routes/message.route.js b/src/routes/message.route.js new file mode 100644 index 000000000..2bc22983c --- /dev/null +++ b/src/routes/message.route.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { messageController } from '../controllers/message.controller.js'; + +const messageRouter = Router(); + +messageRouter.get('/messages', messageController.getAll); +messageRouter.post('/messages', messageController.add); +messageRouter.delete('/messages/:id', messageController.deleteMessage); + +export { messageRouter }; diff --git a/src/services/message.service.js b/src/services/message.service.js new file mode 100644 index 000000000..69f5d7375 --- /dev/null +++ b/src/services/message.service.js @@ -0,0 +1,23 @@ +import { Message } from '../models/Message.js'; + +async function getAll() { + const result = await Message.findAll(); + return result; +} + +const add = async ({ text }) => { + return await Message.create({ text }); +}; + +const deleteMessage = async (id) => { + const deletedCount = await Message.destroy({ + where: { id }, + }); + return deletedCount > 0; +}; + +export const messageService = { + getAll, + add, + deleteMessage, +}; diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 000000000..59d5dc398 --- /dev/null +++ b/src/utils/db.js @@ -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_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + dialect: 'postgres', +}); From 68611112aff3da8cbd2ac60063b62e0ecf50c894 Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Wed, 24 Sep 2025 16:04:54 +0300 Subject: [PATCH 2/6] feat: implement rooms, ability to create, rename, join and delete them --- client/App.css | 34 ++++ client/App.tsx | 216 ++++++++++++++++++++++++++ client/Username.tsx | 37 +++++ client/index.css | 3 + client/index.html | 13 ++ client/main.tsx | 11 ++ client/tsconfig.app.json | 27 ++++ client/tsconfig.json | 7 + client/tsconfig.node.json | 25 +++ client/vite-env.d.ts | 1 + client/vite.config.ts | 13 ++ src/controllers/message.controller.js | 19 ++- src/controllers/room.controller.js | 46 ++++++ src/index.js | 5 + src/models/Message.js | 21 +++ src/models/Room.js | 16 ++ src/routes/message.route.js | 3 +- src/routes/room.route.js | 11 ++ src/services/message.service.js | 20 ++- src/services/room.service.js | 34 ++++ 20 files changed, 548 insertions(+), 14 deletions(-) create mode 100644 client/App.css create mode 100644 client/App.tsx create mode 100644 client/Username.tsx create mode 100644 client/index.css create mode 100644 client/index.html create mode 100644 client/main.tsx create mode 100644 client/tsconfig.app.json create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite-env.d.ts create mode 100644 client/vite.config.ts create mode 100644 src/controllers/room.controller.js create mode 100644 src/models/Room.js create mode 100644 src/routes/room.route.js create mode 100644 src/services/room.service.js diff --git a/client/App.css b/client/App.css new file mode 100644 index 000000000..e317a6850 --- /dev/null +++ b/client/App.css @@ -0,0 +1,34 @@ +.messages { + display: flex; + flex-direction: column-reverse; + min-height: 200px; + max-height: 200px; + overflow: scroll; +} + +.input { + display: flex; + gap: 24px; +} + +.inputs { + display: flex; + gap: 24px; +} + +.room { + display: flex; + gap: 8px; +} + +.roomsList { + display: flex; + flex-direction: column; + + gap: 8px; +} + +.roomName { + margin: 0; + margin-right: 16px; +} \ No newline at end of file diff --git a/client/App.tsx b/client/App.tsx new file mode 100644 index 000000000..b136d4299 --- /dev/null +++ b/client/App.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useState } from 'react'; +import './App.css'; +import Username from './Username'; + +type Message = { + id: number, + text: string, + author: string, + time: Date, + roomId: number, +} + +type Room = { + id: number, + name: string, +} + +const App: React.FC = () => { + const [messages, setMessages] = useState([]); + const [rooms, setRooms] = useState([]); + + const [currentRoom, setCurrentRoom] = useState(null); + + const [messageInputValue, setMessageInputValue] = useState(''); + const [roomNameInputValue, setRoomNameInputValue] = useState(''); + + const [roomRenameInputValue, setRoomRenameInputValue] = useState(''); + + const [username, setUsername] = + useState(localStorage.getItem('username') || ''); + + const [socket, setSocket] = useState(null); + + const handleMessageSend = () => { + if (!username || !currentRoom) return; + if (messageInputValue && socket) { + const message = { + text: messageInputValue, + time: new Date(), + author: username, + roomId: currentRoom, + }; + socket.send(JSON.stringify(message)); + setMessageInputValue(''); + } + }; + + const handleCreateRoom = () => { + if (!roomNameInputValue) return; + + fetch('http://localhost:3005/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: roomNameInputValue }), + }) + .then(response => response.json()) + .then(data => setRooms(prev => [data, ...prev])) + .then(() => setRoomNameInputValue('')) + .catch(error => console.log(error)); + } + + const handleRenameRoom = () => { + if (!roomRenameInputValue) return; + + fetch(`http://localhost:3005/rooms/${currentRoom}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: roomRenameInputValue }), + }) + .then(response => response.json()) + .then(data => { + rooms.find(item => item.id === data.id)!.name = data.name; + }) + .then(() => { + setRoomRenameInputValue(''); + }) + .catch(error => console.log(error)); + } + + const handleDeleteRoom = (id: number) => { + if (id === currentRoom) { + setCurrentRoom(null); + setMessages([]); + } + + fetch(`http://localhost:3005/messages/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(() => { + setMessages(prev => prev.filter(message => message.roomId !== id)); + }) + .catch(error => console.log(error)); + + fetch(`http://localhost:3005/rooms/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(() => { + setRooms(prev => prev.filter(room => room.id !== id)); + }) + .catch(error => console.log(error)); + + + } + + const handleJoinRoom = (id: number) => { + setCurrentRoom(id); + + fetch(`http://localhost:3005/messages/${id}`) + .then(response => response.json()) + .then(data => setMessages(data.reverse())) + .catch(err => console.log(err)); + } + + useEffect(() => { + const newSocket = new WebSocket('ws://localhost:3005'); + + newSocket.onmessage = (event) => { + const message = JSON.parse(event.data); + setMessages(prev => [message, ...prev]); + }; + + setSocket(newSocket); + + return () => newSocket.close(); + }, [currentRoom]); + + useEffect(() => { + fetch('http://localhost:3005/rooms') + .then(response => response.json()) + .then(data => setRooms(data.reverse())) + .catch(err => console.log(err)) + }, []); + + return ( +
+ +
    + {messages.map(message => ( +
  • + {message.text} + {` - FROM ${message.author} AT `} + {new Date(message.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
  • + ))} +
+
+ setMessageInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleMessageSend(); + } + }} + /> + +
+ +
+

Current room: {rooms.find(room => room.id === currentRoom)?.name}

+

Rooms:

+
+
+ setRoomNameInputValue(event?.target.value) + } + /> + +
+ +
+ setRoomRenameInputValue(event?.target.value) + } + /> + +
+
+ +
    + {rooms.map((room) => ( +
  • +

    {room.name}

    + + +
  • + ))} +
+
+
+ ); +} + +export default App; diff --git a/client/Username.tsx b/client/Username.tsx new file mode 100644 index 000000000..9fbfa30a2 --- /dev/null +++ b/client/Username.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; + +type Props = { + username: string; + setUsername: React.Dispatch>; +} +const Username: React.FC = ({ username, setUsername }) => { + const [inputValue, setInputValue] = useState(''); + + function saveUsername() { + if (inputValue) { + setUsername(inputValue); + localStorage.setItem('username', inputValue); + setInputValue(''); + } + } + + return ( +
+ + setInputValue(e.target.value)} + placeholder="Enter your username" + /> + + + {username.length !== 0 && ( +
Your username: {username}
+ )} +
+ ); +}; + +export default Username; diff --git a/client/index.css b/client/index.css new file mode 100644 index 000000000..92080891a --- /dev/null +++ b/client/index.css @@ -0,0 +1,3 @@ +ul { + list-style: none; +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 000000000..f1114253e --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Chat + + +
+ + + diff --git a/client/main.tsx b/client/main.tsx new file mode 100644 index 000000000..c1954d427 --- /dev/null +++ b/client/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import React from 'react'; + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 000000000..227a6c672 --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 000000000..d7d419871 --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["../client/vite.config.ts"] +} diff --git a/client/vite-env.d.ts b/client/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 000000000..e42d7a8a8 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + root: __dirname, + server: { + proxy: { + '/api': 'http://localhost:3005', + }, + }, +}); \ No newline at end of file diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js index baa0a3e71..5107e4d0f 100644 --- a/src/controllers/message.controller.js +++ b/src/controllers/message.controller.js @@ -6,6 +6,12 @@ const getAll = async (req, res) => { res.send(messages); }; +const getByRoom = async (req, res) => { + const messages = await messageService.getByRoom(req.params.roomId); + + res.send(messages); +}; + const add = async (req, res) => { try { const messageData = req.body; @@ -16,14 +22,14 @@ const add = async (req, res) => { } }; -const deleteMessage = async (req, res) => { +const deleteByRoom = async (req, res) => { try { - const { id } = req.params; - const deleted = await messageService.deleteMessage(id); + const { roomId } = req.params; + const deleted = await messageService.deleteByRoom(roomId); if (deleted) { - res.status(200).send({ message: 'Message deleted successfully' }); + res.status(200); } else { - res.status(404).send({ error: 'Message not found' }); + res.status(404); } } catch (error) { res.status(500).send({ error: error.message }); @@ -32,6 +38,7 @@ const deleteMessage = async (req, res) => { export const messageController = { getAll, + getByRoom, add, - deleteMessage, + deleteByRoom, }; diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js new file mode 100644 index 000000000..da1ba6d27 --- /dev/null +++ b/src/controllers/room.controller.js @@ -0,0 +1,46 @@ +import { roomService } from '../services/room.service.js'; + +const getAll = async (req, res) => { + const rooms = await roomService.getAll(); + + res.send(rooms); +}; + +const add = async (req, res) => { + try { + const newRoom = await roomService.add(req.body); + res.status(201).send(newRoom); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const rename = async (req, res) => { + try { + const renamedRoom = await roomService.rename(req.body.name, req.params.id); + res.status(201).send(renamedRoom); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const deleteRoom = async (req, res) => { + try { + const { id } = req.params; + const deleted = await roomService.deleteRoom(id); + if (deleted) { + res.status(200).send({ message: 'Room deleted successfully' }); + } else { + res.status(404).send({ error: 'Room not found' }); + } + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +export const roomController = { + getAll, + add, + rename, + deleteRoom, +}; diff --git a/src/index.js b/src/index.js index 49de5f3c5..a71004473 100644 --- a/src/index.js +++ b/src/index.js @@ -4,13 +4,18 @@ import cors from 'cors'; import { messageRouter } from './routes/message.route.js'; import { WebSocketServer } from 'ws'; import { messageService } from './services/message.service.js'; +import { client } from './utils/db.js'; +import { roomRouter } from './routes/room.route.js'; const app = express(); const PORT = process.env.PORT || 3005; +await client.sync({ alter: true }); + app.use(cors()); app.use(express.json()); app.use(messageRouter); +app.use(roomRouter); const server = app.listen(PORT); const wss = new WebSocketServer({ server }); diff --git a/src/models/Message.js b/src/models/Message.js index 432b56577..ead05e476 100644 --- a/src/models/Message.js +++ b/src/models/Message.js @@ -1,5 +1,6 @@ import { DataTypes } from 'sequelize'; import { client } from '../utils/db.js'; +import { Room } from './Room.js'; export const Message = client.define( 'Message', @@ -8,9 +9,29 @@ export const Message = client.define( type: DataTypes.STRING, allowNull: false, }, + author: { + type: DataTypes.STRING, + allowNull: false, + }, + time: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + roomId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: Room, + key: 'id', + }, + } }, { tableName: 'messages', timestamps: false, }, ); + +Message.belongsTo(Room, { foreignKey: 'roomId' }); +Room.hasMany(Message, { foreignKey: 'roomId' }); diff --git a/src/models/Room.js b/src/models/Room.js new file mode 100644 index 000000000..a68da4ab9 --- /dev/null +++ b/src/models/Room.js @@ -0,0 +1,16 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; + +export const Room = client.define( + 'Room', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'rooms', + timestamps: false, + }, +); diff --git a/src/routes/message.route.js b/src/routes/message.route.js index 2bc22983c..a81c774cd 100644 --- a/src/routes/message.route.js +++ b/src/routes/message.route.js @@ -4,7 +4,8 @@ import { messageController } from '../controllers/message.controller.js'; const messageRouter = Router(); messageRouter.get('/messages', messageController.getAll); +messageRouter.get('/messages/:roomId', messageController.getByRoom); messageRouter.post('/messages', messageController.add); -messageRouter.delete('/messages/:id', messageController.deleteMessage); +messageRouter.delete('/messages/:roomId', messageController.deleteByRoom); export { messageRouter }; diff --git a/src/routes/room.route.js b/src/routes/room.route.js new file mode 100644 index 000000000..27432debf --- /dev/null +++ b/src/routes/room.route.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { roomController } from '../controllers/room.controller.js'; + +const roomRouter = Router(); + +roomRouter.get('/rooms', roomController.getAll); +roomRouter.post('/rooms', roomController.add); +roomRouter.patch('/rooms/:id', roomController.rename); +roomRouter.delete('/rooms/:id', roomController.deleteRoom); + +export { roomRouter }; diff --git a/src/services/message.service.js b/src/services/message.service.js index 69f5d7375..7bb2fef9b 100644 --- a/src/services/message.service.js +++ b/src/services/message.service.js @@ -1,23 +1,29 @@ import { Message } from '../models/Message.js'; -async function getAll() { +const getAll = async () => { const result = await Message.findAll(); return result; -} +}; + +const getByRoom = async (roomId) => { + const result = await Message.findAll({ where: { roomId } }); + return result; +}; -const add = async ({ text }) => { - return await Message.create({ text }); +const add = async (message) => { + return await Message.create(message); }; -const deleteMessage = async (id) => { +const deleteByRoom = async (roomId) => { const deletedCount = await Message.destroy({ - where: { id }, + where: { roomId }, }); return deletedCount > 0; }; export const messageService = { getAll, + getByRoom, add, - deleteMessage, + deleteByRoom, }; diff --git a/src/services/room.service.js b/src/services/room.service.js new file mode 100644 index 000000000..622edff6c --- /dev/null +++ b/src/services/room.service.js @@ -0,0 +1,34 @@ +import { Room } from '../models/Room.js'; + +const getAll = async () => { + const result = await Room.findAll(); + return result; +}; + +const add = async (room) => { + return await Room.create(room); +}; + +const rename = async (name, id) => { + const [updatedCount] = await Room.update({ name }, { where: { id } }); + if (updatedCount > 0) { + const updatedRoom = await Room.findByPk(id); + return updatedRoom; + } + return null; +}; + +const deleteRoom = async (id) => { + const deletedCount = await Room.destroy({ + where: { id }, + }); + console.log(deletedCount > 0); + return deletedCount > 0; +}; + +export const roomService = { + getAll, + add, + deleteRoom, + rename, +}; From 4b2ef466da7f79cd0cc81e32cf106ae694f2979f Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Wed, 24 Sep 2025 16:11:05 +0300 Subject: [PATCH 3/6] fix: fix potential issues --- src/index.js | 7 +++---- src/models/Message.js | 2 +- src/services/message.service.js | 7 ++++++- src/services/room.service.js | 8 ++++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/index.js b/src/index.js index a71004473..83fdc39fd 100644 --- a/src/index.js +++ b/src/index.js @@ -21,9 +21,9 @@ const server = app.listen(PORT); const wss = new WebSocketServer({ server }); function broadcastMessage(message) { - wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(message)); + wss.clients.forEach((cl) => { + if (cl.readyState === cl.OPEN) { + cl.send(JSON.stringify(message)); } }); } @@ -36,7 +36,6 @@ wss.on('connection', (connection) => { broadcastMessage(newMessage); } catch (error) { - console.error('Failed to process message:', error); connection.send( JSON.stringify({ error: 'Invalid message format or server error.' }), ); diff --git a/src/models/Message.js b/src/models/Message.js index ead05e476..f34361452 100644 --- a/src/models/Message.js +++ b/src/models/Message.js @@ -25,7 +25,7 @@ export const Message = client.define( model: Room, key: 'id', }, - } + }, }, { tableName: 'messages', diff --git a/src/services/message.service.js b/src/services/message.service.js index 7bb2fef9b..5d90e2e25 100644 --- a/src/services/message.service.js +++ b/src/services/message.service.js @@ -11,7 +11,12 @@ const getByRoom = async (roomId) => { }; const add = async (message) => { - return await Message.create(message); + try { + const newMessage = await Message.create(message); + return newMessage; + } catch (error) { + throw new Error('Failed to add message: ' + error.message); + } }; const deleteByRoom = async (roomId) => { diff --git a/src/services/room.service.js b/src/services/room.service.js index 622edff6c..d62fe35c6 100644 --- a/src/services/room.service.js +++ b/src/services/room.service.js @@ -6,7 +6,12 @@ const getAll = async () => { }; const add = async (room) => { - return await Room.create(room); + try { + const newRoom = await Room.create(room); + return newRoom; + } catch (error) { + throw new Error('Failed to add room: ' + error.message); + } }; const rename = async (name, id) => { @@ -22,7 +27,6 @@ const deleteRoom = async (id) => { const deletedCount = await Room.destroy({ where: { id }, }); - console.log(deletedCount > 0); return deletedCount > 0; }; From 791b55ab68c18894a5a5b44d1f4e1db570d5fc37 Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Wed, 24 Sep 2025 17:20:45 +0300 Subject: [PATCH 4/6] feat: add username sending to server, room join confirmation and room isolation on server side --- client/App.tsx | 59 ++++++++++++++++++++---- client/Username.tsx | 12 ++++- src/controllers/message.controller.js | 4 +- src/controllers/room.controller.js | 6 ++- src/index.js | 64 ++++++++++++++++++++++++--- src/routes/room.route.js | 1 + 6 files changed, 128 insertions(+), 18 deletions(-) diff --git a/client/App.tsx b/client/App.tsx index b136d4299..c570843f4 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -73,7 +73,11 @@ const App: React.FC = () => { }) .then(response => response.json()) .then(data => { - rooms.find(item => item.id === data.id)!.name = data.name; + setRooms((prev) => + prev.map((room) => + room.id === data.id ? { ...room, name: data.name } : room + ) + ); }) .then(() => { setRoomRenameInputValue(''); @@ -109,28 +113,67 @@ const App: React.FC = () => { }) .catch(error => console.log(error)); - + } const handleJoinRoom = (id: number) => { - setCurrentRoom(id); + fetch(`http://localhost:3005/rooms/${id}/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Room not found or join failed'); + } + }) + .then(data => { + console.log('Room join confirmed:', data.message); + setCurrentRoom(id); + + if (socket) { + socket.send(JSON.stringify({ + type: 'join', + roomId: id + })); + } - fetch(`http://localhost:3005/messages/${id}`) + return fetch(`http://localhost:3005/messages/${id}`); + }) .then(response => response.json()) .then(data => setMessages(data.reverse())) - .catch(err => console.log(err)); } useEffect(() => { const newSocket = new WebSocket('ws://localhost:3005'); newSocket.onmessage = (event) => { - const message = JSON.parse(event.data); - setMessages(prev => [message, ...prev]); + const data = JSON.parse(event.data); + + if (data.type === 'history') { + setMessages(data.messages); + return; + } + + if (data.type === 'join-success' || data.type === 'join-error') return; + + setMessages(prev => [data, ...prev]); }; setSocket(newSocket); + if (currentRoom) { + newSocket.onopen = () => { + newSocket.send(JSON.stringify({ + type: 'join', + roomId: currentRoom + })); + }; + } + return () => newSocket.close(); }, [currentRoom]); @@ -143,7 +186,7 @@ const App: React.FC = () => { return (
- +
    {messages.map(message => (
  • diff --git a/client/Username.tsx b/client/Username.tsx index 9fbfa30a2..66573ac02 100644 --- a/client/Username.tsx +++ b/client/Username.tsx @@ -3,14 +3,24 @@ import React, { useState } from 'react'; type Props = { username: string; setUsername: React.Dispatch>; + socket: WebSocket | null; } -const Username: React.FC = ({ username, setUsername }) => { +const Username: React.FC = ({ username, setUsername, socket }) => { const [inputValue, setInputValue] = useState(''); function saveUsername() { if (inputValue) { setUsername(inputValue); localStorage.setItem('username', inputValue); + + + if (socket) { + socket.send(JSON.stringify({ + type: 'set-username', + username: inputValue + })); + } + setInputValue(''); } } diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js index 5107e4d0f..6e11bff7e 100644 --- a/src/controllers/message.controller.js +++ b/src/controllers/message.controller.js @@ -27,9 +27,9 @@ const deleteByRoom = async (req, res) => { const { roomId } = req.params; const deleted = await messageService.deleteByRoom(roomId); if (deleted) { - res.status(200); + res.sendStatus(200); } else { - res.status(404); + res.status(404).send({ error: 'Messages for this room not found' }); } } catch (error) { res.status(500).send({ error: error.message }); diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js index da1ba6d27..318da48b2 100644 --- a/src/controllers/room.controller.js +++ b/src/controllers/room.controller.js @@ -18,7 +18,10 @@ const add = async (req, res) => { const rename = async (req, res) => { try { const renamedRoom = await roomService.rename(req.body.name, req.params.id); - res.status(201).send(renamedRoom); + if (!renamedRoom) { + return res.status(404).send({ error: 'Room not found' }); + } + res.status(200).send(renamedRoom); } catch (error) { res.status(500).send({ error: error.message }); } @@ -43,4 +46,5 @@ export const roomController = { add, rename, deleteRoom, + joinRoom, }; diff --git a/src/index.js b/src/index.js index 83fdc39fd..157cef403 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { WebSocketServer } from 'ws'; import { messageService } from './services/message.service.js'; import { client } from './utils/db.js'; import { roomRouter } from './routes/room.route.js'; +import { roomService } from './services/room.service.js'; const app = express(); const PORT = process.env.PORT || 3005; @@ -20,10 +21,12 @@ app.use(roomRouter); const server = app.listen(PORT); const wss = new WebSocketServer({ server }); -function broadcastMessage(message) { - wss.clients.forEach((cl) => { - if (cl.readyState === cl.OPEN) { - cl.send(JSON.stringify(message)); +const connectionRooms = new Map(); + +function broadcastToRoom(message, roomId) { + wss.clients.forEach((client) => { + if (client.readyState === client.OPEN && connectionRooms.get(client) === roomId) { + client.send(JSON.stringify(message)); } }); } @@ -32,13 +35,62 @@ wss.on('connection', (connection) => { connection.on('message', async (messageBuffer) => { try { const messageData = JSON.parse(messageBuffer.toString()); + + if (messageData.type === 'set-username') { + connection.username = messageData.username; + return; + } + + if (messageData.type === 'join') { + const roomId = messageData.roomId; + + try { + const rooms = await roomService.getAll(); + const roomExists = rooms.some(room => room.id === roomId); + + if (!roomExists) { + connection.send(JSON.stringify({ + type: 'join-error', + error: 'Room not found' + })); + return; + } + + connectionRooms.set(connection, roomId); + + connection.send(JSON.stringify({ + type: 'join-success', + roomId: roomId, + message: 'Successfully joined room' + })); + + const history = await messageService.getByRoom(roomId); + connection.send(JSON.stringify({ + type: 'history', + messages: history.reverse() + })); + } catch (error) { + connection.send(JSON.stringify({ + type: 'join-error', + error: 'Failed to join room' + })); + } + return; + } + const newMessage = await messageService.add(messageData); - - broadcastMessage(newMessage); + const roomId = connectionRooms.get(connection); + if (roomId) { + broadcastToRoom(newMessage, roomId); + } } catch (error) { connection.send( JSON.stringify({ error: 'Invalid message format or server error.' }), ); } }); + + connection.on('close', () => { + connectionRooms.delete(connection); + }); }); diff --git a/src/routes/room.route.js b/src/routes/room.route.js index 27432debf..3d78e96d0 100644 --- a/src/routes/room.route.js +++ b/src/routes/room.route.js @@ -5,6 +5,7 @@ const roomRouter = Router(); roomRouter.get('/rooms', roomController.getAll); roomRouter.post('/rooms', roomController.add); +roomRouter.post('/rooms/:id/join', roomController.joinRoom); roomRouter.patch('/rooms/:id', roomController.rename); roomRouter.delete('/rooms/:id', roomController.deleteRoom); From 87f7fe2461ff80146c3bbb851d4ef5617ecd4c20 Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Wed, 24 Sep 2025 17:39:07 +0300 Subject: [PATCH 5/6] fix: add joinRoom function --- src/controllers/room.controller.js | 19 +++++++++++++++++++ src/index.js | 10 +++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js index 318da48b2..7b59a6fad 100644 --- a/src/controllers/room.controller.js +++ b/src/controllers/room.controller.js @@ -41,6 +41,25 @@ const deleteRoom = async (req, res) => { } }; +const joinRoom = async (req, res) => { + try { + const { id } = req.params; + const rooms = await roomService.getAll(); + const room = rooms.find(r => r.id === parseInt(id)); + + if (!room) { + return res.status(404).send({ error: 'Room not found' }); + } + + res.status(200).send({ + message: 'Successfully joined room', + room: room + }); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + export const roomController = { getAll, add, diff --git a/src/index.js b/src/index.js index 157cef403..454aa6892 100644 --- a/src/index.js +++ b/src/index.js @@ -24,9 +24,9 @@ const wss = new WebSocketServer({ server }); const connectionRooms = new Map(); function broadcastToRoom(message, roomId) { - wss.clients.forEach((client) => { - if (client.readyState === client.OPEN && connectionRooms.get(client) === roomId) { - client.send(JSON.stringify(message)); + wss.clients.forEach((cl) => { + if (cl.readyState === cl.OPEN && connectionRooms.get(cl) === roomId) { + cl.send(JSON.stringify(message)); } }); } @@ -42,11 +42,11 @@ wss.on('connection', (connection) => { } if (messageData.type === 'join') { - const roomId = messageData.roomId; + const id = messageData.roomId; try { const rooms = await roomService.getAll(); - const roomExists = rooms.some(room => room.id === roomId); + const roomExists = rooms.some(room => room.id === id); if (!roomExists) { connection.send(JSON.stringify({ From fab06bb35a9e2cbc6446106ed18ee4575cdf4a59 Mon Sep 17 00:00:00 2001 From: Mykhailo Date: Wed, 24 Sep 2025 17:41:53 +0300 Subject: [PATCH 6/6] fix: fix potential issue --- src/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 454aa6892..1bfadc1a0 100644 --- a/src/index.js +++ b/src/index.js @@ -56,15 +56,15 @@ wss.on('connection', (connection) => { return; } - connectionRooms.set(connection, roomId); + connectionRooms.set(connection, id); connection.send(JSON.stringify({ type: 'join-success', - roomId: roomId, + roomId: id, message: 'Successfully joined room' })); - const history = await messageService.getByRoom(roomId); + const history = await messageService.getByRoom(id); connection.send(JSON.stringify({ type: 'history', messages: history.reverse()