[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:
2026-03-30 18:05:54 +09:00
parent e763249342
commit f558073ef8
28 changed files with 2578 additions and 10 deletions

View File

@@ -43,6 +43,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"winston": "^3.11.0"
},
@@ -3130,6 +3131,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tediousjs/connection-string": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
@@ -3269,7 +3276,6 @@
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -3672,6 +3678,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -4321,6 +4336,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz",
@@ -5701,6 +5725,45 @@
"node": ">=0.10.0"
}
},
"node_modules/engine.io": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
@@ -10903,6 +10966,47 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@@ -11951,6 +12055,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",

View File

@@ -57,6 +57,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"winston": "^3.11.0"
},

View File

@@ -136,6 +136,7 @@ import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import userMailRoutes from "./routes/userMailRoutes"; // 사용자 메일 계정
import messengerRoutes from "./routes/messengerRoutes"; // 메신저
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
@@ -383,6 +384,7 @@ app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정
app.use("/api/messenger", messengerRoutes); // 메신저
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@@ -410,6 +412,20 @@ const server = app.listen(PORT, HOST, async () => {
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// Socket.IO initialization
try {
const { Server: SocketIOServer } = await import("socket.io");
const { initMessengerSocket } = await import("./socket/messengerSocket");
const io = new SocketIOServer(server, {
cors: { origin: "*", methods: ["GET", "POST"] },
path: "/socket.io",
});
initMessengerSocket(io);
logger.info("💬 Socket.IO messenger initialized");
} catch (error) {
logger.error("❌ Socket.IO initialization failed:", error);
}
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
initializeServices().catch(err => {
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
@@ -426,6 +442,7 @@ async function initializeServices() {
runDtgManagementLogMigration,
runApprovalSystemMigration,
runUserMailAccountsMigration,
runMessengerMigration,
} = await import("./database/runMigration");
await runDashboardMigration();
@@ -433,6 +450,7 @@ async function initializeServices() {
await runDtgManagementLogMigration();
await runApprovalSystemMigration();
await runUserMailAccountsMigration();
await runMessengerMigration();
} catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error);
}

View File

@@ -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,
},
});

View File

@@ -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();

View File

@@ -141,6 +141,35 @@ export async function runUserMailAccountsMigration() {
}
}
/**
* Messenger tables migration
*/
export async function runMessengerMigration() {
try {
console.log("🔄 메신저 테이블 마이그레이션 시작...");
const sqlFilePath = path.join(
__dirname,
"../../../db/migrations/messenger_tables.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 메신저 테이블 마이그레이션 완료!");
} catch (error) {
console.error("❌ 메신저 테이블 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log(" 테이블이 이미 존재합니다.");
}
}
}
export async function runDtgManagementLogMigration() {
try {
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");

View File

@@ -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;

View File

@@ -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();

View File

@@ -0,0 +1,141 @@
import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import config from '../config/environment';
import { messengerService } from '../services/messengerService';
import { JwtPayload } from '../types/auth';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
userName: string;
companyCode: string;
};
}
export function initMessengerSocket(io: Server) {
// JWT authentication middleware
io.use((socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload;
socket.data.userId = decoded.userId;
socket.data.userName = decoded.userName;
socket.data.companyCode = decoded.companyCode || '';
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
io.on('connection', async (socket: AuthenticatedSocket) => {
const { userId, companyCode } = socket.data;
console.log(`[Messenger] User connected: ${userId}`);
// join_rooms: subscribe to all user's rooms
socket.on('join_rooms', async () => {
try {
const roomIds = await messengerService.getUserRoomIds(userId, companyCode);
for (const roomId of roomIds) {
socket.join(`${companyCode}:${roomId}`);
}
socket.emit('rooms_joined', { roomIds });
} catch (error) {
console.error('[Messenger] join_rooms error:', error);
socket.emit('error', { message: 'Failed to join rooms' });
}
});
// send_message: save and broadcast
socket.on('send_message', async (data: {
room_id: number;
content: string;
message_type?: string;
parent_message_id?: number;
}) => {
try {
const message = await messengerService.sendMessage(
data.room_id,
userId,
companyCode,
data.content,
data.message_type || 'text',
data.parent_message_id
);
io.to(`${companyCode}:${data.room_id}`).emit('new_message', message);
} catch (error) {
console.error('[Messenger] send_message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// message_read: update last_read_at
socket.on('message_read', async (data: { room_id: number }) => {
try {
await messengerService.markAsRead(data.room_id, userId);
io.to(`${companyCode}:${data.room_id}`).emit('user_read', {
room_id: data.room_id,
user_id: userId,
read_at: new Date().toISOString(),
});
} catch (error) {
console.error('[Messenger] message_read error:', error);
}
});
// typing indicators
socket.on('typing_start', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
socket.on('typing_stop', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', {
room_id: data.room_id,
user_id: userId,
});
});
// reactions
socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.addReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] add_reaction error:', error);
}
});
socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.removeReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] remove_reaction error:', error);
}
});
// join a specific room (e.g., after creating a new room)
socket.on('join_room', (data: { room_id: number }) => {
socket.join(`${companyCode}:${data.room_id}`);
});
socket.on('disconnect', () => {
console.log(`[Messenger] User disconnected: ${userId}`);
});
});
}

View File

@@ -0,0 +1,97 @@
// Messenger type definitions
export interface MessengerRoom {
id: number;
company_code: string;
room_type: 'dm' | 'group' | 'channel';
room_name: string | null;
created_by: string;
created_at: string;
updated_at: string;
// joined fields
last_message?: string;
last_message_at?: string;
last_sender_id?: string;
unread_count?: number;
participants?: MessengerParticipant[];
}
export interface MessengerParticipant {
id: number;
room_id: number;
user_id: string;
company_code: string;
last_read_at: string;
joined_at: string;
// joined fields
user_name?: string;
dept_name?: string;
photo?: string | null;
}
export interface MessengerMessage {
id: number;
room_id: number;
sender_id: string;
company_code: string;
content: string | null;
message_type: 'text' | 'file' | 'system';
parent_message_id: number | null;
created_at: string;
updated_at: string;
// joined fields
sender_name?: string;
sender_photo?: string | null;
reactions?: MessengerReaction[];
files?: MessengerFile[];
thread_count?: number;
}
export interface MessengerReaction {
id: number;
message_id: number;
user_id: string;
emoji: string;
created_at: string;
}
export interface MessengerFile {
id: number;
message_id: number;
original_name: string;
stored_name: string;
file_path: string;
file_size: number;
mime_type: string | null;
created_at: string;
}
// Request types
export interface CreateRoomRequest {
room_type: 'dm' | 'group' | 'channel';
room_name?: string;
participant_ids: string[];
}
export interface SendMessageRequest {
content: string;
message_type?: 'text' | 'file' | 'system';
parent_message_id?: number;
}
export interface AddReactionRequest {
message_id: number;
emoji: string;
}
export interface UpdateRoomRequest {
room_name: string;
}
export interface MessengerUser {
user_id: string;
user_name: string;
dept_name: string;
email?: string;
photo?: string | null;
}