Merge branch 'ycshin-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -44,6 +44,8 @@ process.on("SIGTERM", () => {
|
||||
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
const { imapConnectionPool } = require("./services/imapConnectionPool");
|
||||
imapConnectionPool.destroyAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -52,6 +54,8 @@ process.on("SIGINT", () => {
|
||||
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
const { imapConnectionPool } = require("./services/imapConnectionPool");
|
||||
imapConnectionPool.destroyAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -131,6 +135,8 @@ import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산
|
||||
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"; // 연쇄 드롭다운 관계 관리
|
||||
@@ -377,6 +383,8 @@ app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → 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);
|
||||
@@ -404,6 +412,22 @@ 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 { setIo } = await import("./socket/socketManager");
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: "*", methods: ["GET", "POST"] },
|
||||
path: "/socket.io",
|
||||
});
|
||||
setIo(io);
|
||||
initMessengerSocket(io);
|
||||
logger.info("💬 Socket.IO messenger initialized");
|
||||
} catch (error) {
|
||||
logger.error("❌ Socket.IO initialization failed:", error);
|
||||
}
|
||||
|
||||
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
|
||||
initializeServices().catch(err => {
|
||||
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
|
||||
@@ -419,12 +443,16 @@ async function initializeServices() {
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
runApprovalSystemMigration,
|
||||
runUserMailAccountsMigration,
|
||||
runMessengerMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
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,220 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { messengerService } from '../services/messengerService';
|
||||
import { AuthenticatedRequest } from '../types/auth';
|
||||
import { getIo } from '../socket/socketManager';
|
||||
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);
|
||||
|
||||
// Broadcast to all room participants via Socket.IO
|
||||
const io = getIo();
|
||||
if (io) {
|
||||
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
|
||||
}
|
||||
|
||||
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 io = getIo();
|
||||
const savedFiles = [];
|
||||
for (const file of files) {
|
||||
// Use a readable placeholder as content to avoid filename encoding issues
|
||||
const isImage = file.mimetype.startsWith('image/');
|
||||
const content = isImage ? '[이미지]' : '[파일]';
|
||||
|
||||
// Create a file message
|
||||
const message = await messengerService.sendMessage(
|
||||
roomId,
|
||||
user.userId,
|
||||
user.companyCode!,
|
||||
content,
|
||||
'file'
|
||||
);
|
||||
|
||||
const savedFile = await messengerService.saveFile(message.id, {
|
||||
originalName: file.originalname,
|
||||
storedName: file.filename,
|
||||
filePath: file.path,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
|
||||
message.files = [savedFile];
|
||||
|
||||
// Broadcast to room so recipients receive it in real-time
|
||||
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../types/auth';
|
||||
import { userMailAccountService } from '../services/userMailAccountService';
|
||||
import { userMailImapService } from '../services/userMailImapService';
|
||||
import { userMailSmtpService } from '../services/userMailSmtpService';
|
||||
import { encryptionService } from '../services/encryptionService';
|
||||
import { imapConnectionPool } from '../services/imapConnectionPool';
|
||||
import { mailCache } from '../services/mailCache';
|
||||
|
||||
class UserMailController {
|
||||
async listAccounts(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const protocol = req.query.protocol as string | undefined;
|
||||
const accounts = await userMailAccountService.getAccountsByUserId(userId, protocol);
|
||||
// 비밀번호 제거 후 반환
|
||||
const safe = accounts.map(({ password, ...rest }) => rest);
|
||||
res.json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
// 저장 전 연결 테스트 (임시 account 객체 사용, 평문 비밀번호를 암호화해서 전달)
|
||||
const tempAccount = { ...req.body, id: 0, userId, status: 'active', password: encryptionService.encrypt(req.body.password) };
|
||||
const service = userMailImapService;
|
||||
const testResult = await service.testConnection(tempAccount);
|
||||
if (!testResult.success) {
|
||||
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
|
||||
}
|
||||
|
||||
const account = await userMailAccountService.createAccount(userId, req.body);
|
||||
const { password, ...safe } = account;
|
||||
res.status(201).json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
|
||||
// 비밀번호 변경이 포함된 경우 연결 테스트
|
||||
if (req.body.password) {
|
||||
const existing = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const tempAccount = { ...existing, ...req.body, password: encryptionService.encrypt(req.body.password) };
|
||||
const service = userMailImapService;
|
||||
const testResult = await service.testConnection(tempAccount);
|
||||
if (!testResult.success) {
|
||||
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const account = await userMailAccountService.updateAccount(accountId, userId, req.body);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
imapConnectionPool.destroyByAccount(accountId);
|
||||
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
|
||||
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
|
||||
|
||||
const { password, ...safe } = account;
|
||||
res.json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const deleted = await userMailAccountService.deleteAccount(accountId, userId);
|
||||
if (!deleted) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
imapConnectionPool.destroyByAccount(accountId);
|
||||
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
|
||||
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
|
||||
|
||||
res.json({ success: true, message: '계정이 삭제되었습니다.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async testConnectionDirect(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const { protocol, host, port, useTls, username, password } = req.body;
|
||||
if (!protocol || !host || !port || !username || !password) {
|
||||
return res.status(400).json({ success: false, message: '필수 항목 누락' });
|
||||
}
|
||||
|
||||
const tempAccount = {
|
||||
id: 0, userId, displayName: '', email: '', protocol, host, port,
|
||||
useTls: useTls ?? true, username, status: 'active',
|
||||
password: encryptionService.encrypt(password),
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const service = userMailImapService;
|
||||
const result = await service.testConnection(tempAccount);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const service = userMailImapService;
|
||||
const result = await service.testConnection(account);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async listMails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const service = userMailImapService;
|
||||
const mails = await service.fetchMailList(account, limit);
|
||||
res.json({ success: true, data: mails });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async streamMails(req: AuthenticatedRequest, res: Response) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const userId = (req as any).user?.userId;
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const before = req.query.before ? parseInt(req.query.before as string) : null;
|
||||
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: '계정을 찾을 수 없습니다.' })}\n\n`);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
let ended = false;
|
||||
req.on('close', () => { ended = true; });
|
||||
|
||||
await userMailImapService.fetchMailListStream(
|
||||
account, limit, before,
|
||||
(mail) => {
|
||||
if (!ended) res.write(`data: ${JSON.stringify(mail)}\n\n`);
|
||||
},
|
||||
() => {
|
||||
if (!ended) {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (!ended) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getMailDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const detail = await service.getMailDetail(account, seqno);
|
||||
if (!detail) return res.status(404).json({ success: false, message: '메일을 찾을 수 없습니다.' });
|
||||
|
||||
res.json({ success: true, data: detail });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const result = await service.markAsRead(account, seqno);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const result = await service.deleteMail(account, seqno);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async listFolders(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folders = await userMailImapService.listFolders(account);
|
||||
res.json({ success: true, data: folders });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async streamFolderMails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const folder = decodeURIComponent(req.params.folder);
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const before = req.query.before ? parseInt(req.query.before as string) : null;
|
||||
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
await userMailImapService.streamMailsByFolder(
|
||||
account, folder, limit, before,
|
||||
(mail) => {
|
||||
res.write(`event: message\ndata: ${JSON.stringify(mail)}\n\n`);
|
||||
},
|
||||
() => {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
res.end();
|
||||
},
|
||||
(err) => {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async moveMail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const { targetFolder } = req.body;
|
||||
if (!targetFolder) { res.status(400).json({ success: false, message: 'targetFolder 필요' }); return; }
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const result = await userMailImapService.moveMail(account, seqno, targetFolder);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async getAttachments(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folder = (req.query.folder as string) || 'INBOX';
|
||||
const attachments = await userMailImapService.getAttachmentList(account, seqno, folder);
|
||||
res.json({ success: true, data: attachments });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAttachment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const partId = decodeURIComponent(req.params.partId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folder = (req.query.folder as string) || 'INBOX';
|
||||
const filenameHint = (req.params.filename as string | undefined) || (req.query.filename as string | undefined);
|
||||
await userMailImapService.downloadAttachment(account, seqno, partId, res, folder, filenameHint);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendMail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const result = await userMailSmtpService.sendMail(account, req.body);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userMailController = new UserMailController();
|
||||
@@ -112,6 +112,64 @@ export async function runTableHistoryActionMigration() {
|
||||
/**
|
||||
* DTG Management 테이블 이력 시스템 마이그레이션
|
||||
*/
|
||||
export async function runUserMailAccountsMigration() {
|
||||
try {
|
||||
console.log("🔄 사용자 메일 계정 테이블 마이그레이션 시작...");
|
||||
await PostgreSQLService.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_mail_accounts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(200) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')),
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await PostgreSQLService.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_user_mail_accounts_user_id ON user_mail_accounts(user_id)
|
||||
`);
|
||||
console.log("✅ 사용자 메일 계정 테이블 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 사용자 메일 계정 테이블 마이그레이션 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 이력 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -29,9 +29,9 @@ export const authenticateToken = async (
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
// Authorization 헤더 또는 query param에서 토큰 추출 (파일 다운로드용)
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
|
||||
const token = (authHeader && authHeader.split(" ")[1]) || (req.query.token as string) || null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
|
||||
@@ -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,26 @@
|
||||
import express from 'express';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
import { userMailController } from '../controllers/userMailController';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.post('/test-connection', (req, res) => userMailController.testConnectionDirect(req as any, res));
|
||||
router.get('/accounts', (req, res) => userMailController.listAccounts(req as any, res));
|
||||
router.post('/accounts', (req, res) => userMailController.createAccount(req as any, res));
|
||||
router.put('/accounts/:accountId', (req, res) => userMailController.updateAccount(req as any, res));
|
||||
router.delete('/accounts/:accountId', (req, res) => userMailController.deleteAccount(req as any, res));
|
||||
router.post('/accounts/:accountId/test', (req, res) => userMailController.testConnection(req as any, res));
|
||||
router.get('/accounts/:accountId/mails/stream', (req, res) => userMailController.streamMails(req as any, res));
|
||||
router.get('/accounts/:accountId/mails', (req, res) => userMailController.listMails(req as any, res));
|
||||
router.get('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.getMailDetail(req as any, res));
|
||||
router.post('/accounts/:accountId/mails/:seqno/mark-read', (req, res) => userMailController.markAsRead(req as any, res));
|
||||
router.delete('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.deleteMail(req as any, res));
|
||||
router.get('/accounts/:accountId/folders', (req, res) => userMailController.listFolders(req as any, res));
|
||||
router.get('/accounts/:accountId/folders/:folder/mails/stream', (req, res) => userMailController.streamFolderMails(req as any, res));
|
||||
router.post('/accounts/:accountId/mails/:seqno/move', (req, res) => userMailController.moveMail(req as any, res));
|
||||
router.get('/accounts/:accountId/mails/:seqno/attachments', (req, res) => userMailController.getAttachments(req as any, res));
|
||||
router.get('/accounts/:accountId/mails/:seqno/attachment/:partId', (req, res) => userMailController.downloadAttachment(req as any, res));
|
||||
router.post('/accounts/:accountId/send', (req, res) => userMailController.sendMail(req as any, res));
|
||||
|
||||
export default router;
|
||||
@@ -14,7 +14,7 @@ class EncryptionService {
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipher(this.algorithm, this.key);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
@@ -34,7 +34,7 @@ class EncryptionService {
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipher(this.algorithm, this.key);
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import { UserMailAccount } from './userMailAccountService';
|
||||
|
||||
interface PoolEntry {
|
||||
client: ImapFlow;
|
||||
accountId: number;
|
||||
lastUsed: number;
|
||||
busy: boolean;
|
||||
queue: Array<{ fn: (client: ImapFlow) => Promise<any>; resolve: (v: any) => void; reject: (e: any) => void }>;
|
||||
}
|
||||
|
||||
class ImapConnectionPool {
|
||||
private pool = new Map<number, PoolEntry>();
|
||||
private readonly maxIdleMs = 300_000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanupIdle(), 60_000);
|
||||
process.on('SIGTERM', () => this.destroyAll());
|
||||
process.on('SIGINT', () => this.destroyAll());
|
||||
}
|
||||
|
||||
async execute<T>(account: UserMailAccount, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||
const decryptedPassword = encryptionService.decrypt(account.password);
|
||||
let entry = this.pool.get(account.id);
|
||||
|
||||
if (entry && !entry.client.usable) {
|
||||
this.pool.delete(account.id);
|
||||
entry = undefined;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
const client = new ImapFlow({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
secure: account.useTls,
|
||||
auth: { user: account.username, pass: decryptedPassword },
|
||||
logger: false as any,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
await client.connect();
|
||||
entry = { client, accountId: account.id, lastUsed: Date.now(), busy: false, queue: [] };
|
||||
this.pool.set(account.id, entry);
|
||||
|
||||
client.on('close', () => {
|
||||
const e = this.pool.get(account.id);
|
||||
if (e && e.client === client) {
|
||||
this.pool.delete(account.id);
|
||||
for (const pending of e.queue) pending.reject(new Error('IMAP 연결이 끊겼습니다'));
|
||||
e.queue = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.busy) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
entry!.queue.push({ fn: fn as any, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
return this.runWithEntry(entry, fn);
|
||||
}
|
||||
|
||||
private async runWithEntry<T>(entry: PoolEntry, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||
entry.busy = true;
|
||||
entry.lastUsed = Date.now();
|
||||
try {
|
||||
return await fn(entry.client);
|
||||
} catch (err) {
|
||||
if (!entry.client.usable) {
|
||||
this.pool.delete(entry.accountId);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
entry.busy = false;
|
||||
if (entry.queue.length > 0) {
|
||||
const next = entry.queue.shift()!;
|
||||
this.runWithEntry(entry, next.fn).then(next.resolve).catch(next.reject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupIdle() {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of this.pool.entries()) {
|
||||
if (!entry.busy && entry.queue.length === 0 && now - entry.lastUsed > this.maxIdleMs) {
|
||||
try { entry.client.logout(); } catch {}
|
||||
this.pool.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroyByAccount(accountId: number) {
|
||||
const entry = this.pool.get(accountId);
|
||||
if (entry) {
|
||||
try { entry.client.logout(); } catch {}
|
||||
this.pool.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
for (const entry of this.pool.values()) {
|
||||
try { entry.client.logout(); } catch {}
|
||||
}
|
||||
this.pool.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const imapConnectionPool = new ImapConnectionPool();
|
||||
@@ -0,0 +1,43 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MailCache {
|
||||
private cache = new Map<string, CacheEntry<any>>();
|
||||
private readonly maxEntries = 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.sweep(), 60_000);
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
set<T>(key: string, data: T, ttlMs: number) {
|
||||
if (this.cache.size >= this.maxEntries) this.sweep();
|
||||
this.cache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
invalidateByPrefix(prefix: string) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.startsWith(prefix)) this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private sweep() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now > entry.expiresAt) this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailCache = new MailCache();
|
||||
@@ -0,0 +1,405 @@
|
||||
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);
|
||||
// Reverse so messages are in chronological order (query uses DESC for cursor pagination)
|
||||
const messages: MessengerMessage[] = result.rows.reverse();
|
||||
|
||||
// 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,121 @@
|
||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||
import { encryptionService } from "./encryptionService";
|
||||
|
||||
export interface UserMailAccount {
|
||||
id: number;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
protocol: 'imap';
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
username: string;
|
||||
password: string; // 암호화된 상태
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
export interface CreateUserMailAccountDto {
|
||||
displayName: string;
|
||||
email: string;
|
||||
protocol: 'imap';
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
username: string;
|
||||
password: string; // 평문 (서비스에서 암호화)
|
||||
}
|
||||
|
||||
function rowToAccount(row: any): UserMailAccount {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
displayName: row.display_name,
|
||||
email: row.email,
|
||||
protocol: row.protocol,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
useTls: row.use_tls,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
class UserMailAccountService {
|
||||
async getAccountsByUserId(userId: string, protocol?: string): Promise<UserMailAccount[]> {
|
||||
let query = 'SELECT * FROM user_mail_accounts WHERE user_id = $1';
|
||||
const params: any[] = [userId];
|
||||
|
||||
if (protocol) {
|
||||
query += ' AND protocol = $2';
|
||||
params.push(protocol);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
const result = await PostgreSQLService.query(query, params);
|
||||
return result.rows.map(rowToAccount);
|
||||
}
|
||||
|
||||
async getAccountById(id: number, userId: string): Promise<UserMailAccount | null> {
|
||||
const result = await PostgreSQLService.query(
|
||||
'SELECT * FROM user_mail_accounts WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async createAccount(userId: string, dto: CreateUserMailAccountDto): Promise<UserMailAccount> {
|
||||
const encryptedPassword = encryptionService.encrypt(dto.password);
|
||||
const result = await PostgreSQLService.query(
|
||||
`INSERT INTO user_mail_accounts (user_id, display_name, email, protocol, host, port, use_tls, username, password)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[userId, dto.displayName, dto.email, dto.protocol, dto.host, dto.port, dto.useTls, dto.username, encryptedPassword]
|
||||
);
|
||||
return rowToAccount(result.rows[0]);
|
||||
}
|
||||
|
||||
async updateAccount(id: number, userId: string, dto: Partial<CreateUserMailAccountDto>): Promise<UserMailAccount | null> {
|
||||
const existing = await this.getAccountById(id, userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (dto.displayName !== undefined) { fields.push(`display_name = $${paramIndex++}`); values.push(dto.displayName); }
|
||||
if (dto.email !== undefined) { fields.push(`email = $${paramIndex++}`); values.push(dto.email); }
|
||||
if (dto.protocol !== undefined) { fields.push(`protocol = $${paramIndex++}`); values.push(dto.protocol); }
|
||||
if (dto.host !== undefined) { fields.push(`host = $${paramIndex++}`); values.push(dto.host); }
|
||||
if (dto.port !== undefined) { fields.push(`port = $${paramIndex++}`); values.push(dto.port); }
|
||||
if (dto.useTls !== undefined) { fields.push(`use_tls = $${paramIndex++}`); values.push(dto.useTls); }
|
||||
if (dto.username !== undefined) { fields.push(`username = $${paramIndex++}`); values.push(dto.username); }
|
||||
if (dto.password !== undefined) { fields.push(`password = $${paramIndex++}`); values.push(encryptionService.encrypt(dto.password)); }
|
||||
|
||||
if (fields.length === 0) return existing;
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id, userId);
|
||||
|
||||
const result = await PostgreSQLService.query(
|
||||
`UPDATE user_mail_accounts SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async deleteAccount(id: number, userId: string): Promise<boolean> {
|
||||
const result = await PostgreSQLService.query(
|
||||
'DELETE FROM user_mail_accounts WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const userMailAccountService = new UserMailAccountService();
|
||||
@@ -0,0 +1,419 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import { UserMailAccount } from './userMailAccountService';
|
||||
import { imapConnectionPool } from './imapConnectionPool';
|
||||
import { mailCache } from './mailCache';
|
||||
|
||||
export interface ReceivedMail {
|
||||
id: string;
|
||||
messageId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: Date;
|
||||
preview: string;
|
||||
isRead: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
export interface MailDetail extends ReceivedMail {
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
attachments: Array<{
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
class UserMailImapService {
|
||||
async fetchMailList(account: UserMailAccount, limit: number = 50): Promise<ReceivedMail[]> {
|
||||
const cacheKey = `mailList:${account.id}:INBOX:${limit}`;
|
||||
const cached = mailCache.get<ReceivedMail[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const mails = await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true });
|
||||
const total = status.messages || 0;
|
||||
if (total === 0) return [];
|
||||
|
||||
const start = Math.max(1, total - limit + 1);
|
||||
const range = `${start}:${total}`;
|
||||
const result: ReceivedMail[] = [];
|
||||
|
||||
for await (const msg of client.fetch(range, {
|
||||
uid: true,
|
||||
flags: true,
|
||||
envelope: true,
|
||||
bodyStructure: true,
|
||||
})) {
|
||||
const hasAttachments = msg.bodyStructure
|
||||
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
|
||||
: false;
|
||||
|
||||
result.push({
|
||||
id: `${account.id}-imap-${msg.seq}`,
|
||||
messageId: msg.envelope?.messageId || `${msg.seq}`,
|
||||
from: msg.envelope?.from?.[0]
|
||||
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
|
||||
: 'Unknown',
|
||||
to: msg.envelope?.to?.[0]?.address || '',
|
||||
subject: msg.envelope?.subject || '(제목 없음)',
|
||||
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
|
||||
preview: '',
|
||||
isRead: msg.flags?.has('\\Seen') || false,
|
||||
hasAttachments,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
return result;
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
|
||||
mailCache.set(cacheKey, mails, 60_000);
|
||||
return mails;
|
||||
}
|
||||
|
||||
async fetchMailListStream(
|
||||
account: UserMailAccount,
|
||||
limit: number = 20,
|
||||
beforeSeqno: number | null = null,
|
||||
onMail: (mail: ReceivedMail) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: Error) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true });
|
||||
const total = status.messages || 0;
|
||||
if (total === 0) { onDone(); return; }
|
||||
|
||||
let start: number, end: number;
|
||||
if (beforeSeqno !== null) {
|
||||
end = beforeSeqno - 1;
|
||||
start = Math.max(1, beforeSeqno - limit);
|
||||
} else {
|
||||
start = Math.max(1, total - limit + 1);
|
||||
end = total;
|
||||
}
|
||||
|
||||
if (end < 1 || start > end) { onDone(); return; }
|
||||
|
||||
for await (const msg of client.fetch(`${start}:${end}`, {
|
||||
uid: true,
|
||||
flags: true,
|
||||
envelope: true,
|
||||
bodyStructure: true,
|
||||
})) {
|
||||
const hasAttachments = msg.bodyStructure
|
||||
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
|
||||
: false;
|
||||
|
||||
onMail({
|
||||
id: `${account.id}-imap-${msg.seq}`,
|
||||
messageId: msg.envelope?.messageId || `${msg.seq}`,
|
||||
from: msg.envelope?.from?.[0]
|
||||
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
|
||||
: 'Unknown',
|
||||
to: msg.envelope?.to?.[0]?.address || '',
|
||||
subject: msg.envelope?.subject || '(제목 없음)',
|
||||
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
|
||||
preview: '',
|
||||
isRead: msg.flags?.has('\\Seen') || false,
|
||||
hasAttachments,
|
||||
});
|
||||
}
|
||||
onDone();
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
async getMailDetail(account: UserMailAccount, seqno: number): Promise<MailDetail | null> {
|
||||
const cacheKey = `mailDetail:${account.id}:${seqno}`;
|
||||
const cached = mailCache.get<MailDetail>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const detail = await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne(`${seqno}`, {
|
||||
uid: true,
|
||||
flags: true,
|
||||
envelope: true,
|
||||
bodyStructure: true,
|
||||
source: true,
|
||||
});
|
||||
if (!msg) return null;
|
||||
|
||||
const parsed = await simpleParser(msg.source as Buffer);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
|
||||
|
||||
return {
|
||||
id: `${account.id}-imap-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
cc: ccAddress?.text,
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
htmlBody: parsed.html || '',
|
||||
textBody: parsed.text || '',
|
||||
preview: '',
|
||||
isRead: msg.flags?.has('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
attachments: (parsed.attachments || []).map((att: any) => ({
|
||||
filename: att.filename || 'unnamed',
|
||||
contentType: att.contentType || 'application/octet-stream',
|
||||
size: att.size || 0,
|
||||
})),
|
||||
} as MailDetail;
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
|
||||
if (detail) mailCache.set(cacheKey, detail, 300_000);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
await client.messageFlagsAdd(`${seqno}`, ['\\Seen']);
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
|
||||
return { success: true, message: '읽음 처리 완료' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : '오류' };
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
// \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등)
|
||||
const folders = await client.list();
|
||||
const trashFolder = folders.find(f => f.specialUse === '\\Trash');
|
||||
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
if (trashFolder) {
|
||||
await client.messageMove(`${seqno}`, trashFolder.path);
|
||||
} else {
|
||||
await client.messageDelete(`${seqno}`);
|
||||
}
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
|
||||
mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`);
|
||||
return { success: true, message: '휴지통으로 이동 완료' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : '오류' };
|
||||
}
|
||||
}
|
||||
|
||||
async listFolders(account: UserMailAccount): Promise<Array<{ path: string; name: string; unseen: number; }>> {
|
||||
return imapConnectionPool.execute(account, async (client) => {
|
||||
const folders = await client.list({ statusQuery: { unseen: true } });
|
||||
return folders
|
||||
.filter(f => f.listed)
|
||||
.map(f => ({
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
unseen: (f as any).status?.unseen ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async streamMailsByFolder(
|
||||
account: UserMailAccount,
|
||||
folder: string,
|
||||
limit: number = 20,
|
||||
beforeSeqno: number | null = null,
|
||||
onMail: (mail: ReceivedMail) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: Error) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const status = await client.status(folder, { messages: true });
|
||||
const total = status.messages || 0;
|
||||
if (total === 0) { onDone(); return; }
|
||||
|
||||
let start: number, end: number;
|
||||
if (beforeSeqno !== null) {
|
||||
end = beforeSeqno - 1;
|
||||
start = Math.max(1, beforeSeqno - limit);
|
||||
} else {
|
||||
start = Math.max(1, total - limit + 1);
|
||||
end = total;
|
||||
}
|
||||
if (end < 1 || start > end) { onDone(); return; }
|
||||
|
||||
for await (const msg of client.fetch(`${start}:${end}`, {
|
||||
uid: true, flags: true, envelope: true, bodyStructure: true,
|
||||
})) {
|
||||
const hasAttachments = msg.bodyStructure
|
||||
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
|
||||
: false;
|
||||
onMail({
|
||||
id: `${account.id}-imap-${msg.seq}`,
|
||||
messageId: msg.envelope?.messageId || `${msg.seq}`,
|
||||
from: msg.envelope?.from?.[0]
|
||||
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
|
||||
: 'Unknown',
|
||||
to: msg.envelope?.to?.[0]?.address || '',
|
||||
subject: msg.envelope?.subject || '(제목 없음)',
|
||||
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
|
||||
preview: '',
|
||||
isRead: msg.flags?.has('\\Seen') || false,
|
||||
hasAttachments,
|
||||
});
|
||||
}
|
||||
onDone();
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
await client.messageMove(`${seqno}`, targetFolder);
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
|
||||
return { success: true, message: '이동 완료' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : '오류' };
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAttachment(
|
||||
account: UserMailAccount,
|
||||
seqno: number,
|
||||
partId: string,
|
||||
res: import('express').Response,
|
||||
folder: string = 'INBOX',
|
||||
filenameHint?: string
|
||||
): Promise<void> {
|
||||
await imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const { meta, content } = await client.download(`${seqno}`, partId);
|
||||
const rawFilename = filenameHint || (meta as any).filename || 'attachment';
|
||||
const encodedFilename = encodeURIComponent(rawFilename);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`);
|
||||
res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream');
|
||||
if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size));
|
||||
await require('stream/promises').pipeline(content, res);
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise<Array<{ partId: string; filename: string; contentType: string; size: number }>> {
|
||||
return imapConnectionPool.execute(account, async (client) => {
|
||||
const mailbox = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true });
|
||||
if (!msg || !msg.bodyStructure) return [];
|
||||
const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = [];
|
||||
function walk(node: any, part: string) {
|
||||
const filename = node.parameters?.name || node.dispositionParameters?.filename;
|
||||
if (filename && node.type !== 'text' && node.type !== 'multipart') {
|
||||
result.push({
|
||||
partId: node.part || part,
|
||||
filename,
|
||||
contentType: `${node.type}/${node.subtype}`,
|
||||
size: node.size || 0,
|
||||
});
|
||||
}
|
||||
if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`));
|
||||
}
|
||||
walk(msg.bodyStructure, '1');
|
||||
return result;
|
||||
} finally {
|
||||
mailbox.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> {
|
||||
const decryptedPassword = encryptionService.decrypt(account.password);
|
||||
const client = new ImapFlow({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
secure: account.useTls,
|
||||
auth: { user: account.username, pass: decryptedPassword },
|
||||
logger: false as any,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.logout();
|
||||
return { success: true, message: 'IMAP 연결 성공' };
|
||||
} catch (err) {
|
||||
let message = '연결 실패';
|
||||
if (err instanceof Error) {
|
||||
const imapErr = err as any;
|
||||
const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message;
|
||||
const r = String(raw).toLowerCase();
|
||||
if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) {
|
||||
message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (r.includes('econnrefused') || r.includes('connection refused')) {
|
||||
message = '연결 거부: 호스트 또는 포트를 확인하세요.';
|
||||
} else if (r.includes('enotfound') || r.includes('getaddrinfo')) {
|
||||
message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.';
|
||||
} else if (r.includes('timeout') || r.includes('etimedout')) {
|
||||
message = '연결 시간 초과: 서버가 응답하지 않습니다.';
|
||||
} else if (r.includes('self signed') || r.includes('certificate')) {
|
||||
message = 'SSL 인증서 오류가 발생했습니다.';
|
||||
} else if (r.includes('econnreset')) {
|
||||
message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.';
|
||||
} else {
|
||||
message = raw;
|
||||
}
|
||||
}
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userMailImapService = new UserMailImapService();
|
||||
@@ -0,0 +1,63 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import { UserMailAccount } from './userMailAccountService';
|
||||
|
||||
export interface SendMailDto {
|
||||
to: string;
|
||||
cc?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
}
|
||||
|
||||
class UserMailSmtpService {
|
||||
private getSmtpConfig(account: UserMailAccount) {
|
||||
const decryptedPassword = encryptionService.decrypt(account.password);
|
||||
// IMAP host에서 SMTP host 추론
|
||||
const smtpHost = account.host
|
||||
.replace(/^imap\./, 'smtp.')
|
||||
.replace(/^mail\./, 'smtp.');
|
||||
// 포트 추론: TLS → 465, plain → 587
|
||||
const port = account.useTls ? 465 : 587;
|
||||
return {
|
||||
host: smtpHost,
|
||||
port,
|
||||
secure: account.useTls, // 465: true, 587: false (STARTTLS)
|
||||
auth: { user: account.username, pass: decryptedPassword },
|
||||
tls: { rejectUnauthorized: false },
|
||||
};
|
||||
}
|
||||
|
||||
async sendMail(account: UserMailAccount, dto: SendMailDto): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport(this.getSmtpConfig(account));
|
||||
await transporter.sendMail({
|
||||
from: `${account.displayName} <${account.email}>`,
|
||||
to: dto.to,
|
||||
cc: dto.cc,
|
||||
subject: dto.subject,
|
||||
html: dto.html,
|
||||
text: dto.text,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
references: dto.references,
|
||||
});
|
||||
return { success: true, message: '발송 완료' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : '발송 실패' };
|
||||
}
|
||||
}
|
||||
|
||||
async testSmtpConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport(this.getSmtpConfig(account));
|
||||
await transporter.verify();
|
||||
return { success: true, message: 'SMTP 연결 성공' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : 'SMTP 연결 실패' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userMailSmtpService = new UserMailSmtpService();
|
||||
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// In-memory presence store: userId → { companyCode, status }
|
||||
const presenceStore = new Map<string, { companyCode: string; status: 'online' | 'away' }>();
|
||||
|
||||
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 company presence room and broadcast online status
|
||||
const presenceRoom = `${companyCode}:presence`;
|
||||
socket.join(presenceRoom);
|
||||
presenceStore.set(userId, { companyCode, status: 'online' });
|
||||
socket.to(presenceRoom).emit('user_status', { userId, status: 'online' });
|
||||
|
||||
// Send current online users list to newly connected socket
|
||||
const currentPresence: Record<string, string> = {};
|
||||
for (const [uid, info] of presenceStore.entries()) {
|
||||
if (info.companyCode === companyCode) {
|
||||
currentPresence[uid] = info.status;
|
||||
}
|
||||
}
|
||||
socket.emit('presence_list', currentPresence);
|
||||
|
||||
// set_status: client emits when tab focus changes
|
||||
socket.on('set_status', (data: { status: 'online' | 'away' }) => {
|
||||
const entry = presenceStore.get(userId);
|
||||
if (entry) {
|
||||
entry.status = data.status;
|
||||
io.to(presenceRoom).emit('user_status', { userId, status: data.status });
|
||||
}
|
||||
});
|
||||
|
||||
// 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,
|
||||
user_name: socket.data.userName,
|
||||
});
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
presenceStore.delete(userId);
|
||||
io.to(presenceRoom).emit('user_status', { userId, status: 'offline' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
let _io: Server | null = null;
|
||||
|
||||
export function setIo(io: Server) {
|
||||
_io = io;
|
||||
}
|
||||
|
||||
export function getIo(): Server | null {
|
||||
return _io;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user