diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 06920113..495db410 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.7.1", + "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", @@ -20,9 +21,12 @@ "helmet": "^7.1.0", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", + "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", + "node-cron": "^4.2.1", "nodemailer": "^6.9.7", + "oracledb": "^6.9.0", "pg": "^8.16.3", "prisma": "^5.7.1", "redis": "^4.6.10", @@ -41,6 +45,7 @@ "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", @@ -731,6 +736,273 @@ "node": ">=18.0.0" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", + "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", + "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.1.tgz", + "integrity": "sha512-UVZlVLfLyz6g3Hy7GNDpooMQonUygH7ghdiSASOOHy97fKj/mPLqgDX7aidOijn+sCMU+WU8NjlPlNTgnvbcGA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.12.0.tgz", + "integrity": "sha512-6vuh2R3Cte6SD6azNalLCjIDoryGdcvDVEV7IDRPtm5lHX5ffkDlIalaoOp5YJU08e4ipjJENel20kSMDLAcug==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz", + "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz", + "integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.0.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.23.0.tgz", + "integrity": "sha512-uHnfRwGAEHaYVXzpCtYsruy6PQxL2v76+MJ3+n/c/3PaTiTIa5ch7VofTUNoA39nHyjJbdiqTwFZK40OOTOkjw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.12.0.tgz", + "integrity": "sha512-4ucXbjVw8KJ5QBgnGJUeA07c8iznwlk5ioHIhI4ASXcXgcf2yRFhWzYOyWg/cI49LC9ekpFJeQtO3zjDTbl6TQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.7.4.tgz", + "integrity": "sha512-fjqvhrThwzzPvqhFOdkkGRJCHPQZTNijpceVy8QjcfQuH482tOVEjHyamZaioOhVtx+FK1u+eMpJA2Zz4U9LVg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1903,6 +2175,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-joda/core": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.6.5.tgz", + "integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==", + "license": "BSD-3-Clause" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2764,6 +3042,12 @@ "node": ">=18.0.0" } }, + "node_modules/@tediousjs/connection-string": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", + "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -3044,6 +3328,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mssql": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz", + "integrity": "sha512-mt9h5jWj+DYE5jxnKaWSV/GqDf9FV52XYVk6T3XZF69noEe+JJV6MKirii48l81+cjmAkSq+qeKX+k61fHkYrQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tarn": "^3.0.1", + "tedious": "*" + } + }, "node_modules/@types/multer": { "version": "1.4.13", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", @@ -3058,7 +3353,6 @@ "version": "20.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3082,6 +3376,16 @@ "@types/node": "*" } }, + "node_modules/@types/oracledb": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.9.1.tgz", + "integrity": "sha512-rXDnApyfaki0dvHuqzQvfirK6yHbtEO5nJ4CXKHrZYdwNAx4PjddqoCXdN1dZaEnZxXFwCy9xEWyIemL8EI/NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", @@ -3108,6 +3412,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.21.tgz", + "integrity": "sha512-19eKVv9tugr03IgfXlA9UVUVRbW6IuqRO5B92Dl4a6pT7K8uaGrNS0GkxiZD0BOk6PLuXl5FhWl//eX/pzYdTQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sanitize-html": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz", @@ -3407,6 +3720,20 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz", + "integrity": "sha512-SnbaqayTVFEA6/tYumdF0UmybY0KHyKwGPBXnyckFlrrKdhWFrL3a2HIPXHjht5ZOElKGcXfD2D63P36btb+ww==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3414,6 +3741,18 @@ "dev": true, "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3472,6 +3811,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3766,6 +4114,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -3785,6 +4153,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.3.tgz", + "integrity": "sha512-nHB8B5roHlGX5TFsWeiQJijdddZIOHuv1eL2cM2kHnG3qR91CYLsysGe+CvxQfEd23EKD0eJf4lto0frTbddKA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3910,6 +4315,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3922,6 +4351,21 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4218,6 +4662,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4412,7 +4865,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4458,6 +4910,46 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4999,6 +5491,24 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5775,6 +6285,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5797,6 +6333,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5924,6 +6480,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5967,6 +6538,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6005,6 +6594,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6685,6 +7289,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7174,6 +7784,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mssql": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz", + "integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.5.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "rfdc": "^1.3.0", + "tarn": "^3.0.2", + "tedious": "^18.2.1" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/multer": { "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", @@ -7250,6 +7880,12 @@ "node": ">=12" } }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7273,6 +7909,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7472,6 +8117,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7490,6 +8153,16 @@ "node": ">= 0.8.0" } }, + "node_modules/oracledb": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.9.0.tgz", + "integrity": "sha512-NwPbIGPv6m0GTFSbyy4/5WEjsKMiiJRxztLmYUcfD3oyh/uXdmVmKOwEWr84wFwWJ/0wQrYQh4PjnzvShibRaA==", + "hasInstallScript": true, + "license": "(Apache-2.0 OR UPL-1.0)", + "engines": { + "node": ">=14.17" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7926,6 +8599,15 @@ "fsevents": "2.3.3" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8202,6 +8884,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -8219,6 +8907,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8786,6 +9486,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.1.tgz", + "integrity": "sha512-9AvErXXQTd6l7TDd5EmM+nxbOGyhnmdbp/8c3pw+tjaiSXW9usME90ET/CRG1LN1Y9tPMtz/p83z4Q97B4DDpw==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.1", + "@types/node": ">=18", + "bl": "^6.0.11", + "iconv-lite": "^0.6.3", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tedious/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9013,7 +9761,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -9110,7 +9857,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -9369,6 +10115,21 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 7c7e9fb8..8cfa8cf7 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -28,6 +28,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.7.1", + "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", @@ -38,9 +39,12 @@ "helmet": "^7.1.0", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", + "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", + "node-cron": "^4.2.1", "nodemailer": "^6.9.7", + "oracledb": "^6.9.0", "pg": "^8.16.3", "prisma": "^5.7.1", "redis": "^4.6.10", @@ -59,6 +63,7 @@ "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index d7c9b38d..2ba11203 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -27,16 +27,12 @@ model external_call_configs { api_type String? @db.VarChar(20) config_data Json description String? - company_code String @default("*") @db.VarChar(20) is_active String? @default("Y") @db.Char(1) - created_date DateTime? @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) updated_by String? @db.VarChar(50) - - @@index([is_active], map: "idx_external_call_configs_active") - @@index([company_code], map: "idx_external_call_configs_company") - @@index([call_type, api_type], map: "idx_external_call_configs_type") + company_code String @default("*") @db.VarChar(20) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) } model external_db_connections { @@ -62,6 +58,9 @@ model external_db_connections { updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) updated_by String? @db.VarChar(50) + // 관계 + collection_configs data_collection_configs[] + @@index([connection_name], map: "idx_external_db_connections_name") } @@ -3968,9 +3967,6 @@ model table_relationships { updated_date DateTime? @db.Timestamp(6) updated_by String? @db.VarChar(50) diagram_id Int? - - // 역방향 관계 - bridges data_relationship_bridge[] } model data_relationship_bridge { @@ -3993,9 +3989,6 @@ model data_relationship_bridge { to_key_value String? @db.VarChar(500) to_record_id String? @db.VarChar(100) - // 관계 정의 - relationship table_relationships? @relation(fields: [relationship_id], references: [relationship_id]) - @@index([connection_type], map: "idx_data_bridge_connection_type") @@index([company_code, is_active], map: "idx_data_bridge_company_active") } @@ -4088,55 +4081,434 @@ model table_relationships_backup { } model test_sales_info { - sales_no String @id @db.VarChar(20) - contract_type String? @db.VarChar(50) - order_seq Int? - domestic_foreign String? @db.VarChar(20) - customer_name String? @db.VarChar(200) - product_type String? @db.VarChar(100) - machine_type String? @db.VarChar(100) - customer_project_name String? @db.VarChar(200) - expected_delivery_date DateTime? @db.Date - receiving_location String? @db.VarChar(200) - setup_location String? @db.VarChar(200) - equipment_direction String? @db.VarChar(100) - equipment_count Int? @default(0) - equipment_type String? @db.VarChar(100) - equipment_length Decimal? @db.Decimal(10,2) - manager_name String? @db.VarChar(100) - reg_date DateTime? @default(now()) @db.Timestamp(6) - status String? @default("진행중") @db.VarChar(50) - - // 관계 정의: 영업 정보에서 프로젝트로 - projects test_project_info[] + sales_no String @id(map: "pk_test_sales_info") @db.VarChar(200) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + product_type String? @db.VarChar(100) + machine_type String? @db.VarChar(100) + customer_project_name String? @db.VarChar(200) + expected_delivery_date DateTime? @db.Date + receiving_location String? @db.VarChar(200) + setup_location String? @db.VarChar(200) + equipment_direction String? @db.VarChar(100) + equipment_count Int? @default(0) + equipment_type String? @db.VarChar(100) + equipment_length Decimal? @db.Decimal(10, 2) + manager_name String? @db.VarChar(100) + reg_date DateTime? @default(now()) @db.Timestamp(6) + status String? @default("진행중") @db.VarChar(50) } model test_project_info { - project_no String @id @db.VarChar(200) - sales_no String? @db.VarChar(20) - contract_type String? @db.VarChar(50) - order_seq Int? - domestic_foreign String? @db.VarChar(20) - customer_name String? @db.VarChar(200) - - // 프로젝트 전용 컬럼들 - project_status String? @default("PLANNING") @db.VarChar(50) - project_start_date DateTime? @db.Date - project_end_date DateTime? @db.Date - project_manager String? @db.VarChar(100) - project_description String? @db.Text - - // 시스템 관리 컬럼들 - created_by String? @db.VarChar(100) - created_date DateTime? @default(now()) @db.Timestamp(6) - updated_by String? @db.VarChar(100) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) - - // 관계 정의: 영업 정보 참조 - sales test_sales_info? @relation(fields: [sales_no], references: [sales_no]) - + project_no String @id @db.VarChar(200) + sales_no String? @db.VarChar(20) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + project_status String? @default("PLANNING") @db.VarChar(50) + project_start_date DateTime? @db.Date + project_end_date DateTime? @db.Date + project_manager String? @db.VarChar(100) + project_description String? + created_by String? @db.VarChar(100) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(100) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + @@index([sales_no], map: "idx_project_sales_no") @@index([project_status], map: "idx_project_status") @@index([customer_name], map: "idx_project_customer") @@index([project_manager], map: "idx_project_manager") } + +model batch_jobs { + id Int @id @default(autoincrement()) + job_name String @db.VarChar(100) + job_type String @db.VarChar(20) + description String? + created_by String? @db.VarChar(50) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + config_json Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + execution_count Int @default(0) + failure_count Int @default(0) + last_executed_at DateTime? @db.Timestamp(6) + next_execution_at DateTime? @db.Timestamp(6) + schedule_cron String? @db.VarChar(100) + success_count Int @default(0) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + is_active String @default("Y") @db.Char(1) + + @@index([job_type], map: "idx_batch_jobs_type") + @@index([company_code], map: "idx_batch_jobs_company_code") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model batch_job_executions { + id Int @id @default(autoincrement()) + job_id Int + execution_id String @unique @db.VarChar(100) + start_time DateTime @db.Timestamp(6) + end_time DateTime? @db.Timestamp(6) + status String @default("STARTED") @db.VarChar(20) + exit_code Int? + exit_message String? + parameters Json? + logs String? + created_at DateTime? @default(now()) @db.Timestamp(6) + + @@index([execution_id], map: "idx_batch_executions_execution_id") + @@index([job_id], map: "idx_batch_executions_job_id") + @@index([start_time], map: "idx_batch_executions_start_time") + @@index([status], map: "idx_batch_executions_status") +} + +model batch_job_parameters { + id Int @id @default(autoincrement()) + job_id Int + parameter_name String @db.VarChar(100) + parameter_value String? + parameter_type String? @default("STRING") @db.VarChar(50) + is_required Boolean? @default(false) + description String? + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @db.Timestamp(6) + + @@unique([job_id, parameter_name]) + @@index([job_id], map: "idx_batch_parameters_job_id") +} + +model batch_schedules { + id Int @id @default(autoincrement()) + job_id Int + schedule_name String @db.VarChar(255) + cron_expression String @db.VarChar(100) + timezone String? @default("Asia/Seoul") @db.VarChar(50) + is_active Boolean? @default(true) + start_date DateTime? @db.Date + end_date DateTime? @db.Date + created_by String @db.VarChar(100) + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(100) + updated_at DateTime? @db.Timestamp(6) + + @@index([is_active], map: "idx_batch_schedules_active") + @@index([job_id], map: "idx_batch_schedules_job_id") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model dataflow_external_calls { + id Int @id @default(autoincrement()) + diagram_id Int + source_table String @db.VarChar(100) + trigger_condition Json + external_call_config_id Int + message_template String? + is_active String? @default("Y") @db.Char(1) + created_by Int? + updated_by Int? + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @db.Timestamp(6) +} + +model ddl_execution_log { + id Int @id @default(autoincrement()) + user_id String @db.VarChar(100) + company_code String @db.VarChar(50) + ddl_type String @db.VarChar(50) + table_name String @db.VarChar(100) + ddl_query String + success Boolean + error_message String? + executed_at DateTime? @default(now()) @db.Timestamp(6) +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model external_call_logs { + id Int @id @default(autoincrement()) + dataflow_external_call_id Int? + external_call_config_id Int + trigger_data Json? + request_data Json? + response_data Json? + status String @db.VarChar(20) + error_message String? + execution_time Int? + executed_at DateTime? @default(now()) @db.Timestamp(6) + + @@index([executed_at], map: "idx_external_call_logs_executed") +} + +model my_custom_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + customer_name String? @db.VarChar + email_address String? @db.VarChar(255) +} + +model table_type_columns { + id Int @id @default(autoincrement()) + table_name String @db.VarChar(255) + column_name String @db.VarChar(255) + input_type String @default("text") @db.VarChar(50) + detail_settings String? @default("{}") + is_nullable String? @default("Y") @db.VarChar(10) + display_order Int? @default(0) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + + @@unique([table_name, column_name]) + @@index([input_type], map: "idx_table_type_columns_input_type") + @@index([table_name], map: "idx_table_type_columns_table_name") +} + +model test_api_integration_1758589777139 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + category String? @db.VarChar(500) +} + +model test_new_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + name String? @db.VarChar + email String? @db.VarChar(255) + user_test_column String? @db.VarChar + dsfsdf123215 String? @db.VarChar + aaaassda String? @db.VarChar +} + +model test_new_table33333 { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + writer String? @db.VarChar(100) + company_code String? @default("*") @db.VarChar(50) + eeeeeeee String? @db.VarChar(500) + wwww String? @db.VarChar(500) + sssss String? @db.VarChar(500) +} + +model test_new_table44444 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @db.VarChar(500) + ttttttt String? @db.VarChar(500) + yyyyyyy String? @db.VarChar(500) + uuuuuuu String? @db.VarChar(500) + iiiiiii String? @db.VarChar(500) +} + +model test_new_table555555 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @db.VarChar(500) + rtrtrtrtr String? @db.VarChar(500) + ererwewewe String? @db.VarChar(500) + wetyeryrtyut String? @db.VarChar(500) + werwqq String? @db.VarChar(500) + saved_file_name String? @db.VarChar(500) +} + +model test_table_info { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + objid Int + test_name String? @db.VarChar(250) + ggggggggggg String? @db.VarChar + test_column_1 String? @db.VarChar + test_column_2 String? @db.VarChar + test_column_3 String? @db.VarChar + final_test_column String? @db.VarChar + zzzzzzz String? @db.VarChar + bbbbbbb String? @db.VarChar + realtime_test String? @db.VarChar + table_update_test String? @db.VarChar +} + +model test_table_info2222 { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + clll_cc String? @db.VarChar + eeee_eee String? @db.VarChar + saved_file_name String? @db.VarChar + debug_test_column String? @db.VarChar + field_1 String? @db.VarChar + rrrrrrrrrr String? @db.VarChar + tttttttt String? @db.VarChar +} + +model test_varchar_unified { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + launch_date String? @db.VarChar(500) + is_active String? @db.VarChar(500) +} + +model test_varchar_unified_1758588878993 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + launch_date String? @db.VarChar(500) + is_active String? @db.VarChar(500) +} + +model writer_test_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + writer String? @db.VarChar(100) + company_code String? @default("*") @db.VarChar(50) + test_field String? @db.VarChar + field_1 String? @db.VarChar +} + +// 데이터 수집 설정 테이블 +model data_collection_configs { + id Int @id @default(autoincrement()) + config_name String @db.VarChar(100) + description String? + source_connection_id Int + source_table String @db.VarChar(100) + target_table String? @db.VarChar(100) + collection_type String @db.VarChar(20) // full, incremental, delta + schedule_cron String? @db.VarChar(100) + is_active String @default("Y") @db.Char(1) + last_collected_at DateTime? @db.Timestamp(6) + collection_options Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + + // 관계 + collection_jobs data_collection_jobs[] + collection_history data_collection_history[] + external_connection external_db_connections @relation(fields: [source_connection_id], references: [id]) + + @@index([source_connection_id], map: "idx_data_collection_configs_connection") + @@index([is_active], map: "idx_data_collection_configs_active") + @@index([company_code], map: "idx_data_collection_configs_company") +} + +// 데이터 수집 작업 테이블 +model data_collection_jobs { + id Int @id @default(autoincrement()) + config_id Int + job_status String @db.VarChar(20) // pending, running, completed, failed + started_at DateTime? @db.Timestamp(6) + completed_at DateTime? @db.Timestamp(6) + records_processed Int? @default(0) + error_message String? + job_details Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + + // 관계 + config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade) + + @@index([config_id], map: "idx_data_collection_jobs_config") + @@index([job_status], map: "idx_data_collection_jobs_status") + @@index([created_date], map: "idx_data_collection_jobs_created") +} + +// 데이터 수집 이력 테이블 +model data_collection_history { + id Int @id @default(autoincrement()) + config_id Int + collection_date DateTime @db.Timestamp(6) + records_collected Int @default(0) + execution_time_ms Int @default(0) + status String @db.VarChar(20) // success, partial, failed + error_details String? + created_date DateTime? @default(now()) @db.Timestamp(6) + + // 관계 + config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade) + + @@index([config_id], map: "idx_data_collection_history_config") + @@index([collection_date], map: "idx_data_collection_history_date") + @@index([status], map: "idx_data_collection_history_status") +} + +// 데이터 수집 배치 관리 테이블 (기존 batch_jobs와 구분) +model collection_batch_management { + id Int @id @default(autoincrement()) + batch_name String @db.VarChar(100) + description String? + batch_type String @db.VarChar(20) // collection, sync, cleanup, custom + schedule_cron String? @db.VarChar(100) + is_active String @default("Y") @db.Char(1) + config_json Json? + last_executed_at DateTime? @db.Timestamp(6) + next_execution_at DateTime? @db.Timestamp(6) + execution_count Int @default(0) + success_count Int @default(0) + failure_count Int @default(0) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + + // 관계 + batch_executions collection_batch_executions[] + + @@index([batch_type], map: "idx_collection_batch_mgmt_type") + @@index([is_active], map: "idx_collection_batch_mgmt_active") + @@index([company_code], map: "idx_collection_batch_mgmt_company") + @@index([next_execution_at], map: "idx_collection_batch_mgmt_next_execution") +} + +// 데이터 수집 배치 실행 테이블 +model collection_batch_executions { + id Int @id @default(autoincrement()) + batch_id Int + execution_status String @db.VarChar(20) // pending, running, completed, failed, cancelled + started_at DateTime? @db.Timestamp(6) + completed_at DateTime? @db.Timestamp(6) + execution_time_ms Int? + result_data Json? + error_message String? + log_details String? + created_date DateTime? @default(now()) @db.Timestamp(6) + + // 관계 + batch collection_batch_management @relation(fields: [batch_id], references: [id], onDelete: Cascade) + + @@index([batch_id], map: "idx_collection_batch_executions_batch") + @@index([execution_status], map: "idx_collection_batch_executions_status") + @@index([created_date], map: "idx_collection_batch_executions_created") +} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 2d75b3d5..9394d3d3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -33,6 +33,8 @@ import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; import ddlRoutes from "./routes/ddlRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes"; +// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 +// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -129,6 +131,8 @@ app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); +// app.use("/api/collections", collectionRoutes); // 임시 주석 +// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts new file mode 100644 index 00000000..99b66364 --- /dev/null +++ b/backend-node/src/controllers/batchController.ts @@ -0,0 +1,294 @@ +// 배치 관리 컨트롤러 +// 작성일: 2024-12-23 + +import { Request, Response } from 'express'; +import { BatchService } from '../services/batchService'; +import { BatchJob, BatchJobFilter } from '../types/batchManagement'; +import { AuthenticatedRequest } from '../middleware/authMiddleware'; + +export class BatchController { + /** + * 배치 작업 목록 조회 + */ + static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise { + try { + const filter: BatchJobFilter = { + job_name: req.query.job_name as string, + job_type: req.query.job_type as string, + is_active: req.query.is_active as string, + company_code: req.user?.companyCode || '*', + search: req.query.search as string, + }; + + const jobs = await BatchService.getBatchJobs(filter); + + res.status(200).json({ + success: true, + data: jobs, + message: '배치 작업 목록을 조회했습니다.', + }); + } catch (error) { + console.error('배치 작업 목록 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.', + }); + } + } + + /** + * 배치 작업 상세 조회 + */ + static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const job = await BatchService.getBatchJobById(id); + if (!job) { + res.status(404).json({ + success: false, + message: '배치 작업을 찾을 수 없습니다.', + }); + return; + } + + res.status(200).json({ + success: true, + data: job, + message: '배치 작업을 조회했습니다.', + }); + } catch (error) { + console.error('배치 작업 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.', + }); + } + } + + /** + * 배치 작업 생성 + */ + static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise { + try { + const data: BatchJob = { + ...req.body, + company_code: req.user?.companyCode || '*', + created_by: req.user?.userId, + }; + + // 필수 필드 검증 + if (!data.job_name || !data.job_type) { + res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다.', + }); + return; + } + + const job = await BatchService.createBatchJob(data); + + res.status(201).json({ + success: true, + data: job, + message: '배치 작업을 생성했습니다.', + }); + } catch (error) { + console.error('배치 작업 생성 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.', + }); + } + } + + /** + * 배치 작업 수정 + */ + static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const data: Partial = { + ...req.body, + updated_by: req.user?.userId, + }; + + const job = await BatchService.updateBatchJob(id, data); + + res.status(200).json({ + success: true, + data: job, + message: '배치 작업을 수정했습니다.', + }); + } catch (error) { + console.error('배치 작업 수정 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.', + }); + } + } + + /** + * 배치 작업 삭제 + */ + static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + await BatchService.deleteBatchJob(id); + + res.status(200).json({ + success: true, + message: '배치 작업을 삭제했습니다.', + }); + } catch (error) { + console.error('배치 작업 삭제 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.', + }); + } + } + + /** + * 배치 작업 수동 실행 + */ + static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const execution = await BatchService.executeBatchJob(id); + + res.status(200).json({ + success: true, + data: execution, + message: '배치 작업을 실행했습니다.', + }); + } catch (error) { + console.error('배치 작업 실행 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.', + }); + } + } + + /** + * 배치 실행 목록 조회 + */ + static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise { + try { + const jobId = req.query.job_id ? parseInt(req.query.job_id as string) : undefined; + const executions = await BatchService.getBatchExecutions(jobId); + + res.status(200).json({ + success: true, + data: executions, + message: '배치 실행 목록을 조회했습니다.', + }); + } catch (error) { + console.error('배치 실행 목록 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.', + }); + } + } + + /** + * 배치 모니터링 정보 조회 + */ + static async getBatchMonitoring(req: AuthenticatedRequest, res: Response): Promise { + try { + const monitoring = await BatchService.getBatchMonitoring(); + + res.status(200).json({ + success: true, + data: monitoring, + message: '배치 모니터링 정보를 조회했습니다.', + }); + } catch (error) { + console.error('배치 모니터링 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '배치 모니터링 조회에 실패했습니다.', + }); + } + } + + /** + * 지원되는 작업 타입 조회 + */ + static async getSupportedJobTypes(req: AuthenticatedRequest, res: Response): Promise { + try { + const { BATCH_JOB_TYPE_OPTIONS } = await import('../types/batchManagement'); + + res.status(200).json({ + success: true, + data: { + types: BATCH_JOB_TYPE_OPTIONS, + }, + message: '지원하는 작업 타입 목록을 조회했습니다.', + }); + } catch (error) { + console.error('작업 타입 조회 오류:', error); + res.status(500).json({ + success: false, + message: '작업 타입 조회에 실패했습니다.', + }); + } + } + + /** + * 스케줄 프리셋 조회 + */ + static async getSchedulePresets(req: AuthenticatedRequest, res: Response): Promise { + try { + const { SCHEDULE_PRESETS } = await import('../types/batchManagement'); + + res.status(200).json({ + success: true, + data: { + presets: SCHEDULE_PRESETS, + }, + message: '스케줄 프리셋 목록을 조회했습니다.', + }); + } catch (error) { + console.error('스케줄 프리셋 조회 오류:', error); + res.status(500).json({ + success: false, + message: '스케줄 프리셋 조회에 실패했습니다.', + }); + } + } +} diff --git a/backend-node/src/controllers/collectionController.ts b/backend-node/src/controllers/collectionController.ts new file mode 100644 index 00000000..e40d174b --- /dev/null +++ b/backend-node/src/controllers/collectionController.ts @@ -0,0 +1,258 @@ +// 수집 관리 컨트롤러 +// 작성일: 2024-12-23 + +import { Request, Response } from 'express'; +import { CollectionService } from '../services/collectionService'; +import { DataCollectionConfig, CollectionFilter } from '../types/collectionManagement'; +import { AuthenticatedRequest } from '../middleware/authMiddleware'; + +export class CollectionController { + /** + * 수집 설정 목록 조회 + */ + static async getCollectionConfigs(req: AuthenticatedRequest, res: Response): Promise { + try { + const filter: CollectionFilter = { + config_name: req.query.config_name as string, + source_connection_id: req.query.source_connection_id ? parseInt(req.query.source_connection_id as string) : undefined, + collection_type: req.query.collection_type as string, + is_active: req.query.is_active as string, + company_code: req.user?.companyCode || '*', + search: req.query.search as string, + }; + + const configs = await CollectionService.getCollectionConfigs(filter); + + res.status(200).json({ + success: true, + data: configs, + message: '수집 설정 목록을 조회했습니다.', + }); + } catch (error) { + console.error('수집 설정 목록 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 설정 목록 조회에 실패했습니다.', + }); + } + } + + /** + * 수집 설정 상세 조회 + */ + static async getCollectionConfigById(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const config = await CollectionService.getCollectionConfigById(id); + if (!config) { + res.status(404).json({ + success: false, + message: '수집 설정을 찾을 수 없습니다.', + }); + return; + } + + res.status(200).json({ + success: true, + data: config, + message: '수집 설정을 조회했습니다.', + }); + } catch (error) { + console.error('수집 설정 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 설정 조회에 실패했습니다.', + }); + } + } + + /** + * 수집 설정 생성 + */ + static async createCollectionConfig(req: AuthenticatedRequest, res: Response): Promise { + try { + const data: DataCollectionConfig = { + ...req.body, + company_code: req.user?.companyCode || '*', + created_by: req.user?.userId, + }; + + // 필수 필드 검증 + if (!data.config_name || !data.source_connection_id || !data.source_table || !data.collection_type) { + res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다.', + }); + return; + } + + const config = await CollectionService.createCollectionConfig(data); + + res.status(201).json({ + success: true, + data: config, + message: '수집 설정을 생성했습니다.', + }); + } catch (error) { + console.error('수집 설정 생성 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 설정 생성에 실패했습니다.', + }); + } + } + + /** + * 수집 설정 수정 + */ + static async updateCollectionConfig(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const data: Partial = { + ...req.body, + updated_by: req.user?.userId, + }; + + const config = await CollectionService.updateCollectionConfig(id, data); + + res.status(200).json({ + success: true, + data: config, + message: '수집 설정을 수정했습니다.', + }); + } catch (error) { + console.error('수집 설정 수정 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 설정 수정에 실패했습니다.', + }); + } + } + + /** + * 수집 설정 삭제 + */ + static async deleteCollectionConfig(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + await CollectionService.deleteCollectionConfig(id); + + res.status(200).json({ + success: true, + message: '수집 설정을 삭제했습니다.', + }); + } catch (error) { + console.error('수집 설정 삭제 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 설정 삭제에 실패했습니다.', + }); + } + } + + /** + * 수집 작업 실행 + */ + static async executeCollection(req: AuthenticatedRequest, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 ID입니다.', + }); + return; + } + + const job = await CollectionService.executeCollection(id); + + res.status(200).json({ + success: true, + data: job, + message: '수집 작업을 시작했습니다.', + }); + } catch (error) { + console.error('수집 작업 실행 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 작업 실행에 실패했습니다.', + }); + } + } + + /** + * 수집 작업 목록 조회 + */ + static async getCollectionJobs(req: AuthenticatedRequest, res: Response): Promise { + try { + const configId = req.query.config_id ? parseInt(req.query.config_id as string) : undefined; + const jobs = await CollectionService.getCollectionJobs(configId); + + res.status(200).json({ + success: true, + data: jobs, + message: '수집 작업 목록을 조회했습니다.', + }); + } catch (error) { + console.error('수집 작업 목록 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 작업 목록 조회에 실패했습니다.', + }); + } + } + + /** + * 수집 이력 조회 + */ + static async getCollectionHistory(req: AuthenticatedRequest, res: Response): Promise { + try { + const configId = parseInt(req.params.configId); + if (isNaN(configId)) { + res.status(400).json({ + success: false, + message: '유효하지 않은 설정 ID입니다.', + }); + return; + } + + const history = await CollectionService.getCollectionHistory(configId); + + res.status(200).json({ + success: true, + data: history, + message: '수집 이력을 조회했습니다.', + }); + } catch (error) { + console.error('수집 이력 조회 오류:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '수집 이력 조회에 실패했습니다.', + }); + } + } +} diff --git a/backend-node/src/database/DatabaseConnectorFactory.ts b/backend-node/src/database/DatabaseConnectorFactory.ts index 6bb55d88..8ece7bba 100644 --- a/backend-node/src/database/DatabaseConnectorFactory.ts +++ b/backend-node/src/database/DatabaseConnectorFactory.ts @@ -1,5 +1,8 @@ import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConnector'; import { PostgreSQLConnector } from './PostgreSQLConnector'; +import { MariaDBConnector } from './MariaDBConnector'; +import { MSSQLConnector } from './MSSQLConnector'; +import { OracleConnector } from './OracleConnector'; export class DatabaseConnectorFactory { private static connectors = new Map(); @@ -20,6 +23,16 @@ export class DatabaseConnectorFactory { case 'postgresql': connector = new PostgreSQLConnector(config); break; + case 'mariadb': + case 'mysql': // mysql 타입도 MariaDB 커넥터 사용 + connector = new MariaDBConnector(config); + break; + case 'mssql': + connector = new MSSQLConnector(config); + break; + case 'oracle': + connector = new OracleConnector(config); + break; // Add other database types here default: throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`); diff --git a/backend-node/src/database/MSSQLConnector.ts b/backend-node/src/database/MSSQLConnector.ts new file mode 100644 index 00000000..b4555a7e --- /dev/null +++ b/backend-node/src/database/MSSQLConnector.ts @@ -0,0 +1,182 @@ +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import * as mssql from 'mssql'; + +export class MSSQLConnector implements DatabaseConnector { + private pool: mssql.ConnectionPool | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (!this.pool) { + const config: mssql.config = { + server: this.config.host, + port: this.config.port, + user: this.config.user, + password: this.config.password, + database: this.config.database, + options: { + encrypt: this.config.ssl === true, + trustServerCertificate: true + }, + connectionTimeout: this.config.connectionTimeoutMillis || 15000, + requestTimeout: this.config.queryTimeoutMillis || 15000 + }; + this.pool = await new mssql.ConnectionPool(config).connect(); + } + } + + async disconnect(): Promise { + if (this.pool) { + await this.pool.close(); + this.pool = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + + // 버전 정보 조회 + const versionResult = await this.pool!.request().query('SELECT @@VERSION as version'); + + // 데이터베이스 크기 조회 + const sizeResult = await this.pool!.request() + .input('dbName', mssql.VarChar, this.config.database) + .query(` + SELECT SUM(size * 8 * 1024) as size + FROM sys.master_files + WHERE database_id = DB_ID(@dbName) + `); + + const responseTime = Date.now() - startTime; + + return { + success: true, + message: "MSSQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: versionResult.recordset[0]?.version || "알 수 없음", + database_size: this.formatBytes(parseInt(sizeResult.recordset[0]?.size || "0")), + }, + }; + } catch (error: any) { + return { + success: false, + message: "MSSQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const result = await this.pool!.request().query(query); + return { + rows: result.recordset, + rowCount: result.rowsAffected[0], + }; + } catch (error: any) { + throw new Error(`쿼리 실행 오류: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const result = await this.pool!.request().query(` + SELECT + t.TABLE_NAME as table_name, + c.COLUMN_NAME as column_name, + c.DATA_TYPE as data_type, + c.IS_NULLABLE as is_nullable, + c.COLUMN_DEFAULT as column_default, + CAST(p.value AS NVARCHAR(MAX)) as description + FROM INFORMATION_SCHEMA.TABLES t + LEFT JOIN INFORMATION_SCHEMA.COLUMNS c + ON c.TABLE_NAME = t.TABLE_NAME + LEFT JOIN sys.extended_properties p + ON p.major_id = OBJECT_ID(t.TABLE_NAME) + AND p.minor_id = 0 + AND p.name = 'MS_Description' + WHERE t.TABLE_TYPE = 'BASE TABLE' + ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION + `); + + // 결과를 TableInfo[] 형식으로 변환 + const tables = new Map(); + + result.recordset.forEach((row: any) => { + if (!tables.has(row.table_name)) { + tables.set(row.table_name, { + table_name: row.table_name, + columns: [], + description: row.description || null + }); + } + + if (row.column_name) { + tables.get(row.table_name)!.columns.push({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable === 'YES' ? 'Y' : 'N', + column_default: row.column_default + }); + } + }); + + return Array.from(tables.values()); + } catch (error: any) { + throw new Error(`테이블 목록 조회 오류: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + await this.connect(); + const result = await this.pool!.request() + .input('tableName', mssql.VarChar, tableName) + .query(` + SELECT + c.COLUMN_NAME as name, + c.DATA_TYPE as type, + c.IS_NULLABLE as nullable, + c.COLUMN_DEFAULT as default_value, + c.CHARACTER_MAXIMUM_LENGTH as max_length, + c.NUMERIC_PRECISION as precision, + c.NUMERIC_SCALE as scale, + CAST(p.value AS NVARCHAR(MAX)) as description + FROM INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN sys.columns sc + ON sc.object_id = OBJECT_ID(@tableName) + AND sc.name = c.COLUMN_NAME + LEFT JOIN sys.extended_properties p + ON p.major_id = sc.object_id + AND p.minor_id = sc.column_id + AND p.name = 'MS_Description' + WHERE c.TABLE_NAME = @tableName + ORDER BY c.ORDINAL_POSITION + `); + + return result.recordset; + } catch (error: any) { + throw new Error(`컬럼 정보 조회 오류: ${error.message}`); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} \ No newline at end of file diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts new file mode 100644 index 00000000..1926f183 --- /dev/null +++ b/backend-node/src/database/MariaDBConnector.ts @@ -0,0 +1,127 @@ +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import * as mysql from 'mysql2/promise'; + +export class MariaDBConnector implements DatabaseConnector { + private connection: mysql.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (!this.connection) { + this.connection = await mysql.createConnection({ + host: this.config.host, + port: this.config.port, + user: this.config.user, + password: this.config.password, + database: this.config.database, + connectTimeout: this.config.connectionTimeoutMillis, + ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + }); + } + } + + async disconnect(): Promise { + if (this.connection) { + await this.connection.end(); + this.connection = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const version = (rows as any[])[0]?.version || "Unknown"; + const responseTime = Date.now() - startTime; + await this.disconnect(); + return { + success: true, + message: "MariaDB/MySQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: version, + }, + }; + } catch (error: any) { + await this.disconnect(); + return { + success: false, + message: "MariaDB/MySQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const [rows, fields] = await this.connection!.query(query); + await this.disconnect(); + return { + rows: rows as any[], + fields: fields as any[], + }; + } catch (error: any) { + await this.disconnect(); + throw new Error(`쿼리 실행 실패: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const [rows] = await this.connection!.query(` + SELECT + TABLE_NAME as table_name, + TABLE_COMMENT as description + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME; + `); + + const tables: TableInfo[] = []; + for (const row of rows as any[]) { + const columns = await this.getColumns(row.table_name); + tables.push({ + table_name: row.table_name, + description: row.description || null, + columns: columns, + }); + } + await this.disconnect(); + return tables; + } catch (error: any) { + await this.disconnect(); + throw new Error(`테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + await this.connect(); + const [rows] = await this.connection!.query(` + SELECT + COLUMN_NAME as column_name, + DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, + COLUMN_DEFAULT as column_default + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION; + `, [tableName]); + await this.disconnect(); + return rows as any[]; + } catch (error: any) { + await this.disconnect(); + throw new Error(`컬럼 정보 조회 실패: ${error.message}`); + } + } +} \ No newline at end of file diff --git a/backend-node/src/database/OracleConnector.ts b/backend-node/src/database/OracleConnector.ts new file mode 100644 index 00000000..a9fea5f6 --- /dev/null +++ b/backend-node/src/database/OracleConnector.ts @@ -0,0 +1,225 @@ +import * as oracledb from 'oracledb'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class OracleConnector implements DatabaseConnector { + private connection: oracledb.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + + // Oracle XE 21c 특화 설정 + // oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT; + // oracledb.autoCommit = true; + } + + async connect(): Promise { + try { + // Oracle XE 21c 연결 문자열 구성 + const connectionString = this.buildConnectionString(); + + const connectionConfig: any = { + user: this.config.user, + password: this.config.password, + connectString: connectionString + }; + + this.connection = await oracledb.getConnection(connectionConfig); + console.log('Oracle XE 21c 연결 성공'); + } catch (error: any) { + console.error('Oracle XE 21c 연결 실패:', error); + throw new Error(`Oracle 연결 실패: ${error.message}`); + } + } + + private buildConnectionString(): string { + const { host, port, database } = this.config; + + // Oracle XE 21c는 기본적으로 XE 서비스명을 사용 + // 다양한 연결 문자열 형식 지원 + if (database.includes('/') || database.includes(':')) { + // 이미 완전한 연결 문자열인 경우 + return database; + } + + // Oracle XE 21c 표준 형식 + return `${host}:${port}/${database}`; + } + + async disconnect(): Promise { + if (this.connection) { + try { + await this.connection.close(); + this.connection = null; + console.log('Oracle 연결 해제됨'); + } catch (error: any) { + console.error('Oracle 연결 해제 실패:', error); + } + } + } + + async testConnection(): Promise { + try { + if (!this.connection) { + await this.connect(); + } + + // Oracle XE 21c 버전 확인 쿼리 + const result = await this.connection!.execute( + 'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\'' + ); + + console.log('Oracle 버전:', result.rows); + return { + success: true, + message: '연결 성공', + details: { + server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown' + } + }; + } catch (error: any) { + console.error('Oracle 연결 테스트 실패:', error); + return { + success: false, + message: '연결 실패', + details: { + server_version: error.message + } + }; + } + } + + async executeQuery(query: string, params: any[] = []): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const startTime = Date.now(); + + // Oracle XE 21c 쿼리 실행 옵션 + const options: any = { + outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format + maxRows: 10000, // XE 제한 고려 + fetchArraySize: 100 + }; + + const result = await this.connection!.execute(query, params, options); + const executionTime = Date.now() - startTime; + + console.log('Oracle 쿼리 실행 결과:', { + query, + rowCount: result.rows?.length || 0, + metaData: result.metaData?.length || 0, + executionTime: `${executionTime}ms`, + actualRows: result.rows, + metaDataInfo: result.metaData + }); + + return { + rows: result.rows || [], + rowCount: result.rowsAffected || (result.rows?.length || 0), + fields: this.extractFieldInfo(result.metaData || []) + }; + } catch (error: any) { + console.error('Oracle 쿼리 실행 실패:', error); + throw new Error(`쿼리 실행 실패: ${error.message}`); + } + } + + private extractFieldInfo(metaData: any[]): any[] { + return metaData.map(field => ({ + name: field.name, + type: this.mapOracleType(field.dbType), + length: field.precision || field.byteSize, + nullable: field.nullable + })); + } + + private mapOracleType(oracleType: any): string { + // Oracle XE 21c 타입 매핑 (간단한 방식) + if (typeof oracleType === 'string') { + return oracleType; + } + return 'UNKNOWN'; + } + + async getTables(): Promise { + try { + // 현재 사용자 스키마의 테이블들만 조회 + const query = ` + SELECT table_name, USER as owner + FROM user_tables + ORDER BY table_name + `; + + console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user); + + const result = await this.executeQuery(query); + console.log('사용자 스키마 테이블 조회 결과:', result.rows); + + const tables = result.rows.map((row: any) => ({ + table_name: row.TABLE_NAME, + columns: [], + description: null + })); + + console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`); + return tables; + + } catch (error: any) { + console.error('Oracle 테이블 목록 조회 실패:', error); + throw new Error(`테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + const query = ` + SELECT + column_name, + data_type, + data_length, + data_precision, + data_scale, + nullable, + data_default + FROM user_tab_columns + WHERE table_name = UPPER(:tableName) + ORDER BY column_id + `; + + const result = await this.executeQuery(query, [tableName]); + + return result.rows.map((row: any) => ({ + column_name: row.COLUMN_NAME, + data_type: this.formatOracleDataType(row), + is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO', + column_default: row.DATA_DEFAULT + })); + } catch (error: any) { + console.error('Oracle 테이블 컬럼 조회 실패:', error); + throw new Error(`테이블 컬럼 조회 실패: ${error.message}`); + } + } + + private formatOracleDataType(row: any): string { + const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row; + + switch (DATA_TYPE) { + case 'NUMBER': + if (DATA_PRECISION && DATA_SCALE !== null) { + return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`; + } else if (DATA_PRECISION) { + return `NUMBER(${DATA_PRECISION})`; + } + return 'NUMBER'; + case 'VARCHAR2': + case 'CHAR': + return `${DATA_TYPE}(${DATA_LENGTH})`; + default: + return DATA_TYPE; + } + } +} diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index a06bd6a0..a54c64c6 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -6,6 +6,9 @@ import { JwtUtils } from "../utils/jwtUtils"; import { AuthenticatedRequest, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export +export { AuthenticatedRequest } from "../types/auth"; + // Express Request 타입 확장 declare global { namespace Express { diff --git a/backend-node/src/routes/batchRoutes.ts b/backend-node/src/routes/batchRoutes.ts new file mode 100644 index 00000000..9be9d0ba --- /dev/null +++ b/backend-node/src/routes/batchRoutes.ts @@ -0,0 +1,73 @@ +// 배치 관리 라우트 +// 작성일: 2024-12-23 + +import { Router } from 'express'; +import { BatchController } from '../controllers/batchController'; +import { authenticateToken } from '../middleware/authMiddleware'; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * GET /api/batch + * 배치 작업 목록 조회 + */ +router.get('/', BatchController.getBatchJobs); + +/** + * GET /api/batch/:id + * 배치 작업 상세 조회 + */ +router.get('/:id', BatchController.getBatchJobById); + +/** + * POST /api/batch + * 배치 작업 생성 + */ +router.post('/', BatchController.createBatchJob); + +/** + * PUT /api/batch/:id + * 배치 작업 수정 + */ +router.put('/:id', BatchController.updateBatchJob); + +/** + * DELETE /api/batch/:id + * 배치 작업 삭제 + */ +router.delete('/:id', BatchController.deleteBatchJob); + +/** + * POST /api/batch/:id/execute + * 배치 작업 수동 실행 + */ +router.post('/:id/execute', BatchController.executeBatchJob); + +/** + * GET /api/batch/executions + * 배치 실행 목록 조회 + */ +router.get('/executions/list', BatchController.getBatchExecutions); + +/** + * GET /api/batch/monitoring + * 배치 모니터링 정보 조회 + */ +router.get('/monitoring/status', BatchController.getBatchMonitoring); + +/** + * GET /api/batch/types/supported + * 지원되는 작업 타입 조회 + */ +router.get('/types/supported', BatchController.getSupportedJobTypes); + +/** + * GET /api/batch/schedules/presets + * 스케줄 프리셋 조회 + */ +router.get('/schedules/presets', BatchController.getSchedulePresets); + +export default router; diff --git a/backend-node/src/routes/collectionRoutes.ts b/backend-node/src/routes/collectionRoutes.ts new file mode 100644 index 00000000..ea33158f --- /dev/null +++ b/backend-node/src/routes/collectionRoutes.ts @@ -0,0 +1,61 @@ +// 수집 관리 라우트 +// 작성일: 2024-12-23 + +import { Router } from 'express'; +import { CollectionController } from '../controllers/collectionController'; +import { authenticateToken } from '../middleware/authMiddleware'; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * GET /api/collections + * 수집 설정 목록 조회 + */ +router.get('/', CollectionController.getCollectionConfigs); + +/** + * GET /api/collections/:id + * 수집 설정 상세 조회 + */ +router.get('/:id', CollectionController.getCollectionConfigById); + +/** + * POST /api/collections + * 수집 설정 생성 + */ +router.post('/', CollectionController.createCollectionConfig); + +/** + * PUT /api/collections/:id + * 수집 설정 수정 + */ +router.put('/:id', CollectionController.updateCollectionConfig); + +/** + * DELETE /api/collections/:id + * 수집 설정 삭제 + */ +router.delete('/:id', CollectionController.deleteCollectionConfig); + +/** + * POST /api/collections/:id/execute + * 수집 작업 실행 + */ +router.post('/:id/execute', CollectionController.executeCollection); + +/** + * GET /api/collections/jobs + * 수집 작업 목록 조회 + */ +router.get('/jobs/list', CollectionController.getCollectionJobs); + +/** + * GET /api/collections/:configId/history + * 수집 이력 조회 + */ +router.get('/:configId/history', CollectionController.getCollectionHistory); + +export default router; diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index 950e6c25..b25f6fe6 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -340,5 +340,37 @@ router.get( } ); +/** + * GET /api/external-db-connections/:id/tables/:tableName/columns + * 특정 테이블의 컬럼 정보 조회 + */ +router.get( + "/:id/tables/:tableName/columns", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + const tableName = req.params.tableName; + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 입력되지 않았습니다." + }); + } + + const result = await ExternalDbConnectionService.getTableColumns(id, tableName); + return res.json(result); + } catch (error) { + console.error("테이블 컬럼 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "테이블 컬럼 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } +); + export default router; diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index b41b9be7..9a54754e 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -9,7 +9,7 @@ import { TableInfo, } from "../types/externalDbTypes"; import { PasswordEncryption } from "../utils/passwordEncryption"; -import { DbConnectionManager } from "./dbConnectionManager"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; const prisma = new PrismaClient(); @@ -254,11 +254,8 @@ export class ExternalDbConnectionService { }; // 연결 테스트 수행 - const testResult = await DbConnectionManager.testConnection( - id, - existingConnection.db_type, - testConfig - ); + const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id); + const testResult = await connector.testConnection(); if (!testResult.success) { return { @@ -401,8 +398,15 @@ export class ExternalDbConnectionService { ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 연결 테스트 - return await DbConnectionManager.testConnection(id, connection.db_type, config); + // DatabaseConnectorFactory를 통한 연결 테스트 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); + const testResult = await connector.testConnection(); + + return { + success: testResult.success, + message: testResult.message, + details: testResult.details + }; } catch (error) { return { success: false, @@ -453,7 +457,7 @@ export class ExternalDbConnectionService { } // DB 타입 유효성 검사 - const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"]; + const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"]; if (!validDbTypes.includes(data.db_type)) { throw new Error("지원하지 않는 DB 타입입니다."); } @@ -524,8 +528,9 @@ export class ExternalDbConnectionService { ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 쿼리 실행 - const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query); + // DatabaseConnectorFactory를 통한 쿼리 실행 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); + const result = await connector.executeQuery(query); return { success: true, @@ -632,8 +637,9 @@ export class ExternalDbConnectionService { ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 테이블 목록 조회 - const tables = await DbConnectionManager.getTables(id, connection.db_type, config); + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); + const tables = await connector.getTables(); return { success: true, @@ -713,4 +719,57 @@ export class ExternalDbConnectionService { } } + /** + * 특정 테이블의 컬럼 정보 조회 + */ + static async getTableColumns(connectionId: number, tableName: string): Promise> { + let client: any = null; + + try { + const connection = await this.getConnectionById(connectionId); + if (!connection.success || !connection.data) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + const connectionData = connection.data; + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connectionData.password); + + // 연결 설정 준비 + const config = { + host: connectionData.host, + port: connectionData.port, + database: connectionData.database_name, + user: connectionData.username, + password: decryptedPassword, + connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined, + ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId); + + // 컬럼 정보 조회 + const columns = await connector.getColumns(tableName); + + return { + success: true, + data: columns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + } diff --git a/backend-node/src/types/batchManagement.ts b/backend-node/src/types/batchManagement.ts new file mode 100644 index 00000000..407100ac --- /dev/null +++ b/backend-node/src/types/batchManagement.ts @@ -0,0 +1,98 @@ +// 배치 관리 관련 타입 정의 +// 작성일: 2024-12-23 + +export interface BatchJob { + id?: number; + job_name: string; + description?: string | null; + job_type: string; + schedule_cron?: string | null; + is_active: string; // 'Y' | 'N' + config_json?: Record | null; + last_executed_at?: Date | null; + next_execution_at?: Date | null; + execution_count: number; + success_count: number; + failure_count: number; + created_date?: Date | null; + created_by?: string | null; + updated_date?: Date | null; + updated_by?: string | null; + company_code: string; +} + +export interface BatchJobFilter { + job_name?: string; + job_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface BatchExecution { + id?: number; + job_id: number; + execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + started_at?: Date; + completed_at?: Date; + execution_time_ms?: number; + result_data?: Record; + error_message?: string; + log_details?: string; + created_date?: Date; +} + +export interface BatchSchedule { + id?: number; + job_id: number; + schedule_name: string; + cron_expression: string; + timezone?: string; + is_active: string; + last_triggered_at?: Date; + next_trigger_at?: Date; + created_date?: Date; + created_by?: string; +} + +export interface BatchMonitoring { + total_jobs: number; + active_jobs: number; + running_jobs: number; + failed_jobs_today: number; + successful_jobs_today: number; + recent_executions: BatchExecution[]; +} + +// 배치 작업 타입 옵션 +export const BATCH_JOB_TYPE_OPTIONS = [ + { value: 'collection', label: '데이터 수집' }, + { value: 'sync', label: '데이터 동기화' }, + { value: 'cleanup', label: '데이터 정리' }, + { value: 'custom', label: '사용자 정의' }, +]; + +// 실행 상태 옵션 +export const EXECUTION_STATUS_OPTIONS = [ + { value: 'pending', label: '대기 중' }, + { value: 'running', label: '실행 중' }, + { value: 'completed', label: '완료' }, + { value: 'failed', label: '실패' }, + { value: 'cancelled', label: '취소됨' }, +]; + +// 스케줄 프리셋 +export const SCHEDULE_PRESETS = [ + { value: '0 */1 * * *', label: '매시간' }, + { value: '0 0 */6 * *', label: '6시간마다' }, + { value: '0 0 * * *', label: '매일 자정' }, + { value: '0 0 * * 0', label: '매주 일요일' }, + { value: '0 0 1 * *', label: '매월 1일' }, +]; + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} diff --git a/backend-node/src/types/collectionManagement.ts b/backend-node/src/types/collectionManagement.ts new file mode 100644 index 00000000..a4e4447f --- /dev/null +++ b/backend-node/src/types/collectionManagement.ts @@ -0,0 +1,75 @@ +// 수집 관리 관련 타입 정의 +// 작성일: 2024-12-23 + +export interface DataCollectionConfig { + id?: number; + config_name: string; + description?: string | null; + source_connection_id: number; + source_table: string; + target_table?: string | null; + collection_type: string; + schedule_cron?: string | null; + is_active: string; // 'Y' | 'N' + last_collected_at?: Date | null; + collection_options?: Record | null; + created_date?: Date | null; + created_by?: string | null; + updated_date?: Date | null; + updated_by?: string | null; + company_code: string; +} + +export interface CollectionFilter { + config_name?: string; + source_connection_id?: number; + collection_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface CollectionJob { + id?: number; + config_id: number; + job_status: string; + started_at?: Date | null; + completed_at?: Date | null; + records_processed?: number | null; + error_message?: string | null; + job_details?: Record | null; + created_date?: Date | null; +} + +export interface CollectionHistory { + id?: number; + config_id: number; + collection_date: Date; + records_collected: number; + execution_time_ms: number; + status: string; + error_details?: string | null; + created_date?: Date | null; +} + +// 수집 타입 옵션 +export const COLLECTION_TYPE_OPTIONS = [ + { value: 'full', label: '전체 수집' }, + { value: 'incremental', label: '증분 수집' }, + { value: 'delta', label: '변경분 수집' }, +]; + +// 작업 상태 옵션 +export const JOB_STATUS_OPTIONS = [ + { value: 'pending', label: '대기 중' }, + { value: 'running', label: '실행 중' }, + { value: 'completed', label: '완료' }, + { value: 'failed', label: '실패' }, +]; + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} diff --git a/backend-node/src/types/externalDbTypes.ts b/backend-node/src/types/externalDbTypes.ts index 4bed52a9..064884a8 100644 --- a/backend-node/src/types/externalDbTypes.ts +++ b/backend-node/src/types/externalDbTypes.ts @@ -5,7 +5,7 @@ export interface ExternalDbConnection { id?: number; connection_name: string; description?: string | null; - db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite"; + db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite" | "mariadb"; host: string; port: number; database_name: string; @@ -58,6 +58,7 @@ export const DB_TYPE_OPTIONS = [ { value: "postgresql", label: "PostgreSQL" }, { value: "oracle", label: "Oracle" }, { value: "mssql", label: "SQL Server" }, + { value: "mariadb", label: "MariaDB" }, { value: "sqlite", label: "SQLite" }, ]; @@ -67,6 +68,7 @@ export const DB_TYPE_DEFAULTS = { postgresql: { port: 5432, driver: "pg" }, oracle: { port: 1521, driver: "oracledb" }, mssql: { port: 1433, driver: "mssql" }, + mariadb: { port: 3306, driver: "mysql2" }, sqlite: { port: 0, driver: "sqlite3" }, }; diff --git a/docs/외부_DB_연결_관리_기능_가이드.md b/docs/외부_DB_연결_관리_기능_가이드.md new file mode 100644 index 00000000..308420f6 --- /dev/null +++ b/docs/외부_DB_연결_관리_기능_가이드.md @@ -0,0 +1,214 @@ +# 외부 DB 연결 관리 기능 가이드 + +## 개요 + +외부 DB 연결 관리 기능은 다양한 외부 데이터베이스와의 연결을 설정하고 관리하는 기능을 제공합니다. 이 기능을 통해 PostgreSQL, MySQL, MariaDB 등 다양한 데이터베이스에 연결하여 데이터를 조회하고 저장할 수 있습니다. + +## 주요 기능 + +### 1. 연결 관리 + +- 새 연결 생성 +- 기존 연결 수정 +- 연결 삭제 +- 연결 활성화/비활성화 +- 연결 테스트 + +### 2. 지원하는 데이터베이스 타입 + +- PostgreSQL +- MySQL +- MariaDB +- Oracle +- SQL Server +- SQLite + +### 3. 연결 설정 항목 + +#### 기본 정보 +- 연결명 (필수) +- DB 타입 (필수) +- 설명 (선택) + +#### 연결 정보 +- 호스트 (필수) +- 포트 (필수) +- 데이터베이스명 (필수) +- 사용자명 (필수) +- 비밀번호 (필수) + +#### 고급 설정 +- 연결 타임아웃 (기본값: 30초) +- 쿼리 타임아웃 (기본값: 60초) +- 최대 연결 수 (기본값: 10) +- SSL 사용 여부 +- SSL 인증서 경로 (SSL 사용 시) + +## 사용 방법 + +### 1. 새 연결 생성 + +1. "외부 DB 연결 관리" 화면에서 "새 연결 추가" 버튼 클릭 +2. 기본 정보 입력 + - 연결명: 고유한 식별자로 사용 + - DB 타입: 연결할 데이터베이스 종류 선택 + - 설명: 연결에 대한 부가 설명 (선택사항) +3. 연결 정보 입력 + - 호스트: 데이터베이스 서버 주소 + - 포트: DB 타입에 따라 기본값 제공 + - 데이터베이스명: 연결할 데이터베이스 이름 + - 사용자명: 데이터베이스 접속 계정 + - 비밀번호: 계정 비밀번호 +4. 필요한 경우 고급 설정 구성 +5. "연결 테스트" 버튼으로 연결 가능 여부 확인 +6. "생성" 버튼으로 연결 저장 + +### 2. 연결 수정 + +1. 연결 목록에서 수정할 연결의 "편집" 버튼 클릭 +2. 필요한 정보 수정 + - 비밀번호는 변경 시에만 입력 + - 비밀번호 필드를 비워두면 기존 비밀번호 유지 +3. "연결 테스트"로 수정된 정보 확인 +4. "수정" 버튼으로 변경사항 저장 + +### 3. 연결 테스트 + +- 새 연결 생성 시: 임시 연결을 생성하여 테스트 후 삭제 +- 기존 연결 수정 시: 현재 연결로 테스트 실행 +- 테스트 결과에 서버 버전, 응답 시간 등 상세 정보 표시 +- 실패 시 상세한 오류 메시지 제공 + +### 4. 연결 삭제 + +1. 연결 목록에서 삭제할 연결의 "삭제" 버튼 클릭 +2. 확인 대화상자에서 "삭제" 선택 +3. 해당 연결과 관련된 모든 설정 제거 + +## 보안 고려사항 + +1. 비밀번호 관리 + - 비밀번호는 AES-256-CBC로 암호화하여 저장 + - 비밀번호는 UI에 표시되지 않음 + - 수정 시 비밀번호 필드를 비워두면 기존 비밀번호 유지 + +2. SSL 연결 + - SSL 사용 옵션 제공 + - 인증서 경로 설정 가능 + - 자체 서명 인증서 지원 + +3. 접근 제어 + - 회사 코드별 연결 관리 + - 활성/비활성 상태 관리 + - 연결별 최대 연결 수 제한 + +## 문제 해결 + +### 일반적인 문제 + +1. 연결 테스트 실패 + - 호스트/포트 접근 가능 여부 확인 + - 데이터베이스명 정확성 확인 + - 사용자 계정 권한 확인 + - 방화벽 설정 확인 + +2. 타임아웃 발생 + - 연결 타임아웃 값 조정 + - 네트워크 상태 확인 + - 데이터베이스 서버 부하 확인 + +3. SSL 연결 오류 + - 인증서 파일 경로 확인 + - 인증서 유효성 확인 + - SSL 설정 일치 여부 확인 + +### 오류 메시지 해석 + +1. "ECONNREFUSED" + - 원인: 호스트/포트에 연결할 수 없음 + - 해결: 호스트/포트 정확성 확인, 방화벽 설정 확인 + +2. "password authentication failed" + - 원인: 사용자명/비밀번호 불일치 + - 해결: 계정 정보 정확성 확인 + +3. "database does not exist" + - 원인: 데이터베이스가 존재하지 않음 + - 해결: 데이터베이스명 정확성 확인 + +## 모범 사례 + +1. 연결명 작성 + - 용도를 명확히 알 수 있는 이름 사용 + - 회사/환경 정보 포함 권장 + - 예: "운영_회계DB", "개발_테스트DB" + +2. 보안 설정 + - 가능한 SSL 사용 + - 최소 권한의 계정 사용 + - 연결 타임아웃 적절히 설정 + +3. 성능 최적화 + - 필요한 최소한의 최대 연결 수 설정 + - 쿼리 타임아웃 적절히 설정 + - 주기적인 연결 테스트 수행 + +## API 참조 + +### ExternalDbConnectionAPI + +#### 연결 관리 +```typescript +// 연결 목록 조회 +getConnections(filter: ExternalDbConnectionFilter): Promise + +// 연결 상세 조회 +getConnectionById(id: number): Promise + +// 새 연결 생성 +createConnection(data: ExternalDbConnection): Promise + +// 연결 수정 +updateConnection(id: number, data: ExternalDbConnection): Promise + +// 연결 삭제 +deleteConnection(id: number): Promise +``` + +#### 연결 테스트 +```typescript +// 연결 테스트 +testConnection(connectionId: number, password?: string): Promise +``` + +#### 데이터 조회/저장 +```typescript +// 테이블 목록 조회 +getTables(connectionId: number): Promise + +// 테이블 컬럼 정보 조회 +getTableColumns(connectionId: number, tableName: string): Promise + +// 테이블 데이터 조회 +getTableData(connectionId: number, tableName: string, options?: QueryOptions): Promise + +// 테이블 데이터 저장 +saveTableData(connectionId: number, tableName: string, data: any[], options: SaveOptions): Promise<{ affected_rows: number }> +``` + +## 향후 계획 + +1. 기능 개선 + - 연결 풀 모니터링 + - 연결 통계 대시보드 + - 쿼리 실행 이력 관리 + +2. 지원 예정 기능 + - 연결 복제 + - 연결 설정 임포트/익스포트 + - 연결 그룹 관리 + +3. 보안 강화 + - 역할 기반 접근 제어 + - 감사 로그 강화 + - 연결 암호화 강화 diff --git a/docs/외부_DB_연결_관리_기능_개선_계획.md b/docs/외부_DB_연결_관리_기능_개선_계획.md new file mode 100644 index 00000000..1eca556e --- /dev/null +++ b/docs/외부_DB_연결_관리_기능_개선_계획.md @@ -0,0 +1,265 @@ +# 외부 DB 연결 관리 기능 개선 계획 + +## 1. 모니터링 및 관리 기능 강화 + +### 1.1 연결 풀 모니터링 +- [ ] 실시간 연결 상태 모니터링 + - 활성 연결 수 + - 대기 중인 연결 수 + - 연결 사용량 통계 +- [ ] 연결 풀 설정 관리 + - 최소/최대 연결 수 조정 + - 연결 타임아웃 관리 + - 유휴 연결 정리 정책 +- [ ] 알림 설정 + - 연결 풀 포화 시 알림 + - 연결 오류 발생 시 알림 + - 성능 저하 시 알림 + +### 1.2 연결 통계 대시보드 +- [ ] 연결별 사용 통계 + - 일/주/월별 사용량 + - 피크 타임 분석 + - 오류 발생 빈도 +- [ ] 성능 메트릭 + - 응답 시간 추이 + - 쿼리 실행 시간 + - 리소스 사용량 +- [ ] 시각화 도구 + - 그래프 및 차트 + - 실시간 모니터링 + - 추세 분석 + +### 1.3 쿼리 실행 이력 +- [ ] 쿼리 로깅 + - 실행된 쿼리 기록 + - 실행 시간 및 결과 + - 오류 정보 +- [ ] 분석 도구 + - 자주 사용되는 쿼리 분석 + - 성능 문제 쿼리 식별 + - 패턴 분석 +- [ ] 감사 기능 + - 접근 이력 관리 + - 변경 사항 추적 + - 보안 감사 + +## 2. 사용자 편의성 개선 + +### 2.1 연결 관리 기능 +- [ ] 연결 복제 + - 기존 연결 설정 복사 + - 환경별 설정 관리 + - 빠른 설정 생성 +- [ ] 설정 임포트/익스포트 + - JSON/YAML 형식 지원 + - 대량 설정 관리 + - 백업/복원 기능 +- [ ] 연결 그룹 관리 + - 논리적 그룹화 + - 권한 일괄 관리 + - 설정 템플릿 + +### 2.2 UI/UX 개선 +- [ ] 연결 테스트 강화 + - 상세 진단 정보 + - 문제 해결 가이드 + - 자동 재시도 옵션 +- [ ] 설정 마법사 + - 단계별 설정 가이드 + - 유효성 검사 강화 + - 모범 사례 추천 +- [ ] 검색 및 필터 + - 고급 검색 옵션 + - 커스텀 필터 저장 + - 빠른 액세스 + +### 2.3 자동화 기능 +- [ ] 스케줄링 + - 주기적 연결 테스트 + - 상태 점검 자동화 + - 리포트 생성 +- [ ] 배치 작업 + - 대량 설정 변경 + - 일괄 작업 실행 + - 작업 이력 관리 +- [ ] 알림 자동화 + - 상태 변경 알림 + - 문제 발생 알림 + - 알림 채널 설정 + +## 3. 보안 강화 + +### 3.1 접근 제어 +- [ ] 역할 기반 접근 제어 (RBAC) + - 세분화된 권한 관리 + - 역할 템플릿 + - 권한 상속 +- [ ] 다단계 인증 + - 중요 작업 승인 + - IP 기반 접근 제어 + - 세션 관리 +- [ ] 감사 로그 + - 상세 작업 이력 + - 변경 사항 추적 + - 보안 이벤트 기록 + +### 3.2 데이터 보안 +- [ ] 암호화 강화 + - 고급 암호화 알고리즘 + - 키 관리 시스템 + - 전송 구간 암호화 +- [ ] 데이터 마스킹 + - 민감 정보 보호 + - 동적 마스킹 규칙 + - 접근 수준별 마스킹 +- [ ] 보안 정책 + - 비밀번호 정책 + - 연결 제한 정책 + - 데이터 접근 정책 + +### 3.3 컴플라이언스 +- [ ] 규정 준수 + - GDPR 대응 + - 개인정보보호법 + - 산업별 규제 +- [ ] 보안 감사 + - 정기 보안 검사 + - 취약점 분석 + - 보안 리포트 +- [ ] 문서화 + - 보안 가이드라인 + - 절차 문서 + - 교육 자료 + +## 4. 성능 최적화 + +### 4.1 연결 풀 최적화 +- [ ] 동적 조정 + - 부하 기반 조정 + - 자동 스케일링 + - 리소스 최적화 +- [ ] 캐싱 전략 + - 쿼리 결과 캐싱 + - 메타데이터 캐싱 + - 캐시 무효화 +- [ ] 부하 분산 + - 읽기/쓰기 분리 + - 연결 분산 + - 장애 조치 + +### 4.2 쿼리 최적화 +- [ ] 쿼리 분석 + - 실행 계획 분석 + - 병목 지점 식별 + - 인덱스 추천 +- [ ] 성능 튜닝 + - 쿼리 재작성 + - 인덱스 최적화 + - 파라미터 조정 +- [ ] 모니터링 + - 성능 메트릭 수집 + - 알림 설정 + - 트렌드 분석 + +### 4.3 리소스 관리 +- [ ] 메모리 관리 + - 메모리 사용량 모니터링 + - 누수 감지 + - 자동 정리 +- [ ] 디스크 I/O + - I/O 패턴 분석 + - 버퍼링 최적화 + - 저장소 관리 +- [ ] CPU 사용 + - 프로세스 모니터링 + - 스레드 관리 + - 부하 분산 + +## 5. 확장성 + +### 5.1 아키텍처 개선 +- [ ] 마이크로서비스 전환 + - 서비스 분리 + - API 게이트웨이 + - 서비스 디스커버리 +- [ ] 컨테이너화 + - Docker 이미지 + - Kubernetes 배포 + - 오케스트레이션 +- [ ] 확장 가능한 설계 + - 모듈화 + - 플러그인 아키텍처 + - 인터페이스 표준화 + +### 5.2 통합 기능 +- [ ] ETL 도구 연동 + - 데이터 추출 + - 변환 규칙 + - 로드 프로세스 +- [ ] BI 도구 연동 + - 데이터 시각화 + - 리포트 생성 + - 대시보드 통합 +- [ ] 외부 시스템 연동 + - API 연동 + - 이벤트 처리 + - 데이터 동기화 + +### 5.3 데이터 관리 +- [ ] 데이터 카탈로그 + - 메타데이터 관리 + - 데이터 계보 + - 검색 기능 +- [ ] 데이터 품질 + - 유효성 검사 + - 정합성 체크 + - 품질 메트릭 +- [ ] 데이터 거버넌스 + - 정책 관리 + - 접근 제어 + - 라이프사이클 관리 + +## 구현 우선순위 + +### Phase 1 (1-3개월) +1. 연결 풀 모니터링 기본 기능 +2. 보안 강화 (RBAC, 암호화) +3. UI/UX 개선 (연결 테스트 강화) + +### Phase 2 (4-6개월) +1. 통계 대시보드 +2. 쿼리 실행 이력 +3. 자동화 기능 (스케줄링) + +### Phase 3 (7-9개월) +1. 성능 최적화 +2. 확장성 개선 +3. 통합 기능 + +### Phase 4 (10-12개월) +1. 고급 모니터링 +2. 데이터 관리 기능 +3. 컴플라이언스 대응 + +## 기대 효과 + +1. 운영 효율성 + - 모니터링 강화로 문제 조기 발견 + - 자동화를 통한 관리 부담 감소 + - 성능 최적화로 리소스 효율성 향상 + +2. 보안 강화 + - 체계적인 접근 제어 + - 데이터 보안 강화 + - 감사 추적성 확보 + +3. 사용자 만족도 + - 직관적인 UI/UX + - 자동화된 작업 처리 + - 빠른 문제 해결 + +4. 비즈니스 가치 + - 데이터 활용도 증가 + - 운영 비용 절감 + - 규정 준수 보장 diff --git a/frontend/app/(main)/admin/batch-management/page.tsx b/frontend/app/(main)/admin/batch-management/page.tsx new file mode 100644 index 00000000..9b23cf70 --- /dev/null +++ b/frontend/app/(main)/admin/batch-management/page.tsx @@ -0,0 +1,433 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Plus, + Search, + MoreHorizontal, + Edit, + Trash2, + Play, + RefreshCw, + BarChart3 +} from "lucide-react"; +import { toast } from "sonner"; +import { BatchAPI, BatchJob } from "@/lib/api/batch"; +import BatchJobModal from "@/components/admin/BatchJobModal"; + +export default function BatchManagementPage() { + const [jobs, setJobs] = useState([]); + const [filteredJobs, setFilteredJobs] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [jobTypes, setJobTypes] = useState>([]); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedJob, setSelectedJob] = useState(null); + + useEffect(() => { + loadJobs(); + loadJobTypes(); + }, []); + + useEffect(() => { + filterJobs(); + }, [jobs, searchTerm, statusFilter, typeFilter]); + + const loadJobs = async () => { + setIsLoading(true); + try { + const data = await BatchAPI.getBatchJobs(); + setJobs(data); + } catch (error) { + console.error("배치 작업 목록 조회 오류:", error); + toast.error("배치 작업 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + const loadJobTypes = async () => { + try { + const types = await BatchAPI.getSupportedJobTypes(); + setJobTypes(types); + } catch (error) { + console.error("작업 타입 조회 오류:", error); + } + }; + + const filterJobs = () => { + let filtered = jobs; + + // 검색어 필터 + if (searchTerm) { + filtered = filtered.filter(job => + job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) || + job.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // 상태 필터 + if (statusFilter !== "all") { + filtered = filtered.filter(job => job.is_active === statusFilter); + } + + // 타입 필터 + if (typeFilter !== "all") { + filtered = filtered.filter(job => job.job_type === typeFilter); + } + + setFilteredJobs(filtered); + }; + + const handleCreate = () => { + setSelectedJob(null); + setIsModalOpen(true); + }; + + const handleEdit = (job: BatchJob) => { + setSelectedJob(job); + setIsModalOpen(true); + }; + + const handleDelete = async (job: BatchJob) => { + if (!confirm(`"${job.job_name}" 배치 작업을 삭제하시겠습니까?`)) { + return; + } + + try { + await BatchAPI.deleteBatchJob(job.id!); + toast.success("배치 작업이 삭제되었습니다."); + loadJobs(); + } catch (error) { + console.error("배치 작업 삭제 오류:", error); + toast.error("배치 작업 삭제에 실패했습니다."); + } + }; + + const handleExecute = async (job: BatchJob) => { + try { + await BatchAPI.executeBatchJob(job.id!); + toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`); + } catch (error) { + console.error("배치 작업 실행 오류:", error); + toast.error("배치 작업 실행에 실패했습니다."); + } + }; + + const handleModalSave = () => { + loadJobs(); + }; + + const getStatusBadge = (isActive: string) => { + return isActive === "Y" ? ( + 활성 + ) : ( + 비활성 + ); + }; + + const getTypeBadge = (type: string) => { + const option = jobTypes.find(opt => opt.value === type); + const colors = { + collection: "bg-blue-100 text-blue-800", + sync: "bg-purple-100 text-purple-800", + cleanup: "bg-orange-100 text-orange-800", + custom: "bg-gray-100 text-gray-800", + }; + + const icons = { + collection: "📥", + sync: "🔄", + cleanup: "🧹", + custom: "⚙️", + }; + + return ( + + {icons[type as keyof typeof icons] || "📋"} + {option?.label || type} + + ); + }; + + const getSuccessRate = (job: BatchJob) => { + if (job.execution_count === 0) return 100; + return Math.round((job.success_count / job.execution_count) * 100); + }; + + return ( +
+ {/* 헤더 */} +
+
+

