diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 117d8632..8fba4591 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -43,6 +43,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, @@ -3130,6 +3131,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -3269,7 +3276,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3672,6 +3678,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -4321,6 +4336,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", @@ -5701,6 +5725,45 @@ "node": ">=0.10.0" } }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", @@ -10903,6 +10966,47 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -11951,6 +12055,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index b22309b1..8154371b 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -57,6 +57,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 69e6634f..06591522 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -136,6 +136,7 @@ import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import userMailRoutes from "./routes/userMailRoutes"; // 사용자 메일 계정 +import messengerRoutes from "./routes/messengerRoutes"; // 메신저 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 @@ -383,6 +384,7 @@ app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/approval", approvalRoutes); // 결재 시스템 app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정 +app.use("/api/messenger", messengerRoutes); // 메신저 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -410,6 +412,20 @@ const server = app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // Socket.IO initialization + try { + const { Server: SocketIOServer } = await import("socket.io"); + const { initMessengerSocket } = await import("./socket/messengerSocket"); + const io = new SocketIOServer(server, { + cors: { origin: "*", methods: ["GET", "POST"] }, + path: "/socket.io", + }); + initMessengerSocket(io); + logger.info("💬 Socket.IO messenger initialized"); + } catch (error) { + logger.error("❌ Socket.IO initialization failed:", error); + } + // 비동기 초기화 작업 (에러가 발생해도 서버는 유지) initializeServices().catch(err => { logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err); @@ -426,6 +442,7 @@ async function initializeServices() { runDtgManagementLogMigration, runApprovalSystemMigration, runUserMailAccountsMigration, + runMessengerMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -433,6 +450,7 @@ async function initializeServices() { await runDtgManagementLogMigration(); await runApprovalSystemMigration(); await runUserMailAccountsMigration(); + await runMessengerMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/config/multerMessengerConfig.ts b/backend-node/src/config/multerMessengerConfig.ts new file mode 100644 index 00000000..5971bf89 --- /dev/null +++ b/backend-node/src/config/multerMessengerConfig.ts @@ -0,0 +1,63 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +// Upload directory +const UPLOAD_DIR = process.env.NODE_ENV === 'production' + ? '/app/uploads/messenger-files' + : path.join(process.cwd(), 'uploads', 'messenger-files'); + +// Create directory if not exists +try { + if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + } +} catch (error) { + console.error('Messenger file upload directory creation failed:', error); +} + +// File storage config +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, UPLOAD_DIR); + }, + filename: (req, file, cb) => { + try { + file.originalname = file.originalname.normalize('NFC'); + const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, `${uniqueId}${ext}`); + } catch (error) { + console.error('Filename processing error:', error); + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`); + } + }, +}); + +// File filter - block dangerous extensions +const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + try { + file.originalname = file.originalname.normalize('NFC'); + } catch (error) { + // ignore normalization failure + } + + const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi']; + const ext = path.extname(file.originalname).toLowerCase(); + + if (dangerousExtensions.includes(ext)) { + cb(new Error(`Security: ${ext} files are not allowed.`)); + return; + } + + cb(null, true); +}; + +export const uploadMessengerFile = multer({ + storage, + fileFilter, + limits: { + fileSize: 20 * 1024 * 1024, // 20MB + files: 10, + }, +}); diff --git a/backend-node/src/controllers/messengerController.ts b/backend-node/src/controllers/messengerController.ts new file mode 100644 index 00000000..25e1ee50 --- /dev/null +++ b/backend-node/src/controllers/messengerController.ts @@ -0,0 +1,202 @@ +import { Request, Response } from 'express'; +import { messengerService } from '../services/messengerService'; +import { AuthenticatedRequest } from '../types/auth'; +import path from 'path'; + +class MessengerController { + async getRooms(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const rooms = await messengerService.getRooms(user.userId, user.companyCode!); + res.json({ success: true, data: rooms }); + } catch (error) { + const err = error as Error; + console.error('getRooms error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async createRoom(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const room_type = req.body.room_type ?? req.body.type; + const room_name = req.body.room_name ?? req.body.name; + const participant_ids = req.body.participant_ids ?? req.body.participantIds; + + if (!room_type || !participant_ids || !Array.isArray(participant_ids)) { + return res.status(400).json({ success: false, message: 'room_type and participant_ids are required.' }); + } + + const room = await messengerService.createRoom(user.userId, user.companyCode!, { + room_type, + room_name, + participant_ids, + }); + res.json({ success: true, data: room }); + } catch (error) { + const err = error as Error; + console.error('createRoom error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getMessages(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const limit = parseInt(req.query.limit as string, 10) || 50; + const before = req.query.before ? parseInt(req.query.before as string, 10) : undefined; + + const messages = await messengerService.getMessages(roomId, user.userId, user.companyCode!, limit, before); + res.json({ success: true, data: messages }); + } catch (error) { + const err = error as Error; + console.error('getMessages error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async sendMessage(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const content = req.body.content; + const messageType = req.body.type ?? req.body.message_type ?? 'text'; + const parentId = req.body.parentId ?? req.body.parent_message_id ?? null; + + if (!content) { + return res.status(400).json({ success: false, message: 'content is required.' }); + } + + const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId); + res.json({ success: true, data: message }); + } catch (error) { + const err = error as Error; + console.error('sendMessage error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async markAsRead(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + await messengerService.markAsRead(roomId, user.userId); + res.json({ success: true }); + } catch (error) { + const err = error as Error; + console.error('markAsRead error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async uploadFile(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const files = req.files as Express.Multer.File[]; + + if (!files || files.length === 0) { + return res.status(400).json({ success: false, message: 'No files uploaded.' }); + } + + const roomId = parseInt(req.body.room_id, 10); + if (!roomId) { + return res.status(400).json({ success: false, message: 'room_id is required.' }); + } + + const savedFiles = []; + for (const file of files) { + // Create a file message + const message = await messengerService.sendMessage( + roomId, + user.userId, + user.companyCode!, + file.originalname, + 'file' + ); + + const savedFile = await messengerService.saveFile(message.id, { + originalName: file.originalname, + storedName: file.filename, + filePath: file.path, + fileSize: file.size, + mimeType: file.mimetype, + }); + + savedFiles.push({ message, file: savedFile }); + } + + res.json({ success: true, data: savedFiles }); + } catch (error) { + const err = error as Error; + console.error('uploadFile error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async downloadFile(req: Request, res: Response) { + try { + const fileId = parseInt(req.params.fileId, 10); + const file = await messengerService.getFileById(fileId); + + if (!file) { + return res.status(404).json({ success: false, message: 'File not found.' }); + } + + res.download(file.file_path, file.original_name); + } catch (error) { + const err = error as Error; + console.error('downloadFile error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getCompanyUsers(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const users = await messengerService.getCompanyUsers(user.companyCode!, user.userId); + res.json({ success: true, data: users }); + } catch (error) { + const err = error as Error; + console.error('getCompanyUsers error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async updateRoom(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const { room_name } = req.body; + + if (!room_name) { + return res.status(400).json({ success: false, message: 'room_name is required.' }); + } + + const room = await messengerService.updateRoom(roomId, user.companyCode!, room_name); + if (!room) { + return res.status(404).json({ success: false, message: 'Room not found.' }); + } + + res.json({ success: true, data: room }); + } catch (error) { + const err = error as Error; + console.error('updateRoom error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getUnreadCount(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const count = await messengerService.getUnreadCount(user.userId, user.companyCode!); + res.json({ success: true, data: { unread_count: count } }); + } catch (error) { + const err = error as Error; + console.error('getUnreadCount error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } +} + +export const messengerController = new MessengerController(); diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index f44d2851..73523b92 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -141,6 +141,35 @@ export async function runUserMailAccountsMigration() { } } +/** + * Messenger tables migration + */ +export async function runMessengerMigration() { + try { + console.log("🔄 메신저 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../../db/migrations/messenger_tables.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 메신저 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 메신저 테이블 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + export async function runDtgManagementLogMigration() { try { console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/routes/messengerRoutes.ts b/backend-node/src/routes/messengerRoutes.ts new file mode 100644 index 00000000..a1b08e0a --- /dev/null +++ b/backend-node/src/routes/messengerRoutes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { messengerController } from '../controllers/messengerController'; +import { authenticateToken } from '../middleware/authMiddleware'; +import { uploadMessengerFile } from '../config/multerMessengerConfig'; + +const router = Router(); + +// All messenger routes require authentication +router.use(authenticateToken); + +// GET /api/messenger/rooms - Get my rooms +router.get('/rooms', (req, res) => messengerController.getRooms(req, res)); + +// POST /api/messenger/rooms - Create a room +router.post('/rooms', (req, res) => messengerController.createRoom(req, res)); + +// GET /api/messenger/rooms/:roomId/messages - Get messages +router.get('/rooms/:roomId/messages', (req, res) => messengerController.getMessages(req, res)); + +// POST /api/messenger/rooms/:roomId/messages - Send message +router.post('/rooms/:roomId/messages', (req, res) => messengerController.sendMessage(req, res)); + +// POST /api/messenger/rooms/:roomId/read - Mark as read +router.post('/rooms/:roomId/read', (req, res) => messengerController.markAsRead(req, res)); + +// PUT /api/messenger/rooms/:roomId - Update room +router.put('/rooms/:roomId', (req, res) => messengerController.updateRoom(req, res)); + +// POST /api/messenger/files/upload - Upload files +router.post( + '/files/upload', + uploadMessengerFile.array('files', 10), + (req, res) => messengerController.uploadFile(req, res) +); + +// GET /api/messenger/files/:fileId - Download file +router.get('/files/:fileId', (req, res) => messengerController.downloadFile(req, res)); + +// GET /api/messenger/users - Get company users +router.get('/users', (req, res) => messengerController.getCompanyUsers(req, res)); + +// GET /api/messenger/unread - Get unread count +router.get('/unread', (req, res) => messengerController.getUnreadCount(req, res)); + +export default router; diff --git a/backend-node/src/services/messengerService.ts b/backend-node/src/services/messengerService.ts new file mode 100644 index 00000000..d3ff7f13 --- /dev/null +++ b/backend-node/src/services/messengerService.ts @@ -0,0 +1,404 @@ +import { PostgreSQLService } from '../database/PostgreSQLService'; +import { + MessengerRoom, + MessengerMessage, + MessengerFile, + MessengerUser, + CreateRoomRequest, + MessengerParticipant, +} from '../types/messenger'; + +class MessengerService { + /** + * Get rooms for a user with last message and unread count + */ + async getRooms(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT r.*, + m.content AS last_message, + m.created_at AS last_message_at, + m.sender_id AS last_sender_id, + COALESCE(unread.cnt, 0)::int AS unread_count + FROM messenger_rooms r + INNER JOIN messenger_participants p ON p.room_id = r.id AND p.user_id = $1 + LEFT JOIN LATERAL ( + SELECT content, created_at, sender_id + FROM messenger_messages + WHERE room_id = r.id + ORDER BY created_at DESC + LIMIT 1 + ) m ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS cnt + FROM messenger_messages + WHERE room_id = r.id + AND created_at > p.last_read_at + AND sender_id != $1 + ) unread ON true + WHERE r.company_code = $2 + ORDER BY COALESCE(m.created_at, r.created_at) DESC`, + [userId, companyCode] + ); + + // Attach participants to each room + const rooms: MessengerRoom[] = result.rows; + if (rooms.length > 0) { + const roomIds = rooms.map((r) => r.id); + const partResult = await PostgreSQLService.query( + `SELECT mp.*, ui.user_name, ui.dept_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo + FROM messenger_participants mp + LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code + WHERE mp.room_id = ANY($1)`, + [roomIds] + ); + const partMap = new Map(); + for (const p of partResult.rows) { + if (!partMap.has(p.room_id)) partMap.set(p.room_id, []); + partMap.get(p.room_id)!.push(p); + } + for (const room of rooms) { + room.participants = partMap.get(room.id) || []; + } + } + + return rooms; + } + + /** + * Create a room. For DM, return existing room if one already exists between the two users. + */ + async createRoom( + creatorId: string, + companyCode: string, + data: CreateRoomRequest + ): Promise { + // DM duplicate check + if (data.room_type === 'dm' && data.participant_ids.length === 1) { + const otherUserId = data.participant_ids[0]; + const existing = await PostgreSQLService.query( + `SELECT r.* FROM messenger_rooms r + WHERE r.company_code = $1 AND r.room_type = 'dm' + AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $2) + AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $3) + AND (SELECT COUNT(*) FROM messenger_participants WHERE room_id = r.id) = 2 + LIMIT 1`, + [companyCode, creatorId, otherUserId] + ); + if (existing.rows.length > 0) { + return existing.rows[0]; + } + } + + // Create room + const roomResult = await PostgreSQLService.query( + `INSERT INTO messenger_rooms (company_code, room_type, room_name, created_by) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [companyCode, data.room_type, data.room_name || null, creatorId] + ); + const room: MessengerRoom = roomResult.rows[0]; + + // Add participants (creator + others) + const allParticipants = [creatorId, ...data.participant_ids.filter((id) => id !== creatorId)]; + for (const uid of allParticipants) { + await PostgreSQLService.query( + `INSERT INTO messenger_participants (room_id, user_id, company_code) + VALUES ($1, $2, $3) + ON CONFLICT (room_id, user_id) DO NOTHING`, + [room.id, uid, companyCode] + ); + } + + return room; + } + + /** + * Get messages with cursor-based pagination + */ + async getMessages( + roomId: number, + userId: string, + companyCode: string, + limit: number = 50, + before?: number + ): Promise { + let query: string; + let params: any[]; + + if (before) { + query = `SELECT msg.*, + ui.user_name AS sender_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo, + COALESCE(tc.thread_count, 0)::int AS thread_count + FROM messenger_messages msg + LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS thread_count + FROM messenger_messages + WHERE parent_message_id = msg.id + ) tc ON true + WHERE msg.room_id = $1 AND msg.company_code = $2 AND msg.id < $3 + ORDER BY msg.created_at DESC + LIMIT $4`; + params = [roomId, companyCode, before, limit]; + } else { + query = `SELECT msg.*, + ui.user_name AS sender_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo, + COALESCE(tc.thread_count, 0)::int AS thread_count + FROM messenger_messages msg + LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS thread_count + FROM messenger_messages + WHERE parent_message_id = msg.id + ) tc ON true + WHERE msg.room_id = $1 AND msg.company_code = $2 + ORDER BY msg.created_at DESC + LIMIT $3`; + params = [roomId, companyCode, limit]; + } + + const result = await PostgreSQLService.query(query, params); + const messages: MessengerMessage[] = result.rows; + + // Attach reactions and files + if (messages.length > 0) { + const msgIds = messages.map((m) => m.id); + + const [reactionsResult, filesResult] = await Promise.all([ + PostgreSQLService.query( + `SELECT * FROM messenger_reactions WHERE message_id = ANY($1)`, + [msgIds] + ), + PostgreSQLService.query( + `SELECT * FROM messenger_files WHERE message_id = ANY($1)`, + [msgIds] + ), + ]); + + const reactionsMap = new Map(); + for (const r of reactionsResult.rows) { + if (!reactionsMap.has(r.message_id)) reactionsMap.set(r.message_id, []); + reactionsMap.get(r.message_id)!.push(r); + } + + const filesMap = new Map(); + for (const f of filesResult.rows) { + if (!filesMap.has(f.message_id)) filesMap.set(f.message_id, []); + filesMap.get(f.message_id)!.push(f); + } + + for (const msg of messages) { + msg.reactions = reactionsMap.get(msg.id) || []; + msg.files = filesMap.get(msg.id) || []; + } + } + + return messages; + } + + /** + * Send a message and return the saved message + */ + async sendMessage( + roomId: number, + senderId: string, + companyCode: string, + content: string, + messageType: string = 'text', + parentMessageId?: number + ): Promise { + const result = await PostgreSQLService.query( + `INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type, parent_message_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [roomId, senderId, companyCode, content, messageType, parentMessageId || null] + ); + + // Update room's updated_at + await PostgreSQLService.query( + `UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`, + [roomId] + ); + + // Get sender info + const userResult = await PostgreSQLService.query( + `SELECT user_name, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info WHERE user_id = $1 AND company_code = $2`, + [senderId, companyCode] + ); + + const message = result.rows[0]; + if (userResult.rows.length > 0) { + message.sender_name = userResult.rows[0].user_name; + message.sender_photo = userResult.rows[0].photo; + } + message.reactions = []; + message.files = []; + + return message; + } + + /** + * Mark messages as read + */ + async markAsRead(roomId: number, userId: string): Promise { + await PostgreSQLService.query( + `UPDATE messenger_participants SET last_read_at = NOW() + WHERE room_id = $1 AND user_id = $2`, + [roomId, userId] + ); + } + + /** + * Get company users for user picker + */ + async getCompanyUsers(companyCode: string, excludeUserId?: string): Promise { + let query: string; + let params: any[]; + + if (excludeUserId) { + query = `SELECT user_id, user_name, dept_name, email, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info + WHERE company_code = $1 AND user_id != $2 + ORDER BY user_name`; + params = [companyCode, excludeUserId]; + } else { + query = `SELECT user_id, user_name, dept_name, email, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info + WHERE company_code = $1 + ORDER BY user_name`; + params = [companyCode]; + } + + const result = await PostgreSQLService.query(query, params); + return result.rows; + } + + /** + * Add a reaction to a message + */ + async addReaction(messageId: number, userId: string, emoji: string): Promise { + await PostgreSQLService.query( + `INSERT INTO messenger_reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO NOTHING`, + [messageId, userId, emoji] + ); + } + + /** + * Remove a reaction from a message + */ + async removeReaction(messageId: number, userId: string, emoji: string): Promise { + await PostgreSQLService.query( + `DELETE FROM messenger_reactions + WHERE message_id = $1 AND user_id = $2 AND emoji = $3`, + [messageId, userId, emoji] + ); + } + + /** + * Get total unread message count for badge + */ + async getUnreadCount(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT COALESCE(SUM(cnt), 0)::int AS total_unread + FROM ( + SELECT COUNT(*) AS cnt + FROM messenger_participants p + INNER JOIN messenger_messages m ON m.room_id = p.room_id + AND m.created_at > p.last_read_at + AND m.sender_id != $1 + WHERE p.user_id = $1 AND p.company_code = $2 + GROUP BY p.room_id + ) sub`, + [userId, companyCode] + ); + return result.rows[0]?.total_unread || 0; + } + + /** + * Save file info for a message + */ + async saveFile( + messageId: number, + fileInfo: { originalName: string; storedName: string; filePath: string; fileSize: number; mimeType: string } + ): Promise { + const result = await PostgreSQLService.query( + `INSERT INTO messenger_files (message_id, original_name, stored_name, file_path, file_size, mime_type) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [messageId, fileInfo.originalName, fileInfo.storedName, fileInfo.filePath, fileInfo.fileSize, fileInfo.mimeType] + ); + return result.rows[0]; + } + + /** + * Get room by ID with participants + */ + async getRoomById(roomId: number, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT * FROM messenger_rooms WHERE id = $1 AND company_code = $2`, + [roomId, companyCode] + ); + if (result.rows.length === 0) return null; + + const room: MessengerRoom = result.rows[0]; + + const partResult = await PostgreSQLService.query( + `SELECT mp.*, ui.user_name, ui.dept_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo + FROM messenger_participants mp + LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code + WHERE mp.room_id = $1`, + [roomId] + ); + room.participants = partResult.rows; + + return room; + } + + /** + * Update room name + */ + async updateRoom(roomId: number, companyCode: string, roomName: string): Promise { + const result = await PostgreSQLService.query( + `UPDATE messenger_rooms SET room_name = $1, updated_at = NOW() + WHERE id = $2 AND company_code = $3 + RETURNING *`, + [roomName, roomId, companyCode] + ); + return result.rows.length > 0 ? result.rows[0] : null; + } + + /** + * Get file by ID + */ + async getFileById(fileId: number): Promise { + const result = await PostgreSQLService.query( + `SELECT * FROM messenger_files WHERE id = $1`, + [fileId] + ); + return result.rows.length > 0 ? result.rows[0] : null; + } + + /** + * Get participant room IDs for socket join + */ + async getUserRoomIds(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT room_id FROM messenger_participants + WHERE user_id = $1 AND company_code = $2`, + [userId, companyCode] + ); + return result.rows.map((r: any) => r.room_id); + } +} + +export const messengerService = new MessengerService(); diff --git a/backend-node/src/socket/messengerSocket.ts b/backend-node/src/socket/messengerSocket.ts new file mode 100644 index 00000000..355bf4b5 --- /dev/null +++ b/backend-node/src/socket/messengerSocket.ts @@ -0,0 +1,141 @@ +import { Server, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import config from '../config/environment'; +import { messengerService } from '../services/messengerService'; +import { JwtPayload } from '../types/auth'; + +interface AuthenticatedSocket extends Socket { + data: { + userId: string; + userName: string; + companyCode: string; + }; +} + +export function initMessengerSocket(io: Server) { + // JWT authentication middleware + io.use((socket, next) => { + const token = socket.handshake.auth?.token || socket.handshake.query?.token; + if (!token) { + return next(new Error('Authentication required')); + } + + try { + const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload; + socket.data.userId = decoded.userId; + socket.data.userName = decoded.userName; + socket.data.companyCode = decoded.companyCode || ''; + next(); + } catch (error) { + next(new Error('Invalid token')); + } + }); + + io.on('connection', async (socket: AuthenticatedSocket) => { + const { userId, companyCode } = socket.data; + console.log(`[Messenger] User connected: ${userId}`); + + // join_rooms: subscribe to all user's rooms + socket.on('join_rooms', async () => { + try { + const roomIds = await messengerService.getUserRoomIds(userId, companyCode); + for (const roomId of roomIds) { + socket.join(`${companyCode}:${roomId}`); + } + socket.emit('rooms_joined', { roomIds }); + } catch (error) { + console.error('[Messenger] join_rooms error:', error); + socket.emit('error', { message: 'Failed to join rooms' }); + } + }); + + // send_message: save and broadcast + socket.on('send_message', async (data: { + room_id: number; + content: string; + message_type?: string; + parent_message_id?: number; + }) => { + try { + const message = await messengerService.sendMessage( + data.room_id, + userId, + companyCode, + data.content, + data.message_type || 'text', + data.parent_message_id + ); + io.to(`${companyCode}:${data.room_id}`).emit('new_message', message); + } catch (error) { + console.error('[Messenger] send_message error:', error); + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // message_read: update last_read_at + socket.on('message_read', async (data: { room_id: number }) => { + try { + await messengerService.markAsRead(data.room_id, userId); + io.to(`${companyCode}:${data.room_id}`).emit('user_read', { + room_id: data.room_id, + user_id: userId, + read_at: new Date().toISOString(), + }); + } catch (error) { + console.error('[Messenger] message_read error:', error); + } + }); + + // typing indicators + socket.on('typing_start', (data: { room_id: number }) => { + socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', { + room_id: data.room_id, + user_id: userId, + user_name: socket.data.userName, + }); + }); + + socket.on('typing_stop', (data: { room_id: number }) => { + socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', { + room_id: data.room_id, + user_id: userId, + }); + }); + + // reactions + socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => { + try { + await messengerService.addReaction(data.message_id, userId, data.emoji); + io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', { + message_id: data.message_id, + user_id: userId, + emoji: data.emoji, + }); + } catch (error) { + console.error('[Messenger] add_reaction error:', error); + } + }); + + socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => { + try { + await messengerService.removeReaction(data.message_id, userId, data.emoji); + io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', { + message_id: data.message_id, + user_id: userId, + emoji: data.emoji, + }); + } catch (error) { + console.error('[Messenger] remove_reaction error:', error); + } + }); + + // join a specific room (e.g., after creating a new room) + socket.on('join_room', (data: { room_id: number }) => { + socket.join(`${companyCode}:${data.room_id}`); + }); + + socket.on('disconnect', () => { + console.log(`[Messenger] User disconnected: ${userId}`); + }); + }); +} diff --git a/backend-node/src/types/messenger.ts b/backend-node/src/types/messenger.ts new file mode 100644 index 00000000..49198adf --- /dev/null +++ b/backend-node/src/types/messenger.ts @@ -0,0 +1,97 @@ +// Messenger type definitions + +export interface MessengerRoom { + id: number; + company_code: string; + room_type: 'dm' | 'group' | 'channel'; + room_name: string | null; + created_by: string; + created_at: string; + updated_at: string; + // joined fields + last_message?: string; + last_message_at?: string; + last_sender_id?: string; + unread_count?: number; + participants?: MessengerParticipant[]; +} + +export interface MessengerParticipant { + id: number; + room_id: number; + user_id: string; + company_code: string; + last_read_at: string; + joined_at: string; + // joined fields + user_name?: string; + dept_name?: string; + photo?: string | null; +} + +export interface MessengerMessage { + id: number; + room_id: number; + sender_id: string; + company_code: string; + content: string | null; + message_type: 'text' | 'file' | 'system'; + parent_message_id: number | null; + created_at: string; + updated_at: string; + // joined fields + sender_name?: string; + sender_photo?: string | null; + reactions?: MessengerReaction[]; + files?: MessengerFile[]; + thread_count?: number; +} + +export interface MessengerReaction { + id: number; + message_id: number; + user_id: string; + emoji: string; + created_at: string; +} + +export interface MessengerFile { + id: number; + message_id: number; + original_name: string; + stored_name: string; + file_path: string; + file_size: number; + mime_type: string | null; + created_at: string; +} + +// Request types +export interface CreateRoomRequest { + room_type: 'dm' | 'group' | 'channel'; + room_name?: string; + participant_ids: string[]; +} + +export interface SendMessageRequest { + content: string; + message_type?: 'text' | 'file' | 'system'; + parent_message_id?: number; +} + +export interface AddReactionRequest { + message_id: number; + emoji: string; +} + +export interface UpdateRoomRequest { + room_name: string; +} + +export interface MessengerUser { + user_id: string; + user_name: string; + dept_name: string; + email?: string; + photo?: string | null; +} diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json index 92cd0e26..4f08ef45 100644 --- a/frontend/.omc/state/last-tool-error.json +++ b/frontend/.omc/state/last-tool-error.json @@ -1,7 +1,7 @@ { "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"git add frontend/lib/api/userMail.ts \\\"frontend/app/(main)/mail/imap/page.tsx\\\" && git commit -m \\\"[RAPID-fix] 첨부파일 다운로드 인증 오류 수정: fetch Blob 방식으로 변경\\n\\nAuthorization 헤더를 지원하지 않는 ...", - "error": "Exit code 128\nwarning: could not open directory 'frontend/frontend/': No such file or directory\nfatal: pathspec 'frontend/lib/api/userMail.ts' did not match any files", - "timestamp": "2026-03-30T00:30:20.014Z", + "tool_input_preview": "{\"command\":\"ls /Users/yc/ERP-node/frontend/.env* 2>/dev/null && cat /Users/yc/ERP-node/frontend/.env.local 2>/dev/null\",\"description\":\"Check frontend env files\"}", + "error": "Exit code 1\n(eval):1: no matches found: /Users/yc/ERP-node/frontend/.env*", + "timestamp": "2026-03-30T09:22:21.149Z", "retry_count": 1 } \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json index a46a9962..9356ac70 100644 --- a/frontend/.omc/state/mission-state.json +++ b/frontend/.omc/state/mission-state.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-03-25T05:06:35.487Z", + "updatedAt": "2026-03-30T09:22:05.771Z", "missions": [ { "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", @@ -276,6 +276,54 @@ "sourceKey": "session-stop:a4eb932c438b898c0" } ] + }, + { + "id": "session:2ea5d668-aa64-4450-a6ac-24143b6e6cee:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-30T09:18:44.199Z", + "updatedAt": "2026-03-30T09:22:05.771Z", + "status": "done", + "workerCount": 1, + "taskCounts": { + "total": 1, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 1, + "failed": 0 + }, + "agents": [ + { + "name": "qa-tester:a8c34e4", + "role": "qa-tester", + "ownership": "a8c34e4ce449d1c4b", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-30T09:22:05.771Z" + } + ], + "timeline": [ + { + "id": "session-start:a8c34e4ce449d1c4b:2026-03-30T09:18:44.199Z", + "at": "2026-03-30T09:18:44.199Z", + "kind": "update", + "agent": "qa-tester:a8c34e4", + "detail": "started qa-tester:a8c34e4", + "sourceKey": "session-start:a8c34e4ce449d1c4b" + }, + { + "id": "session-stop:a8c34e4ce449d1c4b:2026-03-30T09:22:05.771Z", + "at": "2026-03-30T09:22:05.771Z", + "kind": "completion", + "agent": "qa-tester:a8c34e4", + "detail": "completed", + "sourceKey": "session-stop:a8c34e4ce449d1c4b" + } + ] } ] } \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json index bb9f1c9e..de699dd5 100644 --- a/frontend/.omc/state/subagent-tracking.json +++ b/frontend/.omc/state/subagent-tracking.json @@ -107,10 +107,19 @@ "status": "completed", "completed_at": "2026-03-25T05:06:35.487Z", "duration_ms": 401646 + }, + { + "agent_id": "a8c34e4ce449d1c4b", + "agent_type": "oh-my-claudecode:qa-tester", + "started_at": "2026-03-30T09:18:44.199Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-30T09:22:05.771Z", + "duration_ms": 201572 } ], - "total_spawned": 12, - "total_completed": 12, + "total_spawned": 13, + "total_completed": 13, "total_failed": 0, - "last_updated": "2026-03-27T09:24:43.842Z" + "last_updated": "2026-03-30T09:22:05.879Z" } \ No newline at end of file diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index e4144506..29273b0b 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -1,14 +1,21 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { MenuProvider } from "@/contexts/MenuContext"; +import { MessengerProvider } from "@/contexts/MessengerContext"; import { AppLayout } from "@/components/layout/AppLayout"; import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener"; +import { MessengerFAB } from "@/components/messenger/MessengerFAB"; +import { MessengerModal } from "@/components/messenger/MessengerModal"; export default function MainLayout({ children }: { children: React.ReactNode }) { return ( - {children} - + + {children} + + + + ); diff --git a/frontend/components/messenger/ChatPanel.tsx b/frontend/components/messenger/ChatPanel.tsx new file mode 100644 index 00000000..5b969157 --- /dev/null +++ b/frontend/components/messenger/ChatPanel.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { MessageSquare } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useMessages, useMarkAsRead } from "@/hooks/useMessenger"; +import { useAuth } from "@/hooks/useAuth"; +import { useMessengerContext } from "@/contexts/MessengerContext"; +import { useMessengerSocket } from "@/hooks/useMessengerSocket"; +import { MessageItem } from "./MessageItem"; +import { MessageInput } from "./MessageInput"; +import type { Room } from "@/hooks/useMessenger"; + +interface ChatPanelProps { + room: Room | null; +} + +export function ChatPanel({ room }: ChatPanelProps) { + const { user } = useAuth(); + const { selectedRoomId } = useMessengerContext(); + const { data: messages } = useMessages(selectedRoomId); + const markAsRead = useMarkAsRead(); + const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket(); + const bottomRef = useRef(null); + + useEffect(() => { + if (selectedRoomId) { + markAsRead.mutate(selectedRoomId); + } + }, [selectedRoomId, messages?.length]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages?.length]); + + if (!room) { + return ( +
+ +

