[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:
Generated
+126
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} ×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+65
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user