diff --git a/ai-assistant/.env.example b/ai-assistant/.env.example new file mode 100644 index 00000000..3ecc1ba0 --- /dev/null +++ b/ai-assistant/.env.example @@ -0,0 +1,25 @@ +# AI Assistant API (VEXPLOR 내장) - 환경 변수 +# 이 파일을 .env 로 복사한 뒤 값 설정 + +NODE_ENV=development +PORT=3100 + +# PostgreSQL (AI 어시스턴트 전용 DB) +DB_HOST=localhost +DB_PORT=5432 +DB_USER=ai_assistant +DB_PASSWORD=ai_assistant_password +DB_NAME=ai_assistant_db + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production +JWT_REFRESH_EXPIRES_IN=30d + +# LLM (구글 키 등) +GEMINI_API_KEY=your-gemini-api-key +GEMINI_MODEL=gemini-2.0-flash + +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 diff --git a/ai-assistant/Dockerfile.win b/ai-assistant/Dockerfile.win new file mode 100644 index 00000000..f652b312 --- /dev/null +++ b/ai-assistant/Dockerfile.win @@ -0,0 +1,17 @@ +# AI 어시스턴트 API - Docker (Windows 개발용) +FROM node:20-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +ENV NODE_ENV=development +EXPOSE 3100 + +CMD ["node", "src/app.js"] diff --git a/ai-assistant/README.md b/ai-assistant/README.md new file mode 100644 index 00000000..8eb5c2ec --- /dev/null +++ b/ai-assistant/README.md @@ -0,0 +1,43 @@ +# AI 어시스턴트 API (VEXPLOR 내장) + +VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다. + +## 동작 방식 + +- **프론트(9771)** → `/api/ai/v1/*` 호출 +- **Next.js** → `8080/api/ai/v1/*` 로 rewrite +- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스** + +따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 AI 기능을 사용할 수 있습니다. + +## 서비스 올리는 순서 (한 번에 동작하게) + +1. **AI 어시스턴트 API (이 폴더, 포트 3100)** + ```bash + cd ai-assistant + npm install + cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정 + npm start + ``` + +2. **backend-node (포트 8080)** + ```bash + cd backend-node + npm run dev + ``` + +3. **프론트 (포트 9771)** + ```bash + cd frontend + npm run dev + ``` + +브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다. + +## 환경 변수 + +- `.env.example` 을 `.env` 로 복사 후 수정 +- `PORT=3100` (기본값) +- PostgreSQL: `DB_*` +- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET` +- LLM: `GEMINI_API_KEY` 등 diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json new file mode 100644 index 00000000..30eef7bc --- /dev/null +++ b/ai-assistant/package-lock.json @@ -0,0 +1,3453 @@ +{ + "name": "ai-assistant-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-assistant-api", + "version": "1.0.0", + "dependencies": { + "@google/genai": "^1.0.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "sequelize": "^6.35.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "nodemon": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@google/genai": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", + "integrity": "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-hstore": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", + "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", + "license": "MIT", + "dependencies": { + "underscore": "^1.13.1" + }, + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sequelize": { + "version": "6.37.7", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", + "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sequelize/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sequelize/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/ai-assistant/package.json b/ai-assistant/package.json new file mode 100644 index 00000000..58fcec47 --- /dev/null +++ b/ai-assistant/package.json @@ -0,0 +1,38 @@ +{ + "name": "ai-assistant-api", + "version": "1.0.0", + "description": "AI Assistant API (VEXPLOR 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시", + "private": true, + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "nodemon src/app.js" + }, + "dependencies": { + "@google/genai": "^1.0.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "sequelize": "^6.35.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "nodemon": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/ai-assistant/src/app.js b/ai-assistant/src/app.js new file mode 100644 index 00000000..845217c6 --- /dev/null +++ b/ai-assistant/src/app.js @@ -0,0 +1,186 @@ +// src/app.js +// AI Assistant API 서버 메인 엔트리포인트 + +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const rateLimit = require('express-rate-limit'); +const swaggerUi = require('swagger-ui-express'); +const swaggerSpec = require('./config/swagger.config'); + +const logger = require('./config/logger.config'); +const { sequelize } = require('./models'); +const routes = require('./routes'); +const errorHandler = require('./middlewares/error-handler.middleware'); + +const app = express(); +// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용 +const PORT = process.env.PORT || 3100; + +// =========================================== +// 미들웨어 설정 +// =========================================== + +// Trust proxy (Docker/Nginx 환경) +app.set('trust proxy', 1); + +// CORS 설정 (helmet보다 먼저 설정) +app.use(cors({ + origin: true, // 모든 origin 허용 + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], +})); + +// Preflight 요청 처리 +app.options('*', cors()); + +// 보안 헤더 (CORS 이후에 설정) +app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + crossOriginOpenerPolicy: { policy: 'unsafe-none' }, +})); + +// 요청 본문 파싱 +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// 압축 +app.use(compression()); + +// Rate Limiting (전역) +const limiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000, + max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.', + }, + }, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); + +// 요청 로깅 +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); + }); + next(); +}); + +// =========================================== +// 헬스 체크 +// =========================================== + +app.get('/health', (req, res) => { + res.json({ + success: true, + data: { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }, + }); +}); + +// =========================================== +// Swagger API 문서 +// =========================================== + +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'AI Assistant API 문서', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + }, +})); + +// Swagger JSON +app.get('/api-docs.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); +}); + +// =========================================== +// API 라우트 +// =========================================== + +app.use('/api/v1', routes); + +// =========================================== +// 404 처리 +// =========================================== + +app.use((req, res) => { + res.status(404).json({ + success: false, + error: { + code: 'NOT_FOUND', + message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`, + }, + }); +}); + +// =========================================== +// 에러 핸들러 +// =========================================== + +app.use(errorHandler); + +// =========================================== +// 서버 시작 +// =========================================== + +async function startServer() { + try { + // 데이터베이스 연결 + await sequelize.authenticate(); + logger.info('✅ 데이터베이스 연결 성공'); + + // 테이블 동기화 (테이블이 없으면 생성) + await sequelize.sync(); + logger.info('✅ 데이터베이스 스키마 동기화 완료'); + + // 초기 데이터 설정 (관리자 계정, LLM 프로바이더) + const initService = require('./services/init.service'); + await initService.initialize(); + + // 서버 시작 + app.listen(PORT, () => { + logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`); + logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`); + logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`); + }); + } catch (error) { + logger.error('❌ 서버 시작 실패:', error); + process.exit(1); + } +} + +// 프로세스 종료 처리 +process.on('SIGTERM', async () => { + logger.info('SIGTERM 신호 수신, 서버 종료 중...'); + await sequelize.close(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + logger.info('SIGINT 신호 수신, 서버 종료 중...'); + await sequelize.close(); + process.exit(0); +}); + +startServer(); + +module.exports = app; diff --git a/ai-assistant/src/controllers/admin.controller.js b/ai-assistant/src/controllers/admin.controller.js new file mode 100644 index 00000000..6973a892 --- /dev/null +++ b/ai-assistant/src/controllers/admin.controller.js @@ -0,0 +1,474 @@ +// src/controllers/admin.controller.js +// 관리자 컨트롤러 + +const { LLMProvider, User, UsageLog, ApiKey } = require('../models'); +const { Op } = require('sequelize'); +const logger = require('../config/logger.config'); + +// ===== LLM 프로바이더 관리 ===== + +/** + * LLM 프로바이더 목록 조회 + */ +exports.getProviders = async (req, res, next) => { + try { + const providers = await LLMProvider.findAll({ + order: [['priority', 'ASC']], + attributes: [ + 'id', + 'name', + 'displayName', + 'endpoint', + 'modelName', + 'priority', + 'maxTokens', + 'temperature', + 'timeoutMs', + 'costPer1kInputTokens', + 'costPer1kOutputTokens', + 'isActive', + 'isHealthy', + 'lastHealthCheck', + 'createdAt', + 'updatedAt', + // API 키는 마스킹해서 반환 + 'apiKey', + ], + }); + + // API 키 마스킹 + const maskedProviders = providers.map((p) => { + const data = p.toJSON(); + if (data.apiKey) { + // 앞 8자만 보여주고 나머지는 마스킹 + data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4); + data.hasApiKey = true; + } else { + data.hasApiKey = false; + } + return data; + }); + + return res.json({ + success: true, + data: maskedProviders, + }); + } catch (error) { + return next(error); + } +}; + +/** + * LLM 프로바이더 추가 + */ +exports.createProvider = async (req, res, next) => { + try { + const { + name, + displayName, + endpoint, + apiKey, + modelName, + priority = 50, + maxTokens = 4096, + temperature = 0.7, + timeoutMs = 60000, + costPer1kInputTokens = 0, + costPer1kOutputTokens = 0, + } = req.body; + + // 중복 이름 확인 + const existing = await LLMProvider.findOne({ where: { name } }); + if (existing) { + return res.status(409).json({ + success: false, + error: { + code: 'PROVIDER_EXISTS', + message: '이미 존재하는 프로바이더 이름입니다.', + }, + }); + } + + const provider = await LLMProvider.create({ + name, + displayName, + endpoint, + apiKey, + modelName, + priority, + maxTokens, + temperature, + timeoutMs, + costPer1kInputTokens, + costPer1kOutputTokens, + isActive: true, + isHealthy: true, + }); + + logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`); + + return res.status(201).json({ + success: true, + data: { + id: provider.id, + name: provider.name, + displayName: provider.displayName, + modelName: provider.modelName, + priority: provider.priority, + isActive: provider.isActive, + message: 'LLM 프로바이더가 추가되었습니다.', + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * LLM 프로바이더 수정 + */ +exports.updateProvider = async (req, res, next) => { + try { + const { id } = req.params; + const updates = req.body; + + const provider = await LLMProvider.findByPk(id); + if (!provider) { + return res.status(404).json({ + success: false, + error: { + code: 'PROVIDER_NOT_FOUND', + message: 'LLM 프로바이더를 찾을 수 없습니다.', + }, + }); + } + + // 허용된 필드만 업데이트 + const allowedFields = [ + 'displayName', + 'endpoint', + 'apiKey', + 'modelName', + 'priority', + 'maxTokens', + 'temperature', + 'timeoutMs', + 'costPer1kInputTokens', + 'costPer1kOutputTokens', + 'isActive', + 'isHealthy', + ]; + + allowedFields.forEach((field) => { + if (updates[field] !== undefined) { + provider[field] = updates[field]; + } + }); + + await provider.save(); + + logger.info(`LLM 프로바이더 수정: ${provider.name}`); + + return res.json({ + success: true, + data: { + id: provider.id, + name: provider.name, + displayName: provider.displayName, + modelName: provider.modelName, + isActive: provider.isActive, + message: 'LLM 프로바이더가 수정되었습니다.', + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * LLM 프로바이더 삭제 + */ +exports.deleteProvider = async (req, res, next) => { + try { + const { id } = req.params; + + const provider = await LLMProvider.findByPk(id); + if (!provider) { + return res.status(404).json({ + success: false, + error: { + code: 'PROVIDER_NOT_FOUND', + message: 'LLM 프로바이더를 찾을 수 없습니다.', + }, + }); + } + + const providerName = provider.name; + await provider.destroy(); + + logger.info(`LLM 프로바이더 삭제: ${providerName}`); + + return res.json({ + success: true, + data: { + message: 'LLM 프로바이더가 삭제되었습니다.', + }, + }); + } catch (error) { + return next(error); + } +}; + +// ===== 사용자 관리 ===== + +/** + * 사용자 목록 조회 + */ +exports.getUsers = async (req, res, next) => { + try { + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 100; + const offset = (page - 1) * limit; + + const { count, rows: users } = await User.findAndCountAll({ + attributes: [ + 'id', + 'email', + 'name', + 'role', + 'status', + 'plan', + 'monthlyTokenLimit', + 'lastLoginAt', + 'createdAt', + ], + order: [['createdAt', 'DESC']], + limit, + offset, + }); + + // 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환) + return res.json({ + success: true, + data: users, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 사용자 정보 수정 + */ +exports.updateUser = async (req, res, next) => { + try { + const { id } = req.params; + const { role, status, plan, monthlyTokenLimit } = req.body; + + const user = await User.findByPk(id); + if (!user) { + return res.status(404).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + if (role) user.role = role; + if (status) user.status = status; + if (plan) user.plan = plan; + if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit; + + await user.save(); + + logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`); + + return res.json({ + success: true, + data: user.toSafeJSON(), + }); + } catch (error) { + return next(error); + } +}; + +// ===== 시스템 통계 ===== + +/** + * 사용자별 사용량 통계 + */ +exports.getUsageByUser = async (req, res, next) => { + try { + const days = parseInt(req.query.days, 10) || 7; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0, 0); + + // 사용자별 집계 (raw SQL 사용) + const userStats = await UsageLog.sequelize.query(` + SELECT + u.id as "userId", + u.email, + u.name, + COALESCE(SUM(ul.total_tokens), 0) as "totalTokens", + COALESCE(SUM(ul.cost_usd), 0) as "totalCost", + COUNT(ul.id) as "requestCount" + FROM users u + LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate + GROUP BY u.id, u.email, u.name + HAVING COUNT(ul.id) > 0 + ORDER BY SUM(ul.total_tokens) DESC NULLS LAST + `, { + replacements: { startDate }, + type: UsageLog.sequelize.QueryTypes.SELECT, + }); + + // 데이터 정리 + const result = userStats.map((stat) => ({ + userId: stat.userId, + email: stat.email || 'Unknown', + name: stat.name || '', + totalTokens: parseInt(stat.totalTokens, 10) || 0, + totalCost: parseFloat(stat.totalCost) || 0, + requestCount: parseInt(stat.requestCount, 10) || 0, + })); + + return res.json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 프로바이더별 사용량 통계 + */ +exports.getUsageByProvider = async (req, res, next) => { + try { + const days = parseInt(req.query.days, 10) || 7; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0, 0); + + // 프로바이더별 집계 (컬럼명 수정: providerName, modelName) + const providerStats = await UsageLog.findAll({ + where: { + createdAt: { [Op.gte]: startDate }, + }, + attributes: [ + 'providerName', + 'modelName', + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], + [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], + [UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'], + ], + group: ['providerName', 'modelName'], + order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']], + raw: true, + }); + + // 데이터 정리 + const result = providerStats.map((stat) => ({ + provider: stat.providerName || 'Unknown', + model: stat.modelName || 'Unknown', + totalTokens: parseInt(stat.totalTokens, 10) || 0, + promptTokens: parseInt(stat.promptTokens, 10) || 0, + completionTokens: parseInt(stat.completionTokens, 10) || 0, + totalCost: parseFloat(stat.totalCost) || 0, + requestCount: parseInt(stat.requestCount, 10) || 0, + avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0), + })); + + return res.json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 시스템 통계 조회 + */ +exports.getStats = async (req, res, next) => { + try { + // 전체 사용자 수 + const totalUsers = await User.count(); + const activeUsers = await User.count({ where: { status: 'active' } }); + + // 전체 API 키 수 + const totalApiKeys = await ApiKey.count(); + const activeApiKeys = await ApiKey.count({ where: { status: 'active' } }); + + // 오늘 사용량 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayUsage = await UsageLog.findOne({ + where: { + createdAt: { [Op.gte]: today }, + }, + attributes: [ + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], + [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], + ], + raw: true, + }); + + // 이번 달 사용량 + const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); + const monthlyUsage = await UsageLog.findOne({ + where: { + createdAt: { [Op.gte]: monthStart }, + }, + attributes: [ + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], + [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], + ], + raw: true, + }); + + // 활성 프로바이더 수 + const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } }); + + return res.json({ + success: true, + data: { + users: { + total: totalUsers, + active: activeUsers, + }, + apiKeys: { + total: totalApiKeys, + active: activeApiKeys, + }, + providers: { + active: activeProviders, + }, + usage: { + today: { + tokens: parseInt(todayUsage?.totalTokens, 10) || 0, + cost: parseFloat(todayUsage?.totalCost) || 0, + requests: parseInt(todayUsage?.requestCount, 10) || 0, + }, + monthly: { + tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0, + cost: parseFloat(monthlyUsage?.totalCost) || 0, + requests: parseInt(monthlyUsage?.requestCount, 10) || 0, + }, + }, + }, + }); + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/controllers/api-key.controller.js b/ai-assistant/src/controllers/api-key.controller.js new file mode 100644 index 00000000..85d8061c --- /dev/null +++ b/ai-assistant/src/controllers/api-key.controller.js @@ -0,0 +1,215 @@ +// src/controllers/api-key.controller.js +// API 키 컨트롤러 + +const { ApiKey } = require('../models'); +const logger = require('../config/logger.config'); + +/** + * API 키 발급 + */ +exports.create = async (req, res, next) => { + try { + const { name, expiresInDays, permissions } = req.body; + const userId = req.user.userId; + + // API 키 생성 + const rawKey = ApiKey.generateKey(); + const keyHash = ApiKey.hashKey(rawKey); + const keyPrefix = rawKey.substring(0, 12); + + // 만료 일시 계산 + let expiresAt = null; + if (expiresInDays) { + expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + } + + const apiKey = await ApiKey.create({ + userId, + name, + keyPrefix, + keyHash, + permissions: permissions || ['chat:read', 'chat:write'], + expiresAt, + }); + + logger.info(`API 키 발급: ${name} (user: ${userId})`); + + // 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가) + return res.status(201).json({ + success: true, + data: { + id: apiKey.id, + name: apiKey.name, + key: rawKey, // 원본 키 (한 번만 표시) + keyPrefix: apiKey.keyPrefix, + permissions: apiKey.permissions, + expiresAt: apiKey.expiresAt, + createdAt: apiKey.createdAt, + message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.', + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * API 키 목록 조회 + */ +exports.list = async (req, res, next) => { + try { + const userId = req.user.userId; + + const apiKeys = await ApiKey.findAll({ + where: { userId }, + attributes: [ + 'id', + 'name', + 'keyPrefix', + 'permissions', + 'rateLimit', + 'status', + 'expiresAt', + 'lastUsedAt', + 'totalRequests', + 'createdAt', + ], + order: [['createdAt', 'DESC']], + }); + + return res.json({ + success: true, + data: apiKeys, + }); + } catch (error) { + return next(error); + } +}; + +/** + * API 키 상세 조회 + */ +exports.get = async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.userId; + + const apiKey = await ApiKey.findOne({ + where: { id, userId }, + attributes: [ + 'id', + 'name', + 'keyPrefix', + 'permissions', + 'rateLimit', + 'status', + 'expiresAt', + 'lastUsedAt', + 'totalRequests', + 'createdAt', + 'updatedAt', + ], + }); + + if (!apiKey) { + return res.status(404).json({ + success: false, + error: { + code: 'API_KEY_NOT_FOUND', + message: 'API 키를 찾을 수 없습니다.', + }, + }); + } + + return res.json({ + success: true, + data: apiKey, + }); + } catch (error) { + return next(error); + } +}; + +/** + * API 키 수정 + */ +exports.update = async (req, res, next) => { + try { + const { id } = req.params; + const { name, status } = req.body; + const userId = req.user.userId; + + const apiKey = await ApiKey.findOne({ + where: { id, userId }, + }); + + if (!apiKey) { + return res.status(404).json({ + success: false, + error: { + code: 'API_KEY_NOT_FOUND', + message: 'API 키를 찾을 수 없습니다.', + }, + }); + } + + if (name) apiKey.name = name; + if (status) apiKey.status = status; + + await apiKey.save(); + + logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`); + + return res.json({ + success: true, + data: { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + status: apiKey.status, + updatedAt: apiKey.updatedAt, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * API 키 폐기 + */ +exports.revoke = async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.userId; + + const apiKey = await ApiKey.findOne({ + where: { id, userId }, + }); + + if (!apiKey) { + return res.status(404).json({ + success: false, + error: { + code: 'API_KEY_NOT_FOUND', + message: 'API 키를 찾을 수 없습니다.', + }, + }); + } + + apiKey.status = 'revoked'; + await apiKey.save(); + + logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`); + + return res.json({ + success: true, + data: { + message: 'API 키가 폐기되었습니다.', + }, + }); + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/controllers/auth.controller.js b/ai-assistant/src/controllers/auth.controller.js new file mode 100644 index 00000000..73746415 --- /dev/null +++ b/ai-assistant/src/controllers/auth.controller.js @@ -0,0 +1,195 @@ +// src/controllers/auth.controller.js +// 인증 컨트롤러 + +const jwt = require('jsonwebtoken'); +const { User } = require('../models'); +const logger = require('../config/logger.config'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; +const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret'; +const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d'; + +/** + * JWT 토큰 생성 + */ +function generateTokens(user) { + const accessToken = jwt.sign( + { userId: user.id, email: user.email, role: user.role }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + const refreshToken = jwt.sign( + { userId: user.id }, + JWT_REFRESH_SECRET, + { expiresIn: JWT_REFRESH_EXPIRES_IN } + ); + + return { accessToken, refreshToken }; +} + +/** + * 회원가입 + */ +exports.register = async (req, res, next) => { + try { + const { email, password, name } = req.body; + + // 이메일 중복 확인 + const existingUser = await User.findOne({ where: { email } }); + if (existingUser) { + return res.status(409).json({ + success: false, + error: { + code: 'EMAIL_ALREADY_EXISTS', + message: '이미 등록된 이메일입니다.', + }, + }); + } + + // 사용자 생성 + const user = await User.create({ + email, + password, + name, + }); + + // 토큰 생성 + const tokens = generateTokens(user); + + logger.info(`새 사용자 가입: ${email}`); + + return res.status(201).json({ + success: true, + data: { + user: user.toSafeJSON(), + ...tokens, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 로그인 + */ +exports.login = async (req, res, next) => { + try { + const { email, password } = req.body; + + // 사용자 조회 + const user = await User.findOne({ where: { email } }); + if (!user) { + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_CREDENTIALS', + message: '이메일 또는 비밀번호가 올바르지 않습니다.', + }, + }); + } + + // 비밀번호 검증 + const isValidPassword = await user.validatePassword(password); + if (!isValidPassword) { + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_CREDENTIALS', + message: '이메일 또는 비밀번호가 올바르지 않습니다.', + }, + }); + } + + // 계정 상태 확인 + if (user.status !== 'active') { + return res.status(403).json({ + success: false, + error: { + code: 'ACCOUNT_INACTIVE', + message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.', + }, + }); + } + + // 마지막 로그인 시간 업데이트 + user.lastLoginAt = new Date(); + await user.save(); + + // 토큰 생성 + const tokens = generateTokens(user); + + logger.info(`사용자 로그인: ${email}`); + + return res.json({ + success: true, + data: { + user: user.toSafeJSON(), + ...tokens, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 토큰 갱신 + */ +exports.refresh = async (req, res, next) => { + try { + const { refreshToken } = req.body; + + // 리프레시 토큰 검증 + let decoded; + try { + decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET); + } catch (error) { + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_REFRESH_TOKEN', + message: '유효하지 않은 리프레시 토큰입니다.', + }, + }); + } + + // 사용자 조회 + const user = await User.findByPk(decoded.userId); + if (!user || user.status !== 'active') { + return res.status(401).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + // 새 토큰 생성 + const tokens = generateTokens(user); + + return res.json({ + success: true, + data: tokens, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 로그아웃 + */ +exports.logout = async (req, res) => { + // 클라이언트에서 토큰 삭제 처리 + // 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현) + return res.json({ + success: true, + data: { + message: '로그아웃되었습니다.', + }, + }); +}; diff --git a/ai-assistant/src/controllers/chat.controller.js b/ai-assistant/src/controllers/chat.controller.js new file mode 100644 index 00000000..e93d2f98 --- /dev/null +++ b/ai-assistant/src/controllers/chat.controller.js @@ -0,0 +1,152 @@ +// src/controllers/chat.controller.js +// 채팅 컨트롤러 (OpenAI 호환 API) + +const llmService = require('../services/llm.service'); +const logger = require('../config/logger.config'); + +/** + * 채팅 완성 API (OpenAI 호환) + * POST /api/v1/chat/completions + */ +exports.completions = async (req, res, next) => { + try { + const { + model = 'gemini-2.0-flash', + messages, + temperature = 0.7, + max_tokens = 4096, + stream = false, + } = req.body; + + const startTime = Date.now(); + + // 스트리밍 응답 처리 + if (stream) { + return handleStreamingResponse(req, res, { + model, + messages, + temperature, + maxTokens: max_tokens, + }); + } + + // 일반 응답 처리 + const result = await llmService.chat({ + model, + messages, + temperature, + maxTokens: max_tokens, + userId: req.user.id, + apiKeyId: req.apiKey?.id, + }); + + const responseTime = Date.now() - startTime; + + // 사용량 정보 저장 (미들웨어에서 처리) + req.usageData = { + providerId: result.providerId, + providerName: result.provider, + modelName: result.model, + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + costUsd: result.cost, + responseTimeMs: responseTime, + success: true, + }; + + // OpenAI 호환 응답 형식 + return res.json({ + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: result.model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: result.text, + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: result.usage.promptTokens, + completion_tokens: result.usage.completionTokens, + total_tokens: result.usage.totalTokens, + }, + }); + } catch (error) { + logger.error('채팅 완성 오류:', error); + + // 사용량 정보 저장 (실패) + req.usageData = { + success: false, + errorMessage: error.message, + }; + + return next(error); + } +}; + +/** + * 스트리밍 응답 처리 + */ +async function handleStreamingResponse(req, res, params) { + const { model, messages, temperature, maxTokens } = params; + + // SSE 헤더 설정 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + try { + // 스트리밍 응답 생성 + const stream = await llmService.chatStream({ + model, + messages, + temperature, + maxTokens, + userId: req.user.id, + apiKeyId: req.apiKey?.id, + }); + + // 스트림 이벤트 처리 + for await (const chunk of stream) { + const data = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + content: chunk.text, + }, + finish_reason: chunk.done ? 'stop' : null, + }, + ], + }; + + res.write(`data: ${JSON.stringify(data)}\n\n`); + } + + // 스트림 종료 + res.write('data: [DONE]\n\n'); + res.end(); + } catch (error) { + logger.error('스트리밍 오류:', error); + + const errorData = { + error: { + message: error.message, + type: 'server_error', + }, + }; + + res.write(`data: ${JSON.stringify(errorData)}\n\n`); + res.end(); + } +} diff --git a/ai-assistant/src/controllers/model.controller.js b/ai-assistant/src/controllers/model.controller.js new file mode 100644 index 00000000..68df5824 --- /dev/null +++ b/ai-assistant/src/controllers/model.controller.js @@ -0,0 +1,67 @@ +// src/controllers/model.controller.js +// 모델 컨트롤러 + +const { LLMProvider } = require('../models'); + +/** + * 사용 가능한 모델 목록 조회 + */ +exports.list = async (req, res, next) => { + try { + const providers = await LLMProvider.getActiveProviders(); + + // OpenAI 호환 형식으로 변환 + const models = providers.map((provider) => ({ + id: provider.modelName, + object: 'model', + created: Math.floor(new Date(provider.createdAt).getTime() / 1000), + owned_by: provider.name, + permission: [], + root: provider.modelName, + parent: null, + })); + + return res.json({ + object: 'list', + data: models, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 모델 상세 정보 조회 + */ +exports.get = async (req, res, next) => { + try { + const { id } = req.params; + + const provider = await LLMProvider.findOne({ + where: { modelName: id, isActive: true }, + }); + + if (!provider) { + return res.status(404).json({ + error: { + message: `모델 '${id}'을(를) 찾을 수 없습니다.`, + type: 'invalid_request_error', + param: 'model', + code: 'model_not_found', + }, + }); + } + + return res.json({ + id: provider.modelName, + object: 'model', + created: Math.floor(new Date(provider.createdAt).getTime() / 1000), + owned_by: provider.name, + permission: [], + root: provider.modelName, + parent: null, + }); + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/controllers/usage.controller.js b/ai-assistant/src/controllers/usage.controller.js new file mode 100644 index 00000000..ba436919 --- /dev/null +++ b/ai-assistant/src/controllers/usage.controller.js @@ -0,0 +1,177 @@ +// src/controllers/usage.controller.js +// 사용량 컨트롤러 + +const { UsageLog, User } = require('../models'); +const { Op } = require('sequelize'); + +/** + * 사용량 요약 조회 + */ +exports.getSummary = async (req, res, next) => { + try { + const userId = req.user.userId; + + // 사용자 정보 조회 + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + // 이번 달 사용량 + const now = new Date(); + const monthlyUsage = await UsageLog.getMonthlyTotalByUser( + userId, + now.getFullYear(), + now.getMonth() + 1 + ); + + // 오늘 사용량 + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const todayEnd = new Date(todayStart); + todayEnd.setDate(todayEnd.getDate() + 1); + + const todayUsage = await UsageLog.findOne({ + where: { + userId, + createdAt: { + [Op.between]: [todayStart, todayEnd], + }, + }, + attributes: [ + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], + [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], + [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], + ], + raw: true, + }); + + return res.json({ + success: true, + data: { + plan: user.plan, + limit: { + monthly: user.monthlyTokenLimit, + remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens), + }, + usage: { + today: { + tokens: parseInt(todayUsage?.totalTokens, 10) || 0, + cost: parseFloat(todayUsage?.totalCost) || 0, + requests: parseInt(todayUsage?.requestCount, 10) || 0, + }, + monthly: monthlyUsage, + }, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 일별 사용량 조회 + */ +exports.getDailyUsage = async (req, res, next) => { + try { + const userId = req.user.userId; + const { startDate, endDate } = req.query; + + // 기본값: 최근 30일 + const end = endDate ? new Date(endDate) : new Date(); + const start = startDate ? new Date(startDate) : new Date(end); + if (!startDate) { + start.setDate(start.getDate() - 30); + } + + const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end); + + return res.json({ + success: true, + data: { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + usage: dailyUsage, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 월별 사용량 조회 + */ +exports.getMonthlyUsage = async (req, res, next) => { + try { + const userId = req.user.userId; + const now = new Date(); + const year = parseInt(req.query.year, 10) || now.getFullYear(); + const month = parseInt(req.query.month, 10) || (now.getMonth() + 1); + + const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month); + + return res.json({ + success: true, + data: { + year, + month, + usage: monthlyUsage, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 사용량 로그 목록 조회 + */ +exports.getLogs = async (req, res, next) => { + try { + const userId = req.user.userId; + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 20; + const offset = (page - 1) * limit; + + const { count, rows: logs } = await UsageLog.findAndCountAll({ + where: { userId }, + attributes: [ + 'id', + 'providerName', + 'modelName', + 'promptTokens', + 'completionTokens', + 'totalTokens', + 'costUsd', + 'responseTimeMs', + 'success', + 'errorMessage', + 'createdAt', + ], + order: [['createdAt', 'DESC']], + limit, + offset, + }); + + return res.json({ + success: true, + data: { + logs, + pagination: { + total: count, + page, + limit, + totalPages: Math.ceil(count / limit), + }, + }, + }); + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/controllers/user.controller.js b/ai-assistant/src/controllers/user.controller.js new file mode 100644 index 00000000..b498478d --- /dev/null +++ b/ai-assistant/src/controllers/user.controller.js @@ -0,0 +1,113 @@ +// src/controllers/user.controller.js +// 사용자 컨트롤러 + +const { User, UsageLog } = require('../models'); +const logger = require('../config/logger.config'); + +/** + * 내 정보 조회 + */ +exports.getMe = async (req, res, next) => { + try { + const user = await User.findByPk(req.user.userId); + if (!user) { + return res.status(404).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + // 이번 달 사용량 조회 + const now = new Date(); + const monthlyUsage = await UsageLog.getMonthlyTotalByUser( + user.id, + now.getFullYear(), + now.getMonth() + 1 + ); + + return res.json({ + success: true, + data: { + ...user.toSafeJSON(), + usage: { + monthly: monthlyUsage, + limit: user.monthlyTokenLimit, + remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens), + }, + }, + }); + } catch (error) { + return next(error); + } +}; + +/** + * 내 정보 수정 + */ +exports.updateMe = async (req, res, next) => { + try { + const { name, password } = req.body; + + const user = await User.findByPk(req.user.userId); + if (!user) { + return res.status(404).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + // 업데이트할 필드만 설정 + if (name) user.name = name; + if (password) user.password = password; + + await user.save(); + + logger.info(`사용자 정보 수정: ${user.email}`); + + return res.json({ + success: true, + data: user.toSafeJSON(), + }); + } catch (error) { + return next(error); + } +}; + +/** + * 계정 삭제 + */ +exports.deleteMe = async (req, res, next) => { + try { + const user = await User.findByPk(req.user.userId); + if (!user) { + return res.status(404).json({ + success: false, + error: { + code: 'USER_NOT_FOUND', + message: '사용자를 찾을 수 없습니다.', + }, + }); + } + + // 소프트 삭제 (상태 변경) + user.status = 'inactive'; + await user.save(); + + logger.info(`사용자 계정 삭제: ${user.email}`); + + return res.json({ + success: true, + data: { + message: '계정이 삭제되었습니다.', + }, + }); + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/middlewares/auth.middleware.js b/ai-assistant/src/middlewares/auth.middleware.js new file mode 100644 index 00000000..bb19f293 --- /dev/null +++ b/ai-assistant/src/middlewares/auth.middleware.js @@ -0,0 +1,257 @@ +// src/middlewares/auth.middleware.js +// 인증 미들웨어 + +const jwt = require('jsonwebtoken'); +const { ApiKey, User } = require('../models'); +const logger = require('../config/logger.config'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +/** + * JWT 토큰 인증 미들웨어 + * Authorization: Bearer + */ +exports.authenticateJWT = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: '인증 토큰이 필요합니다.', + }, + }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + return next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + error: { + code: 'TOKEN_EXPIRED', + message: '토큰이 만료되었습니다.', + }, + }); + } + + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_TOKEN', + message: '유효하지 않은 토큰입니다.', + }, + }); + } + } catch (error) { + return next(error); + } +}; + +/** + * API 키 인증 미들웨어 + * Authorization: Bearer + */ +exports.authenticateApiKey = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: { + message: 'API 키가 필요합니다.', + type: 'invalid_request_error', + code: 'missing_api_key', + }, + }); + } + + const apiKeyValue = authHeader.substring(7); + + // API 키 접두사 확인 + const prefix = process.env.API_KEY_PREFIX || 'sk-'; + if (!apiKeyValue.startsWith(prefix)) { + return res.status(401).json({ + error: { + message: '유효하지 않은 API 키 형식입니다.', + type: 'invalid_request_error', + code: 'invalid_api_key', + }, + }); + } + + // API 키 조회 + const apiKey = await ApiKey.findByKey(apiKeyValue); + + if (!apiKey) { + return res.status(401).json({ + error: { + message: '유효하지 않은 API 키입니다.', + type: 'invalid_request_error', + code: 'invalid_api_key', + }, + }); + } + + // 만료 확인 + if (apiKey.isExpired()) { + return res.status(401).json({ + error: { + message: 'API 키가 만료되었습니다.', + type: 'invalid_request_error', + code: 'expired_api_key', + }, + }); + } + + // 사용자 상태 확인 + if (apiKey.user.status !== 'active') { + return res.status(403).json({ + error: { + message: '계정이 비활성화되었습니다.', + type: 'invalid_request_error', + code: 'account_inactive', + }, + }); + } + + // 사용 기록 업데이트 + await apiKey.recordUsage(); + + // 요청 객체에 사용자 및 API 키 정보 추가 + req.user = { + id: apiKey.user.id, + userId: apiKey.user.id, + email: apiKey.user.email, + role: apiKey.user.role, + plan: apiKey.user.plan, + }; + req.apiKey = apiKey; + + return next(); + } catch (error) { + logger.error('API 키 인증 오류:', error); + return next(error); + } +}; + +/** + * 관리자 권한 확인 미들웨어 + */ +exports.requireAdmin = (req, res, next) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ + success: false, + error: { + code: 'FORBIDDEN', + message: '관리자 권한이 필요합니다.', + }, + }); + } + return next(); +}; + +/** + * JWT 또는 API 키 인증 미들웨어 + * JWT 토큰과 API 키 모두 허용 + */ +exports.authenticateAny = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: '인증이 필요합니다.', + }, + }); + } + + const token = authHeader.substring(7); + const prefix = process.env.API_KEY_PREFIX || 'sk-'; + + // API 키인 경우 + if (token.startsWith(prefix)) { + const apiKey = await ApiKey.findByKey(token); + + if (!apiKey) { + return res.status(401).json({ + error: { + message: '유효하지 않은 API 키입니다.', + type: 'invalid_request_error', + code: 'invalid_api_key', + }, + }); + } + + if (apiKey.isExpired()) { + return res.status(401).json({ + error: { + message: 'API 키가 만료되었습니다.', + type: 'invalid_request_error', + code: 'expired_api_key', + }, + }); + } + + if (apiKey.user.status !== 'active') { + return res.status(403).json({ + error: { + message: '계정이 비활성화되었습니다.', + type: 'invalid_request_error', + code: 'account_inactive', + }, + }); + } + + await apiKey.recordUsage(); + + req.user = { + id: apiKey.user.id, + userId: apiKey.user.id, + email: apiKey.user.email, + role: apiKey.user.role, + plan: apiKey.user.plan, + }; + req.apiKey = apiKey; + + return next(); + } + + // JWT 토큰인 경우 + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + return next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + error: { + code: 'TOKEN_EXPIRED', + message: '토큰이 만료되었습니다.', + }, + }); + } + + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_TOKEN', + message: '유효하지 않은 토큰입니다.', + }, + }); + } + } catch (error) { + return next(error); + } +}; diff --git a/ai-assistant/src/middlewares/error-handler.middleware.js b/ai-assistant/src/middlewares/error-handler.middleware.js new file mode 100644 index 00000000..56acce44 --- /dev/null +++ b/ai-assistant/src/middlewares/error-handler.middleware.js @@ -0,0 +1,80 @@ +// src/middlewares/error-handler.middleware.js +// 에러 핸들러 미들웨어 + +const logger = require('../config/logger.config'); + +/** + * 전역 에러 핸들러 + */ +module.exports = (err, req, res, _next) => { + // 에러 로깅 + logger.error('에러 발생:', { + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + + // Sequelize 유효성 검사 에러 + if (err.name === 'SequelizeValidationError') { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: '데이터 유효성 검사 실패', + details: err.errors.map((e) => ({ + field: e.path, + message: e.message, + })), + }, + }); + } + + // Sequelize 고유 제약 조건 에러 + if (err.name === 'SequelizeUniqueConstraintError') { + return res.status(409).json({ + success: false, + error: { + code: 'DUPLICATE_ENTRY', + message: '중복된 데이터가 존재합니다.', + details: err.errors.map((e) => ({ + field: e.path, + message: e.message, + })), + }, + }); + } + + // JWT 에러 + if (err.name === 'JsonWebTokenError') { + return res.status(401).json({ + success: false, + error: { + code: 'INVALID_TOKEN', + message: '유효하지 않은 토큰입니다.', + }, + }); + } + + // 기본 에러 응답 + const statusCode = err.statusCode || 500; + const message = err.message || '서버 오류가 발생했습니다.'; + + // 프로덕션 환경에서는 상세 에러 숨김 + const response = { + success: false, + error: { + code: err.code || 'INTERNAL_ERROR', + message: process.env.NODE_ENV === 'production' && statusCode === 500 + ? '서버 오류가 발생했습니다.' + : message, + }, + }; + + // 개발 환경에서는 스택 트레이스 포함 + if (process.env.NODE_ENV === 'development') { + response.error.stack = err.stack; + } + + return res.status(statusCode).json(response); +}; diff --git a/ai-assistant/src/middlewares/usage-logger.middleware.js b/ai-assistant/src/middlewares/usage-logger.middleware.js new file mode 100644 index 00000000..10096f87 --- /dev/null +++ b/ai-assistant/src/middlewares/usage-logger.middleware.js @@ -0,0 +1,50 @@ +// src/middlewares/usage-logger.middleware.js +// 사용량 로깅 미들웨어 + +const { UsageLog } = require('../models'); +const logger = require('../config/logger.config'); + +/** + * 사용량 로깅 미들웨어 + * 응답 완료 후 사용량 정보를 데이터베이스에 저장 + */ +exports.usageLogger = (req, res, next) => { + // 응답 완료 후 처리 + res.on('finish', async () => { + try { + // 사용량 데이터가 없으면 스킵 + if (!req.usageData) { + return; + } + + const usageData = { + userId: req.user?.id || req.user?.userId, + apiKeyId: req.apiKey?.id || null, + providerId: req.usageData.providerId || null, + providerName: req.usageData.providerName || null, + modelName: req.usageData.modelName || null, + promptTokens: req.usageData.promptTokens || 0, + completionTokens: req.usageData.completionTokens || 0, + totalTokens: req.usageData.totalTokens || 0, + costUsd: req.usageData.costUsd || 0, + responseTimeMs: req.usageData.responseTimeMs || null, + success: req.usageData.success !== false, + errorMessage: req.usageData.errorMessage || null, + requestIp: req.ip || req.connection?.remoteAddress, + userAgent: req.headers['user-agent'] || null, + }; + + await UsageLog.create(usageData); + + logger.debug('사용량 로그 저장:', { + userId: usageData.userId, + tokens: usageData.totalTokens, + cost: usageData.costUsd, + }); + } catch (error) { + logger.error('사용량 로그 저장 실패:', error); + } + }); + + next(); +}; diff --git a/ai-assistant/src/middlewares/validation.middleware.js b/ai-assistant/src/middlewares/validation.middleware.js new file mode 100644 index 00000000..23bf035f --- /dev/null +++ b/ai-assistant/src/middlewares/validation.middleware.js @@ -0,0 +1,30 @@ +// src/middlewares/validation.middleware.js +// 유효성 검사 미들웨어 + +const { validationResult } = require('express-validator'); + +/** + * 요청 유효성 검사 결과 처리 + */ +exports.validateRequest = (req, res, next) => { + const errors = validationResult(req); + + if (!errors.isEmpty()) { + const formattedErrors = errors.array().map((error) => ({ + field: error.path, + message: error.msg, + value: error.value, + })); + + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: '입력값이 올바르지 않습니다.', + details: formattedErrors, + }, + }); + } + + return next(); +}; diff --git a/ai-assistant/src/models/api-key.model.js b/ai-assistant/src/models/api-key.model.js new file mode 100644 index 00000000..772b6715 --- /dev/null +++ b/ai-assistant/src/models/api-key.model.js @@ -0,0 +1,130 @@ +// src/models/api-key.model.js +// API 키 모델 + +const { DataTypes } = require('sequelize'); +const crypto = require('crypto'); + +module.exports = (sequelize) => { + const ApiKey = sequelize.define('ApiKey', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + comment: '소유자 사용자 ID', + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'API 키 이름 (사용자 지정)', + }, + keyPrefix: { + type: DataTypes.STRING(12), + allowNull: false, + comment: 'API 키 접두사 (표시용)', + }, + keyHash: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true, + comment: 'API 키 해시 (SHA-256)', + }, + permissions: { + type: DataTypes.JSONB, + defaultValue: ['chat:read', 'chat:write'], + comment: '권한 목록', + }, + rateLimit: { + type: DataTypes.INTEGER, + defaultValue: 60, // 분당 60회 + comment: '분당 요청 제한', + }, + status: { + type: DataTypes.ENUM('active', 'revoked', 'expired'), + defaultValue: 'active', + comment: 'API 키 상태', + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '만료 일시 (null이면 무기한)', + }, + lastUsedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '마지막 사용 시간', + }, + totalRequests: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '총 요청 수', + }, + }, { + tableName: 'api_keys', + timestamps: true, + underscored: true, + indexes: [ + { + fields: ['key_hash'], + unique: true, + }, + { + fields: ['user_id'], + }, + { + fields: ['status'], + }, + ], + }); + + // 클래스 메서드: API 키 생성 + ApiKey.generateKey = function() { + const prefix = process.env.API_KEY_PREFIX || 'sk-'; + const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48; + const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length); + return `${prefix}${randomPart}`; + }; + + // 클래스 메서드: API 키 해시 생성 + ApiKey.hashKey = function(key) { + return crypto.createHash('sha256').update(key).digest('hex'); + }; + + // 클래스 메서드: API 키로 조회 + ApiKey.findByKey = async function(key) { + const keyHash = this.hashKey(key); + const apiKey = await this.findOne({ + where: { keyHash, status: 'active' }, + }); + + if (apiKey) { + // 사용자 정보 별도 조회 + const { User } = require('./index'); + apiKey.user = await User.findByPk(apiKey.userId); + } + + return apiKey; + }; + + // 인스턴스 메서드: 사용 기록 업데이트 + ApiKey.prototype.recordUsage = async function() { + this.lastUsedAt = new Date(); + this.totalRequests += 1; + await this.save(); + }; + + // 인스턴스 메서드: 만료 여부 확인 + ApiKey.prototype.isExpired = function() { + if (!this.expiresAt) return false; + return new Date() > this.expiresAt; + }; + + return ApiKey; +}; diff --git a/ai-assistant/src/models/index.js b/ai-assistant/src/models/index.js new file mode 100644 index 00000000..45f79258 --- /dev/null +++ b/ai-assistant/src/models/index.js @@ -0,0 +1,55 @@ +// src/models/index.js +// Sequelize 모델 인덱스 + +const { Sequelize } = require('sequelize'); +const config = require('../config/database.config'); + +const env = process.env.NODE_ENV || 'development'; +const dbConfig = config[env]; + +// Sequelize 인스턴스 생성 +const sequelize = new Sequelize( + dbConfig.database, + dbConfig.username, + dbConfig.password, + { + host: dbConfig.host, + port: dbConfig.port, + dialect: dbConfig.dialect, + logging: dbConfig.logging, + pool: dbConfig.pool, + dialectOptions: dbConfig.dialectOptions, + } +); + +// 모델 임포트 +const User = require('./user.model')(sequelize); +const ApiKey = require('./api-key.model')(sequelize); +const UsageLog = require('./usage-log.model')(sequelize); +const LLMProvider = require('./llm-provider.model')(sequelize); + +// 관계 설정 +// User - ApiKey (1:N) +User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' }); +ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + +// User - UsageLog (1:N) +User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' }); +UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + +// ApiKey - UsageLog (1:N) +ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' }); +UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' }); + +// LLMProvider - UsageLog (1:N) +LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' }); +UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' }); + +module.exports = { + sequelize, + Sequelize, + User, + ApiKey, + UsageLog, + LLMProvider, +}; diff --git a/ai-assistant/src/models/llm-provider.model.js b/ai-assistant/src/models/llm-provider.model.js new file mode 100644 index 00000000..68b0e443 --- /dev/null +++ b/ai-assistant/src/models/llm-provider.model.js @@ -0,0 +1,143 @@ +// src/models/llm-provider.model.js +// LLM 프로바이더 모델 + +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const LLMProvider = sequelize.define('LLMProvider', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING(50), + allowNull: false, + unique: true, + comment: '프로바이더 이름 (gemini, openai, claude 등)', + }, + displayName: { + type: DataTypes.STRING(100), + allowNull: false, + comment: '표시 이름', + }, + endpoint: { + type: DataTypes.STRING(500), + allowNull: true, + comment: 'API 엔드포인트 URL', + }, + apiKey: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'API 키 (암호화 저장 권장)', + }, + modelName: { + type: DataTypes.STRING(100), + allowNull: false, + comment: '기본 모델 이름', + }, + priority: { + type: DataTypes.INTEGER, + defaultValue: 100, + comment: '우선순위 (낮을수록 우선)', + }, + maxTokens: { + type: DataTypes.INTEGER, + defaultValue: 4096, + comment: '최대 토큰 수', + }, + temperature: { + type: DataTypes.FLOAT, + defaultValue: 0.7, + comment: '기본 온도', + }, + timeoutMs: { + type: DataTypes.INTEGER, + defaultValue: 60000, + comment: '타임아웃 (밀리초)', + }, + costPer1kInputTokens: { + type: DataTypes.DECIMAL(10, 6), + defaultValue: 0, + comment: '입력 토큰 1K당 비용 (USD)', + }, + costPer1kOutputTokens: { + type: DataTypes.DECIMAL(10, 6), + defaultValue: 0, + comment: '출력 토큰 1K당 비용 (USD)', + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: '활성화 여부', + }, + isHealthy: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: '건강 상태', + }, + lastHealthCheck: { + type: DataTypes.DATE, + allowNull: true, + comment: '마지막 헬스 체크 시간', + }, + healthCheckUrl: { + type: DataTypes.STRING(500), + allowNull: true, + comment: '헬스 체크 URL', + }, + config: { + type: DataTypes.JSONB, + defaultValue: {}, + comment: '추가 설정', + }, + }, { + tableName: 'llm_providers', + timestamps: true, + underscored: true, + indexes: [ + { + fields: ['name'], + unique: true, + }, + { + fields: ['priority'], + }, + { + fields: ['is_active', 'is_healthy'], + }, + ], + }); + + // 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순) + LLMProvider.getActiveProviders = async function() { + return this.findAll({ + where: { isActive: true }, + order: [['priority', 'ASC']], + }); + }; + + // 클래스 메서드: 건강한 프로바이더 목록 조회 + LLMProvider.getHealthyProviders = async function() { + return this.findAll({ + where: { isActive: true, isHealthy: true }, + order: [['priority', 'ASC']], + }); + }; + + // 인스턴스 메서드: 헬스 상태 업데이트 + LLMProvider.prototype.updateHealth = async function(isHealthy) { + this.isHealthy = isHealthy; + this.lastHealthCheck = new Date(); + await this.save(); + }; + + // 인스턴스 메서드: 비용 계산 + LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) { + const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0); + const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0); + return inputCost + outputCost; + }; + + return LLMProvider; +}; diff --git a/ai-assistant/src/models/usage-log.model.js b/ai-assistant/src/models/usage-log.model.js new file mode 100644 index 00000000..161e7a1f --- /dev/null +++ b/ai-assistant/src/models/usage-log.model.js @@ -0,0 +1,164 @@ +// src/models/usage-log.model.js +// 사용량 로그 모델 + +const { DataTypes, Op } = require('sequelize'); + +module.exports = (sequelize) => { + const UsageLog = sequelize.define('UsageLog', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + comment: '사용자 ID', + }, + apiKeyId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'api_keys', + key: 'id', + }, + comment: 'API 키 ID', + }, + providerId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'llm_providers', + key: 'id', + }, + comment: 'LLM 프로바이더 ID', + }, + providerName: { + type: DataTypes.STRING(50), + allowNull: true, + comment: 'LLM 프로바이더 이름', + }, + modelName: { + type: DataTypes.STRING(100), + allowNull: true, + comment: '사용된 모델 이름', + }, + promptTokens: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '프롬프트 토큰 수', + }, + completionTokens: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '완성 토큰 수', + }, + totalTokens: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '총 토큰 수', + }, + costUsd: { + type: DataTypes.DECIMAL(10, 6), + defaultValue: 0, + comment: '비용 (USD)', + }, + responseTimeMs: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '응답 시간 (밀리초)', + }, + success: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: '성공 여부', + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true, + comment: '에러 메시지', + }, + requestIp: { + type: DataTypes.STRING(45), + allowNull: true, + comment: '요청 IP 주소', + }, + userAgent: { + type: DataTypes.STRING(500), + allowNull: true, + comment: 'User-Agent', + }, + }, { + tableName: 'usage_logs', + timestamps: true, + underscored: true, + indexes: [ + { + fields: ['user_id'], + }, + { + fields: ['api_key_id'], + }, + { + fields: ['created_at'], + }, + { + fields: ['provider_name'], + }, + ], + }); + + // 클래스 메서드: 사용자별 일별 사용량 조회 + UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) { + return this.findAll({ + where: { + userId, + createdAt: { + [Op.between]: [startDate, endDate], + }, + }, + attributes: [ + [sequelize.fn('DATE', sequelize.col('created_at')), 'date'], + [sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'], + [sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'], + [sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'], + ], + group: [sequelize.fn('DATE', sequelize.col('created_at'))], + order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']], + raw: true, + }); + }; + + // 클래스 메서드: 사용자별 월간 총 사용량 조회 + UsageLog.getMonthlyTotalByUser = async function(userId, year, month) { + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59); + + const result = await this.findOne({ + where: { + userId, + createdAt: { + [Op.between]: [startDate, endDate], + }, + }, + attributes: [ + [sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'], + [sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'], + [sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'], + ], + raw: true, + }); + + return { + totalTokens: parseInt(result.totalTokens, 10) || 0, + totalCost: parseFloat(result.totalCost) || 0, + requestCount: parseInt(result.requestCount, 10) || 0, + }; + }; + + return UsageLog; +}; diff --git a/ai-assistant/src/models/user.model.js b/ai-assistant/src/models/user.model.js new file mode 100644 index 00000000..85fe08a5 --- /dev/null +++ b/ai-assistant/src/models/user.model.js @@ -0,0 +1,92 @@ +// src/models/user.model.js +// 사용자 모델 + +const { DataTypes } = require('sequelize'); +const bcrypt = require('bcryptjs'); + +module.exports = (sequelize) => { + const User = sequelize.define('User', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + email: { + type: DataTypes.STRING(255), + allowNull: false, + unique: true, + validate: { + isEmail: true, + }, + comment: '이메일 (로그인 ID)', + }, + password: { + type: DataTypes.STRING(255), + allowNull: false, + comment: '비밀번호 (해시)', + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: '사용자 이름', + }, + role: { + type: DataTypes.ENUM('user', 'admin'), + defaultValue: 'user', + comment: '역할 (user: 일반 사용자, admin: 관리자)', + }, + status: { + type: DataTypes.ENUM('active', 'inactive', 'suspended'), + defaultValue: 'active', + comment: '계정 상태', + }, + plan: { + type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'), + defaultValue: 'free', + comment: '요금제 플랜', + }, + monthlyTokenLimit: { + type: DataTypes.INTEGER, + defaultValue: 100000, // 무료 플랜 기본 10만 토큰 + comment: '월간 토큰 한도', + }, + lastLoginAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '마지막 로그인 시간', + }, + }, { + tableName: 'users', + timestamps: true, + underscored: true, + hooks: { + // 비밀번호 해싱 + beforeCreate: async (user) => { + if (user.password) { + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(user.password, salt); + } + }, + beforeUpdate: async (user) => { + if (user.changed('password')) { + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(user.password, salt); + } + }, + }, + }); + + // 인스턴스 메서드: 비밀번호 검증 + User.prototype.validatePassword = async function(password) { + return bcrypt.compare(password, this.password); + }; + + // 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외) + User.prototype.toSafeJSON = function() { + const values = { ...this.get() }; + delete values.password; + return values; + }; + + return User; +}; diff --git a/ai-assistant/src/routes/admin.routes.js b/ai-assistant/src/routes/admin.routes.js new file mode 100644 index 00000000..abb2854f --- /dev/null +++ b/ai-assistant/src/routes/admin.routes.js @@ -0,0 +1,151 @@ +// src/routes/admin.routes.js +// 관리자 라우트 + +const express = require('express'); +const { body, param } = require('express-validator'); +const adminController = require('../controllers/admin.controller'); +const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware'); +const { validateRequest } = require('../middlewares/validation.middleware'); + +const router = express.Router(); + +// 모든 라우트에 JWT 인증 + 관리자 권한 필요 +router.use(authenticateJWT); +router.use(requireAdmin); + +// ===== LLM 프로바이더 관리 ===== + +/** + * GET /api/v1/admin/providers + * LLM 프로바이더 목록 조회 + */ +router.get('/providers', adminController.getProviders); + +/** + * POST /api/v1/admin/providers + * LLM 프로바이더 추가 + */ +router.post( + '/providers', + [ + body('name') + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage('프로바이더 이름은 1-50자 사이여야 합니다'), + body('displayName') + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('표시 이름은 1-100자 사이여야 합니다'), + body('modelName') + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('모델 이름은 1-100자 사이여야 합니다'), + body('apiKey') + .optional() + .isString(), + body('priority') + .optional() + .isInt({ min: 1, max: 100 }), + validateRequest, + ], + adminController.createProvider +); + +/** + * PATCH /api/v1/admin/providers/:id + * LLM 프로바이더 수정 (API 키 설정 포함) + */ +router.patch( + '/providers/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 프로바이더 ID가 아닙니다'), + body('apiKey') + .optional() + .isString(), + body('modelName') + .optional() + .isString(), + body('isActive') + .optional() + .isBoolean(), + body('priority') + .optional() + .isInt({ min: 1, max: 100 }), + validateRequest, + ], + adminController.updateProvider +); + +/** + * DELETE /api/v1/admin/providers/:id + * LLM 프로바이더 삭제 + */ +router.delete( + '/providers/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 프로바이더 ID가 아닙니다'), + validateRequest, + ], + adminController.deleteProvider +); + +// ===== 사용자 관리 ===== + +/** + * GET /api/v1/admin/users + * 사용자 목록 조회 + */ +router.get('/users', adminController.getUsers); + +/** + * PATCH /api/v1/admin/users/:id + * 사용자 정보 수정 (역할, 상태, 플랜 등) + */ +router.patch( + '/users/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 사용자 ID가 아닙니다'), + body('role') + .optional() + .isIn(['user', 'admin']), + body('status') + .optional() + .isIn(['active', 'inactive', 'suspended']), + body('plan') + .optional() + .isIn(['free', 'basic', 'pro', 'enterprise']), + body('monthlyTokenLimit') + .optional() + .isInt({ min: 0 }), + validateRequest, + ], + adminController.updateUser +); + +// ===== 시스템 통계 ===== + +/** + * GET /api/v1/admin/stats + * 시스템 통계 조회 + */ +router.get('/stats', adminController.getStats); + +/** + * GET /api/v1/admin/usage/by-user + * 사용자별 사용량 통계 + */ +router.get('/usage/by-user', adminController.getUsageByUser); + +/** + * GET /api/v1/admin/usage/by-provider + * 프로바이더별 사용량 통계 + */ +router.get('/usage/by-provider', adminController.getUsageByProvider); + +module.exports = router; diff --git a/ai-assistant/src/routes/api-key.routes.js b/ai-assistant/src/routes/api-key.routes.js new file mode 100644 index 00000000..c45b2d9c --- /dev/null +++ b/ai-assistant/src/routes/api-key.routes.js @@ -0,0 +1,99 @@ +// src/routes/api-key.routes.js +// API 키 라우트 + +const express = require('express'); +const { body, param } = require('express-validator'); +const apiKeyController = require('../controllers/api-key.controller'); +const { authenticateJWT } = require('../middlewares/auth.middleware'); +const { validateRequest } = require('../middlewares/validation.middleware'); + +const router = express.Router(); + +// 모든 라우트에 JWT 인증 적용 +router.use(authenticateJWT); + +/** + * POST /api/v1/api-keys + * API 키 발급 + */ +router.post( + '/', + [ + body('name') + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('API 키 이름은 1-100자 사이여야 합니다'), + body('expiresInDays') + .optional() + .isInt({ min: 1, max: 365 }) + .withMessage('만료 기간은 1-365일 사이여야 합니다'), + body('permissions') + .optional() + .isArray() + .withMessage('권한은 배열이어야 합니다'), + validateRequest, + ], + apiKeyController.create +); + +/** + * GET /api/v1/api-keys + * API 키 목록 조회 + */ +router.get('/', apiKeyController.list); + +/** + * GET /api/v1/api-keys/:id + * API 키 상세 조회 + */ +router.get( + '/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 API 키 ID가 아닙니다'), + validateRequest, + ], + apiKeyController.get +); + +/** + * PATCH /api/v1/api-keys/:id + * API 키 수정 + */ +router.patch( + '/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 API 키 ID가 아닙니다'), + body('name') + .optional() + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('API 키 이름은 1-100자 사이여야 합니다'), + body('status') + .optional() + .isIn(['active', 'revoked']) + .withMessage('상태는 active 또는 revoked여야 합니다'), + validateRequest, + ], + apiKeyController.update +); + +/** + * DELETE /api/v1/api-keys/:id + * API 키 폐기 + */ +router.delete( + '/:id', + [ + param('id') + .isUUID() + .withMessage('유효한 API 키 ID가 아닙니다'), + validateRequest, + ], + apiKeyController.revoke +); + +module.exports = router; diff --git a/ai-assistant/src/routes/auth.routes.js b/ai-assistant/src/routes/auth.routes.js new file mode 100644 index 00000000..416b0c52 --- /dev/null +++ b/ai-assistant/src/routes/auth.routes.js @@ -0,0 +1,76 @@ +// src/routes/auth.routes.js +// 인증 라우트 + +const express = require('express'); +const { body } = require('express-validator'); +const authController = require('../controllers/auth.controller'); +const { validateRequest } = require('../middlewares/validation.middleware'); + +const router = express.Router(); + +/** + * POST /api/v1/auth/register + * 회원가입 + */ +router.post( + '/register', + [ + body('email') + .isEmail() + .normalizeEmail() + .withMessage('유효한 이메일 주소를 입력해주세요'), + body('password') + .isLength({ min: 8 }) + .withMessage('비밀번호는 최소 8자 이상이어야 합니다') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) + .withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'), + body('name') + .trim() + .isLength({ min: 2, max: 100 }) + .withMessage('이름은 2-100자 사이여야 합니다'), + validateRequest, + ], + authController.register +); + +/** + * POST /api/v1/auth/login + * 로그인 + */ +router.post( + '/login', + [ + body('email') + .isEmail() + .normalizeEmail() + .withMessage('유효한 이메일 주소를 입력해주세요'), + body('password') + .notEmpty() + .withMessage('비밀번호를 입력해주세요'), + validateRequest, + ], + authController.login +); + +/** + * POST /api/v1/auth/refresh + * 토큰 갱신 + */ +router.post( + '/refresh', + [ + body('refreshToken') + .notEmpty() + .withMessage('리프레시 토큰을 입력해주세요'), + validateRequest, + ], + authController.refresh +); + +/** + * POST /api/v1/auth/logout + * 로그아웃 + */ +router.post('/logout', authController.logout); + +module.exports = router; diff --git a/ai-assistant/src/routes/chat.routes.js b/ai-assistant/src/routes/chat.routes.js new file mode 100644 index 00000000..ac8d1ae3 --- /dev/null +++ b/ai-assistant/src/routes/chat.routes.js @@ -0,0 +1,55 @@ +// src/routes/chat.routes.js +// 채팅 API 라우트 (OpenAI 호환) + +const express = require('express'); +const { body } = require('express-validator'); +const chatController = require('../controllers/chat.controller'); +const { authenticateAny } = require('../middlewares/auth.middleware'); +const { validateRequest } = require('../middlewares/validation.middleware'); +const { usageLogger } = require('../middlewares/usage-logger.middleware'); + +const router = express.Router(); + +/** + * POST /api/v1/chat/completions + * 채팅 완성 API (OpenAI 호환) + * + * 인증: Bearer API_KEY 또는 JWT 토큰 + */ +router.post( + '/completions', + authenticateAny, + [ + body('model') + .optional() + .isString() + .withMessage('모델은 문자열이어야 합니다'), + body('messages') + .isArray({ min: 1 }) + .withMessage('메시지 배열이 필요합니다'), + body('messages.*.role') + .isIn(['system', 'user', 'assistant']) + .withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'), + body('messages.*.content') + .isString() + .notEmpty() + .withMessage('메시지 내용이 필요합니다'), + body('temperature') + .optional() + .isFloat({ min: 0, max: 2 }) + .withMessage('온도는 0-2 사이여야 합니다'), + body('max_tokens') + .optional() + .isInt({ min: 1, max: 128000 }) + .withMessage('최대 토큰은 1-128000 사이여야 합니다'), + body('stream') + .optional() + .isBoolean() + .withMessage('스트림은 불리언이어야 합니다'), + validateRequest, + ], + usageLogger, + chatController.completions +); + +module.exports = router; diff --git a/ai-assistant/src/routes/index.js b/ai-assistant/src/routes/index.js new file mode 100644 index 00000000..ddcd9158 --- /dev/null +++ b/ai-assistant/src/routes/index.js @@ -0,0 +1,45 @@ +// src/routes/index.js +// API 라우트 인덱스 + +const express = require('express'); +const authRoutes = require('./auth.routes'); +const userRoutes = require('./user.routes'); +const apiKeyRoutes = require('./api-key.routes'); +const chatRoutes = require('./chat.routes'); +const usageRoutes = require('./usage.routes'); +const modelRoutes = require('./model.routes'); +const adminRoutes = require('./admin.routes'); + +const router = express.Router(); + +// API 정보 +router.get('/', (req, res) => { + res.json({ + success: true, + data: { + name: 'AI Assistant API', + version: '1.0.0', + description: 'LLM API Platform - OpenAI 호환 API', + endpoints: { + auth: '/api/v1/auth', + users: '/api/v1/users', + apiKeys: '/api/v1/api-keys', + chat: '/api/v1/chat', + models: '/api/v1/models', + usage: '/api/v1/usage', + }, + documentation: 'https://docs.example.com', + }, + }); +}); + +// 라우트 등록 +router.use('/auth', authRoutes); +router.use('/users', userRoutes); +router.use('/api-keys', apiKeyRoutes); +router.use('/chat', chatRoutes); +router.use('/models', modelRoutes); +router.use('/usage', usageRoutes); +router.use('/admin', adminRoutes); + +module.exports = router; diff --git a/ai-assistant/src/routes/model.routes.js b/ai-assistant/src/routes/model.routes.js new file mode 100644 index 00000000..edd828c8 --- /dev/null +++ b/ai-assistant/src/routes/model.routes.js @@ -0,0 +1,24 @@ +// src/routes/model.routes.js +// 모델 라우트 + +const express = require('express'); +const modelController = require('../controllers/model.controller'); +const { authenticateAny } = require('../middlewares/auth.middleware'); + +const router = express.Router(); + +/** + * GET /api/v1/models + * 사용 가능한 모델 목록 조회 + * JWT 토큰 또는 API 키로 인증 + */ +router.get('/', authenticateAny, modelController.list); + +/** + * GET /api/v1/models/:id + * 모델 상세 정보 조회 + * JWT 토큰 또는 API 키로 인증 + */ +router.get('/:id', authenticateAny, modelController.get); + +module.exports = router; diff --git a/ai-assistant/src/routes/usage.routes.js b/ai-assistant/src/routes/usage.routes.js new file mode 100644 index 00000000..e8cd0049 --- /dev/null +++ b/ai-assistant/src/routes/usage.routes.js @@ -0,0 +1,81 @@ +// src/routes/usage.routes.js +// 사용량 라우트 + +const express = require('express'); +const { query } = require('express-validator'); +const usageController = require('../controllers/usage.controller'); +const { authenticateJWT } = require('../middlewares/auth.middleware'); +const { validateRequest } = require('../middlewares/validation.middleware'); + +const router = express.Router(); + +// 모든 라우트에 JWT 인증 적용 +router.use(authenticateJWT); + +/** + * GET /api/v1/usage + * 사용량 요약 조회 + */ +router.get('/', usageController.getSummary); + +/** + * GET /api/v1/usage/daily + * 일별 사용량 조회 + */ +router.get( + '/daily', + [ + query('startDate') + .optional() + .isISO8601() + .withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'), + query('endDate') + .optional() + .isISO8601() + .withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'), + validateRequest, + ], + usageController.getDailyUsage +); + +/** + * GET /api/v1/usage/monthly + * 월별 사용량 조회 + */ +router.get( + '/monthly', + [ + query('year') + .optional() + .isInt({ min: 2020, max: 2100 }) + .withMessage('연도는 2020-2100 사이여야 합니다'), + query('month') + .optional() + .isInt({ min: 1, max: 12 }) + .withMessage('월은 1-12 사이여야 합니다'), + validateRequest, + ], + usageController.getMonthlyUsage +); + +/** + * GET /api/v1/usage/logs + * 사용량 로그 목록 조회 + */ +router.get( + '/logs', + [ + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('페이지는 1 이상이어야 합니다'), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('한도는 1-100 사이여야 합니다'), + validateRequest, + ], + usageController.getLogs +); + +module.exports = router; diff --git a/ai-assistant/src/routes/user.routes.js b/ai-assistant/src/routes/user.routes.js new file mode 100644 index 00000000..953c9ebe --- /dev/null +++ b/ai-assistant/src/routes/user.routes.js @@ -0,0 +1,50 @@ +// src/routes/user.routes.js +// 사용자 라우트 + +const express = require('express'); +const { body } = require('express-validator'); +const userController = require('../controllers/user.controller'); +const { authenticateJWT } = require('../middlewares/auth.middleware'); +const { validateRequest } = require('../middlewares/validation.middleware'); + +const router = express.Router(); + +// 모든 라우트에 JWT 인증 적용 +router.use(authenticateJWT); + +/** + * GET /api/v1/users/me + * 내 정보 조회 + */ +router.get('/me', userController.getMe); + +/** + * PATCH /api/v1/users/me + * 내 정보 수정 + */ +router.patch( + '/me', + [ + body('name') + .optional() + .trim() + .isLength({ min: 2, max: 100 }) + .withMessage('이름은 2-100자 사이여야 합니다'), + body('password') + .optional() + .isLength({ min: 8 }) + .withMessage('비밀번호는 최소 8자 이상이어야 합니다') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) + .withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'), + validateRequest, + ], + userController.updateMe +); + +/** + * DELETE /api/v1/users/me + * 계정 삭제 + */ +router.delete('/me', userController.deleteMe); + +module.exports = router; diff --git a/ai-assistant/src/seeders/001-llm-providers.js b/ai-assistant/src/seeders/001-llm-providers.js new file mode 100644 index 00000000..02b95d9f --- /dev/null +++ b/ai-assistant/src/seeders/001-llm-providers.js @@ -0,0 +1,74 @@ +// src/seeders/001-llm-providers.js +// LLM 프로바이더 시드 데이터 + +const { v4: uuidv4 } = require('uuid'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + const now = new Date(); + + await queryInterface.bulkInsert('llm_providers', [ + { + id: uuidv4(), + name: 'gemini', + display_name: 'Google Gemini', + endpoint: null, // SDK 사용 + api_key: process.env.GEMINI_API_KEY || '', + model_name: 'gemini-2.0-flash', + priority: 1, + max_tokens: 8192, + temperature: 0.7, + timeout_ms: 60000, + cost_per_1k_input_tokens: 0.00025, + cost_per_1k_output_tokens: 0.001, + is_active: true, + is_healthy: true, + config: JSON.stringify({}), + created_at: now, + updated_at: now, + }, + { + id: uuidv4(), + name: 'openai', + display_name: 'OpenAI GPT', + endpoint: 'https://api.openai.com/v1/chat/completions', + api_key: process.env.OPENAI_API_KEY || '', + model_name: 'gpt-4o-mini', + priority: 2, + max_tokens: 4096, + temperature: 0.7, + timeout_ms: 60000, + cost_per_1k_input_tokens: 0.00015, + cost_per_1k_output_tokens: 0.0006, + is_active: true, + is_healthy: true, + config: JSON.stringify({}), + created_at: now, + updated_at: now, + }, + { + id: uuidv4(), + name: 'claude', + display_name: 'Anthropic Claude', + endpoint: 'https://api.anthropic.com/v1/messages', + api_key: process.env.CLAUDE_API_KEY || '', + model_name: 'claude-3-haiku-20240307', + priority: 3, + max_tokens: 4096, + temperature: 0.7, + timeout_ms: 60000, + cost_per_1k_input_tokens: 0.00025, + cost_per_1k_output_tokens: 0.00125, + is_active: true, + is_healthy: true, + config: JSON.stringify({}), + created_at: now, + updated_at: now, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('llm_providers', null, {}); + }, +}; diff --git a/ai-assistant/src/services/init.service.js b/ai-assistant/src/services/init.service.js new file mode 100644 index 00000000..65993d3d --- /dev/null +++ b/ai-assistant/src/services/init.service.js @@ -0,0 +1,128 @@ +// src/services/init.service.js +// 초기 데이터 설정 서비스 + +const { User, LLMProvider } = require('../models'); +const logger = require('../config/logger.config'); + +/** + * 초기 관리자 계정 생성 + */ +async function createDefaultAdmin() { + try { + const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com'; + const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!'; + + const existing = await User.findOne({ where: { email: adminEmail } }); + if (existing) { + logger.info(`관리자 계정 이미 존재: ${adminEmail}`); + return existing; + } + + const admin = await User.create({ + email: adminEmail, + password: adminPassword, + name: '관리자', + role: 'admin', + status: 'active', + plan: 'enterprise', + monthlyTokenLimit: 10000000, // 1000만 토큰 + }); + + logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`); + return admin; + } catch (error) { + logger.error('관리자 계정 생성 실패:', error); + throw error; + } +} + +/** + * 기본 LLM 프로바이더 생성 + */ +async function createDefaultProviders() { + try { + const providers = [ + { + name: 'gemini', + displayName: 'Google Gemini', + endpoint: null, + apiKey: process.env.GEMINI_API_KEY || '', + modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash', + priority: 1, + maxTokens: 8192, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00025, + costPer1kOutputTokens: 0.001, + }, + { + name: 'openai', + displayName: 'OpenAI GPT', + endpoint: 'https://api.openai.com/v1/chat/completions', + apiKey: process.env.OPENAI_API_KEY || '', + modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini', + priority: 2, + maxTokens: 4096, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00015, + costPer1kOutputTokens: 0.0006, + }, + { + name: 'claude', + displayName: 'Anthropic Claude', + endpoint: 'https://api.anthropic.com/v1/messages', + apiKey: process.env.CLAUDE_API_KEY || '', + modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307', + priority: 3, + maxTokens: 4096, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00025, + costPer1kOutputTokens: 0.00125, + }, + ]; + + for (const providerData of providers) { + const existing = await LLMProvider.findOne({ where: { name: providerData.name } }); + if (existing) { + // API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트 + if (providerData.apiKey && !existing.apiKey) { + existing.apiKey = providerData.apiKey; + existing.modelName = providerData.modelName; + await existing.save(); + logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`); + } + continue; + } + + await LLMProvider.create({ + ...providerData, + isActive: true, + isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy + }); + logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`); + } + } catch (error) { + logger.error('LLM 프로바이더 생성 실패:', error); + throw error; + } +} + +/** + * 초기화 실행 + */ +async function initialize() { + logger.info('🔧 초기 데이터 설정 시작...'); + + await createDefaultAdmin(); + await createDefaultProviders(); + + logger.info('✅ 초기 데이터 설정 완료'); +} + +module.exports = { + initialize, + createDefaultAdmin, + createDefaultProviders, +}; diff --git a/ai-assistant/src/services/llm.service.js b/ai-assistant/src/services/llm.service.js new file mode 100644 index 00000000..fd17f16d --- /dev/null +++ b/ai-assistant/src/services/llm.service.js @@ -0,0 +1,385 @@ +// src/services/llm.service.js +// LLM 서비스 - 멀티 프로바이더 지원 + +const axios = require('axios'); +const { LLMProvider } = require('../models'); +const logger = require('../config/logger.config'); + +class LLMService { + constructor() { + this.providers = []; + this.initialized = false; + } + + /** + * 서비스 초기화 + */ + async initialize() { + if (this.initialized) return; + + try { + await this.loadProviders(); + this.initialized = true; + logger.info('✅ LLM 서비스 초기화 완료'); + } catch (error) { + logger.error('❌ LLM 서비스 초기화 실패:', error); + // 초기화 실패 시 기본 프로바이더 사용 + this.providers = this.getDefaultProviders(); + this.initialized = true; + } + } + + /** + * 데이터베이스에서 프로바이더 로드 + */ + async loadProviders() { + try { + const providers = await LLMProvider.getHealthyProviders(); + + if (providers.length === 0) { + logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용'); + this.providers = this.getDefaultProviders(); + } else { + this.providers = providers.map((p) => ({ + id: p.id, + name: p.name, + endpoint: p.endpoint, + apiKey: p.apiKey, + modelName: p.modelName, + priority: p.priority, + maxTokens: p.maxTokens, + temperature: p.temperature, + timeoutMs: p.timeoutMs, + costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0, + costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0, + isHealthy: p.isHealthy, + config: p.config, + })); + } + + logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`); + } catch (error) { + logger.error('프로바이더 로드 실패:', error); + throw error; + } + } + + /** + * 기본 프로바이더 설정 (환경 변수 기반) + */ + getDefaultProviders() { + const providers = []; + + // Gemini + if (process.env.GEMINI_API_KEY) { + providers.push({ + id: 'default-gemini', + name: 'gemini', + apiKey: process.env.GEMINI_API_KEY, + modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash', + priority: 1, + maxTokens: 8192, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00025, + costPer1kOutputTokens: 0.001, + isHealthy: true, + }); + } + + // OpenAI + if (process.env.OPENAI_API_KEY) { + providers.push({ + id: 'default-openai', + name: 'openai', + endpoint: 'https://api.openai.com/v1/chat/completions', + apiKey: process.env.OPENAI_API_KEY, + modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini', + priority: 2, + maxTokens: 4096, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00015, + costPer1kOutputTokens: 0.0006, + isHealthy: true, + }); + } + + // Claude + if (process.env.CLAUDE_API_KEY) { + providers.push({ + id: 'default-claude', + name: 'claude', + endpoint: 'https://api.anthropic.com/v1/messages', + apiKey: process.env.CLAUDE_API_KEY, + modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307', + priority: 3, + maxTokens: 4096, + temperature: 0.7, + timeoutMs: 60000, + costPer1kInputTokens: 0.00025, + costPer1kOutputTokens: 0.00125, + isHealthy: true, + }); + } + + return providers; + } + + /** + * 채팅 API 호출 (자동 fallback) + */ + async chat(params) { + const { + model, + messages, + temperature = 0.7, + maxTokens = 4096, + userId, + apiKeyId, + } = params; + + // 초기화 확인 + if (!this.initialized) { + await this.initialize(); + } + + const startTime = Date.now(); + let lastError = null; + + // 요청된 모델에 맞는 프로바이더 찾기 + const requestedProvider = this.providers.find( + (p) => p.modelName === model || p.name === model + ); + + // 우선순위 순으로 프로바이더 정렬 + const sortedProviders = requestedProvider + ? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)] + : this.providers; + + // 프로바이더 순회 (fallback) + for (const provider of sortedProviders) { + if (!provider.isHealthy) { + logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`); + continue; + } + + try { + logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`); + + const result = await this.callProvider(provider, { + messages, + maxTokens: maxTokens || provider.maxTokens, + temperature: temperature || provider.temperature, + }); + + const responseTime = Date.now() - startTime; + + // 비용 계산 + const cost = this.calculateCost( + result.usage.promptTokens, + result.usage.completionTokens, + provider.costPer1kInputTokens, + provider.costPer1kOutputTokens + ); + + logger.info( + `✅ ${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)` + ); + + return { + text: result.text, + provider: provider.name, + providerId: provider.id, + model: provider.modelName, + usage: result.usage, + responseTime, + cost, + }; + } catch (error) { + logger.error(`❌ ${provider.name} 실패:`, error.message); + lastError = error; + + // 다음 프로바이더로 fallback + continue; + } + } + + // 모든 프로바이더 실패 + throw new Error( + `모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}` + ); + } + + /** + * 개별 프로바이더 호출 + */ + async callProvider(provider, { messages, maxTokens, temperature }) { + const timeout = provider.timeoutMs || 60000; + + switch (provider.name) { + case 'gemini': + return this.callGemini(provider, { messages, maxTokens, temperature }); + case 'openai': + return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout }); + case 'claude': + return this.callClaude(provider, { messages, maxTokens, temperature, timeout }); + default: + throw new Error(`지원하지 않는 프로바이더: ${provider.name}`); + } + } + + /** + * Gemini API 호출 + */ + async callGemini(provider, { messages, maxTokens, temperature }) { + const { GoogleGenAI } = require('@google/genai'); + + const ai = new GoogleGenAI({ apiKey: provider.apiKey }); + + // 메시지 변환 (OpenAI 형식 -> Gemini 형식) + const contents = messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts: [{ text: msg.content }], + })); + + // system 메시지 처리 + const systemMessage = messages.find((m) => m.role === 'system'); + const systemInstruction = systemMessage ? systemMessage.content : undefined; + + const config = { + maxOutputTokens: maxTokens, + temperature, + }; + + const result = await ai.models.generateContent({ + model: provider.modelName, + contents: contents.filter((c) => c.role !== 'system'), + systemInstruction, + config, + }); + + // 응답 텍스트 추출 + let text = ''; + if (result.candidates?.[0]?.content?.parts) { + text = result.candidates[0].content.parts + .filter((p) => p.text) + .map((p) => p.text) + .join('\n'); + } + + const usage = result.usageMetadata || {}; + const promptTokens = usage.promptTokenCount ?? 0; + const completionTokens = usage.candidatesTokenCount ?? 0; + + return { + text, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, + }; + } + + /** + * OpenAI API 호출 + */ + async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) { + const response = await axios.post( + provider.endpoint, + { + model: provider.modelName, + messages, + max_tokens: maxTokens, + temperature, + }, + { + timeout, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${provider.apiKey}`, + }, + } + ); + + return { + text: response.data.choices[0].message.content, + usage: { + promptTokens: response.data.usage.prompt_tokens, + completionTokens: response.data.usage.completion_tokens, + totalTokens: response.data.usage.total_tokens, + }, + }; + } + + /** + * Claude API 호출 + */ + async callClaude(provider, { messages, maxTokens, temperature, timeout }) { + // system 메시지 분리 + const systemMessage = messages.find((m) => m.role === 'system'); + const otherMessages = messages.filter((m) => m.role !== 'system'); + + const response = await axios.post( + provider.endpoint, + { + model: provider.modelName, + messages: otherMessages, + system: systemMessage?.content, + max_tokens: maxTokens, + temperature, + }, + { + timeout, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': provider.apiKey, + 'anthropic-version': '2023-06-01', + }, + } + ); + + return { + text: response.data.content[0].text, + usage: { + promptTokens: response.data.usage.input_tokens, + completionTokens: response.data.usage.output_tokens, + totalTokens: + response.data.usage.input_tokens + response.data.usage.output_tokens, + }, + }; + } + + /** + * 스트리밍 채팅 (제너레이터) + */ + async *chatStream(params) { + // 현재는 간단한 구현 (전체 응답 후 청크로 분할) + // 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요 + const result = await this.chat(params); + + // 텍스트를 청크로 분할하여 전송 + const chunkSize = 10; + for (let i = 0; i < result.text.length; i += chunkSize) { + yield { + text: result.text.slice(i, i + chunkSize), + done: i + chunkSize >= result.text.length, + }; + } + } + + /** + * 비용 계산 + */ + calculateCost(promptTokens, completionTokens, inputCost, outputCost) { + const inputTotal = (promptTokens / 1000) * inputCost; + const outputTotal = (completionTokens / 1000) * outputCost; + return parseFloat((inputTotal + outputTotal).toFixed(6)); + } +} + +// 싱글톤 인스턴스 +const llmService = new LLMService(); + +module.exports = llmService; diff --git a/ai-assistant/src/swagger/api-docs.js b/ai-assistant/src/swagger/api-docs.js new file mode 100644 index 00000000..6518aaca --- /dev/null +++ b/ai-assistant/src/swagger/api-docs.js @@ -0,0 +1,359 @@ +// src/swagger/api-docs.js +// Swagger API 문서 정의 + +/** + * @swagger + * /auth/register: + * post: + * tags: [Auth] + * summary: 회원가입 + * description: 새 계정을 생성합니다. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password, name] + * properties: + * email: + * type: string + * format: email + * example: user@example.com + * password: + * type: string + * minLength: 8 + * example: Password123! + * description: 8자 이상, 영문/숫자/특수문자 포함 + * name: + * type: string + * example: 홍길동 + * responses: + * 201: + * description: 회원가입 성공 + * 400: + * description: 유효성 검사 실패 + * 409: + * description: 이미 존재하는 이메일 + */ + +/** + * @swagger + * /auth/login: + * post: + * tags: [Auth] + * summary: 로그인 + * description: 이메일과 비밀번호로 로그인합니다. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: + * type: string + * format: email + * example: admin@admin.com + * password: + * type: string + * example: Admin123! + * responses: + * 200: + * description: 로그인 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * user: + * type: object + * accessToken: + * type: string + * description: JWT 액세스 토큰 + * refreshToken: + * type: string + * description: JWT 리프레시 토큰 + * 401: + * description: 인증 실패 + */ + +/** + * @swagger + * /chat/completions: + * post: + * tags: [Chat] + * summary: 채팅 완성 (OpenAI 호환) + * description: | + * AI 모델에 메시지를 보내고 응답을 받습니다. + * OpenAI API와 호환되는 형식입니다. + * + * **인증**: JWT 토큰 또는 API 키 (sk-xxx) + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChatCompletionRequest' + * examples: + * simple: + * summary: 간단한 질문 + * value: + * model: gemini-2.0-flash + * messages: + * - role: user + * content: 안녕하세요! + * with_system: + * summary: 시스템 프롬프트 포함 + * value: + * model: gemini-2.0-flash + * messages: + * - role: system + * content: 당신은 친절한 AI 어시스턴트입니다. + * - role: user + * content: 파이썬으로 Hello World 출력하는 코드 알려줘 + * temperature: 0.7 + * max_tokens: 1000 + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChatCompletionResponse' + * 401: + * description: 인증 실패 + * 429: + * description: 요청 한도 초과 + */ + +/** + * @swagger + * /models: + * get: + * tags: [Models] + * summary: 모델 목록 조회 + * description: 사용 가능한 AI 모델 목록을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * object: + * type: string + * example: list + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: gemini-2.0-flash + * object: + * type: string + * example: model + * owned_by: + * type: string + * example: google + */ + +/** + * @swagger + * /api-keys: + * get: + * tags: [API Keys] + * summary: API 키 목록 조회 + * description: 발급받은 API 키 목록을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * post: + * tags: [API Keys] + * summary: API 키 발급 + * description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다. + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name] + * properties: + * name: + * type: string + * example: My API Key + * description: API 키 이름 + * responses: + * 201: + * description: 발급 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * key: + * type: string + * description: 발급된 API 키 (한 번만 표시) + * example: sk-abc123def456... + */ + +/** + * @swagger + * /api-keys/{id}: + * delete: + * tags: [API Keys] + * summary: API 키 폐기 + * description: API 키를 폐기합니다. + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: API 키 ID + * responses: + * 200: + * description: 폐기 성공 + * 404: + * description: API 키를 찾을 수 없음 + */ + +/** + * @swagger + * /usage: + * get: + * tags: [Usage] + * summary: 사용량 요약 조회 + * description: 오늘/이번 달 사용량 요약을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * plan: + * type: string + * example: free + * limit: + * type: object + * properties: + * monthly: + * type: integer + * remaining: + * type: integer + * usage: + * type: object + * properties: + * today: + * type: object + * monthly: + * type: object + */ + +/** + * @swagger + * /usage/logs: + * get: + * tags: [Usage] + * summary: 사용 로그 조회 + * description: API 호출 로그를 조회합니다. + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: 성공 + */ + +/** + * @swagger + * /admin/users: + * get: + * tags: [Admin] + * summary: 사용자 목록 조회 (관리자) + * description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ + +/** + * @swagger + * /admin/providers: + * get: + * tags: [Admin] + * summary: LLM 프로바이더 목록 (관리자) + * description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ + +/** + * @swagger + * /admin/stats: + * get: + * tags: [Admin] + * summary: 시스템 통계 (관리자) + * description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index ae55e3c4..f482dc7b 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -22,6 +22,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", + "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", @@ -3318,6 +3319,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/imap": { "version": "0.8.42", "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz", @@ -4419,7 +4429,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6154,7 +6163,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6887,6 +6895,20 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6900,6 +6922,29 @@ "node": ">= 14" } }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7208,7 +7253,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7238,7 +7282,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7269,7 +7312,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7294,6 +7336,15 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -8566,7 +8617,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -9388,7 +9438,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9946,6 +9995,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -10824,7 +10879,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/backend-node/package.json b/backend-node/package.json index 310ab401..53ee00b8 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -36,6 +36,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", + "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 24ba1647..914f608c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware"; // ============================================ // 처리되지 않은 Promise 거부 핸들러 -process.on("unhandledRejection", (reason: Error | any, promise: Promise) => { - logger.error("⚠️ Unhandled Promise Rejection:", { - reason: reason?.message || reason, - stack: reason?.stack, - }); - // 프로세스를 종료하지 않고 로깅만 수행 - // 심각한 에러의 경우 graceful shutdown 고려 -}); +process.on( + "unhandledRejection", + (reason: Error | any, promise: Promise) => { + logger.error("⚠️ Unhandled Promise Rejection:", { + reason: reason?.message || reason, + stack: reason?.stack, + }); + // 프로세스를 종료하지 않고 로깅만 수행 + // 심각한 에러의 경우 graceful shutdown 고려 + }, +); // 처리되지 않은 예외 핸들러 process.on("uncaughtException", (error: Error) => { @@ -38,13 +41,16 @@ process.on("uncaughtException", (error: Error) => { // SIGTERM 시그널 처리 (Docker/Kubernetes 환경) process.on("SIGTERM", () => { logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작..."); - // 여기서 연결 풀 정리 등 cleanup 로직 추가 가능 + const { stopAiAssistant } = require("./utils/startAiAssistant"); + stopAiAssistant(); process.exit(0); }); // SIGINT 시그널 처리 (Ctrl+C) process.on("SIGINT", () => { logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작..."); + const { stopAiAssistant } = require("./utils/startAiAssistant"); + stopAiAssistant(); process.exit(0); }); @@ -112,7 +118,9 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 -import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 +import entitySearchRoutes, { + entityOptionsRouter, +} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 @@ -128,7 +136,9 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 +import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 +import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -152,7 +162,7 @@ app.use( ], // 프론트엔드 도메인 허용 }, }, - }) + }), ); app.use(compression()); app.use(express.json({ limit: "10mb" })); @@ -175,13 +185,13 @@ app.use( res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader( "Access-Control-Allow-Headers", - "Content-Type, Authorization" + "Content-Type, Authorization", ); res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); res.setHeader("Cache-Control", "public, max-age=3600"); next(); }, - express.static(path.join(process.cwd(), "uploads")) + express.static(path.join(process.cwd(), "uploads")), ); // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 @@ -201,7 +211,7 @@ app.use( ], preflightContinue: false, optionsSuccessStatus: 200, - }) + }), ); // Rate Limiting (개발 환경에서는 완화) @@ -317,7 +327,9 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 +app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/approval", approvalRoutes); // 결재 시스템 // app.use("/api/collections", collectionRoutes); // 임시 주석 @@ -406,6 +418,14 @@ app.listen(PORT, HOST, async () => { } catch (error) { logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error); } + + // AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능) + try { + const { startAiAssistant } = await import("./utils/startAiAssistant"); + startAiAssistant(); + } catch (error) { + logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error); + } }); export default app; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 57edad10..1a573834 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3564,6 +3564,7 @@ export async function getTableSchema( logger.info("테이블 스키마 조회", { tableName, companyCode }); // information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 + // 회사별 라벨 우선, 없으면 공통(*) 라벨 사용 const schemaQuery = ` SELECT ic.column_name, @@ -3573,19 +3574,23 @@ export async function getTableSchema( ic.character_maximum_length, ic.numeric_precision, ic.numeric_scale, - ttc.column_label, - ttc.display_order + COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label, + COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order FROM information_schema.columns ic - LEFT JOIN table_type_columns ttc - ON ttc.table_name = ic.table_name - AND ttc.column_name = ic.column_name - AND ttc.company_code = '*' + LEFT JOIN table_type_columns ttc_common + ON ttc_common.table_name = ic.table_name + AND ttc_common.column_name = ic.column_name + AND ttc_common.company_code = '*' + LEFT JOIN table_type_columns ttc_company + ON ttc_company.table_name = ic.table_name + AND ttc_company.column_name = ic.column_name + AND ttc_company.company_code = $2 WHERE ic.table_schema = 'public' AND ic.table_name = $1 - ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position + ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position `; - const columns = await query(schemaQuery, [tableName]); + const columns = await query(schemaQuery, [tableName, companyCode]); if (columns.length === 0) { res.status(404).json({ diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts index 66250bf9..f57b6822 100644 --- a/backend-node/src/controllers/categoryValueCascadingController.ts +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async ( const group = groupResult.rows[0]; - // 부모 카테고리 값 조회 (table_column_category_values에서) + // 부모 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async ( const group = groupResult.rows[0]; - // 자식 카테고리 값 조회 (table_column_category_values에서) + // 자식 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 48b55d18..4ba6013a 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -2,6 +2,7 @@ import { Response } from "express"; import { dynamicFormService } from "../services/dynamicFormService"; import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; import { AuthenticatedRequest } from "../types/auth"; +import { formatPgError } from "../utils/pgErrorUtil"; // 폼 데이터 저장 (기존 버전 - 레거시 지원) export const saveFormData = async ( @@ -68,9 +69,12 @@ export const saveFormData = async ( }); } catch (error: any) { console.error("❌ 폼 데이터 저장 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 저장에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -118,9 +122,12 @@ export const saveFormDataEnhanced = async ( res.json(result); } catch (error: any) { console.error("❌ 개선된 폼 데이터 저장 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 저장에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -163,9 +170,12 @@ export const updateFormData = async ( }); } catch (error: any) { console.error("❌ 폼 데이터 업데이트 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 업데이트에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -216,9 +226,12 @@ export const updateFormDataPartial = async ( }); } catch (error: any) { console.error("❌ 부분 업데이트 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "부분 업데이트에 실패했습니다.", + message: friendlyMsg, }); } }; diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 15e05473..de9ee95f 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -417,10 +417,10 @@ export class EntityJoinController { // 1. 현재 테이블의 Entity 조인 설정 조회 const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); - // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 + // 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 const joinConfigs = allJoinConfigs.filter( - (config) => config.referenceTable !== "table_column_category_values" + (config) => config.referenceTable !== "category_values" ); if (joinConfigs.length === 0) { diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts new file mode 100644 index 00000000..ee500f5e --- /dev/null +++ b/backend-node/src/controllers/moldController.ts @@ -0,0 +1,497 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================ +// 금형 마스터 CRUD +// ============================================ + +export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { mold_code, mold_name, mold_type, operation_status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (mold_code) { + conditions.push(`mold_code ILIKE $${paramIndex}`); + params.push(`%${mold_code}%`); + paramIndex++; + } + if (mold_name) { + conditions.push(`mold_name ILIKE $${paramIndex}`); + params.push(`%${mold_name}%`); + paramIndex++; + } + if (mold_type) { + conditions.push(`mold_type = $${paramIndex}`); + params.push(mold_type); + paramIndex++; + } + if (operation_status) { + conditions.push(`operation_status = $${paramIndex}`); + params.push(operation_status); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`; + const result = await query(sql, params); + + logger.info("금형 목록 조회", { companyCode, count: result.length }); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("금형 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`; + params = [moldCode]; + } else { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`; + params = [moldCode, companyCode]; + } + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("금형 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + mold_code, mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + if (!mold_code || !mold_name) { + res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_mng ( + company_code, mold_code, mold_name, mold_type, category, + manufacturer, manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + RETURNING * + `; + const params = [ + companyCode, mold_code, mold_name, mold_type || null, category || null, + manufacturer || null, manufacturing_number || null, manufacturing_date || null, + cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0, + operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId, + ]; + + const result = await query(sql, params); + logger.info("금형 생성", { companyCode, moldCode: mold_code }); + res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." }); + return; + } + logger.error("금형 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + const { + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + const sql = ` + UPDATE mold_mng SET + mold_name = COALESCE($1, mold_name), + mold_type = $2, category = $3, manufacturer = $4, + manufacturing_number = $5, manufacturing_date = $6, + cavity_count = COALESCE($7, cavity_count), + shot_count = COALESCE($8, shot_count), + mold_quantity = COALESCE($9, mold_quantity), + base_input_qty = COALESCE($10, base_input_qty), + operation_status = COALESCE($11, operation_status), + remarks = $12, image_path = $13, memo = $14, + updated_date = NOW() + WHERE mold_code = $15 AND company_code = $16 + RETURNING * + `; + const params = [ + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, + moldCode, companyCode, + ]; + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 수정", { companyCode, moldCode }); + res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." }); + } catch (error: any) { + logger.error("금형 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + // 관련 데이터 먼저 삭제 + await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + + const result = await query( + `DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`, + [moldCode, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 삭제", { companyCode, moldCode }); + res.json({ success: true, message: "금형이 삭제되었습니다." }); + } catch (error: any) { + logger.error("금형 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 CRUD +// ============================================ + +export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("일련번호 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; + + let finalSerialNumber = serial_number; + + // 일련번호가 비어있으면 채번 규칙으로 자동 생성 + if (!finalSerialNumber) { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + "mold_serial", + "serial_number" + ); + + if (rule) { + // formData에 mold_code를 포함 (reference 파트에서 참조) + const formData = { mold_code: moldCode, ...req.body }; + finalSerialNumber = await numberingRuleService.allocateCode( + rule.ruleId, + companyCode, + formData + ); + logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId }); + } + } catch (numError: any) { + logger.error("일련번호 자동 채번 실패", { error: numError.message }); + } + } + + if (!finalSerialNumber) { + res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." }); + return; + } + + const sql = ` + INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING * + `; + const params = [ + companyCode, moldCode, finalSerialNumber, status || "STORED", + progress || 0, work_description || null, manager || null, + completion_date || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." }); + return; + } + logger.error("일련번호 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "일련번호가 삭제되었습니다." }); + } catch (error: any) { + logger.error("일련번호 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 점검항목 CRUD +// ============================================ + +export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("점검항목 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + inspection_item, inspection_cycle, inspection_method, + inspection_content, lower_limit, upper_limit, unit, + is_active, checklist, remarks, + } = req.body; + + if (!inspection_item) { + res.status(400).json({ success: false, message: "점검항목명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_inspection_item ( + company_code, mold_code, inspection_item, inspection_cycle, + inspection_method, inspection_content, lower_limit, upper_limit, + unit, is_active, checklist, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + const params = [ + companyCode, moldCode, inspection_item, inspection_cycle || null, + inspection_method || null, inspection_content || null, + lower_limit || null, upper_limit || null, unit || null, + is_active || "Y", checklist || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." }); + } catch (error: any) { + logger.error("점검항목 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "점검항목이 삭제되었습니다." }); + } catch (error: any) { + logger.error("점검항목 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 부품 CRUD +// ============================================ + +export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("부품 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + part_name, replacement_cycle, unit, specification, + manufacturer, manufacturer_code, image_path, remarks, + } = req.body; + + if (!part_name) { + res.status(400).json({ success: false, message: "부품명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_part ( + company_code, mold_code, part_name, replacement_cycle, + unit, specification, manufacturer, manufacturer_code, + image_path, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING * + `; + const params = [ + companyCode, moldCode, part_name, replacement_cycle || null, + unit || null, specification || null, manufacturer || null, + manufacturer_code || null, image_path || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." }); + } catch (error: any) { + logger.error("부품 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "부품이 삭제되었습니다." }); + } catch (error: any) { + logger.error("부품 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 현황 집계 +// ============================================ + +export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use, + COUNT(*) FILTER (WHERE status = 'REPAIR') as repair, + COUNT(*) FILTER (WHERE status = 'STORED') as stored, + COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed + FROM mold_serial + WHERE mold_code = $1 AND company_code = $2 + `; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("일련번호 현황 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 8a9f6b56..a3887ab8 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -405,6 +405,30 @@ router.post( } ); +// 테이블+컬럼 기반 채번 규칙 조회 (메인 API) +router.get( + "/by-column/:tableName/:columnName", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); + // ==================== 테스트 테이블용 API ==================== // [테스트] 테스트 테이블에서 채번 규칙 목록 조회 diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index b8436176..0c35fdbd 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3019,3 +3019,72 @@ export async function toggleColumnUnique( }); } } + +/** + * 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일) + * + * @route GET /api/table-management/numbering-columns + */ +export async function getNumberingColumnsByCompany( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode; + + logger.info("회사별 채번 컬럼 조회 요청", { companyCode }); + + if (!companyCode) { + res.status(400).json({ + success: false, + message: "회사 코드를 확인할 수 없습니다.", + }); + return; + } + + const { getPool } = await import("../database/db"); + const pool = getPool(); + + const targetCompanyCode = companyCode === "*" ? "*" : companyCode; + + const columnsQuery = ` + SELECT DISTINCT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ttc.column_name AS "columnName", + COALESCE( + ttc.column_label, + initcap(replace(ttc.column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM table_type_columns ttc + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ttc.input_type = 'numbering' + AND ttc.company_code = $1 + ORDER BY ttc.table_name, ttc.column_name + `; + + const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]); + + logger.info("채번 컬럼 조회 완료", { + companyCode, + rowCount: columnsResult.rows.length, + }); + + res.json({ + success: true, + data: columnsResult.rows, + }); + } catch (error: any) { + logger.error("채번 컬럼 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "채번 컬럼 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 54d8f0a2..baa5ce38 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -47,7 +47,10 @@ export const errorHandler = ( error = new AppError("참조 무결성 제약 조건 위반입니다.", 400); } else if (pgError.code === "23502") { // not_null_violation - error = new AppError("필수 입력값이 누락되었습니다.", 400); + const colName = pgError.column || ""; + const tableName = pgError.table || ""; + const detail = colName ? ` [${tableName}.${colName}]` : ""; + error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400); } else if (pgError.code.startsWith("23")) { // 기타 무결성 제약 조건 위반 error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); @@ -84,6 +87,7 @@ export const errorHandler = ( // 응답 전송 res.status(statusCode).json({ success: false, + message: message, error: { message: message, ...(process.env.NODE_ENV === "development" && { stack: error.stack }), diff --git a/backend-node/src/routes/aiAssistantProxy.ts b/backend-node/src/routes/aiAssistantProxy.ts new file mode 100644 index 00000000..64012976 --- /dev/null +++ b/backend-node/src/routes/aiAssistantProxy.ts @@ -0,0 +1,31 @@ +/** + * AI 어시스턴트 API 프록시 + * - /api/ai/v1/* 요청을 AI 서비스(기본 3100 포트)로 전달 + * - VEXPLOR와 같은 서비스로 쓰려면: 프론트(9771) → 백엔드(8080) → 여기서 3100으로 프록시 + */ +import { createProxyMiddleware } from "http-proxy-middleware"; +import type { RequestHandler } from "express"; + +const AI_SERVICE_URL = + process.env.AI_ASSISTANT_SERVICE_URL || "http://127.0.0.1:3100"; + +const aiAssistantProxy: RequestHandler = createProxyMiddleware({ + target: AI_SERVICE_URL, + changeOrigin: true, + pathRewrite: { "^/api/ai/v1": "/api/v1" }, + // 대상 서비스 미기동 시 502 등 에러 처리 (v3 타입에 없을 수 있음) + onError: (_err, _req, res) => { + if (!res.headersSent) { + res.status(502).json({ + success: false, + error: { + code: "AI_SERVICE_UNAVAILABLE", + message: + "AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.", + }, + }); + } + }, +} as Parameters[0]); + +export default aiAssistantProxy; diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 7ed87a06..688b6cd7 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -2,7 +2,6 @@ // Phase 2-1B: 핵심 인증 API 구현 import { Router } from "express"; -import { checkAuthStatus } from "../middleware/authMiddleware"; import { AuthController } from "../controllers/authController"; const router = Router(); @@ -12,7 +11,7 @@ const router = Router(); * 인증 상태 확인 API * 기존 Java ApiLoginController.checkAuthStatus() 포팅 */ -router.get("/status", checkAuthStatus); +router.get("/status", AuthController.checkAuthStatus); /** * POST /api/auth/login diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 0bae3617..de83e720 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,6 +1,7 @@ import express from "express"; import { dataService } from "../services/dataService"; import { masterDetailExcelService } from "../services/masterDetailExcelService"; +import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { auditLogService } from "../services/auditLogService"; @@ -260,6 +261,117 @@ router.post( } ); +// ================================ +// 다중 테이블 엑셀 업로드 API +// ================================ + +/** + * 다중 테이블 자동 감지 + * GET /api/data/multi-table/auto-detect?rootTable=customer_mng + * + * 루트 테이블명만 넘기면 FK 관계를 자동 탐색하여 + * 완성된 TableChainConfig를 반환한다. + */ +router.get( + "/multi-table/auto-detect", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const rootTable = req.query.rootTable as string; + const screenId = req.query.screenId ? Number(req.query.screenId) : undefined; + const companyCode = req.user?.companyCode || "*"; + + if (!rootTable) { + return res.status(400).json({ + success: false, + message: "rootTable 파라미터가 필요합니다.", + }); + } + + const config = await multiTableExcelService.autoDetectTableChain( + rootTable, + companyCode, + screenId + ); + + return res.json({ success: true, data: config }); + } catch (error: any) { + console.error("다중 테이블 자동 감지 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "자동 감지 중 오류가 발생했습니다.", + }); + } + } +); + +/** + * 다중 테이블 엑셀 업로드 + * POST /api/data/multi-table/upload + * + * Body: { config: TableChainConfig, modeId: string, rows: Record[] } + */ +router.post( + "/multi-table/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { config, modeId, rows } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!config || !modeId || !rows || !Array.isArray(rows)) { + return res.status(400).json({ + success: false, + message: "config, modeId, rows 배열이 필요합니다.", + }); + } + + if (rows.length === 0) { + return res.status(400).json({ + success: false, + message: "업로드할 데이터가 없습니다.", + }); + } + + console.log(`다중 테이블 엑셀 업로드:`, { + configId: config.id, + modeId, + rowCount: rows.length, + companyCode, + userId, + }); + + const result = await multiTableExcelService.uploadMultiTable( + config as TableChainConfig, + modeId, + rows, + companyCode, + userId + ); + + const summaryParts = result.results.map( + (r) => `${r.tableName}: 신규 ${r.inserted}건, 수정 ${r.updated}건` + ); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? summaryParts.join(" / ") + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("다중 테이블 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "다중 테이블 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + // ================================ // 기존 데이터 API // ================================ diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts new file mode 100644 index 00000000..76eaa67d --- /dev/null +++ b/backend-node/src/routes/moldRoutes.ts @@ -0,0 +1,49 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getMoldList, + getMoldDetail, + createMold, + updateMold, + deleteMold, + getMoldSerials, + createMoldSerial, + deleteMoldSerial, + getMoldInspections, + createMoldInspection, + deleteMoldInspection, + getMoldParts, + createMoldPart, + deleteMoldPart, + getMoldSerialSummary, +} from "../controllers/moldController"; + +const router = express.Router(); +router.use(authenticateToken); + +// 금형 마스터 +router.get("/", getMoldList); +router.get("/:moldCode", getMoldDetail); +router.post("/", createMold); +router.put("/:moldCode", updateMold); +router.delete("/:moldCode", deleteMold); + +// 일련번호 +router.get("/:moldCode/serials", getMoldSerials); +router.post("/:moldCode/serials", createMoldSerial); +router.delete("/serials/:id", deleteMoldSerial); + +// 일련번호 현황 집계 +router.get("/:moldCode/serial-summary", getMoldSerialSummary); + +// 점검항목 +router.get("/:moldCode/inspections", getMoldInspections); +router.post("/:moldCode/inspections", createMoldInspection); +router.delete("/inspections/:id", deleteMoldInspection); + +// 부품 +router.get("/:moldCode/parts", getMoldParts); +router.post("/:moldCode/parts", createMoldPart); +router.delete("/parts/:id", deleteMoldPart); + +export default router; diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 24ef3af0..730572d8 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { numberingRuleService } from "../services/numberingRuleService"; const router = Router(); @@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean { return SAFE_IDENTIFIER.test(name); } +interface AutoGenMappingInfo { + numberingRuleId: string; + targetColumn: string; + showResultModal?: boolean; +} + +interface HiddenMappingInfo { + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; +} + interface MappingInfo { targetTable: string; columnMapping: Record; + autoGenMappings?: AutoGenMappingInfo[]; + hiddenMappings?: HiddenMappingInfo[]; } interface StatusConditionRule { @@ -44,7 +62,8 @@ interface StatusChangeRuleBody { } interface ExecuteActionBody { - action: string; + action?: string; + tasks?: TaskBody[]; data: { items?: Record[]; fieldValues?: Record; @@ -54,6 +73,36 @@ interface ExecuteActionBody { field?: MappingInfo | null; }; statusChanges?: StatusChangeRuleBody[]; + cartChanges?: { + toCreate?: Record[]; + toUpdate?: Record[]; + toDelete?: (string | number)[]; + }; +} + +interface TaskBody { + id: string; + type: string; + targetTable?: string; + targetColumn?: string; + operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional"; + valueSource?: "fixed" | "linked" | "reference"; + fixedValue?: string; + sourceField?: string; + referenceTable?: string; + referenceColumn?: string; + referenceJoinKey?: string; + conditionalValue?: ConditionalValueRule; + // db-conditional 전용 (DB 컬럼 간 비교 후 값 판정) + compareColumn?: string; + compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<="; + compareWith?: string; + dbThenValue?: string; + dbElseValue?: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + cartScreenId?: string; } function resolveStatusValue( @@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody; + const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody; const items = data?.items ?? []; const fieldValues = data?.fieldValues ?? {}; logger.info("[pop/execute-action] 요청", { - action, + action: action ?? "task-list", companyCode, userId, itemCount: items.length, hasFieldValues: Object.keys(fieldValues).length > 0, hasMappings: !!mappings, statusChangeCount: statusChanges?.length ?? 0, + taskCount: tasks?.length ?? 0, + hasCartChanges: !!cartChanges, }); await client.query("BEGIN"); let processedCount = 0; let insertedCount = 0; + let deletedCount = 0; + const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; - if (action === "inbound-confirm") { + // ======== v2: tasks 배열 기반 처리 ======== + if (tasks && tasks.length > 0) { + for (const task of tasks) { + switch (task.type) { + case "data-save": { + // 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용) + const cardMapping = mappings?.cardList; + const fieldMapping = mappings?.field; + + if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) { + if (!isSafeIdentifier(cardMapping.targetTable)) { + throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); + } + + for (const item of items) { + const columns: string[] = ["company_code"]; + const values: unknown[] = [companyCode]; + + for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + columns.push(`"${targetColumn}"`); + values.push(item[sourceField] ?? null); + } + + if (fieldMapping?.targetTable === cardMapping.targetTable) { + for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + if (columns.includes(`"${targetColumn}"`)) continue; + columns.push(`"${targetColumn}"`); + values.push(fieldValues[sourceField] ?? null); + } + } + + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + for (const ag of allAutoGen) { + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + if (columns.includes(`"${ag.targetColumn}"`)) continue; + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + + if (columns.length > 1) { + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + await client.query( + `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`, + values, + ); + insertedCount++; + } + } + } + break; + } + + case "data-update": { + if (!task.targetTable || !task.targetColumn) break; + if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break; + + const opType = task.operationType ?? "assign"; + const valSource = task.valueSource ?? "fixed"; + const lookupMode = task.lookupMode ?? "auto"; + + let itemField: string; + let pkColumn: string; + + if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) { + if (!isSafeIdentifier(task.manualPkColumn)) break; + itemField = task.manualItemField; + pkColumn = task.manualPkColumn; + } else if (task.targetTable === "cart_items") { + itemField = "__cart_id"; + pkColumn = "id"; + } else { + itemField = "__cart_row_key"; + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + pkColumn = pkResult.rows[0]?.attname || "id"; + } + + const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean); + if (lookupValues.length === 0) break; + + if (opType === "conditional" && task.conditionalValue) { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + [resolved, companyCode, lookupValues[i]], + ); + processedCount++; + } + } else if (opType === "db-conditional") { + // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; + if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; + + const thenVal = task.dbThenValue ?? ""; + const elseVal = task.dbElseValue ?? ""; + const op = task.compareOperator; + const validOps = ["=", "!=", ">", "<", ">=", "<="]; + if (!validOps.includes(op)) break; + + const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + + const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + [thenVal, elseVal, companyCode, ...lookupValues], + ); + processedCount += lookupValues.length; + } else { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + let value: unknown; + + if (valSource === "linked") { + value = item[task.sourceField ?? ""] ?? null; + } else { + value = task.fixedValue ?? ""; + } + + let setSql: string; + if (opType === "add") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`; + } else if (opType === "subtract") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`; + } else if (opType === "multiply") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`; + } else if (opType === "divide") { + setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`; + } else { + setSql = `"${task.targetColumn}" = $1`; + } + + await client.query( + `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + [value, companyCode, lookupValues[i]], + ); + processedCount++; + } + } + + logger.info("[pop/execute-action] data-update 실행", { + table: task.targetTable, + column: task.targetColumn, + opType, + count: lookupValues.length, + }); + break; + } + + case "data-delete": { + if (!task.targetTable) break; + if (!isSafeIdentifier(task.targetTable)) break; + + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + const pkCol = pkResult.rows[0]?.attname || "id"; + const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean); + + if (deleteKeys.length > 0) { + const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`, + [companyCode, ...deleteKeys], + ); + deletedCount += deleteKeys.length; + } + break; + } + + case "cart-save": { + // cartChanges 처리 (M-9에서 확장) + if (!cartChanges) break; + const { toCreate, toUpdate, toDelete } = cartChanges; + + if (toCreate && toCreate.length > 0) { + for (const item of toCreate) { + const cols = Object.keys(item).filter(isSafeIdentifier); + if (cols.length === 0) continue; + const allCols = ["company_code", ...cols.map((c) => `"${c}"`)]; + const allVals = [companyCode, ...cols.map((c) => item[c])]; + const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", "); + await client.query( + `INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`, + allVals, + ); + insertedCount++; + } + } + + if (toUpdate && toUpdate.length > 0) { + for (const item of toUpdate) { + const id = item.id; + if (!id) continue; + const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c)); + if (cols.length === 0) continue; + const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", "); + await client.query( + `UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`, + [id, companyCode, ...cols.map((c) => item[c])], + ); + processedCount++; + } + } + + if (toDelete && toDelete.length > 0) { + const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...toDelete], + ); + deletedCount += toDelete.length; + } + + logger.info("[pop/execute-action] cart-save 실행", { + created: toCreate?.length ?? 0, + updated: toUpdate?.length ?? 0, + deleted: toDelete?.length ?? 0, + }); + break; + } + + default: + logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type }); + } + } + } + // ======== v1 레거시: action 기반 처리 ======== + else if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) const cardMapping = mappings?.cardList; const fieldMapping = mappings?.field; @@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + + // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + for (const ag of allAutoGen) { + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + if (columns.includes(`"${ag.targetColumn}"`)) continue; + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, + companyCode, + { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, + targetColumn: ag.targetColumn, + generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { + ruleId: ag.numberingRuleId, + error: err.message, + }); + } + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp await client.query("COMMIT"); logger.info("[pop/execute-action] 완료", { - action, + action: action ?? "task-list", companyCode, processedCount, insertedCount, + deletedCount, }); return res.json({ success: true, - message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, - data: { processedCount, insertedCount }, + message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`, + data: { processedCount, insertedCount, deletedCount, generatedCodes }, }); } catch (error: any) { await client.query("ROLLBACK"); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index a8964e99..92449cf6 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 + getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 @@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/category-columns", getCategoryColumnsByCompany); +/** + * 회사 기준 모든 채번 타입 컬럼 조회 + * GET /api/table-management/numbering-columns + */ +router.get("/numbering-columns", getNumberingColumnsByCompany); + /** * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 * GET /api/table-management/menu/:menuObjid/category-columns diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index a37942e1..1f345727 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -92,7 +92,7 @@ export class EntityJoinService { if (column.input_type === "category") { // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || "table_column_category_values"; + referenceTable = referenceTable || "category_values"; referenceColumn = referenceColumn || "value_code"; displayColumn = displayColumn || "value_label"; @@ -308,7 +308,7 @@ export class EntityJoinService { const usedAliasesForColumns = new Set(); // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 - // (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) + // (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( @@ -336,7 +336,7 @@ export class EntityJoinService { counter++; } usedAliasesForColumns.add(alias); - // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) + // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); logger.info( @@ -455,9 +455,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) - if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 @@ -528,10 +529,10 @@ export class EntityJoinService { return "join"; } - // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 캐시 불가 + if (config.referenceTable === "category_values") { logger.info( - `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` + `🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}` ); return "join"; } @@ -723,10 +724,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === "table_column_category_values") { - // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링만) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 40cd58e3..a0370ed6 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -494,7 +494,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 - * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + * numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회 */ private async detectNumberingRuleForColumn( tableName: string, @@ -502,32 +502,58 @@ class MasterDetailExcelService { companyCode?: string ): Promise<{ numberingRuleId: string } | null> { try { - // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + // 1. table_type_columns에서 numbering 타입인지 확인 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($3, '*')` : `AND company_code = '*'`; - const params = companyCode && companyCode !== "*" + const ttcParams = companyCode && companyCode !== "*" ? [tableName, columnName, companyCode] : [tableName, columnName]; - const result = await query( - `SELECT input_type, detail_settings, company_code - FROM table_type_columns + const ttcResult = await query( + `SELECT input_type FROM table_type_columns WHERE table_name = $1 AND column_name = $2 ${companyCondition} - ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, - params + AND input_type = 'numbering' LIMIT 1`, + ttcParams ); - // 채번 타입인 행 찾기 (회사별 우선) - for (const row of result) { - if (row.input_type === "numbering") { - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - - if (settings?.numberingRuleId) { - return { numberingRuleId: settings.numberingRuleId }; - } + if (ttcResult.length === 0) return null; + + // 2. numbering_rules에서 table_name + column_name으로 규칙 조회 + const ruleCompanyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const ruleParams = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + ruleParams + ); + + if (ruleResult.length > 0) { + return { numberingRuleId: ruleResult[0].rule_id }; + } + + // 3. fallback: detail_settings.numberingRuleId (하위 호환) + const fallbackResult = await query( + `SELECT detail_settings FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + AND input_type = 'numbering' + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + ttcParams + ); + + for (const row of fallbackResult) { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + return { numberingRuleId: settings.numberingRuleId }; } } @@ -540,7 +566,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 - * 회사별 설정 우선, 공통(*) 설정 fallback + * numbering_rules 테이블에서 table_name + column_name으로 직접 조회 * @returns Map */ private async detectAllNumberingColumns( @@ -549,6 +575,7 @@ class MasterDetailExcelService { ): Promise> { const numberingCols = new Map(); try { + // 1. table_type_columns에서 numbering 타입 컬럼 목록 조회 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($2, '*')` : `AND company_code = '*'`; @@ -556,22 +583,26 @@ class MasterDetailExcelService { ? [tableName, companyCode] : [tableName]; - const result = await query( - `SELECT column_name, detail_settings, company_code - FROM table_type_columns - WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} - ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + const ttcResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`, params ); - // 컬럼별로 회사 설정 우선 적용 - for (const row of result) { - if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - if (settings?.numberingRuleId) { - numberingCols.set(row.column_name, settings.numberingRuleId); + // 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회 + for (const row of ttcResult) { + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + companyCode && companyCode !== "*" + ? [tableName, row.column_name, companyCode] + : [tableName, row.column_name] + ); + + if (ruleResult.length > 0) { + numberingCols.set(row.column_name, ruleResult[0].rule_id); } } diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 747d5427..af755316 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1127,6 +1127,16 @@ export class MenuCopyService { logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑"); await this.updateMenuUrls(menuIdMap, screenIdMap, client); + // === 6.7단계: screen_group_screens 복제 === + logger.info("\n🏷️ [6.7단계] screen_group_screens 복제"); + await this.copyScreenGroupScreens( + screenIds, + screenIdMap, + sourceCompanyCode, + targetCompanyCode, + client + ); + // === 7단계: 테이블 타입 설정 복사 === if (additionalCopyOptions?.copyTableTypeColumns) { logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); @@ -2108,6 +2118,26 @@ export class MenuCopyService { logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + // screen_group_id 재매핑 맵 생성 (source company → target company) + const screenGroupIdMap = new Map(); + const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[]; + if (sourceGroupIds.length > 0) { + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + screenGroupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`); + } + // 위상 정렬 (부모 먼저 삽입) const sortedMenus = this.topologicalSortMenus(menus); @@ -2252,7 +2282,7 @@ export class MenuCopyService { menu.menu_code, sourceMenuObjid, menu.menu_icon, - menu.screen_group_id, + menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null, ] ); @@ -2500,6 +2530,82 @@ export class MenuCopyService { logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`); } + /** + * screen_group_screens 복제 (화면-스크린그룹 매핑) + */ + private async copyScreenGroupScreens( + screenIds: Set, + screenIdMap: Map, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.size === 0 || screenIdMap.size === 0) { + logger.info("📭 screen_group_screens 복제 대상 없음"); + return; + } + + // 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리) + await client.query( + `DELETE FROM screen_group_screens WHERE company_code = $1`, + [targetCompanyCode] + ); + + // 소스 회사의 screen_group_screens 조회 + const sourceScreenIds = Array.from(screenIds); + const sourceResult = await client.query<{ + group_id: number; + screen_id: number; + screen_role: string; + display_order: number; + is_default: string; + }>( + `SELECT group_id, screen_id, screen_role, display_order, is_default + FROM screen_group_screens + WHERE company_code = $1 AND screen_id = ANY($2)`, + [sourceCompanyCode, sourceScreenIds] + ); + + if (sourceResult.rows.length === 0) { + logger.info("📭 소스에 screen_group_screens 없음"); + return; + } + + // screen_group ID 매핑 (source group_name → target group_id) + const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))]; + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + const groupIdMap = new Map(); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + groupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + + let insertedCount = 0; + for (const row of sourceResult.rows) { + const newGroupId = groupIdMap.get(row.group_id); + const newScreenId = screenIdMap.get(row.screen_id); + if (!newGroupId || !newScreenId) continue; + + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, 'system') + ON CONFLICT DO NOTHING`, + [newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode] + ); + insertedCount++; + } + + logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`); + } + /** * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ @@ -2992,7 +3098,7 @@ export class MenuCopyService { } const allValuesResult = await client.query( - `SELECT * FROM table_column_category_values + `SELECT * FROM category_values WHERE company_code = $1 AND (${columnConditions.join(" OR ")}) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, @@ -3009,7 +3115,7 @@ export class MenuCopyService { // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code - FROM table_column_category_values WHERE company_code = $1`, + FROM category_values WHERE company_code = $1`, [targetCompanyCode] ); const existingValueKeys = new Map( @@ -3088,7 +3194,7 @@ export class MenuCopyService { }); const insertResult = await client.query( - `INSERT INTO table_column_category_values ( + `INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, created_at, created_by, company_code, menu_objid diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts new file mode 100644 index 00000000..d18f479b --- /dev/null +++ b/backend-node/src/services/multiTableExcelService.ts @@ -0,0 +1,1074 @@ +/** + * 다중 테이블 엑셀 업로드 범용 서비스 + * + * 하나의 플랫 엑셀 데이터를 계층적 다중 테이블(2~N개)에 + * 트랜잭션으로 일괄 UPSERT하는 범용 엔진. + * + * 적용 사례: + * - 거래처: customer_mng → customer_item_mapping → customer_item_prices + * - 공급업체: supplier_mng → supplier_item_mapping → supplier_item_prices + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** 테이블 계층 레벨 설정 */ +export interface TableLevel { + tableName: string; + label: string; + parentFkColumn?: string; + parentRefColumn?: string; + upsertMode: "upsert" | "insert"; + upsertKeyColumns?: string[]; + columns: ColumnDef[]; +} + +/** 컬럼 정의 */ +export interface ColumnDef { + dbColumn: string; + excelHeader: string; + required: boolean; + defaultValue?: any; +} + +/** 업로드 모드 정의 */ +export interface UploadMode { + id: string; + label: string; + description: string; + activeLevels: number[]; +} + +/** 테이블 체인 설정 (범용) */ +export interface TableChainConfig { + id: string; + name: string; + description: string; + levels: TableLevel[]; + uploadModes: UploadMode[]; +} + +/** 레벨별 업로드 결과 */ +export interface LevelResult { + tableName: string; + inserted: number; + updated: number; +} + +/** 전체 업로드 결과 */ +export interface MultiTableUploadResult { + success: boolean; + results: LevelResult[]; + totalRows: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MultiTableExcelService { + /** + * 다중 테이블 엑셀 업로드 실행 + * + * @param config 테이블 체인 설정 + * @param modeId 업로드 모드 ID + * @param rows 엑셀에서 파싱된 플랫 JSON 배열 (excelHeader 기준) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + */ + async uploadMultiTable( + config: TableChainConfig, + modeId: string, + rows: Record[], + companyCode: string, + userId: string + ): Promise { + const result: MultiTableUploadResult = { + success: false, + results: [], + totalRows: rows.length, + errors: [], + }; + + const mode = config.uploadModes.find((m) => m.id === modeId); + if (!mode) { + result.errors.push(`업로드 모드를 찾을 수 없습니다: ${modeId}`); + return result; + } + + const activeLevels = mode.activeLevels + .map((i) => config.levels[i]) + .filter(Boolean); + + if (activeLevels.length === 0) { + result.errors.push("활성화된 테이블 레벨이 없습니다."); + return result; + } + + // 레벨별 결과 초기화 + for (const level of activeLevels) { + result.results.push({ + tableName: level.tableName, + inserted: 0, + updated: 0, + }); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 각 레벨의 실제 DB 컬럼 존재 여부 캐시 + const existingColsCache = new Map>(); + for (const level of activeLevels) { + const colsResult = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [level.tableName] + ); + existingColsCache.set( + level.tableName, + new Set(colsResult.rows.map((r: any) => r.column_name)) + ); + } + + // 엑셀 헤더 → DB 컬럼 매핑 테이블 구축 (활성 레벨만) + const headerToColumn = new Map(); + for (let i = 0; i < activeLevels.length; i++) { + for (const col of activeLevels[i].columns) { + headerToColumn.set(col.excelHeader, { + levelIndex: i, + dbColumn: col.dbColumn, + }); + } + } + + // 행 단위로 처리 (트랜잭션 내) + // 부모 ID 캐시: 각 레벨에서 upsertKey → 반환된 PK 매핑 + const pkCaches: Map[] = activeLevels.map( + () => new Map() + ); + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + const row = rows[rowIdx]; + + try { + let parentId: string | number | null = null; + let parentLevelData: Record = {}; + + for (let lvlIdx = 0; lvlIdx < activeLevels.length; lvlIdx++) { + const level = activeLevels[lvlIdx]; + const levelResult = result.results[lvlIdx]; + const existingCols = existingColsCache.get(level.tableName)!; + + const levelData: Record = {}; + for (const colDef of level.columns) { + const excelValue = row[colDef.excelHeader]; + if (excelValue !== undefined && excelValue !== null && excelValue !== "") { + levelData[colDef.dbColumn] = excelValue; + } else if (colDef.defaultValue !== undefined) { + levelData[colDef.dbColumn] = colDef.defaultValue; + } + } + + const hasAnyData = Object.keys(levelData).length > 0; + if (!hasAnyData && lvlIdx > 0) { + break; + } + + const missingRequired = level.columns + .filter((c) => c.required && !levelData[c.dbColumn]) + .map((c) => c.excelHeader); + + if (missingRequired.length > 0) { + result.errors.push( + `[행 ${rowIdx + 1}] ${level.label} 필수 컬럼 누락: ${missingRequired.join(", ")}` + ); + break; + } + + // 부모 FK 주입: parentRefColumn이 'id'가 아닌 경우 부모 데이터에서 해당 컬럼 값 사용 + if (lvlIdx > 0 && level.parentFkColumn && parentId !== null) { + if ( + level.parentRefColumn && + level.parentRefColumn !== "id" && + parentLevelData[level.parentRefColumn] !== undefined && + parentLevelData[level.parentRefColumn] !== null + ) { + levelData[level.parentFkColumn] = String( + parentLevelData[level.parentRefColumn] + ); + } else { + levelData[level.parentFkColumn] = String(parentId); + } + } + + if (existingCols.has("company_code")) { + levelData.company_code = companyCode; + } + if (existingCols.has("writer")) { + levelData.writer = userId; + } + + const upsertKey = level.upsertKeyColumns + ? level.upsertKeyColumns.map((k) => String(levelData[k] ?? "")).join("|||") + : null; + + let returnedId: string | number; + + if (level.upsertMode === "upsert" && upsertKey) { + const cachedId = pkCaches[lvlIdx].get(upsertKey); + if (cachedId !== undefined) { + returnedId = cachedId; + } else { + returnedId = await this.upsertRow( + client, + level, + levelData, + existingCols, + companyCode, + levelResult + ); + pkCaches[lvlIdx].set(upsertKey, returnedId); + } + } else { + returnedId = await this.insertRow( + client, + level.tableName, + levelData, + existingCols + ); + levelResult.inserted++; + } + + parentId = returnedId; + parentLevelData = { ...levelData }; + } + } catch (error: any) { + result.errors.push(`[행 ${rowIdx + 1}] 처리 실패: ${error.message}`); + logger.error(`[행 ${rowIdx + 1}] 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = + result.errors.length === 0 || + result.results.some((r) => r.inserted + r.updated > 0); + + logger.info("다중 테이블 엑셀 업로드 완료:", { + results: result.results, + errors: result.errors.length, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error("다중 테이블 엑셀 업로드 트랜잭션 실패:", error); + } finally { + client.release(); + } + + return result; + } + + /** + * UPSERT 행 처리 (존재하면 UPDATE, 없으면 INSERT) + * @returns 해당 행의 PK (id) + */ + private async upsertRow( + client: any, + level: TableLevel, + data: Record, + existingCols: Set, + companyCode: string, + levelResult: LevelResult + ): Promise { + const { tableName, upsertKeyColumns } = level; + + if (!upsertKeyColumns || upsertKeyColumns.length === 0) { + const id = await this.insertRow(client, tableName, data, existingCols); + levelResult.inserted++; + return id; + } + + // UPSERT 키로 기존 행 조회 + const whereClause = upsertKeyColumns + .map((col, i) => `"${col}" = $${i + 1}`) + .join(" AND "); + + const companyIdx = upsertKeyColumns.length + 1; + const companyWhere = existingCols.has("company_code") + ? ` AND company_code = $${companyIdx}` + : ""; + + const params = upsertKeyColumns.map((col) => data[col]); + if (existingCols.has("company_code")) { + params.push(companyCode); + } + + const existing = await client.query( + `SELECT id FROM "${tableName}" WHERE ${whereClause}${companyWhere} LIMIT 1`, + params + ); + + if (existing.rows.length > 0) { + // UPDATE + const existingId = existing.rows[0].id; + const skipCols = new Set([ + "id", + "company_code", + "created_date", + ...upsertKeyColumns, + ]); + const updateKeys = Object.keys(data).filter( + (k) => !skipCols.has(k) && existingCols.has(k) + ); + + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map((k) => data[k]); + const updatedDateClause = existingCols.has("updated_date") + ? `, updated_date = NOW()` + : ""; + + await client.query( + `UPDATE "${tableName}" SET ${setClauses.join(", ")}${updatedDateClause} + WHERE id = $${setValues.length + 1}`, + [...setValues, existingId] + ); + } + levelResult.updated++; + return existingId; + } else { + // INSERT + const id = await this.insertRow(client, tableName, data, existingCols); + levelResult.inserted++; + return id; + } + } + + /** + * 단순 INSERT 후 PK(id) 반환 + */ + private async insertRow( + client: any, + tableName: string, + data: Record, + existingCols: Set + ): Promise { + // DB에 실제 존재하는 컬럼만 필터 + const cols = Object.keys(data).filter( + (k) => existingCols.has(k) && k !== "id" && data[k] !== undefined + ); + + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map((k) => data[k]); + + const result = await client.query( + `INSERT INTO "${tableName}" (${colList.map((c) => `"${c}"`).join(", ")}) + VALUES (${valList.join(", ")}) + RETURNING id`, + values + ); + + return result.rows[0].id; + } + + /** + * 모드에 맞는 템플릿 데이터 생성 (엑셀 다운로드용) + */ + generateTemplateData( + config: TableChainConfig, + modeId: string + ): { headers: string[]; requiredHeaders: string[]; sampleRow: Record } { + const mode = config.uploadModes.find((m) => m.id === modeId); + if (!mode) { + throw new Error(`업로드 모드를 찾을 수 없습니다: ${modeId}`); + } + + const headers: string[] = []; + const requiredHeaders: string[] = []; + const sampleRow: Record = {}; + + for (const levelIdx of mode.activeLevels) { + const level = config.levels[levelIdx]; + if (!level) continue; + + for (const col of level.columns) { + headers.push(col.excelHeader); + if (col.required) { + requiredHeaders.push(col.excelHeader); + } + sampleRow[col.excelHeader] = col.required ? `(필수)` : ""; + } + } + + return { headers, requiredHeaders, sampleRow }; + } + + // ================================ + // 자동 감지 + // ================================ + + // 엑셀 업로드에서 제외할 시스템 컬럼 + private static SYSTEM_COLUMNS = new Set([ + "id", + "company_code", + "writer", + "created_date", + "updated_date", + "created_at", + "updated_at", + ]); + + /** + * 루트 테이블에서 자식/손자 테이블을 자동 탐색하여 + * TableChainConfig를 실시간으로 생성한다. + * + * @param screenId 화면 ID (선택). 제공 시 관련 화면의 테이블을 참고하여 + * 다수 자식 중 올바른 체인을 선택한다. + */ + async autoDetectTableChain( + rootTableName: string, + companyCode: string, + screenId?: number + ): Promise { + const pool = getPool(); + + // 1) 루트 테이블이 존재하는지 확인 + const tableCheck = await pool.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1`, + [rootTableName] + ); + if (tableCheck.rows.length === 0) { + throw new Error(`테이블이 존재하지 않습니다: ${rootTableName}`); + } + + // 2) 화면 컨텍스트에서 관련 테이블 세트 + 보이는 컬럼 추출 + let contextTables = new Set(); + let visibleColumnsMap = new Map>(); + + if (screenId) { + const ctx = await this.getScreenContext(pool, screenId, companyCode); + contextTables = ctx.contextTables; + visibleColumnsMap = ctx.visibleColumns; + } + + logger.info("자동 감지 시작", { + rootTableName, + screenId, + contextTables: [...contextTables], + visibleColumnsPerTable: Object.fromEntries( + [...visibleColumnsMap].map(([k, v]) => [k, [...v]]) + ), + }); + + // 3) 루트부터 재귀적으로 자식 탐색 (최대 3레벨) + const levels: TableLevel[] = []; + await this.buildLevelChain(pool, rootTableName, companyCode, levels, 0, 3, contextTables); + + // 3.5) 화면 레이아웃에서 보이는 컬럼으로 필터링 + if (visibleColumnsMap.size > 0) { + for (const level of levels) { + const visibleCols = visibleColumnsMap.get(level.tableName); + if (visibleCols && visibleCols.size > 0) { + const before = level.columns.length; + level.columns = level.columns.filter((col) => visibleCols.has(col.dbColumn)); + logger.info(`컬럼 필터링: ${level.tableName}`, { + before, + after: level.columns.length, + visibleCols: [...visibleCols], + }); + } + } + } + + // 4) 업로드 모드 자동 생성 + const uploadModes: UploadMode[] = []; + for (let depth = 0; depth < levels.length; depth++) { + const activeLevels = Array.from({ length: depth + 1 }, (_, i) => i); + const labels = activeLevels.map((i) => levels[i].label); + uploadModes.push({ + id: `auto_mode_${depth}`, + label: labels.join(" + "), + description: `${labels.join(", ")} 일괄 등록`, + activeLevels, + }); + } + + // 5) 라벨 생성 + const rootLabel = await this.getTableLabel(pool, rootTableName, companyCode); + + return { + id: rootTableName, + name: rootLabel, + description: `${rootLabel} 다중 테이블 엑셀 업로드 (자동 감지)`, + levels, + uploadModes, + }; + } + + /** + * 재귀적으로 테이블 계층 빌드 + * contextTables: 화면의 관련 테이블 세트 (자식 선택 시 우선순위에 사용) + */ + private async buildLevelChain( + pool: any, + tableName: string, + companyCode: string, + levels: TableLevel[], + depth: number, + maxDepth: number, + contextTables: Set + ): Promise { + if (depth >= maxDepth) return; + + const columns = await this.getTableColumns(pool, tableName, companyCode); + + const { upsertMode, upsertKeyColumns } = await this.detectUpsertKeys( + pool, + tableName + ); + + const label = await this.getTableLabel(pool, tableName, companyCode); + + let parentFkColumn: string | undefined; + let parentRefColumn: string | undefined; + if (depth > 0 && levels.length > 0) { + const parentTable = levels[depth - 1].tableName; + const fkInfo = await this.findFkColumn(pool, tableName, parentTable, companyCode); + if (fkInfo) { + parentFkColumn = fkInfo.fkColumn; + parentRefColumn = fkInfo.refColumn; + } + } + + const level: TableLevel = { + tableName, + label, + upsertMode, + upsertKeyColumns: upsertMode === "upsert" ? upsertKeyColumns : undefined, + columns, + ...(parentFkColumn ? { parentFkColumn, parentRefColumn } : {}), + }; + + levels.push(level); + + const childTables = await this.findChildTables(pool, tableName, companyCode); + if (childTables.length > 0) { + const bestChild = this.pickBestChild(tableName, childTables, contextTables); + await this.buildLevelChain(pool, bestChild, companyCode, levels, depth + 1, maxDepth, contextTables); + } + } + + /** + * 여러 자식 테이블 중 가장 관련성 높은 하나를 선택 + * + * 우선순위: + * 1. 화면 컨텍스트 테이블에 포함된 자식 (같은 화면 관련 모달이 사용하는 테이블) + * 2. 부모 테이블명의 접두사를 공유하는 자식 + * 3. 첫 번째 자식 + */ + private pickBestChild( + parentTable: string, + children: string[], + contextTables: Set + ): string { + if (children.length === 1) return children[0]; + + // 1순위: 화면 컨텍스트 테이블에 있는 자식 + if (contextTables.size > 0) { + const contextMatch = children.find((c) => contextTables.has(c)); + if (contextMatch) { + logger.info(`pickBestChild: 화면 컨텍스트 매칭 - ${contextMatch}`, { + parentTable, + candidates: children, + }); + return contextMatch; + } + } + + // 2순위: 부모 테이블의 접두사 매칭 (예: customer_mng → customer_item_mapping) + const parentPrefix = parentTable.split("_")[0]; + const prefixMatch = children.find((c) => c.startsWith(parentPrefix + "_")); + if (prefixMatch) return prefixMatch; + + return children[0]; + } + + /** + * 화면 ID로부터 관련 화면 정보를 통합 추출: + * 1) contextTables: 자식 테이블 선택에 사용할 관련 테이블 세트 + * 2) visibleColumns: 각 테이블별 화면에서 실제 사용 중인 컬럼 세트 + * + * 추출 전략: + * A) 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀 추적 + * B) 화면명 키워드로 관련 화면 검색 + * C) 같은 테이블의 등록/수정 폼 검색 + */ + private async getScreenContext( + pool: any, + screenId: number, + companyCode: string + ): Promise<{ + contextTables: Set; + visibleColumns: Map>; + }> { + const emptyResult = { + contextTables: new Set(), + visibleColumns: new Map>(), + }; + + const screenResult = await pool.query( + `SELECT screen_name, table_name FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + + if (screenResult.rows.length === 0) return emptyResult; + + const screenName: string = screenResult.rows[0].screen_name; + const rootTable: string = screenResult.rows[0].table_name; + const companyPrefix = screenName.split(/\s+/)[0] || ""; + + const contextTables = new Set(); + const collectedScreenIds = new Set(); + + // ─── A) 화면명 키워드로 관련 화면 검색 (자식 테이블 선택용) ─── + const suffixWords = new Set(["화면", "모달", "폼", "페이지"]); + const nameTokens = screenName.split(/\s+/); + const middleTokens = nameTokens.filter((t, i) => { + if (i === 0) return false; + if (suffixWords.has(t)) return false; + return t.length >= 2; + }); + + for (const token of middleTokens) { + const relatedResult = await pool.query( + `SELECT screen_id, table_name + FROM screen_definitions + WHERE screen_name LIKE $1 + AND table_name IS NOT NULL`, + [`%${token}%`] + ); + for (const row of relatedResult.rows) { + if (row.table_name && row.table_name !== rootTable) { + contextTables.add(row.table_name); + } + collectedScreenIds.add(row.screen_id); + } + } + + // ─── B) 모달 참조 체인 재귀 추적 (컬럼 추출용) ─── + await this.collectModalChain(pool, screenId, collectedScreenIds, 0, 4); + + // ─── C) 수집된 테이블의 등록/수정/입력 폼도 포함 (컬럼 추출용) ─── + const allTablesInChain = new Set([rootTable]); + if (collectedScreenIds.size > 0) { + const idArr = [...collectedScreenIds]; + const ph = idArr.map((_, i) => `$${i + 1}`).join(","); + const tableResult = await pool.query( + `SELECT DISTINCT table_name FROM screen_definitions WHERE screen_id IN (${ph}) AND table_name IS NOT NULL`, + idArr + ); + for (const row of tableResult.rows) { + allTablesInChain.add(row.table_name); + } + } + + for (const tbl of allTablesInChain) { + const formResult = await pool.query( + `SELECT sd.screen_id + FROM screen_definitions sd + JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id + WHERE sd.table_name = $1 + AND sd.screen_name LIKE $2 + AND (sd.screen_name LIKE '%등록%' OR sd.screen_name LIKE '%수정%' OR sd.screen_name LIKE '%입력%')`, + [tbl, `${companyPrefix}%`] + ); + for (const row of formResult.rows) { + collectedScreenIds.add(row.screen_id); + } + } + + // ─── 수집된 화면들의 layout_data에서 테이블별 컬럼 추출 ─── + const visibleColumns = new Map>(); + + if (collectedScreenIds.size > 0) { + const screenIdArray = [...collectedScreenIds]; + const placeholders = screenIdArray.map((_, i) => `$${i + 1}`).join(","); + + const layoutResult = await pool.query( + `SELECT sd.screen_id, sd.table_name, lv.layout_data + FROM screen_definitions sd + JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id + WHERE sd.screen_id IN (${placeholders}) + AND lv.layout_data IS NOT NULL`, + screenIdArray + ); + + for (const row of layoutResult.rows) { + const tableName = row.table_name; + if (!tableName) continue; + + if (tableName !== rootTable) { + contextTables.add(tableName); + } + + if (!visibleColumns.has(tableName)) { + visibleColumns.set(tableName, new Set()); + } + const colSet = visibleColumns.get(tableName)!; + this.extractColumnsFromLayout(row.layout_data, colSet); + } + } + + logger.info("화면 컨텍스트 추출", { + screenId, + screenName, + collectedScreenCount: collectedScreenIds.size, + contextTables: [...contextTables], + visibleColumnsPerTable: Object.fromEntries( + [...visibleColumns].map(([k, v]) => [k, [...v]]) + ), + }); + + return { contextTables, visibleColumns }; + } + + /** + * 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀적으로 추적 + * 최대 depth까지 모달 → 서브모달 → ... 체인을 따라감 + */ + private async collectModalChain( + pool: any, + screenId: number, + collected: Set, + depth: number, + maxDepth: number + ): Promise { + if (depth >= maxDepth || collected.has(screenId)) return; + collected.add(screenId); + + const layoutResult = await pool.query( + `SELECT lv.layout_data + FROM screen_layouts_v3 lv + WHERE lv.screen_id = $1 AND lv.layout_data IS NOT NULL`, + [screenId] + ); + + if (layoutResult.rows.length === 0) return; + + const layoutData = layoutResult.rows[0].layout_data; + const referencedIds = new Set(); + this.extractModalReferences(layoutData, referencedIds); + + for (const refId of referencedIds) { + await this.collectModalChain(pool, refId, collected, depth + 1, maxDepth); + } + } + + /** + * layout_data에서 targetScreenId, modalScreenId 값을 추출 + */ + private extractModalReferences(obj: any, refs: Set): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + if (item && typeof item === "object") { + this.extractModalReferences(item, refs); + } + } + return; + } + + for (const key of ["targetScreenId", "modalScreenId"]) { + const val = obj[key]; + if (val !== undefined && val !== null) { + const num = Number(val); + if (!isNaN(num) && num > 0) { + refs.add(num); + } + } + } + + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === "object") { + this.extractModalReferences(val, refs); + } + } + } + + /** + * layout_data JSON에서 사용 중인 컬럼명을 재귀적으로 추출 + * - columnName 속성 (v2-input, v2-select 등) + * - columns[].name 속성 (테이블 리스트, 분할 패널) + * - dot notation (supplier_mng.supplier_name) 은 JOIN이므로 제외 + */ + private extractColumnsFromLayout( + layoutData: any, + colSet: Set + ): void { + if (!layoutData || typeof layoutData !== "object") return; + + if (Array.isArray(layoutData)) { + for (const item of layoutData) { + if (item && typeof item === "object") { + this.extractColumnsFromLayout(item, colSet); + } + } + return; + } + + // columnName 속성 (폼 필드) + const cn = layoutData.columnName; + if (cn && typeof cn === "string" && cn.trim() && !cn.includes(".")) { + colSet.add(cn); + } + + // columns 배열 (리스트/테이블) + const cols = layoutData.columns; + if (Array.isArray(cols)) { + for (const col of cols) { + if (col && typeof col === "object") { + const name = col.name; + if (name && typeof name === "string" && !name.includes(".")) { + colSet.add(name); + } + } + } + } + + // 재귀 탐색 + for (const key of Object.keys(layoutData)) { + const val = layoutData[key]; + if (val && typeof val === "object") { + this.extractColumnsFromLayout(val, colSet); + } + } + } + + /** + * 특정 테이블을 참조하는 자식 테이블 목록 찾기 + * 1차: table_type_columns의 reference_table + * 2차: pg_constraint FK + */ + private async findChildTables( + pool: any, + parentTable: string, + companyCode: string + ): Promise { + // 1) table_type_columns에서 이 테이블을 reference_table로 가진 다른 테이블 검색 + const ttcResult = await pool.query( + `SELECT DISTINCT table_name + FROM table_type_columns + WHERE reference_table = $1 + AND table_name != $1 + AND company_code IN ($2, '*') + ORDER BY table_name`, + [parentTable, companyCode] + ); + + if (ttcResult.rows.length > 0) { + return ttcResult.rows.map((r: any) => r.table_name); + } + + // 2) fallback: pg_constraint FK + const fkResult = await pool.query( + `SELECT DISTINCT c2.relname AS child_table + FROM pg_constraint con + JOIN pg_class c1 ON con.confrelid = c1.oid + JOIN pg_class c2 ON con.conrelid = c2.oid + JOIN pg_namespace ns ON c2.relnamespace = ns.oid + WHERE ns.nspname = 'public' + AND c1.relname = $1 + AND con.contype = 'f' + AND c2.relname != $1 + ORDER BY c2.relname`, + [parentTable] + ); + + return fkResult.rows.map((r: any) => r.child_table); + } + + /** + * 자식 테이블에서 부모 테이블을 참조하는 FK 컬럼 찾기 + */ + private async findFkColumn( + pool: any, + childTable: string, + parentTable: string, + companyCode: string + ): Promise<{ fkColumn: string; refColumn: string } | null> { + // 1) table_type_columns + const ttcResult = await pool.query( + `SELECT column_name, reference_column + FROM table_type_columns + WHERE table_name = $1 + AND reference_table = $2 + AND company_code IN ($3, '*') + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + [childTable, parentTable, companyCode] + ); + + if (ttcResult.rows.length > 0) { + return { + fkColumn: ttcResult.rows[0].column_name, + refColumn: ttcResult.rows[0].reference_column || "id", + }; + } + + // 2) pg_constraint FK + const fkResult = await pool.query( + `SELECT a_child.attname AS fk_column, + a_parent.attname AS ref_column + FROM pg_constraint con + JOIN pg_class c_child ON con.conrelid = c_child.oid + JOIN pg_class c_parent ON con.confrelid = c_parent.oid + JOIN pg_namespace ns ON c_child.relnamespace = ns.oid + JOIN pg_attribute a_child ON a_child.attrelid = c_child.oid AND a_child.attnum = ANY(con.conkey) + JOIN pg_attribute a_parent ON a_parent.attrelid = c_parent.oid AND a_parent.attnum = ANY(con.confkey) + WHERE ns.nspname = 'public' + AND c_child.relname = $1 + AND c_parent.relname = $2 + AND con.contype = 'f' + LIMIT 1`, + [childTable, parentTable] + ); + + if (fkResult.rows.length > 0) { + return { + fkColumn: fkResult.rows[0].fk_column, + refColumn: fkResult.rows[0].ref_column, + }; + } + + return null; + } + + /** + * 테이블의 컬럼 목록을 가져와 ColumnDef 배열로 변환 + * 시스템 컬럼과 FK 컬럼은 제외 + */ + private async getTableColumns( + pool: any, + tableName: string, + companyCode: string + ): Promise { + const result = await pool.query( + `SELECT + c.column_name, + c.is_nullable, + c.column_default, + COALESCE(ttc.column_label, cl.column_label) AS column_label, + COALESCE(ttc.reference_table, cl.reference_table) AS reference_table + FROM information_schema.columns c + LEFT JOIN table_type_columns cl + ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' + LEFT JOIN table_type_columns ttc + ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $2 + WHERE c.table_schema = 'public' AND c.table_name = $1 + ORDER BY c.ordinal_position`, + [tableName, companyCode] + ); + + const columns: ColumnDef[] = []; + for (const row of result.rows) { + const colName: string = row.column_name; + + // 시스템 컬럼 제외 + if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; + + // FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) + // 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로, + // _id로 끝나면서 reference_table이 있는 경우만 제외 + if (row.reference_table && colName.endsWith("_id")) continue; + + const hasDefault = row.column_default !== null; + const isNullable = row.is_nullable === "YES"; + const isRequired = !isNullable && !hasDefault; + + columns.push({ + dbColumn: colName, + excelHeader: row.column_label || colName, + required: isRequired, + }); + } + + return columns; + } + + /** + * UPSERT 키 감지: UNIQUE 제약조건 → 없으면 insert 모드 + * company_code가 포함된 UNIQUE 제약조건에서 company_code를 제외한 컬럼 사용 + */ + private async detectUpsertKeys( + pool: any, + tableName: string + ): Promise<{ upsertMode: "upsert" | "insert"; upsertKeyColumns: string[] }> { + const result = await pool.query( + `SELECT con.conname, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint con + JOIN pg_class c ON con.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' + AND c.relname = $1 + AND con.contype = 'u' + GROUP BY con.conname + ORDER BY con.conname + LIMIT 1`, + [tableName] + ); + + if (result.rows.length > 0) { + let rawCols = result.rows[0].columns; + // pg 드라이버가 array_agg를 문자열로 반환할 수 있음 + if (typeof rawCols === "string") { + rawCols = rawCols.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()); + } + const cols: string[] = (rawCols as string[]).filter( + (c: string) => c !== "company_code" + ); + if (cols.length > 0) { + return { upsertMode: "upsert", upsertKeyColumns: cols }; + } + } + + return { upsertMode: "insert", upsertKeyColumns: [] }; + } + + /** + * 테이블의 한글 라벨 가져오기 + */ + private async getTableLabel( + pool: any, + tableName: string, + companyCode: string + ): Promise { + const result = await pool.query( + `SELECT COALESCE( + (SELECT table_label FROM table_labels WHERE table_name = $1 LIMIT 1), + $1 + ) AS label`, + [tableName] + ); + return result.rows[0]?.label || tableName; + } +} + +export const multiTableExcelService = new MultiTableExcelService(); diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 6f6fe81c..91ae4cb5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -172,6 +172,16 @@ class NumberingRuleService { break; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + prefixParts.push(String(formData[refColumn])); + } else { + prefixParts.push(""); + } + break; + } + default: break; } @@ -1245,6 +1255,14 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return "REF"; + } + default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; @@ -1375,6 +1393,13 @@ class NumberingRuleService { return catMapping2?.format || "CATEGORY"; } + case "reference": { + const refCol2 = autoConfig.referenceColumnName; + if (refCol2 && formData && formData[refCol2]) { + return String(formData[refCol2]); + } + return "REF"; + } default: return ""; } @@ -1524,6 +1549,15 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); + return ""; + } + default: return ""; } @@ -1747,7 +1781,53 @@ class NumberingRuleService { `; const params = [companyCode, tableName, columnName]; - const result = await pool.query(query, params); + let result = await pool.query(query, params); + + // fallback: column_name이 비어있는 레거시 규칙 검색 + if (result.rows.length === 0) { + const fallbackQuery = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules r + LEFT JOIN category_values cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND (r.column_name IS NULL OR r.column_name = '') + AND r.category_value_id IS NULL + ORDER BY r.updated_at DESC + LIMIT 1 + `; + result = await pool.query(fallbackQuery, [companyCode, tableName]); + + // 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션) + if (result.rows.length > 0) { + const foundRule = result.rows[0]; + await pool.query( + `UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`, + [columnName, foundRule.ruleId, companyCode] + ); + result.rows[0].columnName = columnName; + logger.info("레거시 채번 규칙 자동 매핑 완료", { + ruleId: foundRule.ruleId, + tableName, + columnName, + }); + } + } if (result.rows.length === 0) { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { @@ -1760,7 +1840,6 @@ class NumberingRuleService { const rule = result.rows[0]; - // 파트 정보 조회 (테스트 테이블) const partsQuery = ` SELECT id, @@ -1779,7 +1858,7 @@ class NumberingRuleService { ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); - logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { + logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, }); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a75fc431..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -3482,8 +3482,74 @@ export class ScreenManagementService { } console.log( - `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, + `✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, ); + + // V2 레이아웃(screen_layouts_v2)도 동일하게 처리 + const v2LayoutsResult = await client.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id IN (${placeholders}) + AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`, + targetScreenIds, + ); + + console.log( + `🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`, + ); + + let v2Updated = 0; + for (const v2Layout of v2LayoutsResult.rows) { + let layoutData = v2Layout.layout_data; + if (!layoutData) continue; + + let v2HasChanges = false; + + const updateV2References = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) updateV2References(item); + return; + } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "screenId" || key === "targetScreenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && numVal > 0) { + const newId = screenMap.get(numVal); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); + v2HasChanges = true; + console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`); + } + } + } + if (typeof value === "object" && value !== null) { + updateV2References(value); + } + } + }; + + updateV2References(layoutData); + + if (v2HasChanges) { + await client.query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2Updated++; + } + } + + console.log( + `✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`, + ); + result.updated += v2Updated; }); return result; @@ -4610,9 +4676,60 @@ export class ScreenManagementService { } console.log( - `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, + `✅ V1: ${updateCount}개 레이아웃 업데이트 완료`, ); - return updateCount; + + // V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑 + const v2Layouts = await query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id = $1 + AND layout_data IS NOT NULL`, + [screenId], + ); + + let v2UpdateCount = 0; + for (const v2Layout of v2Layouts) { + const layoutData = v2Layout.layout_data; + if (!layoutData?.components) continue; + + let v2Changed = false; + const updateV2Refs = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "targetScreenId" || key === "screenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && screenIdMapping.has(numVal)) { + obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString(); + v2Changed = true; + } + } + if (typeof value === "object" && value !== null) updateV2Refs(value); + } + }; + updateV2Refs(layoutData); + + if (v2Changed) { + await query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2UpdateCount++; + } + } + + const total = updateCount + v2UpdateCount; + console.log( + `✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`, + ); + return total; } /** diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index dd2f73a9..96efdfbb 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -31,7 +31,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -50,7 +50,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -110,7 +110,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -133,7 +133,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true AND company_code = $1 GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -207,7 +207,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", - NULL::numeric AS "menuObjid", + menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", @@ -289,7 +289,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 회사에서 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -300,7 +300,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -316,8 +316,41 @@ class TableCategoryValueService { throw new Error("이미 존재하는 코드입니다"); } + // 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지) + let labelDupQuery: string; + let labelDupParams: any[]; + + if (companyCode === "*") { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel]; + } else { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode]; + } + + const labelDupResult = await pool.query(labelDupQuery, labelDupParams); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`); + } + const insertQuery = ` - INSERT INTO table_column_category_values ( + INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by @@ -425,6 +458,32 @@ class TableCategoryValueService { values.push(updates.isDefault); } + // 라벨 수정 시 중복 체크 (자기 자신 제외) + if (updates.valueLabel !== undefined) { + const currentRow = await pool.query( + `SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`, + [valueId] + ); + + if (currentRow.rows.length > 0) { + const { table_name, column_name, company_code } = currentRow.rows[0]; + const labelDupResult = await pool.query( + `SELECT value_id FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + AND value_id != $5`, + [table_name, column_name, updates.valueLabel, company_code, valueId] + ); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`); + } + } + } + setClauses.push(`updated_at = NOW()`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); @@ -436,7 +495,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 수정 가능 values.push(valueId); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} RETURNING @@ -459,7 +518,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값만 수정 가능 values.push(valueId, companyCode); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} AND company_code = $${paramIndex++} @@ -516,14 +575,14 @@ class TableCategoryValueService { if (companyCode === "*") { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 `; valueParams = [valueId]; } else { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 AND company_code = $2 `; @@ -635,10 +694,10 @@ class TableCategoryValueService { if (companyCode === "*") { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1 + SELECT value_id FROM category_values WHERE parent_value_id = $1 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id ) SELECT value_id FROM category_tree @@ -647,11 +706,11 @@ class TableCategoryValueService { } else { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values + SELECT value_id FROM category_values WHERE parent_value_id = $1 AND company_code = $2 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id WHERE cv.company_code = $2 ) @@ -697,10 +756,10 @@ class TableCategoryValueService { let labelParams: any[]; if (companyCode === "*") { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`; labelParams = [id]; } else { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`; labelParams = [id, companyCode]; } @@ -730,10 +789,10 @@ class TableCategoryValueService { let deleteParams: any[]; if (companyCode === "*") { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1`; deleteParams = [id]; } else { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`; deleteParams = [id, companyCode]; } @@ -770,7 +829,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $2 WHERE value_id = ANY($1::int[]) `; @@ -778,7 +837,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $3 WHERE value_id = ANY($1::int[]) AND company_code = $2 @@ -819,7 +878,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 `; @@ -827,7 +886,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 AND company_code = $3 @@ -1379,48 +1438,23 @@ class TableCategoryValueService { let query: string; let params: any[]; + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 if (companyCode === "*") { - // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) - // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n - const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) `; - params = [...valueCodes, ...valueCodes]; + params = [...valueCodes]; } else { - // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 - // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) - // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) - const companyIdx1 = n + 1; - const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); - const companyIdx2 = 2 * n + 2; - + const companyIdx = n + 1; query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - AND (company_code = $${companyIdx1} OR company_code = '*') - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - AND (company_code = $${companyIdx2} OR company_code = '*') - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) + AND (company_code = $${companyIdx} OR company_code = '*') `; - params = [...valueCodes, companyCode, ...valueCodes, companyCode]; + params = [...valueCodes, companyCode]; } const result = await pool.query(query, params); @@ -1488,7 +1522,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -1498,7 +1532,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9dea4037..6d994f93 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2691,6 +2691,32 @@ export class TableManagementService { logger.info(`created_date 자동 추가: ${data.created_date}`); } + // 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번 + try { + const companyCode = data.company_code || "*"; + const numberingColsResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' + AND company_code IN ($2, '*')`, + [tableName, companyCode] + ); + + for (const row of numberingColsResult) { + const col = row.column_name; + if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") { + const { numberingRuleService } = await import("./numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col); + if (rule) { + const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data); + data[col] = generatedCode; + logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`); + } + } + } + } catch (numErr: any) { + logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { @@ -3437,10 +3463,12 @@ export class TableManagementService { } // ORDER BY 절 구성 - // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + // sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용 const hasCreatedDateColumn = selectColumns.includes("created_date"); const orderBy = options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateColumn ? `main."created_date" DESC` : ""; @@ -3505,7 +3533,7 @@ export class TableManagementService { const referenceTableColumns = new Map(); const uniqueRefTables = new Set( joinConfigs - .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외 .map((c) => `${c.referenceTable}:${c.sourceColumn}`) ); @@ -3684,7 +3712,9 @@ export class TableManagementService { selectColumns, "", // WHERE 절은 나중에 추가 options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` + : `"${options.sortBy}" ${options.sortOrder || "ASC"}` : hasCreatedDateForSearch ? `main."created_date" DESC` : undefined, @@ -3783,15 +3813,15 @@ export class TableManagementService { ); } } else if (operator === "equals") { - // 🔧 equals 연산자: 정확히 일치 + // 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용) whereConditions.push( - `${alias}.${joinConfig.displayColumn}::text = '${safeValue}'` + `main.${joinConfig.sourceColumn}::text = '${safeValue}'` ); entitySearchColumns.push( - `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + `${key} (main.${joinConfig.sourceColumn})` ); logger.info( - `🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})` + `🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'` ); } else { // 기본: 부분 일치 (ILIKE) @@ -3875,7 +3905,9 @@ export class TableManagementService { const whereClause = whereConditions.join(" AND "); const hasCreatedDateForOrder = selectColumns.includes("created_date"); const orderBy = options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateForOrder ? `main."created_date" DESC` : ""; @@ -4310,8 +4342,8 @@ export class TableManagementService { ]; for (const config of joinConfigs) { - // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 + if (config.referenceTable === "category_values") { dbJoins.push(config); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); continue; diff --git a/backend-node/src/utils/pgErrorUtil.ts b/backend-node/src/utils/pgErrorUtil.ts new file mode 100644 index 00000000..abec8f4e --- /dev/null +++ b/backend-node/src/utils/pgErrorUtil.ts @@ -0,0 +1,50 @@ +import { query } from "../database/db"; + +/** + * PostgreSQL 에러를 사용자 친절한 메시지로 변환 + * table_type_columns의 column_label을 조회하여 한글 라벨로 표시 + */ +export async function formatPgError( + error: any, + companyCode?: string +): Promise { + if (!error || !error.code) { + return error?.message || "데이터 처리 중 오류가 발생했습니다."; + } + + switch (error.code) { + case "23502": { + // not_null_violation + const colName = error.column || ""; + const tblName = error.table || ""; + + if (colName && tblName && companyCode) { + try { + const rows = await query( + `SELECT column_label FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3 + LIMIT 1`, + [tblName, colName, companyCode] + ); + const label = rows[0]?.column_label; + if (label) { + return `필수 입력값이 누락되었습니다: ${label}`; + } + } catch { + // 라벨 조회 실패 시 컬럼명으로 폴백 + } + } + const detail = colName ? ` [${colName}]` : ""; + return `필수 입력값이 누락되었습니다.${detail}`; + } + case "23505": + return "중복된 데이터가 존재합니다."; + case "23503": + return "참조 무결성 제약 조건 위반입니다."; + default: + if (error.code.startsWith("23")) { + return "데이터 무결성 제약 조건 위반입니다."; + } + return error.message || "데이터 처리 중 오류가 발생했습니다."; + } +} diff --git a/backend-node/src/utils/startAiAssistant.ts b/backend-node/src/utils/startAiAssistant.ts new file mode 100644 index 00000000..080df078 --- /dev/null +++ b/backend-node/src/utils/startAiAssistant.ts @@ -0,0 +1,65 @@ +/** + * AI 어시스턴트 서비스를 자식 프로세스로 기동 + * - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료 (한 번에 킬) + */ +import path from "path"; +import { spawn, ChildProcess } from "child_process"; +import { logger } from "./logger"; + +const AI_PORT = process.env.AI_ASSISTANT_SERVICE_PORT || "3100"; + +let aiAssistantProcess: ChildProcess | null = null; + +/** ERP-node/ai-assistant 경로 (backend-node 기준 상대) */ +function getAiAssistantDir(): string { + return path.resolve(process.cwd(), "..", "ai-assistant"); +} + +/** + * AI 어시스턴트 서비스 기동 (있으면 띄움, 실패해도 backend는 계속 동작) + */ +export function startAiAssistant(): void { + const aiDir = getAiAssistantDir(); + const appPath = path.join(aiDir, "src", "app.js"); + + try { + const fs = require("fs"); + if (!fs.existsSync(appPath)) { + logger.info(`⏭️ AI 어시스턴트 스킵 (경로 없음: ${appPath})`); + return; + } + } catch { + return; + } + + aiAssistantProcess = spawn("node", ["src/app.js"], { + cwd: aiDir, + stdio: "inherit", + env: { ...process.env, PORT: AI_PORT }, + shell: true, // Windows에서 node 경로 인식 + }); + + aiAssistantProcess.on("error", (err) => { + logger.warn(`⚠️ AI 어시스턴트 프로세스 에러: ${err.message}`); + }); + + aiAssistantProcess.on("exit", (code, signal) => { + aiAssistantProcess = null; + if (code != null && code !== 0) { + logger.warn(`⚠️ AI 어시스턴트 종료 (code=${code}, signal=${signal})`); + } + }); + + logger.info(`🤖 AI 어시스턴트 서비스 기동 (포트 ${AI_PORT}, cwd: ${aiDir})`); +} + +/** + * AI 어시스턴트 프로세스 종료 (SIGTERM/SIGINT 시 호출) + */ +export function stopAiAssistant(): void { + if (aiAssistantProcess && aiAssistantProcess.kill) { + aiAssistantProcess.kill("SIGTERM"); + aiAssistantProcess = null; + logger.info("🤖 AI 어시스턴트 프로세스 종료"); + } +} diff --git a/docs/plan-multi-table-excel-upload.md b/docs/plan-multi-table-excel-upload.md new file mode 100644 index 00000000..92b72f16 --- /dev/null +++ b/docs/plan-multi-table-excel-upload.md @@ -0,0 +1,194 @@ +# 다중 테이블 엑셀 업로드 범용 시스템 + +## 개요 +하나의 플랫 엑셀 파일로 계층적 다중 테이블(2~N개)에 데이터를 일괄 등록하는 범용 시스템. +거래처 관리(customer_mng → customer_item_mapping → customer_item_prices)를 첫 번째 적용 대상으로 하되, +공급업체, BOM 등 다른 화면에서도 재사용 가능하도록 설계한다. + +## 핵심 기능 +1. 모드 선택: 어느 레벨까지 등록할지 사용자가 선택 +2. 템플릿 다운로드: 모드에 맞는 엑셀 양식 자동 생성 +3. 파일 업로드: 플랫 엑셀 → 계층 그룹핑 → 트랜잭션 UPSERT +4. 컬럼 매핑: 엑셀 헤더 ↔ DB 컬럼 자동/수동 매핑 + +## DB 테이블 관계 (거래처 관리) + +``` +customer_mng (Level 1 - 루트) + PK: id (SERIAL) + UNIQUE: customer_code + └─ customer_item_mapping (Level 2) + PK: id (UUID) + FK: customer_id → customer_mng.id + UPSERT키: customer_id + customer_item_code + └─ customer_item_prices (Level 3) + PK: id (UUID) + FK: mapping_id → customer_item_mapping.id + 항상 INSERT (기간별 단가 이력) +``` + +## 범용 설정 구조 (TableChainConfig) + +```typescript +interface TableLevel { + tableName: string; + label: string; + // 부모와의 관계 + parentFkColumn?: string; // 이 테이블에서 부모를 참조하는 FK 컬럼 + parentRefColumn?: string; // 부모 테이블에서 참조되는 컬럼 (PK 또는 UNIQUE) + // UPSERT 설정 + upsertMode: 'upsert' | 'insert'; // upsert: 기존 데이터 있으면 UPDATE, insert: 항상 신규 + upsertKeyColumns?: string[]; // UPSERT 매칭 키 (예: ['customer_code']) + // 엑셀 매핑 컬럼 + columns: Array<{ + dbColumn: string; + excelHeader: string; + required: boolean; + defaultValue?: any; + }>; +} + +interface TableChainConfig { + id: string; + name: string; + description: string; + levels: TableLevel[]; // 0 = 루트, 1 = 자식, 2 = 손자... + uploadModes: Array<{ + id: string; + label: string; + description: string; + activeLevels: number[]; // 이 모드에서 활성화되는 레벨 인덱스 + }>; +} +``` + +## 거래처 관리 설정 예시 + +```typescript +const customerChainConfig: TableChainConfig = { + id: 'customer_management', + name: '거래처 관리', + description: '거래처, 품목매핑, 단가 일괄 등록', + levels: [ + { + tableName: 'customer_mng', + label: '거래처', + upsertMode: 'upsert', + upsertKeyColumns: ['customer_code'], + columns: [ + { dbColumn: 'customer_code', excelHeader: '거래처코드', required: true }, + { dbColumn: 'customer_name', excelHeader: '거래처명', required: true }, + { dbColumn: 'division', excelHeader: '구분', required: false }, + { dbColumn: 'contact_person', excelHeader: '담당자', required: false }, + { dbColumn: 'contact_phone', excelHeader: '연락처', required: false }, + { dbColumn: 'email', excelHeader: '이메일', required: false }, + { dbColumn: 'business_number', excelHeader: '사업자번호', required: false }, + { dbColumn: 'address', excelHeader: '주소', required: false }, + ], + }, + { + tableName: 'customer_item_mapping', + label: '품목매핑', + parentFkColumn: 'customer_id', + parentRefColumn: 'id', + upsertMode: 'upsert', + upsertKeyColumns: ['customer_id', 'customer_item_code'], + columns: [ + { dbColumn: 'customer_item_code', excelHeader: '거래처품번', required: true }, + { dbColumn: 'customer_item_name', excelHeader: '거래처품명', required: true }, + { dbColumn: 'item_id', excelHeader: '품목ID', required: false }, + ], + }, + { + tableName: 'customer_item_prices', + label: '단가', + parentFkColumn: 'mapping_id', + parentRefColumn: 'id', + upsertMode: 'insert', + columns: [ + { dbColumn: 'base_price', excelHeader: '기준단가', required: true }, + { dbColumn: 'discount_type', excelHeader: '할인유형', required: false }, + { dbColumn: 'discount_value', excelHeader: '할인값', required: false }, + { dbColumn: 'start_date', excelHeader: '적용시작일', required: false }, + { dbColumn: 'end_date', excelHeader: '적용종료일', required: false }, + { dbColumn: 'currency_code', excelHeader: '통화', required: false }, + ], + }, + ], + uploadModes: [ + { id: 'customer_only', label: '거래처만 등록', description: '거래처 기본정보만', activeLevels: [0] }, + { id: 'customer_item', label: '거래처 + 품목정보', description: '거래처와 품목매핑', activeLevels: [0, 1] }, + { id: 'customer_item_price', label: '거래처 + 품목 + 단가', description: '전체 등록', activeLevels: [0, 1, 2] }, + ], +}; +``` + +## 처리 로직 (백엔드) + +### 1단계: 그룹핑 +엑셀의 플랫 행을 계층별 그룹으로 변환: +- Level 0 (거래처): customer_code 기준 그룹핑 +- Level 1 (품목매핑): customer_code + customer_item_code 기준 그룹핑 +- Level 2 (단가): 매 행마다 INSERT + +### 2단계: 계단식 UPSERT (트랜잭션) +``` +BEGIN TRANSACTION + +FOR EACH unique customer_code: + 1. customer_mng UPSERT → 결과에서 id 획득 (returnedId) + + FOR EACH unique customer_item_code (해당 거래처): + 2. customer_item_mapping의 customer_id = returnedId 주입 + UPSERT → 결과에서 id 획득 (mappingId) + + FOR EACH price row (해당 품목매핑): + 3. customer_item_prices의 mapping_id = mappingId 주입 + INSERT + +COMMIT (전체 성공) or ROLLBACK (하나라도 실패) +``` + +### 3단계: 결과 반환 +```json +{ + "success": true, + "results": { + "customer_mng": { "inserted": 2, "updated": 1 }, + "customer_item_mapping": { "inserted": 5, "updated": 2 }, + "customer_item_prices": { "inserted": 12 } + }, + "errors": [] +} +``` + +## 테스트 계획 + +### 1단계: 백엔드 서비스 +- [x] plan.md 작성 +- [ ] multiTableExcelService.ts 기본 구조 작성 +- [ ] 그룹핑 로직 구현 +- [ ] 계단식 UPSERT 로직 구현 +- [ ] 트랜잭션 처리 +- [ ] 에러 핸들링 + +### 2단계: API 엔드포인트 +- [ ] POST /api/data/multi-table/upload 추가 +- [ ] POST /api/data/multi-table/template 추가 (템플릿 다운로드) +- [ ] 입력값 검증 + +### 3단계: 프론트엔드 +- [ ] MultiTableExcelUploadModal.tsx 컴포넌트 작성 +- [ ] 모드 선택 UI +- [ ] 템플릿 다운로드 버튼 +- [ ] 파일 업로드 + 미리보기 +- [ ] 컬럼 매핑 UI +- [ ] 업로드 결과 표시 + +### 4단계: 통합 +- [ ] 거래처 관리 화면에 연결 +- [ ] 실제 데이터로 테스트 + +## 진행 상태 +- 완료된 테스트는 [x]로 표시 +- 현재 진행 중인 테스트는 [진행중]으로 표시 diff --git a/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx b/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx new file mode 100644 index 00000000..5b2ac043 --- /dev/null +++ b/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { aiAssistantApi } from "@/lib/api/aiAssistant"; +import type { ApiKeyItem } from "@/lib/api/aiAssistant"; +import { + Key, + Plus, + Copy, + Trash2, + Loader2, + Check, + Eye, + EyeOff, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; + +export default function AiAssistantApiKeysPage() { + const [loading, setLoading] = useState(true); + const [apiKeys, setApiKeys] = useState([]); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newKeyDialogOpen, setNewKeyDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [newKey, setNewKey] = useState(""); + const [creating, setCreating] = useState(false); + const [showKey, setShowKey] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + loadApiKeys(); + }, []); + + const loadApiKeys = async () => { + setLoading(true); + try { + const res = await aiAssistantApi.get("/api-keys"); + setApiKeys(res.data?.data ?? []); + } catch { + toast.error("API 키 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const createApiKey = async () => { + if (!newKeyName.trim()) { + toast.error("키 이름을 입력해주세요."); + return; + } + setCreating(true); + try { + const res = await aiAssistantApi.post("/api-keys", { name: newKeyName }); + setNewKey((res.data?.data as { key?: string })?.key ?? ""); + setCreateDialogOpen(false); + setNewKeyDialogOpen(true); + setNewKeyName(""); + loadApiKeys(); + toast.success("API 키가 생성되었습니다."); + } catch (err: unknown) { + const msg = + err && typeof err === "object" && "response" in err + ? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data + ?.error?.message + : null; + toast.error(msg ?? "API 키 생성에 실패했습니다."); + } finally { + setCreating(false); + } + }; + + const revokeApiKey = async (id: number) => { + if (!confirm("이 API 키를 폐기하시겠습니까?")) return; + try { + await aiAssistantApi.delete(`/api-keys/${id}`); + loadApiKeys(); + toast.success("API 키가 폐기되었습니다."); + } catch { + toast.error("API 키 폐기에 실패했습니다."); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success("클립보드에 복사되었습니다."); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("복사에 실패했습니다."); + } + }; + + const baseUrl = + typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || "http://localhost:3100/api/v1" + : ""; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

