diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b4aca4a..0dceb78 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -60,7 +60,7 @@ jobs: image: harbor.galpodlipnik.com/chat-app/backend:latest restart: always env_file: - - ./backend/.env + - ./.env ports: - "3000:3000" networks: diff --git a/backend/Dockerfile b/backend/Dockerfile index 0fb0b6d..a49912a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,8 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . + +RUN npm run db:generate RUN npm run build EXPOSE 3000 diff --git a/backend/dist/config/database.js b/backend/dist/config/database.js new file mode 100644 index 0000000..ceb2a68 --- /dev/null +++ b/backend/dist/config/database.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.disconnectDatabase = exports.connectDatabase = exports.prisma = void 0; +const client_1 = require("@prisma/client"); +exports.prisma = new client_1.PrismaClient({ + log: ["query", "info", "warn", "error"], +}); +const connectDatabase = async () => { + try { + await exports.prisma.$connect(); + console.log("✅ Database connected successfully"); + } + catch (error) { + console.error("❌ Database connection failed:", error); + process.exit(1); + } +}; +exports.connectDatabase = connectDatabase; +const disconnectDatabase = async () => { + await exports.prisma.$disconnect(); +}; +exports.disconnectDatabase = disconnectDatabase; diff --git a/backend/dist/config/env.js b/backend/dist/config/env.js new file mode 100644 index 0000000..b8e428d --- /dev/null +++ b/backend/dist/config/env.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.env = void 0; +const zod_1 = require("zod"); +const envSchema = zod_1.z.object({ + CORS_ORIGIN: zod_1.z.string().default("*"), + DATABASE_URL: zod_1.z.string(), + JWT_SECRET: zod_1.z.string(), + NODE_ENV: zod_1.z.enum(["development", "production", "test"]).default("development"), + PORT: zod_1.z.string().transform(Number).default("3000"), +}); +exports.env = envSchema.parse(process.env); diff --git a/backend/dist/controllers/authController.js b/backend/dist/controllers/authController.js new file mode 100644 index 0000000..39f2f35 --- /dev/null +++ b/backend/dist/controllers/authController.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AuthController = void 0; +const authService_js_1 = require("../services/authService.js"); +class AuthController { + static async register(req, res) { + const data = req.body; + const result = await authService_js_1.AuthService.register(data); + res.status(result.success ? 201 : 400).json(result); + } + static async login(req, res) { + const data = req.body; + const result = await authService_js_1.AuthService.login(data); + res.status(result.success ? 200 : 400).json(result); + } +} +exports.AuthController = AuthController; diff --git a/backend/dist/controllers/chatController.js b/backend/dist/controllers/chatController.js new file mode 100644 index 0000000..9a5b3fe --- /dev/null +++ b/backend/dist/controllers/chatController.js @@ -0,0 +1,76 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ChatController = void 0; +const chatService_js_1 = require("../services/chatService.js"); +class ChatController { + static async getChatRooms(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const result = await chatService_js_1.ChatService.getChatRooms(req.user.userId); + res.status(result.success ? 200 : 500).json(result); + } + static async createChatRoom(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const data = req.body; + const result = await chatService_js_1.ChatService.createChatRoom(req.user.userId, data); + res.status(result.success ? 201 : 500).json(result); + } + static async getMessages(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const query = { + roomId: req.params.roomId, + page: req.query.page, + limit: req.query.limit, + }; + const result = await chatService_js_1.ChatService.getMessages(query); + res.status(result.success ? 200 : 500).json(result); + } + static async updateChatRoom(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const roomId = req.params.roomId; + const data = req.body; + const result = await chatService_js_1.ChatService.updateChatRoom(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 400).json(result); + } + static async deleteChatRoom(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const roomId = req.params.roomId; + const result = await chatService_js_1.ChatService.deleteChatRoom(req.user.userId, roomId); + res.status(result.success ? 200 : result.status || 500).json(result); + } + static async addChatRoomMember(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const roomId = req.params.roomId; + const data = req.body; + const result = await chatService_js_1.ChatService.addChatRoomMember(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 500).json(result); + } + static async removeChatRoomMember(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const roomId = req.params.roomId; + const data = req.body; + const result = await chatService_js_1.ChatService.removeChatRoomMember(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 500).json(result); + } +} +exports.ChatController = ChatController; diff --git a/backend/dist/controllers/userController.js b/backend/dist/controllers/userController.js new file mode 100644 index 0000000..1b4fb07 --- /dev/null +++ b/backend/dist/controllers/userController.js @@ -0,0 +1,16 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UserController = void 0; +const userService_1 = require("../services/userService"); +class UserController { + static async updateProfile(req, res) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + const data = req.body; + const result = await userService_1.UserService.updateProfile(req.user.userId, data); + res.status(result.success ? 200 : 400).json(result); + } +} +exports.UserController = UserController; diff --git a/backend/dist/index.js b/backend/dist/index.js new file mode 100644 index 0000000..a5e6001 --- /dev/null +++ b/backend/dist/index.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const cors_1 = __importDefault(require("cors")); +const express_1 = __importDefault(require("express")); +const http_1 = __importDefault(require("http")); +const socket_io_1 = require("socket.io"); +const database_js_1 = require("./config/database.js"); +const env_js_1 = require("./config/env.js"); +const auth_js_1 = require("./middleware/auth.js"); +const index_js_1 = require("./routes/index.js"); +const socketHandlers_js_1 = require("./socket/socketHandlers.js"); +const app = (0, express_1.default)(); +const server = http_1.default.createServer(app); +const io = new socket_io_1.Server(server, { + cors: { + methods: ["GET", "POST", "PUT", "DELETE"], + origin: "http://localhost:5173", + }, +}); +app.use((0, cors_1.default)({ + methods: ["GET", "POST", "PUT", "DELETE"], + origin: "http://localhost:5173", +})); +app.use(express_1.default.json({ limit: "5mb" })); +app.use("/api", index_js_1.apiRoutes); +app.get("/health", (_, res) => { + res.json({ status: "OK", timestamp: new Date().toISOString() }); +}); +io.use(auth_js_1.authenticateSocket); +io.on("connection", async (socket) => { + await (0, socketHandlers_js_1.handleConnection)(io, socket); +}); +app.use((err, _req, res, _next) => { + console.error("Unhandled error:", err); + res.status(500).json({ + success: false, + error: env_js_1.env.NODE_ENV === "production" ? "Internal server error" : err.message, + }); +}); +const startServer = async () => { + try { + await (0, database_js_1.connectDatabase)(); + server.listen(env_js_1.env.PORT, () => { + console.log(`🚀 Server running on port ${env_js_1.env.PORT}`); + console.log(`📊 Environment: ${env_js_1.env.NODE_ENV}`); + console.log(`🔗 CORS Origin: ${env_js_1.env.CORS_ORIGIN}`); + }); + } + catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } +}; +process.on("SIGTERM", () => { + console.log("SIGTERM received, shutting down gracefully..."); + server.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); +}); +(async () => { + await startServer(); +})(); diff --git a/backend/dist/middleware/auth.js b/backend/dist/middleware/auth.js new file mode 100644 index 0000000..d9ba31a --- /dev/null +++ b/backend/dist/middleware/auth.js @@ -0,0 +1,51 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.authenticateSocket = exports.authenticateToken = void 0; +const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); +const database_js_1 = require("../config/database.js"); +const env_js_1 = require("../config/env.js"); +const authenticateToken = (req, res, next) => { + try { + const authHeader = req.headers.authorization; + const token = authHeader?.split(" ")[1]; + if (!token) { + res.status(401).json({ success: false, error: "Access token required" }); + return; + } + const decoded = jsonwebtoken_1.default.verify(token, env_js_1.env.JWT_SECRET); + req.user = decoded; + next(); + } + catch (error) { + console.error("Authentication error:", error); + res.status(403).json({ success: false, error: "Invalid or expired token" }); + } +}; +exports.authenticateToken = authenticateToken; +const authenticateSocket = async (socket, next) => { + try { + const token = socket.handshake.auth.token; + if (!token) { + return next(new Error("Authentication token required")); + } + const decoded = jsonwebtoken_1.default.verify(token, env_js_1.env.JWT_SECRET); + const user = await database_js_1.prisma.user.findUnique({ + where: { + id: decoded.userId, + }, + }); + if (!user) + return next(new Error("User not found")); + const authenticateSocket = socket; + authenticateSocket.userId = user.id; + authenticateSocket.user = user; + next(); + } + catch (error) { + console.error("Socket authentication error:", error); + } +}; +exports.authenticateSocket = authenticateSocket; diff --git a/backend/dist/middleware/rateLimiter.js b/backend/dist/middleware/rateLimiter.js new file mode 100644 index 0000000..b58637a --- /dev/null +++ b/backend/dist/middleware/rateLimiter.js @@ -0,0 +1,42 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.checkMessageRateLimit = exports.messageRateLimiter = void 0; +class RateLimiter { + limits = new Map(); + maxMessages; + windowMs; + constructor(maxMessages = 10, windowMs = 60000) { + this.maxMessages = maxMessages; + this.windowMs = windowMs; + } + checkLimit(userId) { + const now = Date.now(); + const userLimit = this.limits.get(userId) ?? { count: 0, resetTime: now + this.windowMs }; + if (now > userLimit.resetTime) { + userLimit.count = 0; + userLimit.resetTime = now + this.windowMs; + } + userLimit.count++; + this.limits.set(userId, userLimit); + return userLimit.count <= this.maxMessages; + } + getRemainingTime(userId) { + const userLimit = this.limits.get(userId); + if (!userLimit) + return 0; + return Math.max(0, userLimit.resetTime - Date.now()); + } +} +exports.messageRateLimiter = new RateLimiter(10, 60000); // 10 messages per minute +const checkMessageRateLimit = (socket) => { + const canSend = exports.messageRateLimiter.checkLimit(socket.userId); + if (!canSend) { + const remainingTime = exports.messageRateLimiter.getRemainingTime(socket.userId); + socket.emit("rate_limit_exceeded", { + message: `Too many messages. Please wait ${Math.ceil(remainingTime / 1000)} seconds before sending another message.`, + remainingTime, + }); + } + return canSend; +}; +exports.checkMessageRateLimit = checkMessageRateLimit; diff --git a/backend/dist/middleware/validation.js b/backend/dist/middleware/validation.js new file mode 100644 index 0000000..4244759 --- /dev/null +++ b/backend/dist/middleware/validation.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.removeChatRoomMemberSchema = exports.addChatRoomMemberSchema = exports.updateChatRoomSchema = exports.updateUserSchema = exports.createChatRoomSchema = exports.registerSchema = exports.loginSchema = exports.validate = void 0; +const zod_1 = require("zod"); +const validate = (schema) => { + return (req, res, next) => { + try { + schema.parse(req.body); + next(); + } + catch (error) { + if (error instanceof zod_1.z.ZodError) { + res.status(400).json({ + success: false, + error: "Validation failed", + details: error.errors, + }); + return; + } + next(error); + } + }; +}; +exports.validate = validate; +exports.loginSchema = zod_1.z.object({ + email: zod_1.z.string().email("Invalid email format"), + password: zod_1.z.string().min(6, "Password must be at least 6 characters long"), +}); +exports.registerSchema = zod_1.z.object({ + username: zod_1.z.string().min(3, "Username must be at least 3 characters long"), + email: zod_1.z.string().email("Invalid email format"), + password: zod_1.z.string().min(6, "Password must be at least 6 characters long"), +}); +exports.createChatRoomSchema = zod_1.z.object({ + name: zod_1.z.string().min(1, "Room name is required"), + description: zod_1.z.string().optional(), + memberUsernames: zod_1.z.array(zod_1.z.string()).default([]), +}); +exports.updateUserSchema = zod_1.z.object({ + username: zod_1.z.string().min(3, "Username must be at least 3 characters long").optional(), + email: zod_1.z.string().email("Invalid email format").optional(), + password: zod_1.z.string().min(6, "Password must be at least 6 characters long").optional(), + avatar: zod_1.z.string().nullable().optional(), +}); +exports.updateChatRoomSchema = zod_1.z.object({ + name: zod_1.z.string().min(1, "Room name is required"), + description: zod_1.z.string().optional(), +}); +exports.addChatRoomMemberSchema = zod_1.z.object({ + username: zod_1.z.string().min(1, "Username is required"), +}); +exports.removeChatRoomMemberSchema = zod_1.z.object({ + userId: zod_1.z.string().min(1, "User ID is required"), +}); diff --git a/backend/dist/routes/authRoutes.js b/backend/dist/routes/authRoutes.js new file mode 100644 index 0000000..78ba4d3 --- /dev/null +++ b/backend/dist/routes/authRoutes.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.authRoutes = void 0; +const authController_js_1 = require("../controllers/authController.js"); +const validation_js_1 = require("../middleware/validation.js"); +const express_1 = require("express"); +const router = (0, express_1.Router)(); +exports.authRoutes = router; +router.post("/register", (0, validation_js_1.validate)(validation_js_1.registerSchema), authController_js_1.AuthController.register); +router.post("/login", (0, validation_js_1.validate)(validation_js_1.loginSchema), authController_js_1.AuthController.login); diff --git a/backend/dist/routes/chatRoutes.js b/backend/dist/routes/chatRoutes.js new file mode 100644 index 0000000..b43661a --- /dev/null +++ b/backend/dist/routes/chatRoutes.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.chatRoutes = void 0; +const chatController_js_1 = require("../controllers/chatController.js"); +const auth_js_1 = require("../middleware/auth.js"); +const validation_js_1 = require("../middleware/validation.js"); +const express_1 = require("express"); +const router = (0, express_1.Router)(); +exports.chatRoutes = router; +router.use(auth_js_1.authenticateToken); +router.get("/chat-rooms", chatController_js_1.ChatController.getChatRooms); +router.post("/chat-rooms", (0, validation_js_1.validate)(validation_js_1.createChatRoomSchema), chatController_js_1.ChatController.createChatRoom); +router.put("/chat-rooms/:roomId", (0, validation_js_1.validate)(validation_js_1.updateChatRoomSchema), chatController_js_1.ChatController.updateChatRoom); +router.delete("/chat-rooms/:roomId", chatController_js_1.ChatController.deleteChatRoom); +router.post("/chat-rooms/:roomId/members", (0, validation_js_1.validate)(validation_js_1.addChatRoomMemberSchema), chatController_js_1.ChatController.addChatRoomMember); +router.delete("/chat-rooms/:roomId/members", (0, validation_js_1.validate)(validation_js_1.removeChatRoomMemberSchema), chatController_js_1.ChatController.removeChatRoomMember); +router.get("/messages/:roomId", chatController_js_1.ChatController.getMessages); diff --git a/backend/dist/routes/index.js b/backend/dist/routes/index.js new file mode 100644 index 0000000..e8544c7 --- /dev/null +++ b/backend/dist/routes/index.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.apiRoutes = void 0; +const express_1 = require("express"); +const authRoutes_js_1 = require("./authRoutes.js"); +const chatRoutes_js_1 = require("./chatRoutes.js"); +const userRoutes_js_1 = require("./userRoutes.js"); +const router = (0, express_1.Router)(); +exports.apiRoutes = router; +router.use("/auth", authRoutes_js_1.authRoutes); +router.use("/users", userRoutes_js_1.userRoutes); +router.use("/", chatRoutes_js_1.chatRoutes); diff --git a/backend/dist/routes/userRoutes.js b/backend/dist/routes/userRoutes.js new file mode 100644 index 0000000..2622448 --- /dev/null +++ b/backend/dist/routes/userRoutes.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.userRoutes = void 0; +const userController_js_1 = require("../controllers/userController.js"); +const auth_js_1 = require("../middleware/auth.js"); +const validation_js_1 = require("../middleware/validation.js"); +const express_1 = require("express"); +const router = (0, express_1.Router)(); +exports.userRoutes = router; +router.use(auth_js_1.authenticateToken); +router.put("/profile", (0, validation_js_1.validate)(validation_js_1.updateUserSchema), userController_js_1.UserController.updateProfile); diff --git a/backend/dist/services/authService.js b/backend/dist/services/authService.js new file mode 100644 index 0000000..2594aa6 --- /dev/null +++ b/backend/dist/services/authService.js @@ -0,0 +1,100 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AuthService = void 0; +const database_js_1 = require("../config/database.js"); +const env_js_1 = require("../config/env.js"); +const bcryptjs_1 = __importDefault(require("bcryptjs")); +const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); +class AuthService { + static async register(data) { + try { + const existingUser = await database_js_1.prisma.user.findFirst({ + where: { + OR: [{ email: data.email }, { username: data.username }], + }, + }); + if (existingUser) { + return { + success: false, + error: existingUser.email === data.email ? "Email already exists" : "Username already exists", + }; + } + const hashedPassword = await bcryptjs_1.default.hash(data.password, 12); + const user = await database_js_1.prisma.user.create({ + data: { + username: data.username, + email: data.email, + password: hashedPassword, + }, + }); + const token = this.generateToken({ + userId: user.id, + username: user.username, + email: user.email, + }); + return { + success: true, + data: { + token, + user: { + id: user.id, + username: user.username, + email: user.email, + }, + }, + }; + } + catch (error) { + console.error("Registration error:", error); + return { + success: false, + error: "Registration failed", + }; + } + } + static async login(data) { + try { + const user = await database_js_1.prisma.user.findUnique({ + where: { + email: data.email, + }, + }); + if (!user || !(await bcryptjs_1.default.compare(data.password, user.password))) { + return { + success: false, + error: "Invalid email or password", + }; + } + const token = this.generateToken({ + userId: user.id, + username: user.username, + email: user.email, + }); + return { + success: true, + data: { + token, + user: { + id: user.id, + username: user.username, + email: user.email, + }, + }, + }; + } + catch (error) { + console.error("Login error:", error); + return { + success: false, + error: "Login failed", + }; + } + } + static generateToken(payload) { + return jsonwebtoken_1.default.sign(payload, env_js_1.env.JWT_SECRET, { expiresIn: "7d" }); + } +} +exports.AuthService = AuthService; diff --git a/backend/dist/services/chatService.js b/backend/dist/services/chatService.js new file mode 100644 index 0000000..63951e4 --- /dev/null +++ b/backend/dist/services/chatService.js @@ -0,0 +1,490 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ChatService = void 0; +const database_js_1 = require("../config/database.js"); +class ChatService { + static async getChatRooms(userId) { + try { + const chatRooms = await database_js_1.prisma.chatRoom.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + }); + return { + success: true, + data: chatRooms, + }; + } + catch (error) { + console.error("Get chat rooms error:", error); + return { + success: false, + error: "Failed to retrieve chat rooms", + }; + } + } + static async createChatRoom(userId, data) { + try { + const chatRoom = await database_js_1.prisma.chatRoom.create({ + data: { + name: data.name, + description: data.description, + createdBy: userId, + members: { + create: [ + { + userId, + role: "admin", + }, + ], + }, + }, + }); + if (data.memberUsernames.length > 0) { + const users = await database_js_1.prisma.user.findMany({ + where: { + username: { + in: data.memberUsernames, + }, + }, + }); + const memberData = users.map((user) => ({ + userId: user.id, + roomId: chatRoom.id, + })); + await database_js_1.prisma.chatRoomMember.createMany({ + data: memberData, + skipDuplicates: true, + }); + } + const fullChatRoom = await database_js_1.prisma.chatRoom.findUnique({ + where: { id: chatRoom.id }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + return { + success: true, + data: fullChatRoom, + }; + } + catch (error) { + console.error("Create chat room error:", error); + return { + success: false, + error: "Failed to create chat room", + }; + } + } + static async getMessages(query) { + try { + const page = Number.parseInt(query.page ?? "1"); + const limit = Number.parseInt(query.limit ?? "50"); + const skip = (page - 1) * limit; + const messages = await database_js_1.prisma.message.findMany({ + where: { + roomId: query.roomId, + }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + reactions: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + seenBy: { + select: { + userId: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + take: limit, + skip, + }); + const formattedMessages = messages.map((message) => ({ + ...message, + seenBy: message.seenBy.map((seen) => seen.userId), + })); + return { + success: true, + data: formattedMessages.reverse(), + }; + } + catch (error) { + console.error("Get messages error:", error); + return { + success: false, + error: "Failed to retrieve messages", + }; + } + } + static async checkRoomMembership(userId, roomId) { + try { + const membership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + return !!membership; + } + catch (error) { + console.error("Check room membership error:", error); + return false; + } + } + static async updateChatRoom(userId, roomId, data) { + try { + const membership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can update chat room settings", + status: 403, + }; + } + const updatedRoom = await database_js_1.prisma.chatRoom.update({ + where: { id: roomId }, + data: { + name: data.name, + description: data.description, + updatedAt: new Date(), + }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + return { + success: true, + data: updatedRoom, + }; + } + catch (error) { + console.error("Update chat room error:", error); + return { + success: false, + error: "Failed to update chat room", + status: 500, + }; + } + } + static async deleteChatRoom(userId, roomId) { + try { + const membership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can delete chat rooms", + status: 403, + }; + } + await database_js_1.prisma.chatRoom.delete({ + where: { id: roomId }, + }); + return { + success: true, + data: { id: roomId }, + }; + } + catch (error) { + console.error("Delete chat room error:", error); + return { + success: false, + error: "Failed to delete chat room", + status: 500, + }; + } + } + static async addChatRoomMember(userId, roomId, data) { + try { + const membership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can add members", + status: 403, + }; + } + const user = await database_js_1.prisma.user.findUnique({ + where: { username: data.username }, + }); + if (!user) { + return { + success: false, + error: "User not found", + status: 404, + }; + } + const existingMembership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId: user.id, + roomId, + }, + }, + }); + if (existingMembership) { + return { + success: false, + error: "User is already a member of this chat room", + status: 409, + }; + } + await database_js_1.prisma.chatRoomMember.create({ + data: { + userId: user.id, + roomId, + role: "member", + }, + }); + const updatedRoom = await database_js_1.prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + return { + success: true, + data: updatedRoom, + }; + } + catch (error) { + console.error("Add chat room member error:", error); + return { + success: false, + error: "Failed to add member to chat room", + status: 500, + }; + } + } + static async removeChatRoomMember(userId, roomId, data) { + try { + const membership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can remove members", + status: 403, + }; + } + // Check if target user is an admin + const targetMembership = await database_js_1.prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId: data.userId, + roomId, + }, + }, + }); + if (!targetMembership) { + return { + success: false, + error: "User is not a member of this chat room", + status: 404, + }; + } + if (targetMembership.role === "admin") { + return { + success: false, + error: "Cannot remove an admin from the chat room", + status: 403, + }; + } + // Remove user from chat room + await database_js_1.prisma.chatRoomMember.delete({ + where: { + userId_roomId: { + userId: data.userId, + roomId, + }, + }, + }); + // Get updated chat room + const updatedRoom = await database_js_1.prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + return { + success: true, + data: updatedRoom, + }; + } + catch (error) { + console.error("Remove chat room member error:", error); + return { + success: false, + error: "Failed to remove member from chat room", + status: 500, + }; + } + } +} +exports.ChatService = ChatService; diff --git a/backend/dist/services/messageService.js b/backend/dist/services/messageService.js new file mode 100644 index 0000000..0659d62 --- /dev/null +++ b/backend/dist/services/messageService.js @@ -0,0 +1,191 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MessageService = void 0; +const database_js_1 = require("../config/database.js"); +class MessageService { + static async sendMessage(userId, data) { + try { + const message = await database_js_1.prisma.message.create({ + data: { + content: data.content, + image: data.image, + userId, + roomId: data.roomId, + }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + reactions: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + }, + }); + return { + success: true, + data: message, + }; + } + catch (error) { + console.error("Send message error:", error); + return { + success: false, + error: "Failed to send message", + }; + } + } + static async reactToMessage(userId, data) { + try { + const existingReaction = await database_js_1.prisma.messageReaction.findUnique({ + where: { + userId_messageId_type: { + userId, + messageId: data.messageId, + type: data.type, + }, + }, + }); + if (existingReaction) { + await database_js_1.prisma.messageReaction.delete({ + where: { + id: existingReaction.id, + }, + }); + } + else { + await database_js_1.prisma.messageReaction.create({ + data: { + userId, + messageId: data.messageId, + type: data.type, + }, + }); + } + const message = await database_js_1.prisma.message.findUnique({ + where: { + id: data.messageId, + }, + include: { + reactions: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + }, + }); + return { + success: true, + data: { + messageId: data.messageId, + reactions: message?.reactions ?? [], + }, + }; + } + catch (error) { + console.error("React to message error:", error); + return { + success: false, + error: "Failed to react to message", + }; + } + } + static async markMessagesAsSeen(userId, messageIds) { + try { + const seenEntries = messageIds.map((messageId) => ({ + userId, + messageId, + })); + await database_js_1.prisma.messageSeen.createMany({ + data: seenEntries, + skipDuplicates: true, + }); + const messages = await database_js_1.prisma.message.findMany({ + where: { + id: { in: messageIds }, + }, + include: { + seenBy: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + }, + }); + const seenData = messages.map((message) => ({ + messageId: message.id, + seenBy: message.seenBy.map((seen) => seen.userId), + })); + return { + success: true, + data: seenData, + }; + } + catch (error) { + console.error("Mark messages as seen error:", error); + return { + success: false, + error: "Failed to mark messages as seen", + }; + } + } + static async deleteMessage(userId, messageId, roomId) { + try { + const message = await database_js_1.prisma.message.findFirst({ + where: { + id: messageId, + userId: userId, + roomId: roomId, + }, + }); + if (!message) { + return { + success: false, + error: "Message not found or you don't have permission to delete it", + status: 403, + }; + } + await database_js_1.prisma.message.delete({ + where: { + id: messageId, + }, + }); + return { + success: true, + data: { + messageId, + roomId, + }, + }; + } + catch (error) { + console.error("Delete message error:", error); + return { + success: false, + error: "Failed to delete message", + status: 500, + }; + } + } +} +exports.MessageService = MessageService; diff --git a/backend/dist/services/userService.js b/backend/dist/services/userService.js new file mode 100644 index 0000000..ddfa3be --- /dev/null +++ b/backend/dist/services/userService.js @@ -0,0 +1,74 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UserService = void 0; +const database_js_1 = require("../config/database.js"); +const bcryptjs_1 = __importDefault(require("bcryptjs")); +class UserService { + static async updateProfile(userId, data) { + try { + if (data.username) { + const existingUser = await database_js_1.prisma.user.findFirst({ + where: { + username: data.username, + id: { not: userId }, + }, + }); + if (existingUser) { + return { + success: false, + error: "Username already taken", + }; + } + } + if (data.email) { + const existingUser = await database_js_1.prisma.user.findFirst({ + where: { + email: data.email, + id: { not: userId }, + }, + }); + if (existingUser) { + return { + success: false, + error: "Email already taken", + }; + } + } + const updateData = {}; + if (data.username) + updateData.username = data.username; + if (data.email) + updateData.email = data.email; + if (data.avatar !== undefined) + updateData.avatar = data.avatar; + if (data.password) { + updateData.password = await bcryptjs_1.default.hash(data.password, 12); + } + const updatedUser = await database_js_1.prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + username: true, + email: true, + avatar: true, + }, + }); + return { + success: true, + data: updatedUser, + }; + } + catch (error) { + console.error("Update profile error:", error); + return { + success: false, + error: "Failed to update profile", + }; + } + } +} +exports.UserService = UserService; diff --git a/backend/dist/socket/socketHandlers.js b/backend/dist/socket/socketHandlers.js new file mode 100644 index 0000000..32d1174 --- /dev/null +++ b/backend/dist/socket/socketHandlers.js @@ -0,0 +1,225 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.handleConnection = void 0; +const database_js_1 = require("../config/database.js"); +const rateLimiter_js_1 = require("../middleware/rateLimiter.js"); +const chatService_js_1 = require("../services/chatService.js"); +const messageService_js_1 = require("../services/messageService.js"); +const handleConnection = async (io, socket) => { + console.log(`User connected: ${socket.user.username} (${socket.user.id})`); + await updateUserOnlineStatus(socket.userId, true); + const onlineUsers = await database_js_1.prisma.user.findMany({ + where: { isOnline: true }, + select: { id: true, username: true }, + }); + socket.emit("online_users", onlineUsers); + await joinUserRooms(socket); + socket.on("send_message", (data) => handleSendMessage(io, socket, data)); + socket.on("delete_message", (data) => handleDeleteMessage(io, socket, data)); + socket.on("react_to_message", (data) => handleReactToMessage(io, socket, data)); + socket.on("typing_start", (data) => handleTypingStart(io, socket, data)); + socket.on("typing_stop", (data) => handleTypingStop(io, socket, data)); + socket.on("join_room", (roomId) => handleJoinRoom(socket, roomId)); + socket.on("leave_room", (roomId) => handleLeaveRoom(socket, roomId)); + socket.on("messages_seen", (data) => handleMessagesSeen(io, socket, data)); + socket.on("disconnect", () => handleDisconnect(socket)); +}; +exports.handleConnection = handleConnection; +const handleTypingStart = async (io, socket, data) => { + try { + const isMember = await chatService_js_1.ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) + return; + io.to(data.roomId).emit("user_typing", { + roomId: data.roomId, + username: data.username, + }); + } + catch (error) { + console.error(`Error handling typing start: ${error}`); + } +}; +const handleTypingStop = async (io, socket, data) => { + try { + const isMember = await chatService_js_1.ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) + return; + io.to(data.roomId).emit("user_stopped_typing", { + roomId: data.roomId, + username: data.username, + }); + } + catch (error) { + console.error(`Error handling typing stop: ${error}`); + } +}; +const updateUserOnlineStatus = async (userId, isOnline) => { + try { + await database_js_1.prisma.user.update({ + where: { id: userId }, + data: { + isOnline, + lastSeen: new Date(), + }, + }); + } + catch (error) { + console.error(`Error updating user online status: ${error}`); + } +}; +const joinUserRooms = async (socket) => { + try { + const userRooms = await database_js_1.prisma.chatRoomMember.findMany({ + where: { userId: socket.userId }, + include: { room: true }, + }); + userRooms.forEach(async (member) => { + await socket.join(member.roomId); + socket.to(member.roomId).emit("user_online", { + userId: socket.userId, + username: socket.user.username, + }); + }); + } + catch (error) { + console.error(`Error joining user rooms: ${error}`); + } +}; +const handleSendMessage = async (io, socket, data) => { + try { + if (!data.roomId) { + socket.emit("error", { message: "Room ID is required to send a message" }); + return; + } + if (data.image && data.image.length > 2 * 1024 * 1024) { + socket.emit("error", { message: "Image size exceeds 2MB limit" }); + return; + } + if (!data.content && !data.image) { + socket.emit("error", { message: "Message cannot be empty" }); + return; + } + if (!(0, rateLimiter_js_1.checkMessageRateLimit)(socket)) + return; + const isMember = await chatService_js_1.ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) { + socket.emit("error", { message: "Not authorized to send messages to this room" }); + return; + } + const result = await messageService_js_1.MessageService.sendMessage(socket.userId, data); + if (result.success) { + io.to(data.roomId).emit("new_message", result.data); + io.to(data.roomId).emit("user_stopped_typing", { + roomId: data.roomId, + username: socket.user.username, + }); + } + else { + socket.emit("error", { message: "Failed to send message" }); + } + } + catch (error) { + console.error(`Error handling send message: ${error}`); + socket.emit("error", { message: "Failed to send message" }); + } +}; +const handleDeleteMessage = async (io, socket, data) => { + try { + const result = await messageService_js_1.MessageService.deleteMessage(socket.userId, data.messageId, data.roomId); + if (result.success) { + io.to(data.roomId).emit("message_deleted", { + messageId: data.messageId, + roomId: data.roomId, + }); + } + else { + socket.emit("error", { message: result.error }); + } + } + catch (error) { + console.error(`Error handling delete message: ${error}`); + socket.emit("error", { message: "Failed to delete message" }); + } +}; +const handleReactToMessage = async (io, socket, data) => { + try { + const result = await messageService_js_1.MessageService.reactToMessage(socket.userId, data); + if (result.success) { + const message = await database_js_1.prisma.message.findUnique({ + where: { id: data.messageId }, + select: { roomId: true }, + }); + if (message) { + io.to(message.roomId).emit("message_reaction_updated", result.data); + } + } + else { + socket.emit("error", { message: result.error }); + } + } + catch (error) { + console.error(`Error handling react to message: ${error}`); + socket.emit("error", { message: "Failed to react to message" }); + } +}; +const handleMessagesSeen = async (io, socket, data) => { + try { + const isMember = await chatService_js_1.ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) { + socket.emit("error", { message: "Not authorized to access this room" }); + return; + } + const messagesInRoom = await database_js_1.prisma.message.findMany({ + where: { + id: { in: data.messageIds }, + roomId: data.roomId, + }, + }); + const validMessageIds = messagesInRoom.map((msg) => msg.id); + if (validMessageIds.length === 0) + return; + const result = await messageService_js_1.MessageService.markMessagesAsSeen(socket.userId, validMessageIds); + if (result.success && result.data) { + for (const item of result.data) { + io.to(data.roomId).emit("message_seen_update", { + messageId: item.messageId, + seenBy: item.seenBy, + }); + } + } + } + catch (error) { + console.error(`Error handling messages seen: ${error}`); + socket.emit("error", { message: "Failed to mark messages as seen" }); + } +}; +const handleJoinRoom = async (socket, roomId) => { + await socket.join(roomId); +}; +const handleLeaveRoom = async (socket, roomId) => { + await socket.leave(roomId); +}; +const handleDisconnect = async (socket) => { + console.log(`User disconnected: ${socket.user.username}`); + await updateUserOnlineStatus(socket.userId, false); + try { + const userRooms = await database_js_1.prisma.chatRoomMember.findMany({ + where: { + userId: socket.userId, + }, + }); + userRooms.forEach((member) => { + socket.to(member.roomId).emit("user_offline", { + userId: socket.userId, + username: socket.user.username, + }); + socket.to(member.roomId).emit("user_stopped_typing", { + roomId: member.roomId, + username: socket.user.username, + }); + }); + } + catch (error) { + console.error(`Error handling user disconnect: ${error}`); + } +}; diff --git a/backend/dist/types/index.js b/backend/dist/types/index.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/backend/dist/types/index.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true });