diff --git a/.gitignore b/.gitignore index a766194f..e2062811 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,9 @@ settings/ *.crt *.cert secrets/ + +# oh-my-claudecode 로컬 세션/상태 파일 +.omc/ secrets.json secrets.yaml secrets.yml @@ -231,3 +234,4 @@ test-results/ frontend/playwright.config.ts frontend/tests/ frontend/test-results/ +db/checkpoints/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json deleted file mode 100644 index 80e41159..00000000 --- a/.omc/project-memory.json +++ /dev/null @@ -1,447 +0,0 @@ -{ - "version": "1.0.0", - "lastScanned": 1774313213052, - "projectRoot": "/Users/kimjuseok/ERP-node", - "techStack": { - "languages": [ - { - "name": "JavaScript/TypeScript", - "version": null, - "confidence": "high", - "markers": [ - "package.json" - ] - } - ], - "frameworks": [ - { - "name": "playwright", - "version": "1.58.2", - "category": "testing" - } - ], - "packageManager": "npm", - "runtime": null - }, - "build": { - "buildCommand": null, - "testCommand": null, - "lintCommand": null, - "devCommand": null, - "scripts": {} - }, - "conventions": { - "namingStyle": null, - "importStyle": null, - "testPattern": null, - "fileOrganization": null - }, - "structure": { - "isMonorepo": false, - "workspaces": [], - "mainDirectories": [ - "docs", - "scripts" - ], - "gitBranches": { - "defaultBranch": "main", - "branchingStrategy": null - } - }, - "customNotes": [], - "directoryMap": { - "_local": { - "path": "_local", - "purpose": null, - "fileCount": 1, - "lastAccessed": 1774313213033, - "keyFiles": [ - "pipeline-progress.json" - ] - }, - "ai-assistant": { - "path": "ai-assistant", - "purpose": null, - "fileCount": 5, - "lastAccessed": 1774313213036, - "keyFiles": [ - "Dockerfile.win", - "README.md", - "package-lock.json", - "package.json" - ] - }, - "backend": { - "path": "backend", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213038, - "keyFiles": [] - }, - "backend-node": { - "path": "backend-node", - "purpose": null, - "fileCount": 17, - "lastAccessed": 1774313213039, - "keyFiles": [ - "API_연동_가이드.md", - "API_키_정리.md", - "Dockerfile.win", - "PHASE1_USAGE_GUIDE.md", - "README.md" - ] - }, - "backup": { - "path": "backup", - "purpose": null, - "fileCount": 6, - "lastAccessed": 1774313213040, - "keyFiles": [ - "Dockerfile", - "README.md", - "backup.py", - "docker-compose.backup.yml" - ] - }, - "db": { - "path": "db", - "purpose": null, - "fileCount": 14, - "lastAccessed": 1774313213041, - "keyFiles": [ - "00-create-roles.sh", - "check_category_values.sql", - "check_numbering_rules.sql", - "cleanup_duplicate_screens_daejin.sql", - "company7_screen_backup.sql" - ] - }, - "deploy": { - "path": "deploy", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213041, - "keyFiles": [] - }, - "digitalTwin": { - "path": "digitalTwin", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213041, - "keyFiles": [ - "architecture-v4.md", - "fleet-management-plan.md", - "디지털트윈 아키텍쳐_v3.png", - "디지털트윈 아키텍쳐_v4.png" - ] - }, - "docker": { - "path": "docker", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213042, - "keyFiles": [] - }, - "docs": { - "path": "docs", - "purpose": "Documentation", - "fileCount": 35, - "lastAccessed": 1774313213042, - "keyFiles": [ - "AI_화면생성_시스템_설계서.md", - "BOM_개발_현황.md", - "DB_ARCHITECTURE_ANALYSIS.md", - "DB_STRUCTURE_DIAGRAM.html", - "DB_WORKFLOW_ANALYSIS.md" - ] - }, - "frontend": { - "path": "frontend", - "purpose": null, - "fileCount": 17, - "lastAccessed": 1774313213043, - "keyFiles": [ - "MODAL_REPEATER_TABLE_DEBUG.md", - "README.md", - "approval-box-result.png", - "components.json", - "eslint.config.mjs" - ] - }, - "k8s": { - "path": "k8s", - "purpose": null, - "fileCount": 7, - "lastAccessed": 1774313213043, - "keyFiles": [ - "local-path-provisioner.yaml", - "namespace.yaml", - "vexplor-backend-deployment.yaml", - "vexplor-config.yaml", - "vexplor-frontend-deployment.yaml" - ] - }, - "mcp-agent-orchestrator": { - "path": "mcp-agent-orchestrator", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213043, - "keyFiles": [ - "README.md", - "package-lock.json", - "package.json", - "tsconfig.json" - ] - }, - "mcp-task-queue": { - "path": "mcp-task-queue", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213043, - "keyFiles": [ - "package-lock.json", - "package.json", - "tsconfig.json" - ] - }, - "mcp-task-server": { - "path": "mcp-task-server", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213043, - "keyFiles": [] - }, - "scripts": { - "path": "scripts", - "purpose": "Build/utility scripts", - "fileCount": 11, - "lastAccessed": 1774313213044, - "keyFiles": [ - "add-modal-ids.py", - "analyze-company-info-layout.js", - "browser-test-admin-switch-button.js", - "browser-test-customer-crud.js", - "browser-test-customer-via-menu.js" - ] - }, - "test-output": { - "path": "test-output", - "purpose": null, - "fileCount": 2, - "lastAccessed": 1774313213044, - "keyFiles": [ - "screen-149-field-type-verification-guide.md", - "unified-field-type-config-panel-test-guide.md" - ] - }, - "test-results": { - "path": "test-results", - "purpose": null, - "fileCount": 1, - "lastAccessed": 1774313213044, - "keyFiles": [] - }, - "ai-assistant/src": { - "path": "ai-assistant/src", - "purpose": "Source code", - "fileCount": 1, - "lastAccessed": 1774313213045, - "keyFiles": [ - "app.js" - ] - }, - "frontend/app": { - "path": "frontend/app", - "purpose": "Application code", - "fileCount": 5, - "lastAccessed": 1774313213046, - "keyFiles": [ - "favicon.ico", - "globals.css", - "layout.tsx" - ] - }, - "frontend/components": { - "path": "frontend/components", - "purpose": "UI components", - "fileCount": 1, - "lastAccessed": 1774313213046, - "keyFiles": [ - "GlobalFileViewer.tsx" - ] - }, - "mcp-agent-orchestrator/src": { - "path": "mcp-agent-orchestrator/src", - "purpose": "Source code", - "fileCount": 1, - "lastAccessed": 1774313213047, - "keyFiles": [ - "index.ts" - ] - }, - "mcp-task-queue/data": { - "path": "mcp-task-queue/data", - "purpose": "Data files", - "fileCount": 2, - "lastAccessed": 1774313213047, - "keyFiles": [ - "knowledge.json", - "tasks.json" - ] - }, - "mcp-task-queue/dist": { - "path": "mcp-task-queue/dist", - "purpose": "Distribution/build output", - "fileCount": 28, - "lastAccessed": 1774313213048, - "keyFiles": [ - "agent-runner.d.ts", - "agent-runner.d.ts.map", - "agent-runner.js" - ] - }, - "mcp-task-queue/node_modules": { - "path": "mcp-task-queue/node_modules", - "purpose": "Dependencies", - "fileCount": 1, - "lastAccessed": 1774313213049, - "keyFiles": [] - }, - "mcp-task-queue/src": { - "path": "mcp-task-queue/src", - "purpose": "Source code", - "fileCount": 7, - "lastAccessed": 1774313213049, - "keyFiles": [ - "agent-runner.ts", - "index.ts", - "knowledge-store.ts" - ] - }, - "mcp-task-server/data": { - "path": "mcp-task-server/data", - "purpose": "Data files", - "fileCount": 0, - "lastAccessed": 1774313213049, - "keyFiles": [] - }, - "mcp-task-server/dist": { - "path": "mcp-task-server/dist", - "purpose": "Distribution/build output", - "fileCount": 6, - "lastAccessed": 1774313213050, - "keyFiles": [ - "index.d.ts", - "index.js", - "taskStore.d.ts" - ] - }, - "mcp-task-server/node_modules": { - "path": "mcp-task-server/node_modules", - "purpose": "Dependencies", - "fileCount": 1, - "lastAccessed": 1774313213050, - "keyFiles": [] - }, - "mcp-task-server/src": { - "path": "mcp-task-server/src", - "purpose": "Source code", - "fileCount": 0, - "lastAccessed": 1774313213052, - "keyFiles": [] - } - }, - "hotPaths": [ - { - "path": "frontend/app/(main)/sales/order/page.tsx", - "accessCount": 19, - "lastAccessed": 1774408850812, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/shipping-plan/page.tsx", - "accessCount": 4, - "lastAccessed": 1774313720455, - "type": "file" - }, - { - "path": "frontend/components/common/DataGrid.tsx", - "accessCount": 4, - "lastAccessed": 1774408732451, - "type": "file" - }, - { - "path": "frontend/components/common/DynamicSearchFilter.tsx", - "accessCount": 3, - "lastAccessed": 1774408732309, - "type": "file" - }, - { - "path": "frontend/app/(main)/production/plan-management/page.tsx", - "accessCount": 2, - "lastAccessed": 1774313461313, - "type": "file" - }, - { - "path": "frontend/app/(main)", - "accessCount": 2, - "lastAccessed": 1774313529384, - "type": "directory" - }, - { - "path": "frontend/lib/api/shipping.ts", - "accessCount": 2, - "lastAccessed": 1774313725308, - "type": "file" - }, - { - "path": ".claude/plans/lively-wishing-yeti.md", - "accessCount": 2, - "lastAccessed": 1774313824670, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/shipping-order/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313447495, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/claim/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313450420, - "type": "file" - }, - { - "path": "frontend/app/(main)/production/process-info/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313450623, - "type": "file" - }, - { - "path": "frontend/components/common/ExcelUploadModal.tsx", - "accessCount": 1, - "lastAccessed": 1774313454238, - "type": "file" - }, - { - "path": "frontend/app/(main)/master-data/item-info/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313528166, - "type": "file" - }, - { - "path": "frontend/components/common/ShippingPlanModal.tsx", - "accessCount": 1, - "lastAccessed": 1774313925751, - "type": "file" - }, - { - "path": "frontend/components/common/TableSettingsModal.tsx", - "accessCount": 1, - "lastAccessed": 1774409034693, - "type": "file" - } - ], - "userDirectives": [] -} \ No newline at end of file diff --git a/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json b/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json deleted file mode 100644 index 319727ce..00000000 --- a/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "037169c7-72ba-4843-8e9a-417ca1423715", - "ended_at": "2026-03-26T08:24:13.261Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json deleted file mode 100644 index ec93e466..00000000 --- a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23", - "ended_at": "2026-03-04T08:10:16.810Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json b/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json deleted file mode 100644 index 2d90700f..00000000 --- a/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "8145031e-d7ea-4aa3-94d7-ddaa69383b8a", - "ended_at": "2026-03-26T09:35:10.082Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json deleted file mode 100644 index 5d45e30d..00000000 --- a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43", - "ended_at": "2026-03-24T01:15:06.127Z", - "reason": "other", - "agents_spawned": 1, - "agents_completed": 1, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json deleted file mode 100644 index 123b9291..00000000 --- a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8", - "ended_at": "2026-03-24T01:15:07.644Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json deleted file mode 100644 index 176c69ac..00000000 --- a/.omc/state/idle-notif-cooldown.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lastSentAt": "2026-03-24T02:36:44.477Z" -} \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json deleted file mode 100644 index f23e7222..00000000 --- a/.omc/state/mission-state.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "updatedAt": "2026-03-24T00:51:37.962Z", - "missions": [ - { - "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-24T00:50:40.568Z", - "updatedAt": "2026-03-24T00:51:37.962Z", - "status": "done", - "workerCount": 1, - "taskCounts": { - "total": 1, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 1, - "failed": 0 - }, - "agents": [ - { - "name": "Explore:a9237b1", - "role": "Explore", - "ownership": "a9237b1b6af985371", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-24T00:51:37.962Z" - } - ], - "timeline": [ - { - "id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z", - "at": "2026-03-24T00:50:40.568Z", - "kind": "update", - "agent": "Explore:a9237b1", - "detail": "started Explore:a9237b1", - "sourceKey": "session-start:a9237b1b6af985371" - }, - { - "id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z", - "at": "2026-03-24T00:51:37.962Z", - "kind": "completion", - "agent": "Explore:a9237b1", - "detail": "completed", - "sourceKey": "session-stop:a9237b1b6af985371" - } - ] - } - ] -} \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 24ef7619..8fba4591 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -26,6 +26,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -34,6 +35,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -41,6 +43,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, @@ -2361,6 +2364,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -3122,6 +3131,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -3261,7 +3276,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3664,6 +3678,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3900,6 +3923,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4120,6 +4154,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -4293,6 +4336,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", @@ -5673,6 +5725,45 @@ "node": ">=0.10.0" } }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", @@ -7215,6 +7306,48 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/imapflow": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz", + "integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "8.0.4", + "pino": "10.3.1", + "socks": "2.8.7" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/imapflow/node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -7291,6 +7424,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9039,6 +9181,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-pop3": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/node-pop3/-/node-pop3-0.11.0.tgz", + "integrity": "sha512-M5qRCamSTxu5lIVBW9q6XyC6nH30fZxTdTQDzfHRSaLl8CCiZMSh80rDnIysB6ECvh9j8sf8+KveEQpLDRmMYg==", + "license": "MIT", + "bin": { + "pop": "bin/pop.js" + }, + "engines": { + "node": "^20.11.0 || >= 22.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", @@ -9212,6 +9366,15 @@ "node": ">= 0.4" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9641,6 +9804,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -9872,6 +10072,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9993,6 +10209,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quill": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", @@ -10187,6 +10409,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", @@ -10725,6 +10956,80 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11098,6 +11403,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tlds": { "version": "1.260.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", @@ -11738,6 +12055,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 2217eff6..8154371b 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -40,6 +40,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -48,6 +49,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -55,6 +57,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 0749e730..1c6a9d9e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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"; // 연쇄 드롭다운 관계 관리 @@ -156,6 +162,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 +import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -375,10 +382,13 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재 app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 +app.use("/api/quotes", quoteRoutes); // 견적관리 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); @@ -406,6 +416,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); @@ -421,12 +447,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); } diff --git a/backend-node/src/config/multerMessengerConfig.ts b/backend-node/src/config/multerMessengerConfig.ts new file mode 100644 index 00000000..5971bf89 --- /dev/null +++ b/backend-node/src/config/multerMessengerConfig.ts @@ -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, + }, +}); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index d7aa247d..2b04b8e7 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -237,7 +237,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 회사 코드 필터 (권한 그룹 멤버 관리 시 사용) if (companyCode && typeof companyCode === "string" && companyCode.trim()) { - whereConditions.push(`company_code = $${paramIndex}`); + whereConditions.push(`u.company_code = $${paramIndex}`); queryParams.push(companyCode.trim()); paramIndex++; logger.info("회사 코드 필터 적용", { companyCode }); @@ -246,7 +246,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음) if (req.user && req.user.companyCode !== "*") { // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외 - whereConditions.push(`company_code != '*'`); + whereConditions.push(`u.company_code != '*'`); logger.info("최고 관리자 필터링 적용", { userCompanyCode: req.user.companyCode, }); @@ -259,15 +259,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const searchTerm = search.trim(); whereConditions.push(`( - sabun ILIKE $${paramIndex} OR - user_type_name ILIKE $${paramIndex} OR - dept_name ILIKE $${paramIndex} OR - position_name ILIKE $${paramIndex} OR - user_id ILIKE $${paramIndex} OR - user_name ILIKE $${paramIndex} OR - tel ILIKE $${paramIndex} OR - cell_phone ILIKE $${paramIndex} OR - email ILIKE $${paramIndex} + u.sabun ILIKE $${paramIndex} OR + u.user_type_name ILIKE $${paramIndex} OR + u.dept_name ILIKE $${paramIndex} OR + u.position_name ILIKE $${paramIndex} OR + u.user_id ILIKE $${paramIndex} OR + u.user_name ILIKE $${paramIndex} OR + u.tel ILIKE $${paramIndex} OR + u.cell_phone ILIKE $${paramIndex} OR + u.email ILIKE $${paramIndex} )`); queryParams.push(`%${searchTerm}%`); paramIndex++; @@ -277,21 +277,21 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 단일 필드 검색 searchType = "single"; const fieldMap: { [key: string]: string } = { - sabun: "sabun", - companyName: "user_type_name", - deptName: "dept_name", - positionName: "position_name", - userId: "user_id", - userName: "user_name", - tel: "tel", - cellPhone: "cell_phone", - email: "email", + sabun: "u.sabun", + companyName: "u.user_type_name", + deptName: "u.dept_name", + positionName: "u.position_name", + userId: "u.user_id", + userName: "u.user_name", + tel: "u.tel", + cellPhone: "u.cell_phone", + email: "u.email", }; if (fieldMap[searchField as string]) { if (searchField === "tel") { whereConditions.push( - `(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` + `(u.tel ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex})` ); queryParams.push(`%${searchValue}%`); paramIndex++; @@ -307,13 +307,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { } else { // 고급 검색 (개별 필드별 AND 조건) const advancedSearchFields = [ - { param: search_sabun, field: "sabun" }, - { param: search_companyName, field: "user_type_name" }, - { param: search_deptName, field: "dept_name" }, - { param: search_positionName, field: "position_name" }, - { param: search_userId, field: "user_id" }, - { param: search_userName, field: "user_name" }, - { param: search_email, field: "email" }, + { param: search_sabun, field: "u.sabun" }, + { param: search_companyName, field: "u.user_type_name" }, + { param: search_deptName, field: "u.dept_name" }, + { param: search_positionName, field: "u.position_name" }, + { param: search_userId, field: "u.user_id" }, + { param: search_userName, field: "u.user_name" }, + { param: search_email, field: "u.email" }, ]; let hasAdvancedSearch = false; @@ -330,7 +330,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 전화번호 검색 if (search_tel && typeof search_tel === "string" && search_tel.trim()) { whereConditions.push( - `(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` + `(u.tel ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex})` ); queryParams.push(`%${search_tel.trim()}%`); paramIndex++; @@ -354,7 +354,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우) if (req.user && req.user.companyCode !== "*" && !companyCode) { - whereConditions.push(`company_code = $${paramIndex}`); + whereConditions.push(`u.company_code = $${paramIndex}`); queryParams.push(req.user.companyCode); paramIndex++; logger.info("사용자 회사 코드 필터 적용", { @@ -364,13 +364,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 기존 필터들 if (deptCode) { - whereConditions.push(`dept_code = $${paramIndex}`); + whereConditions.push(`u.dept_code = $${paramIndex}`); queryParams.push(deptCode); paramIndex++; } if (status) { - whereConditions.push(`status = $${paramIndex}`); + whereConditions.push(`u.status = $${paramIndex}`); queryParams.push(status); paramIndex++; } @@ -383,7 +383,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 총 개수 조회 const countQuery = ` SELECT COUNT(*) as total - FROM user_info + FROM user_info u ${whereClause} `; const countResult = await query<{ total: string }>(countQuery, queryParams); @@ -394,26 +394,28 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const offset = (Number(page) - 1) * limit; const usersQuery = ` SELECT - sabun, - user_id, - user_name, - user_name_eng, - dept_code, - dept_name, - position_code, - position_name, - email, - tel, - cell_phone, - user_type, - user_type_name, - regdate, - status, - company_code, - locale - FROM user_info + u.sabun, + u.user_id, + u.user_name, + u.user_name_eng, + u.dept_code, + u.dept_name, + u.position_code, + u.position_name, + u.email, + u.tel, + u.cell_phone, + u.user_type, + u.user_type_name, + u.regdate, + u.status, + u.company_code, + u.locale, + c.company_name + FROM user_info u + LEFT JOIN company_mng c ON u.company_code = c.company_code ${whereClause} - ORDER BY regdate DESC, user_name ASC + ORDER BY u.regdate DESC, u.user_name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; @@ -436,6 +438,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { userTypeName: user.user_type_name || null, status: user.status || "active", companyCode: user.company_code || null, + companyName: user.company_name || null, locale: user.locale || null, regDate: user.regdate ? new Date(user.regdate).toISOString().split("T")[0] @@ -1402,10 +1405,11 @@ export async function updateMenu( ] ); - // menu_url이 비어있으면 화면 할당도 해제 (screen_menu_assignments의 is_active를 'N'으로) - if (!menuUrl) { + // menu_url이 비어있거나 화면관리 URL이 아니면 화면 할당 해제 + const isScreenUrl = menuUrl && (menuUrl.startsWith("/screens/") || menuUrl.startsWith("/screen/")); + if (!menuUrl || !isScreenUrl) { await query( - `UPDATE screen_menu_assignments + `UPDATE screen_menu_assignments SET is_active = 'N' WHERE menu_objid = $1 AND company_code = $2`, [Number(menuId), companyCode] @@ -2696,6 +2700,35 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { }); return; } + + // SUPER_ADMIN 권한 부여는 최고관리자만 가능 + const requestUser = req.user; + const isRequesterSuperAdmin = requestUser?.companyCode === "*" && requestUser?.userType === "SUPER_ADMIN"; + + if (userData.userType.trim() === "SUPER_ADMIN" && !isRequesterSuperAdmin) { + res.status(403).json({ + success: false, + message: "최고 관리자 권한은 최고 관리자만 부여할 수 있습니다.", + error: { code: "FORBIDDEN_SUPER_ADMIN_GRANT" }, + }); + return; + } + + // 기존 SUPER_ADMIN 사용자의 권한은 최고관리자만 변경 가능 + if (isUpdate && !isRequesterSuperAdmin) { + const targetUser = await queryOne<{ user_type: string }>( + `SELECT user_type FROM user_info WHERE user_id = $1`, + [userData.userId?.trim()] + ); + if (targetUser?.user_type === "SUPER_ADMIN") { + res.status(403).json({ + success: false, + message: "최고 관리자의 권한은 다른 최고 관리자만 변경할 수 있습니다.", + error: { code: "FORBIDDEN_SUPER_ADMIN_MODIFY" }, + }); + return; + } + } } // 4. 비밀번호 최소 길이 검증 (신규 등록 시) @@ -3696,17 +3729,6 @@ export const resetUserPassword = async ( return; } - // 비밀번호 길이 검증 (최소 4자) - if (newPassword.length < 4) { - res.status(400).json({ - success: false, - result: false, - message: "비밀번호는 최소 4자 이상이어야 합니다.", - msg: "비밀번호는 최소 4자 이상이어야 합니다.", - }); - return; - } - try { // 1. Raw Query로 사용자 존재 여부 확인 const currentUser = await queryOne( @@ -3724,19 +3746,10 @@ export const resetUserPassword = async ( return; } - // 2. 비밀번호 암호화 (기존 Java 로직과 동일) + // 2. 비밀번호 암호화 (EncryptUtil 사용) let encryptedPassword: string; try { - // EncryptUtil과 동일한 암호화 사용 - const crypto = require("crypto"); - const keyName = "ILJIAESSECRETKEY"; - const algorithm = "aes-128-ecb"; - - // AES-128-ECB 암호화 - const cipher = crypto.createCipher(algorithm, keyName); - let encrypted = cipher.update(newPassword, "utf8", "hex"); - encrypted += cipher.final("hex"); - encryptedPassword = encrypted.toUpperCase(); + encryptedPassword = EncryptUtil.encrypt(newPassword); } catch (encryptError) { logger.error("비밀번호 암호화 중 오류 발생", { error: encryptError, diff --git a/backend-node/src/controllers/messengerController.ts b/backend-node/src/controllers/messengerController.ts new file mode 100644 index 00000000..5db22901 --- /dev/null +++ b/backend-node/src/controllers/messengerController.ts @@ -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(); diff --git a/backend-node/src/controllers/quoteController.ts b/backend-node/src/controllers/quoteController.ts new file mode 100644 index 00000000..b7063852 --- /dev/null +++ b/backend-node/src/controllers/quoteController.ts @@ -0,0 +1,84 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as quoteService from "../services/quoteService"; +import { logger } from "../utils/logger"; + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search, status, startDate, endDate } = req.query as Record; + + const data = await quoteService.getList(companyCode, { search, status, startDate, endDate }); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getById(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const data = await quoteService.getById(companyCode, parseInt(id)); + if (!data) { + return res.status(404).json({ success: false, message: "견적을 찾을 수 없습니다." }); + } + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 상세 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const quoteNo = await quoteService.generateNumber(companyCode); + return res.json({ success: true, data: { quoteNo } }); + } catch (error: any) { + logger.error("견적번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function create(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const data = await quoteService.create(companyCode, userId, req.body); + return res.status(201).json({ success: true, data, message: "견적이 등록되었습니다." }); + } catch (error: any) { + logger.error("견적 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + + await quoteService.update(companyCode, userId, parseInt(id), req.body); + return res.json({ success: true, message: "견적이 수정되었습니다." }); + } catch (error: any) { + logger.error("견적 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await quoteService.remove(companyCode, parseInt(id)); + return res.json({ success: true, message: "견적이 삭제되었습니다." }); + } catch (error: any) { + logger.error("견적 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/userMailController.ts b/backend-node/src/controllers/userMailController.ts new file mode 100644 index 00000000..65eb3c22 --- /dev/null +++ b/backend-node/src/controllers/userMailController.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 07f714d6..73523b92 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -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 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 8dfe28b3..b967f32e 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -29,9 +29,9 @@ export const authenticateToken = async ( next: NextFunction ): Promise => { 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({ diff --git a/backend-node/src/middleware/superAdminMiddleware.ts b/backend-node/src/middleware/superAdminMiddleware.ts index d92139f5..67e99e37 100644 --- a/backend-node/src/middleware/superAdminMiddleware.ts +++ b/backend-node/src/middleware/superAdminMiddleware.ts @@ -15,6 +15,7 @@ export interface AuthenticatedRequest extends Request { userId: string; userName: string; companyCode: string; + userType?: string; userLang?: string; }; } @@ -47,8 +48,9 @@ export const requireSuperAdmin = ( return; } - // 슈퍼관리자 권한 확인 (회사코드가 '*'인 사용자) - if (req.user.companyCode !== "*") { + // 슈퍼관리자 권한 확인 (회사코드가 '*'이거나 userType이 'SUPER_ADMIN'인 사용자) + // 회사전환 후에도 SUPER_ADMIN은 관리 기능에 접근 가능해야 함 + if (req.user.companyCode !== "*" && req.user.userType !== "SUPER_ADMIN") { logger.warn("DDL 실행 시도 - 권한 부족", { userId: req.user.userId, companyCode: req.user.companyCode, @@ -167,7 +169,7 @@ export const validateDDLPermission = ( * 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수 */ export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => { - return user?.companyCode === "*"; + return user?.companyCode === "*" || user?.userType === "SUPER_ADMIN"; }; /** diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index d0ddbd6c..3a173cbe 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -33,6 +33,7 @@ import { getTableSchema, // 테이블 스키마 조회 } from "../controllers/adminController"; import { authenticateToken } from "../middleware/authMiddleware"; +import { requireSuperAdmin } from "../middleware/permissionMiddleware"; const router = Router(); @@ -68,13 +69,13 @@ router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete) // 부서 관리 API router.get("/departments", getDepartmentList); // 부서 목록 조회 -// 회사 관리 API -router.get("/companies", getCompanyList); -router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 -router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회 -router.post("/companies", createCompany); // 회사 등록 -router.put("/companies/:companyCode", updateCompany); // 회사 수정 -router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 +// 회사 관리 API (최고관리자 전용) +router.get("/companies", requireSuperAdmin, getCompanyList); +router.get("/companies/db", requireSuperAdmin, getCompanyListFromDB); +router.get("/companies/:companyCode", requireSuperAdmin, getCompanyByCode); +router.post("/companies", requireSuperAdmin, createCompany); +router.put("/companies/:companyCode", requireSuperAdmin, updateCompany); +router.delete("/companies/:companyCode", requireSuperAdmin, deleteCompany); // 사용자 로케일 API router.get("/user-locale", getUserLocale); diff --git a/backend-node/src/routes/companyManagementRoutes.ts b/backend-node/src/routes/companyManagementRoutes.ts index 630a3234..34b044fc 100644 --- a/backend-node/src/routes/companyManagementRoutes.ts +++ b/backend-node/src/routes/companyManagementRoutes.ts @@ -1,5 +1,6 @@ import express from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { requireSuperAdmin } from "../middleware/permissionMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; import { FileSystemManager } from "../utils/fileSystemManager"; @@ -7,8 +8,9 @@ import { query, queryOne } from "../database/db"; const router = express.Router(); -// 모든 라우트에 인증 미들웨어 적용 +// 모든 라우트에 인증 + 최고관리자 권한 필수 router.use(authenticateToken); +router.use(requireSuperAdmin); /** * DELETE /api/company-management/:companyCode diff --git a/backend-node/src/routes/messengerRoutes.ts b/backend-node/src/routes/messengerRoutes.ts new file mode 100644 index 00000000..a1b08e0a --- /dev/null +++ b/backend-node/src/routes/messengerRoutes.ts @@ -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; diff --git a/backend-node/src/routes/quoteRoutes.ts b/backend-node/src/routes/quoteRoutes.ts new file mode 100644 index 00000000..ce83db85 --- /dev/null +++ b/backend-node/src/routes/quoteRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as quoteController from "../controllers/quoteController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", quoteController.getList); +router.get("/generate-number", quoteController.generateNumber); +router.get("/:id", quoteController.getById); +router.post("/", quoteController.create); +router.put("/:id", quoteController.update); +router.delete("/:id", quoteController.remove); + +export default router; diff --git a/backend-node/src/routes/userMailRoutes.ts b/backend-node/src/routes/userMailRoutes.ts new file mode 100644 index 00000000..d1c1a3db --- /dev/null +++ b/backend-node/src/routes/userMailRoutes.ts @@ -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; diff --git a/backend-node/src/services/encryptionService.ts b/backend-node/src/services/encryptionService.ts index a3608b2e..c21f009c 100644 --- a/backend-node/src/services/encryptionService.ts +++ b/backend-node/src/services/encryptionService.ts @@ -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'); diff --git a/backend-node/src/services/imapConnectionPool.ts b/backend-node/src/services/imapConnectionPool.ts new file mode 100644 index 00000000..04233b5e --- /dev/null +++ b/backend-node/src/services/imapConnectionPool.ts @@ -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; resolve: (v: any) => void; reject: (e: any) => void }>; +} + +class ImapConnectionPool { + private pool = new Map(); + private readonly maxIdleMs = 300_000; + + constructor() { + setInterval(() => this.cleanupIdle(), 60_000); + process.on('SIGTERM', () => this.destroyAll()); + process.on('SIGINT', () => this.destroyAll()); + } + + async execute(account: UserMailAccount, fn: (client: ImapFlow) => Promise): Promise { + 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((resolve, reject) => { + entry!.queue.push({ fn: fn as any, resolve, reject }); + }); + } + + return this.runWithEntry(entry, fn); + } + + private async runWithEntry(entry: PoolEntry, fn: (client: ImapFlow) => Promise): Promise { + 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(); diff --git a/backend-node/src/services/mailCache.ts b/backend-node/src/services/mailCache.ts new file mode 100644 index 00000000..b410280a --- /dev/null +++ b/backend-node/src/services/mailCache.ts @@ -0,0 +1,43 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +class MailCache { + private cache = new Map>(); + private readonly maxEntries = 1000; + + constructor() { + setInterval(() => this.sweep(), 60_000); + } + + get(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(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(); diff --git a/backend-node/src/services/messengerService.ts b/backend-node/src/services/messengerService.ts new file mode 100644 index 00000000..36da6678 --- /dev/null +++ b/backend-node/src/services/messengerService.ts @@ -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 { + 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(); + 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 { + // 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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend-node/src/services/quoteService.ts b/backend-node/src/services/quoteService.ts new file mode 100644 index 00000000..64ede8d1 --- /dev/null +++ b/backend-node/src/services/quoteService.ts @@ -0,0 +1,321 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface QuoteFilter { + search?: string; + status?: string; + startDate?: string; + endDate?: string; +} + +interface QuoteBody { + quote_no?: string; + quote_date: string; + valid_until?: string; + customer_objid?: number; + customer_name?: string; + status?: string; + manager?: string; + domestic_type?: string; + payment_terms?: string; + delivery_method?: string; + notes?: string; + customer_ceo?: string; + customer_biz_no?: string; + customer_address?: string; + customer_contact?: string; + customer_phone?: string; + incoterms?: string; + currency?: string; + exchange_rate?: number; + port_of_loading?: string; + port_of_discharge?: string; + shipment_date?: string; + hs_code?: string; + country_of_origin?: string; + lc_number?: string; + trade_notes?: string; + items?: QuoteItem[]; +} + +interface QuoteItem { + item_no?: number; + item_code?: string; + item_name?: string; + spec?: string; + qty?: number; + unit?: string; + request_length?: number; + unit_price?: number; + supply_amount?: number; + vat_amount?: number; + total_amount?: number; + notes?: string; +} + +export async function getList(companyCode: string, filter: QuoteFilter) { + const pool = getPool(); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`q.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + + conditions.push(`q.use_yn = 'Y'`); + + if (filter.search) { + conditions.push(`(q.quote_no ILIKE $${idx} OR q.customer_name ILIKE $${idx})`); + params.push(`%${filter.search}%`); + idx++; + } + + if (filter.status) { + conditions.push(`q.status = $${idx}`); + params.push(filter.status); + idx++; + } + + if (filter.startDate) { + conditions.push(`q.quote_date >= $${idx}`); + params.push(filter.startDate); + idx++; + } + + if (filter.endDate) { + conditions.push(`q.quote_date <= $${idx}`); + params.push(filter.endDate); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT q.objid, q.quote_no, q.quote_date, q.valid_until, + q.customer_objid, q.customer_name, q.status, q.manager, + q.domestic_type, q.payment_terms, q.delivery_method, q.notes, + q.total_supply, q.total_vat, q.total_amount, + q.customer_ceo, q.customer_biz_no, q.customer_address, + q.customer_contact, q.customer_phone, + q.incoterms, q.currency, q.exchange_rate, + q.port_of_loading, q.port_of_discharge, q.shipment_date, + q.hs_code, q.country_of_origin, q.lc_number, q.trade_notes, + q.revision_count, q.created_by, q.created_at, q.updated_at + FROM quote_mng q + ${where} + ORDER BY q.created_at DESC + `; + + const result = await pool.query(query, params); + logger.info("견적 목록 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + +export async function getById(companyCode: string, objid: number) { + const pool = getPool(); + + // 마스터 + const masterRes = await pool.query( + `SELECT * FROM quote_mng WHERE objid = $1 AND company_code = $2 AND use_yn = 'Y'`, + [objid, companyCode], + ); + + if (masterRes.rowCount === 0) return null; + + // 품목 + const detailRes = await pool.query( + `SELECT * FROM quote_detail WHERE quote_objid = $1 ORDER BY item_no`, + [objid], + ); + + return { ...masterRes.rows[0], items: detailRes.rows }; +} + +export async function generateNumber(companyCode: string): Promise { + const pool = getPool(); + const year = new Date().getFullYear(); + const prefix = `QT-${year}-`; + + const res = await pool.query( + `SELECT quote_no FROM quote_mng + WHERE company_code = $1 AND quote_no LIKE $2 + ORDER BY quote_no DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (res.rowCount && res.rowCount > 0) { + const last = res.rows[0].quote_no as string; + const lastSeq = parseInt(last.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +export async function create(companyCode: string, userId: string, body: QuoteBody) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const quoteNo = body.quote_no || (await generateNumber(companyCode)); + + // 합계 계산 + const items = body.items ?? []; + const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0); + const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0); + const totalAmount = totalSupply + totalVat; + + const masterRes = await client.query( + `INSERT INTO quote_mng ( + quote_no, quote_date, valid_until, customer_objid, customer_name, + status, manager, domestic_type, payment_terms, delivery_method, notes, + total_supply, total_vat, total_amount, + customer_ceo, customer_biz_no, customer_address, customer_contact, customer_phone, + incoterms, currency, exchange_rate, port_of_loading, port_of_discharge, + shipment_date, hs_code, country_of_origin, lc_number, trade_notes, + company_code, created_by, updated_by + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, + $20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$31 + ) RETURNING objid`, + [ + quoteNo, body.quote_date, body.valid_until || null, + body.customer_objid || null, body.customer_name || null, + body.status || "draft", body.manager || null, + body.domestic_type || "국내", body.payment_terms || null, + body.delivery_method || null, body.notes || null, + totalSupply, totalVat, totalAmount, + body.customer_ceo || null, body.customer_biz_no || null, + body.customer_address || null, body.customer_contact || null, + body.customer_phone || null, + body.incoterms || null, body.currency || "KRW", + body.exchange_rate || null, body.port_of_loading || null, + body.port_of_discharge || null, body.shipment_date || null, + body.hs_code || null, body.country_of_origin || null, + body.lc_number || null, body.trade_notes || null, + companyCode, userId, + ], + ); + + const quoteObjid = masterRes.rows[0].objid; + + // 품목 INSERT + for (let i = 0; i < items.length; i++) { + const item = items[i]; + await client.query( + `INSERT INTO quote_detail ( + quote_objid, item_no, item_code, item_name, spec, + qty, unit, request_length, unit_price, + supply_amount, vat_amount, total_amount, notes, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + quoteObjid, i + 1, item.item_code || null, item.item_name || null, + item.spec || null, item.qty ?? 0, item.unit || "EA", + item.request_length || null, item.unit_price ?? 0, + item.supply_amount ?? 0, item.vat_amount ?? 0, + item.total_amount ?? 0, item.notes || null, companyCode, + ], + ); + } + + await client.query("COMMIT"); + logger.info("견적 등록 완료", { companyCode, quoteNo, quoteObjid }); + return { objid: quoteObjid, quote_no: quoteNo }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function update(companyCode: string, userId: string, objid: number, body: QuoteBody) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const items = body.items ?? []; + const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0); + const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0); + const totalAmount = totalSupply + totalVat; + + await client.query( + `UPDATE quote_mng SET + quote_date=$1, valid_until=$2, customer_objid=$3, customer_name=$4, + status=$5, manager=$6, domestic_type=$7, payment_terms=$8, + delivery_method=$9, notes=$10, + total_supply=$11, total_vat=$12, total_amount=$13, + customer_ceo=$14, customer_biz_no=$15, customer_address=$16, + customer_contact=$17, customer_phone=$18, + incoterms=$19, currency=$20, exchange_rate=$21, + port_of_loading=$22, port_of_discharge=$23, shipment_date=$24, + hs_code=$25, country_of_origin=$26, lc_number=$27, trade_notes=$28, + revision_count = revision_count + 1, + updated_by=$29, updated_at=CURRENT_TIMESTAMP + WHERE objid=$30 AND company_code=$31`, + [ + body.quote_date, body.valid_until || null, + body.customer_objid || null, body.customer_name || null, + body.status || "draft", body.manager || null, + body.domestic_type || "국내", body.payment_terms || null, + body.delivery_method || null, body.notes || null, + totalSupply, totalVat, totalAmount, + body.customer_ceo || null, body.customer_biz_no || null, + body.customer_address || null, body.customer_contact || null, + body.customer_phone || null, + body.incoterms || null, body.currency || "KRW", + body.exchange_rate || null, body.port_of_loading || null, + body.port_of_discharge || null, body.shipment_date || null, + body.hs_code || null, body.country_of_origin || null, + body.lc_number || null, body.trade_notes || null, + userId, objid, companyCode, + ], + ); + + // 기존 품목 삭제 후 재등록 + await client.query(`DELETE FROM quote_detail WHERE quote_objid = $1`, [objid]); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + await client.query( + `INSERT INTO quote_detail ( + quote_objid, item_no, item_code, item_name, spec, + qty, unit, request_length, unit_price, + supply_amount, vat_amount, total_amount, notes, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + objid, i + 1, item.item_code || null, item.item_name || null, + item.spec || null, item.qty ?? 0, item.unit || "EA", + item.request_length || null, item.unit_price ?? 0, + item.supply_amount ?? 0, item.vat_amount ?? 0, + item.total_amount ?? 0, item.notes || null, companyCode, + ], + ); + } + + await client.query("COMMIT"); + logger.info("견적 수정 완료", { companyCode, objid }); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function remove(companyCode: string, objid: number) { + const pool = getPool(); + await pool.query( + `UPDATE quote_mng SET use_yn = 'N', updated_at = CURRENT_TIMESTAMP WHERE objid = $1 AND company_code = $2`, + [objid, companyCode], + ); + logger.info("견적 삭제(소프트)", { companyCode, objid }); +} diff --git a/backend-node/src/services/userMailAccountService.ts b/backend-node/src/services/userMailAccountService.ts new file mode 100644 index 00000000..129fd0f2 --- /dev/null +++ b/backend-node/src/services/userMailAccountService.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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(); diff --git a/backend-node/src/services/userMailImapService.ts b/backend-node/src/services/userMailImapService.ts new file mode 100644 index 00000000..c135aa2c --- /dev/null +++ b/backend-node/src/services/userMailImapService.ts @@ -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 { + const cacheKey = `mailList:${account.id}:INBOX:${limit}`; + const cached = mailCache.get(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 { + 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 { + const cacheKey = `mailDetail:${account.id}:${seqno}`; + const cached = mailCache.get(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> { + 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 { + 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 { + 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> { + 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(); diff --git a/backend-node/src/services/userMailSmtpService.ts b/backend-node/src/services/userMailSmtpService.ts new file mode 100644 index 00000000..aab8a6a8 --- /dev/null +++ b/backend-node/src/services/userMailSmtpService.ts @@ -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(); diff --git a/backend-node/src/socket/messengerSocket.ts b/backend-node/src/socket/messengerSocket.ts new file mode 100644 index 00000000..048e841d --- /dev/null +++ b/backend-node/src/socket/messengerSocket.ts @@ -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(); + +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 = {}; + 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' }); + }); + }); +} diff --git a/backend-node/src/socket/socketManager.ts b/backend-node/src/socket/socketManager.ts new file mode 100644 index 00000000..309ef6c0 --- /dev/null +++ b/backend-node/src/socket/socketManager.ts @@ -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; +} diff --git a/backend-node/src/types/messenger.ts b/backend-node/src/types/messenger.ts new file mode 100644 index 00000000..49198adf --- /dev/null +++ b/backend-node/src/types/messenger.ts @@ -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; +} diff --git a/backend-node/src/utils/permissionUtils.ts b/backend-node/src/utils/permissionUtils.ts index bbc85398..4bf6aa75 100644 --- a/backend-node/src/utils/permissionUtils.ts +++ b/backend-node/src/utils/permissionUtils.ts @@ -21,7 +21,8 @@ export enum PermissionLevel { */ export function isSuperAdmin(user?: PersonBean | null): boolean { if (!user) return false; - return user.companyCode === "*" && user.userType === "SUPER_ADMIN"; + // 회사전환 후에도 userType이 SUPER_ADMIN이면 최고관리자로 인정 + return user.userType === "SUPER_ADMIN"; } /** diff --git a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md b/docs/yc/BIC[계획]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[계획]-버튼-아이콘화.md rename to docs/yc/BIC[계획]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md b/docs/yc/BIC[맥락]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md rename to docs/yc/BIC[맥락]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md b/docs/yc/BIC[체크]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[체크]-버튼-아이콘화.md rename to docs/yc/BIC[체크]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md b/docs/yc/BTN-일괄변경-탑씰-버튼스타일.md similarity index 100% rename from docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md rename to docs/yc/BTN-일괄변경-탑씰-버튼스타일.md diff --git a/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md b/docs/yc/CCA[계획]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md rename to docs/yc/CCA[계획]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md b/docs/yc/CCA[맥락]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md rename to docs/yc/CCA[맥락]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md b/docs/yc/CCA[체크]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md rename to docs/yc/CCA[체크]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md b/docs/yc/CTI[계획]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md rename to docs/yc/CTI[계획]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md b/docs/yc/CTI[맥락]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md rename to docs/yc/CTI[맥락]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md b/docs/yc/CTI[체크]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md rename to docs/yc/CTI[체크]-카테고리-깊이구분.md diff --git a/docs/yc/IMX[계획]-imap-메일기능확장.md b/docs/yc/IMX[계획]-imap-메일기능확장.md new file mode 100644 index 00000000..f282f0ea --- /dev/null +++ b/docs/yc/IMX[계획]-imap-메일기능확장.md @@ -0,0 +1,122 @@ +--- +name: IMX[계획] IMAP 메일 기능 확장 +description: 메일 삭제, SMTP 발송, 폴더 전환, 첨부파일 다운로드, 이동, 답장/전달 구현 +type: plan +--- + +# IMX 계획 — IMAP 메일 기능 확장 + +## 개요 + +기존 메일 조회/읽음처리만 되던 IMAP 페이지에 전체 메일 클라이언트 기능 추가. +nodemailer(이미 설치), imapflow(이미 설치), TipTap v2(신규 설치) 기반. + +## 현재 동작 + +- 계정 목록 조회 +- 메일 스트리밍 목록 +- 메일 상세 보기 +- 읽음 처리 +- 메일 삭제 (백엔드만, UI 버튼 있으나 실제 연동 확인 필요) + +## 변경 후 동작 + +- 좌측 패널: 폴더 목록 (INBOX, Sent, Trash, Spam 등) + 미읽음 수 +- 메일 상세 우측 버튼: 답장 / 전달 / 이동 / 삭제(→Trash) +- 하단 첨부파일 목록 + 다운로드 버튼 +- 우상단 `작성` 버튼 → Dialog (TipTap 에디터, to/cc/subject) +- 답장/전달 시 원문 인용 자동 삽입 + +## 시각적 예시 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 메일 관리 (IMAP) [작성] [계정추가] │ +├──────────────┬───────────────────┬───────────────────────────────┤ +│ [계정목록] │ [검색창] │ 제목: 보안 알림 │ +│ │ │ From: Google │ +│ Gmail │ ● Google 오후1:51 │ To: yechul@gmail.com │ +│ Wace │ 보안 알림 │ Date: 2026-03-27 │ +│ │ ● GitHub 오전9:48 │ [답장][전달][이동▼][삭제] │ +│ ───────── │ Sudo code │ ───────────────────────────── │ +│ [폴더목록] │ │ │ +│ INBOX (3) │ │ │ +│ Sent │ │ 📎 첨부파일 │ +│ Trash │ │ file.pdf (120KB) [다운로드] │ +│ Spam │ │ │ +└──────────────┴───────────────────┴───────────────────────────────┘ +``` + +## 아키텍처 + +```mermaid +graph TD + FE[page.tsx] -->|GET /folders| BE_CTRL[userMailController] + FE -->|POST /send| BE_CTRL + FE -->|POST /move| BE_CTRL + FE -->|GET /attachment| BE_CTRL + BE_CTRL --> IMAP[userMailImapService] + BE_CTRL --> SMTP[userMailSmtpService - 신규] + IMAP --> Pool[imapConnectionPool] + SMTP --> Nodemailer[nodemailer] +``` + +## 변경 파일 + +### 신규 +- `backend-node/src/services/userMailSmtpService.ts` — SMTP 발송 전용 + +### 수정 +- `backend-node/src/services/userMailImapService.ts` — 폴더목록, 이동, 첨부파일 추가 +- `backend-node/src/controllers/userMailController.ts` — 신규 엔드포인트 핸들러 +- `backend-node/src/routes/userMailRoutes.ts` — 신규 라우트 등록 +- `frontend/lib/api/userMail.ts` — 신규 API 함수 +- `frontend/app/(main)/mail/imap/page.tsx` — UI 전면 확장 + +## 신규 API 엔드포인트 + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/user-mail/accounts/:id/folders` | 폴더 목록 + 미읽음 수 | +| GET | `/user-mail/accounts/:id/folders/:folder/mails/stream` | 폴더별 메일 스트리밍 | +| POST | `/user-mail/accounts/:id/mails/:seqno/move` | 메일 이동 `{ targetFolder }` | +| GET | `/user-mail/accounts/:id/mails/:seqno/attachment/:partId` | 첨부파일 다운로드 (스트리밍) | +| POST | `/user-mail/accounts/:id/send` | 메일 발송 `{ to, cc, subject, html, text, inReplyTo?, references? }` | + +## 코드 설계 + +### userMailSmtpService.ts +```typescript +// SMTP 포트 추론: useTls true → 465, false → 587 +// Gmail: smtp.gmail.com, wace.me: mail.wace.me 또는 host 에서 도메인 추출 +// nodemailer createTransport + sendMail +// 답장: inReplyTo, references 헤더 설정 +``` + +### userMailImapService.ts 추가 메서드 +```typescript +listFolders(account): Promise<{ path, name, unseen }[]> // client.list({ statusQuery }) +moveMail(account, seqno, targetFolder): Promise // messageMove +downloadAttachment(account, seqno, partId, res): Promise // download() + pipeline(res) +``` + +### page.tsx 추가 UI +- `folders` state: 폴더 목록 +- `currentFolder` state: 현재 폴더 (기본 INBOX) +- `ComposeDialog`: TipTap 에디터 + to/cc/subject 필드 +- `composeMode`: 'new' | 'reply' | 'forward' +- 메일 상세 버튼: 답장, 전달, 이동(DropdownMenu), 삭제 + +## 예상 문제 + +1. **Gmail SMTP 포트**: Gmail은 587(STARTTLS) 또는 465(SSL). host에서 자동 추론. +2. **폴더명 인코딩**: 한글 폴더 등 UTF-7/UTF-8 혼용 → imapflow가 자동 처리 +3. **첨부파일 partId**: bodyStructure 파싱이 복잡 → `client.download(seqno, partId)` 직접 사용 +4. **TipTap SSR**: Next.js에서 dynamic import 필요 (`ssr: false`) + +## 설계 원칙 + +- SMTP 서비스는 IMAP 서비스와 완전 분리 (파일 분리) +- 첨부파일은 서버에 저장하지 않고 스트리밍으로 직접 응답 +- 답장/전달 인용: `
` + RFC 2822 헤더 표준 준수 +- TipTap은 dynamic import로 SSR 방지 diff --git a/docs/yc/IMX[맥락]-imap-메일기능확장.md b/docs/yc/IMX[맥락]-imap-메일기능확장.md new file mode 100644 index 00000000..553ef5bd --- /dev/null +++ b/docs/yc/IMX[맥락]-imap-메일기능확장.md @@ -0,0 +1,58 @@ +--- +name: IMX[맥락] IMAP 메일 기능 확장 +description: 왜 이 기능들을 추가하는가, 핵심 결정 근거 +type: context +--- + +# IMX 맥락 — IMAP 메일 기능 확장 + +## 왜 하는가 + +ERP 시스템에서 메일 확인만 되면 의미가 없음. 거래처 메일 수신 후 바로 답장, 견적서 첨부파일 저장, 담당자 전달까지 워크플로우가 연결되어야 실용적. + +## 핵심 결정 + 근거 + +### SMTP 서비스 분리 (`userMailSmtpService.ts`) +- IMAP(수신)과 SMTP(송신)은 프로토콜 자체가 다름 +- 파일 분리로 각각 독립적으로 교체/테스트 가능 +- nodemailer는 이미 설치됨 (추가 의존성 없음) + +### TipTap v2 선택 (메일 에디터) +- ProseMirror 기반 → 안정적, 확장 용이 +- `@tiptap/react` 공식 패키지 → Next.js 15 호환 +- Quill보다 번들 크기 작음 (~100KB vs ~200KB) +- dynamic import (`ssr: false`)로 SSR 문제 회피 + +### 첨부파일 스트리밍 +- 서버에 임시 저장하지 않음 → 디스크 절약, 보안 +- `imapflow client.download()` → `stream/promises pipeline()` → HTTP 응답 +- 대용량 파일도 메모리 부담 없음 + +### 메일 삭제 = Trash 이동 +- 즉시 삭제(`messageDelete`) 대신 Trash 폴더 이동 +- 실수로 삭제 시 복구 가능 +- Gmail/wace.me 모두 Trash 폴더 표준 지원 + +### 폴더 구조 표시 +- `client.list({ statusQuery: { unseen: true } })` 로 미읽음 수 포함 +- 폴더 클릭 시 기존 스트리밍 로직 재활용 (folder 파라미터 추가) + +### 답장/전달 RFC 준수 +- `inReplyTo`, `references` 헤더 → 메일 클라이언트에서 스레드로 묶임 +- `
` 인용 → Gmail/Outlook 모두 올바르게 렌더링 + +## 관련 파일 + +- `backend-node/src/services/userMailImapService.ts` — IMAP 수신 로직 +- `backend-node/src/services/imapConnectionPool.ts` — 커넥션 풀 (건드리지 않음) +- `backend-node/src/services/mailCache.ts` — TTL 캐시 (건드리지 않음) +- `frontend/app/(main)/mail/imap/page.tsx` — 메인 UI + +## 기술 참고 + +- imapflow `client.list()` → statusQuery 옵션으로 unseen 포함 +- imapflow `client.messageMove(seqno, folder)` → UID 기반 이동 +- imapflow `client.download(seqno, partId)` → ReadableStream 반환 +- nodemailer `createTransport({ host, port, secure, auth })` → `sendMail()` +- RFC 2822 §3.6.4: `In-Reply-To`, `References` 헤더 +- W3C HTML Threading: `
` 권장 diff --git a/docs/yc/IMX[체크]-imap-메일기능확장.md b/docs/yc/IMX[체크]-imap-메일기능확장.md new file mode 100644 index 00000000..c4be6ecf --- /dev/null +++ b/docs/yc/IMX[체크]-imap-메일기능확장.md @@ -0,0 +1,58 @@ +--- +name: IMX[체크] IMAP 메일 기능 확장 +description: 구현 및 검증 체크리스트 +type: checklist +--- + +# IMX 체크리스트 — IMAP 메일 기능 확장 + +## 공정 상태: 0% + +## 구현 체크리스트 + +### Unit A — 백엔드 서비스 +- [ ] `userMailImapService.ts`: `listFolders()` 추가 +- [ ] `userMailImapService.ts`: `streamMailsByFolder()` 추가 +- [ ] `userMailImapService.ts`: `moveMail()` 추가 +- [ ] `userMailImapService.ts`: `downloadAttachment()` 추가 +- [ ] `userMailSmtpService.ts` 신규 생성 (nodemailer 기반) +- [ ] TypeScript 에러 없음 + +### Unit B — 백엔드 컨트롤러/라우트 +- [ ] `userMailController.ts`: `listFolders`, `streamFolderMails`, `moveMail`, `downloadAttachment`, `sendMail` 핸들러 +- [ ] `userMailRoutes.ts`: 5개 신규 라우트 등록 +- [ ] TypeScript 에러 없음 + +### Unit C — 프론트엔드 API +- [ ] `userMail.ts`: `getUserMailFolders()` 추가 +- [ ] `userMail.ts`: `streamFolderMails()` 추가 +- [ ] `userMail.ts`: `moveUserMail()` 추가 +- [ ] `userMail.ts`: `sendUserMail()` 추가 +- [ ] `userMail.ts`: 첨부파일 다운로드 URL 헬퍼 추가 + +### Unit D — 프론트엔드 UI (TipTap 설치 포함) +- [ ] TipTap 패키지 설치 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`) +- [ ] 좌측 패널: 폴더 목록 + 미읽음 수 +- [ ] 폴더 클릭 → 해당 폴더 메일 스트리밍 +- [ ] 메일 상세: 답장/전달/이동/삭제 버튼 +- [ ] ComposeDialog: TipTap 에디터 + to/cc/subject +- [ ] 답장/전달 시 원문 인용 자동 삽입 +- [ ] 첨부파일 목록 + 다운로드 링크 +- [ ] TypeScript 에러 없음 + +## 검증 체크리스트 + +- [ ] 폴더 목록이 좌측 패널에 표시됨 (INBOX, Sent, Trash 등) +- [ ] 폴더 클릭 시 해당 폴더 메일이 로드됨 +- [ ] 메일 삭제 버튼 클릭 → Trash로 이동됨 +- [ ] 메일 이동 드롭다운 → 다른 폴더로 이동됨 +- [ ] 첨부파일 있는 메일에서 다운로드 버튼 동작 +- [ ] 새 메일 작성 → 발송 성공 +- [ ] 답장 → To 자동 입력, 원문 인용 포함 +- [ ] 전달 → 원문 전체 포함 + +## 변경 이력 + +| 일자 | 내용 | +|------|------| +| 2026-03-27 | PCC 작성, 구현 시작 | diff --git a/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md b/docs/yc/LFC[계획]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md rename to docs/yc/LFC[계획]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md b/docs/yc/LFC[맥락]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md rename to docs/yc/LFC[맥락]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md b/docs/yc/LFC[체크]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md rename to docs/yc/LFC[체크]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md b/docs/yc/MPN[계획]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md rename to docs/yc/MPN[계획]-품번-수동접두어채번.md diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/yc/MPN[맥락]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md rename to docs/yc/MPN[맥락]-품번-수동접두어채번.md diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/yc/MPN[체크]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md rename to docs/yc/MPN[체크]-품번-수동접두어채번.md diff --git a/docs/yc/MSN[계획]-메신저기능.md b/docs/yc/MSN[계획]-메신저기능.md new file mode 100644 index 00000000..f75eca41 --- /dev/null +++ b/docs/yc/MSN[계획]-메신저기능.md @@ -0,0 +1,212 @@ +# MSN[계획] 메신저 기능 개발 + +## 개요 + +벡스플로어 ERP에 내장 메신저 기능을 추가한다. Gmail 편지쓰기 스타일의 우측 하단 플로팅 모달로 동작하며, Socket.IO 기반 실시간 통신을 제공한다. 모든 화면에서 접근 가능하고, 동일 company_code 내 사용자끼리 1:1 DM / 그룹 채팅 / 채널 대화를 지원한다. + +--- + +## 현재 동작 + +- 메신저 기능 없음 +- 사내 커뮤니케이션 수단 부재 + +## 변경 후 동작 + +- 모든 화면 우측 하단에 메신저 FAB 버튼 고정 (z-index: 9999) +- FAB 클릭 시 Gmail 편지쓰기 스타일 모달 팝업 (우측 하단) +- 모달 좌측: 채팅방 목록 (DM / 그룹 / 채널 탭) +- 모달 우측: 채팅 영역 (메시지 입력, 파일 첨부, 이모지, 멘션, 스레드) +- Socket.IO로 실시간 메시지 수신 +- 읽지 않은 메시지 수 FAB 배지 표시 +- 토스트 알림 on/off 토글 (메신저 설정 내) + +--- + +## 시각적 예시 + +``` +┌─────────────────────────────────────────────────────┐ +│ 벡스플로어 화면 │ +│ │ +│ │ +│ ┌────────────────────┐ │ +│ │ 채팅방 목록 │ 채팅창 │ │ +│ │─────────────────── │ │ +│ │ DM 그룹 채널 │ │ +│ │ ───────────────── │ │ +│ │ 👤 김민호 ● │ │ +│ │ 👥 개발팀 │ │ +│ │ # 공지사항 │ │ +│ └────────────────────┘ │ +│ [💬 3] │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 아키텍처 + +```mermaid +graph TB + subgraph Frontend + FAB[MessengerFAB] --> Modal[MessengerModal] + Modal --> RoomList[RoomList 좌측 240px] + Modal --> ChatPanel[ChatPanel 우측 480px] + ChatPanel --> MessageList[MessageList] + ChatPanel --> MessageInput[MessageInput] + MessageInput --> FileUpload[파일 첨부] + MessageInput --> EmojiPicker[이모지] + MessageInput --> MentionDropdown[멘션 @] + end + + subgraph SocketIO + SocketClient[socket.io-client] <--> SocketServer[messengerSocket.ts] + end + + subgraph Backend + SocketServer --> MessengerService[messengerService.ts] + Route[messengerRoutes.ts] --> Controller[messengerController.ts] + Controller --> MessengerService + MessengerService --> DB[(PostgreSQL)] + FileRoute[파일 업로드] --> Multer[multerMessengerConfig.ts] + end + + Frontend <--> SocketIO + Frontend <--> Route +``` + +--- + +## 변경 파일 + +### 신규 생성 +| 파일 | 역할 | +|------|------| +| `backend-node/src/types/messenger.ts` | TypeScript 인터페이스 | +| `backend-node/src/services/messengerService.ts` | 비즈니스 로직 | +| `backend-node/src/controllers/messengerController.ts` | HTTP 핸들러 | +| `backend-node/src/routes/messengerRoutes.ts` | REST API 라우트 | +| `backend-node/src/socket/messengerSocket.ts` | Socket.IO 이벤트 핸들러 | +| `backend-node/src/config/multerMessengerConfig.ts` | 파일 업로드 설정 | +| `db/migrations/messenger_tables.sql` | DB 테이블 생성 | +| `frontend/components/messenger/MessengerFAB.tsx` | 플로팅 버튼 | +| `frontend/components/messenger/MessengerModal.tsx` | 메인 모달 컨테이너 | +| `frontend/components/messenger/RoomList.tsx` | 채팅방 목록 | +| `frontend/components/messenger/ChatPanel.tsx` | 채팅 영역 | +| `frontend/components/messenger/MessageItem.tsx` | 메시지 단일 아이템 | +| `frontend/components/messenger/MessageInput.tsx` | 입력창 | +| `frontend/components/messenger/NewRoomModal.tsx` | 방 생성 모달 | +| `frontend/components/messenger/UserAvatar.tsx` | 프로필 아바타 | +| `frontend/hooks/useMessenger.ts` | 메신저 상태 훅 | +| `frontend/hooks/useMessengerSocket.ts` | Socket.IO 훅 | +| `frontend/contexts/MessengerContext.tsx` | 전역 메신저 상태 | + +### 수정 +| 파일 | 변경 내용 | +|------|-----------| +| `backend-node/src/app.ts` | Socket.IO 서버 초기화, messengerRoutes 등록 | +| `frontend/app/(main)/layout.tsx` | ``, `` 추가 | + +--- + +## 코드 설계 + +### DB 스키마 +```sql +-- 채팅방 +messenger_rooms ( + room_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_code VARCHAR(50) NOT NULL, + room_type VARCHAR(10) NOT NULL CHECK (room_type IN ('dm', 'group', 'channel')), + room_name VARCHAR(100), + created_by VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +) + +-- 참여자 +messenger_participants ( + room_id UUID REFERENCES messenger_rooms(room_id), + user_id VARCHAR(100) NOT NULL, + company_code VARCHAR(50) NOT NULL, + joined_at TIMESTAMP DEFAULT NOW(), + last_read_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (room_id, user_id) +) + +-- 메시지 +messenger_messages ( + message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES messenger_rooms(room_id), + company_code VARCHAR(50) NOT NULL, + sender_id VARCHAR(100) NOT NULL, + content TEXT, + message_type VARCHAR(10) DEFAULT 'text' CHECK (message_type IN ('text', 'file', 'system')), + parent_message_id UUID REFERENCES messenger_messages(message_id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + is_deleted BOOLEAN DEFAULT FALSE +) + +-- 이모지 리액션 +messenger_reactions ( + message_id UUID REFERENCES messenger_messages(message_id), + user_id VARCHAR(100) NOT NULL, + emoji VARCHAR(10) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (message_id, user_id, emoji) +) + +-- 파일 첨부 +messenger_files ( + file_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID REFERENCES messenger_messages(message_id), + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### Socket.IO 이벤트 +| 이벤트 | 방향 | 설명 | +|--------|------|------| +| `join_rooms` | Client→Server | 참여 중인 방 전체 구독 | +| `send_message` | Client→Server | 메시지 전송 | +| `new_message` | Server→Client | 새 메시지 수신 | +| `message_read` | Client→Server | 읽음 처리 | +| `user_online` | Server→Client | 온라인 상태 변경 | +| `typing_start/stop` | Client↔Server | 타이핑 표시 | +| `add_reaction` | Client→Server | 이모지 리액션 추가 | +| `reaction_updated` | Server→Client | 리액션 업데이트 | + +### REST API +| Method | URL | 설명 | +|--------|-----|------| +| GET | `/api/messenger/rooms` | 내 채팅방 목록 | +| POST | `/api/messenger/rooms` | 채팅방 생성 | +| GET | `/api/messenger/rooms/:roomId/messages` | 메시지 히스토리 | +| POST | `/api/messenger/rooms/:roomId/read` | 읽음 처리 | +| POST | `/api/messenger/files/upload` | 파일 업로드 | +| GET | `/api/messenger/files/:fileId` | 파일 다운로드 | +| GET | `/api/messenger/users` | 회사 내 사용자 목록 | +| PUT | `/api/messenger/rooms/:roomId` | 방 이름/설정 수정 | + +--- + +## 예상 문제 + +1. **Socket.IO + Next.js**: Next.js는 기본적으로 HTTP 서버를 추상화하므로, Express HTTP 서버에 Socket.IO를 붙이고 프론트엔드는 백엔드 포트로 직접 연결 +2. **JWT 인증 with Socket.IO**: handshake 시 Authorization 헤더 또는 auth 옵션으로 토큰 전달, 서버에서 미들웨어로 검증 +3. **채널 분리 가능성**: `room_type = 'channel'` 쿼리 조건으로만 필터링하므로, 채널 UI/라우트만 제거하면 기능 완전 제거 가능 + +--- + +## 설계 원칙 + +- 기존 MVC + Service 패턴 준수 +- 모든 쿼리에 `company_code` 필터 적용 (멀티테넌시) +- 채널은 `room_type` 값으로만 분리 — 코드 결합도 최소화 +- FAB/모달은 기존 레이아웃 DOM에 독립적으로 렌더링 (Portal 또는 최상단 마운트) diff --git a/docs/yc/MSN[맥락]-메신저기능.md b/docs/yc/MSN[맥락]-메신저기능.md new file mode 100644 index 00000000..cd83c315 --- /dev/null +++ b/docs/yc/MSN[맥락]-메신저기능.md @@ -0,0 +1,61 @@ +# MSN[맥락] 메신저 기능 개발 + +## 왜 하는가 + +벡스플로어 ERP 내에서 사용자 간 실시간 커뮤니케이션 수단이 없다. 업무 맥락을 ERP 밖(카카오톡, 슬랙 등)으로 내보내지 않고 시스템 안에서 처리할 수 있도록 내장 메신저를 도입한다. + +--- + +## 핵심 결정 및 근거 + +| 결정 | 근거 | +|------|------| +| Socket.IO 신규 도입 | HTTP 폴링은 메신저에 부적합. 실시간 타이핑 표시, 온라인 상태, 즉각적인 메시지 수신이 필요 | +| Gmail 스타일 우측 하단 모달 | 업무 화면을 방해하지 않고 언제든 접근 가능. 별도 페이지 이동 없이 사용 | +| DM + 그룹 + 채널 1차 구현 | 채널은 `room_type` 값으로만 분리하여 나중에 제거 용이하게 설계 | +| company_code 격리 | 기존 멀티테넌시 패턴과 동일하게 적용. 회사 간 데이터 유출 방지 | +| 파일 업로드: multer 로컬 | 기존 메일 첨부파일과 동일한 방식. 인프라 추가 없이 일관성 유지 | +| 프로필: photo BLOB 활용 | `user_info.photo` 컬럼에 이미 이미지 저장됨. 없으면 이름 첫 글자 원형 아바타 | +| 토스트 알림 on/off | 집중 업무 시 알림 방해 방지. localStorage에 설정 저장 | + +--- + +## 관련 파일 + +### 참고 패턴 (기존 코드) +| 파일 | 참고 목적 | +|------|-----------| +| `backend-node/src/config/multerConfig.ts` | 파일 업로드 설정 패턴 | +| `backend-node/src/services/authService.ts` | user_info.photo BLOB → base64 변환 패턴 | +| `backend-node/src/middleware/authMiddleware.ts` | JWT 인증 미들웨어 (Socket.IO handshake에도 동일 로직 적용) | +| `frontend/components/mail/` | UI 컴포넌트 구조 참고 | +| `frontend/hooks/use-toast.ts` | 기존 토스트 훅 사용 | +| `backend-node/src/app.ts` | 라우트 등록 및 미들웨어 설정 위치 | + +--- + +## 기술 참고 + +### Socket.IO JWT 인증 +```typescript +// 서버: handshake 시 토큰 검증 +io.use((socket, next) => { + const token = socket.handshake.auth.token; + const user = verifyJWT(token); + socket.data.user = user; + next(); +}); + +// 클라이언트: 연결 시 토큰 전달 +const socket = io(BACKEND_URL, { + auth: { token: localStorage.getItem('token') } +}); +``` + +### 채널 분리 설계 +채널 기능은 `room_type = 'channel'` 조건으로만 분리되어 있음. +제거 시: RoomList의 채널 탭 UI 제거 + `/api/messenger/rooms?type=channel` 호출 제거만으로 완전 비활성화 가능. DB 스키마 변경 불필요. + +### 멀티테넌시 적용 +모든 쿼리에 `WHERE company_code = $n` 조건 필수 적용. +Socket.IO 룸 네이밍: `{company_code}:{room_id}` 형식으로 회사별 격리. diff --git a/docs/yc/MSN[체크]-메신저기능.md b/docs/yc/MSN[체크]-메신저기능.md new file mode 100644 index 00000000..b35908e6 --- /dev/null +++ b/docs/yc/MSN[체크]-메신저기능.md @@ -0,0 +1,97 @@ +# MSN[체크] 메신저 기능 개발 + +## 공정 상태: 90% (1차 구현 완료, 2차 테스트 대기) + +--- + +## 구현 체크리스트 + +### Phase 1: DB & 백엔드 기반 +- [x] `db/migrations/messenger_tables.sql` 작성 및 실행 +- [x] `backend-node/src/types/messenger.ts` 타입 정의 +- [x] `backend-node/src/services/messengerService.ts` 구현 + - [x] getRooms (내 채팅방 목록) + - [x] createRoom (DM / 그룹 / 채널) + - [x] getMessages (메시지 히스토리, 페이지네이션) + - [x] sendMessage + - [x] markAsRead + - [x] getCompanyUsers (사용자 목록) + - [x] addReaction / removeReaction +- [x] `backend-node/src/controllers/messengerController.ts` 구현 +- [x] `backend-node/src/routes/messengerRoutes.ts` 구현 +- [x] `backend-node/src/config/multerMessengerConfig.ts` 구현 (파일 업로드) +- [x] `backend-node/src/socket/messengerSocket.ts` 구현 + - [x] JWT 인증 미들웨어 + - [x] join_rooms 이벤트 + - [x] send_message 이벤트 + - [x] message_read 이벤트 + - [x] typing_start / typing_stop 이벤트 + - [x] add_reaction 이벤트 + - [x] 온라인 상태 관리 (connect / disconnect) +- [x] `backend-node/src/app.ts` Socket.IO 초기화 및 라우트 등록 + +### Phase 2: 프론트엔드 컴포넌트 +- [x] `frontend/contexts/MessengerContext.tsx` (전역 상태) +- [x] `frontend/hooks/useMessengerSocket.ts` (Socket.IO 연결 관리) +- [x] `frontend/hooks/useMessenger.ts` (채팅방/메시지 React Query) +- [x] `frontend/components/messenger/UserAvatar.tsx` (프로필 이미지 / 이름 첫 글자) +- [x] `frontend/components/messenger/MessengerFAB.tsx` (플로팅 버튼 + 배지) +- [x] `frontend/components/messenger/MessengerModal.tsx` (메인 모달 컨테이너) +- [x] `frontend/components/messenger/RoomList.tsx` (채팅방 목록 + DM/그룹/채널 탭) +- [x] `frontend/components/messenger/ChatPanel.tsx` (채팅 영역) +- [x] `frontend/components/messenger/MessageItem.tsx` (메시지 + 리액션 + 스레드 버튼) +- [x] `frontend/components/messenger/MessageInput.tsx` (입력창 + 파일 + 이모지 + 멘션) +- [x] `frontend/components/messenger/NewRoomModal.tsx` (방 생성 모달) +- [x] `frontend/components/messenger/MessengerSettings.tsx` (토스트 알림 on/off 등) +- [x] `frontend/app/(main)/layout.tsx` MessengerProvider + MessengerFAB 추가 + +--- + +## 검증 체크리스트 + +### 기본 동작 +- [ ] FAB 버튼이 모든 페이지에서 우측 하단 고정 (z-index 최상위) +- [ ] FAB 클릭 시 모달 열기/닫기 동작 +- [ ] 모달 크기: 좌측 240px + 우측 480px + +### 채팅 +- [ ] DM 방 생성 (사용자 선택 → 방 생성) +- [ ] 그룹 방 생성 (여러 사용자 선택 → 방 이름 입력 → 생성) +- [ ] 채널 방 생성 +- [ ] 메시지 전송 및 실시간 수신 (Socket.IO) +- [ ] 메시지 히스토리 로드 (스크롤 시 이전 메시지) + +### 부가 기능 +- [ ] 파일 첨부 업로드/다운로드 +- [ ] 이모지 리액션 추가/제거 +- [ ] 멘션(@) 자동완성 드롭다운 +- [ ] 스레드 답글 +- [ ] 타이핑 표시 ("김민호님이 입력 중...") + +### 알림 +- [ ] 읽지 않은 메시지 수 FAB 배지 표시 +- [ ] 다른 방 메시지 수신 시 토스트 알림 +- [ ] 토스트 알림 on/off 토글 동작 +- [ ] 토스트 설정값 localStorage 저장/복원 + +### 프로필 +- [ ] photo 있는 사용자: 원형 이미지 표시 +- [ ] photo 없는 사용자: 이름 첫 글자 원형 아바타 표시 + +### 멀티테넌시 +- [ ] 다른 company_code 사용자 목록에 미노출 +- [ ] 다른 회사 채팅방 접근 불가 + +--- + +## 정리 + +- [ ] DB 체크포인트 파일 삭제 (2차 테스트 완료 후) + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-30 | 최초 설계 확정 | diff --git a/docs/ycshin-node/MST[계획]-다중선택-라벨표시.md b/docs/yc/MST[계획]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[계획]-다중선택-라벨표시.md rename to docs/yc/MST[계획]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md b/docs/yc/MST[맥락]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md rename to docs/yc/MST[맥락]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/MST[체크]-다중선택-라벨표시.md b/docs/yc/MST[체크]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[체크]-다중선택-라벨표시.md rename to docs/yc/MST[체크]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/yc/PGN[계획]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[계획]-페이징-직접입력.md rename to docs/yc/PGN[계획]-페이징-직접입력.md diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/yc/PGN[맥락]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[맥락]-페이징-직접입력.md rename to docs/yc/PGN[맥락]-페이징-직접입력.md diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/yc/PGN[체크]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[체크]-페이징-직접입력.md rename to docs/yc/PGN[체크]-페이징-직접입력.md diff --git a/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md b/docs/yc/RFO[계획]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md rename to docs/yc/RFO[계획]-렉구조-층필수해제.md diff --git a/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md b/docs/yc/RFO[맥락]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md rename to docs/yc/RFO[맥락]-렉구조-층필수해제.md diff --git a/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md b/docs/yc/RFO[체크]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md rename to docs/yc/RFO[체크]-렉구조-층필수해제.md diff --git a/docs/yc/UML[계획]-user-mail.md b/docs/yc/UML[계획]-user-mail.md new file mode 100644 index 00000000..9997e10e --- /dev/null +++ b/docs/yc/UML[계획]-user-mail.md @@ -0,0 +1,169 @@ +# UML[계획] - 사용자 메일 관리 시스템 + +## 개요 + +벡스플로우(Vexflow) 사용자 메일 관리 페이지 구현 프로젝트입니다. 외부 메일 서버(POP3/IMAP)와 연동하여 사용자가 본인의 메일 계정을 등록하고 벡스플로우 내에서 메일을 조회할 수 있는 기능을 제공합니다. + +### 현재 동작 +- Admin 메일 시스템만 존재 +- JSON 파일 기반 저장소 +- 사용자 구분 없음 + +### 변경 후 동작 +- 사용자가 외부 메일 계정(IMAP 또는 POP3) 등록 +- 벡스플로우에서 해당 계정의 메일 조회 +- PostgreSQL 기반 계정 저장 및 관리 +- 사용자별 격리(user_id 기반) + +--- + +## 아키텍처 + +``` +┌─────────────┐ +│ 사용자 │ +│ (Frontend) │ +└──────┬──────┘ + │ + ├─→ /mail/imap 페이지 + └─→ /mail/pop3 페이지 + │ + ↓ + ┌──────────────────┐ + │ userMail.ts │ (API 클라이언트) + │ (lib/api/) │ + └────────┬─────────┘ + │ + ↓ + ┌────────────────────────────┐ + │ /api/user-mail/* 라우트 │ + │ (userMailController) │ + └────────┬───────────────────┘ + │ + ┌───────┴────────┐ + │ │ + ↓ ↓ + ┌────────────────┐ ┌──────────────┐ + │ userMailAccount│ │ userMailImap │ + │ Service │ │ Service │ + │ (PostgreSQL) │ │ (IMAP) │ + └────────────────┘ │ │ + └──────────────┘ + │ + ↓ + ┌──────────────────┐ + │ 외부 IMAP 서버 │ + └──────────────────┘ + + 또는 + + ┌──────────────────┐ + │ userMailPop3 │ + │ Service │ + │ (POP3) │ + └──────────────────┘ + │ + ↓ + ┌──────────────────┐ + │ 외부 POP3 서버 │ + └──────────────────┘ +``` + +--- + +## 신규 파일 목록 + +### 백엔드 (Node.js/Express) + +| 파일 경로 | 역할 | +|----------|------| +| `src/services/userMailAccountService.ts` | DB 계정 관리 (생성, 조회, 삭제, 수정) | +| `src/services/userMailImapService.ts` | IMAP 프로토콜 연결 및 메일 조회 | +| `src/services/userMailPop3Service.ts` | POP3 프로토콜 연결 및 메일 조회 | +| `src/controllers/userMailController.ts` | API 엔드포인트 처리 | +| `src/routes/userMailRoutes.ts` | 라우트 정의 | + +### 프론트엔드 (React/TypeScript) + +| 파일 경로 | 역할 | +|----------|------| +| `frontend/lib/api/userMail.ts` | API 클라이언트 | +| `frontend/app/(main)/mail/imap/page.tsx` | IMAP 메일 관리 페이지 | +| `frontend/app/(main)/mail/pop3/page.tsx` | POP3 메일 관리 페이지 | + +--- + +## 수정 파일 목록 + +| 파일 경로 | 변경 사항 | +|----------|---------| +| `src/runMigration.ts` | 마이그레이션 스크립트에 user_mail_accounts 테이블 추가 | +| `src/app.ts` | userMailRoutes 등록 | +| `src/components/AdminPageRenderer.tsx` | /mail/imap, /mail/pop3 페이지 하드코딩 등록 (2줄) | + +--- + +## 데이터베이스 스키마 + +### user_mail_accounts 테이블 + +```sql +CREATE TABLE user_mail_accounts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')), + host VARCHAR(255) NOT NULL, + port INT NOT NULL DEFAULT 993, + use_tls BOOLEAN DEFAULT TRUE, + username VARCHAR(255) NOT NULL, + password TEXT NOT NULL, -- 암호화됨 (encryptionService 사용) + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, protocol, host, username) +); +``` + +--- + +## 설계 원칙 + +### 1. 사용자 격리 +- 모든 API 요청에서 현재 사용자의 user_id 검증 +- 다른 사용자의 계정/메일 접근 불가 + +### 2. 프로토콜별 서비스 분리 +- userMailImapService.ts: IMAP 전용 +- userMailPop3Service.ts: POP3 전용 +- 각 서비스는 독립적으로 동작 + +### 3. 기존 기능 재활용 +- `encryptionService`: 비밀번호 암호화/복호화 +- `mailparser`: 메일 본문 파싱 +- `imap` 패키지: IMAP 연결(기존 mailReceiveBasicService 참조) + +### 4. 기존 Admin 메일 시스템과 분리 +- 새로운 테이블, 서비스, 라우트로 완전 독립 +- JSON 파일 기반 방식 미사용 + +--- + +## 주요 API 엔드포인트 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/user-mail/accounts` | 새 계정 등록 | +| GET | `/api/user-mail/accounts` | 사용자 계정 목록 | +| GET | `/api/user-mail/accounts/:id` | 계정 상세 조회 | +| PUT | `/api/user-mail/accounts/:id` | 계정 수정 | +| DELETE | `/api/user-mail/accounts/:id` | 계정 삭제 | +| POST | `/api/user-mail/accounts/:id/test` | 연결 테스트 | +| GET | `/api/user-mail/accounts/:id/mails` | 메일 목록 조회 | + +--- + +## 변경 이력 + +| 날짜 | 버전 | 내용 | +|------|------|------| +| 2026-03-27 | v1.0 | 초안 작성 | diff --git a/docs/yc/UML[맥락]-user-mail.md b/docs/yc/UML[맥락]-user-mail.md new file mode 100644 index 00000000..7b1f54fe --- /dev/null +++ b/docs/yc/UML[맥락]-user-mail.md @@ -0,0 +1,147 @@ +# UML[맥락] - 사용자 메일 관리 시스템 + +## 프로젝트 배경 + +### 추진 이유 +- 팀장 지시로 POP3 구현 필요 +- IMAP 허용 여부 확인 대기 중 +- 두 프로토콜 모두 구현 후 비교하여 최적 솔루션 채택 + +--- + +## 핵심 기술 결정 사항 + +### 1. 페이지 등록 방식: 하드코딩 +**선택**: 하드코딩 (AdminPageRenderer.tsx에 직접 등록) + +**사유**: +- 컴포넌트 레지스트리에 추가할 권한 없음 +- 간단한 추가 작업으로 빠른 구현 가능 + +**구현**: +```typescript +// AdminPageRenderer.tsx에 2줄 추가 +{path: '/mail/imap', label: '메일(IMAP)', component: () => }, +{path: '/mail/pop3', label: '메일(POP3)', component: () => }, +``` + +--- + +### 2. 저장소: PostgreSQL (Admin 메일과 완전 분리) + +**선택**: PostgreSQL `user_mail_accounts` 테이블 + +**사유**: +- Admin 메일 시스템(JSON 파일 기반)과 완전 독립 +- 사용자별 격리 용이 (user_id 기반) +- 확장성 및 성능 이점 + +**결과**: +- 기존 Admin 메일: JSON 파일 유지 +- 신규 사용자 메일: PostgreSQL 관리 + +--- + +### 3. POP3 메일 삭제 정책: 서버 유지 + +**선택**: DELE 명령 미호출 (서버 메일 유지) + +**사유**: +- 데이터 손실 방지 +- 사용자 실수로 인한 피해 최소화 +- 벡스플로우는 조회만 수행 + +**구현**: +- `userMailPop3Service.ts`에서 RETR 후 DELE 호출 안 함 +- 서버의 자동 정리 정책에 의존 + +--- + +### 4. 페이지별 프로토콜 고정 + +**선택**: 페이지당 프로토콜 1개로 제한 + +**구현**: +- `/mail/imap` → IMAP 계정만 표시/관리 +- `/mail/pop3` → POP3 계정만 표시/관리 + +**사유**: +- UI 단순화 +- 프로토콜별 메일 구조 차이 처리 용이 +- 사용자 혼동 최소화 + +--- + +## 관련 기존 코드 참조 + +### mailReceiveBasicService.ts +- IMAP 연결 및 메일 조회 로직 +- 메일 파싱 및 저장 방식 +- Error handling 패턴 + +**참조 사항**: +```typescript +// IMAP 연결 구조, 메일 검색 쿼리, 메일 수신 처리 방식 +``` + +### encryptionService.ts +- 비밀번호 암호화/복호화 +- DB 저장 시 암호화, 조회 시 복호화 + +**사용 방식**: +```typescript +// 저장: encryptionService.encrypt(password) +// 조회: encryptionService.decrypt(encrypted_password) +``` + +### AdminPageRenderer.tsx +- 기존 페이지 하드코딩 구조 +- 페이지 등록 형식 및 라벨 지정 방식 + +**추가 위치**: +```typescript +// 기존 페이지 목록에 /mail/imap, /mail/pop3 추가 +``` + +--- + +## 기술 스택 및 패키지 + +### 기존 패키지 (재활용) +| 패키지 | 버전 | 용도 | +|--------|------|------| +| `imap` | - | IMAP 연결 | +| `mailparser` | - | 메일 파싱 | +| `pg` | - | PostgreSQL 클라이언트 | + +### 신규 패키지 +| 패키지 | 버전 | 용도 | +|--------|------|------| +| `node-pop3` | latest | POP3 연결 | + +--- + +## 핵심 고려 사항 + +### 보안 +1. 메일 계정 비밀번호는 항상 암호화 상태로 저장 +2. 사용자 격리: user_id 기반 접근 제어 +3. 외부 서버 연결 정보는 민감: 환경변수 활용 + +### 성능 +1. 메일 조회는 페이지네이션 처리 +2. 연결 테스트는 별도 API (현재 메일 검색과 분리) +3. 대량 메일 처리 시 비동기 처리 + +### 에러 처리 +1. 네트워크 오류: 재시도 로직 +2. 인증 실패: 명확한 에러 메시지 제공 +3. DB 오류: 트랜잭션 롤백 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-27 | 초안 작성 | diff --git a/docs/yc/UML[체크]-user-mail.md b/docs/yc/UML[체크]-user-mail.md new file mode 100644 index 00000000..822a994d --- /dev/null +++ b/docs/yc/UML[체크]-user-mail.md @@ -0,0 +1,161 @@ +# UML[체크] - 사용자 메일 관리 시스템 + +## 공정 상태 +**진행률: 90%** (IMAP 완성, POP3 미구현) + +--- + +## 구현 체크리스트 + +### 데이터베이스 +- [x] DB 마이그레이션 작성 (user_mail_accounts 테이블 생성) + +### 패키지 설치 +- [ ] npm install node-pop3 (설치됨, 서비스 미구현) + +### 백엔드 서비스 계층 +- [x] userMailAccountService.ts (DB CRUD) +- [x] userMailImapService.ts (IMAP 프로토콜) +- [x] userMailSmtpService.ts (SMTP 발송) +- [x] imapConnectionPool.ts (IMAP 연결 풀) +- [x] mailCache.ts (메일 캐시) +- [ ] userMailPop3Service.ts (POP3 프로토콜 - 미구현) + +### 백엔드 API 계층 +- [x] userMailController.ts (요청 처리) +- [x] userMailRoutes.ts (라우트 정의) +- [x] app.ts에 userMailRoutes 등록 (`/api/user-mail`) + +### 프론트엔드 API 클라이언트 +- [x] frontend/lib/api/userMail.ts + +### 프론트엔드 페이지 +- [x] frontend/app/(main)/mail/imap/page.tsx +- [x] frontend/app/(main)/mail/imap/ComposeDialog.tsx (메일 작성) +- [ ] frontend/app/(main)/mail/pop3/page.tsx (미구현) + +### 페이지 등록 +- [x] AdminPageRenderer.tsx에 /mail/imap 등록 +- [ ] AdminPageRenderer.tsx에 /mail/pop3 등록 (미구현) + +--- + +## 구현된 IMAP 기능 + +### 계정 관리 +- [x] 계정 추가 (연결 테스트 후 저장) +- [x] 계정 수정 +- [x] 계정 삭제 +- [x] 연결 테스트 (저장 전 자동 + 수동) + +### 메일 조회 +- [x] SSE 스트리밍으로 메일 목록 로드 (20개씩) +- [x] 이전 메일 더 보기 (무한 스크롤 방식) +- [x] 메일 상세 조회 (HTML/텍스트 본문) +- [x] 폴더별 메일 조회 (INBOX, 휴지통, 스팸 등) +- [x] 새로고침 버튼 + +### 메일 관리 +- [x] 읽음 처리 (클릭 시 자동, IMAP \Seen 플래그) +- [x] 메일 삭제 (\Trash 특수 폴더로 이동 - Gmail 호환) +- [x] 메일 이동 (폴더 간 이동) + +### 첨부파일 +- [x] 첨부파일 목록 표시 (pill 형태) +- [x] 첨부파일 다운로드 (ReadableStream 진행바 표시) +- [x] Content-Length 헤더 지원 (정확한 진행률) + +### 발신 +- [x] 메일 작성 / 발송 (SMTP) +- [x] 답장 (Re: 제목, inReplyTo 헤더) +- [x] 전달 (Fwd: 제목, 원본 본문 인용) + +### UI +- [x] 3단 패널 레이아웃 (계정 / 메일 목록 / 상세) +- [x] 폴더 목록 (unseen 카운트 표시) +- [x] 읽음/삭제 후 unseen 카운트 자동 갱신 +- [x] 검색 (제목/발신자 클라이언트 필터) + +--- + +## 검증 체크리스트 + +### 데이터베이스 +- [x] `user_mail_accounts` 테이블 존재 확인 +- [x] 테이블 스키마 정확성 확인 + +### 계정 관리 API +- [x] POST `/api/user-mail/accounts` - 계정 생성 +- [x] GET `/api/user-mail/accounts` - 사용자 계정 목록 +- [x] PUT `/api/user-mail/accounts/:id` - 계정 수정 +- [x] DELETE `/api/user-mail/accounts/:id` - 계정 삭제 +- [x] POST `/api/user-mail/accounts/:id/test` - 연결 테스트 +- [x] POST `/api/user-mail/test-connection` - 직접 연결 테스트 + +### 메일 API +- [x] GET `/api/user-mail/accounts/:id/mails/stream` - 스트리밍 목록 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno` - 상세 조회 +- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/mark-read` - 읽음 처리 +- [x] DELETE `/api/user-mail/accounts/:id/mails/:seqno` - 삭제 (휴지통 이동) +- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/move` - 이동 +- [x] GET `/api/user-mail/accounts/:id/folders` - 폴더 목록 +- [x] GET `/api/user-mail/accounts/:id/folders/:folder/mails/stream` - 폴더별 스트리밍 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachments` - 첨부파일 목록 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachment/:partId` - 첨부파일 다운로드 +- [x] POST `/api/user-mail/accounts/:id/send` - 메일 발송 + +### 사용자 격리 검증 +- [x] 모든 쿼리에 WHERE user_id = $n 포함 (DB 레벨 강제) +- [x] 다른 user_id로 계정 접근 시 404 반환 + +### 프론트엔드 페이지 +- [x] `/mail/imap` 페이지 접속 및 동작 +- [x] Gmail IMAP 연동 확인 +- [x] 메일 목록 → 상세 → 읽음 처리 +- [x] 첨부파일 다운로드 진행바 +- [x] 메일 삭제 → Gmail 휴지통 이동 확인 +- [x] 답장/전달 발송 확인 + +--- + +## 알려진 이슈 및 주의사항 + +### 1. 메일 삭제 방식 +- `\Trash` 특수 폴더로 이동 (EXPUNGE 아님) +- Gmail 호환: `[Gmail]/휴지통`으로 자동 라우팅 +- 폴더 없으면 `messageDelete` fallback (영구 삭제 주의) + +### 2. 첨부파일 진행바 +- Content-Length 헤더 기반 진행률 계산 +- imapflow `meta.size`로 헤더 설정 +- totalSize fallback: `getUserMailAttachments`의 size 필드 사용 + +### 3. IMAP 연결 풀 +- 계정당 1개 연결 유지 (maxIdleMs: 5분) +- busy 상태 시 큐잉 처리 +- 연결 끊김 시 자동 재연결 + +### 4. 캐시 +- 메일 목록: 60초 TTL +- 메일 상세: 5분 TTL +- 읽음/삭제/이동 시 해당 캐시 무효화 + +### 5. POP3 미구현 +- `node-pop3` 패키지 설치됨 +- 서비스 파일 미작성 +- 팀장 지시 후 구현 예정 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 내용 | +|------|------|------| +| 2026-03-27 | v1.0 | 초안 작성 | +| 2026-03-30 | v2.0 | IMAP 전 기능 구현 완료 (메일 조회/삭제/이동/첨부/발송/답장/전달/폴더/진행바) | + +--- + +## 관련 문서 + +- [UML[계획]-user-mail.md](./UML[계획]-user-mail.md): 아키텍처 및 설계 diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/yc/탭_시스템_설계.md similarity index 100% rename from docs/ycshin-node/탭_시스템_설계.md rename to docs/yc/탭_시스템_설계.md diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/yc/필수입력항목_자동검증_설계.md similarity index 100% rename from docs/ycshin-node/필수입력항목_자동검증_설계.md rename to docs/yc/필수입력항목_자동검증_설계.md diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a520..21b00950 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.omc/ diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl deleted file mode 100644 index eeffca86..00000000 --- a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl +++ /dev/null @@ -1,14 +0,0 @@ -{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167} -{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548} -{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997} -{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528} -{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641} -{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980} -{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646} diff --git a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl deleted file mode 100644 index 64204160..00000000 --- a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735} -{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607} -{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249} -{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624} -{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json deleted file mode 100644 index 9b6eaa2a..00000000 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lastSentAt": "2026-03-25T05:06:13.529Z" -} \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json deleted file mode 100644 index cc6d2569..00000000 --- a/frontend/.omc/state/last-tool-error.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}", - "error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx", - "timestamp": "2026-03-25T05:00:38.410Z", - "retry_count": 1 -} \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json deleted file mode 100644 index a46a9962..00000000 --- a/frontend/.omc/state/mission-state.json +++ /dev/null @@ -1,281 +0,0 @@ -{ - "updatedAt": "2026-03-25T05:06:35.487Z", - "missions": [ - { - "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-25T00:33:45.197Z", - "updatedAt": "2026-03-25T01:37:19.659Z", - "status": "done", - "workerCount": 5, - "taskCounts": { - "total": 5, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 5, - "failed": 0 - }, - "agents": [ - { - "name": "Explore:ad233db", - "role": "Explore", - "ownership": "ad233db7fa6f059dd", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:34:44.932Z" - }, - { - "name": "Explore:a31a0f7", - "role": "Explore", - "ownership": "a31a0f729d328643f", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:35:24.588Z" - }, - { - "name": "executor:a9510b7", - "role": "executor", - "ownership": "a9510b7d8ec5a1ce7", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:42:01.730Z" - }, - { - "name": "executor:a1c1d18", - "role": "executor", - "ownership": "a1c1d186f0eb6dfc1", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:40:12.608Z" - }, - { - "name": "executor:a9a231d", - "role": "executor", - "ownership": "a9a231d40fd5a150b", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T01:37:19.659Z" - } - ], - "timeline": [ - { - "id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z", - "at": "2026-03-25T00:40:12.608Z", - "kind": "completion", - "agent": "executor:a1c1d18", - "detail": "completed", - "sourceKey": "session-stop:a1c1d186f0eb6dfc1" - }, - { - "id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z", - "at": "2026-03-25T00:42:01.730Z", - "kind": "completion", - "agent": "executor:a9510b7", - "detail": "completed", - "sourceKey": "session-stop:a9510b7d8ec5a1ce7" - }, - { - "id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z", - "at": "2026-03-25T01:35:00.232Z", - "kind": "update", - "agent": "executor:a9a231d", - "detail": "started executor:a9a231d", - "sourceKey": "session-start:a9a231d40fd5a150b" - }, - { - "id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z", - "at": "2026-03-25T01:37:19.659Z", - "kind": "completion", - "agent": "executor:a9a231d", - "detail": "completed", - "sourceKey": "session-stop:a9a231d40fd5a150b" - } - ] - }, - { - "id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-25T04:59:24.101Z", - "updatedAt": "2026-03-25T05:06:35.487Z", - "status": "done", - "workerCount": 7, - "taskCounts": { - "total": 7, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 7, - "failed": 0 - }, - "agents": [ - { - "name": "executor:a32b34c", - "role": "executor", - "ownership": "a32b34c341b854da5", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:18.081Z" - }, - { - "name": "executor:ad2c89c", - "role": "executor", - "ownership": "ad2c89cf14936ea42", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:02:45.524Z" - }, - { - "name": "executor:a2c140c", - "role": "executor", - "ownership": "a2c140c5a5adb0719", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:05:13.388Z" - }, - { - "name": "executor:a2e5213", - "role": "executor", - "ownership": "a2e52136ea8f04385", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:03:53.163Z" - }, - { - "name": "executor:a3735bf", - "role": "executor", - "ownership": "a3735bf51a74d6fc8", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:01:33.817Z" - }, - { - "name": "executor:a77742b", - "role": "executor", - "ownership": "a77742ba65fd2451c", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:09.324Z" - }, - { - "name": "executor:a4eb932", - "role": "executor", - "ownership": "a4eb932c438b898c0", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:35.487Z" - } - ], - "timeline": [ - { - "id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z", - "at": "2026-03-25T04:59:43.650Z", - "kind": "update", - "agent": "executor:a3735bf", - "detail": "started executor:a3735bf", - "sourceKey": "session-start:a3735bf51a74d6fc8" - }, - { - "id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z", - "at": "2026-03-25T04:59:48.683Z", - "kind": "update", - "agent": "executor:a77742b", - "detail": "started executor:a77742b", - "sourceKey": "session-start:a77742ba65fd2451c" - }, - { - "id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z", - "at": "2026-03-25T04:59:53.841Z", - "kind": "update", - "agent": "executor:a4eb932", - "detail": "started executor:a4eb932", - "sourceKey": "session-start:a4eb932c438b898c0" - }, - { - "id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z", - "at": "2026-03-25T05:01:33.817Z", - "kind": "completion", - "agent": "executor:a3735bf", - "detail": "completed", - "sourceKey": "session-stop:a3735bf51a74d6fc8" - }, - { - "id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z", - "at": "2026-03-25T05:02:45.524Z", - "kind": "completion", - "agent": "executor:ad2c89c", - "detail": "completed", - "sourceKey": "session-stop:ad2c89cf14936ea42" - }, - { - "id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z", - "at": "2026-03-25T05:03:53.163Z", - "kind": "completion", - "agent": "executor:a2e5213", - "detail": "completed", - "sourceKey": "session-stop:a2e52136ea8f04385" - }, - { - "id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z", - "at": "2026-03-25T05:05:13.388Z", - "kind": "completion", - "agent": "executor:a2c140c", - "detail": "completed", - "sourceKey": "session-stop:a2c140c5a5adb0719" - }, - { - "id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z", - "at": "2026-03-25T05:06:09.324Z", - "kind": "completion", - "agent": "executor:a77742b", - "detail": "completed", - "sourceKey": "session-stop:a77742ba65fd2451c" - }, - { - "id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z", - "at": "2026-03-25T05:06:18.081Z", - "kind": "completion", - "agent": "executor:a32b34c", - "detail": "completed", - "sourceKey": "session-stop:a32b34c341b854da5" - }, - { - "id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z", - "at": "2026-03-25T05:06:35.487Z", - "kind": "completion", - "agent": "executor:a4eb932", - "detail": "completed", - "sourceKey": "session-stop:a4eb932c438b898c0" - } - ] - } - ] -} \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json deleted file mode 100644 index 355a60d1..00000000 --- a/frontend/.omc/state/subagent-tracking.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "agents": [ - { - "agent_id": "ad233db7fa6f059dd", - "agent_type": "Explore", - "started_at": "2026-03-25T00:33:45.197Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:34:44.932Z", - "duration_ms": 59735 - }, - { - "agent_id": "a31a0f729d328643f", - "agent_type": "Explore", - "started_at": "2026-03-25T00:33:50.981Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:35:24.588Z", - "duration_ms": 93607 - }, - { - "agent_id": "a9510b7d8ec5a1ce7", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T00:37:40.106Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:42:01.730Z", - "duration_ms": 261624 - }, - { - "agent_id": "a1c1d186f0eb6dfc1", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T00:37:56.359Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:40:12.608Z", - "duration_ms": 136249 - }, - { - "agent_id": "a9a231d40fd5a150b", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T01:35:00.232Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T01:37:19.659Z", - "duration_ms": 139427 - }, - { - "agent_id": "a32b34c341b854da5", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:24.101Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:18.081Z", - "duration_ms": 413980 - }, - { - "agent_id": "ad2c89cf14936ea42", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:28.976Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:02:45.524Z", - "duration_ms": 196548 - }, - { - "agent_id": "a2c140c5a5adb0719", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:33.860Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:05:13.388Z", - "duration_ms": 339528 - }, - { - "agent_id": "a2e52136ea8f04385", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:39.166Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:03:53.163Z", - "duration_ms": 253997 - }, - { - "agent_id": "a3735bf51a74d6fc8", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:43.650Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:01:33.817Z", - "duration_ms": 110167 - }, - { - "agent_id": "a77742ba65fd2451c", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:48.683Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:09.324Z", - "duration_ms": 380641 - }, - { - "agent_id": "a4eb932c438b898c0", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:53.841Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:35.487Z", - "duration_ms": 401646 - } - ], - "total_spawned": 12, - "total_completed": 12, - "total_failed": 0, - "last_updated": "2026-03-25T05:06:35.589Z" -} \ No newline at end of file diff --git a/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx new file mode 100644 index 00000000..8879ba8a --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx @@ -0,0 +1,1655 @@ +"use client"; + +import React, { useState, useMemo, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, + RotateCcw, + Plus, + Save, + ClipboardList, + Inbox, + Pencil, + FileText, + XCircle, + ArrowRight, + Paperclip, + Upload, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getDesignRequestList, + createDesignRequest, + updateDesignRequest, + addRequestHistory, + getEcnList, + createEcn, + updateEcn, +} from "@/lib/api/design"; + +// --- Types --- +type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; +type EcrStatus = "요청접수" | "영향도분석" | "ECN발행" | "기각"; +type EcnStatus = "ECN발행" | "도면변경" | "통보완료" | "적용완료"; +type TabType = "ecr" | "ecn"; + +interface EcrHistory { + status: string; + date: string; + user: string; + desc: string; +} + +interface EcrItem { + id: string; + _id?: string; + date: string; + changeType: ChangeType; + urgency: "보통" | "긴급"; + status: EcrStatus; + target: string; + drawingNo: string; + reqDept: string; + requester: string; + reason: string; + content: string; + impact: string[]; + applyTiming: string; + ecnNo: string; + history: EcrHistory[]; +} + +interface EcnItem { + id: string; + _id?: string; + ecrNo: string; + ecrId?: string; + date: string; + applyDate: string; + status: EcnStatus; + target: string; + drawingBefore: string; + drawingAfter: string; + designer: string; + before: string; + after: string; + reason: string; + notifyDepts: string[]; + remark: string; + history: EcrHistory[]; +} + +// --- Style Helpers --- +const getChangeTypeStyle = (type: ChangeType) => { + switch (type) { + case "설계오류": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "원가절감": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "고객요청": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정개선": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "법규대응": + return "bg-purple-100 text-purple-800 border-purple-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcrStatusStyle = (status: EcrStatus) => { + switch (status) { + case "요청접수": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "영향도분석": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "ECN발행": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "기각": + return "bg-slate-100 text-slate-800 border-slate-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcnStatusStyle = (status: EcnStatus) => { + switch (status) { + case "ECN발행": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "도면변경": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "통보완료": + return "bg-teal-100 text-teal-800 border-teal-200"; + case "적용완료": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getImpactBadgeStyle = (impact: string) => { + switch (impact) { + case "BOM": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "금형": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "검사기준": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "구매": + case "원가": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +// --- Constants --- +const CHANGE_TYPES: ChangeType[] = ["설계오류", "원가절감", "고객요청", "공정개선", "법규대응"]; +const ECR_STATUSES: EcrStatus[] = ["요청접수", "영향도분석", "ECN발행", "기각"]; +const ECN_STATUSES: EcnStatus[] = ["ECN발행", "도면변경", "통보완료", "적용완료"]; +const DEPARTMENTS = ["품질팀", "생산팀", "영업팀", "구매팀", "설계팀"]; +const DESIGNERS = ["이설계", "박도면", "최기구", "김전장"]; +const IMPACT_OPTIONS = [ + { key: "BOM", label: "BOM 변경" }, + { key: "공정", label: "공정 변경" }, + { key: "금형", label: "금형 변경" }, + { key: "검사기준", label: "검사기준 변경" }, + { key: "구매", label: "구매 변경" }, + { key: "원가", label: "원가 영향" }, +]; +const NOTIFY_DEPTS = [ + { key: "생산팀", label: "생산팀" }, + { key: "품질팀", label: "품질팀" }, + { key: "구매팀", label: "구매팀" }, + { key: "영업팀", label: "영업팀" }, + { key: "물류팀", label: "물류팀" }, + { key: "금형팀", label: "금형팀" }, +]; + +// --- API Response Mapping --- +function mapEcrFromApi(raw: any): EcrItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.step || h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + return { + id: raw.request_no || raw.id || "", + _id: raw.id, + date: raw.request_date || "", + changeType: (raw.change_type as ChangeType) || "설계오류", + urgency: (raw.urgency as "보통" | "긴급") || "보통", + status: (raw.status as EcrStatus) || "요청접수", + target: raw.target_name || "", + drawingNo: raw.drawing_no || "", + reqDept: raw.req_dept || "", + requester: raw.requester || "", + reason: raw.reason || "", + content: raw.content || "", + impact: Array.isArray(raw.impact) ? raw.impact : [], + applyTiming: raw.apply_timing || "", + ecnNo: raw.ecn_no || "", + history, + }; +} + +function mapEcnFromApi(raw: any, ecrData: EcrItem[]): EcnItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + const ecrNo = raw.ecr_id + ? ecrData.find((e) => e._id === raw.ecr_id)?.id ?? raw.ecr_id + : ""; + return { + id: raw.ecn_no || raw.id || "", + _id: raw.id, + ecrNo, + ecrId: raw.ecr_id, + date: raw.ecn_date || "", + applyDate: raw.apply_date || "", + status: (raw.status as EcnStatus) || "ECN발행", + target: raw.target || "", + drawingBefore: raw.drawing_before || "", + drawingAfter: raw.drawing_after || "", + designer: raw.designer || "", + before: raw.before_content || "", + after: raw.after_content || "", + reason: raw.reason || "", + notifyDepts: Array.isArray(raw.notify_depts) ? raw.notify_depts : [], + remark: raw.remark || "", + history, + }; +} + +// --- Timeline Component --- +function Timeline({ history }: { history: EcrHistory[] }) { + return ( +
+ {history.map((h, idx) => { + const isLast = idx === history.length - 1; + const isRejected = h.status === "기각"; + const isCompleted = h.status === "적용완료"; + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+
+ + {h.status} + +
+

{h.desc}

+

+ {h.date} · {h.user} +

+
+
+ ); + })} +
+ ); +} + +// --- Main Component --- +export default function DesignChangeManagementPage() { + const [currentTab, setCurrentTab] = useState("ecr"); + const [ecrData, setEcrData] = useState([]); + const [ecnData, setEcnData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + + // 검색 상태 + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchChangeType, setSearchChangeType] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + + // ECR 모달 + const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); + const [isEcrEditMode, setIsEcrEditMode] = useState(false); + const [ecrForm, setEcrForm] = useState>({}); + const [ecrImpactChecks, setEcrImpactChecks] = useState>({}); + + // ECN 모달 + const [isEcnModalOpen, setIsEcnModalOpen] = useState(false); + const [ecnForm, setEcnForm] = useState>({}); + const [ecnNotifyChecks, setEcnNotifyChecks] = useState>({}); + + // 기각 모달 + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [rejectTargetId, setRejectTargetId] = useState(""); + + useEffect(() => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [ecrRes, ecnRes] = await Promise.all([ + getDesignRequestList({ source_type: "ecr" }), + getEcnList(), + ]); + if (ecrRes.success && ecrRes.data) { + setEcrData((ecrRes.data as any[]).map(mapEcrFromApi)); + } + if (ecnRes.success && ecnRes.data) { + const ecrList = ecrRes.success && ecrRes.data ? (ecrRes.data as any[]).map(mapEcrFromApi) : []; + setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // --- Filtered Data --- + const filteredEcr = useMemo(() => { + return ecrData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchChangeType !== "all" && item.changeType !== searchChangeType) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.requester, item.drawingNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecrData, searchDateFrom, searchDateTo, searchStatus, searchChangeType, searchKeyword]); + + const filteredEcn = useMemo(() => { + return ecnData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.designer, item.ecrNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecnData, searchDateFrom, searchDateTo, searchStatus, searchKeyword]); + + // --- Status Counts --- + const ecrStatusCounts = useMemo(() => { + const counts: Record = {}; + ECR_STATUSES.forEach((s) => (counts[s] = ecrData.filter((r) => r.status === s).length)); + return counts; + }, [ecrData]); + + const ecnStatusCounts = useMemo(() => { + const counts: Record = {}; + ECN_STATUSES.forEach((s) => (counts[s] = ecnData.filter((r) => r.status === s).length)); + return counts; + }, [ecnData]); + + // --- Selected Items --- + const selectedEcr = useMemo( + () => (currentTab === "ecr" ? ecrData.find((r) => r.id === selectedId) : null), + [ecrData, selectedId, currentTab] + ); + const selectedEcn = useMemo( + () => (currentTab === "ecn" ? ecnData.find((r) => r.id === selectedId) : null), + [ecnData, selectedId, currentTab] + ); + + // --- Tab Switch --- + const handleTabSwitch = (tab: TabType) => { + setCurrentTab(tab); + setSelectedId(null); + setSearchStatus("all"); + }; + + // --- Search --- + const handleResetSearch = () => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + setSearchStatus("all"); + setSearchChangeType("all"); + setSearchKeyword(""); + }; + + const handleFilterByStatus = (status: string) => { + setSearchStatus(status); + }; + + // --- ECR/ECN Navigation --- + const navigateToLink = (targetId: string) => { + if (targetId.startsWith("ECN")) { + setCurrentTab("ecn"); + setSelectedId(targetId); + setSearchStatus("all"); + } else if (targetId.startsWith("ECR")) { + setCurrentTab("ecr"); + setSelectedId(targetId); + setSearchStatus("all"); + } + }; + + // --- ECR Number Generator --- + const generateEcrNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECR-${year}-`; + const existing = ecrData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecrData]); + + const generateEcnNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECN-${year}-`; + const existing = ecnData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecnData]); + + // --- ECR Modal --- + const openEcrRegisterModal = () => { + setIsEcrEditMode(false); + setEcrForm({ + id: generateEcrNo(), + date: new Date().toISOString().split("T")[0], + changeType: undefined, + urgency: "보통", + target: "", + drawingNo: "", + reqDept: "", + requester: "", + reason: "", + content: "", + applyTiming: "즉시", + }); + setEcrImpactChecks({}); + setIsEcrModalOpen(true); + }; + + const openEcrEditModal = (id: string) => { + const item = ecrData.find((r) => r.id === id); + if (!item) return; + setIsEcrEditMode(true); + setEcrForm({ ...item }); + const checks: Record = {}; + IMPACT_OPTIONS.forEach((opt) => { + checks[opt.key] = item.impact.includes(opt.key); + }); + setEcrImpactChecks(checks); + setIsEcrModalOpen(true); + }; + + const handleSaveEcr = async () => { + if (!ecrForm.changeType) { + toast.error("변경 유형을 선택하세요."); + return; + } + if (!ecrForm.target?.trim()) { + toast.error("대상 품목/설비를 입력하세요."); + return; + } + if (!ecrForm.reason?.trim()) { + toast.error("변경 사유를 입력하세요."); + return; + } + if (!ecrForm.content?.trim()) { + toast.error("변경 요구 내용을 입력하세요."); + return; + } + + const impact = IMPACT_OPTIONS.filter((opt) => ecrImpactChecks[opt.key]).map((opt) => opt.key); + const reqDate = ecrForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + step: "요청접수", + history_date: reqDate, + user_name: ecrForm.requester || "시스템", + description: `${ecrForm.reqDept || ""}에서 ECR 등록`, + }; + + if (isEcrEditMode && ecrForm._id) { + const res = await updateDesignRequest(ecrForm._id, { + request_no: ecrForm.id, + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + }); + if (res.success) { + toast.success("ECR이 수정되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 수정에 실패했습니다."); + } + } else { + const res = await createDesignRequest({ + request_no: ecrForm.id || generateEcrNo(), + source_type: "ecr", + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + status: "요청접수", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + history: [historyEntry], + }); + if (res.success) { + toast.success("ECR이 등록되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 등록에 실패했습니다."); + } + } + }; + + // --- ECN Modal --- + const openEcnIssueModal = (ecrId: string) => { + const ecr = ecrData.find((r) => r.id === ecrId); + if (!ecr) return; + + setEcnForm({ + id: generateEcnNo(), + ecrNo: ecrId, + ecrId: ecr._id, + date: new Date().toISOString().split("T")[0], + target: ecr.target, + reason: ecr.reason, + drawingBefore: ecr.drawingNo, + drawingAfter: "", + designer: "", + before: "", + after: "", + applyDate: "", + remark: "", + }); + setEcnNotifyChecks({}); + setIsEcnModalOpen(true); + }; + + const handleSaveEcn = async () => { + if (!ecnForm.after?.trim()) { + toast.error("변경 후(TO-BE) 내용을 입력하세요."); + return; + } + if (!ecnForm.applyDate) { + toast.error("적용일자를 입력하세요."); + return; + } + if (!ecnForm.ecrId) { + toast.error("관련 ECR 정보가 없습니다."); + return; + } + + const notifyDepts = NOTIFY_DEPTS.filter((d) => ecnNotifyChecks[d.key]).map((d) => d.key); + const ecnDate = ecnForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + status: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: "ECN 발행", + }; + + const ecnNo = ecnForm.id || generateEcnNo(); + const res = await createEcn({ + ecn_no: ecnNo, + ecr_id: ecnForm.ecrId, + ecn_date: ecnDate, + apply_date: ecnForm.applyDate, + status: "ECN발행", + target: ecnForm.target || "", + drawing_before: ecnForm.drawingBefore || "", + drawing_after: ecnForm.drawingAfter || "(미정)", + designer: ecnForm.designer || "", + before_content: ecnForm.before || "", + after_content: ecnForm.after || "", + reason: ecnForm.reason || "", + remark: ecnForm.remark || "", + notify_depts: notifyDepts, + history: [historyEntry], + }); + + if (res.success) { + await updateDesignRequest(ecnForm.ecrId!, { + status: "ECN발행", + ecn_no: ecnNo, + }); + await addRequestHistory(ecnForm.ecrId!, { + step: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: `${ecnNo} 발행`, + }); + toast.success("ECN이 발행되었습니다."); + setIsEcnModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECN 발행에 실패했습니다."); + } + }; + + // --- ECR Reject --- + const openRejectModal = (id: string) => { + setRejectTargetId(id); + setRejectReason(""); + setIsRejectModalOpen(true); + }; + + const handleRejectSubmit = async () => { + if (!rejectReason.trim()) { + toast.error("기각 사유를 입력하세요."); + return; + } + + const ecr = ecrData.find((r) => r.id === rejectTargetId); + if (!ecr?._id) { + toast.error("ECR 정보를 찾을 수 없습니다."); + return; + } + + const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); + if (!updateRes.success) { + toast.error(updateRes.message || "ECR 기각에 실패했습니다."); + return; + } + await addRequestHistory(ecr._id, { + step: "기각", + history_date: new Date().toISOString().split("T")[0], + user_name: "설계팀", + description: rejectReason, + }); + toast.success("ECR이 기각되었습니다."); + setIsRejectModalOpen(false); + fetchData(); + }; + + // --- Stat Cards --- + const ecrStatCards = [ + { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, gradient: "from-indigo-500 to-blue-600", textColor: "text-white" }, + { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, gradient: "from-amber-400 to-orange-500", textColor: "text-white" }, + { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const ecnStatCards = [ + { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, gradient: "from-purple-400 to-violet-600", textColor: "text-white" }, + { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, gradient: "from-teal-400 to-cyan-600", textColor: "text-white" }, + { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; + const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; + const currentStatuses = currentTab === "ecr" ? ECR_STATUSES : ECN_STATUSES; + + return ( +
+ {loading && ( +
+ +
+ )} + {/* 검색 섹션 */} + + +
+ +
+ setSearchDateFrom(e.target.value)} + /> + ~ + setSearchDateTo(e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + +
+ + {currentTab === "ecr" && ( +
+ + +
+ )} + +
+ + setSearchKeyword(e.target.value)} + /> +
+ +
+ +
+ +
+ + + + {/* 메인 분할 레이아웃 */} +
+ + {/* 왼쪽: 목록 */} + +
+
+
+ + {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} + + {currentList.length}건 + +
+ {currentTab === "ecr" && ( + + )} +
+ +
+ {currentTab === "ecr" ? ( + + + + No + ECR번호 + 변경유형 + 상태 + 긴급 + 대상 품목/설비 + 도면번호 + 요청부서 + 요청자 + 요청일자 + 관련 ECN + + + + {filteredEcr.length === 0 ? ( + + +
+ + 조건에 맞는 ECR이 없습니다 +
+
+
+ ) : ( + filteredEcr.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.changeType} + + + + + {item.status} + + + + {item.urgency === "긴급" ? ( + + 긴급 + + ) : ( + "-" + )} + + {item.target} + {item.drawingNo} + {item.reqDept} + {item.requester} + {item.date} + + {item.ecnNo ? ( + + ) : ( + "-" + )} + + + )) + )} +
+
+ ) : ( + + + + No + ECN번호 + 상태 + 대상 품목/설비 + 도면 (변경 후) + 설계담당 + 발행일자 + 적용일자 + 통보 부서 + 관련 ECR + + + + {filteredEcn.length === 0 ? ( + + +
+ + 조건에 맞는 ECN이 없습니다 +
+
+
+ ) : ( + filteredEcn.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.status} + + + {item.target} + {item.drawingAfter} + {item.designer} + {item.date} + {item.applyDate} + {item.notifyDepts.join(", ")} + + + + + )) + )} +
+
+ )} +
+
+
+ + + + {/* 오른쪽: 상세 */} + +
+
+ + + 상세 정보 + + {selectedEcr && ( +
+ + {selectedEcr.status === "영향도분석" && ( + <> + + + + )} +
+ )} +
+ +
+ {/* 현황 카드 */} +
+ {currentStatCards.map((card) => ( + + ))} +
+ + {/* ECR 상세 */} + {selectedEcr ? ( +
+
+

+ 기본 정보 +

+
+
+ ECR번호 + {selectedEcr.id} +
+
+ 상태 + + {selectedEcr.status} + +
+
+ 변경 유형 + + {selectedEcr.changeType} + +
+
+ 긴급도 + + {selectedEcr.urgency === "긴급" ? ( + 긴급 + ) : ( + "보통" + )} + +
+
+ 대상 품목/설비 + {selectedEcr.target} +
+
+ 도면번호 + {selectedEcr.drawingNo} +
+
+ 요청부서 / 요청자 + {selectedEcr.reqDept} / {selectedEcr.requester} +
+
+ 요청일자 + {selectedEcr.date} +
+
+ 희망 적용시점 + {selectedEcr.applyTiming} +
+
+ 관련 ECN + {selectedEcr.ecnNo ? ( + + ) : ( + 미발행 + )} +
+
+
+ +
+

변경 사유

+
+ {selectedEcr.reason} +
+
+ +
+

변경 요구 내용

+
+ {selectedEcr.content} +
+
+ +
+

영향 범위

+
+ {selectedEcr.impact.map((imp) => ( + + {imp} + + ))} +
+
+ +
+

처리 이력

+ +
+
+ ) : selectedEcn ? ( +
+
+

+ ECN 기본 정보 +

+
+
+ ECN번호 + {selectedEcn.id} +
+
+ 상태 + + {selectedEcn.status} + +
+
+ 대상 품목/설비 + {selectedEcn.target} +
+
+ 설계담당 + {selectedEcn.designer} +
+
+ 발행일자 + {selectedEcn.date} +
+
+ 적용일자 + {selectedEcn.applyDate} +
+
+ 관련 ECR + +
+
+ 통보 부서 + {selectedEcn.notifyDepts.join(", ")} +
+
+
+ +
+

변경 전/후 비교

+
+
+
+ 변경 전 ({selectedEcn.drawingBefore}) +
+
{selectedEcn.before}
+
+
+
+ 변경 후 ({selectedEcn.drawingAfter}) +
+
{selectedEcn.after}
+
+
+
+ +
+

변경 사유

+
+ {selectedEcn.reason} +
+ {selectedEcn.remark && ( +

비고: {selectedEcn.remark}

+ )} +
+ +
+

처리 이력

+ +
+
+ ) : ( +
+
+ +
+

좌측 목록에서 항목을 선택하세요

+
+ )} +
+
+
+
+
+ + {/* ECR 등록/수정 모달 */} + + + + + {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} + + + {isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."} + + + +
+
+ {/* 좌측: 요청 정보 */} +
+

변경 요청 정보

+ +
+ + +
+ +
+
+ + setEcrForm((p) => ({ ...p, date: e.target.value }))} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + setEcrForm((p) => ({ ...p, target: e.target.value }))} + placeholder="품목코드 / 설비명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} + placeholder="DWG-XXX-XXX" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+ + +
+
+ + setEcrForm((p) => ({ ...p, requester: e.target.value }))} + placeholder="요청자명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + {/* 우측: 변경 내용 */} +
+
+

변경 내용

+ +
+ +