API 키 관리

+

+ 외부 시스템에서 AI Assistant API를 사용하기 위한 키를 관리합니다. +

+
+ + + + + + + 새 API 키 생성 + + 새로운 API 키를 생성합니다. 키는 한 번만 표시되므로 안전하게 보관하세요. + + +
+
+ + setNewKeyName(e.target.value)} + /> +
+
+ + + + +
+
+
+ + + + + API 키가 생성되었습니다 + + 이 키는 다시 표시되지 않습니다. 안전한 곳에 복사하여 보관하세요. + + +
+
+ + + +
+
+ + + +
+
+ + + + API 키 목록 + 발급된 모든 API 키를 확인하고 관리합니다. + + + {apiKeys.length === 0 ? ( +
+ +

API 키가 없습니다

+

새 API 키를 생성하여 시작하세요.

+
+ ) : ( + + + + 이름 + + 상태 + 사용량 + 마지막 사용 + 생성일 + 작업 + + + + {apiKeys.map((key) => ( + + {key.name} + +
+ + {key.keyPrefix}... + + +
+
+ + + {key.status === "active" ? "활성" : "폐기됨"} + + + {(key.usageCount ?? 0).toLocaleString()} 토큰 + + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleDateString("ko-KR") + : "-"} + + {new Date(key.createdAt).toLocaleDateString("ko-KR")} + + {key.status === "active" && ( + + )} + +
+ ))} +
+
+ )} +
+
+ + + + API 사용 방법 + + 발급받은 API 키를 Authorization 헤더에 포함하여 요청하세요. + + + +
+            {`curl -X POST ${baseUrl}/chat/completions \\
+  -H "Content-Type: application/json" \\
+  -H "Authorization: Bearer YOUR_API_KEY" \\
+  -d '{"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hello!"}]}'`}
+          
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx b/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx new file mode 100644 index 00000000..96898cb0 --- /dev/null +++ b/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { toast } from "sonner"; + +const DEFAULT_BASE = "http://localhost:3100/api/v1"; +const PRESETS = [ + { name: "채팅 완성", method: "POST", endpoint: "/chat/completions", body: '{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"안녕하세요!"}],"temperature":0.7}' }, + { name: "모델 목록", method: "GET", endpoint: "/models", body: "" }, + { name: "사용량", method: "GET", endpoint: "/usage", body: "" }, + { name: "API 키 목록", method: "GET", endpoint: "/api-keys", body: "" }, +]; + +export default function AiAssistantApiTestPage() { + const [baseUrl, setBaseUrl] = useState( + typeof window !== "undefined" ? (process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || DEFAULT_BASE) : DEFAULT_BASE + ); + const [apiKey, setApiKey] = useState(""); + const [method, setMethod] = useState("POST"); + const [endpoint, setEndpoint] = useState("/chat/completions"); + const [body, setBody] = useState(PRESETS[0].body); + const [loading, setLoading] = useState(false); + const [response, setResponse] = useState<{ status: number; statusText: string; data: unknown } | null>(null); + const [responseTime, setResponseTime] = useState(null); + const [copied, setCopied] = useState(false); + + const apply = (p: (typeof PRESETS)[0]) => { + setMethod(p.method); + setEndpoint(p.endpoint); + setBody(p.body); + }; + + const send = async () => { + setLoading(true); + setResponse(null); + setResponseTime(null); + const start = Date.now(); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const opt: RequestInit = { method, headers }; + if (method !== "GET" && body.trim()) { + try { + JSON.parse(body); + opt.body = body; + } catch { + toast.error("JSON 형식 오류"); + setLoading(false); + return; + } + } + const res = await fetch(`${baseUrl}${endpoint}`, opt); + const elapsed = Date.now() - start; + setResponseTime(elapsed); + const ct = res.headers.get("content-type"); + const data = ct?.includes("json") ? await res.json() : await res.text(); + setResponse({ status: res.status, statusText: res.statusText, data }); + toast.success(res.ok ? `성공 ${res.status}` : `실패 ${res.status}`); + } catch (e) { + setResponseTime(Date.now() - start); + setResponse({ status: 0, statusText: "Network Error", data: { error: String(e) } }); + toast.error("네트워크 오류"); + } finally { + setLoading(false); + } + }; + + const copyRes = () => { + navigator.clipboard.writeText(JSON.stringify(response?.data, null, 2)); + setCopied(true); + toast.success("복사됨"); + setTimeout(() => setCopied(false), 2000); + }; + + const statusV = (s: number) => (s >= 200 && s < 300 ? "success" : s >= 400 ? "destructive" : "secondary"); + + return ( +
+
+

API 테스트

+

API를 직접 호출하여 테스트합니다.

+
+
+
+ + + API 설정 + + +
+ + setBaseUrl(e.target.value)} /> +
+
+ + setApiKey(e.target.value)} placeholder="sk-xxx" /> +
+
+
+ + + 빠른 선택 + + +
+ {PRESETS.map((p, i) => ( + + ))} +
+
+
+ + + 요청 + + +
+ + setEndpoint(e.target.value)} className="flex-1" /> +
+ {method !== "GET" && ( +
+ +