commit bad40540bde135f15749afeec14e5963565a12d9 Author: 최준흠 Date: Fri Jan 16 15:40:38 2026 +0900 ChatSystem init...1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd6b4a2 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DB_HOST=localhost +DB_USER=root +DB_PASS= +DB_NAME=chat_db +PORT=3000 +SESSION_SECRET=your_secret_key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d82c5af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +#codeigniter4 +.env +package-lock.json +node_modules/ +public/vendor/ +uploads/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..a130d16 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "chatsystem", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "http://gitlab.idcjp.jp:3000/idcjp/ChatSystem.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "bcryptjs": "^3.0.3", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.2.1", + "fs-extra": "^11.3.3", + "multer": "^2.0.2", + "mysql2": "^3.16.0", + "socket.io": "^4.8.3" + } +} diff --git a/public/chat.js b/public/chat.js new file mode 100644 index 0000000..9c763c6 --- /dev/null +++ b/public/chat.js @@ -0,0 +1,382 @@ +const socket = io(); + +// UI Elements +const chatMessages = document.getElementById('chat-messages'); +const messageInput = document.getElementById('message-input'); +const sendBtn = document.getElementById('send-btn'); +const authToggleBtn = document.getElementById('auth-toggle-btn'); +const loginArea = document.getElementById('login-area'); +const chatInputArea = document.getElementById('chat-input-area'); +const loginSubmitBtn = document.getElementById('login-submit-btn'); +const loginCancelBtn = document.getElementById('login-cancel-btn'); +const imageBtn = document.getElementById('image-btn'); +const imageInput = document.getElementById('image-input'); +const replyInfo = document.getElementById('reply-info'); +const sidebar = document.getElementById('sidebar'); +const roomListContainer = document.getElementById('room-list'); +const roomSearchInput = document.getElementById('room-search'); +const headerAvatar = document.getElementById('header-avatar'); +const targetNameLabel = document.getElementById('target-name'); +const targetStatusLabel = document.getElementById('target-status'); +const exitReplyBtn = document.getElementById('exit-reply-btn'); +const signupShowBtn = document.getElementById('signup-show-btn'); + +// Unique Anonymous ID generation/recovery +function getAnonymousId() { + let id = localStorage.getItem('chat_anon_id'); + if (!id) { + id = 'anon_' + Math.random().toString(36).substr(2, 9); + localStorage.setItem('chat_anon_id', id); + } + return id; +} + +const isAdminPath = window.location.pathname.includes('/admin'); + +// Load User from LocalStorage or create Anonymous +let savedUser = localStorage.getItem('chat_user'); +let currentUser; + +// CI4 Session Bridge: CI4 뷰에서 넘겨준 세션 정보가 있는지 확인 +// CI4 PHP 예시: window.chatConfig = { isLog-in: get('ISLGIN') ?>, auth: get('AUTH')) ?> }; +if (window.chatConfig && window.chatConfig.ISLGIN) { + const auth = window.chatConfig.AUTH; + currentUser = { + id: auth.id || auth.uid, + username: auth.name || auth.id, + role: auth.role || 'user' + }; + // 세션 정보가 있으면 로컬 스토리지도 동기화 (선택 사항) + localStorage.setItem('chat_user', JSON.stringify(currentUser)); +} else if (savedUser) { + currentUser = JSON.parse(savedUser); +} else { + currentUser = { id: getAnonymousId(), username: '익명', role: 'guest' }; +} + +if (currentUser && currentUser.role !== 'guest') { + if (authToggleBtn) authToggleBtn.textContent = '로그아웃 (' + currentUser.username + ')'; +} + +let currentRoom = (isAdminPath && isConsultantRole(currentUser.role)) + ? 'consultants_group' + : 'room_' + currentUser.id; +let currentConsultant = null; + +// Initialize +if (isAdminPath) { + const headerTitle = document.querySelector('#chat-header span'); + if (headerTitle) headerTitle.innerText = 'IDC 상담원 관리 시스템'; + + // 상담원 채널 접속 시 로그인 영역 자동 표시 및 사이드바 노출 준비 + if (currentUser.role === 'guest') { + loginArea.style.display = 'flex'; + } else if (isConsultantRole(currentUser.role)) { + if (sidebar) sidebar.style.display = 'flex'; + } +} else { + // 일반 채팅 경로(/chat) + authToggleBtn.innerText = '상담원 로그인'; + if (sidebar) sidebar.style.display = 'none'; + if (replyInfo) replyInfo.style.display = 'none'; + + // 일반 사용자용 화면은 너비를 컴팩트하게 조정 (기존 1000px의 70% 수준인 700px) + const chatWidget = document.getElementById('chat-widget'); + if (chatWidget) chatWidget.style.width = '700px'; +} + +socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role }); + +// 룸 목록 업데이트 수신 (상담원 전용) +socket.on('update_room_list', (roomList) => { + if (!roomListContainer) return; + + allRoomsData = roomList; // 검색 필터링을 위해 저장 + renderRoomList(allRoomsData); +}); + +let allRoomsData = []; + +function getAvatarColor(name) { + const colors = ['#0078d4', '#107c10', '#d13438', '#008575', '#8764b8', '#004e8c']; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} + +function renderRoomList(rooms) { + // 기본 모니터링 룸 추가 + let listHtml = ` +
+
📢
+
+ 전체 모니터링 + 모든 실시간 대화 감시 +
+
+ `; + + rooms.forEach(roomInfo => { + const isActive = currentRoom === roomInfo.room; + const initial = roomInfo.username.charAt(0); + const avatarColor = getAvatarColor(roomInfo.username); + + listHtml += ` +
+
${initial}
+
+ ${roomInfo.username} + 최근 메시지 확인 중... +
+
+ `; + }); + + roomListContainer.innerHTML = listHtml; +} + +// 룸 검색 필터링 제거 (사용자 요청) +// if (roomSearchInput) { ... } + +// Send Message +async function sendMessage(text = null, imageUrl = null) { + const message = text || messageInput.value.trim(); + if (message || imageUrl) { + const data = { + room: currentRoom, + sender: currentUser.username, + user_code: currentUser.id, + consultant_code: currentConsultant, + message: imageUrl || message, + msg_type: imageUrl ? 'image' : 'text', + role: currentUser.role, // Add role to metadata + timestamp: new Date().toLocaleTimeString() + }; + socket.emit('send_message', data); + if (!imageUrl) messageInput.value = ''; + } +} + +sendBtn.addEventListener('click', () => sendMessage()); +messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') sendMessage(); +}); + +// Image Upload Handling +imageBtn.addEventListener('click', () => imageInput.click()); +imageInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (file) await uploadAndSendImage(file); + imageInput.value = ''; +}); + +// Clipboard Paste Handling (Screen Capture) +messageInput.addEventListener('paste', async (e) => { + const clipboardData = e.clipboardData || window.clipboardData; + if (!clipboardData) return; + + const items = clipboardData.items; + let imageFound = false; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile(); + if (file) { + imageFound = true; + console.log('이미지 붙여넣기 감지됨:', file.name); + await uploadAndSendImage(file); + } + } + } + + // 이미지를 전송했다면 텍스트박스에 텍스트가 중복으로 들어가는 것을 방지하고 싶을 때 사용 (선택 사항) + // if (imageFound) e.preventDefault(); +}); + +async function uploadAndSendImage(file) { + const formData = new FormData(); + formData.append('image', file); + console.log('이미지 업로드 시도 중:', file.name); + + try { + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`서버 응답 오류: ${response.status}`); + } + + const result = await response.json(); + if (result.success) { + console.log('이미지 업로드 성공:', result.imageUrl); + await sendMessage(null, result.imageUrl); + } else { + alert('이미지 업로드 실패: ' + result.message); + } + } catch (err) { + console.error('이미지 업로드 프로세스 오류:', err); + alert('이미지 전송 중 오류가 발생했습니다. 서버 로그를 확인하거나 다시 시도해 주세요.'); + } +} + +// Helper: 상담원 역할 확인 +function isConsultantRole(role) { + if (!role) return false; + const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant']; + const roles = String(role).toLowerCase(); + return consultantKeywords.some(keyword => roles.includes(keyword)); +} + +// Receive Message +socket.on('receive_message', (data) => { + if (!chatMessages) return; + + const msgDiv = document.createElement('div'); + msgDiv.classList.add('message'); + + // Align messages: Consultant right, Guest left (Skype style is often left-dominant, but we follow chat standards) + const isConsultant = isConsultantRole(data.role); + const isMe = (data.user_code === currentUser.id); + + msgDiv.classList.add('message'); + msgDiv.classList.add(isMe ? 'right' : 'left'); + + let contentHtml = ''; + if (data.msg_type === 'image') { + contentHtml += ``; + } else { + contentHtml += `${data.message}`; + } + + msgDiv.innerHTML = contentHtml; + chatMessages.appendChild(msgDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + // 만약 상담원이고 전체 모니터링 모드라면 룸 목록의 미리보기 텍스트 업데이트 + if (isAdminPath && isConsultantRole(currentUser.role) && currentRoom === 'consultants_group') { + const roomItem = document.querySelector(`.room-item[onclick*="${data.room}"] .preview`); + if (roomItem) { + roomItem.innerText = data.msg_type === 'image' ? '(이미지 메시지)' : data.message; + } + } +}); + +// 상담원이 특정 유저의 대화방에 참여하는 함수 (답장 기능) +function joinUserRoom(roomName, userName) { + if (currentRoom === roomName) return; // 이미 해당 방이면 무시 + + // 기존 대화 내용 비우기 + chatMessages.innerHTML = ''; + + currentRoom = roomName; + socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role }); + + // UI 업데이트 + updateSidebarActive(); + if (targetNameLabel) targetNameLabel.innerText = userName; + if (targetStatusLabel) targetStatusLabel.innerText = '상담 중'; + if (headerAvatar) { + headerAvatar.innerText = userName.charAt(0); + headerAvatar.style.background = getAvatarColor(userName); + } + if (exitReplyBtn) exitReplyBtn.style.display = 'block'; + + const sysMsg = document.createElement('div'); + sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;"; + sysMsg.innerHTML = `--- ${userName} 님과의 대화가 시작되었습니다 ---`; + chatMessages.appendChild(sysMsg); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function updateSidebarActive() { + const items = document.querySelectorAll('.room-item'); + items.forEach(item => { + // onclick 속성에서 룸 분석 (간결한 구현을 위해 속성 매칭) + if (item.getAttribute('onclick').includes(currentRoom)) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); +} + +// 답장 모드 종료 (전체 모니터링으로 복귀) +function exitReplyMode() { + if (currentRoom === 'consultants_group') return; + + chatMessages.innerHTML = ''; + + currentRoom = 'consultants_group'; + socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role }); + + updateSidebarActive(); + if (targetNameLabel) targetNameLabel.innerText = '전체 모니터링'; + if (targetStatusLabel) targetStatusLabel.innerText = '실시간 대기 중'; + if (headerAvatar) { + headerAvatar.innerText = '📢'; + headerAvatar.style.background = '#605e5c'; + } + if (exitReplyBtn) exitReplyBtn.style.display = 'none'; + + const sysMsg = document.createElement('div'); + sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;"; + sysMsg.innerHTML = `--- 실시간 전체 대화 모니터링 중입니다 ---`; + chatMessages.appendChild(sysMsg); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +if (exitReplyBtn) { + exitReplyBtn.addEventListener('click', exitReplyMode); +} + +// Auth Toggle +authToggleBtn.addEventListener('click', () => { + if (currentUser.role === 'guest') { + loginArea.style.display = loginArea.style.display === 'flex' ? 'none' : 'flex'; + } else { + // 로그아웃 처리: 세션 삭제 후 새로고침 + localStorage.removeItem('chat_user'); + location.reload(); + } +}); + +// Handle Login Cancellation +loginCancelBtn.addEventListener('click', () => { + if (isAdminPath) { + location.href = '/chat'; + } else { + loginArea.style.display = 'none'; + } +}); + +// Handle Login Submit +loginSubmitBtn.addEventListener('click', async () => { + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const result = await response.json(); + + if (result.success) { + // 로그인 정보 저장 및 새로고침 + localStorage.setItem('chat_user', JSON.stringify(result.user)); + alert(`${result.user.username}님 환영합니다!`); + location.reload(); + } else { + alert('로그인 실패: ' + result.message); + } + } catch (err) { + console.error('Login error:', err); + alert('로그인 처리 중 오류 발생'); + } +}); +// Signup Show(Page Redirect)는 HTML의 onclick="location.href='/register'"으로 처리됨 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1327120 --- /dev/null +++ b/public/index.html @@ -0,0 +1,360 @@ + + + + + + + IDC 상담 채팅 + + + + + +
+ + + + +
+
+
+
?
+
+
전체 모니터링
+
실시간 대기 중
+
+
+
+ + + +
+
+ +
+ +
+ + + + +
+ + + + +
+
+
+ + +
+ +
+ + + + + \ No newline at end of file diff --git a/public/register.html b/public/register.html new file mode 100644 index 0000000..889fd6a --- /dev/null +++ b/public/register.html @@ -0,0 +1,151 @@ + + + + + + + 회원가입 - IDC 상담 채널 + + + + + + +
+

상담 서비스 회원가입

+
+ + +
+
+ + +
+ + + +
+ + + + + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..3a4d560 --- /dev/null +++ b/server.js @@ -0,0 +1,278 @@ +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const mysql = require('mysql2/promise'); +const bcrypt = require('bcryptjs'); +const cors = require('cors'); +const path = require('path'); +const multer = require('multer'); +const fs = require('fs-extra'); +require('dotenv').config(); + +const app = express(); +const server = http.createServer(app); +const io = new Server(server, { + cors: { + origin: "*", + methods: ["GET", "POST"] + }, + maxHttpBufferSize: 1e8 // 100MB +}); + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +// Multer Storage Configuration +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = path.join(__dirname, 'uploads'); + fs.ensureDirSync(uploadDir); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); +const upload = multer({ storage: storage }); + +// DB Pool Connection +const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +// Create Table if not exists +const initDB = async () => { + try { + // Create table + await pool.execute(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_code VARCHAR(50), + consultant_code VARCHAR(50), + room_id VARCHAR(100), + content TEXT, + msg_type VARCHAR(10) DEFAULT 'text', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Ensure msg_type column exists (for existing tables) + const [columns] = await pool.execute("SHOW COLUMNS FROM chat_messages LIKE 'msg_type'"); + if (columns.length === 0) { + await pool.execute("ALTER TABLE chat_messages ADD COLUMN msg_type VARCHAR(10) DEFAULT 'text' AFTER content"); + console.log('DB schema updated: added msg_type column'); + } + + console.log('Database initialized'); + } catch (err) { + console.error('DB Init Error:', err); + } +}; +initDB(); + +// Image Upload API +app.post('/api/upload', upload.single('image'), (req, res) => { + try { + if (!req.file) { + console.log('Upload failed: No file uploaded'); + return res.status(400).json({ success: false, message: 'No file uploaded' }); + } + const imageUrl = `/uploads/${req.file.filename}`; + console.log('File uploaded successfully:', imageUrl); + res.json({ success: true, imageUrl }); + } catch (err) { + console.error('Upload API Error:', err); + res.status(500).json({ success: false, message: 'Upload internal error' }); + } +}); + +// Basic Routes +app.get('/chat', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.get('/admin/chat', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.get('/register', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'register.html')); +}); + +// 루트로 접속 시 기본적으로 /chat으로 리다이렉트 +app.get('/', (req, res) => { + res.redirect('/chat'); +}); + +// Login API (Supports both 'user' and 'clientinfo' tables) +app.post('/api/login', async (req, res) => { + const { username, password } = req.body; + try { + // 1. 상담원(user) 테이블 먼저 조회 + let [rows] = await pool.execute('SELECT id, passwd, role FROM user WHERE id = ?', [username]); + let isConsultant = true; + + if (rows.length === 0) { + // 2. 상담원이 없으면 일반 사용자(clientinfo) 테이블 조회 + [rows] = await pool.execute('SELECT id, passwd, role FROM clientinfo WHERE id = ?', [username]); + isConsultant = false; + } + + if (rows.length === 0) { + return res.status(401).json({ success: false, message: '사용자 계정을 찾을 수 없습니다.' }); + } + + const user = rows[0]; + const isValid = await bcrypt.compare(password, user.passwd); + + if (!isValid) { + return res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' }); + } + + res.json({ + success: true, + user: { + id: user.id, + username: user.id, + role: user.role || (isConsultant ? 'master' : 'user') + } + }); + } catch (error) { + console.error('Login API error:', error); + res.status(500).json({ success: false, message: '서버 내부 오류' }); + } +}); + +// User Registration API (clientinfo table) +app.post('/api/register', async (req, res) => { + const { username, password } = req.body; + try { + // 중복 체크 + const [existing] = await pool.execute('SELECT id FROM clientinfo WHERE id = ?', [username]); + if (existing.length > 0) { + return res.status(400).json({ success: false, message: '이미 존재하는 아이디입니다.' }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + await pool.execute( + 'INSERT INTO clientinfo (id, passwd, role, created_at) VALUES (?, ?, ?, NOW())', + [username, hashedPassword, 'user'] + ); + + res.json({ success: true, message: '회원가입이 완료되었습니다.' }); + } catch (error) { + console.error('Registration API error:', error); + res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다.' }); + } +}); + +const isConsultantRole = (role) => { + if (!role) return false; + // 상담원 관련 키워드 목록 (manager/manage 오타 및 콤마 구분 대응) + const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant']; + + // role이 "manager,cloudflare..." 처럼 여러 개일 경우를 대비해 체크 + const roles = String(role).toLowerCase(); + return consultantKeywords.some(keyword => roles.includes(keyword)); +}; + +// 룸 목록 관리를 위한 객체 (room_id -> { user_id, last_message, ... }) +const activeRooms = new Map(); + +const emitRoomList = () => { + const roomList = Array.from(activeRooms.entries()).map(([room, data]) => ({ + room, + user_id: data.user_id, + username: data.user_id.startsWith('anon_') ? `익명(${data.user_id.substr(-5)})` : data.user_id + })); + io.to('consultants_group').emit('update_room_list', roomList); +}; + +// Socket.io logic +io.on('connection', (socket) => { + console.log('A user connected:', socket.id); + + socket.on('join_room', (data) => { + const { room, role, user_id } = data; + console.log('[DEBUG] Join Room Request:', data); + + // 상담원의 경우 룸 전환 시 정리가 필요함 + if (isConsultantRole(role)) { + // 다른 모든 룸에서 퇴장 순회 (본인 소켓 ID 방 제외) + socket.rooms.forEach(r => { + if (r !== socket.id) socket.leave(r); + }); + + if (room === 'consultants_group') { + socket.join('consultants_group'); + console.log(`[DEBUG] Consultant ${user_id} joined consultants_group`); + // 입장 즉시 최신 룸 목록 전송 + emitRoomList(); + } else { + // 특정 유저 룸 조인 + socket.join(room); + console.log(`[DEBUG] Consultant ${user_id} moved to 1:1 room: ${room}`); + } + } else { + // 일반 유저나 익명은 본인 방 가입 + socket.join(room); + console.log(`[DEBUG] User ${user_id} joined room: ${room}`); + + // 일반 유저의 룸을 활성 목록에 추가 + if (room.startsWith('room_')) { + activeRooms.set(room, { user_id: user_id, socketId: socket.id }); + emitRoomList(); + } + } + }); + + socket.on('send_message', async (data) => { + const { room, sender, message, user_code, consultant_code, msg_type, role } = data; + console.log(`[DEBUG] Message from ${sender} (role: ${role}) targeting room: ${room}`); + + try { + await pool.execute( + 'INSERT INTO chat_messages (user_code, consultant_code, room_id, content, msg_type, created_at) VALUES (?, ?, ?, ?, ?, NOW())', + [user_code || 'anonymous', consultant_code || null, room, message, msg_type || 'text'] + ); + } catch (err) { + console.error('Error saving message:', err); + } + + // 해당 룸 사용자에게 메시지 전송 + io.to(room).emit('receive_message', data); + + // 상담원 그룹(전체 모니터링)에도 메시지 복사 전송 + // 유저의 메시지뿐만 아니라 상담원의 모든 답장도 다른 상담원 및 발신자 본인이 볼 수 있게 함 + if (room !== 'consultants_group') { + io.to('consultants_group').emit('receive_message', data); + } + }); + + socket.on('disconnect', () => { + console.log('User disconnected:', socket.id); + + // 해당 소켓이 소유했던 룸 정보 제거 + for (let [room, data] of activeRooms.entries()) { + if (data.socketId === socket.id) { + activeRooms.delete(room); + emitRoomList(); + break; + } + } + }); +}); + +const PORT = process.env.PORT || 3000; +server.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); +});