[RAPID] feat: 메신저 기능 구현 (Socket.IO 실시간 채팅)

- DB: messenger_rooms/participants/messages/reactions/files 테이블 생성
- Backend: REST API 9개 엔드포인트 + Socket.IO 실시간 핸들러
- Frontend: Gmail 스타일 FAB + 모달, 채팅방 목록, 채팅 패널
- 기능: DM/그룹/채널, 파일 첨부, 이모지 리액션, 멘션, 스레드
- 알림: 토스트 on/off 토글, FAB 읽지 않은 배지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

[RAPID-fix] 메신저 API snake_case→camelCase 변환 및 Socket.IO URL 수정

- useRooms/useMessages/useCompanyUsers 훅에서 DB 응답 camelCase 변환
- Socket.IO 기본 연결 URL 3001 → 8080 수정
- runMigration.ts 마이그레이션 파일 경로 수정 (../../ → ../../../)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

[RAPID-fix] 방 생성 API camelCase/snake_case 호환 처리

- createRoom 컨트롤러에서 participantIds/type/name (camelCase) fallback 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

[RAPID-fix] 메시지 전송 API 추가 (sendMessage 라우트/컨트롤러 누락)

- POST /api/messenger/rooms/:roomId/messages 라우트 등록
- MessengerController.sendMessage 메서드 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syc0123
2026-03-30 18:05:54 +09:00
parent e763249342
commit f558073ef8
28 changed files with 2578 additions and 10 deletions
+126 -1
View File
@@ -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",
+1
View File
@@ -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"
},
+18
View File
@@ -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);
}
@@ -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,
},
});
@@ -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();
+29
View File
@@ -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 이력 테이블 마이그레이션 시작...");
@@ -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;
@@ -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<MessengerRoom[]> {
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<number, MessengerParticipant[]>();
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<MessengerRoom> {
// 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<MessengerMessage[]> {
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<number, any[]>();
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<number, any[]>();
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<MessengerMessage> {
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<void> {
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<MessengerUser[]> {
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<void> {
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<void> {
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<number> {
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<MessengerFile> {
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<MessengerRoom | null> {
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<MessengerRoom | null> {
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<MessengerFile | null> {
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<number[]> {
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();
+141
View File
@@ -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}`);
});
});
}
+97
View File
@@ -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;
}
+3 -3
View File
@@ -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 헤더를 지원하지 않는 <a href> ...",
"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
}
+49 -1
View File
@@ -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"
}
]
}
]
}
+12 -3
View File
@@ -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"
}
+9 -2
View File
@@ -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 (
<AuthProvider>
<MenuProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
<MessengerProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
<MessengerFAB />
<MessengerModal />
</MessengerProvider>
</MenuProvider>
</AuthProvider>
);
+118
View File
@@ -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<HTMLDivElement>(null);
useEffect(() => {
if (selectedRoomId) {
markAsRead.mutate(selectedRoomId);
}
}, [selectedRoomId, messages?.length]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages?.length]);
if (!room) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2">
<MessageSquare className="h-10 w-10" />
<p className="text-sm"> </p>
</div>
);
}
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 (
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="border-b px-4 py-2 flex items-center gap-2">
<h3 className="font-semibold text-sm truncate">{room.name}</h3>
<span className="text-xs text-muted-foreground">
{room.participants.length}
</span>
</div>
{/* Messages */}
<ScrollArea className="flex-1">
<div className="py-2">
{messages?.map((msg, idx) => (
<div key={msg.id}>
{shouldShowDate(idx) && (
<div className="flex items-center gap-2 px-4 py-2">
<div className="flex-1 h-px bg-border" />
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
})}
</span>
<div className="flex-1 h-px bg-border" />
</div>
)}
<MessageItem
message={msg}
isOwn={msg.senderId === user?.userId}
showAvatar={!isSameGroup(idx)}
/>
</div>
))}
<div ref={bottomRef} />
</div>
</ScrollArea>
{/* Typing indicator */}
{roomTyping && roomTyping.length > 0 && (
<div className="px-4 py-1 text-xs text-muted-foreground">
{roomTyping.join(", ")} ...
</div>
)}
{/* Input */}
<MessageInput
roomId={room.id}
onTypingStart={() => emitTypingStart(room.id)}
onTypingStop={() => emitTypingStop(room.id)}
/>
</div>
);
}
@@ -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<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="border-t p-2 relative">
{mentionQuery !== null && filteredMentionUsers.length > 0 && (
<div className="absolute bottom-full left-2 right-2 bg-background border rounded-md shadow-md max-h-40 overflow-y-auto">
{filteredMentionUsers.map((u, i) => (
<button
key={u.userId}
onClick={() => insertMention(u)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-muted ${i === mentionIndex ? "bg-muted" : ""}`}
>
<span className="font-medium">{u.userName}</span>
{u.deptName && <span className="text-muted-foreground ml-2">{u.deptName}</span>}
</button>
))}
</div>
)}
<div className="flex items-end gap-1">
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded">
<Paperclip className="h-4 w-4 text-muted-foreground" />
</button>
<input ref={fileRef} type="file" className="hidden" onChange={handleFileChange} />
<div className="relative">
<button onClick={() => setShowEmoji((p) => !p)} className="p-1.5 hover:bg-muted rounded">
<SmilePlus className="h-4 w-4 text-muted-foreground" />
</button>
{showEmoji && (
<div className="absolute bottom-full left-0 bg-background border rounded-md shadow-md p-1.5 flex flex-wrap gap-1 w-48 z-10">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => {
setText((p) => p + emoji);
setShowEmoji(false);
textareaRef.current?.focus();
}}
className="hover:bg-muted rounded p-1 text-lg"
>
{emoji}
</button>
))}
</div>
)}
</div>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="메시지를 입력하세요..."
rows={1}
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1.5 px-2 max-h-[120px]"
/>
<button
onClick={handleSend}
disabled={!text.trim()}
className="p-1.5 hover:bg-muted rounded disabled:opacity-40"
>
<Send className="h-4 w-4 text-primary" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { SmilePlus, MessageSquare, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserAvatar } from "./UserAvatar";
import type { Message } from "@/hooks/useMessenger";
import { useAddReaction } from "@/hooks/useMessenger";
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}"];
interface MessageItemProps {
message: Message;
isOwn: boolean;
showAvatar: boolean;
}
export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
const [showActions, setShowActions] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const addReaction = useAddReaction();
if (message.isDeleted) {
return (
<div className={cn("flex gap-2 px-3 py-1", isOwn && "flex-row-reverse")}>
{showAvatar && !isOwn ? (
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
) : (
<div className="w-7" />
)}
<div className="text-xs text-muted-foreground italic"> </div>
</div>
);
}
const time = new Date(message.createdAt).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
className={cn("group flex gap-2 px-3 py-0.5", isOwn && "flex-row-reverse")}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => {
setShowActions(false);
setShowEmojiPicker(false);
}}
>
{showAvatar && !isOwn ? (
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
) : (
<div className="w-7" />
)}
<div className={cn("flex flex-col max-w-[70%]", isOwn && "items-end")}>
{showAvatar && !isOwn && (
<span className="text-xs font-medium text-muted-foreground mb-0.5">
{message.senderName}
</span>
)}
<div className="relative">
<div
className={cn(
"rounded-lg px-3 py-1.5 text-sm break-words",
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{message.type === "file" && message.fileUrl ? (
<a
href={message.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 underline"
>
<Download className="h-3.5 w-3.5" />
{message.fileName || "파일 다운로드"}
</a>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
{showActions && (
<div
className={cn(
"absolute top-0 flex items-center gap-0.5 bg-background border rounded-md shadow-sm px-1 py-0.5 -translate-y-1/2",
isOwn ? "left-0 -translate-x-full mr-1" : "right-0 translate-x-full ml-1"
)}
>
<button
onClick={() => setShowEmojiPicker((p) => !p)}
className="p-0.5 hover:bg-muted rounded"
>
<SmilePlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
<button className="p-0.5 hover:bg-muted rounded">
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
)}
{showEmojiPicker && (
<div
className={cn(
"absolute top-0 -translate-y-full bg-background border rounded-md shadow-md p-1 flex gap-0.5 z-10",
isOwn ? "right-0" : "left-0"
)}
>
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => {
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji });
setShowEmojiPicker(false);
}}
className="hover:bg-muted rounded p-0.5 text-base"
>
{emoji}
</button>
))}
</div>
)}
</div>
{message.reactions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{message.reactions.map((r) => (
<button
key={r.emoji}
onClick={() =>
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji: r.emoji })
}
className="flex items-center gap-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs hover:bg-muted/80"
>
<span>{r.emoji}</span>
<span className="text-muted-foreground">{r.users.length}</span>
</button>
))}
</div>
)}
<span className="text-[10px] text-muted-foreground mt-0.5">{time}</span>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import { MessageSquare } from "lucide-react";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { useUnreadCount } from "@/hooks/useMessenger";
import { useEffect } from "react";
export function MessengerFAB() {
const { isOpen, openMessenger, unreadCount, setUnreadCount } = useMessengerContext();
const { data: serverUnread } = useUnreadCount();
useEffect(() => {
if (serverUnread !== undefined) setUnreadCount(serverUnread);
}, [serverUnread, setUnreadCount]);
if (isOpen) return null;
return (
<button
onClick={() => openMessenger()}
className="fixed bottom-6 right-6 z-[9999] flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors"
aria-label="메신저 열기"
>
<MessageSquare className="h-6 w-6" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[11px] font-bold text-destructive-foreground">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { X, Settings } from "lucide-react";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { useRooms } from "@/hooks/useMessenger";
import { RoomList } from "./RoomList";
import { ChatPanel } from "./ChatPanel";
import { MessengerSettings } from "./MessengerSettings";
export function MessengerModal() {
const { isOpen, closeMessenger, selectedRoomId } = useMessengerContext();
const { data: rooms = [] } = useRooms();
const [showSettings, setShowSettings] = useState(false);
if (!isOpen) return null;
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
return (
<div
className="fixed bottom-6 right-6 z-[9999] flex flex-col bg-background border rounded-lg shadow-2xl overflow-hidden"
style={{ width: 720, height: 500 }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
<h2 className="text-sm font-semibold"></h2>
<div className="flex items-center gap-1">
<button
onClick={() => setShowSettings((p) => !p)}
className="p-1 hover:bg-muted rounded"
aria-label="설정"
>
<Settings className="h-4 w-4 text-muted-foreground" />
</button>
<button
onClick={closeMessenger}
className="p-1 hover:bg-muted rounded"
aria-label="닫기"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
{/* Body */}
<div className="flex flex-1 min-h-0 relative">
<RoomList />
<ChatPanel room={selectedRoom} />
{/* Settings slide panel */}
{showSettings && (
<div className="absolute inset-0 bg-background z-10 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-semibold"></span>
<button onClick={() => setShowSettings(false)} className="p-1 hover:bg-muted rounded">
<X className="h-4 w-4" />
</button>
</div>
<MessengerSettings />
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useMessengerContext } from "@/contexts/MessengerContext";
export function MessengerSettings() {
const { notificationEnabled, toggleNotification } = useMessengerContext();
return (
<div className="p-4 space-y-4">
<h3 className="font-semibold text-sm"></h3>
<div className="flex items-center justify-between">
<Label htmlFor="notification-toggle" className="text-sm">
</Label>
<Switch
id="notification-toggle"
checked={notificationEnabled}
onCheckedChange={toggleNotification}
/>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCompanyUsers, useCreateRoom } from "@/hooks/useMessenger";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { UserAvatar } from "./UserAvatar";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface NewRoomModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewRoomModal({ open, onOpenChange }: NewRoomModalProps) {
const [tab, setTab] = useState<"dm" | "group" | "channel">("dm");
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [roomName, setRoomName] = useState("");
const [channelDesc, setChannelDesc] = useState("");
const { data: users = [] } = useCompanyUsers();
const createRoom = useCreateRoom();
const { selectRoom } = useMessengerContext();
const filtered = users.filter(
(u) =>
u.userName.toLowerCase().includes(search.toLowerCase()) ||
(u.deptName && u.deptName.toLowerCase().includes(search.toLowerCase()))
);
const toggleUser = (userId: string) => {
if (tab === "dm") {
setSelectedIds([userId]);
} else {
setSelectedIds((prev) =>
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
);
}
};
const handleCreate = async () => {
try {
const room = await createRoom.mutateAsync({
type: tab,
name: tab === "channel" ? roomName : tab === "group" ? roomName : undefined,
description: tab === "channel" ? channelDesc : undefined,
participantIds: selectedIds,
});
selectRoom(room.id);
onOpenChange(false);
reset();
} catch {
// handled by query
}
};
const reset = () => {
setSearch("");
setSelectedIds([]);
setRoomName("");
setChannelDesc("");
};
const canCreate =
selectedIds.length > 0 &&
(tab === "dm" || tab === "group" || (tab === "channel" && roomName.trim()));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => { setTab(v as typeof tab); reset(); }}>
<TabsList className="w-full">
<TabsTrigger value="dm" className="flex-1">DM</TabsTrigger>
<TabsTrigger value="group" className="flex-1"></TabsTrigger>
<TabsTrigger value="channel" className="flex-1"></TabsTrigger>
</TabsList>
{(tab === "group" || tab === "channel") && (
<div className="mt-3 space-y-2">
<Input
placeholder={tab === "group" ? "그룹 이름" : "채널 이름"}
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
/>
{tab === "channel" && (
<Input
placeholder="채널 설명 (선택)"
value={channelDesc}
onChange={(e) => setChannelDesc(e.target.value)}
/>
)}
</div>
)}
<TabsContent value={tab} className="mt-3">
<Input
placeholder="사용자 검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2"
/>
{selectedIds.length > 0 && tab !== "dm" && (
<div className="flex flex-wrap gap-1 mb-2">
{selectedIds.map((id) => {
const u = users.find((x) => x.userId === id);
return u ? (
<span
key={id}
onClick={() => toggleUser(id)}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5 cursor-pointer hover:bg-primary/20"
>
{u.userName} &times;
</span>
) : null;
})}
</div>
)}
<ScrollArea className="h-60">
{filtered.map((u) => {
const selected = selectedIds.includes(u.userId);
return (
<button
key={u.userId}
onClick={() => toggleUser(u.userId)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left",
selected && "bg-accent"
)}
>
<UserAvatar photo={u.photo} name={u.userName} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{u.userName}</div>
{u.deptName && (
<div className="text-xs text-muted-foreground truncate">
{u.deptName} {u.positionName && `/ ${u.positionName}`}
</div>
)}
</div>
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
</button>
);
})}
</ScrollArea>
</TabsContent>
</Tabs>
<div className="flex justify-end mt-2">
<Button onClick={handleCreate} disabled={!canCreate || createRoom.isPending} size="sm">
{createRoom.isPending ? "생성 중..." : "대화 시작"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRooms } from "@/hooks/useMessenger";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { UserAvatar } from "./UserAvatar";
import { NewRoomModal } from "./NewRoomModal";
import { cn } from "@/lib/utils";
import type { Room } from "@/hooks/useMessenger";
function formatTime(dateStr?: string) {
if (!dateStr) return "";
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return "어제";
return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
}
function RoomItem({ room, selected, onClick }: { room: Room; selected: boolean; onClick: () => void }) {
const firstParticipant = room.participants[0];
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left",
selected && "bg-accent"
)}
>
<UserAvatar
photo={firstParticipant?.photo}
name={room.name || firstParticipant?.userName || "?"}
size="md"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium truncate">{room.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0 ml-1">
{formatTime(room.lastMessageAt)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate">
{room.lastMessage || "\u00A0"}
</span>
{room.unreadCount > 0 && (
<span className="ml-1 shrink-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground px-1">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
)}
</div>
</div>
</button>
);
}
export function RoomList() {
const { data: rooms = [] } = useRooms();
const { selectedRoomId, selectRoom } = useMessengerContext();
const [newRoomOpen, setNewRoomOpen] = useState(false);
const dmRooms = rooms.filter((r) => r.type === "dm");
const groupRooms = rooms.filter((r) => r.type === "group");
const channelRooms = rooms.filter((r) => r.type === "channel");
const renderRooms = (list: Room[]) =>
list.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4"> </div>
) : (
list.map((r) => (
<RoomItem
key={r.id}
room={r}
selected={r.id === selectedRoomId}
onClick={() => selectRoom(r.id)}
/>
))
);
return (
<div className="flex flex-col h-full border-r w-[240px] shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<button
onClick={() => setNewRoomOpen(true)}
className="p-1 hover:bg-muted rounded"
aria-label="새 대화"
>
<Plus className="h-4 w-4" />
</button>
</div>
<Tabs defaultValue="dm" className="flex-1 flex flex-col">
<TabsList className="w-full rounded-none border-b h-8 bg-transparent p-0">
<TabsTrigger value="dm" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
DM
</TabsTrigger>
<TabsTrigger value="group" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
</TabsTrigger>
<TabsTrigger value="channel" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1">
<TabsContent value="dm" className="m-0">{renderRooms(dmRooms)}</TabsContent>
<TabsContent value="group" className="m-0">{renderRooms(groupRooms)}</TabsContent>
<TabsContent value="channel" className="m-0">{renderRooms(channelRooms)}</TabsContent>
</ScrollArea>
</Tabs>
<NewRoomModal open={newRoomOpen} onOpenChange={setNewRoomOpen} />
</div>
);
}
@@ -0,0 +1,45 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
interface UserAvatarProps {
photo?: string | null;
name: string;
size?: "sm" | "md" | "lg";
online?: boolean;
}
const sizeMap = {
sm: "h-7 w-7 text-xs",
md: "h-9 w-9 text-sm",
lg: "h-11 w-11 text-base",
};
const dotSizeMap = {
sm: "h-2 w-2",
md: "h-2.5 w-2.5",
lg: "h-3 w-3",
};
export function UserAvatar({ photo, name, size = "md", online }: UserAvatarProps) {
return (
<div className="relative inline-block shrink-0">
<Avatar className={cn(sizeMap[size])}>
{photo && <AvatarImage src={photo} alt={name} />}
<AvatarFallback className="bg-muted text-muted-foreground">
{name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{online !== undefined && (
<span
className={cn(
"absolute bottom-0 right-0 rounded-full border-2 border-background",
dotSizeMap[size],
online ? "bg-green-500" : "bg-gray-300"
)}
/>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
interface MessengerContextValue {
isOpen: boolean;
selectedRoomId: string | null;
unreadCount: number;
notificationEnabled: boolean;
openMessenger: (roomId?: string) => void;
closeMessenger: () => void;
selectRoom: (roomId: string) => void;
setUnreadCount: (count: number) => void;
toggleNotification: () => void;
}
const MessengerContext = createContext<MessengerContextValue | undefined>(undefined);
export function MessengerProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationEnabled, setNotificationEnabled] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("messenger_notification");
if (stored !== null) {
setNotificationEnabled(stored === "true");
}
}, []);
const openMessenger = useCallback((roomId?: string) => {
setIsOpen(true);
if (roomId) setSelectedRoomId(roomId);
}, []);
const closeMessenger = useCallback(() => {
setIsOpen(false);
}, []);
const selectRoom = useCallback((roomId: string) => {
setSelectedRoomId(roomId);
}, []);
const toggleNotification = useCallback(() => {
setNotificationEnabled((prev) => {
const next = !prev;
localStorage.setItem("messenger_notification", String(next));
return next;
});
}, []);
return (
<MessengerContext.Provider
value={{
isOpen,
selectedRoomId,
unreadCount,
notificationEnabled,
openMessenger,
closeMessenger,
selectRoom,
setUnreadCount,
toggleNotification,
}}
>
{children}
</MessengerContext.Provider>
);
}
export function useMessengerContext() {
const ctx = useContext(MessengerContext);
if (!ctx) throw new Error("useMessengerContext must be used within MessengerProvider");
return ctx;
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
// ============================================
// Types
// ============================================
export interface Room {
id: string;
name: string;
type: "dm" | "group" | "channel";
lastMessage?: string;
lastMessageAt?: string;
unreadCount: number;
participants: Participant[];
description?: string;
}
export interface Participant {
userId: string;
userName: string;
photo?: string | null;
}
export interface Message {
id: string;
roomId: string;
senderId: string;
senderName: string;
senderPhoto?: string | null;
content: string;
type: "text" | "file" | "system";
fileUrl?: string;
fileName?: string;
reactions: Reaction[];
threadCount?: number;
parentId?: string | null;
isDeleted: boolean;
createdAt: string;
}
export interface Reaction {
emoji: string;
users: { userId: string; userName: string }[];
}
export interface CompanyUser {
userId: string;
userName: string;
deptName?: string;
positionName?: string;
photo?: string | null;
}
// ============================================
// API helpers
// ============================================
async function fetchApi<T>(url: string): Promise<T> {
const res = await apiClient.get(url);
return res.data?.data ?? res.data;
}
async function postApi<T>(url: string, data?: unknown): Promise<T> {
const res = await apiClient.post(url, data);
return res.data?.data ?? res.data;
}
// ============================================
// Hooks
// ============================================
export function useRooms() {
return useQuery<Room[]>({
queryKey: ["messenger", "rooms"],
queryFn: async () => {
const data = await fetchApi<any[]>("/messenger/rooms");
return data.map((r) => ({
id: String(r.id),
name: r.room_name ?? r.name ?? "",
type: r.room_type ?? r.type,
lastMessage: r.last_message ?? r.lastMessage,
lastMessageAt: r.last_message_at ?? r.lastMessageAt,
unreadCount: r.unread_count ?? r.unreadCount ?? 0,
description: r.description,
participants: (r.participants ?? []).map((p: any) => ({
userId: p.user_id ?? p.userId,
userName: p.user_name ?? p.userName,
photo: p.photo ? `data:image/jpeg;base64,${p.photo}` : null,
})),
}));
},
refetchInterval: 30000,
});
}
export function useMessages(roomId: string | null) {
return useQuery<Message[]>({
queryKey: ["messenger", "messages", roomId],
queryFn: async () => {
const data = await fetchApi<any[]>(`/messenger/rooms/${roomId}/messages`);
return data.map((m) => ({
id: String(m.id),
roomId: String(m.room_id ?? m.roomId),
senderId: m.sender_id ?? m.senderId,
senderName: m.sender_name ?? m.senderName ?? m.sender_id,
senderPhoto: m.sender_photo
? `data:image/jpeg;base64,${m.sender_photo}`
: (m.senderPhoto ?? null),
content: m.content ?? "",
type: m.message_type ?? m.type ?? "text",
fileUrl: m.file_url ?? m.fileUrl,
fileName: m.file_name ?? m.fileName,
reactions: m.reactions ?? [],
threadCount: m.thread_count ?? m.threadCount ?? 0,
parentId: m.parent_message_id ?? m.parentId ?? null,
isDeleted: m.is_deleted ?? m.isDeleted ?? false,
createdAt: m.created_at ?? m.createdAt,
}));
},
enabled: !!roomId,
});
}
export function useCompanyUsers() {
return useQuery<CompanyUser[]>({
queryKey: ["messenger", "users"],
queryFn: async () => {
const data = await fetchApi<any[]>("/messenger/users");
return data.map((u) => ({
userId: u.user_id ?? u.userId,
userName: u.user_name ?? u.userName,
deptName: u.dept_name ?? u.deptName,
photo: u.photo ? `data:image/jpeg;base64,${u.photo}` : null,
}));
},
});
}
export function useUnreadCount() {
return useQuery<number>({
queryKey: ["messenger", "unread"],
queryFn: () => fetchApi("/messenger/unread"),
refetchInterval: 15000,
});
}
export function useSendMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { roomId: string; content: string; type?: string; parentId?: string | null }) =>
postApi(`/messenger/rooms/${payload.roomId}/messages`, payload),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
},
});
}
export function useCreateRoom() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { type: "dm" | "group" | "channel"; name?: string; description?: string; participantIds: string[] }) =>
postApi<Room>("/messenger/rooms", payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
},
});
}
export function useMarkAsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (roomId: string) => postApi(`/messenger/rooms/${roomId}/read`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
},
});
}
export function useAddReaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { messageId: string; roomId: string; emoji: string }) =>
postApi(`/messenger/messages/${payload.messageId}/reactions`, { emoji: payload.emoji }),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
},
});
}
export function useUploadFile() {
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const res = await apiClient.post("/messenger/files/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data?.data ?? res.data;
},
});
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { io, Socket } from "socket.io-client";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { useToast } from "@/hooks/use-toast";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
interface NewMessageEvent {
roomId: string;
message: {
id: string;
content: string;
senderName: string;
senderId: string;
createdAt: string;
};
}
interface TypingEvent {
roomId: string;
userId: string;
userName: string;
}
export function useMessengerSocket() {
const socketRef = useRef<Socket | null>(null);
const { selectedRoomId, notificationEnabled } = useMessengerContext();
const { toast } = useToast();
const [onlineUsers, setOnlineUsers] = useState<Set<string>>(new Set());
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
useEffect(() => {
const token = localStorage.getItem("authToken");
if (!token) return;
const socket = io(BACKEND_URL, {
path: "/socket.io",
auth: { token },
transports: ["websocket", "polling"],
});
socketRef.current = socket;
socket.on("user_online", (data: { userId: string; online: boolean }) => {
setOnlineUsers((prev) => {
const next = new Set(prev);
if (data.online) next.add(data.userId);
else next.delete(data.userId);
return next;
});
});
socket.on("new_message", (data: NewMessageEvent) => {
if (data.roomId !== selectedRoomId && notificationEnabled) {
toast({
title: data.message.senderName,
description: data.message.content.slice(0, 50),
});
}
});
socket.on("typing_start", (data: TypingEvent) => {
setTypingUsers((prev) => {
const next = new Map(prev);
const users = next.get(data.roomId) || [];
if (!users.includes(data.userName)) {
next.set(data.roomId, [...users, data.userName]);
}
return next;
});
});
socket.on("typing_stop", (data: TypingEvent) => {
setTypingUsers((prev) => {
const next = new Map(prev);
const users = next.get(data.roomId) || [];
next.set(
data.roomId,
users.filter((u) => u !== data.userName)
);
return next;
});
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [selectedRoomId, notificationEnabled, toast]);
const emitTypingStart = useCallback(
(roomId: string) => {
socketRef.current?.emit("typing_start", { roomId });
},
[]
);
const emitTypingStop = useCallback(
(roomId: string) => {
socketRef.current?.emit("typing_stop", { roomId });
},
[]
);
return { socket: socketRef, onlineUsers, typingUsers, emitTypingStart, emitTypingStop };
}
+65
View File
@@ -94,6 +94,7 @@
"reactflow": "^11.11.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",
@@ -3409,6 +3410,12 @@
"dev": true,
"license": "MIT"
},
"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/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -9434,6 +9441,28 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"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/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -14777,6 +14806,34 @@
"integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"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/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -16236,6 +16293,14 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+1
View File
@@ -103,6 +103,7 @@
"reactflow": "^11.11.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",