대화를 선택하세요

+
+ ); + } + + const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined; + + // Group consecutive messages from same sender + const isSameGroup = (idx: number) => { + if (idx === 0) return false; + const prev = messages![idx - 1]; + const curr = messages![idx]; + return prev.senderId === curr.senderId && !curr.isDeleted && !prev.isDeleted; + }; + + // Date separator helper + const shouldShowDate = (idx: number) => { + if (idx === 0) return true; + const prev = new Date(messages![idx - 1].createdAt).toDateString(); + const curr = new Date(messages![idx].createdAt).toDateString(); + return prev !== curr; + }; + + return ( +
+ {/* Header */} +
+

{room.name}

+ + {room.participants.length}명 + +
+ + {/* Messages */} + +
+ {messages?.map((msg, idx) => ( +
+ {shouldShowDate(idx) && ( +
+
+ + {new Date(msg.createdAt).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + })} + +
+
+ )} + +
+ ))} +
+
+ + + {/* Typing indicator */} + {roomTyping && roomTyping.length > 0 && ( +
+ {roomTyping.join(", ")}님이 입력 중... +
+ )} + + {/* Input */} + emitTypingStart(room.id)} + onTypingStop={() => emitTypingStop(room.id)} + /> +
+ ); +} diff --git a/frontend/components/messenger/MessageInput.tsx b/frontend/components/messenger/MessageInput.tsx new file mode 100644 index 00000000..13f2c605 --- /dev/null +++ b/frontend/components/messenger/MessageInput.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect, KeyboardEvent, ChangeEvent } from "react"; +import { Paperclip, Send, SmilePlus } from "lucide-react"; +import { useSendMessage, useUploadFile, useCompanyUsers } from "@/hooks/useMessenger"; + +const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}", "\u{1F389}", "\u{1F914}"]; + +interface MessageInputProps { + roomId: string; + onTypingStart?: () => void; + onTypingStop?: () => void; +} + +export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps) { + const [text, setText] = useState(""); + const [showEmoji, setShowEmoji] = useState(false); + const [mentionQuery, setMentionQuery] = useState(null); + const [mentionIndex, setMentionIndex] = useState(0); + const textareaRef = useRef(null); + const fileRef = useRef(null); + const typingTimerRef = useRef | null>(null); + + const sendMessage = useSendMessage(); + const uploadFile = useUploadFile(); + const { data: users } = useCompanyUsers(); + + const filteredMentionUsers = mentionQuery !== null && users + ? users.filter((u) => u.userName.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5) + : []; + + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (el) { + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 120) + "px"; + } + }, []); + + useEffect(() => { + adjustHeight(); + }, [text, adjustHeight]); + + const handleSend = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + sendMessage.mutate({ roomId, content: trimmed }); + setText(""); + onTypingStop?.(); + }, [text, roomId, sendMessage, onTypingStop]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (mentionQuery !== null && filteredMentionUsers.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setMentionIndex((p) => Math.max(p - 1, 0)); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + insertMention(filteredMentionUsers[mentionIndex]); + return; + } + if (e.key === "Escape") { + setMentionQuery(null); + return; + } + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const insertMention = (user: { userId: string; userName: string }) => { + const el = textareaRef.current; + if (!el) return; + const val = el.value; + const atIdx = val.lastIndexOf("@", el.selectionStart - 1); + if (atIdx === -1) return; + const before = val.slice(0, atIdx); + const after = val.slice(el.selectionStart); + setText(`${before}@${user.userName} ${after}`); + setMentionQuery(null); + }; + + const handleChange = (e: ChangeEvent) => { + const val = e.target.value; + setText(val); + + // Typing events + onTypingStart?.(); + if (typingTimerRef.current) clearTimeout(typingTimerRef.current); + typingTimerRef.current = setTimeout(() => onTypingStop?.(), 2000); + + // Mention detection + const cursor = e.target.selectionStart; + const textBeforeCursor = val.slice(0, cursor); + const atMatch = textBeforeCursor.match(/@(\S*)$/); + if (atMatch) { + setMentionQuery(atMatch[1]); + setMentionIndex(0); + } else { + setMentionQuery(null); + } + }; + + const handleFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const result = await uploadFile.mutateAsync(file); + sendMessage.mutate({ + roomId, + content: file.name, + type: "file", + }); + } catch { + // upload failed silently + } + e.target.value = ""; + }; + + return ( +
+ {mentionQuery !== null && filteredMentionUsers.length > 0 && ( +
+ {filteredMentionUsers.map((u, i) => ( + + ))} +
+ )} + +
+ + + +
+ + {showEmoji && ( +
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ )} +
+ +