diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js new file mode 100644 index 000000000..e69de29bb diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js new file mode 100644 index 000000000..333b824d4 --- /dev/null +++ b/src/controllers/room.controller.js @@ -0,0 +1,153 @@ +import { messageService } from '../services/message.service.js'; +import { roomService } from '../services/room.service.js'; +import { Message } from '../data/message.js'; +import { userService } from '../services/user.service.js'; + +const getRoomInfoByRoomId = async (req, res) => { + const { roomId } = req.params; + + const id = parseInt(roomId, 10); + + if (isNaN(id)) { + return res.status(400).json({ message: 'Invalid roomId' }); + } + + const room = await roomService.findRoomById(id); + + if (!room) { + return res.status(404).json({ message: 'Room not Found' }); + } + + const messages = await messageService.findRoomsMessages(id); + + return res.status(200).json({ + room: { + id: room.id, + name: room.name, + }, + messages, + }); +}; + +const createRoom = async (req, res) => { + const { name } = req.body; + const user = req.user; + + if (!name) { + return res.status(400).json({ message: 'Room name is required' }); + } + + const newRoom = await roomService.createRoom(name); + + if (!newRoom) { + return res.status(400).json({ message: 'Cannot create the room' }); + } + + await newRoom.addUser(user); + + const roomUsers = await newRoom.getUsers(); + const roomInfo = { + room: { id: newRoom.id, name: newRoom.name }, + members: roomUsers, + }; + + return res.status(201).json(roomInfo); +}; + +const deleteRoom = async (req, res) => { + const { roomId } = req.params; + const room = await roomService.findRoomById(roomId); + + if (!roomId || !room) { + return res.status(404).json({ message: 'Room not Found' }); + } + + await room.setUsers([]); + await Message.destroy({ where: { roomId: room.id } }); + await room.destroy(); + + res.status(200).json({ message: 'Room deleted successfully' }); +}; + +const renameRoom = async (req, res) => { + const room = req.room; + const { newName } = req.body; + + if (!room) { + return res.status(404).json({ message: 'Room not Found' }); + } + + if (!newName || typeof newName !== 'string' || !newName.trim()) { + return res.status(400).json({ message: 'Invalid room name provided' }); + } + + room.name = newName.trim(); + await room.save(); + + return res.status(200).json(room); +}; + +const mergeRooms = async (req, res) => { + const currentRoom = req.room; + const user = req.user; + const { targetRoomId } = req.body; + + if (!user) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + if (!currentRoom) { + return res.status(404).json({ error: 'Current room not found' }); + } + + if (!targetRoomId) { + return res.status(400).json({ error: 'No targetRoomId provided' }); + } + + const targetRoomIdNum = parseInt(targetRoomId, 10); + + if (Number.isNaN(targetRoomIdNum)) { + return res.status(400).json({ error: 'Invalid targetRoomId' }); + } + + const usersRooms = await roomService.findRoomsByUserId(user.id); + const targetRoom = usersRooms.find((r) => r.id === targetRoomIdNum); + + if (!targetRoom) { + return res + .status(403) + .json({ error: 'The user doesn’t have access to the target room' }); + } + + await userService.mergeUsers(currentRoom.id, targetRoomIdNum); + await messageService.mergeMessages(currentRoom.id, targetRoomIdNum); + + await currentRoom.setUsers([]); + await currentRoom.destroy(); + + return res.status(200).json(targetRoom); +}; + +const joinRoom = async (req, res) => { + const { roomId } = req.params; + const userId = req.user.id; + + const room = await roomService.findRoomById(roomId); + + if (!room) { + return res.status(404).json({ message: 'Room not found' }); + } + + await roomService.addUserToRoom(userId, roomId); + + return res.status(200).json({ message: 'Joined room successfully' }); +}; + +export const roomController = { + getRoomInfoByRoomId, + createRoom, + deleteRoom, + renameRoom, + mergeRooms, + joinRoom, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 000000000..17ccb09e5 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,76 @@ +import { roomService } from '../services/room.service.js'; +import { userService } from '../services/user.service.js'; + +const createUser = async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Name is required' }); + } + + const newUser = await userService.createUser(name); + + res.status(201).json(newUser); +}; + +const getUserInfo = async (req, res) => { + const { userId } = req.params; + + if (!userId) { + return res.status(400).json({ message: 'Id is required' }); + } + + const user = await userService.getUserById(userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const rooms = await roomService.findRoomsByUserId(user.id); + const userInfo = { + user: { id: user.id, name: user.name, createdAt: user.createdAt }, + rooms, + }; + + res.status(200).json(userInfo); +}; + +const changeUserName = async (req, res) => { + const user = req.user; + const { newName } = req.body; + + if (!user) { + return res.status(404).json({ message: 'User not Found' }); + } + + if (!newName) { + return res.status(400).json({ message: 'No new name provided' }); + } + + user.name = newName; + + await user.save(); + + res.status(200).json({ id: user.id, userName: user.name }); +}; + +const deleteUser = async (req, res) => { + const user = req.user; + + if (!user) { + return res.status(404).json({ message: 'User not Found' }); + } + + await user.destroy(); + + req.user = null; + + res.status(200).json({ message: 'User deleted successfully' }); +}; + +export const userController = { + createUser, + getUserInfo, + changeUserName, + deleteUser, +}; diff --git a/src/createServer.js b/src/createServer.js new file mode 100644 index 000000000..bfd29eafc --- /dev/null +++ b/src/createServer.js @@ -0,0 +1,31 @@ +import express from 'express'; +import cors from 'cors'; + +import { userRouter } from './routes/user.router.js'; +import { roomRouter } from './routes/room.router.js'; + +export function createServer() { + const server = express(); + + server.use(express.json()); + + server.use( + cors({ + origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000', + credentials: true, + }), + ); + + server.get('/', (req, res) => { + res.status(200).json({ message: 'Server is running' }); + }); + + server.use('/user', userRouter); + server.use('/room', roomRouter); + + server.use((req, res, next) => { + res.status(404).json({ message: 'Route not found' }); + }); + + return server; +} diff --git a/src/data/associations.js b/src/data/associations.js new file mode 100644 index 000000000..9a377d3f1 --- /dev/null +++ b/src/data/associations.js @@ -0,0 +1,12 @@ +import { User } from './user.js'; +import { Message } from './message.js'; +import { Room } from './room.js'; + +User.hasMany(Message, { foreignKey: 'userId' }); +Message.belongsTo(User, { foreignKey: 'userId' }); + +Room.hasMany(Message, { foreignKey: 'roomId' }); +Message.belongsTo(Room, { foreignKey: 'roomId' }); + +User.belongsToMany(Room, { through: 'user_room', foreignKey: 'userId' }); +Room.belongsToMany(User, { through: 'user_room', foreignKey: 'roomId' }); diff --git a/src/data/message.js b/src/data/message.js new file mode 100644 index 000000000..8acedae35 --- /dev/null +++ b/src/data/message.js @@ -0,0 +1,29 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../db/db.js'; + +export const Message = client.define( + 'Message', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + roomId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + text: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + timestamps: true, + tableName: 'messages', + }, +); diff --git a/src/data/room.js b/src/data/room.js new file mode 100644 index 000000000..e5c6a4da4 --- /dev/null +++ b/src/data/room.js @@ -0,0 +1,22 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../db/db.js'; + +export const Room = client.define( + 'Room', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, + { + timestamps: true, + tableName: 'rooms', + }, +); diff --git a/src/data/user.js b/src/data/user.js new file mode 100644 index 000000000..c5beef6c0 --- /dev/null +++ b/src/data/user.js @@ -0,0 +1,22 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../db/db.js'; + +export const User = client.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, + { + timestamps: true, + tableName: 'users', + }, +); diff --git a/src/db/db.init.js b/src/db/db.init.js new file mode 100644 index 000000000..dbc961069 --- /dev/null +++ b/src/db/db.init.js @@ -0,0 +1,17 @@ +import { client } from './db.js'; +import '../data/associations.js'; + +export const dbInit = async () => { + await client.authenticate(); + // eslint-disable-next-line no-console + console.log('✅ Database connected'); + + await client.sync({ alter: true }); + // eslint-disable-next-line no-console + console.log('✅ Tables synced'); + + const tables = await client.getQueryInterface().showAllTables(); + + // eslint-disable-next-line no-console + console.log(tables); +}; diff --git a/src/db/db.js b/src/db/db.js new file mode 100644 index 000000000..b80224efd --- /dev/null +++ b/src/db/db.js @@ -0,0 +1,10 @@ +import { Sequelize } from 'sequelize'; + +export const client = new Sequelize({ + database: 'postgres', + username: 'postgres', + host: 'localhost', + dialect: 'postgres', + port: 5432, + password: 'Rerlol100', +}); diff --git a/src/index.js b/src/index.js index ad9a93a7c..7aaad7e01 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,31 @@ 'use strict'; + +import { createServer } from './createServer.js'; +import http from 'http'; +import { Server as SocketIOServer } from 'socket.io'; +import { messageServer } from './messageServer.js'; +import { dbInit } from './db/db.init.js'; + +async function start() { + try { + await dbInit(); + + const app = createServer(); + const httpServer = http.createServer(app); + const io = new SocketIOServer(httpServer, { + cors: { origin: '*', credentials: true }, + }); + + messageServer(io); + + httpServer.listen(3000, () => { + // eslint-disable-next-line no-console + console.log('Server running on port 3000'); + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('❌ Failed to start server:', err); + } +} + +start(); diff --git a/src/messageServer.js b/src/messageServer.js new file mode 100644 index 000000000..91b4dce0f --- /dev/null +++ b/src/messageServer.js @@ -0,0 +1,32 @@ +import { messageService } from './services/message.service.js'; + +export const messageServer = (io) => { + io.on('connection', (socket) => { + // eslint-disable-next-line no-console + console.log('Новий користувач підключився:', socket.id); + + socket.on('joinRoom', ({ roomId }) => { + socket.join(`room_${roomId}`); + }); + + socket.on('newMessage', async ({ roomId, userId, text }) => { + try { + const message = await messageService.createMessage({ + roomId, + userId, + text, + }); + + io.to(`room_${roomId}`).emit('messageBroadcast', message); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error in newMessage handler:', err); + } + }); + + socket.on('disconnect', () => { + // eslint-disable-next-line no-console + console.log('Користувач відключився:', socket.id); + }); + }); +}; diff --git a/src/midlewares/isAuth.js b/src/midlewares/isAuth.js new file mode 100644 index 000000000..134a72686 --- /dev/null +++ b/src/midlewares/isAuth.js @@ -0,0 +1,17 @@ +import { userService } from '../services/user.service.js'; + +export const isAuth = async (req, res, next) => { + const userName = req.headers['x-username']; + + if (!userName) { + return res.status(401).json({ error: 'Користувач не авторизований' }); + } + + const trueUser = await userService.getUserByName(userName); + + if (!trueUser) { + return res.status(401).json({ error: 'Користувач не авторизований' }); + } + req.user = trueUser; + next(); +}; diff --git a/src/midlewares/isNotAuth.js b/src/midlewares/isNotAuth.js new file mode 100644 index 000000000..7a3e2fc57 --- /dev/null +++ b/src/midlewares/isNotAuth.js @@ -0,0 +1,17 @@ +import { userService } from '../services/user.service.js'; + +export const isNotAuth = async (req, res, next) => { + const userName = req.headers['x-username']; + + if (!userName) { + return next(); + } + + const trueUser = await userService.getUserByName(userName); + + if (trueUser) { + return res.status(401).json({ error: 'Користувач авторизований' }); + } + + next(); +}; diff --git a/src/midlewares/isUserInRoom.js b/src/midlewares/isUserInRoom.js new file mode 100644 index 000000000..8116a62e2 --- /dev/null +++ b/src/midlewares/isUserInRoom.js @@ -0,0 +1,19 @@ +import { roomService } from '../services/room.service.js'; + +export const isUserInRoom = async (req, res, next) => { + const user = req.user; + const { roomId } = req.params; + const usersRooms = await roomService.findRoomsByUserId(user.id); + + const room = usersRooms.find((r) => r.id === parseInt(roomId, 10)); + + if (!room) { + return res + .status(401) + .json({ error: 'Користувач не має доступа до кімнати' }); + } + + req.room = room; + + next(); +}; diff --git a/src/routes/room.router.js b/src/routes/room.router.js new file mode 100644 index 000000000..47b57ef7c --- /dev/null +++ b/src/routes/room.router.js @@ -0,0 +1,43 @@ +import express from 'express'; +import { isAuth } from '../midlewares/isAuth.js'; +import { isUserInRoom } from '../midlewares/isUserInRoom.js'; +import { catchError } from '../utils/catchError.js'; +import { roomController } from '../controllers/room.controller.js'; + +export const roomRouter = express.Router(); + +roomRouter.get( + '/:roomId', + catchError(isAuth), + catchError(isUserInRoom), + catchError(roomController.getRoomInfoByRoomId), +); + +roomRouter.post('/', catchError(isAuth), catchError(roomController.createRoom)); + +roomRouter.delete( + '/:roomId', + catchError(isAuth), + catchError(isUserInRoom), + catchError(roomController.deleteRoom), +); + +roomRouter.patch( + '/:roomId', + catchError(isAuth), + catchError(isUserInRoom), + catchError(roomController.renameRoom), +); + +roomRouter.post( + '/:roomId/merge', + catchError(isAuth), + catchError(isUserInRoom), + catchError(roomController.mergeRooms), +); + +roomRouter.post( + '/:roomId/join', + catchError(isAuth), + catchError(roomController.joinRoom), +); diff --git a/src/routes/user.router.js b/src/routes/user.router.js new file mode 100644 index 000000000..681ed2d3e --- /dev/null +++ b/src/routes/user.router.js @@ -0,0 +1,31 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { catchError } from '../utils/catchError.js'; +import { isAuth } from '../midlewares/isAuth.js'; +import { isNotAuth } from '../midlewares/isNotAuth.js'; + +export const userRouter = express.Router(); + +userRouter.post( + '/', + catchError(isNotAuth), + catchError(userController.createUser), +); + +userRouter.get( + '/:userId', + catchError(isAuth), + catchError(userController.getUserInfo), +); + +userRouter.patch( + '/:userId', + catchError(isAuth), + catchError(userController.changeUserName), +); + +userRouter.delete( + '/:userId', + catchError(isAuth), + catchError(userController.deleteUser), +); diff --git a/src/services/message.service.js b/src/services/message.service.js new file mode 100644 index 000000000..b7412a123 --- /dev/null +++ b/src/services/message.service.js @@ -0,0 +1,59 @@ +import { Message } from '../data/message.js'; +import { User } from '../data/user.js'; + +const findRoomsMessages = async (roomId) => { + const rawMessages = await Message.findAll({ + where: { roomId }, + include: [{ model: User, attributes: ['name'] }], + order: [['createdAt', 'ASC']], + }); + + if (!rawMessages || rawMessages.length === 0) { + return []; + } + + return rawMessages.map((message) => ({ + author: message.User.name, + text: message.text, + time: message.createdAt, + })); +}; + +const mergeMessages = async (roomId, targetRoomId) => { + const messages = await findRoomsMessages(roomId); + + if (!messages || !messages.length) { + return { moved: 0 }; + } + + for (const msg of messages) { + msg.roomId = targetRoomId; + await msg.save(); + } + + return { moved: messages.length, into: targetRoomId }; +}; + +const createMessage = async (roomId, userId, text) => { + const message = await Message.create({ + text: text.trim(), + userId, + roomId, + }); + + const savedMessage = await Message.findByPk(message.id, { + include: [{ model: User, attributes: ['name'] }], + }); + + return { + author: savedMessage.User.name, + text: savedMessage.text, + time: savedMessage.createdAt, + }; +}; + +export const messageService = { + findRoomsMessages, + mergeMessages, + createMessage, +}; diff --git a/src/services/room.service.js b/src/services/room.service.js new file mode 100644 index 000000000..6051451ba --- /dev/null +++ b/src/services/room.service.js @@ -0,0 +1,45 @@ +import { User } from '../data/user.js'; +import { Room } from '../data/room.js'; +import { userService } from './user.service.js'; + +const findRoomsByUserId = async (userId) => { + const user = await User.findByPk(userId, { + include: Room, + }); + + if (!user) { + return []; + } + + return user.Rooms; +}; + +const findRoomById = async (roomId) => { + const room = await Room.findByPk(roomId); + + if (!room) { + return null; + } + + return room; +}; + +const createRoom = async (name) => { + const newRoom = await Room.create({ name }); + + return newRoom; +}; + +const addUserToRoom = async (userId, roomId) => { + const room = await roomService.findRoomById(roomId); + const user = await userService.getUserById(userId); + + await room.addUser(user); +}; + +export const roomService = { + findRoomsByUserId, + findRoomById, + createRoom, + addUserToRoom, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 000000000..1c6c3f5be --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,71 @@ +import { User } from '../data/user.js'; +import { Room } from '../data/room.js'; +import { roomService } from './room.service.js'; + +const createUser = async (name) => { + const newUser = await User.create({ + name, + }); + + return newUser; +}; + +const getUserById = async (id) => { + const user = await User.findOne({ where: { id } }); + + return user; +}; + +const getUserByName = async (name) => { + const user = await User.findOne({ where: { name } }); + + return user; +}; + +const findUsersByRoomId = async (roomId) => { + const room = await Room.findByPk(roomId, { + include: User, + }); + + if (!room) { + return []; + } + + return room.Users; +}; + +const mergeUsers = async (roomId, targetRoomId) => { + const room = await roomService.findRoomById(roomId); + const targetRoom = await roomService.findRoomById(targetRoomId); + const usersToMerge = await findUsersByRoomId(roomId); + const targetUsers = await findUsersByRoomId(targetRoomId); + + if (!room || !targetRoom) { + throw new Error('One or both rooms not found'); + } + + const targetUserIds = new Set(targetUsers.map((u) => u.id)); + + for (const user of usersToMerge) { + if (!targetUserIds.has(user.id)) { + await targetRoom.addUser(user); + } + } + + return { merged: usersToMerge.length, into: targetRoomId }; +}; + +const getUserNameById = async (userId) => { + const user = User.findOne({ where: { userId } }); + + return user.name; +}; + +export const userService = { + createUser, + getUserById, + getUserByName, + findUsersByRoomId, + mergeUsers, + getUserNameById, +}; diff --git a/src/utils/catchError.js b/src/utils/catchError.js new file mode 100644 index 000000000..0d7178665 --- /dev/null +++ b/src/utils/catchError.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async function (req, res, next) { + try { + await action.call(this, req, res, next); + } catch (error) { + next(error); + } + }; +};