배치 관리

+

+ 스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다. +

+
+
+ + +
+
+ + {/* 통계 카드 */} +
+ + + 총 작업 +
📋
+
+ +
{jobs.length}
+

+ 활성: {jobs.filter(j => j.is_active === 'Y').length}개 +

+
+
+ + + + 총 실행 +
▶️
+
+ +
+ {jobs.reduce((sum, job) => sum + job.execution_count, 0)} +
+

누적 실행 횟수

+
+
+ + + + 성공 +
+
+ +
+ {jobs.reduce((sum, job) => sum + job.success_count, 0)} +
+

총 성공 횟수

+
+
+ + + + 실패 +
+
+ +
+ {jobs.reduce((sum, job) => sum + job.failure_count, 0)} +
+

총 실패 횟수

+
+
+
+ + {/* 필터 및 검색 */} + + + 필터 및 검색 + + +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + + + + + +
+
+
+ + {/* 배치 작업 목록 */} + + + 배치 작업 목록 ({filteredJobs.length}개) + + + {isLoading ? ( +
+ +

배치 작업을 불러오는 중...

+
+ ) : filteredJobs.length === 0 ? ( +
+ {jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."} +
+ ) : ( + + + + 작업명 + 타입 + 스케줄 + 상태 + 실행 통계 + 성공률 + 마지막 실행 + 작업 + + + + {filteredJobs.map((job) => ( + + +
+
{job.job_name}
+ {job.description && ( +
+ {job.description} +
+ )} +
+
+ + {getTypeBadge(job.job_type)} + + + {job.schedule_cron || "-"} + + + {getStatusBadge(job.is_active)} + + +
+
총 {job.execution_count}회
+
+ 성공 {job.success_count} / 실패 {job.failure_count} +
+
+
+ +
+
= 90 ? 'text-green-600' : + getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600' + }`}> + {getSuccessRate(job)}% +
+
+
+ + {job.last_executed_at + ? new Date(job.last_executed_at).toLocaleString() + : "-"} + + + + + + + + handleEdit(job)}> + + 수정 + + handleExecute(job)} + disabled={job.is_active !== "Y"} + > + + 실행 + + handleDelete(job)}> + + 삭제 + + + + +
+ ))} +
+
+ )} +
+
+ + {/* 배치 작업 모달 */} + setIsModalOpen(false)} + onSave={handleModalSave} + job={selectedJob} + /> +
+ ); +} diff --git a/frontend/app/(main)/admin/collection-management/page.tsx b/frontend/app/(main)/admin/collection-management/page.tsx new file mode 100644 index 00000000..4edbcaec --- /dev/null +++ b/frontend/app/(main)/admin/collection-management/page.tsx @@ -0,0 +1,337 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Plus, + Search, + MoreHorizontal, + Edit, + Trash2, + Play, + History, + RefreshCw +} from "lucide-react"; +import { toast } from "sonner"; +import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection"; +import CollectionConfigModal from "@/components/admin/CollectionConfigModal"; + +export default function CollectionManagementPage() { + const [configs, setConfigs] = useState([]); + const [filteredConfigs, setFilteredConfigs] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + + const collectionTypeOptions = CollectionAPI.getCollectionTypeOptions(); + + useEffect(() => { + loadConfigs(); + }, []); + + useEffect(() => { + filterConfigs(); + }, [configs, searchTerm, statusFilter, typeFilter]); + + const loadConfigs = async () => { + setIsLoading(true); + try { + const data = await CollectionAPI.getCollectionConfigs(); + setConfigs(data); + } catch (error) { + console.error("수집 설정 목록 조회 오류:", error); + toast.error("수집 설정 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + const filterConfigs = () => { + let filtered = configs; + + // 검색어 필터 + if (searchTerm) { + filtered = filtered.filter(config => + config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) || + config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) || + config.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // 상태 필터 + if (statusFilter !== "all") { + filtered = filtered.filter(config => config.is_active === statusFilter); + } + + // 타입 필터 + if (typeFilter !== "all") { + filtered = filtered.filter(config => config.collection_type === typeFilter); + } + + setFilteredConfigs(filtered); + }; + + const handleCreate = () => { + setSelectedConfig(null); + setIsModalOpen(true); + }; + + const handleEdit = (config: DataCollectionConfig) => { + setSelectedConfig(config); + setIsModalOpen(true); + }; + + const handleDelete = async (config: DataCollectionConfig) => { + if (!confirm(`"${config.config_name}" 수집 설정을 삭제하시겠습니까?`)) { + return; + } + + try { + await CollectionAPI.deleteCollectionConfig(config.id!); + toast.success("수집 설정이 삭제되었습니다."); + loadConfigs(); + } catch (error) { + console.error("수집 설정 삭제 오류:", error); + toast.error("수집 설정 삭제에 실패했습니다."); + } + }; + + const handleExecute = async (config: DataCollectionConfig) => { + try { + await CollectionAPI.executeCollection(config.id!); + toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`); + } catch (error) { + console.error("수집 작업 실행 오류:", error); + toast.error("수집 작업 실행에 실패했습니다."); + } + }; + + const handleModalSave = () => { + loadConfigs(); + }; + + const getStatusBadge = (isActive: string) => { + return isActive === "Y" ? ( + 활성 + ) : ( + 비활성 + ); + }; + + const getTypeBadge = (type: string) => { + const option = collectionTypeOptions.find(opt => opt.value === type); + const colors = { + full: "bg-blue-100 text-blue-800", + incremental: "bg-purple-100 text-purple-800", + delta: "bg-orange-100 text-orange-800", + }; + return ( + + {option?.label || type} + + ); + }; + + return ( +
+ {/* 헤더 */} +
+
+

수집 관리

+

+ 외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다. +

+
+ +
+ + {/* 필터 및 검색 */} + + + 필터 및 검색 + + +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + + + + + +
+
+
+ + {/* 수집 설정 목록 */} + + + 수집 설정 목록 ({filteredConfigs.length}개) + + + {isLoading ? ( +
+ +

수집 설정을 불러오는 중...

+
+ ) : filteredConfigs.length === 0 ? ( +
+ {configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."} +
+ ) : ( + + + + 설정명 + 수집 타입 + 소스 테이블 + 대상 테이블 + 스케줄 + 상태 + 마지막 수집 + 작업 + + + + {filteredConfigs.map((config) => ( + + +
+
{config.config_name}
+ {config.description && ( +
+ {config.description} +
+ )} +
+
+ + {getTypeBadge(config.collection_type)} + + + {config.source_table} + + + {config.target_table || "-"} + + + {config.schedule_cron || "-"} + + + {getStatusBadge(config.is_active)} + + + {config.last_collected_at + ? new Date(config.last_collected_at).toLocaleString() + : "-"} + + + + + + + + handleEdit(config)}> + + 수정 + + handleExecute(config)} + disabled={config.is_active !== "Y"} + > + + 실행 + + handleDelete(config)}> + + 삭제 + + + + +
+ ))} +
+
+ )} +
+
+ + {/* 수집 설정 모달 */} + setIsModalOpen(false)} + onSave={handleModalSave} + config={selectedConfig} + /> +
+ ); +} diff --git a/frontend/app/(main)/admin/monitoring/page.tsx b/frontend/app/(main)/admin/monitoring/page.tsx new file mode 100644 index 00000000..6161c387 --- /dev/null +++ b/frontend/app/(main)/admin/monitoring/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; +import MonitoringDashboard from "@/components/admin/MonitoringDashboard"; + +export default function MonitoringPage() { + return ( +
+ {/* 헤더 */} +
+

모니터링

+

+ 배치 작업 실행 상태를 실시간으로 모니터링합니다. +

+
+ + {/* 모니터링 대시보드 */} + +
+ ); +} diff --git a/frontend/components/admin/BatchJobModal.tsx b/frontend/components/admin/BatchJobModal.tsx new file mode 100644 index 00000000..7b466f64 --- /dev/null +++ b/frontend/components/admin/BatchJobModal.tsx @@ -0,0 +1,374 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { BatchAPI, BatchJob } from "@/lib/api/batch"; +import { CollectionAPI } from "@/lib/api/collection"; + +interface BatchJobModalProps { + isOpen: boolean; + onClose: () => void; + onSave: () => void; + job?: BatchJob | null; +} + +export default function BatchJobModal({ + isOpen, + onClose, + onSave, + job, +}: BatchJobModalProps) { + const [formData, setFormData] = useState>({ + job_name: "", + description: "", + job_type: "collection", + schedule_cron: "", + is_active: "Y", + config_json: {}, + execution_count: 0, + success_count: 0, + failure_count: 0, + }); + const [isLoading, setIsLoading] = useState(false); + const [jobTypes, setJobTypes] = useState>([]); + const [schedulePresets, setSchedulePresets] = useState>([]); + const [collectionConfigs, setCollectionConfigs] = useState([]); + + useEffect(() => { + if (isOpen) { + loadJobTypes(); + loadSchedulePresets(); + loadCollectionConfigs(); + + if (job) { + setFormData({ + ...job, + config_json: job.config_json || {}, + }); + } else { + setFormData({ + job_name: "", + description: "", + job_type: "collection", + schedule_cron: "", + is_active: "Y", + config_json: {}, + execution_count: 0, + success_count: 0, + failure_count: 0, + }); + } + } + }, [isOpen, job]); + + const loadJobTypes = async () => { + try { + const types = await BatchAPI.getSupportedJobTypes(); + setJobTypes(types); + } catch (error) { + console.error("작업 타입 조회 오류:", error); + } + }; + + const loadSchedulePresets = async () => { + try { + const presets = await BatchAPI.getSchedulePresets(); + setSchedulePresets(presets); + } catch (error) { + console.error("스케줄 프리셋 조회 오류:", error); + } + }; + + const loadCollectionConfigs = async () => { + try { + const configs = await CollectionAPI.getCollectionConfigs({ + is_active: "Y", + }); + setCollectionConfigs(configs); + } catch (error) { + console.error("수집 설정 조회 오류:", error); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.job_name || !formData.job_type) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + setIsLoading(true); + try { + if (job?.id) { + await BatchAPI.updateBatchJob(job.id, formData); + toast.success("배치 작업이 수정되었습니다."); + } else { + await BatchAPI.createBatchJob(formData as BatchJob); + toast.success("배치 작업이 생성되었습니다."); + } + onSave(); + onClose(); + } catch (error) { + console.error("배치 작업 저장 오류:", error); + toast.error( + error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다." + ); + } finally { + setIsLoading(false); + } + }; + + const handleSchedulePresetSelect = (preset: string) => { + setFormData(prev => ({ + ...prev, + schedule_cron: preset, + })); + }; + + const handleJobTypeChange = (jobType: string) => { + setFormData(prev => ({ + ...prev, + job_type: jobType as any, + config_json: {}, + })); + }; + + const handleCollectionConfigChange = (configId: string) => { + setFormData(prev => ({ + ...prev, + config_json: { + ...prev.config_json, + collectionConfigId: parseInt(configId), + }, + })); + }; + + const getJobTypeIcon = (type: string) => { + switch (type) { + case 'collection': return '📥'; + case 'sync': return '🔄'; + case 'cleanup': return '🧹'; + case 'custom': return '⚙️'; + default: return '📋'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'Y': return 'bg-green-100 text-green-800'; + case 'N': return 'bg-red-100 text-red-800'; + default: return 'bg-gray-100 text-gray-800'; + } + }; + + return ( + + + + + {job ? "배치 작업 수정" : "새 배치 작업"} + + + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+
+ + + setFormData(prev => ({ ...prev, job_name: e.target.value })) + } + placeholder="배치 작업명을 입력하세요" + required + /> +
+ +
+ + +
+
+ +
+ +