commit 5a2ecd0fea0255fe3c7fcc47831996a6b9d00e32 Author: chyoo Date: Tue Jul 7 16:09:12 2026 +0900 Sync LMS backend source diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c85ac06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +.git +.env +npm-debug.log* +Dockerfile +docker-compose.yml +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ca21f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +NODE_ENV=development +PORT=4400 +CORS_ORIGIN=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176 +DATABASE_URL=postgresql://lms:future1234!%40@localhost:5432/lms?schema=public +AUTH_SERVER_URL=http://localhost:4100 +GITEA_BASE_URL=http://localhost:3000 +GITEA_ADMIN_TOKEN= +GITEA_STUDENT_OWNER=student +PLATFORM_GATEWAY_URL=http://localhost:4500 +PLATFORM_GATEWAY_TOKEN=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d84a15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +coverage +*.tsbuildinfo +.env +.env.* +!.env.example +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7c1e54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build +RUN npm prune --omit=dev + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY package*.json ./ +EXPOSE 4400 +CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a3fc13 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# lms-be + +Nest.js backend API for the LMS Electron frontend. + +## Local development + +```bash +npm install +cp .env.example .env +npm run prisma:generate +npm run start:dev +``` + +The API listens on `http://localhost:4400`. + +- `GET /health` +- `GET /health/db` +- `GET /api` + +## Database + +Local PostgreSQL is included in Docker Compose. + +```bash +docker compose up -d postgres +npm run prisma:migrate +npm run start:dev +``` + +For a separated database server, set `DATABASE_URL` to the remote PostgreSQL host: + +```bash +DATABASE_URL=postgresql://USER:PASSWORD@DB_HOST:5432/DB_NAME?schema=public +``` + +## Docker + +```bash +docker compose up --build +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7592d84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + lms-be: + build: + context: . + container_name: lms-be + environment: + NODE_ENV: production + PORT: 4400 + CORS_ORIGIN: ${CORS_ORIGIN:-*} + DATABASE_URL: ${DATABASE_URL:-postgresql://lms:future1234!%40@postgres:5432/lms?schema=public} + AUTH_SERVER_URL: ${AUTH_SERVER_URL:-http://host.docker.internal:4100} + ports: + - "${PORT:-4400}:4400" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4400/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + postgres: + image: postgres:17-alpine + container_name: lms-postgres + command: postgres -c timezone=Asia/Seoul + environment: + POSTGRES_DB: ${POSTGRES_DB:-lms} + POSTGRES_USER: ${POSTGRES_USER:-lms} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-future1234!@} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms} -d ${POSTGRES_DB:-lms}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..288a0b6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,33 @@ +import eslint from '@eslint/js'; +import prettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs', 'dist/**', 'node_modules/**', 'coverage/**'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + prettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.spec.ts', 'test/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, +); diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..becaade --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10686 @@ +{ + "name": "lms-be", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lms-be", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.1.24", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.24", + "@nestjs/platform-express": "^11.1.24", + "@nestjs/platform-socket.io": "^11.1.24", + "@nestjs/websockets": "^11.1.24", + "@prisma/client": "^6.19.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "socket.io": "^4.8.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@nestjs/cli": "^11.0.21", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.24", + "@types/express": "^5.0.6", + "@types/jest": "^30.0.0", + "@types/node": "^25.9.1", + "@types/supertest": "^7.2.0", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.6.0", + "jest": "^30.4.2", + "prettier": "^3.8.3", + "prisma": "^6.19.3", + "source-map-support": "^0.5.21", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-loader": "^9.6.0", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.24.tgz", + "integrity": "sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "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==", + "dev": true, + "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/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/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==", + "dev": true, + "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/@isaacs/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "dev": true, + "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/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/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", + "dev": true, + "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/@jest/reporters/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==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/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==", + "dev": true, + "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/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.21", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", + "integrity": "sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@angular-devkit/schematics-cli": "19.2.24", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.0", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/cli/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@nestjs/cli/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nestjs/cli/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@nestjs/cli/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/cli/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/cli/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@nestjs/cli/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@nestjs/cli/node_modules/webpack": { + "version": "5.106.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", + "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.24.tgz", + "integrity": "sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.24.tgz", + "integrity": "sha512-K4bzT+lEdd0Hhcsw3jtk56QAW6s6skK3ViN7hIROSN0kUf4ROwWEAKopJID6yhPQxB45kDtP2wEcjzE8171J3g==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.24.tgz", + "integrity": "sha512-CeMKbRBm05aOBiWhIHWO2xDeHbxynBF9ySQv3gRjObz2N5+uJnYriAYkHvVqvC4JIydmMPmT5VdICFNlNz3qyA==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.24.tgz", + "integrity": "sha512-ImdR9G8W5Y2Hhcptdci+tNaG6JV/dzDguFTgtXOL5ie/gD9O9ARw8Cd9RzF2+oteyzQ+1sPK/+wgVOPOyYGVCA==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.24.tgz", + "integrity": "sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nestjs/websockets": { + "version": "11.1.24", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.24.tgz", + "integrity": "sha512-37Z/QYzZ4nPHcGyGGjhjoKVOcpSPMhmRQj5DS1l0RKlRYgq8S0cmgaZ6kQ8PI3259PdchLx41oQibXh22iEUiA==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "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/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "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/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "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==", + "dev": true, + "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/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "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/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/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==", + "dev": true, + "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/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "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/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "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/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/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/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==", + "dev": true, + "license": "MIT" + }, + "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/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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/engine.io": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/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/engine.io/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/engine.io/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/engine.io/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/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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==", + "dev": true, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "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/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "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": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "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/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dev": true, + "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/form-data/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "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==", + "dev": true, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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.", + "dev": true, + "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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "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/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/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", + "dev": true, + "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/jest-config/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==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/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==", + "dev": true, + "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/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/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", + "dev": true, + "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/jest-runtime/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==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/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==", + "dev": true, + "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/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.4.tgz", + "integrity": "sha512-/lhWr7vq8foWN9Apksnd9v8/cfwzW6g6qKOCo25XBGkNaVCHucXO57hLy4CWHGvytvLz6Nt3J5Gs8p3jlCGFXA==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "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", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/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/multer/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/multer/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/multer/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/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "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-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "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/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/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, + "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": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/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/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.20.1" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/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/socket.io/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/socket.io/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/socket.io/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/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "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-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "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": { + "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==", + "dev": true, + "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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "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/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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/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/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d68310 --- /dev/null +++ b/package.json @@ -0,0 +1,78 @@ +{ + "name": "lms-be", + "version": "0.1.0", + "private": true, + "description": "Backend API for LMS", + "license": "UNLICENSED", + "scripts": { + "build": "prisma generate && nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main.js", + "lint": "eslint \"{src,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "dependencies": { + "@nestjs/common": "^11.1.24", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.24", + "@nestjs/platform-express": "^11.1.24", + "@nestjs/platform-socket.io": "^11.1.24", + "@nestjs/websockets": "^11.1.24", + "@prisma/client": "^6.19.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "socket.io": "^4.8.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@nestjs/cli": "^11.0.21", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.24", + "@types/express": "^5.0.6", + "@types/jest": "^30.0.0", + "@types/node": "^25.9.1", + "@types/supertest": "^7.2.0", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.6.0", + "jest": "^30.4.2", + "prettier": "^3.8.3", + "prisma": "^6.19.3", + "source-map-support": "^0.5.21", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-loader": "^9.6.0", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "src/**/*.(t|j)s" + ], + "coverageDirectory": "coverage", + "testEnvironment": "node" + } +} diff --git a/prisma/migrations/20260601015302_init/migration.sql b/prisma/migrations/20260601015302_init/migration.sql new file mode 100644 index 0000000..ed097f7 --- /dev/null +++ b/prisma/migrations/20260601015302_init/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/migrations/20260602011000_add_lms_notifications/migration.sql b/prisma/migrations/20260602011000_add_lms_notifications/migration.sql new file mode 100644 index 0000000..6b7f520 --- /dev/null +++ b/prisma/migrations/20260602011000_add_lms_notifications/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "lms_notifications" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'notice', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lms_notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "lms_notifications_is_active_created_at_idx" ON "lms_notifications"("is_active", "created_at"); + +-- Seed +INSERT INTO "lms_notifications" ("id", "title", "body", "category", "created_at", "updated_at") +VALUES + ('lms-notification-course-review', '신규 과제 검토 요청', '자율주행 인지 실습 과정에 제출된 과제 3건의 검토가 필요합니다.', 'assignment', '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP), + ('lms-notification-live-session', '라이브 세션 일정 안내', '오늘 15:00에 SLAM 기초 라이브 세션이 예정되어 있습니다.', 'schedule', '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP), + ('lms-notification-system-maintenance', '시스템 점검 안내', '6월 5일 02:00부터 03:00까지 LMS 점검이 진행됩니다.', 'system', '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP); diff --git a/prisma/migrations/20260602013000_add_notification_audience/migration.sql b/prisma/migrations/20260602013000_add_notification_audience/migration.sql new file mode 100644 index 0000000..11df541 --- /dev/null +++ b/prisma/migrations/20260602013000_add_notification_audience/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "lms_notifications" ADD COLUMN "audience" TEXT NOT NULL DEFAULT 'all'; + +-- Existing admin messages are instructor-facing. +UPDATE "lms_notifications" +SET "audience" = 'instructor' +WHERE "id" IN ( + 'lms-notification-course-review', + 'lms-notification-live-session', + 'lms-notification-system-maintenance' +); + +-- Seed student messages. +INSERT INTO "lms_notifications" ("id", "title", "body", "category", "audience", "created_at", "updated_at") +VALUES + ('lms-student-notification-assignment-due', '과제 제출 마감 안내', '인지 실습 리포트 제출 마감이 오늘 18:00까지입니다.', 'assignment', 'student', '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP), + ('lms-student-notification-new-lesson', '신규 강의 공개', '자율주행 센서 융합 기초 강의가 새로 공개되었습니다.', 'course', 'student', '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP), + ('lms-student-notification-session', '실습 세션 안내', '내일 10:00에 ROS2 실습 세션이 예정되어 있습니다.', 'schedule', 'student', '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP); + +-- CreateIndex +CREATE INDEX "lms_notifications_audience_is_active_created_at_idx" ON "lms_notifications"("audience", "is_active", "created_at"); diff --git a/prisma/migrations/20260602022000_add_table_comments/migration.sql b/prisma/migrations/20260602022000_add_table_comments/migration.sql new file mode 100644 index 0000000..9fcd5cb --- /dev/null +++ b/prisma/migrations/20260602022000_add_table_comments/migration.sql @@ -0,0 +1,16 @@ +COMMENT ON TABLE "users" IS 'LMS 내부 사용자 기본 정보'; +COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 고유 ID'; +COMMENT ON COLUMN "users"."email" IS '사용자 이메일'; +COMMENT ON COLUMN "users"."name" IS '사용자 이름'; +COMMENT ON COLUMN "users"."createdAt" IS '사용자 생성 일시'; +COMMENT ON COLUMN "users"."updatedAt" IS '사용자 수정 일시'; + +COMMENT ON TABLE "lms_notifications" IS 'LMS 학생/강사/전체 대상 알림 메시지'; +COMMENT ON COLUMN "lms_notifications"."id" IS 'LMS 알림 고유 ID'; +COMMENT ON COLUMN "lms_notifications"."title" IS '알림 제목'; +COMMENT ON COLUMN "lms_notifications"."body" IS '알림 본문'; +COMMENT ON COLUMN "lms_notifications"."category" IS '알림 분류'; +COMMENT ON COLUMN "lms_notifications"."audience" IS '알림 대상. student, instructor, all 등을 사용'; +COMMENT ON COLUMN "lms_notifications"."is_active" IS '알림 활성 여부'; +COMMENT ON COLUMN "lms_notifications"."created_at" IS '알림 생성 일시'; +COMMENT ON COLUMN "lms_notifications"."updated_at" IS '알림 수정 일시'; diff --git a/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql b/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql new file mode 100644 index 0000000..aae2773 --- /dev/null +++ b/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql @@ -0,0 +1,15 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +UPDATE "users" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "lms_notifications" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +ALTER TABLE "users" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid; + +ALTER TABLE "lms_notifications" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid; diff --git a/prisma/migrations/20260602030000_add_auth_user_id/migration.sql b/prisma/migrations/20260602030000_add_auth_user_id/migration.sql new file mode 100644 index 0000000..9912111 --- /dev/null +++ b/prisma/migrations/20260602030000_add_auth_user_id/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "users" + ADD COLUMN "auth_user_id" uuid; + +CREATE UNIQUE INDEX "users_auth_user_id_key" ON "users"("auth_user_id"); + +COMMENT ON COLUMN "users"."auth_user_id" IS '중앙 auth DB users.id와 매핑되는 사용자 ID'; diff --git a/prisma/migrations/20260602032000_drop_lms_notifications/migration.sql b/prisma/migrations/20260602032000_drop_lms_notifications/migration.sql new file mode 100644 index 0000000..ede739b --- /dev/null +++ b/prisma/migrations/20260602032000_drop_lms_notifications/migration.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "lms_notifications"; diff --git a/prisma/migrations/20260602035000_add_curriculum_courses/migration.sql b/prisma/migrations/20260602035000_add_curriculum_courses/migration.sql new file mode 100644 index 0000000..1587bee --- /dev/null +++ b/prisma/migrations/20260602035000_add_curriculum_courses/migration.sql @@ -0,0 +1,177 @@ +CREATE TABLE "courses" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "subtitle" TEXT, + "description" TEXT, + "organization_name" TEXT NOT NULL, + "teacher_id" UUID NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "courses_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_weeks" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "tags" TEXT[] NOT NULL, + "progress" INTEGER NOT NULL DEFAULT 0, + "state" TEXT NOT NULL, + "duration" TEXT NOT NULL, + "github" TEXT NOT NULL, + "overview" TEXT NOT NULL, + "deliverable" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "course_weeks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_week_topics" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "week_id" UUID NOT NULL, + "title" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL, + + CONSTRAINT "course_week_topics_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_week_practices" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "week_id" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL, + + CONSTRAINT "course_week_practices_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "courses_slug_key" ON "courses"("slug"); +CREATE INDEX "courses_is_active_idx" ON "courses"("is_active"); +CREATE INDEX "courses_teacher_id_idx" ON "courses"("teacher_id"); +CREATE UNIQUE INDEX "course_weeks_course_id_week_key" ON "course_weeks"("course_id", "week"); +CREATE INDEX "course_weeks_course_id_idx" ON "course_weeks"("course_id"); +CREATE UNIQUE INDEX "course_week_topics_week_id_sort_order_key" ON "course_week_topics"("week_id", "sort_order"); +CREATE INDEX "course_week_topics_week_id_idx" ON "course_week_topics"("week_id"); +CREATE UNIQUE INDEX "course_week_practices_week_id_sort_order_key" ON "course_week_practices"("week_id", "sort_order"); +CREATE INDEX "course_week_practices_week_id_idx" ON "course_week_practices"("week_id"); + +ALTER TABLE "courses" ADD CONSTRAINT "courses_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "course_weeks" ADD CONSTRAINT "course_weeks_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "course_week_topics" ADD CONSTRAINT "course_week_topics_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "course_week_practices" ADD CONSTRAINT "course_week_practices_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "courses" IS 'LMS 강의 마스터. 소속과 강사 기준으로 전체 강의 정보를 관리한다.'; +COMMENT ON TABLE "course_weeks" IS '강의별 주차 커리큘럼 정보.'; +COMMENT ON TABLE "course_week_topics" IS '주차별 학습 주제와 상세 설명.'; +COMMENT ON TABLE "course_week_practices" IS '주차별 실습 내용.'; +COMMENT ON COLUMN "courses"."organization_name" IS '강의를 등록한 소속명. 현재 퓨처드라이브 기준.'; +COMMENT ON COLUMN "courses"."teacher_id" IS '강의를 등록한 강사 사용자 UUID.'; + +INSERT INTO "users" ("id", "email", "name", "createdAt", "updatedAt") +VALUES (gen_random_uuid(), 'teacher@futuredrive.net', '퓨처드라이브 강사', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("email") DO UPDATE +SET "name" = EXCLUDED."name", + "updatedAt" = CURRENT_TIMESTAMP; + +WITH teacher AS ( + SELECT "id" FROM "users" WHERE "email" = 'teacher@futuredrive.net' +) +INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "updated_at") +SELECT + 'futuredrive-autonomy-basic', + '단계별 자율주행 알고리즘 학습 과정', + '주차별 커리큘럼', + 'ROS2, 센서 처리, SLAM, 객체 인식, 경로 계획까지 자율주행 알고리즘 개발 흐름을 주차별로 학습합니다.', + '퓨처드라이브', + teacher."id", + CURRENT_TIMESTAMP +FROM teacher +ON CONFLICT ("slug") DO UPDATE +SET "title" = EXCLUDED."title", + "subtitle" = EXCLUDED."subtitle", + "description" = EXCLUDED."description", + "organization_name" = EXCLUDED."organization_name", + "teacher_id" = EXCLUDED."teacher_id", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic' +) +INSERT INTO "course_weeks" ("course_id", "week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable", "updated_at") +SELECT course."id", data."week", data."title", data."tags", data."progress", data."state", data."duration", data."github", data."overview", data."deliverable", CURRENT_TIMESTAMP +FROM course +CROSS JOIN ( + VALUES + (1, 'ROS2 기초 & 개발 환경 설정', ARRAY['시뮬레이션','완료']::TEXT[], 100, 'done', '8-10시간', '완료', 'ROS2 패키지 구조와 노드 실행 흐름을 이해하고, 실습 환경에서 기본 통신 구조를 직접 구성합니다.', '실습 노드 코드, 실행 로그, README 정리본을 GitHub에 제출'), + (2, '센서 데이터 수집 및 처리', ARRAY['MK-mini','완료']::TEXT[], 100, 'done', '8-10시간', '완료', '자율주행 시스템에서 사용하는 LiDAR와 카메라 데이터를 수집하고, ROS2 토픽 기반으로 전처리하는 흐름을 학습합니다.', '센서 토픽 분석표, 전처리 코드, 결과 캡처 이미지를 포함한 제출물'), + (3, 'SLAM & 지도 생성', ARRAY['MK-mini','진행중']::TEXT[], 65, 'current', '8-10시간', '미제출', 'Cartographer 기반 SLAM을 실행해 주행 환경 지도를 생성하고, 저장된 맵을 이용해 Localization 흐름을 검증합니다.', '생성된 map 파일, SLAM 실행 로그, Localization 검증 결과를 GitHub에 제출'), + (4, '객체 인식 & 딥러닝', ARRAY['시뮬레이션','진행예정']::TEXT[], 0, 'locked', '-', '진행예정', '카메라 기반 객체 인식 모델을 실행하고, 인식 결과를 주행 판단에 연결하는 과정을 학습합니다.', '모델 실행 로그와 인식 결과 리포트 제출'), + (5, '경로 계획 알고리즘', ARRAY['f1tenth','진행예정']::TEXT[], 0, 'locked', '-', '진행예정', '생성된 지도와 차량 상태를 기반으로 경로를 계획하고, 제어 명령으로 연결하는 전체 흐름을 정리합니다.', '경로 계획 설정값, 주행 결과 영상, 개선 사항 정리본 제출') +) AS data("week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable") +ON CONFLICT ("course_id", "week") DO UPDATE +SET "title" = EXCLUDED."title", + "tags" = EXCLUDED."tags", + "progress" = EXCLUDED."progress", + "state" = EXCLUDED."state", + "duration" = EXCLUDED."duration", + "github" = EXCLUDED."github", + "overview" = EXCLUDED."overview", + "deliverable" = EXCLUDED."deliverable", + "updated_at" = CURRENT_TIMESTAMP; + +WITH rows AS ( + SELECT w."id" AS week_id, topic.* + FROM "course_weeks" w + JOIN "courses" c ON c."id" = w."course_id" + JOIN LATERAL ( + VALUES + (1, 1, 'ROS2 노드 생성', '패키지 생성, 노드 실행, 로그 확인까지 ROS2 개발의 기본 흐름을 실습합니다.'), + (1, 2, '토픽 통신', 'Publisher와 Subscriber를 구성하고 메시지 타입, 주기, 토픽 이름을 확인합니다.'), + (1, 3, '파라미터 설정', '노드 파라미터를 외부에서 변경하고 launch 파일로 실행 구성을 정리합니다.'), + (2, 1, 'LiDAR 포인트클라우드', 'LiDAR 토픽을 확인하고 포인트클라우드 메시지 구조와 좌표 범위를 분석합니다.'), + (2, 2, '카메라 이미지 처리', '카메라 프레임을 수신해 기본 전처리와 시각화 결과를 확인합니다.'), + (2, 3, '센서 퓨전 기초', 'LiDAR와 카메라 데이터를 같은 주행 상황 기준으로 비교하고 센서 결합 조건을 정리합니다.'), + (3, 1, 'Cartographer SLAM', 'Cartographer 설정을 확인하고 센서 입력 조건에 맞춰 SLAM launch를 실행합니다.'), + (3, 2, '맵 저장/로드', '생성된 지도를 저장한 뒤 재로딩 과정에서 맵 파일과 설정값을 검증합니다.'), + (3, 3, 'Localization', '저장된 맵 위에서 차량 위치 추정이 안정적으로 동작하는지 확인합니다.'), + (4, 1, '객체 인식 모델 실행', '객체 인식 모델의 입력 이미지와 출력 박스 구조를 확인합니다.'), + (4, 2, 'ROS2 메시지 변환', '검출 결과를 ROS2 메시지로 변환하고 후속 노드에서 사용할 수 있게 정리합니다.'), + (4, 3, '인식 결과 시각화', '시뮬레이션 장면에서 인식 결과를 시각화하고 실패 케이스를 기록합니다.'), + (5, 1, '전역 경로 생성', '지도 기반 전역 경로를 생성하고 목적지까지의 기준 경로를 확인합니다.'), + (5, 2, '지역 경로 갱신', '주변 장애물과 차량 상태를 반영해 지역 경로를 갱신합니다.'), + (5, 3, '제어 명령 연결', '계획된 경로를 제어 명령으로 연결하고 주행 결과를 비교합니다.') + ) AS topic(week_no, sort_order, title, detail) ON topic.week_no = w."week" + WHERE c."slug" = 'futuredrive-autonomy-basic' +) +INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail") +SELECT week_id, sort_order, title, detail FROM rows +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET "title" = EXCLUDED."title", + "detail" = EXCLUDED."detail"; + +WITH week3 AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + JOIN "courses" c ON c."id" = w."course_id" + WHERE c."slug" = 'futuredrive-autonomy-basic' AND w."week" = 3 +) +INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description") +SELECT week3.week_id, data.sort_order, data.title, data.description +FROM week3 +CROSS JOIN ( + VALUES + (1, 'SLAM 개요', '동시적 위치추정 및 지도작성의 기본 개념'), + (2, 'Cartographer 설정', 'ROS2 Cartographer 패키지 설정 방법'), + (3, '실습: 맵 생성', 'LiDAR 데이터를 활용한 실시간 맵핑'), + (4, '위치 추정', '생성된 맵에서 로봇 위치 인식') +) AS data(sort_order, title, description) +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET "title" = EXCLUDED."title", + "description" = EXCLUDED."description"; diff --git a/prisma/migrations/20260602036000_add_course_platforms_and_progress/migration.sql b/prisma/migrations/20260602036000_add_course_platforms_and_progress/migration.sql new file mode 100644 index 0000000..e7f37aa --- /dev/null +++ b/prisma/migrations/20260602036000_add_course_platforms_and_progress/migration.sql @@ -0,0 +1,119 @@ +CREATE TABLE "platforms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "platform_key" TEXT NOT NULL, + "display_name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "organization_name" TEXT NOT NULL, + "icon_key" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "platforms_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_platforms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "platform_id" UUID NOT NULL, + "status" TEXT NOT NULL, + "action" TEXT NOT NULL, + "state" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "course_platforms_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_student_progress" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "completed_weeks" INTEGER NOT NULL DEFAULT 0, + "total_weeks" INTEGER NOT NULL DEFAULT 0, + "learning_hours" INTEGER NOT NULL DEFAULT 0, + "commit_count" INTEGER NOT NULL DEFAULT 0, + "average_score" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "course_student_progress_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "platforms_platform_key_key" ON "platforms"("platform_key"); +CREATE INDEX "platforms_organization_name_idx" ON "platforms"("organization_name"); +CREATE INDEX "platforms_is_active_idx" ON "platforms"("is_active"); +CREATE UNIQUE INDEX "course_platforms_course_id_platform_id_key" ON "course_platforms"("course_id", "platform_id"); +CREATE INDEX "course_platforms_course_id_sort_order_idx" ON "course_platforms"("course_id", "sort_order"); +CREATE INDEX "course_platforms_platform_id_idx" ON "course_platforms"("platform_id"); +CREATE UNIQUE INDEX "course_student_progress_course_id_user_id_key" ON "course_student_progress"("course_id", "user_id"); +CREATE INDEX "course_student_progress_user_id_idx" ON "course_student_progress"("user_id"); + +ALTER TABLE "course_platforms" ADD CONSTRAINT "course_platforms_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "course_platforms" ADD CONSTRAINT "course_platforms_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "course_student_progress" ADD CONSTRAINT "course_student_progress_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "course_student_progress" ADD CONSTRAINT "course_student_progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "platforms" IS '최고관리자가 등록하는 실습 플랫폼 마스터. 학교/기관별 개별 장비를 구분한다.'; +COMMENT ON TABLE "course_platforms" IS '강사가 강의에 사용할 플랫폼을 선택해 배정한 목록.'; +COMMENT ON TABLE "course_student_progress" IS '학생별 강의 학습 현황 통계.'; + +INSERT INTO "users" ("id", "email", "name", "createdAt", "updatedAt") +VALUES (gen_random_uuid(), 'student@futuredrive.net', '퓨처드라이브 학생', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("email") DO UPDATE +SET "name" = EXCLUDED."name", + "updatedAt" = CURRENT_TIMESTAMP; + +INSERT INTO "platforms" ("platform_key", "display_name", "description", "organization_name", "icon_key", "updated_at") +VALUES + ('futuredrive-local-pc', '로컬 PC', '내 PC에서 직접 개발 환경을 실행합니다.', '퓨처드라이브', 'laptop', CURRENT_TIMESTAMP), + ('futuredrive-simulator-01', '시뮬레이터', '가상 환경에서 안전하게 테스트합니다.', '퓨처드라이브', 'monitor', CURRENT_TIMESTAMP), + ('futuredrive-f1tenth-01', 'F1Tenth #01', '고속 주행 및 회피 알고리즘 실습 장비입니다.', '퓨처드라이브', 'car', CURRENT_TIMESTAMP), + ('futuredrive-mkmini-02', 'MK-mini #02', '기초 SLAM 및 자율주행 실습 장비입니다.', '퓨처드라이브', 'car', CURRENT_TIMESTAMP) +ON CONFLICT ("platform_key") DO UPDATE +SET "display_name" = EXCLUDED."display_name", + "description" = EXCLUDED."description", + "organization_name" = EXCLUDED."organization_name", + "icon_key" = EXCLUDED."icon_key", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic' +), data AS ( + SELECT * + FROM (VALUES + ('futuredrive-local-pc', '클라이언트 실행 시 제어권이 부여됩니다', '연결', 'ready', 1), + ('futuredrive-simulator-01', '사용 가능', '제어권 요청', 'ready', 2), + ('futuredrive-f1tenth-01', '타 학생 사용 중', '대기 예약', 'reserved', 3), + ('futuredrive-mkmini-02', '접근 권한 없음', '강사에게 권한 요청', 'locked', 4) + ) AS v(platform_key, status, action, state, sort_order) +) +INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at") +SELECT course."id", p."id", data.status, data.action, data.state, data.sort_order, CURRENT_TIMESTAMP +FROM course +JOIN data ON true +JOIN "platforms" p ON p."platform_key" = data.platform_key +ON CONFLICT ("course_id", "platform_id") DO UPDATE +SET "status" = EXCLUDED."status", + "action" = EXCLUDED."action", + "state" = EXCLUDED."state", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic' +), student AS ( + SELECT "id" FROM "users" WHERE "email" = 'student@futuredrive.net' +) +INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at") +SELECT course."id", student."id", 2, 5, 42, 18, 85, CURRENT_TIMESTAMP +FROM course, student +ON CONFLICT ("course_id", "user_id") DO UPDATE +SET "completed_weeks" = EXCLUDED."completed_weeks", + "total_weeks" = EXCLUDED."total_weeks", + "learning_hours" = EXCLUDED."learning_hours", + "commit_count" = EXCLUDED."commit_count", + "average_score" = EXCLUDED."average_score", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260602037000_add_missing_table_column_comments/migration.sql b/prisma/migrations/20260602037000_add_missing_table_column_comments/migration.sql new file mode 100644 index 0000000..65effd5 --- /dev/null +++ b/prisma/migrations/20260602037000_add_missing_table_column_comments/migration.sql @@ -0,0 +1,82 @@ +COMMENT ON TABLE "users" IS 'LMS 내부 사용자 기본 정보. 중앙 auth 사용자와 동기화해 앱별 데이터를 연결한다.'; +COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 고유 UUID.'; +COMMENT ON COLUMN "users"."auth_user_id" IS '중앙 auth DB users.id와 매핑되는 사용자 UUID.'; +COMMENT ON COLUMN "users"."email" IS '사용자 로그인 이메일.'; +COMMENT ON COLUMN "users"."name" IS '사용자 표시 이름.'; +COMMENT ON COLUMN "users"."createdAt" IS '사용자 데이터 생성 일시.'; +COMMENT ON COLUMN "users"."updatedAt" IS '사용자 데이터 수정 일시.'; + +COMMENT ON TABLE "courses" IS 'LMS 강의 마스터. 소속과 강사 기준으로 전체 강의 정보를 관리한다.'; +COMMENT ON COLUMN "courses"."id" IS '강의 고유 UUID.'; +COMMENT ON COLUMN "courses"."slug" IS '강의 식별용 문자열 키.'; +COMMENT ON COLUMN "courses"."title" IS '강의 제목.'; +COMMENT ON COLUMN "courses"."subtitle" IS '강의 부제목.'; +COMMENT ON COLUMN "courses"."description" IS '강의 설명.'; +COMMENT ON COLUMN "courses"."organization_name" IS '강의를 운영하는 소속명.'; +COMMENT ON COLUMN "courses"."teacher_id" IS '강의를 등록하거나 운영하는 강사 사용자 UUID.'; +COMMENT ON COLUMN "courses"."is_active" IS '강의 활성 여부.'; +COMMENT ON COLUMN "courses"."created_at" IS '강의 생성 일시.'; +COMMENT ON COLUMN "courses"."updated_at" IS '강의 수정 일시.'; + +COMMENT ON TABLE "course_weeks" IS '강의별 주차 커리큘럼 정보.'; +COMMENT ON COLUMN "course_weeks"."id" IS '주차 커리큘럼 고유 UUID.'; +COMMENT ON COLUMN "course_weeks"."course_id" IS '소속 강의 UUID.'; +COMMENT ON COLUMN "course_weeks"."week" IS '강의 내 주차 번호.'; +COMMENT ON COLUMN "course_weeks"."title" IS '주차 제목.'; +COMMENT ON COLUMN "course_weeks"."tags" IS '주차 상태 및 분류 태그 목록.'; +COMMENT ON COLUMN "course_weeks"."progress" IS '주차 기준 진행률.'; +COMMENT ON COLUMN "course_weeks"."state" IS '주차 상태. done, current, locked 등을 사용한다.'; +COMMENT ON COLUMN "course_weeks"."duration" IS '예상 학습 시간 표시값.'; +COMMENT ON COLUMN "course_weeks"."github" IS 'GitHub 제출 상태 표시값.'; +COMMENT ON COLUMN "course_weeks"."overview" IS '주차 개요 설명.'; +COMMENT ON COLUMN "course_weeks"."deliverable" IS '주차 제출 기준.'; +COMMENT ON COLUMN "course_weeks"."created_at" IS '주차 커리큘럼 생성 일시.'; +COMMENT ON COLUMN "course_weeks"."updated_at" IS '주차 커리큘럼 수정 일시.'; + +COMMENT ON TABLE "course_week_topics" IS '주차별 학습 주제와 상세 설명.'; +COMMENT ON COLUMN "course_week_topics"."id" IS '주차 학습 주제 고유 UUID.'; +COMMENT ON COLUMN "course_week_topics"."week_id" IS '소속 주차 커리큘럼 UUID.'; +COMMENT ON COLUMN "course_week_topics"."title" IS '학습 주제 제목.'; +COMMENT ON COLUMN "course_week_topics"."detail" IS '학습 주제 상세 설명.'; +COMMENT ON COLUMN "course_week_topics"."sort_order" IS '주차 내 학습 주제 표시 순서.'; + +COMMENT ON TABLE "course_week_practices" IS '주차별 실습 내용.'; +COMMENT ON COLUMN "course_week_practices"."id" IS '주차 실습 내용 고유 UUID.'; +COMMENT ON COLUMN "course_week_practices"."week_id" IS '소속 주차 커리큘럼 UUID.'; +COMMENT ON COLUMN "course_week_practices"."title" IS '실습 항목 제목.'; +COMMENT ON COLUMN "course_week_practices"."description" IS '실습 항목 설명.'; +COMMENT ON COLUMN "course_week_practices"."sort_order" IS '주차 내 실습 항목 표시 순서.'; + +COMMENT ON TABLE "platforms" IS '최고관리자가 등록하는 실습 플랫폼 마스터. 학교/기관별 개별 장비를 구분한다.'; +COMMENT ON COLUMN "platforms"."id" IS '실습 플랫폼 고유 UUID.'; +COMMENT ON COLUMN "platforms"."platform_key" IS '플랫폼 식별용 문자열 키. 기관과 장비를 구분할 수 있어야 한다.'; +COMMENT ON COLUMN "platforms"."display_name" IS '화면에 표시할 플랫폼 이름.'; +COMMENT ON COLUMN "platforms"."description" IS '플랫폼 설명.'; +COMMENT ON COLUMN "platforms"."organization_name" IS '플랫폼을 소유하거나 운영하는 소속명.'; +COMMENT ON COLUMN "platforms"."icon_key" IS '프론트엔드에서 사용할 아이콘 키.'; +COMMENT ON COLUMN "platforms"."is_active" IS '플랫폼 활성 여부.'; +COMMENT ON COLUMN "platforms"."created_at" IS '플랫폼 생성 일시.'; +COMMENT ON COLUMN "platforms"."updated_at" IS '플랫폼 수정 일시.'; + +COMMENT ON TABLE "course_platforms" IS '강사가 강의에 사용할 플랫폼을 선택해 배정한 목록.'; +COMMENT ON COLUMN "course_platforms"."id" IS '강의-플랫폼 배정 고유 UUID.'; +COMMENT ON COLUMN "course_platforms"."course_id" IS '플랫폼을 배정한 강의 UUID.'; +COMMENT ON COLUMN "course_platforms"."platform_id" IS '강의에 배정된 플랫폼 UUID.'; +COMMENT ON COLUMN "course_platforms"."status" IS '플랫폼 사용 가능 상태 설명.'; +COMMENT ON COLUMN "course_platforms"."action" IS '플랫폼 버튼에 표시할 작업명.'; +COMMENT ON COLUMN "course_platforms"."state" IS '플랫폼 상태. ready, reserved, locked 등을 사용한다.'; +COMMENT ON COLUMN "course_platforms"."sort_order" IS '강의 내 플랫폼 표시 순서.'; +COMMENT ON COLUMN "course_platforms"."created_at" IS '강의-플랫폼 배정 생성 일시.'; +COMMENT ON COLUMN "course_platforms"."updated_at" IS '강의-플랫폼 배정 수정 일시.'; + +COMMENT ON TABLE "course_student_progress" IS '학생별 강의 학습 현황 통계.'; +COMMENT ON COLUMN "course_student_progress"."id" IS '학생 학습 현황 고유 UUID.'; +COMMENT ON COLUMN "course_student_progress"."course_id" IS '학습 현황이 속한 강의 UUID.'; +COMMENT ON COLUMN "course_student_progress"."user_id" IS '학습 현황 대상 학생 사용자 UUID.'; +COMMENT ON COLUMN "course_student_progress"."completed_weeks" IS '완료한 주차 수.'; +COMMENT ON COLUMN "course_student_progress"."total_weeks" IS '전체 주차 수.'; +COMMENT ON COLUMN "course_student_progress"."learning_hours" IS '누적 학습 시간.'; +COMMENT ON COLUMN "course_student_progress"."commit_count" IS '누적 커밋 수.'; +COMMENT ON COLUMN "course_student_progress"."average_score" IS '평균 점수.'; +COMMENT ON COLUMN "course_student_progress"."created_at" IS '학생 학습 현황 생성 일시.'; +COMMENT ON COLUMN "course_student_progress"."updated_at" IS '학생 학습 현황 수정 일시.'; diff --git a/prisma/migrations/20260602038000_align_seed_users_with_auth/migration.sql b/prisma/migrations/20260602038000_align_seed_users_with_auth/migration.sql new file mode 100644 index 0000000..399e9fb --- /dev/null +++ b/prisma/migrations/20260602038000_align_seed_users_with_auth/migration.sql @@ -0,0 +1,43 @@ +UPDATE "users" +SET "auth_user_id" = '54803583-0a04-44f4-9398-78478239c566', + "name" = 'ATNMY Studio Admin', + "updatedAt" = CURRENT_TIMESTAMP +WHERE "email" = 'teacher@futuredrive.net'; + +INSERT INTO "users" ("id", "auth_user_id", "email", "name", "createdAt", "updatedAt") +VALUES ( + gen_random_uuid(), + 'a8e7f37e-ef63-4912-a412-b4874b91ee67', + 'chyoo@futuredrive.net', + '유창훈', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) +ON CONFLICT ("email") DO UPDATE +SET "auth_user_id" = EXCLUDED."auth_user_id", + "name" = EXCLUDED."name", + "updatedAt" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic' +), learner AS ( + SELECT "id" FROM "users" WHERE "email" = 'chyoo@futuredrive.net' +) +INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at") +SELECT course."id", learner."id", 2, 5, 42, 18, 85, CURRENT_TIMESTAMP +FROM course, learner +ON CONFLICT ("course_id", "user_id") DO UPDATE +SET "completed_weeks" = EXCLUDED."completed_weeks", + "total_weeks" = EXCLUDED."total_weeks", + "learning_hours" = EXCLUDED."learning_hours", + "commit_count" = EXCLUDED."commit_count", + "average_score" = EXCLUDED."average_score", + "updated_at" = CURRENT_TIMESTAMP; + +DELETE FROM "course_student_progress" +WHERE "user_id" IN ( + SELECT "id" FROM "users" WHERE "email" = 'student@futuredrive.net' +); + +DELETE FROM "users" +WHERE "email" = 'student@futuredrive.net'; diff --git a/prisma/migrations/20260602039000_remove_auth_user_seed_mapping/migration.sql b/prisma/migrations/20260602039000_remove_auth_user_seed_mapping/migration.sql new file mode 100644 index 0000000..caf6655 --- /dev/null +++ b/prisma/migrations/20260602039000_remove_auth_user_seed_mapping/migration.sql @@ -0,0 +1,4 @@ +UPDATE "users" +SET "auth_user_id" = NULL, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "email" IN ('teacher@futuredrive.net', 'chyoo@futuredrive.net'); diff --git a/prisma/migrations/20260602040000_remove_auth_user_copied_seed/migration.sql b/prisma/migrations/20260602040000_remove_auth_user_copied_seed/migration.sql new file mode 100644 index 0000000..559f2e2 --- /dev/null +++ b/prisma/migrations/20260602040000_remove_auth_user_copied_seed/migration.sql @@ -0,0 +1,7 @@ +DELETE FROM "course_student_progress" +WHERE "user_id" IN ( + SELECT "id" FROM "users" WHERE "email" = 'chyoo@futuredrive.net' +); + +DELETE FROM "users" +WHERE "email" = 'chyoo@futuredrive.net'; diff --git a/prisma/migrations/20260602173000_add_chat_records/migration.sql b/prisma/migrations/20260602173000_add_chat_records/migration.sql new file mode 100644 index 0000000..870642d --- /dev/null +++ b/prisma/migrations/20260602173000_add_chat_records/migration.sql @@ -0,0 +1,72 @@ +CREATE TYPE "ChatThreadType" AS ENUM ('AI', 'INSTRUCTOR', 'MIXED'); +CREATE TYPE "ChatThreadStatus" AS ENUM ('OPEN', 'WAITING_INSTRUCTOR', 'RESOLVED', 'ARCHIVED'); +CREATE TYPE "ChatSenderType" AS ENUM ('STUDENT', 'AI', 'INSTRUCTOR', 'SYSTEM'); +CREATE TYPE "ChatContentFormat" AS ENUM ('TEXT', 'MARKDOWN'); +CREATE TYPE "AiChatRunStatus" AS ENUM ('SUCCEEDED', 'FAILED'); + +CREATE TABLE "chat_threads" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week_id" UUID, + "student_id" UUID NOT NULL, + "assigned_instructor_id" UUID, + "type" "ChatThreadType" NOT NULL, + "status" "ChatThreadStatus" NOT NULL DEFAULT 'OPEN', + "title" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_threads_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "chat_messages" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "thread_id" UUID NOT NULL, + "sender_type" "ChatSenderType" NOT NULL, + "sender_user_id" UUID, + "content" TEXT NOT NULL, + "content_format" "ChatContentFormat" NOT NULL DEFAULT 'TEXT', + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ai_chat_runs" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "message_id" UUID NOT NULL, + "model" TEXT NOT NULL, + "prompt_tokens" INTEGER, + "completion_tokens" INTEGER, + "total_tokens" INTEGER, + "latency_ms" INTEGER, + "status" "AiChatRunStatus" NOT NULL, + "error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_chat_runs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "chat_threads_course_id_type_last_message_at_idx" ON "chat_threads"("course_id", "type", "last_message_at"); +CREATE INDEX "chat_threads_student_id_type_last_message_at_idx" ON "chat_threads"("student_id", "type", "last_message_at"); +CREATE INDEX "chat_threads_week_id_idx" ON "chat_threads"("week_id"); +CREATE INDEX "chat_threads_assigned_instructor_id_idx" ON "chat_threads"("assigned_instructor_id"); +CREATE INDEX "chat_messages_thread_id_created_at_idx" ON "chat_messages"("thread_id", "created_at"); +CREATE INDEX "chat_messages_sender_user_id_idx" ON "chat_messages"("sender_user_id"); +CREATE UNIQUE INDEX "ai_chat_runs_message_id_key" ON "ai_chat_runs"("message_id"); +CREATE INDEX "ai_chat_runs_status_created_at_idx" ON "ai_chat_runs"("status", "created_at"); + +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_assigned_instructor_id_fkey" FOREIGN KEY ("assigned_instructor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "chat_threads"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_sender_user_id_fkey" FOREIGN KEY ("sender_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ai_chat_runs" ADD CONSTRAINT "ai_chat_runs_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "chat_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "chat_threads" IS '학생 질의응답 대화방. AI, 강사, 혼합 대화의 상태와 연결 주차를 관리한다.'; +COMMENT ON TABLE "chat_messages" IS '질의응답 대화방의 개별 메시지 기록.'; +COMMENT ON TABLE "ai_chat_runs" IS 'AI 응답 생성 실행 기록. 모델, 토큰, 지연 시간, 오류를 추적한다.'; +COMMENT ON COLUMN "chat_messages"."metadata" IS '코드 파일, 터미널 출력, 선택 모듈 등 메시지와 함께 보낸 추가 컨텍스트.'; diff --git a/prisma/migrations/20260604090000_use_timestamptz/migration.sql b/prisma/migrations/20260604090000_use_timestamptz/migration.sql new file mode 100644 index 0000000..4bac5a4 --- /dev/null +++ b/prisma/migrations/20260604090000_use_timestamptz/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + column_record record; +BEGIN + FOR column_record IN + SELECT table_schema, table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND data_type = 'timestamp without time zone' + LOOP + EXECUTE format( + 'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''', + column_record.table_schema, + column_record.table_name, + column_record.column_name, + column_record.column_name + ); + END LOOP; +END $$; diff --git a/prisma/migrations/20260604110000_add_user_affiliation_snapshot/migration.sql b/prisma/migrations/20260604110000_add_user_affiliation_snapshot/migration.sql new file mode 100644 index 0000000..ed90291 --- /dev/null +++ b/prisma/migrations/20260604110000_add_user_affiliation_snapshot/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE "users" + ADD COLUMN "auth_role" TEXT, + ADD COLUMN "primary_organization_id" UUID, + ADD COLUMN "primary_organization_name" TEXT, + ADD COLUMN "primary_organization_type" TEXT, + ADD COLUMN "department" TEXT, + ADD COLUMN "position" TEXT; + +CREATE INDEX "users_auth_role_idx" ON "users"("auth_role"); +CREATE INDEX "users_primary_organization_id_idx" ON "users"("primary_organization_id"); diff --git a/prisma/migrations/20260604113000_add_student_groups/migration.sql b/prisma/migrations/20260604113000_add_student_groups/migration.sql new file mode 100644 index 0000000..a346e95 --- /dev/null +++ b/prisma/migrations/20260604113000_add_student_groups/migration.sql @@ -0,0 +1,73 @@ +CREATE TABLE "student_groups" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "name" TEXT NOT NULL, + "week" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT '진행중', + "platform_name" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "student_groups_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "student_group_members" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "group_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "student_group_members_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "student_groups_organization_name_name_key" ON "student_groups"("organization_name", "name"); +CREATE INDEX "student_groups_organization_id_idx" ON "student_groups"("organization_id"); +CREATE INDEX "student_groups_organization_name_idx" ON "student_groups"("organization_name"); +CREATE UNIQUE INDEX "student_group_members_group_id_user_id_key" ON "student_group_members"("group_id", "user_id"); +CREATE INDEX "student_group_members_user_id_idx" ON "student_group_members"("user_id"); + +ALTER TABLE "student_group_members" + ADD CONSTRAINT "student_group_members_group_id_fkey" + FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "student_group_members" + ADD CONSTRAINT "student_group_members_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "student_groups" ("organization_id", "organization_name", "name", "week", "status", "platform_name") +SELECT org."organization_id", org."organization_name", seed."name", seed."week", '진행중', seed."platform_name" +FROM ( + VALUES + ('그룹 A', 3, 'MK-mini #01'), + ('그룹 B', 2, '시뮬레이션'), + ('그룹 C', 4, 'f1tenth #01') +) AS seed("name", "week", "platform_name") +CROSS JOIN ( + SELECT DISTINCT "primary_organization_id" AS "organization_id", "primary_organization_name" AS "organization_name" + FROM "users" + WHERE "primary_organization_name" IS NOT NULL +) org +ON CONFLICT ("organization_name", "name") DO NOTHING; + +INSERT INTO "student_group_members" ("group_id", "user_id") +SELECT g."id", u."id" +FROM "student_groups" g +JOIN "users" u ON u."primary_organization_name" = g."organization_name" +WHERE g."name" = '그룹 A' + AND u."email" IN ('kms@yu.ac.kr', 'lsy@yu.ac.kr', 'cyj@yu.ac.kr') +ON CONFLICT ("group_id", "user_id") DO NOTHING; + +INSERT INTO "student_group_members" ("group_id", "user_id") +SELECT g."id", u."id" +FROM "student_groups" g +JOIN "users" u ON u."primary_organization_name" = g."organization_name" +WHERE g."name" = '그룹 B' + AND u."email" IN ('jyr@yu.ac.kr', 'pjh@yu.ac.kr') +ON CONFLICT ("group_id", "user_id") DO NOTHING; + +INSERT INTO "student_group_members" ("group_id", "user_id") +SELECT g."id", u."id" +FROM "student_groups" g +JOIN "users" u ON u."primary_organization_name" = g."organization_name" +WHERE g."name" = '그룹 C' + AND u."email" IN ('hdy@yu.ac.kr', 'ojh@yu.ac.kr', 'ysj@yu.ac.kr', 'kdh@yu.ac.kr') +ON CONFLICT ("group_id", "user_id") DO NOTHING; diff --git a/prisma/migrations/20260604143000_add_student_enrollments/migration.sql b/prisma/migrations/20260604143000_add_student_enrollments/migration.sql new file mode 100644 index 0000000..7160ee7 --- /dev/null +++ b/prisma/migrations/20260604143000_add_student_enrollments/migration.sql @@ -0,0 +1,29 @@ +CREATE TABLE "student_enrollments" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "removed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "student_enrollments_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "student_enrollments_user_id_organization_name_key" ON "student_enrollments"("user_id", "organization_name"); +CREATE INDEX "student_enrollments_organization_id_status_idx" ON "student_enrollments"("organization_id", "status"); +CREATE INDEX "student_enrollments_organization_name_status_idx" ON "student_enrollments"("organization_name", "status"); + +ALTER TABLE "student_enrollments" ADD CONSTRAINT "student_enrollments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "student_enrollments" ("user_id", "organization_id", "organization_name", "status") +SELECT + u."id", + u."primary_organization_id", + u."primary_organization_name", + 'ACTIVE' +FROM "users" u +WHERE u."primary_organization_name" IS NOT NULL + AND (u."auth_role" IS NULL OR u."auth_role" IN ('USER', 'STUDENT')) +ON CONFLICT ("user_id", "organization_name") DO NOTHING; diff --git a/prisma/migrations/20260604170000_add_instructor_platforms/migration.sql b/prisma/migrations/20260604170000_add_instructor_platforms/migration.sql new file mode 100644 index 0000000..bd61c46 --- /dev/null +++ b/prisma/migrations/20260604170000_add_instructor_platforms/migration.sql @@ -0,0 +1,37 @@ +CREATE TABLE "instructor_platforms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "platform_id" UUID NOT NULL, + "display_name" TEXT NOT NULL, + "instance_no" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT '대기', + "battery" INTEGER NOT NULL DEFAULT 100, + "uptime_minutes" INTEGER NOT NULL DEFAULT 0, + "speed_meters" DECIMAL(6, 2) NOT NULL DEFAULT 0, + "error_count" INTEGER NOT NULL DEFAULT 0, + "emergency_stop" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "instructor_platforms_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "instructor_platforms_organization_name_display_name_key" + ON "instructor_platforms"("organization_name", "display_name"); + +CREATE UNIQUE INDEX "instructor_platforms_organization_name_platform_id_instance_no_key" + ON "instructor_platforms"("organization_name", "platform_id", "instance_no"); + +CREATE INDEX "instructor_platforms_organization_id_idx" + ON "instructor_platforms"("organization_id"); + +CREATE INDEX "instructor_platforms_organization_name_idx" + ON "instructor_platforms"("organization_name"); + +CREATE INDEX "instructor_platforms_platform_id_idx" + ON "instructor_platforms"("platform_id"); + +ALTER TABLE "instructor_platforms" + ADD CONSTRAINT "instructor_platforms_platform_id_fkey" + FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260604172000_add_instructor_curriculum_settings/migration.sql b/prisma/migrations/20260604172000_add_instructor_curriculum_settings/migration.sql new file mode 100644 index 0000000..d038881 --- /dev/null +++ b/prisma/migrations/20260604172000_add_instructor_curriculum_settings/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "instructor_curriculum_settings" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "course_id" UUID NOT NULL, + "active_week" INTEGER NOT NULL DEFAULT 1, + "active_learning_step" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "instructor_curriculum_settings_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_name_key" + ON "instructor_curriculum_settings"("organization_name"); + +CREATE INDEX "instructor_curriculum_settings_organization_id_idx" + ON "instructor_curriculum_settings"("organization_id"); + +CREATE INDEX "instructor_curriculum_settings_course_id_idx" + ON "instructor_curriculum_settings"("course_id"); + +ALTER TABLE "instructor_curriculum_settings" + ADD CONSTRAINT "instructor_curriculum_settings_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260604174000_add_curriculum_week_overrides/migration.sql b/prisma/migrations/20260604174000_add_curriculum_week_overrides/migration.sql new file mode 100644 index 0000000..bb5a0de --- /dev/null +++ b/prisma/migrations/20260604174000_add_curriculum_week_overrides/migration.sql @@ -0,0 +1,37 @@ +UPDATE "courses" +SET "organization_name" = '공통' +WHERE "slug" = 'futuredrive-autonomy-basic' + AND "organization_name" = '퓨처드라이브'; + +CREATE TABLE "instructor_curriculum_week_overrides" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "title" TEXT, + "overview" TEXT, + "deliverable" TEXT, + "topics" JSONB, + "topic_details" JSONB, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "instructor_curriculum_week_overrides_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "instructor_curriculum_week_overrides_organization_name_course_id_week_key" + ON "instructor_curriculum_week_overrides"("organization_name", "course_id", "week"); + +CREATE INDEX "instructor_curriculum_week_overrides_organization_id_idx" + ON "instructor_curriculum_week_overrides"("organization_id"); + +CREATE INDEX "instructor_curriculum_week_overrides_organization_name_idx" + ON "instructor_curriculum_week_overrides"("organization_name"); + +CREATE INDEX "instructor_curriculum_week_overrides_course_id_idx" + ON "instructor_curriculum_week_overrides"("course_id"); + +ALTER TABLE "instructor_curriculum_week_overrides" + ADD CONSTRAINT "instructor_curriculum_week_overrides_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260604175000_add_curriculum_override_duration/migration.sql b/prisma/migrations/20260604175000_add_curriculum_override_duration/migration.sql new file mode 100644 index 0000000..659b7e2 --- /dev/null +++ b/prisma/migrations/20260604175000_add_curriculum_override_duration/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "instructor_curriculum_week_overrides" + ADD COLUMN "duration" TEXT; diff --git a/prisma/migrations/20260605093000_remove_local_pc_and_simulator_platforms/migration.sql b/prisma/migrations/20260605093000_remove_local_pc_and_simulator_platforms/migration.sql new file mode 100644 index 0000000..4d0119e --- /dev/null +++ b/prisma/migrations/20260605093000_remove_local_pc_and_simulator_platforms/migration.sql @@ -0,0 +1,32 @@ +UPDATE "student_groups" +SET "platform_name" = NULL +WHERE "platform_name" IN ('로컬 PC', '시뮬레이션', '시뮬레이터', 'Simulator'); + +DELETE FROM "instructor_platforms" +WHERE "platform_id" IN ( + SELECT "id" + FROM "platforms" + WHERE "platform_key" ILIKE '%local%' + OR "platform_key" ILIKE '%simulator%' + OR "display_name" ILIKE '%로컬%' + OR "display_name" ILIKE '%시뮬%' + OR "display_name" ILIKE '%simulator%' +); + +DELETE FROM "course_platforms" +WHERE "platform_id" IN ( + SELECT "id" + FROM "platforms" + WHERE "platform_key" ILIKE '%local%' + OR "platform_key" ILIKE '%simulator%' + OR "display_name" ILIKE '%로컬%' + OR "display_name" ILIKE '%시뮬%' + OR "display_name" ILIKE '%simulator%' +); + +DELETE FROM "platforms" +WHERE "platform_key" ILIKE '%local%' + OR "platform_key" ILIKE '%simulator%' + OR "display_name" ILIKE '%로컬%' + OR "display_name" ILIKE '%시뮬%' + OR "display_name" ILIKE '%simulator%'; diff --git a/prisma/migrations/20260605103000_restore_local_pc_and_simulator_platforms/migration.sql b/prisma/migrations/20260605103000_restore_local_pc_and_simulator_platforms/migration.sql new file mode 100644 index 0000000..de8fa10 --- /dev/null +++ b/prisma/migrations/20260605103000_restore_local_pc_and_simulator_platforms/migration.sql @@ -0,0 +1,32 @@ +INSERT INTO "platforms" ("platform_key", "display_name", "description", "organization_name", "icon_key", "is_active", "updated_at") +VALUES + ('futuredrive-local-pc', '로컬 PC', '내 PC에서 직접 개발 환경을 실행합니다.', '퓨처드라이브', 'laptop', true, CURRENT_TIMESTAMP), + ('futuredrive-simulator-01', '시뮬레이터', '가상 환경에서 안전하게 테스트합니다.', '퓨처드라이브', 'monitor', true, CURRENT_TIMESTAMP) +ON CONFLICT ("platform_key") DO UPDATE +SET "display_name" = EXCLUDED."display_name", + "description" = EXCLUDED."description", + "organization_name" = EXCLUDED."organization_name", + "icon_key" = EXCLUDED."icon_key", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic' +), data AS ( + SELECT * + FROM (VALUES + ('futuredrive-local-pc', '클라이언트 실행 시 제어권이 부여됩니다', '연결', 'ready', 1), + ('futuredrive-simulator-01', '사용 가능', '제어권 요청', 'ready', 2) + ) AS v(platform_key, status, action, state, sort_order) +) +INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at") +SELECT course."id", p."id", data.status, data.action, data.state, data.sort_order, CURRENT_TIMESTAMP +FROM course +JOIN data ON true +JOIN "platforms" p ON p."platform_key" = data.platform_key +ON CONFLICT ("course_id", "platform_id") DO UPDATE +SET "status" = EXCLUDED."status", + "action" = EXCLUDED."action", + "state" = EXCLUDED."state", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260605110000_normalize_master_platform_display_names/migration.sql b/prisma/migrations/20260605110000_normalize_master_platform_display_names/migration.sql new file mode 100644 index 0000000..f0162a3 --- /dev/null +++ b/prisma/migrations/20260605110000_normalize_master_platform_display_names/migration.sql @@ -0,0 +1,7 @@ +UPDATE platforms +SET display_name = 'F1Tenth' +WHERE platform_key = 'futuredrive-f1tenth-01'; + +UPDATE platforms +SET display_name = 'MK-mini' +WHERE platform_key = 'futuredrive-mkmini-02'; diff --git a/prisma/migrations/20260605112000_add_platform_control/migration.sql b/prisma/migrations/20260605112000_add_platform_control/migration.sql new file mode 100644 index 0000000..ba130f9 --- /dev/null +++ b/prisma/migrations/20260605112000_add_platform_control/migration.sql @@ -0,0 +1,63 @@ +ALTER TABLE "instructor_platforms" +ADD COLUMN "control_duration_minutes" INTEGER NOT NULL DEFAULT 30, +ADD COLUMN "control_warning_minutes" INTEGER NOT NULL DEFAULT 5; + +CREATE TABLE "platform_control_sessions" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "platform_id" UUID, + "instructor_platform_id" UUID, + "holder_user_id" UUID NOT NULL, + "group_id" UUID, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "started_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ends_at" TIMESTAMPTZ(3) NOT NULL, + "warning_at" TIMESTAMPTZ(3) NOT NULL, + "warning_sent_at" TIMESTAMPTZ(3), + "released_at" TIMESTAMPTZ(3), + "end_reason" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "platform_control_sessions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "platform_control_sessions_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "platform_control_sessions_instructor_platform_id_fkey" FOREIGN KEY ("instructor_platform_id") REFERENCES "instructor_platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "platform_control_sessions_holder_user_id_fkey" FOREIGN KEY ("holder_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "platform_control_sessions_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE TABLE "platform_control_queue_entries" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "organization_name" TEXT NOT NULL, + "platform_id" UUID, + "instructor_platform_id" UUID, + "user_id" UUID NOT NULL, + "group_id" UUID, + "status" TEXT NOT NULL DEFAULT 'WAITING', + "requested_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "granted_at" TIMESTAMPTZ(3), + "canceled_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "platform_control_queue_entries_pkey" PRIMARY KEY ("id"), + CONSTRAINT "platform_control_queue_entries_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "platform_control_queue_entries_instructor_platform_id_fkey" FOREIGN KEY ("instructor_platform_id") REFERENCES "instructor_platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "platform_control_queue_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "platform_control_queue_entries_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX "platform_control_sessions_organization_id_idx" ON "platform_control_sessions"("organization_id"); +CREATE INDEX "platform_control_sessions_organization_name_idx" ON "platform_control_sessions"("organization_name"); +CREATE INDEX "platform_control_sessions_platform_id_status_idx" ON "platform_control_sessions"("platform_id", "status"); +CREATE INDEX "platform_control_sessions_instructor_platform_id_status_idx" ON "platform_control_sessions"("instructor_platform_id", "status"); +CREATE INDEX "platform_control_sessions_holder_user_id_status_idx" ON "platform_control_sessions"("holder_user_id", "status"); +CREATE INDEX "platform_control_sessions_ends_at_status_idx" ON "platform_control_sessions"("ends_at", "status"); + +CREATE INDEX "platform_control_queue_entries_organization_id_idx" ON "platform_control_queue_entries"("organization_id"); +CREATE INDEX "platform_control_queue_entries_organization_name_idx" ON "platform_control_queue_entries"("organization_name"); +CREATE INDEX "platform_control_queue_entries_platform_id_status_requested_at_idx" ON "platform_control_queue_entries"("platform_id", "status", "requested_at"); +CREATE INDEX "platform_control_queue_entries_instructor_platform_id_status_requested_at_idx" ON "platform_control_queue_entries"("instructor_platform_id", "status", "requested_at"); +CREATE INDEX "platform_control_queue_entries_user_id_status_idx" ON "platform_control_queue_entries"("user_id", "status"); diff --git a/prisma/migrations/20260605113000_add_platform_default_settings/migration.sql b/prisma/migrations/20260605113000_add_platform_default_settings/migration.sql new file mode 100644 index 0000000..f39085c --- /dev/null +++ b/prisma/migrations/20260605113000_add_platform_default_settings/migration.sql @@ -0,0 +1,44 @@ +ALTER TABLE "platforms" +ADD COLUMN "vehicle_type" TEXT NOT NULL DEFAULT 'MK-mini', +ADD COLUMN "wheelbase_meters" DECIMAL(6, 3) NOT NULL DEFAULT 0.325, +ADD COLUMN "max_speed_meters_per_second" DECIMAL(6, 2) NOT NULL DEFAULT 7.0, +ADD COLUMN "max_steering_angle_degrees" DECIMAL(6, 2) NOT NULL DEFAULT 30, +ADD COLUMN "sensors" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "drivers" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "algorithms" JSONB NOT NULL DEFAULT '{}', +ADD COLUMN "control_duration_minutes" INTEGER NOT NULL DEFAULT 30, +ADD COLUMN "control_warning_minutes" INTEGER NOT NULL DEFAULT 5; + +ALTER TABLE "instructor_platforms" +ADD COLUMN "vehicle_type" TEXT NOT NULL DEFAULT 'MK-mini', +ADD COLUMN "wheelbase_meters" DECIMAL(6, 3) NOT NULL DEFAULT 0.325, +ADD COLUMN "max_speed_meters_per_second" DECIMAL(6, 2) NOT NULL DEFAULT 7.0, +ADD COLUMN "max_steering_angle_degrees" DECIMAL(6, 2) NOT NULL DEFAULT 30, +ADD COLUMN "sensors" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "drivers" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "algorithms" JSONB NOT NULL DEFAULT '{}'; + +UPDATE "platforms" +SET + "vehicle_type" = CASE + WHEN "display_name" ILIKE '%F1Tenth%' THEN 'F1tenth' + WHEN "display_name" ILIKE '%시뮬%' OR "display_name" ILIKE '%Simulator%' THEN 'Simulator' + ELSE 'MK-mini' + END, + "sensors" = '[{"title":"LiDAR","description":"Robosense Helios 16채널"},{"title":"RGB-D 카메라","description":"Intel Realsense D435"},{"title":"IMU 센서","description":"9축 관성측정장치"}]'::jsonb, + "drivers" = '[{"title":"LiDAR 드라이버","description":"robosense_driver v1.5.0"},{"title":"Camera 드라이버","description":"realsense2_camera v4.55.1"},{"title":"IMU 드라이버","description":"xsens_driver v2.2.0"}]'::jsonb, + "algorithms" = '{"slam":"Cartographer","planning":"A* + DWA","control":"PID Control"}'::jsonb; + +UPDATE "instructor_platforms" AS ip +SET + "vehicle_type" = p."vehicle_type", + "wheelbase_meters" = p."wheelbase_meters", + "max_speed_meters_per_second" = p."max_speed_meters_per_second", + "max_steering_angle_degrees" = p."max_steering_angle_degrees", + "sensors" = p."sensors", + "drivers" = p."drivers", + "algorithms" = p."algorithms", + "control_duration_minutes" = p."control_duration_minutes", + "control_warning_minutes" = p."control_warning_minutes" +FROM "platforms" AS p +WHERE p."id" = ip."platform_id"; diff --git a/prisma/migrations/20260605114000_add_sensor_enabled_defaults/migration.sql b/prisma/migrations/20260605114000_add_sensor_enabled_defaults/migration.sql new file mode 100644 index 0000000..3917c22 --- /dev/null +++ b/prisma/migrations/20260605114000_add_sensor_enabled_defaults/migration.sql @@ -0,0 +1,11 @@ +UPDATE "platforms" +SET "sensors" = ( + SELECT COALESCE(jsonb_agg(sensor || jsonb_build_object('enabled', COALESCE((sensor->>'enabled')::boolean, true))), '[]'::jsonb) + FROM jsonb_array_elements("platforms"."sensors") AS sensor +); + +UPDATE "instructor_platforms" +SET "sensors" = ( + SELECT COALESCE(jsonb_agg(sensor || jsonb_build_object('enabled', COALESCE((sensor->>'enabled')::boolean, true))), '[]'::jsonb) + FROM jsonb_array_elements("instructor_platforms"."sensors") AS sensor +); diff --git a/prisma/migrations/20260605123000_add_course_week_modules/migration.sql b/prisma/migrations/20260605123000_add_course_week_modules/migration.sql new file mode 100644 index 0000000..974421c --- /dev/null +++ b/prisma/migrations/20260605123000_add_course_week_modules/migration.sql @@ -0,0 +1,62 @@ +CREATE TABLE "course_week_modules" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "week_id" UUID NOT NULL, + "sort_order" INTEGER NOT NULL, + "category" TEXT NOT NULL, + "name" TEXT NOT NULL, + "folder_name" TEXT NOT NULL, + "template_repository_url" TEXT, + "docker_image" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "course_week_modules_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "course_week_modules_week_id_folder_name_key" ON "course_week_modules"("week_id", "folder_name"); +CREATE UNIQUE INDEX "course_week_modules_week_id_sort_order_key" ON "course_week_modules"("week_id", "sort_order"); +CREATE INDEX "course_week_modules_week_id_idx" ON "course_week_modules"("week_id"); + +ALTER TABLE "course_week_modules" + ADD CONSTRAINT "course_week_modules_week_id_fkey" + FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "course_week_modules" IS '주차별 개발 스튜디오 모듈 목록.'; +COMMENT ON COLUMN "course_week_modules"."folder_name" IS '관리자 템플릿 또는 로컬 캐시의 원본 모듈 폴더명.'; +COMMENT ON COLUMN "course_week_modules"."template_repository_url" IS '추후 Gitea 템플릿 저장소 연동 시 사용할 원본 repo URL.'; +COMMENT ON COLUMN "course_week_modules"."docker_image" IS '모듈 실행에 사용할 Docker 이미지.'; + +WITH target_week AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + JOIN "courses" c ON c."id" = w."course_id" + WHERE c."slug" = 'futuredrive-autonomy-basic' AND w."week" = 3 +), +seed(sort_order, category, name, folder_name) AS ( + VALUES + (1, 'Gui', 'AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws'), + (2, 'Map', 'AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws'), + (3, 'Simulator', 'AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws'), + (4, 'Map', 'DGIST-Campus', '[furive-map]DGIST-Campus_v1.0.0_ws'), + (5, 'Planning', 'AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws'), + (6, 'Bag', 'AW-SampleBag', '[furive-bag]AW-SampleBag_v1.0.0_ws'), + (7, 'Perception', 'AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws'), + (8, 'Sensing', 'test2', '[furive-sensing]test2_v1.0.0_ws'), + (9, 'Localization', 'AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws'), + (10, 'System', 'AW-System', '[furive-system]AW-System_v1.0.0_ws'), + (11, 'Vehicle', 'AW-TestSampleVehicle', '[furive-vehicle]AW-TestSampleVehicle_v1.0.0_ws'), + (12, 'Module', 'vehicle-f1', 'vehicle-f1_v1.0.0_ws'), + (13, 'Vehicle', 'AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws'), + (14, 'Control', 'AW-Control', '[furive-control]AW-Control_v1.0.0_ws'), + (15, 'Map', 'AW-SampleMapRosBag', '[furive-map]AW-SampleMapRosBag_v1.0.0_ws'), + (16, 'Sensing', 'AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws') +) +INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name") +SELECT target_week.week_id, seed.sort_order, seed.category, seed.name, seed.folder_name +FROM target_week +CROSS JOIN seed +ON CONFLICT ("week_id", "folder_name") DO UPDATE +SET "sort_order" = EXCLUDED."sort_order", + "category" = EXCLUDED."category", + "name" = EXCLUDED."name", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260605124000_seed_course_week_modules_all_weeks/migration.sql b/prisma/migrations/20260605124000_seed_course_week_modules_all_weeks/migration.sql new file mode 100644 index 0000000..4378bfc --- /dev/null +++ b/prisma/migrations/20260605124000_seed_course_week_modules_all_weeks/migration.sql @@ -0,0 +1,18 @@ +DELETE FROM "course_week_modules"; + +WITH seed(sort_order, category, name, folder_name) AS ( + VALUES + (1, 'Gui', 'AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws'), + (2, 'Map', 'AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws'), + (3, 'Planning', 'AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws'), + (4, 'Perception', 'AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws'), + (5, 'Localization', 'AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws'), + (6, 'System', 'AW-System', '[furive-system]AW-System_v1.0.0_ws'), + (7, 'Vehicle', 'AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws'), + (8, 'Control', 'AW-Control', '[furive-control]AW-Control_v1.0.0_ws'), + (9, 'Sensing', 'AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws') +) +INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name") +SELECT w."id", seed.sort_order, seed.category, seed.name, seed.folder_name +FROM "course_weeks" w +CROSS JOIN seed; diff --git a/prisma/migrations/20260605125000_set_course_week_module_template_urls/migration.sql b/prisma/migrations/20260605125000_set_course_week_module_template_urls/migration.sql new file mode 100644 index 0000000..9260d1b --- /dev/null +++ b/prisma/migrations/20260605125000_set_course_week_module_template_urls/migration.sql @@ -0,0 +1,24 @@ +UPDATE "course_week_modules" +SET "template_repository_url" = CASE "name" + WHEN 'AW-Rviz' THEN 'http://localhost:3000/templates/aw-rviz.git' + WHEN 'AW-SampleMap' THEN 'http://localhost:3000/templates/aw-samplemap.git' + WHEN 'AW-Planning' THEN 'http://localhost:3000/templates/aw-planning.git' + WHEN 'AW-Perception' THEN 'http://localhost:3000/templates/aw-perception.git' + WHEN 'AW-Localization' THEN 'http://localhost:3000/templates/aw-localization.git' + WHEN 'AW-System' THEN 'http://localhost:3000/templates/aw-system.git' + WHEN 'AW-SampleVehicle' THEN 'http://localhost:3000/templates/aw-samplevehicle.git' + WHEN 'AW-Control' THEN 'http://localhost:3000/templates/aw-control.git' + WHEN 'AW-SampleSensor' THEN 'http://localhost:3000/templates/aw-samplesensor.git' + ELSE "template_repository_url" +END +WHERE "name" IN ( + 'AW-Rviz', + 'AW-SampleMap', + 'AW-Planning', + 'AW-Perception', + 'AW-Localization', + 'AW-System', + 'AW-SampleVehicle', + 'AW-Control', + 'AW-SampleSensor' +); diff --git a/prisma/migrations/20260605130000_add_studio_repositories/migration.sql b/prisma/migrations/20260605130000_add_studio_repositories/migration.sql new file mode 100644 index 0000000..21e1fd9 --- /dev/null +++ b/prisma/migrations/20260605130000_add_studio_repositories/migration.sql @@ -0,0 +1,28 @@ +CREATE TABLE "studio_repositories" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "week_id" UUID NOT NULL, + "module_id" UUID NOT NULL, + "repo_owner" TEXT NOT NULL, + "repo_name" TEXT NOT NULL, + "repository_url" TEXT NOT NULL, + "template_repository_url" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "studio_repositories_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "studio_repositories_user_id_module_id_key" ON "studio_repositories"("user_id", "module_id"); +CREATE INDEX "studio_repositories_week_id_idx" ON "studio_repositories"("week_id"); +CREATE INDEX "studio_repositories_repo_owner_repo_name_idx" ON "studio_repositories"("repo_owner", "repo_name"); + +ALTER TABLE "studio_repositories" + ADD CONSTRAINT "studio_repositories_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "studio_repositories" + ADD CONSTRAINT "studio_repositories_module_id_fkey" + FOREIGN KEY ("module_id") REFERENCES "course_week_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "studio_repositories" IS '개발 스튜디오 사용자별 모듈 Git 저장소 매핑.'; diff --git a/prisma/migrations/20260608104500_add_aw_planningsim_to_all_weeks/migration.sql b/prisma/migrations/20260608104500_add_aw_planningsim_to_all_weeks/migration.sql new file mode 100644 index 0000000..8422ac2 --- /dev/null +++ b/prisma/migrations/20260608104500_add_aw_planningsim_to_all_weeks/migration.sql @@ -0,0 +1,31 @@ +WITH target_weeks AS ( + SELECT + w."id" AS week_id, + COALESCE(MAX(m."sort_order"), 0) + 1 AS next_sort_order + FROM "course_weeks" w + LEFT JOIN "course_week_modules" m ON m."week_id" = w."id" + GROUP BY w."id" +) +INSERT INTO "course_week_modules" ( + "week_id", + "sort_order", + "category", + "name", + "folder_name", + "template_repository_url" +) +SELECT + target_weeks.week_id, + target_weeks.next_sort_order, + 'Simulator', + 'AW-PlanningSim', + '[furive-simulator]AW-PlanningSim_v1.0.0_ws', + 'http://localhost:3000/templates/aw-planningsim' +FROM target_weeks +ON CONFLICT ("week_id", "folder_name") DO UPDATE +SET + "sort_order" = EXCLUDED."sort_order", + "category" = EXCLUDED."category", + "name" = EXCLUDED."name", + "template_repository_url" = EXCLUDED."template_repository_url", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260608111500_remove_week3_hidden_modules/migration.sql b/prisma/migrations/20260608111500_remove_week3_hidden_modules/migration.sql new file mode 100644 index 0000000..5cade22 --- /dev/null +++ b/prisma/migrations/20260608111500_remove_week3_hidden_modules/migration.sql @@ -0,0 +1,9 @@ +DELETE FROM "course_week_modules" m +USING "course_weeks" w +WHERE m."week_id" = w."id" + AND w."week" = 3 + AND m."name" IN ( + 'AW-Perception', + 'AW-Localization', + 'AW-SampleSensor' + ); diff --git a/prisma/migrations/20260608123000_add_platform_studio_settings/migration.sql b/prisma/migrations/20260608123000_add_platform_studio_settings/migration.sql new file mode 100644 index 0000000..be0c78b --- /dev/null +++ b/prisma/migrations/20260608123000_add_platform_studio_settings/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "platforms" +ADD COLUMN "default_studio_settings" JSONB NOT NULL DEFAULT '{"workspaceRoot":"~/autonomy-studio/workspaces","giteaBaseUrl":"http://localhost:3000","giteaOwner":"students"}'; + +ALTER TABLE "instructor_platforms" +ADD COLUMN "studio_settings_override" JSONB; + +UPDATE "instructor_platforms" AS ip +SET "studio_settings_override" = p."default_studio_settings" +FROM "platforms" AS p +WHERE ip."platform_id" = p."id" + AND ip."studio_settings_override" IS NULL; diff --git a/prisma/migrations/20260608130000_set_course_week_module_priority/migration.sql b/prisma/migrations/20260608130000_set_course_week_module_priority/migration.sql new file mode 100644 index 0000000..664fcf3 --- /dev/null +++ b/prisma/migrations/20260608130000_set_course_week_module_priority/migration.sql @@ -0,0 +1,37 @@ +WITH priority(name, folder_name, sort_order) AS ( + VALUES + ('AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws', 1), + ('AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws', 2), + ('AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws', 3), + ('AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws', 4), + ('AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws', 5), + ('AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws', 6), + ('AW-Control', '[furive-control]AW-Control_v1.0.0_ws', 7), + ('AW-System', '[furive-system]AW-System_v1.0.0_ws', 8), + ('AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws', 9), + ('AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws', 10) +) +UPDATE "course_week_modules" module +SET "sort_order" = priority.sort_order + 1000, + "updated_at" = CURRENT_TIMESTAMP +FROM priority +WHERE module."folder_name" = priority.folder_name; + +WITH priority(name, folder_name, sort_order) AS ( + VALUES + ('AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws', 1), + ('AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws', 2), + ('AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws', 3), + ('AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws', 4), + ('AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws', 5), + ('AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws', 6), + ('AW-Control', '[furive-control]AW-Control_v1.0.0_ws', 7), + ('AW-System', '[furive-system]AW-System_v1.0.0_ws', 8), + ('AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws', 9), + ('AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws', 10) +) +UPDATE "course_week_modules" module +SET "sort_order" = priority.sort_order, + "updated_at" = CURRENT_TIMESTAMP +FROM priority +WHERE module."folder_name" = priority.folder_name; diff --git a/prisma/migrations/20260609143000_add_course_environment_profiles/migration.sql b/prisma/migrations/20260609143000_add_course_environment_profiles/migration.sql new file mode 100644 index 0000000..a4d047e --- /dev/null +++ b/prisma/migrations/20260609143000_add_course_environment_profiles/migration.sql @@ -0,0 +1,122 @@ +CREATE TABLE "course_environment_profiles" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "platform_key" TEXT NOT NULL DEFAULT 'futuredrive-local-pc', + "profile_type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "config" JSONB NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "course_environment_profiles_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "course_environment_profiles_course_id_week_platform_key_key" + ON "course_environment_profiles"("course_id", "week", "platform_key"); +CREATE INDEX "course_environment_profiles_course_id_week_idx" + ON "course_environment_profiles"("course_id", "week"); +CREATE INDEX "course_environment_profiles_profile_type_idx" + ON "course_environment_profiles"("profile_type"); + +ALTER TABLE "course_environment_profiles" + ADD CONSTRAINT "course_environment_profiles_course_id_fkey" + FOREIGN KEY ("course_id") + REFERENCES "courses"("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + +COMMENT ON TABLE "course_environment_profiles" IS '강좌/주차/플랫폼별 개발환경 실행 프로파일.'; +COMMENT ON COLUMN "course_environment_profiles"."config" IS 'Docker runtime, ROS launch, bridge, viewer 실행 설정 JSON.'; + +INSERT INTO "course_environment_profiles" ( + "course_id", + "week", + "platform_key", + "profile_type", + "name", + "config", + "updated_at" +) +SELECT + course."id", + 3, + 'futuredrive-local-pc', + 'SLAM', + 'TurtleBot3 SLAM 로컬 시뮬레이션', + '{ + "type": "SLAM", + "viewer": { + "type": "slam", + "url": "/slam-viewer/index.html", + "healthUrl": "http://localhost:8765/health" + }, + "runtime": { + "containers": [ + { + "name": "nav2-slam", + "image": "nav2-slam:humble", + "network": "host", + "privileged": true, + "env": { + "DISPLAY": ":1", + "QT_X11_NO_MITSHM": "1", + "LIBGL_ALWAYS_SOFTWARE": "1", + "TURTLEBOT3_MODEL": "burger" + }, + "volumes": [ + "/tmp/.X11-unix:/tmp/.X11-unix", + "/dev/shm:/dev/shm", + "~/.gazebo:/root/.gazebo", + "~/.cache:/root/.cache" + ], + "command": "tail -f /dev/null" + } + ], + "steps": [ + { + "container": "nav2-slam", + "mode": "execDetached", + "delayMs": 0, + "command": "ros2 launch gazebo_ros gzserver.launch.py world:=/opt/ros/humble/share/turtlebot3_gazebo/worlds/turtlebot3_world.world & ros2 launch turtlebot3_gazebo robot_state_publisher.launch.py use_sim_time:=true & sleep 6; ros2 launch turtlebot3_gazebo spawn_turtlebot3.launch.py x_pose:=-2.0 y_pose:=-0.5; wait", + "logFile": "/tmp/turtlebot3_gazebo.log" + }, + { + "container": "nav2-slam", + "mode": "execDetached", + "delayMs": 6000, + "command": "ros2 launch slam_toolbox online_async_launch.py use_sim_time:=true", + "logFile": "/tmp/slam_toolbox.log" + }, + { + "container": "nav2-slam", + "mode": "execDetached", + "delayMs": 3000, + "command": "ros2 launch nav2_bringup navigation_launch.py use_sim_time:=true autostart:=true", + "logFile": "/tmp/nav2.log" + } + ] + }, + "bridge": { + "name": "slam-ros-bridge", + "image": "slam-ros-bridge", + "buildContext": "resources/slam-ros-bridge", + "port": 8765, + "env": { + "ODOM_TOPIC": "/odom", + "SCAN_TOPIC": "/scan", + "MAP_TOPIC": "/map", + "CMD_VEL_TOPIC": "/cmd_vel", + "NAVIGATE_TO_POSE_ACTION": "/navigate_to_pose" + } + } + }'::jsonb, + CURRENT_TIMESTAMP +FROM "courses" AS course +ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE +SET + "profile_type" = EXCLUDED."profile_type", + "name" = EXCLUDED."name", + "config" = EXCLUDED."config", + "enabled" = true, + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260609162000_add_week6_autoware_course/migration.sql b/prisma/migrations/20260609162000_add_week6_autoware_course/migration.sql new file mode 100644 index 0000000..a247ca6 --- /dev/null +++ b/prisma/migrations/20260609162000_add_week6_autoware_course/migration.sql @@ -0,0 +1,175 @@ +INSERT INTO "course_weeks" ( + "course_id", + "week", + "title", + "tags", + "progress", + "state", + "duration", + "github", + "overview", + "deliverable", + "updated_at" +) +SELECT + course."id", + 6, + 'Autoware 기반 자율주행', + ARRAY['Autoware','진행예정']::TEXT[], + 0, + 'locked', + '10-12시간', + '미제출', + 'Autoware 모듈을 실행하고 시각화 화면에서 초기 위치와 목표 포즈를 설정해 차량이 경로를 따라 주행하는 흐름을 검증합니다.', + '초기 위치/목표 포즈 설정값, 주행 로그, 경로 계획 및 제어 결과를 정리해 제출', + CURRENT_TIMESTAMP +FROM "courses" AS course +ON CONFLICT ("course_id", "week") DO UPDATE +SET + "title" = EXCLUDED."title", + "tags" = EXCLUDED."tags", + "progress" = EXCLUDED."progress", + "state" = EXCLUDED."state", + "duration" = EXCLUDED."duration", + "github" = EXCLUDED."github", + "overview" = EXCLUDED."overview", + "deliverable" = EXCLUDED."deliverable", + "updated_at" = CURRENT_TIMESTAMP; + +WITH target_weeks AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + WHERE w."week" = 6 +) +INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail") +SELECT target_weeks.week_id, topic.sort_order, topic.title, topic.detail +FROM target_weeks +CROSS JOIN ( + VALUES + (1, 'Autoware 실행 구조', '차량, 센서, 지도, 위치추정, 인지, 계획, 제어 모듈의 실행 순서와 ROS2 토픽 흐름을 확인합니다.'), + (2, '초기 위치 설정', '시각화 화면에서 초기 위치를 지정하고 Localization 상태와 차량 pose 갱신을 검증합니다.'), + (3, '목표 포즈와 경로 계획', '목표 포즈를 지정해 mission planning과 behavior planning 결과 경로를 확인합니다.'), + (4, '제어 명령과 주행 검증', '계획된 경로를 따라 차량이 움직이는지 제어 명령, 상태 토픽, 로그를 기준으로 점검합니다.') +) AS topic(sort_order, title, detail) +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET + "title" = EXCLUDED."title", + "detail" = EXCLUDED."detail"; + +WITH target_weeks AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + WHERE w."week" = 6 +) +INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description") +SELECT target_weeks.week_id, practice.sort_order, practice.title, practice.description +FROM target_weeks +CROSS JOIN ( + VALUES + (1, 'Autoware 모듈 실행', '주차에 등록된 Autoware 모듈을 순서대로 실행하고 컨테이너 상태를 확인합니다.'), + (2, '초기 위치 지정', '시각화에서 초기 위치를 지정하고 차량 위치가 지도 좌표계에 정상 반영되는지 확인합니다.'), + (3, '목표 포즈 지정', '목표 지점을 설정하고 생성된 route와 주행 상태를 비교합니다.'), + (4, '주행 결과 제출', '수정한 소스, 실행 로그, 주행 검증 결과를 제출합니다.') +) AS practice(sort_order, title, description) +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET + "title" = EXCLUDED."title", + "description" = EXCLUDED."description"; + +WITH week_pairs AS ( + SELECT + source_week."id" AS source_week_id, + target_week."id" AS target_week_id + FROM "course_weeks" source_week + JOIN "course_weeks" target_week + ON target_week."course_id" = source_week."course_id" + AND target_week."week" = 6 + WHERE source_week."week" = 3 +) +INSERT INTO "course_week_modules" ( + "week_id", + "sort_order", + "category", + "name", + "folder_name", + "template_repository_url", + "docker_image", + "updated_at" +) +SELECT + week_pairs.target_week_id, + source_module."sort_order", + source_module."category", + source_module."name", + source_module."folder_name", + source_module."template_repository_url", + source_module."docker_image", + CURRENT_TIMESTAMP +FROM week_pairs +JOIN "course_week_modules" source_module ON source_module."week_id" = week_pairs.source_week_id +ON CONFLICT ("week_id", "folder_name") DO UPDATE +SET + "sort_order" = EXCLUDED."sort_order", + "category" = EXCLUDED."category", + "name" = EXCLUDED."name", + "template_repository_url" = EXCLUDED."template_repository_url", + "docker_image" = EXCLUDED."docker_image", + "updated_at" = CURRENT_TIMESTAMP; + +INSERT INTO "course_environment_profiles" ( + "course_id", + "week", + "platform_key", + "profile_type", + "name", + "config", + "updated_at" +) +SELECT + course."id", + 6, + 'futuredrive-local-pc', + 'AUTOWARE', + 'Autoware 로컬 자율주행', + '{ + "type": "autoware", + "viewer": { + "type": "autoware", + "url": "/autoware-viewer/index.html", + "healthUrl": "http://localhost:8765/health" + }, + "runtime": { + "containers": [], + "steps": [] + }, + "bridge": { + "name": "autoware-ros-bridge", + "image": "autoware-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "autoware", + "MAP_POINT_STRIDE": "8", + "MAP_POINT_LIMIT": "80000", + "AUTOWARE_ROUTE_INPUT": "rviz_topic", + "INITIAL_POSE_TOPIC": "/initialpose", + "GOAL_POSE_TOPIC": "/planning/mission_planning/goal", + "VEHICLE_POSE_TOPIC": "/localization/kinematic_state", + "TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory", + "ROUTE_TOPIC": "/planning/mission_planning/route" + } + } + }'::jsonb, + CURRENT_TIMESTAMP +FROM "courses" AS course +ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE +SET + "profile_type" = EXCLUDED."profile_type", + "name" = EXCLUDED."name", + "config" = EXCLUDED."config", + "enabled" = true, + "updated_at" = CURRENT_TIMESTAMP; + +UPDATE "course_student_progress" +SET + "total_weeks" = GREATEST("total_weeks", 6), + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260609170000_group_curriculum_settings/migration.sql b/prisma/migrations/20260609170000_group_curriculum_settings/migration.sql new file mode 100644 index 0000000..64f1ac2 --- /dev/null +++ b/prisma/migrations/20260609170000_group_curriculum_settings/migration.sql @@ -0,0 +1,25 @@ +ALTER TABLE "instructor_curriculum_settings" + ADD COLUMN "group_id" UUID; + +ALTER TABLE "instructor_curriculum_settings" + ADD CONSTRAINT "instructor_curriculum_settings_group_id_fkey" + FOREIGN KEY ("group_id") + REFERENCES "student_groups"("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + +DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_name_key"; + +CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_default_key" + ON "instructor_curriculum_settings"("organization_name") + WHERE "group_id" IS NULL; + +CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_group_key" + ON "instructor_curriculum_settings"("organization_name", "group_id") + WHERE "group_id" IS NOT NULL; + +CREATE INDEX "instructor_curriculum_settings_organization_name_idx" + ON "instructor_curriculum_settings"("organization_name"); + +CREATE INDEX "instructor_curriculum_settings_group_id_idx" + ON "instructor_curriculum_settings"("group_id"); diff --git a/prisma/migrations/20260609174000_course_scoped_enrollments/migration.sql b/prisma/migrations/20260609174000_course_scoped_enrollments/migration.sql new file mode 100644 index 0000000..4eccead --- /dev/null +++ b/prisma/migrations/20260609174000_course_scoped_enrollments/migration.sql @@ -0,0 +1,38 @@ +ALTER TABLE "student_enrollments" + ADD COLUMN "course_id" UUID; + +UPDATE "student_enrollments" enrollment +SET "course_id" = course."id" +FROM "courses" course +WHERE course."slug" = 'futuredrive-autonomy-basic' + AND course."is_active" = true + AND enrollment."course_id" IS NULL; + +ALTER TABLE "student_enrollments" + ALTER COLUMN "course_id" SET NOT NULL; + +ALTER TABLE "student_enrollments" + ADD CONSTRAINT "student_enrollments_course_id_fkey" + FOREIGN KEY ("course_id") + REFERENCES "courses"("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + +DROP INDEX IF EXISTS "student_enrollments_user_id_organization_name_key"; + +CREATE UNIQUE INDEX "student_enrollments_user_id_course_id_key" + ON "student_enrollments"("user_id", "course_id"); + +CREATE INDEX "student_enrollments_course_id_status_idx" + ON "student_enrollments"("course_id", "status"); + +DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_default_key"; +DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_group_key"; + +CREATE UNIQUE INDEX "instructor_curriculum_settings_course_default_key" + ON "instructor_curriculum_settings"("organization_name", "course_id") + WHERE "group_id" IS NULL; + +CREATE UNIQUE INDEX "instructor_curriculum_settings_course_group_key" + ON "instructor_curriculum_settings"("organization_name", "course_id", "group_id") + WHERE "group_id" IS NOT NULL; diff --git a/prisma/migrations/20260609180000_fix_week3_learning_steps/migration.sql b/prisma/migrations/20260609180000_fix_week3_learning_steps/migration.sql new file mode 100644 index 0000000..705558e --- /dev/null +++ b/prisma/migrations/20260609180000_fix_week3_learning_steps/migration.sql @@ -0,0 +1,33 @@ +WITH week3 AS ( + SELECT "id" AS week_id + FROM "course_weeks" + WHERE "week" = 3 +), +desired(sort_order, title, detail) AS ( + VALUES + (1, 'SLAM 개요', '동시적 위치추정 및 지도작성의 기본 개념'), + (2, 'Cartographer 설정', 'ROS2 Cartographer 패키지 설정 방법'), + (3, '실습: 맵 생성', 'LiDAR 데이터를 활용한 실시간 맵핑'), + (4, '위치 추정', '생성된 맵에서 로봇 위치 인식') +) +INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail") +SELECT week3.week_id, desired.sort_order, desired.title, desired.detail +FROM week3 +CROSS JOIN desired +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET + "title" = EXCLUDED."title", + "detail" = EXCLUDED."detail"; + +DELETE FROM "course_week_topics" topic +USING "course_weeks" week +WHERE topic."week_id" = week."id" + AND week."week" = 3 + AND topic."sort_order" > 4; + +UPDATE "instructor_curriculum_week_overrides" +SET + "topics" = '["SLAM 개요","Cartographer 설정","실습: 맵 생성","위치 추정"]'::jsonb, + "topic_details" = '["동시적 위치추정 및 지도작성의 기본 개념","ROS2 Cartographer 패키지 설정 방법","LiDAR 데이터를 활용한 실시간 맵핑","생성된 맵에서 로봇 위치 인식"]'::jsonb, + "updated_at" = CURRENT_TIMESTAMP +WHERE "week" = 3; diff --git a/prisma/migrations/20260609181500_clear_course_descriptions/migration.sql b/prisma/migrations/20260609181500_clear_course_descriptions/migration.sql new file mode 100644 index 0000000..3b20c57 --- /dev/null +++ b/prisma/migrations/20260609181500_clear_course_descriptions/migration.sql @@ -0,0 +1,4 @@ +UPDATE "courses" +SET "description" = NULL, + "updated_at" = CURRENT_TIMESTAMP +WHERE "description" IS NOT NULL; diff --git a/prisma/migrations/20260609183000_split_courses_by_organization/migration.sql b/prisma/migrations/20260609183000_split_courses_by_organization/migration.sql new file mode 100644 index 0000000..d9e80d8 --- /dev/null +++ b/prisma/migrations/20260609183000_split_courses_by_organization/migration.sql @@ -0,0 +1,216 @@ +UPDATE "courses" +SET "organization_name" = '퓨처드라이브', + "updated_at" = CURRENT_TIMESTAMP +WHERE "slug" = 'futuredrive-autonomy-basic' + AND "organization_name" = '공통'; + +CREATE TEMP TABLE "_course_split_source" AS +SELECT "id" AS source_course_id +FROM "courses" +WHERE "slug" = 'futuredrive-autonomy-basic' +LIMIT 1; + +INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "is_active", "updated_at") +SELECT + 'yeungnam-autonomy-basic', + source."title", + source."subtitle", + source."description", + '영남대학교', + teacher."id", + true, + CURRENT_TIMESTAMP +FROM "_course_split_source" split +JOIN "courses" source ON source."id" = split.source_course_id +JOIN "users" teacher ON teacher."email" = 'teacher@yu.ac.kr' +ON CONFLICT ("slug") DO UPDATE +SET "title" = EXCLUDED."title", + "subtitle" = EXCLUDED."subtitle", + "description" = EXCLUDED."description", + "organization_name" = EXCLUDED."organization_name", + "teacher_id" = EXCLUDED."teacher_id", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +CREATE TEMP TABLE "_course_split_target" AS +SELECT "id" AS target_course_id +FROM "courses" +WHERE "slug" = 'yeungnam-autonomy-basic' +LIMIT 1; + +INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at") +SELECT target.target_course_id, platform."platform_id", platform."status", platform."action", platform."state", platform."sort_order", CURRENT_TIMESTAMP +FROM "course_platforms" platform +JOIN "_course_split_source" source ON source.source_course_id = platform."course_id" +CROSS JOIN "_course_split_target" target +ON CONFLICT ("course_id", "platform_id") DO UPDATE +SET "status" = EXCLUDED."status", + "action" = EXCLUDED."action", + "state" = EXCLUDED."state", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = CURRENT_TIMESTAMP; + +INSERT INTO "course_environment_profiles" ("course_id", "week", "platform_key", "profile_type", "name", "config", "enabled", "updated_at") +SELECT target.target_course_id, profile."week", profile."platform_key", profile."profile_type", profile."name", profile."config", profile."enabled", CURRENT_TIMESTAMP +FROM "course_environment_profiles" profile +JOIN "_course_split_source" source ON source.source_course_id = profile."course_id" +CROSS JOIN "_course_split_target" target +ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE +SET "profile_type" = EXCLUDED."profile_type", + "name" = EXCLUDED."name", + "config" = EXCLUDED."config", + "enabled" = EXCLUDED."enabled", + "updated_at" = CURRENT_TIMESTAMP; + +INSERT INTO "course_weeks" ("course_id", "week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable", "updated_at") +SELECT target.target_course_id, week."week", week."title", week."tags", week."progress", week."state", week."duration", week."github", week."overview", week."deliverable", CURRENT_TIMESTAMP +FROM "course_weeks" week +JOIN "_course_split_source" source ON source.source_course_id = week."course_id" +CROSS JOIN "_course_split_target" target +ON CONFLICT ("course_id", "week") DO UPDATE +SET "title" = EXCLUDED."title", + "tags" = EXCLUDED."tags", + "progress" = EXCLUDED."progress", + "state" = EXCLUDED."state", + "duration" = EXCLUDED."duration", + "github" = EXCLUDED."github", + "overview" = EXCLUDED."overview", + "deliverable" = EXCLUDED."deliverable", + "updated_at" = CURRENT_TIMESTAMP; + +CREATE TEMP TABLE "_course_split_week_map" AS +SELECT source_week."id" AS source_week_id, target_week."id" AS target_week_id, source_week."week" +FROM "course_weeks" source_week +JOIN "_course_split_source" source ON source.source_course_id = source_week."course_id" +JOIN "_course_split_target" target ON true +JOIN "course_weeks" target_week ON target_week."course_id" = target.target_course_id AND target_week."week" = source_week."week"; + +INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail") +SELECT week_map.target_week_id, topic."sort_order", topic."title", topic."detail" +FROM "course_week_topics" topic +JOIN "_course_split_week_map" week_map ON week_map.source_week_id = topic."week_id" +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET "title" = EXCLUDED."title", + "detail" = EXCLUDED."detail"; + +INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description") +SELECT week_map.target_week_id, practice."sort_order", practice."title", practice."description" +FROM "course_week_practices" practice +JOIN "_course_split_week_map" week_map ON week_map.source_week_id = practice."week_id" +ON CONFLICT ("week_id", "sort_order") DO UPDATE +SET "title" = EXCLUDED."title", + "description" = EXCLUDED."description"; + +INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name", "template_repository_url", "docker_image", "updated_at") +SELECT week_map.target_week_id, module."sort_order", module."category", module."name", module."folder_name", module."template_repository_url", module."docker_image", CURRENT_TIMESTAMP +FROM "course_week_modules" module +JOIN "_course_split_week_map" week_map ON week_map.source_week_id = module."week_id" +ON CONFLICT ("week_id", "folder_name") DO UPDATE +SET "sort_order" = EXCLUDED."sort_order", + "category" = EXCLUDED."category", + "name" = EXCLUDED."name", + "template_repository_url" = EXCLUDED."template_repository_url", + "docker_image" = EXCLUDED."docker_image", + "updated_at" = CURRENT_TIMESTAMP; + +CREATE TEMP TABLE "_course_split_module_map" AS +SELECT source_module."id" AS source_module_id, target_module."id" AS target_module_id, week_map.source_week_id, week_map.target_week_id +FROM "course_week_modules" source_module +JOIN "_course_split_week_map" week_map ON week_map.source_week_id = source_module."week_id" +JOIN "course_week_modules" target_module ON target_module."week_id" = week_map.target_week_id AND target_module."folder_name" = source_module."folder_name"; + +INSERT INTO "student_enrollments" ("user_id", "course_id", "organization_id", "organization_name", "status", "removed_at", "updated_at") +SELECT enrollment."user_id", target.target_course_id, enrollment."organization_id", enrollment."organization_name", enrollment."status", enrollment."removed_at", CURRENT_TIMESTAMP +FROM "student_enrollments" enrollment +JOIN "_course_split_source" source ON source.source_course_id = enrollment."course_id" +CROSS JOIN "_course_split_target" target +WHERE enrollment."organization_name" = '영남대학교' +ON CONFLICT ("user_id", "course_id") DO UPDATE +SET "organization_id" = EXCLUDED."organization_id", + "organization_name" = EXCLUDED."organization_name", + "status" = EXCLUDED."status", + "removed_at" = EXCLUDED."removed_at", + "updated_at" = CURRENT_TIMESTAMP; + +DELETE FROM "student_enrollments" enrollment +USING "_course_split_source" source +WHERE enrollment."course_id" = source.source_course_id + AND enrollment."organization_name" = '영남대학교'; + +INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at") +SELECT target.target_course_id, progress."user_id", progress."completed_weeks", progress."total_weeks", progress."learning_hours", progress."commit_count", progress."average_score", CURRENT_TIMESTAMP +FROM "course_student_progress" progress +JOIN "_course_split_source" source ON source.source_course_id = progress."course_id" +JOIN "users" student ON student."id" = progress."user_id" +CROSS JOIN "_course_split_target" target +WHERE student."primary_organization_name" = '영남대학교' +ON CONFLICT ("course_id", "user_id") DO UPDATE +SET "completed_weeks" = EXCLUDED."completed_weeks", + "total_weeks" = EXCLUDED."total_weeks", + "learning_hours" = EXCLUDED."learning_hours", + "commit_count" = EXCLUDED."commit_count", + "average_score" = EXCLUDED."average_score", + "updated_at" = CURRENT_TIMESTAMP; + +DELETE FROM "course_student_progress" progress +USING "_course_split_source" source, "users" student +WHERE progress."course_id" = source.source_course_id + AND student."id" = progress."user_id" + AND student."primary_organization_name" = '영남대학교'; + +UPDATE "instructor_curriculum_settings" setting +SET "course_id" = target.target_course_id, + "updated_at" = CURRENT_TIMESTAMP +FROM "_course_split_source" source, "_course_split_target" target +WHERE setting."course_id" = source.source_course_id + AND setting."organization_name" = '영남대학교'; + +INSERT INTO "instructor_curriculum_week_overrides" ("organization_id", "organization_name", "course_id", "week", "title", "duration", "overview", "deliverable", "topics", "topic_details", "updated_at") +SELECT override."organization_id", override."organization_name", target.target_course_id, override."week", override."title", override."duration", override."overview", override."deliverable", override."topics", override."topic_details", CURRENT_TIMESTAMP +FROM "instructor_curriculum_week_overrides" override +JOIN "_course_split_source" source ON source.source_course_id = override."course_id" +CROSS JOIN "_course_split_target" target +WHERE override."organization_name" = '영남대학교' +ON CONFLICT ("organization_name", "course_id", "week") DO UPDATE +SET "title" = EXCLUDED."title", + "duration" = EXCLUDED."duration", + "overview" = EXCLUDED."overview", + "deliverable" = EXCLUDED."deliverable", + "topics" = EXCLUDED."topics", + "topic_details" = EXCLUDED."topic_details", + "updated_at" = CURRENT_TIMESTAMP; + +DELETE FROM "instructor_curriculum_week_overrides" override +USING "_course_split_source" source +WHERE override."course_id" = source.source_course_id + AND override."organization_name" = '영남대학교'; + +UPDATE "studio_repositories" repository +SET "week_id" = module_map.target_week_id, + "module_id" = module_map.target_module_id, + "updated_at" = CURRENT_TIMESTAMP +FROM "_course_split_module_map" module_map, "users" student +WHERE repository."module_id" = module_map.source_module_id + AND repository."user_id" = student."id" + AND student."primary_organization_name" = '영남대학교'; + +UPDATE "chat_threads" thread +SET "course_id" = target.target_course_id, + "updated_at" = CURRENT_TIMESTAMP +FROM "_course_split_source" source +JOIN "_course_split_target" target ON true +JOIN "users" student ON true +WHERE thread."course_id" = source.source_course_id + AND student."id" = thread."student_id" + AND student."primary_organization_name" = '영남대학교'; + +UPDATE "chat_threads" thread +SET "week_id" = week_map.target_week_id, + "updated_at" = CURRENT_TIMESTAMP +FROM "_course_split_week_map" week_map +JOIN "_course_split_target" target ON true +JOIN "users" student ON true +WHERE thread."course_id" = target.target_course_id + AND thread."week_id" = week_map.source_week_id + AND student."id" = thread."student_id" + AND student."primary_organization_name" = '영남대학교'; diff --git a/prisma/migrations/20260609184500_add_curriculum_practice_overrides/migration.sql b/prisma/migrations/20260609184500_add_curriculum_practice_overrides/migration.sql new file mode 100644 index 0000000..7c2bbfb --- /dev/null +++ b/prisma/migrations/20260609184500_add_curriculum_practice_overrides/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "instructor_curriculum_week_overrides" +ADD COLUMN "practice_contents" JSONB; diff --git a/prisma/migrations/20260609190000_add_source_submission_requirement/migration.sql b/prisma/migrations/20260609190000_add_source_submission_requirement/migration.sql new file mode 100644 index 0000000..08d5b49 --- /dev/null +++ b/prisma/migrations/20260609190000_add_source_submission_requirement/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "course_weeks" + ADD COLUMN IF NOT EXISTS "requires_source_submission" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "instructor_curriculum_week_overrides" + ADD COLUMN IF NOT EXISTS "requires_source_submission" BOOLEAN DEFAULT true; + +COMMENT ON COLUMN "course_weeks"."requires_source_submission" IS '주차 제출 시 소스 커밋/푸시가 필요한지 여부.'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."requires_source_submission" IS '강사별 주차 소스 제출 필요 여부 오버라이드.'; diff --git a/prisma/migrations/20260610093000_add_course_student_week_progress/migration.sql b/prisma/migrations/20260610093000_add_course_student_week_progress/migration.sql new file mode 100644 index 0000000..a14952a --- /dev/null +++ b/prisma/migrations/20260610093000_add_course_student_week_progress/migration.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS "course_student_week_progress" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "submitted_at" TIMESTAMPTZ(3), + "commit_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "course_student_week_progress_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "course_student_week_progress_course_user_week_key" + ON "course_student_week_progress"("course_id", "user_id", "week"); + +CREATE INDEX IF NOT EXISTS "course_student_week_progress_user_idx" + ON "course_student_week_progress"("user_id"); + +ALTER TABLE "course_student_week_progress" + ADD CONSTRAINT "course_student_week_progress_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "course_student_week_progress" + ADD CONSTRAINT "course_student_week_progress_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "course_student_week_progress" IS '학생별 주차 학습 및 제출 요약.'; +COMMENT ON COLUMN "course_student_week_progress"."submitted_at" IS '해당 주차 소스 제출 완료 시각.'; diff --git a/prisma/migrations/20260610100000_sync_week6_curriculum_overrides/migration.sql b/prisma/migrations/20260610100000_sync_week6_curriculum_overrides/migration.sql new file mode 100644 index 0000000..5258ba3 --- /dev/null +++ b/prisma/migrations/20260610100000_sync_week6_curriculum_overrides/migration.sql @@ -0,0 +1,18 @@ +WITH week6_topics AS ( + SELECT + w."course_id", + jsonb_agg(t."title" ORDER BY t."sort_order") AS topics, + jsonb_agg(t."detail" ORDER BY t."sort_order") AS topic_details + FROM "course_weeks" w + JOIN "course_week_topics" t ON t."week_id" = w."id" + WHERE w."week" = 6 + GROUP BY w."course_id" +) +UPDATE "instructor_curriculum_week_overrides" override +SET + "topics" = week6_topics.topics, + "topic_details" = week6_topics.topic_details, + "updated_at" = CURRENT_TIMESTAMP +FROM week6_topics +WHERE override."course_id" = week6_topics."course_id" + AND override."week" = 6; diff --git a/prisma/migrations/20260610101500_sync_week6_content_overrides/migration.sql b/prisma/migrations/20260610101500_sync_week6_content_overrides/migration.sql new file mode 100644 index 0000000..56c085d --- /dev/null +++ b/prisma/migrations/20260610101500_sync_week6_content_overrides/migration.sql @@ -0,0 +1,26 @@ +WITH week6_content AS ( + SELECT + w."course_id", + w."overview", + w."deliverable", + jsonb_agg( + jsonb_build_object( + 'title', p."title", + 'description', p."description" + ) + ORDER BY p."sort_order" + ) FILTER (WHERE p."id" IS NOT NULL) AS practice_contents + FROM "course_weeks" w + LEFT JOIN "course_week_practices" p ON p."week_id" = w."id" + WHERE w."week" = 6 + GROUP BY w."course_id", w."overview", w."deliverable" +) +UPDATE "instructor_curriculum_week_overrides" override +SET + "overview" = week6_content."overview", + "deliverable" = week6_content."deliverable", + "practice_contents" = COALESCE(week6_content.practice_contents, '[]'::jsonb), + "updated_at" = CURRENT_TIMESTAMP +FROM week6_content +WHERE override."course_id" = week6_content."course_id" + AND override."week" = 6; diff --git a/prisma/migrations/20260610103000_add_course_week_references/migration.sql b/prisma/migrations/20260610103000_add_course_week_references/migration.sql new file mode 100644 index 0000000..52258a3 --- /dev/null +++ b/prisma/migrations/20260610103000_add_course_week_references/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE "course_week_references" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "week_id" UUID NOT NULL, + "title" TEXT NOT NULL, + "original_file_name" TEXT NOT NULL, + "stored_file_name" TEXT NOT NULL, + "content_type" TEXT, + "file_size_bytes" INTEGER NOT NULL DEFAULT 0, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "course_week_references_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "course_week_references_week_id_idx" ON "course_week_references"("week_id"); +CREATE UNIQUE INDEX "course_week_references_week_id_stored_file_name_key" ON "course_week_references"("week_id", "stored_file_name"); + +ALTER TABLE "course_week_references" +ADD CONSTRAINT "course_week_references_week_id_fkey" +FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260610104500_add_curriculum_disabled_modules/migration.sql b/prisma/migrations/20260610104500_add_curriculum_disabled_modules/migration.sql new file mode 100644 index 0000000..b3bef81 --- /dev/null +++ b/prisma/migrations/20260610104500_add_curriculum_disabled_modules/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "instructor_curriculum_week_overrides" +ADD COLUMN "disabled_module_ids" JSONB; diff --git a/prisma/migrations/20260610111500_add_submission_scores/migration.sql b/prisma/migrations/20260610111500_add_submission_scores/migration.sql new file mode 100644 index 0000000..522c159 --- /dev/null +++ b/prisma/migrations/20260610111500_add_submission_scores/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "submission_scores" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week_id" UUID NOT NULL, + "module_id" UUID NOT NULL, + "student_id" UUID NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "submission_scores_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "submission_scores_student_id_module_id_key" ON "submission_scores"("student_id", "module_id"); +CREATE INDEX "submission_scores_course_id_idx" ON "submission_scores"("course_id"); +CREATE INDEX "submission_scores_week_id_idx" ON "submission_scores"("week_id"); +CREATE INDEX "submission_scores_module_id_idx" ON "submission_scores"("module_id"); + +ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_module_id_fkey" FOREIGN KEY ("module_id") REFERENCES "course_week_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260610112500_add_submission_total_scores/migration.sql b/prisma/migrations/20260610112500_add_submission_total_scores/migration.sql new file mode 100644 index 0000000..8a7c09f --- /dev/null +++ b/prisma/migrations/20260610112500_add_submission_total_scores/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE "submission_total_scores" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week_id" UUID NOT NULL, + "student_id" UUID NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "submission_total_scores_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "submission_total_scores_student_id_week_id_key" ON "submission_total_scores"("student_id", "week_id"); +CREATE INDEX "submission_total_scores_course_id_idx" ON "submission_total_scores"("course_id"); +CREATE INDEX "submission_total_scores_week_id_idx" ON "submission_total_scores"("week_id"); + +ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260610114000_add_visualization_platforms/migration.sql b/prisma/migrations/20260610114000_add_visualization_platforms/migration.sql new file mode 100644 index 0000000..8d03082 --- /dev/null +++ b/prisma/migrations/20260610114000_add_visualization_platforms/migration.sql @@ -0,0 +1,135 @@ +CREATE TABLE "visualization_platforms" ( + "platform_key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "display_name" TEXT NOT NULL, + "description" TEXT, + "default_config" JSONB NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "visualization_platforms_pkey" PRIMARY KEY ("platform_key") +); + +CREATE UNIQUE INDEX "visualization_platforms_type_key" ON "visualization_platforms"("type"); +CREATE INDEX "visualization_platforms_enabled_sort_order_idx" ON "visualization_platforms"("enabled", "sort_order"); + +CREATE TABLE "course_week_visualization_platforms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "platform_key" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "course_week_visualization_platforms_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "course_week_visualization_platforms_course_id_week_platform_key_key" + ON "course_week_visualization_platforms"("course_id", "week", "platform_key"); +CREATE INDEX "course_week_visualization_platforms_course_id_week_idx" + ON "course_week_visualization_platforms"("course_id", "week"); +CREATE INDEX "course_week_visualization_platforms_platform_key_idx" + ON "course_week_visualization_platforms"("platform_key"); + +ALTER TABLE "course_week_visualization_platforms" + ADD CONSTRAINT "course_week_visualization_platforms_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "course_week_visualization_platforms" + ADD CONSTRAINT "course_week_visualization_platforms_platform_key_fkey" + FOREIGN KEY ("platform_key") REFERENCES "visualization_platforms"("platform_key") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "visualization_platforms" ("platform_key", "type", "display_name", "description", "default_config", "sort_order", "updated_at") +VALUES + ( + 'AUTOWARE', + 'AUTOWARE', + 'Autoware', + 'Autoware 기반 자율주행 시각화와 제어 실습 환경', + '{ + "type": "AUTOWARE", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "autoware-ros-bridge", + "image": "autoware-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "autoware", + "MAP_POINT_STRIDE": "8", + "MAP_POINT_LIMIT": "80000", + "AUTOWARE_ROUTE_INPUT": "rviz_topic", + "INITIAL_POSE_TOPIC": "/initialpose", + "GOAL_POSE_TOPIC": "/planning/mission_planning/goal", + "VEHICLE_POSE_TOPIC": "/localization/kinematic_state", + "TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory", + "ROUTE_TOPIC": "/planning/mission_planning/route" + } + }, + "viewer": { "stack": "autoware" } + }'::jsonb, + 1, + CURRENT_TIMESTAMP + ), + ( + 'NAV2', + 'NAV2', + 'Nav2', + 'Nav2 기반 주행 알고리즘 시각화 실습 환경', + '{ + "type": "NAV2", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "nav2-ros-bridge", + "image": "nav2-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "nav2" + } + }, + "viewer": { "stack": "nav2" } + }'::jsonb, + 2, + CURRENT_TIMESTAMP + ), + ( + 'SLAM', + 'SLAM', + 'SLAM', + 'TurtleBot3 SLAM 지도 생성 시각화 실습 환경', + '{ + "type": "SLAM", + "runtime": { + "containers": [ + { "name": "nav2-slam", "image": "nav2-slam:humble", "network": "host" }, + { "name": "slam-ros-bridge", "image": "slam-ros-bridge", "network": "host" } + ] + }, + "bridge": { "url": "http://localhost:8765" }, + "viewer": { "stack": "slam" }, + "ros": { + "turtlebotModel": "burger" + } + }'::jsonb, + 3, + CURRENT_TIMESTAMP + ) +ON CONFLICT ("platform_key") DO UPDATE +SET "type" = EXCLUDED."type", + "display_name" = EXCLUDED."display_name", + "description" = EXCLUDED."description", + "default_config" = EXCLUDED."default_config", + "sort_order" = EXCLUDED."sort_order", + "enabled" = true, + "updated_at" = CURRENT_TIMESTAMP; + +INSERT INTO "course_week_visualization_platforms" ("course_id", "week", "platform_key", "enabled", "updated_at") +SELECT DISTINCT profile."course_id", profile."week", upper(profile."profile_type"), true, CURRENT_TIMESTAMP +FROM "course_environment_profiles" profile +JOIN "visualization_platforms" platform ON platform."platform_key" = upper(profile."profile_type") +WHERE profile."enabled" = true +ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE +SET "enabled" = true, + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260610115500_fix_visualization_bridge_defaults/migration.sql b/prisma/migrations/20260610115500_fix_visualization_bridge_defaults/migration.sql new file mode 100644 index 0000000..1088cea --- /dev/null +++ b/prisma/migrations/20260610115500_fix_visualization_bridge_defaults/migration.sql @@ -0,0 +1,41 @@ +UPDATE "visualization_platforms" +SET "default_config" = '{ + "type": "AUTOWARE", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "autoware-ros-bridge", + "image": "autoware-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "autoware", + "MAP_POINT_STRIDE": "8", + "MAP_POINT_LIMIT": "80000", + "AUTOWARE_ROUTE_INPUT": "rviz_topic", + "INITIAL_POSE_TOPIC": "/initialpose", + "GOAL_POSE_TOPIC": "/planning/mission_planning/goal", + "VEHICLE_POSE_TOPIC": "/localization/kinematic_state", + "TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory", + "ROUTE_TOPIC": "/planning/mission_planning/route" + } + }, + "viewer": { "stack": "autoware" } +}'::jsonb, + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'AUTOWARE'; + +UPDATE "visualization_platforms" +SET "default_config" = '{ + "type": "NAV2", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "nav2-ros-bridge", + "image": "nav2-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "nav2" + } + }, + "viewer": { "stack": "nav2" } +}'::jsonb, + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'NAV2'; diff --git a/prisma/migrations/20260610121000_add_visualization_runtime_targets/migration.sql b/prisma/migrations/20260610121000_add_visualization_runtime_targets/migration.sql new file mode 100644 index 0000000..c0447e4 --- /dev/null +++ b/prisma/migrations/20260610121000_add_visualization_runtime_targets/migration.sql @@ -0,0 +1,85 @@ +UPDATE "visualization_platforms" +SET "default_config" = '{ + "type": "AUTOWARE", + "target": "local", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "autoware-ros-bridge", + "image": "autoware-ros-bridge", + "buildContext": "resources/autoware-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "autoware", + "MAP_POINT_STRIDE": "8", + "MAP_POINT_LIMIT": "80000", + "AUTOWARE_ROUTE_INPUT": "rviz_topic", + "INITIAL_POSE_TOPIC": "/initialpose", + "GOAL_POSE_TOPIC": "/planning/mission_planning/goal", + "VEHICLE_POSE_TOPIC": "/localization/kinematic_state", + "TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory", + "ROUTE_TOPIC": "/planning/mission_planning/route" + } + }, + "viewer": { "stack": "autoware" } +}'::jsonb, + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'AUTOWARE'; + +UPDATE "visualization_platforms" +SET "default_config" = '{ + "type": "NAV2", + "target": "local", + "runtime": { "containers": [], "steps": [] }, + "bridge": { + "name": "nav2-ros-bridge", + "image": "nav2-ros-bridge", + "buildContext": "resources/nav2-ros-bridge", + "port": 8765, + "env": { + "ROBOT_STACK": "nav2" + } + }, + "viewer": { "stack": "nav2" } +}'::jsonb, + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'NAV2'; + +UPDATE "visualization_platforms" +SET "default_config" = jsonb_set("default_config", '{target}', '"local"'::jsonb, true), + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'SLAM'; + +UPDATE "course_environment_profiles" +SET "config" = jsonb_set( + jsonb_set( + jsonb_set("config", '{target}', '"local"'::jsonb, true), + '{bridge,buildContext}', + '"resources/autoware-ros-bridge"'::jsonb, + true + ), + '{bridge,image}', + '"autoware-ros-bridge"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE upper("profile_type") = 'AUTOWARE'; + +UPDATE "course_environment_profiles" +SET "config" = jsonb_set( + jsonb_set( + jsonb_set("config", '{target}', '"local"'::jsonb, true), + '{bridge,buildContext}', + '"resources/nav2-ros-bridge"'::jsonb, + true + ), + '{bridge,image}', + '"nav2-ros-bridge"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE upper("profile_type") = 'NAV2'; + +UPDATE "course_environment_profiles" +SET "config" = jsonb_set("config", '{target}', '"local"'::jsonb, true), + "updated_at" = CURRENT_TIMESTAMP +WHERE upper("profile_type") = 'SLAM'; diff --git a/prisma/migrations/20260611033000_add_course_week_platforms/migration.sql b/prisma/migrations/20260611033000_add_course_week_platforms/migration.sql new file mode 100644 index 0000000..3316c4c --- /dev/null +++ b/prisma/migrations/20260611033000_add_course_week_platforms/migration.sql @@ -0,0 +1,29 @@ +CREATE TABLE "course_week_platforms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "platform_id" UUID NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "course_week_platforms_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "course_week_platforms_course_id_week_platform_id_key" ON "course_week_platforms"("course_id", "week", "platform_id"); +CREATE INDEX "course_week_platforms_course_id_week_idx" ON "course_week_platforms"("course_id", "week"); +CREATE INDEX "course_week_platforms_platform_id_idx" ON "course_week_platforms"("platform_id"); + +ALTER TABLE "course_week_platforms" +ADD CONSTRAINT "course_week_platforms_course_id_fkey" +FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "course_week_platforms" +ADD CONSTRAINT "course_week_platforms_platform_id_fkey" +FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO "course_week_platforms" ("course_id", "week", "platform_id", "enabled") +SELECT cw."course_id", cw."week", cp."platform_id", true +FROM "course_weeks" cw +JOIN "course_platforms" cp ON cp."course_id" = cw."course_id" +ON CONFLICT ("course_id", "week", "platform_id") DO NOTHING; diff --git a/prisma/migrations/20260611034500_add_curriculum_week_tag_label/migration.sql b/prisma/migrations/20260611034500_add_curriculum_week_tag_label/migration.sql new file mode 100644 index 0000000..335ae48 --- /dev/null +++ b/prisma/migrations/20260611034500_add_curriculum_week_tag_label/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "instructor_curriculum_week_overrides" +ADD COLUMN IF NOT EXISTS "tag_label" TEXT; + +DROP TABLE IF EXISTS "course_week_platforms"; diff --git a/prisma/migrations/20260611040000_add_curriculum_tag_options/migration.sql b/prisma/migrations/20260611040000_add_curriculum_tag_options/migration.sql new file mode 100644 index 0000000..391dee6 --- /dev/null +++ b/prisma/migrations/20260611040000_add_curriculum_tag_options/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "curriculum_tag_options" ( + "option_key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "curriculum_tag_options_pkey" PRIMARY KEY ("option_key") +); + +CREATE UNIQUE INDEX "curriculum_tag_options_label_key" ON "curriculum_tag_options"("label"); +CREATE INDEX "curriculum_tag_options_enabled_sort_order_idx" ON "curriculum_tag_options"("enabled", "sort_order"); + +INSERT INTO "curriculum_tag_options" ("option_key", "label", "sort_order", "enabled") +VALUES + ('none', '없음', 0, true), + ('simulation', '시뮬레이션', 10, true), + ('mk-mini', 'MK-mini', 20, true), + ('f1tenth', 'f1tenth', 30, true) +ON CONFLICT ("option_key") DO UPDATE SET + "label" = EXCLUDED."label", + "sort_order" = EXCLUDED."sort_order", + "enabled" = EXCLUDED."enabled", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260611041500_use_platforms_for_curriculum_tags/migration.sql b/prisma/migrations/20260611041500_use_platforms_for_curriculum_tags/migration.sql new file mode 100644 index 0000000..e203300 --- /dev/null +++ b/prisma/migrations/20260611041500_use_platforms_for_curriculum_tags/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "platforms" +ADD COLUMN IF NOT EXISTS "curriculum_tag_only" BOOLEAN NOT NULL DEFAULT false; + +UPDATE "platforms" +SET "curriculum_tag_only" = true, + "updated_at" = CURRENT_TIMESTAMP +WHERE lower("platform_key") LIKE '%simulator%' + OR "display_name" ILIKE '%시뮬%' + OR lower("display_name") LIKE '%simulator%'; + +DROP TABLE IF EXISTS "curriculum_tag_options"; diff --git a/prisma/migrations/20260611043000_use_student_gitea_org/migration.sql b/prisma/migrations/20260611043000_use_student_gitea_org/migration.sql new file mode 100644 index 0000000..13d686a --- /dev/null +++ b/prisma/migrations/20260611043000_use_student_gitea_org/migration.sql @@ -0,0 +1,24 @@ +ALTER TABLE "platforms" + ALTER COLUMN "default_studio_settings" + SET DEFAULT '{"workspaceRoot":"~/autonomy-studio/workspaces","giteaBaseUrl":"http://localhost:3000","giteaOwner":"student"}'; + +UPDATE "platforms" +SET "default_studio_settings" = jsonb_set( + COALESCE("default_studio_settings", '{}'::jsonb), + '{giteaOwner}', + '"student"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE COALESCE("default_studio_settings"->>'giteaOwner', '') IN ('', 'students'); + +UPDATE "instructor_platforms" +SET "studio_settings_override" = jsonb_set( + "studio_settings_override", + '{giteaOwner}', + '"student"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE "studio_settings_override" IS NOT NULL + AND "studio_settings_override"->>'giteaOwner' = 'students'; diff --git a/prisma/migrations/20260611043500_fix_studio_repository_owner/migration.sql b/prisma/migrations/20260611043500_fix_studio_repository_owner/migration.sql new file mode 100644 index 0000000..cc81720 --- /dev/null +++ b/prisma/migrations/20260611043500_fix_studio_repository_owner/migration.sql @@ -0,0 +1,4 @@ +UPDATE "studio_repositories" +SET "repo_owner" = 'student', + "repository_url" = replace("repository_url", '/students/', '/student/') +WHERE "repo_owner" = 'students'; diff --git a/prisma/migrations/20260611083000_comment_lms_tables/migration.sql b/prisma/migrations/20260611083000_comment_lms_tables/migration.sql new file mode 100644 index 0000000..115cdd9 --- /dev/null +++ b/prisma/migrations/20260611083000_comment_lms_tables/migration.sql @@ -0,0 +1,350 @@ +COMMENT ON TABLE "users" IS 'LMS 내부 사용자 프로필. 인증 서버 사용자와 LMS 수강/강의/채팅 데이터를 연결한다.'; +COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 ID'; +COMMENT ON COLUMN "users"."auth_user_id" IS '인증 서버 사용자 ID'; +COMMENT ON COLUMN "users"."email" IS '사용자 이메일'; +COMMENT ON COLUMN "users"."name" IS '사용자 이름'; +COMMENT ON COLUMN "users"."auth_role" IS '인증 서버 기준 역할'; +COMMENT ON COLUMN "users"."primary_organization_id" IS '대표 소속 조직 ID'; +COMMENT ON COLUMN "users"."primary_organization_name" IS '대표 소속 조직명'; +COMMENT ON COLUMN "users"."primary_organization_type" IS '대표 소속 조직 유형'; +COMMENT ON COLUMN "users"."department" IS '부서 또는 학과'; +COMMENT ON COLUMN "users"."position" IS '직책 또는 직위'; +COMMENT ON COLUMN "users"."createdAt" IS '레코드 생성 시각'; +COMMENT ON COLUMN "users"."updatedAt" IS '레코드 수정 시각'; + +COMMENT ON TABLE "student_enrollments" IS '수강생과 강좌의 등록 관계. 기존 사용자는 새 계정 생성 없이 강좌에 연결된다.'; +COMMENT ON COLUMN "student_enrollments"."id" IS '수강 등록 ID'; +COMMENT ON COLUMN "student_enrollments"."user_id" IS '수강생 사용자 ID'; +COMMENT ON COLUMN "student_enrollments"."course_id" IS '등록 강좌 ID'; +COMMENT ON COLUMN "student_enrollments"."organization_id" IS '등록 기준 조직 ID'; +COMMENT ON COLUMN "student_enrollments"."organization_name" IS '등록 기준 조직명'; +COMMENT ON COLUMN "student_enrollments"."status" IS '수강 등록 상태'; +COMMENT ON COLUMN "student_enrollments"."removed_at" IS '수강 등록 해제 시각'; +COMMENT ON COLUMN "student_enrollments"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "student_enrollments"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "student_groups" IS '강사가 관리하는 수강생 그룹. 강의 진행 상태와 플랫폼 배정의 기준이다.'; +COMMENT ON COLUMN "student_groups"."id" IS '수강생 그룹 ID'; +COMMENT ON COLUMN "student_groups"."organization_id" IS '그룹 소속 조직 ID'; +COMMENT ON COLUMN "student_groups"."organization_name" IS '그룹 소속 조직명'; +COMMENT ON COLUMN "student_groups"."name" IS '그룹명'; +COMMENT ON COLUMN "student_groups"."week" IS '그룹 기본 주차 값'; +COMMENT ON COLUMN "student_groups"."status" IS '그룹 운영 상태'; +COMMENT ON COLUMN "student_groups"."platform_name" IS '그룹에 배정된 플랫폼 표시명'; +COMMENT ON COLUMN "student_groups"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "student_groups"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "student_group_members" IS '수강생 그룹과 사용자 간의 멤버십.'; +COMMENT ON COLUMN "student_group_members"."id" IS '그룹 멤버 ID'; +COMMENT ON COLUMN "student_group_members"."group_id" IS '수강생 그룹 ID'; +COMMENT ON COLUMN "student_group_members"."user_id" IS '그룹에 포함된 사용자 ID'; +COMMENT ON COLUMN "student_group_members"."created_at" IS '레코드 생성 시각'; + +COMMENT ON TABLE "courses" IS 'LMS 강좌 마스터. 주차별 커리큘럼과 수강생 등록의 상위 단위이다.'; +COMMENT ON COLUMN "courses"."id" IS '강좌 ID'; +COMMENT ON COLUMN "courses"."slug" IS '강좌 식별 슬러그'; +COMMENT ON COLUMN "courses"."title" IS '강좌명'; +COMMENT ON COLUMN "courses"."subtitle" IS '강좌 부제목'; +COMMENT ON COLUMN "courses"."description" IS '강좌 설명'; +COMMENT ON COLUMN "courses"."organization_name" IS '강좌 소속 조직명'; +COMMENT ON COLUMN "courses"."teacher_id" IS '담당 강사 사용자 ID'; +COMMENT ON COLUMN "courses"."is_active" IS '강좌 활성 여부'; +COMMENT ON COLUMN "courses"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "courses"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_weeks" IS '강좌별 주차 커리큘럼 기본 데이터.'; +COMMENT ON COLUMN "course_weeks"."id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "course_weeks"."course_id" IS '소속 강좌 ID'; +COMMENT ON COLUMN "course_weeks"."week" IS '주차 번호'; +COMMENT ON COLUMN "course_weeks"."title" IS '주차 제목'; +COMMENT ON COLUMN "course_weeks"."tags" IS '주차 태그 목록'; +COMMENT ON COLUMN "course_weeks"."progress" IS '기본 진행률'; +COMMENT ON COLUMN "course_weeks"."state" IS '주차 상태'; +COMMENT ON COLUMN "course_weeks"."duration" IS '예상 학습 시간'; +COMMENT ON COLUMN "course_weeks"."github" IS '관련 GitHub 또는 저장소 정보'; +COMMENT ON COLUMN "course_weeks"."overview" IS '주차 개요'; +COMMENT ON COLUMN "course_weeks"."deliverable" IS '제출 기준 또는 산출물 설명'; +COMMENT ON COLUMN "course_weeks"."requires_source_submission" IS '소스 제출 필요 여부'; +COMMENT ON COLUMN "course_weeks"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_weeks"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_week_topics" IS '주차별 학습단계 목록.'; +COMMENT ON COLUMN "course_week_topics"."id" IS '학습단계 ID'; +COMMENT ON COLUMN "course_week_topics"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "course_week_topics"."title" IS '학습단계 제목'; +COMMENT ON COLUMN "course_week_topics"."detail" IS '학습단계 상세 내용'; +COMMENT ON COLUMN "course_week_topics"."sort_order" IS '학습단계 표시 순서'; + +COMMENT ON TABLE "course_week_practices" IS '주차별 실습내용 목록.'; +COMMENT ON COLUMN "course_week_practices"."id" IS '실습내용 ID'; +COMMENT ON COLUMN "course_week_practices"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "course_week_practices"."title" IS '실습내용 제목'; +COMMENT ON COLUMN "course_week_practices"."description" IS '실습내용 설명'; +COMMENT ON COLUMN "course_week_practices"."sort_order" IS '실습내용 표시 순서'; + +COMMENT ON TABLE "course_week_references" IS '주차별 참고자료 파일 메타데이터.'; +COMMENT ON COLUMN "course_week_references"."id" IS '참고자료 ID'; +COMMENT ON COLUMN "course_week_references"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "course_week_references"."title" IS '참고자료 제목'; +COMMENT ON COLUMN "course_week_references"."original_file_name" IS '업로드 원본 파일명'; +COMMENT ON COLUMN "course_week_references"."stored_file_name" IS '서버 저장 파일명'; +COMMENT ON COLUMN "course_week_references"."content_type" IS '파일 MIME 타입'; +COMMENT ON COLUMN "course_week_references"."file_size_bytes" IS '파일 크기 바이트'; +COMMENT ON COLUMN "course_week_references"."sort_order" IS '참고자료 표시 순서'; +COMMENT ON COLUMN "course_week_references"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_week_references"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_week_modules" IS '주차별 개발 스튜디오 모듈 목록.'; +COMMENT ON COLUMN "course_week_modules"."id" IS '주차 모듈 ID'; +COMMENT ON COLUMN "course_week_modules"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "course_week_modules"."sort_order" IS '모듈 표시 및 실행 순서'; +COMMENT ON COLUMN "course_week_modules"."category" IS '모듈 분류'; +COMMENT ON COLUMN "course_week_modules"."name" IS '모듈명'; +COMMENT ON COLUMN "course_week_modules"."folder_name" IS '워크스페이스 폴더명'; +COMMENT ON COLUMN "course_week_modules"."template_repository_url" IS '모듈 템플릿 저장소 URL'; +COMMENT ON COLUMN "course_week_modules"."docker_image" IS '모듈 실행 Docker 이미지'; +COMMENT ON COLUMN "course_week_modules"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_week_modules"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "instructor_curriculum_settings" IS '강사/그룹별 현재 진행 중인 주차와 학습단계 설정.'; +COMMENT ON COLUMN "instructor_curriculum_settings"."id" IS '강의 진행 설정 ID'; +COMMENT ON COLUMN "instructor_curriculum_settings"."organization_id" IS '설정 소속 조직 ID'; +COMMENT ON COLUMN "instructor_curriculum_settings"."organization_name" IS '설정 소속 조직명'; +COMMENT ON COLUMN "instructor_curriculum_settings"."group_id" IS '진행 상태가 적용되는 수강생 그룹 ID'; +COMMENT ON COLUMN "instructor_curriculum_settings"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "instructor_curriculum_settings"."active_week" IS '현재 진행 주차'; +COMMENT ON COLUMN "instructor_curriculum_settings"."active_learning_step" IS '현재 진행 학습단계 식별값'; +COMMENT ON COLUMN "instructor_curriculum_settings"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "instructor_curriculum_settings"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "instructor_curriculum_week_overrides" IS '강사 소속/강좌별 주차 커리큘럼 수정값.'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."id" IS '주차 커리큘럼 수정값 ID'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."organization_id" IS '수정 소속 조직 ID'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."organization_name" IS '수정 소속 조직명'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."week" IS '주차 번호'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."tag_label" IS '주차 플랫폼/태그 표시 라벨'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."title" IS '수정된 주차 제목'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."duration" IS '수정된 예상 학습 시간'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."overview" IS '수정된 주차 개요'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."deliverable" IS '수정된 제출 기준'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."requires_source_submission" IS '소스 제출 필요 여부 수정값'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topics" IS '수정된 학습단계 JSON'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topic_details" IS '수정된 학습단계 상세 JSON'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."practice_contents" IS '수정된 실습내용 JSON'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."disabled_module_ids" IS '비활성화한 주차 모듈 ID 목록 JSON'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "platforms" IS '관리자가 정의하는 플랫폼 마스터. 강좌 태그와 강사 등록 플랫폼의 기준이다.'; +COMMENT ON COLUMN "platforms"."id" IS '플랫폼 마스터 ID'; +COMMENT ON COLUMN "platforms"."platform_key" IS '플랫폼 고유 키'; +COMMENT ON COLUMN "platforms"."display_name" IS '플랫폼 표시명'; +COMMENT ON COLUMN "platforms"."description" IS '플랫폼 설명'; +COMMENT ON COLUMN "platforms"."organization_name" IS '플랫폼 기본 소속 조직명'; +COMMENT ON COLUMN "platforms"."icon_key" IS '플랫폼 아이콘 키'; +COMMENT ON COLUMN "platforms"."is_active" IS '플랫폼 활성 여부'; +COMMENT ON COLUMN "platforms"."curriculum_tag_only" IS '커리큘럼 태그 전용 여부'; +COMMENT ON COLUMN "platforms"."vehicle_type" IS '차량 또는 장비 유형'; +COMMENT ON COLUMN "platforms"."wheelbase_meters" IS '휠베이스 미터 단위'; +COMMENT ON COLUMN "platforms"."max_speed_meters_per_second" IS '최대 속도 m/s'; +COMMENT ON COLUMN "platforms"."max_steering_angle_degrees" IS '최대 조향각 도 단위'; +COMMENT ON COLUMN "platforms"."sensors" IS '기본 센서 구성 JSON'; +COMMENT ON COLUMN "platforms"."drivers" IS '기본 드라이버 구성 JSON'; +COMMENT ON COLUMN "platforms"."algorithms" IS '기본 알고리즘 구성 JSON'; +COMMENT ON COLUMN "platforms"."default_studio_settings" IS '강사 플랫폼 등록 시 복사되는 기본 개발 스튜디오 설정 JSON'; +COMMENT ON COLUMN "platforms"."control_duration_minutes" IS '기본 제어권 유지 시간 분'; +COMMENT ON COLUMN "platforms"."control_warning_minutes" IS '기본 제어권 만료 경고 시간 분'; +COMMENT ON COLUMN "platforms"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "platforms"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "instructor_platforms" IS '강사가 실제 수업에 등록한 플랫폼 인스턴스.'; +COMMENT ON COLUMN "instructor_platforms"."id" IS '강사 등록 플랫폼 ID'; +COMMENT ON COLUMN "instructor_platforms"."organization_id" IS '등록 소속 조직 ID'; +COMMENT ON COLUMN "instructor_platforms"."organization_name" IS '등록 소속 조직명'; +COMMENT ON COLUMN "instructor_platforms"."platform_id" IS '플랫폼 마스터 ID'; +COMMENT ON COLUMN "instructor_platforms"."display_name" IS '수업에서 사용할 플랫폼 표시명'; +COMMENT ON COLUMN "instructor_platforms"."instance_no" IS '동일 플랫폼 내 인스턴스 번호'; +COMMENT ON COLUMN "instructor_platforms"."status" IS '플랫폼 운영 상태'; +COMMENT ON COLUMN "instructor_platforms"."battery" IS '배터리 잔량 퍼센트'; +COMMENT ON COLUMN "instructor_platforms"."uptime_minutes" IS '가동 시간 분'; +COMMENT ON COLUMN "instructor_platforms"."speed_meters" IS '현재 속도 m/s'; +COMMENT ON COLUMN "instructor_platforms"."error_count" IS '오류 발생 수'; +COMMENT ON COLUMN "instructor_platforms"."emergency_stop" IS '긴급정지 여부'; +COMMENT ON COLUMN "instructor_platforms"."vehicle_type" IS '차량 또는 장비 유형'; +COMMENT ON COLUMN "instructor_platforms"."wheelbase_meters" IS '휠베이스 미터 단위'; +COMMENT ON COLUMN "instructor_platforms"."max_speed_meters_per_second" IS '최대 속도 m/s'; +COMMENT ON COLUMN "instructor_platforms"."max_steering_angle_degrees" IS '최대 조향각 도 단위'; +COMMENT ON COLUMN "instructor_platforms"."sensors" IS '강사 플랫폼 센서 설정 JSON'; +COMMENT ON COLUMN "instructor_platforms"."drivers" IS '강사 플랫폼 드라이버 정보 JSON'; +COMMENT ON COLUMN "instructor_platforms"."algorithms" IS '강사 플랫폼 알고리즘 설정 JSON'; +COMMENT ON COLUMN "instructor_platforms"."studio_settings_override" IS '플랫폼별 개발 스튜디오 설정 오버라이드 JSON'; +COMMENT ON COLUMN "instructor_platforms"."control_duration_minutes" IS '제어권 유지 시간 분'; +COMMENT ON COLUMN "instructor_platforms"."control_warning_minutes" IS '제어권 만료 경고 시간 분'; +COMMENT ON COLUMN "instructor_platforms"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "instructor_platforms"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_platforms" IS '강좌에서 사용 가능한 플랫폼 목록.'; +COMMENT ON COLUMN "course_platforms"."id" IS '강좌 플랫폼 ID'; +COMMENT ON COLUMN "course_platforms"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "course_platforms"."platform_id" IS '플랫폼 마스터 ID'; +COMMENT ON COLUMN "course_platforms"."status" IS '강좌 내 플랫폼 표시 상태'; +COMMENT ON COLUMN "course_platforms"."action" IS '강좌 내 플랫폼 기본 액션 문구'; +COMMENT ON COLUMN "course_platforms"."state" IS '강좌 내 플랫폼 사용 상태 키'; +COMMENT ON COLUMN "course_platforms"."sort_order" IS '플랫폼 표시 순서'; +COMMENT ON COLUMN "course_platforms"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_platforms"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_environment_profiles" IS '강좌/주차/플랫폼별 개발환경 실행 프로파일.'; +COMMENT ON COLUMN "course_environment_profiles"."id" IS '개발환경 프로파일 ID'; +COMMENT ON COLUMN "course_environment_profiles"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "course_environment_profiles"."week" IS '주차 번호'; +COMMENT ON COLUMN "course_environment_profiles"."platform_key" IS '대상 플랫폼 키'; +COMMENT ON COLUMN "course_environment_profiles"."profile_type" IS '환경 프로파일 유형'; +COMMENT ON COLUMN "course_environment_profiles"."name" IS '프로파일 이름'; +COMMENT ON COLUMN "course_environment_profiles"."config" IS '컨테이너, 브릿지, 뷰어 실행 설정 JSON'; +COMMENT ON COLUMN "course_environment_profiles"."enabled" IS '프로파일 사용 여부'; +COMMENT ON COLUMN "course_environment_profiles"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_environment_profiles"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "visualization_platforms" IS '시각화 화면에서 선택 가능한 주행/시뮬레이션 스택 마스터.'; +COMMENT ON COLUMN "visualization_platforms"."platform_key" IS '시각화 플랫폼 키'; +COMMENT ON COLUMN "visualization_platforms"."type" IS '시각화 스택 유형. AUTOWARE, NAV2, SLAM 등'; +COMMENT ON COLUMN "visualization_platforms"."display_name" IS '시각화 플랫폼 표시명'; +COMMENT ON COLUMN "visualization_platforms"."description" IS '시각화 플랫폼 설명'; +COMMENT ON COLUMN "visualization_platforms"."default_config" IS '기본 시각화 실행 설정 JSON'; +COMMENT ON COLUMN "visualization_platforms"."sort_order" IS '시각화 플랫폼 표시 순서'; +COMMENT ON COLUMN "visualization_platforms"."enabled" IS '시각화 플랫폼 사용 여부'; +COMMENT ON COLUMN "visualization_platforms"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "visualization_platforms"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_week_visualization_platforms" IS '강좌 주차별 허용 시각화 플랫폼 선택값.'; +COMMENT ON COLUMN "course_week_visualization_platforms"."id" IS '주차 시각화 플랫폼 선택 ID'; +COMMENT ON COLUMN "course_week_visualization_platforms"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "course_week_visualization_platforms"."week" IS '주차 번호'; +COMMENT ON COLUMN "course_week_visualization_platforms"."platform_key" IS '시각화 플랫폼 키'; +COMMENT ON COLUMN "course_week_visualization_platforms"."enabled" IS '주차에서 해당 시각화 플랫폼 사용 여부'; +COMMENT ON COLUMN "course_week_visualization_platforms"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_week_visualization_platforms"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "platform_control_sessions" IS '플랫폼 제어권을 실제로 점유 중이거나 종료된 세션.'; +COMMENT ON COLUMN "platform_control_sessions"."id" IS '제어권 세션 ID'; +COMMENT ON COLUMN "platform_control_sessions"."organization_id" IS '세션 소속 조직 ID'; +COMMENT ON COLUMN "platform_control_sessions"."organization_name" IS '세션 소속 조직명'; +COMMENT ON COLUMN "platform_control_sessions"."platform_id" IS '플랫폼 마스터 ID'; +COMMENT ON COLUMN "platform_control_sessions"."instructor_platform_id" IS '강사 등록 플랫폼 ID'; +COMMENT ON COLUMN "platform_control_sessions"."holder_user_id" IS '제어권 보유 사용자 ID'; +COMMENT ON COLUMN "platform_control_sessions"."group_id" IS '제어권 대상 수강생 그룹 ID'; +COMMENT ON COLUMN "platform_control_sessions"."status" IS '제어권 세션 상태'; +COMMENT ON COLUMN "platform_control_sessions"."started_at" IS '제어권 시작 시각'; +COMMENT ON COLUMN "platform_control_sessions"."ends_at" IS '제어권 만료 예정 시각'; +COMMENT ON COLUMN "platform_control_sessions"."warning_at" IS '만료 경고 예정 시각'; +COMMENT ON COLUMN "platform_control_sessions"."warning_sent_at" IS '만료 경고 발송 시각'; +COMMENT ON COLUMN "platform_control_sessions"."released_at" IS '제어권 반납 또는 종료 시각'; +COMMENT ON COLUMN "platform_control_sessions"."end_reason" IS '제어권 종료 사유'; +COMMENT ON COLUMN "platform_control_sessions"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "platform_control_sessions"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "platform_control_queue_entries" IS '플랫폼 제어권 대기 예약 목록.'; +COMMENT ON COLUMN "platform_control_queue_entries"."id" IS '제어권 대기 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."organization_id" IS '대기 소속 조직 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."organization_name" IS '대기 소속 조직명'; +COMMENT ON COLUMN "platform_control_queue_entries"."platform_id" IS '플랫폼 마스터 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."instructor_platform_id" IS '강사 등록 플랫폼 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."user_id" IS '대기 신청 사용자 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."group_id" IS '대기 대상 수강생 그룹 ID'; +COMMENT ON COLUMN "platform_control_queue_entries"."status" IS '대기 예약 상태'; +COMMENT ON COLUMN "platform_control_queue_entries"."requested_at" IS '대기 신청 시각'; +COMMENT ON COLUMN "platform_control_queue_entries"."granted_at" IS '제어권 배정 시각'; +COMMENT ON COLUMN "platform_control_queue_entries"."canceled_at" IS '대기 취소 시각'; +COMMENT ON COLUMN "platform_control_queue_entries"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "platform_control_queue_entries"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_student_progress" IS '학생별 강좌 전체 학습 현황 집계.'; +COMMENT ON COLUMN "course_student_progress"."id" IS '강좌 학습 현황 ID'; +COMMENT ON COLUMN "course_student_progress"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "course_student_progress"."user_id" IS '학생 사용자 ID'; +COMMENT ON COLUMN "course_student_progress"."completed_weeks" IS '완료한 주차 수'; +COMMENT ON COLUMN "course_student_progress"."total_weeks" IS '전체 주차 수'; +COMMENT ON COLUMN "course_student_progress"."learning_hours" IS '누적 학습 시간 분'; +COMMENT ON COLUMN "course_student_progress"."commit_count" IS '소스 제출 커밋 수'; +COMMENT ON COLUMN "course_student_progress"."average_score" IS '평균 점수'; +COMMENT ON COLUMN "course_student_progress"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_student_progress"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "course_student_week_progress" IS '학생별 주차 제출 및 진행 현황.'; +COMMENT ON COLUMN "course_student_week_progress"."id" IS '주차 학습 현황 ID'; +COMMENT ON COLUMN "course_student_week_progress"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "course_student_week_progress"."user_id" IS '학생 사용자 ID'; +COMMENT ON COLUMN "course_student_week_progress"."week" IS '주차 번호'; +COMMENT ON COLUMN "course_student_week_progress"."submitted_at" IS '학생 제출 완료 시각'; +COMMENT ON COLUMN "course_student_week_progress"."commit_count" IS '해당 주차 제출 커밋 수'; +COMMENT ON COLUMN "course_student_week_progress"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "course_student_week_progress"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "studio_repositories" IS '학생별 주차 모듈 Gitea 저장소 매핑.'; +COMMENT ON COLUMN "studio_repositories"."id" IS '스튜디오 저장소 매핑 ID'; +COMMENT ON COLUMN "studio_repositories"."user_id" IS '학생 사용자 ID'; +COMMENT ON COLUMN "studio_repositories"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "studio_repositories"."module_id" IS '주차 모듈 ID'; +COMMENT ON COLUMN "studio_repositories"."repo_owner" IS 'Gitea 저장소 owner 또는 조직'; +COMMENT ON COLUMN "studio_repositories"."repo_name" IS 'Gitea 저장소명'; +COMMENT ON COLUMN "studio_repositories"."repository_url" IS '학생 작업 저장소 URL'; +COMMENT ON COLUMN "studio_repositories"."template_repository_url" IS '원본 템플릿 저장소 URL'; +COMMENT ON COLUMN "studio_repositories"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "studio_repositories"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "submission_scores" IS '학생 제출물의 모듈별 점수.'; +COMMENT ON COLUMN "submission_scores"."id" IS '모듈별 제출 점수 ID'; +COMMENT ON COLUMN "submission_scores"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "submission_scores"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "submission_scores"."module_id" IS '주차 모듈 ID'; +COMMENT ON COLUMN "submission_scores"."student_id" IS '학생 사용자 ID'; +COMMENT ON COLUMN "submission_scores"."score" IS '모듈별 점수'; +COMMENT ON COLUMN "submission_scores"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "submission_scores"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "submission_total_scores" IS '학생 제출물의 주차별 총점.'; +COMMENT ON COLUMN "submission_total_scores"."id" IS '주차 제출 총점 ID'; +COMMENT ON COLUMN "submission_total_scores"."course_id" IS '강좌 ID'; +COMMENT ON COLUMN "submission_total_scores"."week_id" IS '주차 커리큘럼 ID'; +COMMENT ON COLUMN "submission_total_scores"."student_id" IS '학생 사용자 ID'; +COMMENT ON COLUMN "submission_total_scores"."score" IS '주차 제출 총점'; +COMMENT ON COLUMN "submission_total_scores"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "submission_total_scores"."updated_at" IS '레코드 수정 시각'; + +COMMENT ON TABLE "chat_threads" IS '개발 스튜디오 질의응답 채팅방.'; +COMMENT ON COLUMN "chat_threads"."id" IS '채팅방 ID'; +COMMENT ON COLUMN "chat_threads"."course_id" IS '채팅 대상 강좌 ID'; +COMMENT ON COLUMN "chat_threads"."week_id" IS '채팅 대상 주차 ID'; +COMMENT ON COLUMN "chat_threads"."student_id" IS '질문 학생 사용자 ID'; +COMMENT ON COLUMN "chat_threads"."assigned_instructor_id" IS '배정된 강사 사용자 ID'; +COMMENT ON COLUMN "chat_threads"."type" IS '채팅방 유형. AI, 강사, 혼합'; +COMMENT ON COLUMN "chat_threads"."status" IS '채팅방 처리 상태'; +COMMENT ON COLUMN "chat_threads"."title" IS '채팅방 제목'; +COMMENT ON COLUMN "chat_threads"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "chat_threads"."updated_at" IS '레코드 수정 시각'; +COMMENT ON COLUMN "chat_threads"."last_message_at" IS '마지막 메시지 작성 시각'; + +COMMENT ON TABLE "chat_messages" IS '개발 스튜디오 질의응답 메시지.'; +COMMENT ON COLUMN "chat_messages"."id" IS '채팅 메시지 ID'; +COMMENT ON COLUMN "chat_messages"."thread_id" IS '소속 채팅방 ID'; +COMMENT ON COLUMN "chat_messages"."sender_type" IS '메시지 발신자 유형'; +COMMENT ON COLUMN "chat_messages"."sender_user_id" IS '발신 사용자 ID'; +COMMENT ON COLUMN "chat_messages"."content" IS '메시지 내용'; +COMMENT ON COLUMN "chat_messages"."content_format" IS '메시지 내용 형식'; +COMMENT ON COLUMN "chat_messages"."metadata" IS '메시지 부가 메타데이터 JSON'; +COMMENT ON COLUMN "chat_messages"."created_at" IS '레코드 생성 시각'; +COMMENT ON COLUMN "chat_messages"."deleted_at" IS '메시지 삭제 시각'; + +COMMENT ON TABLE "ai_chat_runs" IS 'AI 채팅 응답 생성 실행 로그.'; +COMMENT ON COLUMN "ai_chat_runs"."id" IS 'AI 실행 로그 ID'; +COMMENT ON COLUMN "ai_chat_runs"."message_id" IS 'AI 응답 메시지 ID'; +COMMENT ON COLUMN "ai_chat_runs"."model" IS '사용한 AI 모델명'; +COMMENT ON COLUMN "ai_chat_runs"."prompt_tokens" IS '프롬프트 토큰 수'; +COMMENT ON COLUMN "ai_chat_runs"."completion_tokens" IS '응답 토큰 수'; +COMMENT ON COLUMN "ai_chat_runs"."total_tokens" IS '전체 토큰 수'; +COMMENT ON COLUMN "ai_chat_runs"."latency_ms" IS 'AI 응답 지연 시간 밀리초'; +COMMENT ON COLUMN "ai_chat_runs"."status" IS 'AI 실행 상태'; +COMMENT ON COLUMN "ai_chat_runs"."error" IS 'AI 실행 오류 메시지'; +COMMENT ON COLUMN "ai_chat_runs"."created_at" IS '레코드 생성 시각'; diff --git a/prisma/migrations/20260611084500_group_instructor_platform_fk/migration.sql b/prisma/migrations/20260611084500_group_instructor_platform_fk/migration.sql new file mode 100644 index 0000000..d4e3f5f --- /dev/null +++ b/prisma/migrations/20260611084500_group_instructor_platform_fk/migration.sql @@ -0,0 +1,25 @@ +ALTER TABLE "student_groups" + ADD COLUMN "instructor_platform_id" UUID; + +UPDATE "student_groups" AS g +SET "instructor_platform_id" = p."id" +FROM "instructor_platforms" AS p +WHERE g."platform_name" IS NOT NULL + AND g."platform_name" = p."display_name" + AND ( + g."organization_id" = p."organization_id" + OR g."organization_name" = p."organization_name" + ); + +CREATE INDEX "student_groups_instructor_platform_id_idx" + ON "student_groups"("instructor_platform_id"); + +ALTER TABLE "student_groups" + ADD CONSTRAINT "student_groups_instructor_platform_id_fkey" + FOREIGN KEY ("instructor_platform_id") + REFERENCES "instructor_platforms"("id") + ON DELETE SET NULL + ON UPDATE CASCADE; + +COMMENT ON COLUMN "student_groups"."platform_name" IS '기존 호환용 플랫폼 표시명. 실제 배정 참조는 instructor_platform_id를 사용한다.'; +COMMENT ON COLUMN "student_groups"."instructor_platform_id" IS '그룹에 배정된 강사 등록 플랫폼 ID'; diff --git a/prisma/migrations/20260617090000_add_topic_video_urls/migration.sql b/prisma/migrations/20260617090000_add_topic_video_urls/migration.sql new file mode 100644 index 0000000..ef58336 --- /dev/null +++ b/prisma/migrations/20260617090000_add_topic_video_urls/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "instructor_curriculum_week_overrides" + ADD COLUMN "topic_video_urls" JSONB; + +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topic_video_urls" IS '학습단계별 동영상 강의 URL 목록'; diff --git a/prisma/migrations/20260617093000_add_course_week_reference_kind/migration.sql b/prisma/migrations/20260617093000_add_course_week_reference_kind/migration.sql new file mode 100644 index 0000000..3df2845 --- /dev/null +++ b/prisma/migrations/20260617093000_add_course_week_reference_kind/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "course_week_references" +ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'REFERENCE'; + +CREATE INDEX IF NOT EXISTS "course_week_references_week_id_kind_idx" +ON "course_week_references"("week_id", "kind"); + +COMMENT ON COLUMN "course_week_references"."kind" IS '주차 파일 자료 유형. REFERENCE는 참고자료, EXAMPLE_CODE는 예제 코드 파일입니다.'; diff --git a/prisma/migrations/20260618090000_add_chat_message_status/migration.sql b/prisma/migrations/20260618090000_add_chat_message_status/migration.sql new file mode 100644 index 0000000..a0a2725 --- /dev/null +++ b/prisma/migrations/20260618090000_add_chat_message_status/migration.sql @@ -0,0 +1,11 @@ +CREATE TYPE "ChatMessageStatus" AS ENUM ('ACTIVE', 'CANCELED'); + +ALTER TABLE "chat_messages" + ADD COLUMN "status" "ChatMessageStatus" NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN "canceled_at" TIMESTAMPTZ(3); + +CREATE INDEX "chat_messages_thread_id_status_created_at_idx" + ON "chat_messages"("thread_id", "status", "created_at"); + +COMMENT ON COLUMN "chat_messages"."status" IS '채팅 메시지 처리 상태. AI 응답 중단 시 CANCELED로 표시해 다음 AI 컨텍스트에서 제외한다.'; +COMMENT ON COLUMN "chat_messages"."canceled_at" IS 'AI 응답이 사용자에 의해 중단된 시각.'; diff --git a/prisma/migrations/20260629090000_add_module_artifacts/migration.sql b/prisma/migrations/20260629090000_add_module_artifacts/migration.sql new file mode 100644 index 0000000..167be7d --- /dev/null +++ b/prisma/migrations/20260629090000_add_module_artifacts/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "course_week_modules" + ADD COLUMN IF NOT EXISTS "source_type" TEXT NOT NULL DEFAULT 'GIT', + ADD COLUMN IF NOT EXISTS "prebuilt_artifacts" JSONB NOT NULL DEFAULT '[]'; + +COMMENT ON COLUMN "course_week_modules"."source_type" IS '모듈 소스 제공 방식입니다. 기본값 GIT은 template_repository_url을 사용합니다.'; +COMMENT ON COLUMN "course_week_modules"."prebuilt_artifacts" IS 'R2 등 외부 저장소에 둔 모듈별 사전 빌드 산출물 목록입니다. build/install tar 아카이브를 기록합니다.'; diff --git a/prisma/migrations/20260629093000_use_slam_runtime_image_for_bridge/migration.sql b/prisma/migrations/20260629093000_use_slam_runtime_image_for_bridge/migration.sql new file mode 100644 index 0000000..442a54c --- /dev/null +++ b/prisma/migrations/20260629093000_use_slam_runtime_image_for_bridge/migration.sql @@ -0,0 +1,49 @@ +UPDATE "course_environment_profiles" +SET "config" = jsonb_set( + jsonb_set("config", '{bridge,image}', '"nav2-slam:humble"'::jsonb, true), + '{bridge,buildContext}', + '"resources/slam-ros-bridge"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE upper("profile_type") = 'SLAM'; + +UPDATE "course_environment_profiles" +SET "config" = jsonb_set( + "config", + '{runtime,containers}', + COALESCE(( + SELECT jsonb_agg(container) + FROM jsonb_array_elements("config" #> '{runtime,containers}') AS container + WHERE container->>'name' <> 'slam-ros-bridge' + ), '[]'::jsonb), + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE upper("profile_type") = 'SLAM' + AND jsonb_typeof("config" #> '{runtime,containers}') = 'array'; + +UPDATE "visualization_platforms" +SET "default_config" = jsonb_set( + jsonb_set("default_config", '{bridge,image}', '"nav2-slam:humble"'::jsonb, true), + '{bridge,buildContext}', + '"resources/slam-ros-bridge"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'SLAM'; + +UPDATE "visualization_platforms" +SET "default_config" = jsonb_set( + "default_config", + '{runtime,containers}', + COALESCE(( + SELECT jsonb_agg(container) + FROM jsonb_array_elements("default_config" #> '{runtime,containers}') AS container + WHERE container->>'name' <> 'slam-ros-bridge' + ), '[]'::jsonb), + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'SLAM' + AND jsonb_typeof("default_config" #> '{runtime,containers}') = 'array'; diff --git a/prisma/migrations/20260706090000_add_public_guide_course/migration.sql b/prisma/migrations/20260706090000_add_public_guide_course/migration.sql new file mode 100644 index 0000000..bf087e2 --- /dev/null +++ b/prisma/migrations/20260706090000_add_public_guide_course/migration.sql @@ -0,0 +1,139 @@ +INSERT INTO "users" ("id", "email", "name", "auth_role", "primary_organization_name", "createdAt", "updatedAt") +VALUES (gen_random_uuid(), 'system@autonomystudio.local', 'Autonomy Studio', 'SYSTEM', 'Autonomy Studio', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("email") DO UPDATE +SET "name" = EXCLUDED."name", + "auth_role" = EXCLUDED."auth_role", + "primary_organization_name" = EXCLUDED."primary_organization_name", + "updatedAt" = CURRENT_TIMESTAMP; + +WITH teacher AS ( + SELECT "id" FROM "users" WHERE "email" = 'system@autonomystudio.local' +) +INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "is_active", "updated_at") +SELECT + 'autonomy-studio-lms-guide', + 'NAV2 알아보기', + '처음 사용하는 수강생을 위한 짧은 체험 강좌', + '강좌 배정 전 신규 사용자가 LMS 화면 흐름을 빠르게 익힐 수 있는 공통 체험 강좌입니다. NAV2 개념을 예시로 커리큘럼 확인, 학습단계 열람, 질의응답 사용법만 간단히 안내합니다.', + 'Autonomy Studio', + teacher."id", + true, + CURRENT_TIMESTAMP +FROM teacher +ON CONFLICT ("slug") DO UPDATE +SET "title" = EXCLUDED."title", + "subtitle" = EXCLUDED."subtitle", + "description" = EXCLUDED."description", + "organization_name" = EXCLUDED."organization_name", + "teacher_id" = EXCLUDED."teacher_id", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +WITH course AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'autonomy-studio-lms-guide' +) +INSERT INTO "course_weeks" ( + "course_id", + "week", + "title", + "tags", + "progress", + "state", + "duration", + "github", + "overview", + "deliverable", + "requires_source_submission", + "updated_at" +) +SELECT + course."id", + 1, + 'NAV2 알아보기', + ARRAY['NAV2','진행중']::TEXT[], + 0, + 'current', + '15분', + '제출 없음', + 'NAV2가 어떤 역할을 하는지 간단히 살펴보고, LMS에서 강좌 내용과 학습단계, 질문 기능을 어떻게 사용하는지 확인합니다.', + '이 강좌는 체험용이므로 별도 제출은 없습니다. 실제 수업 강좌가 배정되면 해당 강좌가 우선 표시됩니다.', + false, + CURRENT_TIMESTAMP +FROM course +ON CONFLICT ("course_id", "week") DO UPDATE +SET "title" = EXCLUDED."title", + "tags" = EXCLUDED."tags", + "progress" = EXCLUDED."progress", + "state" = EXCLUDED."state", + "duration" = EXCLUDED."duration", + "github" = EXCLUDED."github", + "overview" = EXCLUDED."overview", + "deliverable" = EXCLUDED."deliverable", + "requires_source_submission" = false, + "updated_at" = CURRENT_TIMESTAMP; + +DELETE FROM "course_weeks" +USING "courses" +WHERE "course_weeks"."course_id" = "courses"."id" + AND "courses"."slug" = 'autonomy-studio-lms-guide' + AND "course_weeks"."week" <> 1; + +DELETE FROM "course_week_practices" +USING "course_weeks", "courses" +WHERE "course_week_practices"."week_id" = "course_weeks"."id" + AND "course_weeks"."course_id" = "courses"."id" + AND "courses"."slug" = 'autonomy-studio-lms-guide'; + +DELETE FROM "course_week_topics" +USING "course_weeks", "courses" +WHERE "course_week_topics"."week_id" = "course_weeks"."id" + AND "course_weeks"."course_id" = "courses"."id" + AND "courses"."slug" = 'autonomy-studio-lms-guide'; + +WITH week1 AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + JOIN "courses" c ON c."id" = w."course_id" + WHERE c."slug" = 'autonomy-studio-lms-guide' AND w."week" = 1 +) +INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail") +SELECT week1.week_id, data.sort_order, data.title, data.detail +FROM week1 +CROSS JOIN ( + VALUES + (1, 'NAV2란 무엇인가', 'NAV2는 ROS 2 기반 로봇 내비게이션 스택입니다. 지도, 위치추정, 경로계획, 제어 흐름이 어떻게 이어지는지 큰 그림을 확인합니다.'), + (2, 'LMS에서 강좌 읽기', '주차 카드와 학습단계를 열어 강의 내용, 참고 자료, 진행 상태를 확인하는 방법을 익힙니다.'), + (3, '질문 기능 사용하기', '학습 중 궁금한 내용을 AI 또는 강사에게 질문하고, 답변을 확인하는 기본 흐름을 살펴봅니다.') +) AS data(sort_order, title, detail); + +WITH week1 AS ( + SELECT w."id" AS week_id + FROM "course_weeks" w + JOIN "courses" c ON c."id" = w."course_id" + WHERE c."slug" = 'autonomy-studio-lms-guide' AND w."week" = 1 +) +INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description") +SELECT week1.week_id, data.sort_order, data.title, data.description +FROM week1 +CROSS JOIN ( + VALUES + (1, '화면 둘러보기', '커리큘럼 화면에서 주차 카드, 학습단계, 강의 내용을 차례로 확인합니다.'), + (2, '질문 남겨보기', '질의응답 화면에서 NAV2 관련 질문을 입력하는 위치를 확인합니다.') +) AS data(sort_order, title, description); + +WITH guide AS ( + SELECT "id" FROM "courses" WHERE "slug" = 'autonomy-studio-lms-guide' +), +local_platform AS ( + SELECT "id" FROM "platforms" WHERE "platform_key" = 'futuredrive-local-pc' +) +INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at") +SELECT guide."id", local_platform."id", '사용 가능', '연결', 'ready', 1, CURRENT_TIMESTAMP +FROM guide +CROSS JOIN local_platform +ON CONFLICT ("course_id", "platform_id") DO UPDATE +SET "status" = EXCLUDED."status", + "action" = EXCLUDED."action", + "state" = EXCLUDED."state", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260707090000_add_curriculum_learning_steps/migration.sql b/prisma/migrations/20260707090000_add_curriculum_learning_steps/migration.sql new file mode 100644 index 0000000..95a0743 --- /dev/null +++ b/prisma/migrations/20260707090000_add_curriculum_learning_steps/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "instructor_curriculum_week_overrides" + ADD COLUMN IF NOT EXISTS "learning_steps" JSONB; + +COMMENT ON COLUMN "instructor_curriculum_week_overrides"."learning_steps" IS '학습단계별 제목, 설명, 동영상, 핵심개념을 묶은 JSON 목록.'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..6e14f33 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,666 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) @db.Uuid + authUserId String? @unique @map("auth_user_id") @db.Uuid + email String @unique + name String? + authRole String? @map("auth_role") + primaryOrganizationId String? @map("primary_organization_id") @db.Uuid + primaryOrganizationName String? @map("primary_organization_name") + primaryOrganizationType String? @map("primary_organization_type") + department String? + position String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + courses Course[] @relation("CourseTeacher") + progress CourseStudentProgress[] + weekProgress CourseStudentWeekProgress[] + groupMemberships StudentGroupMember[] + enrollments StudentEnrollment[] + studentChatThreads ChatThread[] @relation("ChatThreadStudent") + assignedChatThreads ChatThread[] @relation("ChatThreadInstructor") + chatMessages ChatMessage[] + platformControlSessions PlatformControlSession[] + platformControlQueueEntries PlatformControlQueueEntry[] + studioRepositories StudioRepository[] + submissionScores SubmissionScore[] + submissionTotalScores SubmissionTotalScore[] + + @@index([authRole]) + @@index([primaryOrganizationId]) + @@map("users") +} + +model StudentEnrollment { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + courseId String @map("course_id") @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + status String @default("ACTIVE") + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([userId, courseId]) + @@index([courseId, status]) + @@index([organizationId, status]) + @@index([organizationName, status]) + @@map("student_enrollments") +} + +model StudentGroup { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + name String + week Int @default(1) + status String @default("진행중") + platformName String? @map("platform_name") + instructorPlatformId String? @map("instructor_platform_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + members StudentGroupMember[] + instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: SetNull) + platformControlSessions PlatformControlSession[] + platformControlQueueEntries PlatformControlQueueEntry[] + curriculumSettings InstructorCurriculumSetting[] + + @@unique([organizationName, name]) + @@index([organizationId]) + @@index([organizationName]) + @@index([instructorPlatformId]) + @@map("student_groups") +} + +model InstructorPlatform { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + platformId String @map("platform_id") @db.Uuid + displayName String @map("display_name") + instanceNo Int @map("instance_no") + status String @default("대기") + battery Int @default(100) + uptimeMinutes Int @default(0) @map("uptime_minutes") + speedMeters Decimal @default(0) @map("speed_meters") @db.Decimal(6, 2) + errorCount Int @default(0) @map("error_count") + emergencyStop Boolean @default(false) @map("emergency_stop") + vehicleType String @default("MK-mini") @map("vehicle_type") + wheelbaseMeters Decimal @default(0.325) @map("wheelbase_meters") @db.Decimal(6, 3) + maxSpeedMetersPerSecond Decimal @default(7.0) @map("max_speed_meters_per_second") @db.Decimal(6, 2) + maxSteeringAngleDegrees Decimal @default(30) @map("max_steering_angle_degrees") @db.Decimal(6, 2) + sensors Json @default("[]") + drivers Json @default("[]") + algorithms Json @default("{}") + studioSettingsOverride Json? @map("studio_settings_override") + controlDurationMinutes Int @default(30) @map("control_duration_minutes") + controlWarningMinutes Int @default(5) @map("control_warning_minutes") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + platform Platform @relation(fields: [platformId], references: [id], onDelete: Restrict) + assignedGroups StudentGroup[] + controlSessions PlatformControlSession[] + controlQueueEntries PlatformControlQueueEntry[] + + @@unique([organizationName, displayName]) + @@unique([organizationName, platformId, instanceNo]) + @@index([organizationId]) + @@index([organizationName]) + @@index([platformId]) + @@map("instructor_platforms") +} + +model StudentGroupMember { + id String @id @default(uuid()) @db.Uuid + groupId String @map("group_id") @db.Uuid + userId String @map("user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + group StudentGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId]) + @@index([userId]) + @@map("student_group_members") +} + +model Course { + id String @id @default(uuid()) @db.Uuid + slug String @unique + title String + subtitle String? + description String? + organizationName String @map("organization_name") + teacherId String @map("teacher_id") @db.Uuid + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + teacher User @relation("CourseTeacher", fields: [teacherId], references: [id], onDelete: Restrict) + weeks CourseWeek[] + platforms CoursePlatform[] + studentProgress CourseStudentProgress[] + studentWeekProgress CourseStudentWeekProgress[] + chatThreads ChatThread[] + enrollments StudentEnrollment[] + instructorCurriculumSettings InstructorCurriculumSetting[] + instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[] + environmentProfiles CourseEnvironmentProfile[] + visualizationSelections CourseWeekVisualizationPlatform[] + submissionScores SubmissionScore[] + submissionTotalScores SubmissionTotalScore[] + + @@index([isActive]) + @@index([teacherId]) + @@map("courses") +} + +model CourseEnvironmentProfile { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + platformKey String @default("futuredrive-local-pc") @map("platform_key") + profileType String @map("profile_type") + name String + config Json + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([courseId, week, platformKey]) + @@index([courseId, week]) + @@index([profileType]) + @@map("course_environment_profiles") +} + +model VisualizationPlatform { + key String @id @map("platform_key") + type String @unique + displayName String @map("display_name") + description String? + defaultConfig Json @map("default_config") + sortOrder Int @default(0) @map("sort_order") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + courseWeekSelections CourseWeekVisualizationPlatform[] + + @@index([enabled, sortOrder]) + @@map("visualization_platforms") +} + +model CourseWeekVisualizationPlatform { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + platformKey String @map("platform_key") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + platform VisualizationPlatform @relation(fields: [platformKey], references: [key], onDelete: Cascade) + + @@unique([courseId, week, platformKey]) + @@index([courseId, week]) + @@index([platformKey]) + @@map("course_week_visualization_platforms") +} + +model InstructorCurriculumSetting { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + groupId String? @map("group_id") @db.Uuid + courseId String @map("course_id") @db.Uuid + activeWeek Int @default(1) @map("active_week") + activeLearningStep String? @map("active_learning_step") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Restrict) + group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([organizationName]) + @@index([groupId]) + @@index([courseId]) + @@map("instructor_curriculum_settings") +} + +model InstructorCurriculumWeekOverride { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + courseId String @map("course_id") @db.Uuid + week Int + tagLabel String? @map("tag_label") + title String? + duration String? + overview String? + deliverable String? + requiresSourceSubmission Boolean? @default(true) @map("requires_source_submission") + learningSteps Json? @map("learning_steps") + topics Json? + topicDetails Json? @map("topic_details") + topicVideoUrls Json? @map("topic_video_urls") + practiceContents Json? @map("practice_contents") + disabledModuleIds Json? @map("disabled_module_ids") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([organizationName, courseId, week]) + @@index([organizationId]) + @@index([organizationName]) + @@index([courseId]) + @@map("instructor_curriculum_week_overrides") +} + +model Platform { + id String @id @default(uuid()) @db.Uuid + platformKey String @unique @map("platform_key") + displayName String @map("display_name") + description String + organizationName String @map("organization_name") + iconKey String @map("icon_key") + isActive Boolean @default(true) @map("is_active") + curriculumTagOnly Boolean @default(false) @map("curriculum_tag_only") + vehicleType String @default("MK-mini") @map("vehicle_type") + wheelbaseMeters Decimal @default(0.325) @map("wheelbase_meters") @db.Decimal(6, 3) + maxSpeedMetersPerSecond Decimal @default(7.0) @map("max_speed_meters_per_second") @db.Decimal(6, 2) + maxSteeringAngleDegrees Decimal @default(30) @map("max_steering_angle_degrees") @db.Decimal(6, 2) + sensors Json @default("[]") + drivers Json @default("[]") + algorithms Json @default("{}") + defaultStudioSettings Json @default("{\"workspaceRoot\":\"~/autonomy-studio/workspaces\",\"giteaBaseUrl\":\"http://localhost:3000\",\"giteaOwner\":\"student\"}") @map("default_studio_settings") + controlDurationMinutes Int @default(30) @map("control_duration_minutes") + controlWarningMinutes Int @default(5) @map("control_warning_minutes") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + courses CoursePlatform[] + instructorPlatforms InstructorPlatform[] + controlSessions PlatformControlSession[] + controlQueueEntries PlatformControlQueueEntry[] + + @@index([organizationName]) + @@index([isActive]) + @@map("platforms") +} + +model CoursePlatform { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + platformId String @map("platform_id") @db.Uuid + status String + action String + state String + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + platform Platform @relation(fields: [platformId], references: [id], onDelete: Restrict) + + @@unique([courseId, platformId]) + @@index([courseId, sortOrder]) + @@index([platformId]) + @@map("course_platforms") +} + +model PlatformControlSession { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + platformId String? @map("platform_id") @db.Uuid + instructorPlatformId String? @map("instructor_platform_id") @db.Uuid + holderUserId String @map("holder_user_id") @db.Uuid + groupId String? @map("group_id") @db.Uuid + status String @default("ACTIVE") + startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3) + endsAt DateTime @map("ends_at") @db.Timestamptz(3) + warningAt DateTime @map("warning_at") @db.Timestamptz(3) + warningSentAt DateTime? @map("warning_sent_at") @db.Timestamptz(3) + releasedAt DateTime? @map("released_at") @db.Timestamptz(3) + endReason String? @map("end_reason") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + platform Platform? @relation(fields: [platformId], references: [id], onDelete: Restrict) + instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: Restrict) + holderUser User @relation(fields: [holderUserId], references: [id], onDelete: Cascade) + group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + + @@index([organizationId]) + @@index([organizationName]) + @@index([platformId, status]) + @@index([instructorPlatformId, status]) + @@index([holderUserId, status]) + @@index([endsAt, status]) + @@map("platform_control_sessions") +} + +model PlatformControlQueueEntry { + id String @id @default(uuid()) @db.Uuid + organizationId String? @map("organization_id") @db.Uuid + organizationName String @map("organization_name") + platformId String? @map("platform_id") @db.Uuid + instructorPlatformId String? @map("instructor_platform_id") @db.Uuid + userId String @map("user_id") @db.Uuid + groupId String? @map("group_id") @db.Uuid + status String @default("WAITING") + requestedAt DateTime @default(now()) @map("requested_at") @db.Timestamptz(3) + grantedAt DateTime? @map("granted_at") @db.Timestamptz(3) + canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + platform Platform? @relation(fields: [platformId], references: [id], onDelete: Restrict) + instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + + @@index([organizationId]) + @@index([organizationName]) + @@index([platformId, status, requestedAt]) + @@index([instructorPlatformId, status, requestedAt]) + @@index([userId, status]) + @@map("platform_control_queue_entries") +} + +model CourseStudentProgress { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + userId String @map("user_id") @db.Uuid + completedWeeks Int @default(0) @map("completed_weeks") + totalWeeks Int @default(0) @map("total_weeks") + learningHours Int @default(0) @map("learning_hours") + commitCount Int @default(0) @map("commit_count") + averageScore Int @default(0) @map("average_score") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([courseId, userId]) + @@index([userId]) + @@map("course_student_progress") +} + +model CourseStudentWeekProgress { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + userId String @map("user_id") @db.Uuid + week Int + submittedAt DateTime? @map("submitted_at") @db.Timestamptz(3) + commitCount Int @default(0) @map("commit_count") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([courseId, userId, week]) + @@index([userId]) + @@map("course_student_week_progress") +} + +model CourseWeek { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + title String + tags String[] + progress Int @default(0) + state String + duration String + github String + overview String + deliverable String + requiresSourceSubmission Boolean @default(true) @map("requires_source_submission") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + topics CourseWeekTopic[] + practices CourseWeekPractice[] + references CourseWeekReference[] + modules CourseWeekModule[] + chatThreads ChatThread[] + submissionScores SubmissionScore[] + submissionTotalScores SubmissionTotalScore[] + + @@unique([courseId, week]) + @@index([courseId]) + @@map("course_weeks") +} + +model CourseWeekModule { + id String @id @default(uuid()) @db.Uuid + weekId String @map("week_id") @db.Uuid + sortOrder Int @map("sort_order") + category String + name String + folderName String @map("folder_name") + templateRepositoryUrl String? @map("template_repository_url") + sourceType String @default("GIT") @map("source_type") + prebuiltArtifacts Json @default("[]") @map("prebuilt_artifacts") + dockerImage String? @map("docker_image") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + studioRepositories StudioRepository[] + submissionScores SubmissionScore[] + + @@unique([weekId, folderName]) + @@unique([weekId, sortOrder]) + @@index([weekId]) + @@map("course_week_modules") +} + +model StudioRepository { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + weekId String @map("week_id") @db.Uuid + moduleId String @map("module_id") @db.Uuid + repoOwner String @map("repo_owner") + repoName String @map("repo_name") + repositoryUrl String @map("repository_url") + templateRepositoryUrl String @map("template_repository_url") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + module CourseWeekModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) + + @@unique([userId, moduleId]) + @@index([weekId]) + @@index([repoOwner, repoName]) + @@map("studio_repositories") +} + +model SubmissionScore { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + weekId String @map("week_id") @db.Uuid + moduleId String @map("module_id") @db.Uuid + studentId String @map("student_id") @db.Uuid + score Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + module CourseWeekModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) + student User @relation(fields: [studentId], references: [id], onDelete: Cascade) + + @@unique([studentId, moduleId]) + @@index([courseId]) + @@index([weekId]) + @@index([moduleId]) + @@map("submission_scores") +} + +model SubmissionTotalScore { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + weekId String @map("week_id") @db.Uuid + studentId String @map("student_id") @db.Uuid + score Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + student User @relation(fields: [studentId], references: [id], onDelete: Cascade) + + @@unique([studentId, weekId]) + @@index([courseId]) + @@index([weekId]) + @@map("submission_total_scores") +} + +model CourseWeekTopic { + id String @id @default(uuid()) @db.Uuid + weekId String @map("week_id") @db.Uuid + title String + detail String + sortOrder Int @map("sort_order") + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + + @@unique([weekId, sortOrder]) + @@index([weekId]) + @@map("course_week_topics") +} + +model CourseWeekPractice { + id String @id @default(uuid()) @db.Uuid + weekId String @map("week_id") @db.Uuid + title String + description String + sortOrder Int @map("sort_order") + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + + @@unique([weekId, sortOrder]) + @@index([weekId]) + @@map("course_week_practices") +} + +model CourseWeekReference { + id String @id @default(uuid()) @db.Uuid + weekId String @map("week_id") @db.Uuid + kind String @default("REFERENCE") + title String + originalFileName String @map("original_file_name") + storedFileName String @map("stored_file_name") + contentType String? @map("content_type") + fileSizeBytes Int @default(0) @map("file_size_bytes") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade) + + @@unique([weekId, storedFileName]) + @@index([weekId]) + @@index([weekId, kind]) + @@map("course_week_references") +} + +enum ChatThreadType { + AI + INSTRUCTOR + MIXED +} + +enum ChatThreadStatus { + OPEN + WAITING_INSTRUCTOR + RESOLVED + ARCHIVED +} + +enum ChatSenderType { + STUDENT + AI + INSTRUCTOR + SYSTEM +} + +enum ChatContentFormat { + TEXT + MARKDOWN +} + +enum ChatMessageStatus { + ACTIVE + CANCELED +} + +enum AiChatRunStatus { + SUCCEEDED + FAILED +} + +model ChatThread { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + weekId String? @map("week_id") @db.Uuid + studentId String @map("student_id") @db.Uuid + assignedInstructorId String? @map("assigned_instructor_id") @db.Uuid + type ChatThreadType + status ChatThreadStatus @default(OPEN) + title String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + lastMessageAt DateTime @default(now()) @map("last_message_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + week CourseWeek? @relation(fields: [weekId], references: [id], onDelete: SetNull) + student User @relation("ChatThreadStudent", fields: [studentId], references: [id], onDelete: Cascade) + assignedInstructor User? @relation("ChatThreadInstructor", fields: [assignedInstructorId], references: [id], onDelete: SetNull) + messages ChatMessage[] + + @@index([courseId, type, lastMessageAt]) + @@index([studentId, type, lastMessageAt]) + @@index([weekId]) + @@index([assignedInstructorId]) + @@map("chat_threads") +} + +model ChatMessage { + id String @id @default(uuid()) @db.Uuid + threadId String @map("thread_id") @db.Uuid + senderType ChatSenderType @map("sender_type") + senderUserId String? @map("sender_user_id") @db.Uuid + content String + contentFormat ChatContentFormat @default(TEXT) @map("content_format") + status ChatMessageStatus @default(ACTIVE) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + senderUser User? @relation(fields: [senderUserId], references: [id], onDelete: SetNull) + aiRun AiChatRun? + + @@index([threadId, createdAt]) + @@index([threadId, status, createdAt]) + @@index([senderUserId]) + @@map("chat_messages") +} + +model AiChatRun { + id String @id @default(uuid()) @db.Uuid + messageId String @unique @map("message_id") @db.Uuid + model String + promptTokens Int? @map("prompt_tokens") + completionTokens Int? @map("completion_tokens") + totalTokens Int? @map("total_tokens") + latencyMs Int? @map("latency_ms") + status AiChatRunStatus + error String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@index([status, createdAt]) + @@map("ai_chat_runs") +} diff --git a/src/app.controller.ts b/src/app.controller.ts new file mode 100644 index 0000000..3be06cd --- /dev/null +++ b/src/app.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get } from '@nestjs/common'; + +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getRoot() { + return this.appService.getRoot(); + } + + @Get('health') + getHealth() { + return this.appService.getHealth(); + } + + @Get('health/db') + getDatabaseHealth() { + return this.appService.getDatabaseHealth(); + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..e69d13c --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { ChatModule } from './chat/chat.module'; +import { CurriculumModule } from './curriculum/curriculum.module'; +import { InstructorModule } from './instructor/instructor.module'; +import { PlatformControlModule } from './platform-control/platform-control.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { StudioModule } from './studio/studio.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + AuthModule, + ChatModule, + CurriculumModule, + InstructorModule, + PlatformControlModule, + PrismaModule, + StudioModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/src/app.service.spec.ts b/src/app.service.spec.ts new file mode 100644 index 0000000..f7af5ad --- /dev/null +++ b/src/app.service.spec.ts @@ -0,0 +1,35 @@ +import { AppService } from './app.service'; +import { PrismaService } from './prisma/prisma.service'; + +describe('AppService', () => { + let service: AppService; + let prisma: Pick; + + beforeEach(() => { + prisma = { + $queryRaw: jest.fn().mockResolvedValue([{ '?column?': 1 }]), + } as Pick; + service = new AppService(prisma as PrismaService); + }); + + it('returns the root service status', () => { + expect(service.getRoot()).toEqual({ + service: 'lms-be', + status: 'ok', + }); + }); + + it('returns a health payload', () => { + expect(service.getHealth()).toMatchObject({ + status: 'ok', + }); + }); + + it('checks database health', async () => { + await expect(service.getDatabaseHealth()).resolves.toMatchObject({ + status: 'ok', + provider: 'postgresql', + }); + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); +}); diff --git a/src/app.service.ts b/src/app.service.ts new file mode 100644 index 0000000..9ccc8dd --- /dev/null +++ b/src/app.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from './prisma/prisma.service'; + +@Injectable() +export class AppService { + constructor(private readonly prisma: PrismaService) {} + + getRoot() { + return { + service: 'lms-be', + status: 'ok', + }; + } + + getHealth() { + return { + status: 'ok', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }; + } + + async getDatabaseHealth() { + await this.prisma.$queryRaw`SELECT 1`; + + return { + status: 'ok', + provider: 'postgresql', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..7a22e3e --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import { Request } from 'express'; + +import { AuthService } from './auth.service'; +import { HandoffExchangeDto } from './dto/handoff-exchange.dto'; + +type RefreshBody = { + refreshToken?: string; +}; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('handoff/exchange') + exchangeHandoff(@Body() handoffExchangeDto: HandoffExchangeDto) { + return this.authService.exchangeHandoff( + handoffExchangeDto.handoffToken, + handoffExchangeDto.audienceApp, + ); + } + + @Post('refresh') + refresh(@Body() body: RefreshBody) { + return this.authService.refresh(body.refreshToken ?? ''); + } + + @Get('me') + me(@Req() request: Request) { + return this.authService.me(this.getBearerToken(request)); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..2a40dda --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +@Module({ + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..b11eab2 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,162 @@ +import { + BadGatewayException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type AuthResponseUser = { + id: string; + email: string; + name: string | null; + role: string; + roles?: string[]; + primaryAffiliation?: { + id: string; + organizationId: string; + organizationName: string; + organizationType: string; + department: string | null; + position: string | null; + } | null; + currentLicense?: { + id: string; + code: string; + name: string; + startsAt: string; + expiresAt: string | null; + } | null; +}; + +export type CreateManagedAuthUserInput = { + email: string; + password: string; + name: string; + organizationId: string; + department?: string | null; + position?: string | null; + licenseCode?: 'FREE' | 'EDU' | 'PRO'; +}; + +type AuthTokenResponse = { + accessToken: string; + accessTokenExpiresIn?: number; + refreshToken?: string; + refreshTokenExpiresIn?: number; + user: AuthResponseUser; +}; + +@Injectable() +export class AuthService { + private readonly authServerUrl: string; + + constructor(private readonly configService: ConfigService) { + this.authServerUrl = this.configService + .get('AUTH_SERVER_URL', 'http://localhost:4100') + .replace(/\/$/, ''); + } + + async me(accessToken: string | undefined) { + const authResponse = await this.request<{ user: AuthResponseUser }>( + '/auth/me', + { + method: 'GET', + accessToken, + unauthorizedMessage: 'Invalid bearer token', + }, + ); + + return authResponse; + } + + async exchangeHandoff(handoffToken: string, audienceApp?: string) { + const authResponse = await this.request( + '/auth/handoff/exchange', + { + method: 'POST', + body: { handoffToken, audienceApp }, + unauthorizedMessage: 'Invalid handoff token', + }, + ); + + return this.frontendTokenResponse(authResponse); + } + + async refresh(refreshToken: string) { + const authResponse = await this.request('/auth/refresh', { + method: 'POST', + body: { refreshToken }, + unauthorizedMessage: 'Invalid refresh token', + }); + + return this.frontendTokenResponse(authResponse); + } + + async createManagedUser( + accessToken: string, + input: CreateManagedAuthUserInput, + ) { + return this.request<{ user: AuthResponseUser }>('/auth/admin/users', { + method: 'POST', + body: input, + accessToken, + unauthorizedMessage: 'Invalid bearer token', + }); + } + + private async frontendTokenResponse(authResponse: AuthTokenResponse) { + return { + accessToken: authResponse.accessToken, + accessTokenExpiresIn: authResponse.accessTokenExpiresIn, + refreshToken: authResponse.refreshToken, + refreshTokenExpiresIn: authResponse.refreshTokenExpiresIn, + tokenType: 'Bearer', + user: authResponse.user, + }; + } + + private async request( + path: string, + options: { + method: 'GET' | 'POST'; + body?: unknown; + accessToken?: string; + unauthorizedMessage?: string; + }, + ): Promise { + const headers: Record = {}; + if (options.body) headers['content-type'] = 'application/json'; + if (options.accessToken) { + headers.authorization = `Bearer ${options.accessToken}`; + } + + let authResponse: globalThis.Response; + try { + authResponse = await fetch(`${this.authServerUrl}${path}`, { + method: options.method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch { + throw new BadGatewayException('Auth server is unavailable'); + } + + const payload: unknown = await authResponse.json().catch((): null => null); + + if (authResponse.status === 401) { + throw new UnauthorizedException( + options.unauthorizedMessage ?? 'Unauthorized', + ); + } + + if (!authResponse.ok) { + throw new BadGatewayException({ + message: 'Auth server request failed', + statusCode: authResponse.status, + error: payload, + }); + } + + return payload as T; + } +} diff --git a/src/auth/dto/handoff-exchange.dto.ts b/src/auth/dto/handoff-exchange.dto.ts new file mode 100644 index 0000000..75aaf5d --- /dev/null +++ b/src/auth/dto/handoff-exchange.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class HandoffExchangeDto { + @IsString() + handoffToken: string; + + @IsOptional() + @IsString() + audienceApp?: string; +} diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts new file mode 100644 index 0000000..6413a7e --- /dev/null +++ b/src/chat/chat.controller.ts @@ -0,0 +1,109 @@ +import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common'; +import { Request } from 'express'; + +import { ChatService } from './chat.service'; +import { CreateAiResponseDto } from './dto/create-ai-response.dto'; +import { CreateChatMessageDto } from './dto/create-chat-message.dto'; +import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto'; +import { ListChatThreadsDto } from './dto/list-chat-threads.dto'; + +@Controller('chat') +export class ChatController { + constructor(private readonly chatService: ChatService) {} + + @Get('threads') + listThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) { + return this.chatService.listThreads(this.getBearerToken(request), query); + } + + @Delete('threads') + clearThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) { + return this.chatService.clearStudentThreads(this.getBearerToken(request), query); + } + + @Post('messages') + createMessage( + @Req() request: Request, + @Body() createChatMessageDto: CreateChatMessageDto, + ) { + return this.chatService.createStudentMessage( + this.getBearerToken(request), + createChatMessageDto, + ); + } + + @Post('ai/respond') + createAiResponse( + @Req() request: Request, + @Body() createAiResponseDto: CreateAiResponseDto, + ) { + return this.chatService.createAiResponse( + this.getBearerToken(request), + createAiResponseDto, + ); + } + + @Post('ai/messages/:messageId/cancel') + cancelAiMessage( + @Req() request: Request, + @Param('messageId') messageId: string, + @Body() body: { content?: string }, + ) { + return this.chatService.cancelAiMessage( + this.getBearerToken(request), + messageId, + body.content, + ); + } + + @Post('ai/cancel-active') + cancelActiveAiResponse( + @Req() request: Request, + @Body() body: { week?: number }, + ) { + return this.chatService.cancelActiveAiResponse( + this.getBearerToken(request), + body.week, + ); + } + + @Get('instructor/threads') + listInstructorThreads( + @Req() request: Request, + @Query() query: ListChatThreadsDto, + ) { + return this.chatService.listInstructorThreads( + this.getBearerToken(request), + query, + ); + } + + @Get('instructor/threads/:threadId') + getInstructorThread( + @Req() request: Request, + @Param('threadId') threadId: string, + ) { + return this.chatService.getInstructorThread( + this.getBearerToken(request), + threadId, + ); + } + + @Post('instructor/threads/:threadId/messages') + createInstructorMessage( + @Req() request: Request, + @Param('threadId') threadId: string, + @Body() createInstructorChatMessageDto: CreateInstructorChatMessageDto, + ) { + return this.chatService.createInstructorMessage( + this.getBearerToken(request), + threadId, + createInstructorChatMessageDto, + ); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts new file mode 100644 index 0000000..ab902d5 --- /dev/null +++ b/src/chat/chat.gateway.ts @@ -0,0 +1,220 @@ +import { Injectable } from '@nestjs/common'; +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { AuthService, type AuthResponseUser } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +type ChatSocketUser = { + id: string; + email: string; + role: string; + roles?: string[]; +}; + +export type ChatNotificationPayload = { + type: 'chat.message.created'; + threadId: string; + messageId: string; + senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'; + preview: string; + createdAt: string; +}; + +export type UserNotificationPayload = + | ChatNotificationPayload + | { + type: 'platform.control.warning'; + sessionId: string; + platformId: string | null; + instructorPlatformId: string | null; + endsAt: string; + warningAt: string; + } + | { + type: 'platform.control.granted'; + sessionId: string; + startedAt: string; + endsAt: string; + warningAt: string; + } + | { + type: 'platform.control.terminated'; + sessionId: string; + platformId: string | null; + instructorPlatformId: string | null; + reason: string; + message: string; + endedAt: string; + }; + +@Injectable() +@WebSocketGateway({ + cors: { + origin: true, + credentials: true, + }, +}) +export class ChatGateway implements OnGatewayConnection { + @WebSocketServer() + private server!: Server; + + constructor( + private readonly authService: AuthService, + private readonly prisma: PrismaService, + ) {} + + async handleConnection(client: Socket) { + const accessToken = this.getAccessToken(client); + if (!accessToken) { + client.disconnect(true); + return; + } + + try { + const { user } = await this.authService.me(accessToken); + const lmsUser = await this.prisma.user.upsert({ + where: { email: user.email }, + update: this.toUserSyncData(user), + create: { + email: user.email, + ...this.toUserSyncData(user), + }, + }); + const socketUser: ChatSocketUser = { + id: lmsUser.id, + email: user.email, + role: user.role, + roles: user.roles, + }; + + client.data.user = socketUser; + await client.join(this.userRoom(lmsUser.id)); + const organizationName = user.primaryAffiliation?.organizationName?.trim(); + if (organizationName) { + await client.join(this.organizationRoom(organizationName)); + } + + if (this.isInstructor(socketUser)) { + const activeCourses = await this.prisma.course.findMany({ + where: { isActive: true }, + select: { id: true }, + }); + await Promise.all( + activeCourses.map((course) => + client.join(this.courseInstructorsRoom(course.id)), + ), + ); + } + } catch { + client.disconnect(true); + } + } + + @SubscribeMessage('chat.thread.join') + async joinThread( + @ConnectedSocket() client: Socket, + @MessageBody() payload: { threadId?: string }, + ) { + const threadId = payload.threadId; + if (!threadId || !client.data.user) return { ok: false }; + + await client.join(this.threadRoom(threadId)); + return { ok: true }; + } + + @SubscribeMessage('chat.thread.leave') + async leaveThread( + @ConnectedSocket() client: Socket, + @MessageBody() payload: { threadId?: string }, + ) { + const threadId = payload.threadId; + if (!threadId) return { ok: false }; + + await client.leave(this.threadRoom(threadId)); + return { ok: true }; + } + + notifyUser(userId: string, payload: UserNotificationPayload) { + this.server.to(this.userRoom(userId)).emit(payload.type, payload); + } + + notifyCourseInstructors(courseId: string, payload: ChatNotificationPayload) { + this.server + .to(this.courseInstructorsRoom(courseId)) + .emit(payload.type, payload); + } + + notifyThread(threadId: string, payload: ChatNotificationPayload) { + this.server.to(this.threadRoom(threadId)).emit(payload.type, payload); + } + + notifyOrganization(organizationName: string, eventName: string, payload: unknown) { + this.server.to(this.organizationRoom(organizationName)).emit(eventName, payload); + } + + async notifyGroupMembers(groupId: string, eventName: string, payload: unknown) { + const members = await this.prisma.studentGroupMember.findMany({ + where: { groupId }, + select: { userId: true }, + }); + + members.forEach((member) => { + this.server.to(this.userRoom(member.userId)).emit(eventName, payload); + }); + } + + private getAccessToken(client: Socket) { + const authToken = client.handshake.auth?.token; + if (typeof authToken === 'string' && authToken) return authToken; + + const authorization = client.handshake.headers.authorization; + if (!authorization) return null; + + const [type, token] = authorization.split(' '); + return type === 'Bearer' ? token : null; + } + + private isInstructor(user: ChatSocketUser) { + const roles = new Set( + [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()), + ); + return ['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role)); + } + + private toUserSyncData(user: AuthResponseUser) { + return { + authUserId: user.id, + name: user.name, + authRole: user.role, + primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null, + primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null, + primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null, + department: user.primaryAffiliation?.department ?? null, + position: user.primaryAffiliation?.position ?? null, + }; + } + + private userRoom(userId: string) { + return `user:${userId}`; + } + + private courseInstructorsRoom(courseId: string) { + return `course:${courseId}:instructors`; + } + + private threadRoom(threadId: string) { + return `thread:${threadId}`; + } + + private organizationRoom(organizationName: string) { + return `organization:${organizationName}`; + } +} diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts new file mode 100644 index 0000000..b611de1 --- /dev/null +++ b/src/chat/chat.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ChatController } from './chat.controller'; +import { ChatGateway } from './chat.gateway'; +import { ChatService } from './chat.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [ChatController], + providers: [ChatGateway, ChatService], + exports: [ChatGateway], +}) +export class ChatModule {} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts new file mode 100644 index 0000000..583dc5d --- /dev/null +++ b/src/chat/chat.service.ts @@ -0,0 +1,989 @@ +import { + BadGatewayException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + AiChatRunStatus, + ChatMessageStatus, + ChatSenderType, + ChatThreadStatus, + ChatThreadType, + Prisma, +} from '@prisma/client'; + +import { AuthService, type AuthResponseUser } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ChatGateway, type ChatNotificationPayload } from './chat.gateway'; +import { CreateAiResponseDto } from './dto/create-ai-response.dto'; +import { CreateChatMessageDto } from './dto/create-chat-message.dto'; +import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto'; +import { ListChatThreadsDto } from './dto/list-chat-threads.dto'; + +type OpenAiChatCompletionResponse = { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + error?: { + message?: string; + }; +}; + +type FutureDriveLlmChatResponse = { + answer?: string; + sources?: Array<{ + title?: string; + snippet?: string; + score?: number; + }>; + topic?: string; + follow_ups?: string[]; + request_id?: string; + error?: { + message?: string; + }; + message?: string; +}; + +const STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20; +const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6; + +@Injectable() +export class ChatService { + private readonly authServerUrl: string; + + constructor( + private readonly authService: AuthService, + private readonly chatGateway: ChatGateway, + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) { + this.authServerUrl = this.configService + .get('AUTH_SERVER_URL', 'http://localhost:4100') + .replace(/\/$/, ''); + } + + async listThreads(accessToken: string | undefined, query: ListChatThreadsDto) { + const student = await this.getLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = query.week + ? await this.getCourseWeek(course.id, query.week) + : null; + + const threads = await this.prisma.chatThread.findMany({ + where: { + courseId: course.id, + studentId: student.id, + weekId: week?.id, + type: query.mode ? this.toThreadType(query.mode) : undefined, + status: { not: ChatThreadStatus.ARCHIVED }, + }, + orderBy: { lastMessageAt: 'desc' }, + include: { + messages: { + where: { deletedAt: null }, + orderBy: { createdAt: 'desc' }, + take: STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }, + week: { + select: { week: true, title: true }, + }, + }, + }); + + return { + threads: threads.map((thread) => this.serializeThread(thread)), + }; + } + + async clearStudentThreads(accessToken: string | undefined, query: ListChatThreadsDto) { + const student = await this.getLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = query.week + ? await this.getCourseWeek(course.id, query.week) + : null; + + const result = await this.prisma.chatThread.updateMany({ + where: { + courseId: course.id, + studentId: student.id, + weekId: week?.id, + type: query.mode ? this.toThreadType(query.mode) : undefined, + status: { not: ChatThreadStatus.ARCHIVED }, + }, + data: { + status: ChatThreadStatus.ARCHIVED, + }, + }); + + return { deletedCount: result.count }; + } + + async createStudentMessage( + accessToken: string | undefined, + dto: CreateChatMessageDto, + ) { + const student = await this.getLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null; + const thread = await this.getOrCreateThread({ + courseId: course.id, + weekId: week?.id ?? null, + studentId: student.id, + type: this.toThreadType(dto.mode), + firstMessage: dto.content, + status: + dto.mode === 'teacher' + ? ChatThreadStatus.WAITING_INSTRUCTOR + : ChatThreadStatus.OPEN, + }); + + const message = await this.prisma.$transaction(async (tx) => { + const createdMessage = await tx.chatMessage.create({ + data: { + threadId: thread.id, + senderType: ChatSenderType.STUDENT, + senderUserId: student.id, + content: dto.content, + }, + }); + await tx.chatThread.update({ + where: { id: thread.id }, + data: { + status: + dto.mode === 'teacher' + ? ChatThreadStatus.WAITING_INSTRUCTOR + : thread.status, + lastMessageAt: createdMessage.createdAt, + }, + }); + return createdMessage; + }); + const payload = this.toNotificationPayload(thread.id, message); + this.chatGateway.notifyThread(thread.id, payload); + this.chatGateway.notifyUser(student.id, payload); + if (dto.mode === 'teacher') { + this.chatGateway.notifyCourseInstructors(course.id, payload); + } + + return { + threadId: thread.id, + message: this.serializeMessage(message), + }; + } + + async createAiResponse( + accessToken: string | undefined, + dto: CreateAiResponseDto, + ) { + const student = await this.getLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null; + const thread = await this.getOrCreateThread({ + courseId: course.id, + weekId: week?.id ?? null, + studentId: student.id, + type: ChatThreadType.AI, + firstMessage: dto.content, + status: ChatThreadStatus.OPEN, + }); + + const studentMessage = await this.prisma.$transaction(async (tx) => { + const createdMessage = await tx.chatMessage.create({ + data: { + threadId: thread.id, + senderType: ChatSenderType.STUDENT, + senderUserId: student.id, + content: dto.content, + }, + }); + await tx.chatThread.update({ + where: { id: thread.id }, + data: { lastMessageAt: createdMessage.createdAt }, + }); + return createdMessage; + }); + const studentPayload = this.toNotificationPayload(thread.id, studentMessage); + this.chatGateway.notifyThread(thread.id, studentPayload); + this.chatGateway.notifyUser(student.id, studentPayload); + + const recentMessages = await this.prisma.chatMessage.findMany({ + where: { + threadId: thread.id, + deletedAt: null, + senderType: { in: [ChatSenderType.STUDENT, ChatSenderType.AI] }, + status: ChatMessageStatus.ACTIVE, + }, + orderBy: { createdAt: 'desc' }, + take: AI_CHAT_CONTEXT_MESSAGE_LIMIT, + }); + + const startedAt = Date.now(); + const aiProvider = this.configService.get('AI_PROVIDER', 'openai').trim().toLowerCase(); + const model = aiProvider === 'futuredrive' + ? this.configService.get('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm') + : this.configService.get('OPENAI_CHAT_MODEL', 'gpt-4.1-nano'); + + try { + const aiResponse = await this.requestAiChatCompletion( + model, + recentMessages.reverse().map((message) => ({ + role: + message.senderType === ChatSenderType.STUDENT + ? ('user' as const) + : ('assistant' as const), + content: message.content, + })), + ); + const currentStudentMessage = await this.prisma.chatMessage.findUnique({ + where: { id: studentMessage.id }, + select: { status: true }, + }); + if (currentStudentMessage?.status === ChatMessageStatus.CANCELED) { + return { + threadId: thread.id, + message: this.serializeMessage({ + ...studentMessage, + status: ChatMessageStatus.CANCELED, + canceledAt: new Date(), + aiRun: null, + senderUser: null, + }), + content: '', + canceled: true, + }; + } + + const assistantMessage = await this.prisma.$transaction(async (tx) => { + const message = await tx.chatMessage.create({ + data: { + threadId: thread.id, + senderType: ChatSenderType.AI, + content: aiResponse.content, + aiRun: { + create: { + model, + promptTokens: aiResponse.usage.promptTokens, + completionTokens: aiResponse.usage.completionTokens, + totalTokens: aiResponse.usage.totalTokens, + latencyMs: Date.now() - startedAt, + status: AiChatRunStatus.SUCCEEDED, + }, + }, + }, + include: { aiRun: true }, + }); + await tx.chatThread.update({ + where: { id: thread.id }, + data: { lastMessageAt: message.createdAt }, + }); + return message; + }); + const payload = this.toNotificationPayload(thread.id, assistantMessage); + this.chatGateway.notifyThread(thread.id, payload); + this.chatGateway.notifyUser(student.id, payload); + + return { + threadId: thread.id, + message: this.serializeMessage(assistantMessage), + content: assistantMessage.content, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'AI request failed'; + const failedMessage = await this.prisma.chatMessage.create({ + data: { + threadId: thread.id, + senderType: ChatSenderType.SYSTEM, + content: message, + aiRun: { + create: { + model, + latencyMs: Date.now() - startedAt, + status: AiChatRunStatus.FAILED, + error: message, + }, + }, + }, + }); + await this.prisma.chatThread.update({ + where: { id: thread.id }, + data: { lastMessageAt: failedMessage.createdAt }, + }); + const payload = this.toNotificationPayload(thread.id, failedMessage); + this.chatGateway.notifyThread(thread.id, payload); + this.chatGateway.notifyUser(student.id, payload); + throw new BadGatewayException(message); + } + } + + async cancelAiMessage( + accessToken: string | undefined, + messageId: string, + content?: string, + ) { + const student = await this.getLmsUser(accessToken); + const message = await this.prisma.chatMessage.findFirst({ + where: { + id: messageId, + senderType: ChatSenderType.AI, + thread: { + studentId: student.id, + type: ChatThreadType.AI, + status: { not: ChatThreadStatus.ARCHIVED }, + }, + }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }); + + if (!message) { + throw new NotFoundException('AI 메시지를 찾을 수 없습니다.'); + } + + const nextContent = typeof content === 'string' && content.trim() + ? content.trim() + : message.content; + const canceledMessage = await this.prisma.chatMessage.update({ + where: { id: message.id }, + data: { + content: nextContent, + status: ChatMessageStatus.CANCELED, + canceledAt: new Date(), + }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }); + + return { + message: this.serializeMessage(canceledMessage), + }; + } + + async cancelActiveAiResponse( + accessToken: string | undefined, + weekNumber?: number, + ) { + const student = await this.getLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = weekNumber ? await this.getCourseWeek(course.id, weekNumber) : null; + const message = await this.prisma.chatMessage.findFirst({ + where: { + senderType: ChatSenderType.STUDENT, + status: ChatMessageStatus.ACTIVE, + thread: { + courseId: course.id, + studentId: student.id, + weekId: week?.id ?? null, + type: ChatThreadType.AI, + status: { not: ChatThreadStatus.ARCHIVED }, + }, + }, + orderBy: { createdAt: 'desc' }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }); + + if (!message) { + return { message: null }; + } + + const canceledMessage = await this.prisma.chatMessage.update({ + where: { id: message.id }, + data: { + status: ChatMessageStatus.CANCELED, + canceledAt: new Date(), + }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }); + + return { + message: this.serializeMessage(canceledMessage), + }; + } + + async listInstructorThreads( + accessToken: string | undefined, + query: ListChatThreadsDto, + ) { + const instructor = await this.getInstructorLmsUser(accessToken); + const course = await this.getActiveCourse(); + const week = query.week + ? await this.getCourseWeek(course.id, query.week) + : null; + + const threads = await this.prisma.chatThread.findMany({ + where: { + courseId: course.id, + weekId: week?.id, + type: ChatThreadType.INSTRUCTOR, + status: { not: ChatThreadStatus.ARCHIVED }, + ...this.instructorThreadVisibilityWhere(instructor), + }, + orderBy: { lastMessageAt: 'desc' }, + include: { + student: { + select: { + id: true, + authUserId: true, + email: true, + name: true, + }, + }, + assignedInstructor: { + select: { + id: true, + email: true, + name: true, + }, + }, + messages: { + where: { deletedAt: null }, + orderBy: { createdAt: 'asc' }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }, + week: { + select: { week: true, title: true }, + }, + }, + }); + + return { + threads: threads.map((thread) => this.serializeInstructorThread(thread)), + }; + } + + async getInstructorThread(accessToken: string | undefined, threadId: string) { + const instructor = await this.getInstructorLmsUser(accessToken); + const thread = await this.getInstructorThreadById(threadId, instructor); + + return { + thread: this.serializeInstructorThread(thread), + }; + } + + async createInstructorMessage( + accessToken: string | undefined, + threadId: string, + dto: CreateInstructorChatMessageDto, + ) { + const instructor = await this.getInstructorLmsUser(accessToken); + const thread = await this.getInstructorThreadById(threadId, instructor); + + const message = await this.prisma.$transaction(async (tx) => { + const createdMessage = await tx.chatMessage.create({ + data: { + threadId, + senderType: ChatSenderType.INSTRUCTOR, + senderUserId: instructor.id, + content: dto.content, + }, + }); + + await tx.chatThread.update({ + where: { id: threadId }, + data: { + assignedInstructorId: instructor.id, + status: ChatThreadStatus.OPEN, + lastMessageAt: createdMessage.createdAt, + }, + }); + + return createdMessage; + }); + + const updatedThread = await this.getInstructorThreadById(threadId, instructor); + const payload = this.toNotificationPayload(threadId, message); + this.chatGateway.notifyThread(threadId, payload); + this.chatGateway.notifyUser(thread.student.id, payload); + this.chatGateway.notifyCourseInstructors(thread.courseId, payload); + + return { + thread: this.serializeInstructorThread(updatedThread), + message: this.serializeMessage(message), + }; + } + + private async getLmsUser(accessToken: string | undefined) { + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + + const { user } = await this.authService.me(accessToken); + + return this.prisma.user.upsert({ + where: { email: user.email }, + update: this.toUserSyncData(user), + create: { + email: user.email, + ...this.toUserSyncData(user), + }, + }); + } + + private async getInstructorLmsUser(accessToken: string | undefined) { + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + + const { user } = await this.authService.me(accessToken); + const roles = new Set( + [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()), + ); + + if (!['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role))) { + throw new ForbiddenException('Instructor role required'); + } + + return this.prisma.user.upsert({ + where: { email: user.email }, + update: this.toUserSyncData(user), + create: { + email: user.email, + ...this.toUserSyncData(user), + }, + }); + } + + private toUserSyncData(user: AuthResponseUser) { + return { + authUserId: user.id, + name: user.name, + authRole: user.role, + primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null, + primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null, + primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null, + department: user.primaryAffiliation?.department ?? null, + position: user.primaryAffiliation?.position ?? null, + }; + } + + private instructorThreadVisibilityWhere( + instructor: Prisma.UserGetPayload>, + ): Prisma.ChatThreadWhereInput { + const studentOrganizationFilters = [ + instructor.primaryOrganizationId + ? { + primaryOrganizationId: instructor.primaryOrganizationId, + } + : undefined, + instructor.primaryOrganizationName + ? { + progress: { + some: { + course: { + organizationName: instructor.primaryOrganizationName, + }, + }, + }, + } + : undefined, + ].filter(Boolean) as Prisma.UserWhereInput[]; + + return { + OR: [ + { assignedInstructorId: null }, + { assignedInstructorId: instructor.id }, + ], + student: studentOrganizationFilters.length + ? { + OR: studentOrganizationFilters, + } + : { + id: '__NO_ORGANIZATION_SCOPE__', + }, + }; + } + + private async getActiveCourse() { + const course = await this.prisma.course.findFirst({ + where: { slug: 'futuredrive-autonomy-basic', isActive: true }, + }); + if (!course) throw new NotFoundException('Curriculum course not found'); + return course; + } + + private async getCourseWeek(courseId: string, weekNumber: number) { + const week = await this.prisma.courseWeek.findUnique({ + where: { courseId_week: { courseId, week: weekNumber } }, + }); + if (!week) throw new NotFoundException('Course week not found'); + return week; + } + + private async getOrCreateThread(input: { + courseId: string; + weekId: string | null; + studentId: string; + type: ChatThreadType; + firstMessage: string; + status: ChatThreadStatus; + }) { + const thread = await this.prisma.chatThread.findFirst({ + where: { + courseId: input.courseId, + weekId: input.weekId, + studentId: input.studentId, + type: input.type, + status: { not: ChatThreadStatus.ARCHIVED }, + }, + orderBy: { lastMessageAt: 'desc' }, + }); + + if (thread) return thread; + + return this.prisma.chatThread.create({ + data: { + courseId: input.courseId, + weekId: input.weekId, + studentId: input.studentId, + type: input.type, + status: input.status, + title: input.firstMessage.slice(0, 80), + }, + }); + } + + private async getInstructorThreadById( + threadId: string, + instructor: Prisma.UserGetPayload>, + ) { + const thread = await this.prisma.chatThread.findFirst({ + where: { + id: threadId, + type: ChatThreadType.INSTRUCTOR, + status: { not: ChatThreadStatus.ARCHIVED }, + ...this.instructorThreadVisibilityWhere(instructor), + }, + include: { + student: { + select: { + id: true, + authUserId: true, + email: true, + name: true, + }, + }, + assignedInstructor: { + select: { + id: true, + email: true, + name: true, + }, + }, + messages: { + where: { deletedAt: null }, + orderBy: { createdAt: 'asc' }, + include: { + aiRun: true, + senderUser: { + select: { + authUserId: true, + email: true, + name: true, + }, + }, + }, + }, + week: { + select: { week: true, title: true }, + }, + }, + }); + + if (!thread) throw new NotFoundException('Instructor chat thread not found'); + + return thread; + } + + private async requestAiChatCompletion( + model: string, + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + ) { + const provider = this.configService.get('AI_PROVIDER', 'openai').trim().toLowerCase(); + if (provider === 'futuredrive') { + return this.requestFutureDriveLlmChatCompletion(messages); + } + return this.requestOpenAiChatCompletion(model, messages); + } + + private async requestOpenAiChatCompletion( + model: string, + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + ) { + const apiKey = this.configService.get('OPENAI_API_KEY'); + if (!apiKey) { + throw new Error( + 'OPENAI_API_KEY가 설정되어 있지 않습니다. lms-be .env에 OPENAI_API_KEY=... 형식으로 추가하세요.', + ); + } + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + temperature: 0.3, + messages: [ + { + role: 'system', + content: + '당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.', + }, + ...messages, + ], + }), + }); + const data = (await response.json()) as OpenAiChatCompletionResponse; + + if (!response.ok) { + throw new Error(data.error?.message ?? `OpenAI request failed: ${response.status}`); + } + + const content = data.choices?.[0]?.message?.content?.trim(); + if (!content) throw new Error('OpenAI 응답이 비어 있습니다.'); + + return { + content, + usage: { + promptTokens: data.usage?.prompt_tokens, + completionTokens: data.usage?.completion_tokens, + totalTokens: data.usage?.total_tokens, + }, + }; + } + + private async requestFutureDriveLlmChatCompletion( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + ) { + const apiKey = this.configService.get('FUTUREDRIVE_LLM_API_KEY'); + if (!apiKey) { + throw new Error( + 'FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다. lms-be .env에 FUTUREDRIVE_LLM_API_KEY=... 형식으로 추가하세요.', + ); + } + + const baseUrl = this.configService + .get('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev') + .replace(/\/+$/, ''); + const lang = this.configService.get('FUTUREDRIVE_LLM_LANG', 'ko'); + + const response = await fetch(`${baseUrl}/api/chat/complete`, { + method: 'POST', + headers: { + 'X-API-Key': apiKey, + 'Content-Type': 'application/json', + 'ngrok-skip-browser-warning': '1', + }, + body: JSON.stringify({ + messages, + lang, + }), + }); + const data = (await response.json().catch(() => ({}))) as FutureDriveLlmChatResponse; + + if (!response.ok) { + throw new Error(data.error?.message ?? data.message ?? `FutureDrive LLM request failed: ${response.status}`); + } + + const content = data.answer?.trim(); + if (!content) throw new Error('FutureDrive LLM 응답이 비어 있습니다.'); + + return { + content, + usage: { + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + }, + }; + } + + private toThreadType(mode: 'ai' | 'teacher') { + return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR; + } + + private serializeThread( + thread: Prisma.ChatThreadGetPayload<{ + include: { + messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } }; + week: { select: { week: true; title: true } }; + }; + }>, + ) { + return { + id: thread.id, + type: thread.type, + status: thread.status, + title: thread.title, + week: thread.week, + lastMessageAt: thread.lastMessageAt, + messages: [...thread.messages] + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) + .map((message) => this.serializeMessage(message)), + }; + } + + private serializeInstructorThread( + thread: Prisma.ChatThreadGetPayload<{ + include: { + student: { select: { id: true; authUserId: true; email: true; name: true } }; + assignedInstructor: { select: { id: true; email: true; name: true } }; + messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } }; + week: { select: { week: true; title: true } }; + }; + }>, + ) { + return { + id: thread.id, + type: thread.type, + status: thread.status, + title: thread.title, + student: thread.student, + studentName: thread.student.name ?? thread.student.email, + studentAvatarUrl: this.getStudentAvatarUrl(thread.student), + assignedInstructor: thread.assignedInstructor, + week: thread.week, + lastMessageAt: thread.lastMessageAt, + messages: thread.messages.map((message) => this.serializeMessage(message)), + }; + } + + private serializeMessage( + message: Prisma.ChatMessageGetPayload<{ + include: { + aiRun: true; + senderUser: { select: { authUserId: true; email: true; name: true } }; + }; + }> | { + id: string; + senderType: ChatSenderType; + senderUserId: string | null; + senderUser?: { email: string; name: string | null } | null; + content: string; + contentFormat: string; + status?: ChatMessageStatus; + metadata: Prisma.JsonValue | null; + createdAt: Date; + canceledAt?: Date | null; + deletedAt: Date | null; + }, + ) { + return { + id: message.id, + senderType: message.senderType, + senderUserId: message.senderUserId, + senderName: message.senderUser?.name ?? message.senderUser?.email ?? null, + senderAvatarUrl: this.getUserAvatarUrl(message.senderUser ?? null), + content: message.content, + contentFormat: message.contentFormat, + status: 'status' in message ? message.status : ChatMessageStatus.ACTIVE, + metadata: message.metadata, + createdAt: message.createdAt, + canceledAt: 'canceledAt' in message ? message.canceledAt : null, + }; + } + + private toNotificationPayload( + threadId: string, + message: { + id: string; + senderType: ChatSenderType; + content: string; + createdAt: Date; + }, + ): ChatNotificationPayload { + return { + type: 'chat.message.created', + threadId, + messageId: message.id, + senderType: message.senderType, + preview: message.content.slice(0, 120), + createdAt: message.createdAt.toISOString(), + }; + } + + private getStudentAvatarUrl(student: { + authUserId: string | null; + email: string; + }) { + return this.getUserAvatarUrl(student); + } + + private getUserAvatarUrl(user: { + authUserId?: string | null; + email?: string | null; + } | null) { + if (!user) return null; + + if (user.authUserId) { + return `${this.authServerUrl}/auth/users/${user.authUserId}/avatar`; + } + + if (user.email) { + return `${this.authServerUrl}/auth/users/avatar?email=${encodeURIComponent(user.email)}`; + } + + return null; + } +} diff --git a/src/chat/dto/create-ai-response.dto.ts b/src/chat/dto/create-ai-response.dto.ts new file mode 100644 index 0000000..c305dca --- /dev/null +++ b/src/chat/dto/create-ai-response.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateAiResponseDto { + @IsString() + content: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + week?: number; +} diff --git a/src/chat/dto/create-chat-message.dto.ts b/src/chat/dto/create-chat-message.dto.ts new file mode 100644 index 0000000..05c2906 --- /dev/null +++ b/src/chat/dto/create-chat-message.dto.ts @@ -0,0 +1,15 @@ +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateChatMessageDto { + @IsIn(['ai', 'teacher']) + mode: 'ai' | 'teacher'; + + @IsString() + content: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + week?: number; +} diff --git a/src/chat/dto/create-instructor-chat-message.dto.ts b/src/chat/dto/create-instructor-chat-message.dto.ts new file mode 100644 index 0000000..2aee48c --- /dev/null +++ b/src/chat/dto/create-instructor-chat-message.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class CreateInstructorChatMessageDto { + @IsString() + content: string; +} diff --git a/src/chat/dto/list-chat-threads.dto.ts b/src/chat/dto/list-chat-threads.dto.ts new file mode 100644 index 0000000..d08b2d2 --- /dev/null +++ b/src/chat/dto/list-chat-threads.dto.ts @@ -0,0 +1,15 @@ +import { Transform } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListChatThreadsDto { + @IsOptional() + @IsIn(['ai', 'teacher']) + mode?: 'ai' | 'teacher'; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + week?: number; +} diff --git a/src/curriculum/curriculum.controller.ts b/src/curriculum/curriculum.controller.ts new file mode 100644 index 0000000..57c5f4a --- /dev/null +++ b/src/curriculum/curriculum.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common'; +import { Request, Response } from 'express'; + +import { CurriculumService } from './curriculum.service'; + +@Controller('curriculum') +export class CurriculumController { + constructor(private readonly curriculumService: CurriculumService) {} + + @Get() + getActiveCurriculum(@Req() request: Request, @Query('courseId') courseId?: string) { + return this.curriculumService.getActiveCurriculum( + this.getBearerToken(request), + courseId, + ); + } + + @Get('courses') + listCourses(@Req() request: Request) { + return this.curriculumService.listCourses(this.getBearerToken(request)); + } + + @Get('references/:referenceId/download') + async downloadReference( + @Req() request: Request, + @Param('referenceId') referenceId: string, + @Res() response: Response, + ) { + const download = await this.curriculumService.getReferenceDownload( + this.getBearerToken(request), + referenceId, + ); + response.setHeader('content-type', download.contentType); + return response.download(download.path, download.fileName); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/curriculum/curriculum.module.ts b/src/curriculum/curriculum.module.ts new file mode 100644 index 0000000..20ca617 --- /dev/null +++ b/src/curriculum/curriculum.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PlatformControlModule } from '../platform-control/platform-control.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { CurriculumController } from './curriculum.controller'; +import { CurriculumService } from './curriculum.service'; + +@Module({ + imports: [AuthModule, PrismaModule, PlatformControlModule], + controllers: [CurriculumController], + providers: [CurriculumService], +}) +export class CurriculumModule {} diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts new file mode 100644 index 0000000..cedfb17 --- /dev/null +++ b/src/curriculum/curriculum.service.ts @@ -0,0 +1,929 @@ +import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { join } from 'node:path'; + +import { AuthService } from '../auth/auth.service'; +import { PlatformControlService } from '../platform-control/platform-control.service'; +import { PrismaService } from '../prisma/prisma.service'; + +const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); +const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; + +@Injectable() +export class CurriculumService { + constructor( + private readonly authService: AuthService, + private readonly platformControlService: PlatformControlService, + private readonly prisma: PrismaService, + ) {} + + async listCourses(accessToken?: string) { + const authUser = accessToken + ? await this.authService.me(accessToken).then((response) => response.user).catch(() => null) + : null; + const lmsUser = authUser?.email + ? await this.prisma.user.findUnique({ where: { email: authUser.email } }) + : null; + + if (!lmsUser) { + const guideCourse = await this.findPublicGuideCourse(); + + return { + courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [], + }; + } + + const enrollments = await this.prisma.studentEnrollment.findMany({ + where: { + userId: lmsUser.id, + status: 'ACTIVE', + course: { isActive: true }, + }, + include: { + course: { + include: { + teacher: { + select: { + id: true, + email: true, + name: true, + primaryOrganizationName: true, + }, + }, + studentProgress: { + where: { userId: lmsUser.id }, + take: 1, + }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + if (enrollments.length) { + return { + courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)), + }; + } + + const guideCourse = await this.findPublicGuideCourse(lmsUser.id); + + return { + courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [], + }; + } + + async getActiveCurriculum(accessToken?: string, courseId?: string | null) { + const authUser = accessToken + ? await this.authService.me(accessToken).then((response) => response.user).catch(() => null) + : null; + const lmsUser = authUser?.email + ? await this.prisma.user.findUnique({ where: { email: authUser.email } }) + : null; + const requestedCourseId = courseId?.trim(); + const enrolledCourse = !requestedCourseId && authUser?.email + ? await this.prisma.course.findFirst({ + where: { + isActive: true, + enrollments: { + some: { + user: { email: authUser.email }, + status: 'ACTIVE', + }, + }, + }, + select: { id: true }, + orderBy: { updatedAt: 'desc' }, + }) + : null; + const course = await this.prisma.course.findFirst({ + where: { + ...(requestedCourseId + ? { + id: requestedCourseId, + ...(authUser?.email + ? { + OR: [ + { slug: PUBLIC_GUIDE_COURSE_SLUG }, + { + enrollments: { + some: { + user: { email: authUser.email }, + status: 'ACTIVE', + }, + }, + }, + ], + } + : { slug: PUBLIC_GUIDE_COURSE_SLUG }), + } + : enrolledCourse + ? { id: enrolledCourse.id } + : { slug: PUBLIC_GUIDE_COURSE_SLUG }), + isActive: true, + }, + include: { + teacher: { + select: { + id: true, + email: true, + name: true, + primaryOrganizationName: true, + }, + }, + weeks: { + orderBy: { week: 'asc' }, + include: { + topics: { + orderBy: { sortOrder: 'asc' }, + }, + practices: { + orderBy: { sortOrder: 'asc' }, + }, + references: { + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }, + modules: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + platforms: { + orderBy: { sortOrder: 'asc' }, + include: { + platform: true, + }, + }, + environmentProfiles: { + where: { enabled: true }, + }, + studentProgress: { + where: { + user: { + email: authUser?.email ?? '__NO_AUTH_USER__', + }, + }, + take: 1, + }, + }, + }); + + if (!course) { + return emptyCurriculumResponse(); + } + + const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({ + where: { + courseId: course.id, + enabled: true, + }, + include: { + platform: true, + }, + }); + const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile])); + const visualizationProfilesByWeek = new Map>(); + visualizationSelections.forEach((selection) => { + const profileType = selection.platform.type.toUpperCase(); + const environmentProfile = environmentProfileByWeekType.get(`${selection.week}:${profileType}`); + const items = visualizationProfilesByWeek.get(selection.week) ?? []; + items.push({ + id: environmentProfile?.id ?? selection.id, + week: selection.week, + platformKey: environmentProfile?.platformKey ?? 'futuredrive-local-pc', + profileType: selection.platform.type, + name: environmentProfile?.name ?? selection.platform.displayName, + config: environmentProfile?.config ?? selection.platform.defaultConfig, + }); + visualizationProfilesByWeek.set(selection.week, items); + }); + const progress = course.studentProgress[0]; + const enrollment = lmsUser + ? await this.prisma.studentEnrollment.findFirst({ + where: { + userId: lmsUser.id, + courseId: course.id, + status: 'ACTIVE', + }, + }) + : null; + const organizationName = enrollment?.organizationName ?? course.organizationName; + const organizationId = enrollment?.organizationId ?? undefined; + const studentGroup = lmsUser + ? await this.prisma.studentGroup.findFirst({ + where: { + organizationName, + members: { + some: { + userId: lmsUser.id, + user: { + enrollments: { + some: { + courseId: course.id, + status: 'ACTIVE', + }, + }, + }, + }, + }, + }, + select: { + id: true, + name: true, + }, + }) + : null; + const assignedInstructorPlatformIds = await this.findStudentAssignedInstructorPlatformIds( + organizationId, + organizationName, + authUser?.email, + authUser?.id, + ); + const instructorPlatforms = await this.findStudentPlatformOptions( + organizationId, + organizationName, + assignedInstructorPlatformIds, + ); + const environmentProfilesByPlatformKey = new Map>(); + Array.from(visualizationProfilesByWeek.values()).flat().forEach((profile) => { + const items = environmentProfilesByPlatformKey.get(profile.platformKey) ?? []; + items.push({ + id: profile.id, + week: profile.week, + platformKey: profile.platformKey, + profileType: profile.profileType, + name: profile.name, + config: profile.config, + }); + environmentProfilesByPlatformKey.set(profile.platformKey, items); + }); + const fixedPlatforms = course.platforms + .filter((coursePlatform) => { + return this.isFixedPlatform(coursePlatform.platform); + }) + .map((coursePlatform) => { + const isLocal = this.isFixedPlatform(coursePlatform.platform); + return { + id: coursePlatform.platform.id, + platformKey: coursePlatform.platform.platformKey, + title: coursePlatform.platform.displayName, + description: coursePlatform.platform.description, + organizationName: coursePlatform.platform.organizationName, + iconKey: coursePlatform.platform.iconKey, + sourceSettings: coursePlatform.platform.defaultStudioSettings, + environmentProfiles: environmentProfilesByPlatformKey.get(coursePlatform.platform.platformKey) ?? [], + status: isLocal ? '사용 가능' : coursePlatform.status, + action: isLocal ? '연결' : coursePlatform.action, + state: coursePlatform.state, + local: isLocal, + }; + }); + const controlStates = await this.platformControlService.getControlStates( + { organizationId, organizationName }, + lmsUser?.id ?? null, + [ + ...fixedPlatforms.filter((platform) => !platform.local).map((platform) => + this.platformControlService.makePlatformResource(platform.id, null), + ), + ...instructorPlatforms.map((platform) => + this.platformControlService.makePlatformResource(null, platform.id), + ), + ], + ); + const platforms = [ + ...fixedPlatforms.map((platform) => { + const { local: _local, ...payload } = platform; + return platform.local + ? payload + : this.withControlState( + payload, + controlStates.get(this.platformControlService.resourceKeyForIds(platform.id, null)), + ); + }), + ...instructorPlatforms.map((platform) => this.withControlState( + platform, + controlStates.get(this.platformControlService.resourceKeyForIds(null, platform.id)), + )), + ]; + const groupSetting = studentGroup + ? await this.prisma.instructorCurriculumSetting.findFirst({ + where: { + organizationName, + courseId: course.id, + groupId: studentGroup.id, + }, + select: { + activeWeek: true, + activeLearningStep: true, + }, + }) + : null; + const defaultSetting = await this.prisma.instructorCurriculumSetting.findFirst({ + where: { + organizationName, + courseId: course.id, + groupId: null, + }, + select: { + activeWeek: true, + activeLearningStep: true, + }, + }); + const setting = groupSetting ?? defaultSetting; + const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({ + where: { + organizationName, + courseId: course.id, + }, + }); + const overrideByWeek = new Map(overrides.map((override) => [override.week, override])); + const weekProgressItems = lmsUser + ? await this.prisma.courseStudentWeekProgress.findMany({ + where: { + courseId: course.id, + userId: lmsUser.id, + }, + }) + : []; + const weekProgressByWeek = new Map(weekProgressItems.map((item) => [item.week, item])); + + const baseWeekNumbers = new Set(course.weeks.map((week) => week.week)); + const baseWeeks = course.weeks.map((week) => { + const override = overrideByWeek.get(week.week); + const weekProgress = weekProgressByWeek.get(week.week); + const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []); + const learningSteps = this.resolveLearningSteps( + override?.learningSteps, + this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title), + this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail), + this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''), + ); + const topics = learningSteps.map((step) => step.title); + const topicDetails = learningSteps.map((step) => step.description); + const topicVideoUrls = learningSteps.map((step) => step.videoUrl); + const practiceContents = this.practiceContentsArray(override?.practiceContents) + ?? (week.practices.length + ? week.practices.map((practice) => ({ + title: practice.title, + description: practice.description, + })) + : undefined); + const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting); + + return { + week: week.week, + title: override?.title ?? week.title, + tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state), + progress: progressState.progress, + state: progressState.state, + topics, + duration: override?.duration ?? week.duration, + github: week.github, + detail: { + overview: override?.overview ?? week.overview, + learningSteps, + topicDetails, + topicVideoUrls, + practiceContents, + references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)), + exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)), + deliverable: override?.deliverable ?? week.deliverable, + requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission, + sourceSubmitted: Boolean(weekProgress?.submittedAt), + sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null, + }, + modules: week.modules + .filter((module) => !disabledModuleIdSet.has(module.id)) + .map((module) => { + const moduleWithArtifacts = module as typeof module & { + sourceType?: string | null; + prebuiltArtifacts?: unknown; + }; + return { + id: module.id, + sortOrder: module.sortOrder, + category: module.category, + name: module.name, + folderName: module.folderName, + templateRepositoryUrl: module.templateRepositoryUrl, + sourceType: moduleWithArtifacts.sourceType ?? 'GIT', + prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [], + dockerImage: module.dockerImage, + }; + }), + environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [], + }; + }); + const addedWeeks = overrides + .filter((override) => !baseWeekNumbers.has(override.week)) + .map((override) => { + const learningSteps = this.resolveLearningSteps( + override.learningSteps, + this.stringArray(override.topics) ?? ['학습단계 1'], + this.stringArray(override.topicDetails) ?? [''], + this.stringArray(override.topicVideoUrls) ?? [''], + ); + const topics = learningSteps.map((step) => step.title); + const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting); + const weekProgress = weekProgressByWeek.get(override.week); + return { + week: override.week, + title: override.title ?? `${override.week}주차 커리큘럼`, + tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state), + progress: progressState.progress, + state: progressState.state, + topics, + duration: override.duration ?? '-', + github: '-', + detail: { + overview: override.overview ?? '', + learningSteps, + topicDetails: learningSteps.map((step) => step.description), + topicVideoUrls: learningSteps.map((step) => step.videoUrl), + practiceContents: this.practiceContentsArray(override.practiceContents) ?? undefined, + references: [], + deliverable: override.deliverable ?? '', + requiresSourceSubmission: override.requiresSourceSubmission ?? true, + sourceSubmitted: Boolean(weekProgress?.submittedAt), + sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null, + }, + environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [], + }; + }); + + return { + course: { + id: course.id, + slug: course.slug, + title: course.title, + subtitle: course.subtitle, + description: course.description, + organizationName: course.organizationName, + teacher: course.teacher, + }, + weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week), + activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? null, + activeLearningStep: setting?.activeLearningStep ?? null, + learningStats: this.serializeLearningStats(progress, course.weeks.length), + platforms, + }; + } + + async getReferenceDownload(accessToken: string | undefined, referenceId: string) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + + const reference = await this.prisma.courseWeekReference.findUnique({ + where: { id: referenceId }, + include: { + week: { + include: { + course: { + include: { + enrollments: { + where: { + status: 'ACTIVE', + user: { email: authUser.email }, + }, + take: 1, + }, + }, + }, + }, + }, + }, + }); + if (!reference || !reference.week.course.enrollments.length) { + throw new NotFoundException('Reference not found'); + } + + return { + path: join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName), + fileName: reference.originalFileName, + contentType: reference.contentType ?? 'application/octet-stream', + }; + } + + private withControlState( + platform: { + id: string; + platformKey: string; + title: string; + description: string; + organizationName: string; + iconKey: string; + status: string; + action: string; + state: string; + }, + controlState: + | { + status: string; + action: string; + state: string; + session: unknown; + queue: unknown; + warning: boolean; + } + | undefined, + ) { + if (!controlState) return platform; + + return { + ...platform, + status: controlState.status === 'AVAILABLE' ? platform.status : this.toControlStatusLabel(controlState.status), + action: controlState.status === 'AVAILABLE' ? platform.action : controlState.action, + state: controlState.state, + control: controlState, + }; + } + + private async findPublicGuideCourse(userId?: string) { + return this.prisma.course.findFirst({ + where: { + slug: PUBLIC_GUIDE_COURSE_SLUG, + isActive: true, + }, + include: { + teacher: { + select: { + id: true, + email: true, + name: true, + primaryOrganizationName: true, + }, + }, + studentProgress: { + where: { userId: userId ?? '00000000-0000-0000-0000-000000000000' }, + take: 1, + }, + }, + }); + } + + private serializeCourseListItem( + course: { + id: string; + slug: string; + title: string; + subtitle: string | null; + description: string | null; + organizationName: string; + teacher: { + id: string; + email: string; + name: string | null; + primaryOrganizationName: string | null; + }; + studentProgress: Array<{ + completedWeeks: number; + totalWeeks: number; + }>; + }, + publicGuide = false, + ) { + const progress = course.studentProgress[0]; + + return { + id: course.id, + slug: course.slug, + title: course.title, + subtitle: course.subtitle, + description: course.description, + organizationName: course.organizationName, + teacher: course.teacher, + progress: progress && progress.totalWeeks > 0 + ? Math.round((progress.completedWeeks / progress.totalWeeks) * 100) + : 0, + publicGuide, + }; + } + + private serializeLearningStats( + progress: { + completedWeeks: number; + totalWeeks: number; + learningHours: number; + commitCount: number; + averageScore: number; + } | null | undefined, + fallbackTotalWeeks: number, + ) { + const completedWeeks = progress?.completedWeeks ?? 0; + const totalWeeks = progress?.totalWeeks ?? fallbackTotalWeeks; + const learningHours = progress?.learningHours ?? 0; + const commitCount = progress?.commitCount ?? 0; + const averageScore = progress?.averageScore ?? 0; + + return [ + { + key: 'completedWeeks', + value: `${completedWeeks}/${totalWeeks}`, + label: '완료한 주차', + iconKey: 'check', + }, + { + key: 'learningHours', + value: this.formatLearningHours(learningHours), + label: '학습 시간', + iconKey: 'clock', + }, + { + key: 'commitCount', + value: String(commitCount), + label: '커밋 수', + iconKey: 'trending', + }, + { + key: 'averageScore', + value: String(averageScore), + label: '평균 점수', + iconKey: 'gauge', + }, + ]; + } + + private toControlStatusLabel(status: string) { + if (status === 'ACTIVE') return '제어 중'; + if (status === 'QUEUED') return '대기 예약됨'; + if (status === 'IN_USE') return '다른 수강생 사용 중'; + return '사용 가능'; + } + + private async findStudentAssignedInstructorPlatformIds( + organizationId: string | undefined, + organizationName: string, + email: string | undefined, + authUserId: string | undefined, + ) { + const userConditions = [ + email ? { email } : undefined, + authUserId ? { authUserId } : undefined, + ].filter(Boolean) as Prisma.UserWhereInput[]; + + if (!userConditions.length) return []; + + const groups = await this.prisma.studentGroup.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: { not: null }, + members: { + some: { + user: { + OR: userConditions, + }, + }, + }, + }, + select: { + instructorPlatformId: true, + }, + }); + const assignedInstructorPlatformIds = [ + ...new Set( + groups + .map((group) => group.instructorPlatformId) + .filter((platformId): platformId is string => Boolean(platformId)), + ), + ]; + + return assignedInstructorPlatformIds; + } + + private async findStudentPlatformOptions( + organizationId: string | undefined, + organizationName: string, + assignedInstructorPlatformIds: string[], + ) { + if (!assignedInstructorPlatformIds.length) return []; + + const platforms = await this.prisma.instructorPlatform.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + id: { in: assignedInstructorPlatformIds }, + platform: { + is: { + isActive: true, + NOT: [ + { platformKey: { contains: 'local', mode: 'insensitive' } }, + { displayName: { contains: '로컬', mode: 'insensitive' } }, + ], + }, + }, + }, + orderBy: [{ displayName: 'asc' }], + include: { platform: true }, + }); + + return platforms.map((platform) => { + const blocked = platform.emergencyStop || platform.errorCount > 0; + + return { + id: platform.id, + platformKey: platform.platform.platformKey, + title: platform.displayName, + description: platform.platform.description, + organizationName: platform.organizationName, + iconKey: platform.platform.iconKey, + sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings, + status: blocked + ? platform.emergencyStop + ? '비상 정지' + : '오류 발생' + : '상태 확인 중', + action: blocked ? '연결 불가' : '제어권 요청', + state: blocked ? 'locked' : 'ready', + }; + }); + } + + private organizationWhere( + organizationId: string | undefined, + organizationName: string | undefined, + ): T { + return { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean), + } as T; + } + + private isFixedPlatform(platform: { platformKey: string; displayName: string }) { + const platformKey = platform.platformKey.toLowerCase(); + const displayName = platform.displayName.toLowerCase(); + return ( + platformKey.includes('local') || + displayName.includes('로컬') + ); + } + + private stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return value.filter((item): item is string => typeof item === 'string'); + } + + private resolveLearningSteps( + value: unknown, + topics: string[], + topicDetails: string[] = [], + topicVideoUrls: string[] = [], + ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> { + return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? []; + } + + private normalizeLearningSteps( + value?: unknown, + topics: string[] = [], + topicDetails: string[] = [], + topicVideoUrls: string[] = [], + ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null { + if (Array.isArray(value)) { + const steps = value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const record = item as Record; + const title = typeof record.title === 'string' ? record.title.trim() : ''; + const description = typeof record.description === 'string' ? record.description.trim() : ''; + const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : ''; + const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl); + const concepts = Array.isArray(record.concepts) + ? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean) + : []; + return title || description || videoUrl || concepts.length + ? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts } + : null; + }) + .filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item)); + return steps.length ? steps : null; + } + + const titles = topics.map((topic) => topic.trim()).filter(Boolean); + if (!titles.length) return null; + return titles.map((title, index) => { + const videoUrl = topicVideoUrls[index]?.trim() ?? ''; + return { + title, + description: topicDetails[index]?.trim() ?? '', + videoEnabled: Boolean(videoUrl), + videoUrl, + concepts: [], + }; + }); + } + + private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null { + if (!Array.isArray(value)) return null; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const record = item as Record; + const title = typeof record.title === 'string' ? record.title.trim() : ''; + const description = typeof record.description === 'string' ? record.description.trim() : ''; + return title || description ? { title, description } : null; + }) + .filter((item): item is { title: string; description: string } => Boolean(item)); + } + + private serializeWeekReference( + reference: { + id: string; + kind: string; + title: string; + originalFileName: string; + contentType: string | null; + fileSizeBytes: number; + createdAt: Date; + }, + week: number, + ) { + return { + id: reference.id, + kind: reference.kind, + title: reference.title, + description: reference.originalFileName, + fileName: reference.originalFileName, + contentType: reference.contentType, + fileSizeBytes: reference.fileSizeBytes, + week, + weekNumber: week, + downloadUrl: `/curriculum/references/${reference.id}/download`, + createdAt: reference.createdAt.toISOString(), + }; + } + + private resolveWeekProgressState( + week: number, + baseState: string, + topics: string[], + setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined, + ) { + if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 }; + if (week < setting.activeWeek) return { state: 'done', progress: 100 }; + if (week > setting.activeWeek) return { state: 'locked', progress: 0 }; + + const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep); + return { + state: progress >= 100 ? 'done' : 'current', + progress, + }; + } + + private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) { + if (!topics.length) return activeLearningStep === null ? 100 : 0; + if (activeLearningStep === null) return 100; + const activeIndex = topics.findIndex((topic) => topic === activeLearningStep); + if (activeIndex < 0) return 0; + return Math.min(99, Math.round((activeIndex / topics.length) * 100)); + } + + private resolveWeekTags(tags: string[], state: string) { + const nextTags = tags.length ? [...tags] : ['커리큘럼']; + nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기'; + return nextTags; + } + + private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) { + const nextTags = tags.length ? [...tags] : ['커리큘럼']; + const label = tagLabel?.trim(); + if (label) nextTags[0] = label; + return nextTags; + } + + private formatLearningHours(learningMinutes: number) { + const totalMinutes = Math.max(0, Math.floor(learningMinutes)); + if (totalMinutes < 60) return `${totalMinutes}m`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } +} + +function emptyCurriculumResponse() { + return { + course: null, + weeks: [], + learningStats: [], + platforms: [], + }; +} diff --git a/src/instructor/dto/assign-instructor-platform.dto.ts b/src/instructor/dto/assign-instructor-platform.dto.ts new file mode 100644 index 0000000..989b79a --- /dev/null +++ b/src/instructor/dto/assign-instructor-platform.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class AssignInstructorPlatformDto { + @IsString() + groupId: string; +} diff --git a/src/instructor/dto/create-instructor-platform.dto.ts b/src/instructor/dto/create-instructor-platform.dto.ts new file mode 100644 index 0000000..98de816 --- /dev/null +++ b/src/instructor/dto/create-instructor-platform.dto.ts @@ -0,0 +1,34 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateInstructorPlatformDto { + @IsString() + platformId: string; + + @IsOptional() + @IsString() + displayName?: string; + + @IsOptional() + @IsString() + agentNodeId?: string; + + @IsOptional() + @IsString() + agentHostname?: string; + + @IsOptional() + @IsString() + agentOwnerEmail?: string; + + @IsOptional() + @IsString() + sshUser?: string; + + @IsOptional() + @IsString() + sshPort?: string; + + @IsOptional() + @IsString() + workspaceRoot?: string; +} diff --git a/src/instructor/dto/create-instructor-student.dto.ts b/src/instructor/dto/create-instructor-student.dto.ts new file mode 100644 index 0000000..90bd108 --- /dev/null +++ b/src/instructor/dto/create-instructor-student.dto.ts @@ -0,0 +1,18 @@ +import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateInstructorStudentDto { + @IsOptional() + @IsString() + courseId?: string | null; + + @IsEmail() + email: string; + + @IsString() + @MaxLength(80) + name: string; + + @IsOptional() + @IsString() + groupId?: string | null; +} diff --git a/src/instructor/dto/save-instructor-group.dto.ts b/src/instructor/dto/save-instructor-group.dto.ts new file mode 100644 index 0000000..484ac6d --- /dev/null +++ b/src/instructor/dto/save-instructor-group.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SaveInstructorGroupDto { + @IsOptional() + @IsString() + courseId?: string | null; + + @IsString() + @MaxLength(80) + name: string; + + @IsOptional() + @IsString() + platformName?: string | null; + + @IsOptional() + @IsString() + instructorPlatformId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + memberIds?: string[]; +} diff --git a/src/instructor/dto/save-submission-score.dto.ts b/src/instructor/dto/save-submission-score.dto.ts new file mode 100644 index 0000000..931991f --- /dev/null +++ b/src/instructor/dto/save-submission-score.dto.ts @@ -0,0 +1,14 @@ +import { IsInt, IsString, Max, Min } from 'class-validator'; + +export class SaveSubmissionScoreDto { + @IsString() + studentId!: string; + + @IsString() + weekId!: string; + + @IsInt() + @Min(1) + @Max(100) + score!: number; +} diff --git a/src/instructor/dto/update-instructor-curriculum.dto.ts b/src/instructor/dto/update-instructor-curriculum.dto.ts new file mode 100644 index 0000000..591246c --- /dev/null +++ b/src/instructor/dto/update-instructor-curriculum.dto.ts @@ -0,0 +1,94 @@ +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class UpdateInstructorCurriculumDto { + @IsOptional() + @IsString() + courseId?: string | null; + + @IsOptional() + @IsString() + groupId?: string | null; + + @IsInt() + @Min(1) + @Max(52) + activeWeek: number; + + @IsOptional() + @IsString() + activeLearningStep?: string | null; + + @IsOptional() + @IsBoolean() + applyToStudents?: boolean; + + @IsOptional() + @IsBoolean() + completed?: boolean; + + @IsOptional() + @IsString() + tagLabel?: string; + + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + duration?: string; + + @IsOptional() + @IsString() + overview?: string; + + @IsOptional() + @IsString() + deliverable?: string; + + @IsOptional() + @IsBoolean() + requiresSourceSubmission?: boolean; + + @IsOptional() + @IsArray() + learningSteps?: Array<{ + title?: string; + description?: string; + videoEnabled?: boolean; + videoUrl?: string; + concepts?: string[]; + }>; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + topics?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + topicDetails?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + topicVideoUrls?: string[]; + + @IsOptional() + @IsArray() + practiceContents?: Array<{ + title?: string; + description?: string; + }>; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + disabledModuleIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + visualizationPlatformKeys?: string[]; +} diff --git a/src/instructor/dto/update-instructor-platform.dto.ts b/src/instructor/dto/update-instructor-platform.dto.ts new file mode 100644 index 0000000..b58bc16 --- /dev/null +++ b/src/instructor/dto/update-instructor-platform.dto.ts @@ -0,0 +1,56 @@ +import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class UpdateInstructorPlatformDto { + @IsString() + displayName: string; + + @IsOptional() + @IsString() + vehicleType?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + wheelbaseMeters?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + maxSpeedMetersPerSecond?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(90) + maxSteeringAngleDegrees?: number; + + @IsOptional() + @IsArray() + sensors?: Array<{ title?: string; description?: string; enabled?: boolean }>; + + @IsOptional() + @IsArray() + drivers?: Array<{ title?: string; description?: string }>; + + @IsOptional() + @IsObject() + algorithms?: { + slam?: string; + planning?: string; + control?: string; + }; + + @IsOptional() + @IsInt() + @Min(1) + @Max(240) + controlDurationMinutes?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(60) + controlWarningMinutes?: number; +} diff --git a/src/instructor/instructor.controller.ts b/src/instructor/instructor.controller.ts new file mode 100644 index 0000000..32b744b --- /dev/null +++ b/src/instructor/instructor.controller.ts @@ -0,0 +1,215 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Request } from 'express'; + +import { InstructorService } from './instructor.service'; +import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto'; +import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; +import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; +import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; +import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; +import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; +import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; + +@Controller('instructor') +export class InstructorController { + constructor(private readonly instructorService: InstructorService) {} + + @Get('students') + listStudents(@Req() request: Request, @Query('courseId') courseId?: string) { + return this.instructorService.listStudents(this.getBearerToken(request), courseId); + } + + @Get('courses') + listCourses(@Req() request: Request) { + return this.instructorService.listCourses(this.getBearerToken(request)); + } + + @Get('submissions') + listSubmissions(@Req() request: Request) { + return this.instructorService.listSubmissions(this.getBearerToken(request)); + } + + @Put('submissions/score') + saveSubmissionScore(@Req() request: Request, @Body() body: SaveSubmissionScoreDto) { + return this.instructorService.saveSubmissionScore(this.getBearerToken(request), body); + } + + @Post('students') + createStudent( + @Req() request: Request, + @Body() body: CreateInstructorStudentDto, + ) { + return this.instructorService.createStudent( + this.getBearerToken(request), + body, + ); + } + + @Delete('students/:studentId') + removeStudent(@Req() request: Request, @Param('studentId') studentId: string) { + return this.instructorService.removeStudent( + this.getBearerToken(request), + studentId, + ); + } + + @Get('groups') + listGroups(@Req() request: Request, @Query('courseId') courseId?: string) { + return this.instructorService.listGroups(this.getBearerToken(request), courseId); + } + + @Get('platforms') + listPlatforms(@Req() request: Request) { + return this.instructorService.listPlatforms(this.getBearerToken(request)); + } + + @Get('platforms/agents') + listPlatformAgents(@Req() request: Request) { + return this.instructorService.listPlatformAgents(this.getBearerToken(request)); + } + + @Get('curriculum') + getCurriculum(@Req() request: Request, @Query('groupId') groupId?: string, @Query('courseId') courseId?: string) { + return this.instructorService.getCurriculum(this.getBearerToken(request), groupId, courseId); + } + + @Put('curriculum') + updateCurriculum( + @Req() request: Request, + @Body() body: UpdateInstructorCurriculumDto, + ) { + return this.instructorService.updateCurriculum( + this.getBearerToken(request), + body, + ); + } + + @Delete('curriculum/weeks/:week') + deleteCurriculumWeek( + @Req() request: Request, + @Param('week', ParseIntPipe) week: number, + ) { + return this.instructorService.deleteCurriculumWeek( + this.getBearerToken(request), + week, + ); + } + + @Post('curriculum/weeks/:week/references') + @UseInterceptors(FileInterceptor('file')) + uploadCurriculumReference( + @Req() request: Request, + @Param('week', ParseIntPipe) week: number, + @UploadedFile() file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer }, + @Body('title') title?: string, + @Body('courseId') courseId?: string, + ) { + return this.instructorService.uploadCurriculumReference( + this.getBearerToken(request), + { courseId, week, title, kind: 'REFERENCE' }, + file, + ); + } + + @Post('curriculum/weeks/:week/example-codes') + @UseInterceptors(FileInterceptor('file')) + uploadCurriculumExampleCode( + @Req() request: Request, + @Param('week', ParseIntPipe) week: number, + @UploadedFile() file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer }, + @Body('title') title?: string, + @Body('courseId') courseId?: string, + ) { + return this.instructorService.uploadCurriculumReference( + this.getBearerToken(request), + { courseId, week, title, kind: 'EXAMPLE_CODE' }, + file, + ); + } + + @Delete('curriculum/references/:referenceId') + deleteCurriculumReference( + @Req() request: Request, + @Param('referenceId') referenceId: string, + @Query('courseId') courseId?: string, + ) { + return this.instructorService.deleteCurriculumReference( + this.getBearerToken(request), + referenceId, + courseId, + ); + } + + @Post('platforms') + createPlatform( + @Req() request: Request, + @Body() body: CreateInstructorPlatformDto, + ) { + return this.instructorService.createPlatform( + this.getBearerToken(request), + body, + ); + } + + @Put('platforms/:platformId') + updatePlatform( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: UpdateInstructorPlatformDto, + ) { + return this.instructorService.updatePlatform(this.getBearerToken(request), platformId, body); + } + + @Post('platforms/:platformId/assignment') + assignPlatform( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: AssignInstructorPlatformDto, + @Query('force') force?: string, + ) { + return this.instructorService.assignPlatformToGroup( + this.getBearerToken(request), + platformId, + body.groupId, + force === 'true', + ); + } + + @Delete('platforms/:platformId') + deletePlatform( + @Req() request: Request, + @Param('platformId') platformId: string, + @Query('force') force?: string, + ) { + return this.instructorService.deletePlatform(this.getBearerToken(request), platformId, force === 'true'); + } + + @Post('groups') + createGroup(@Req() request: Request, @Body() body: SaveInstructorGroupDto) { + return this.instructorService.createGroup(this.getBearerToken(request), body); + } + + @Put('groups/:groupId') + updateGroup( + @Req() request: Request, + @Param('groupId') groupId: string, + @Body() body: SaveInstructorGroupDto, + ) { + return this.instructorService.updateGroup( + this.getBearerToken(request), + groupId, + body, + ); + } + + @Delete('groups/:groupId') + deleteGroup(@Req() request: Request, @Param('groupId') groupId: string, @Query('courseId') courseId?: string) { + return this.instructorService.deleteGroup(this.getBearerToken(request), groupId, courseId); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/instructor/instructor.module.ts b/src/instructor/instructor.module.ts new file mode 100644 index 0000000..25674bc --- /dev/null +++ b/src/instructor/instructor.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { ChatModule } from '../chat/chat.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PlatformGatewayClient } from '../platform-control/platform-gateway.client'; +import { InstructorController } from './instructor.controller'; +import { InstructorService } from './instructor.service'; + +@Module({ + imports: [AuthModule, ChatModule, PrismaModule], + controllers: [InstructorController], + providers: [InstructorService, PlatformGatewayClient], +}) +export class InstructorModule {} diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts new file mode 100644 index 0000000..14f9b98 --- /dev/null +++ b/src/instructor/instructor.service.ts @@ -0,0 +1,3396 @@ +import { + BadGatewayException, + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; + +import { AuthService, type AuthResponseUser } from '../auth/auth.service'; +import { ChatGateway } from '../chat/chat.gateway'; +import { PrismaService } from '../prisma/prisma.service'; +import { PlatformGatewayClient, type GatewayAccessAssignment, type GatewayAgent } from '../platform-control/platform-gateway.client'; +import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; +import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; +import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; +import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; +import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; +import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; + +const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']); +const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net'; +const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); + +type SubmissionCommitFile = { + path: string; + status: string; + patch: string; +}; + +type SubmissionCommit = { + hash: string; + shortHash: string; + message: string; + author: string; + authorEmail: string; + date: string | null; + files: SubmissionCommitFile[]; +}; + +type SubmissionProgress = { + commits: SubmissionCommit[]; + finalFiles: SubmissionCommitFile[]; +}; + +type StudentIssueItem = { + type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE'; + title: string; + detail: string; + week?: number; +}; + +@Injectable() +export class InstructorService { + constructor( + private readonly authService: AuthService, + private readonly chatGateway: ChatGateway, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + private readonly platformGatewayClient: PlatformGatewayClient, + ) {} + + async listCourses(accessToken: string | undefined) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + const courses = await this.prisma.course.findMany({ + where: { + isActive: true, + teacherId: instructor.id, + }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + slug: true, + title: true, + subtitle: true, + description: true, + organizationName: true, + teacher: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + return { + courses: courses.map((course) => ({ + id: course.id, + slug: course.slug, + title: course.title, + subtitle: course.subtitle, + description: course.description, + organizationName: course.organizationName, + teacher: course.teacher, + })), + }; + } + + async listStudents(accessToken: string | undefined, courseId?: string | null) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + + if (!organizationId && !organizationName) { + return { + organization: null, + summary: this.toSummary([]), + students: [], + }; + } + + const course = organizationName + ? await this.findInstructorCourse(organizationName, instructor.id, courseId) + : null; + + const enrollments = await this.prisma.studentEnrollment.findMany({ + where: { + status: 'ACTIVE', + ...(course ? { courseId: course.id } : {}), + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[], + user: { + OR: [ + { authRole: null }, + { authRole: 'STUDENT' }, + { authRole: 'USER' }, + ], + }, + }, + orderBy: [ + { updatedAt: 'desc' }, + ], + include: { + course: true, + user: { + include: { + groupMemberships: { + where: { + group: { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + }, + take: 1, + include: { + group: true, + }, + }, + progress: { + where: course ? { courseId: course.id } : undefined, + orderBy: { updatedAt: 'desc' }, + take: 1, + include: { + course: true, + }, + }, + }, + }, + }, + }); + + const courseWeeks = course?.weeks ?? []; + const weekProgressItems = course + ? await this.prisma.courseStudentWeekProgress.findMany({ + where: { + courseId: course.id, + userId: { in: enrollments.map((enrollment) => enrollment.userId) }, + }, + select: { + userId: true, + week: true, + submittedAt: true, + }, + }) + : []; + const weekProgressByUserWeek = new Map(weekProgressItems.map((item) => [`${item.userId}:${item.week}`, item])); + const completedWeekNumbersByGroupName = new Map(); + + if (course && organizationName) { + const groupSettings = await this.prisma.instructorCurriculumSetting.findMany({ + where: { + organizationName, + courseId: course.id, + groupId: { not: null }, + }, + include: { + group: true, + }, + }); + groupSettings.forEach((setting) => { + const completedThroughWeek = setting.activeLearningStep ? setting.activeWeek - 1 : setting.activeWeek; + completedWeekNumbersByGroupName.set( + setting.group?.name ?? '미배정', + courseWeeks.filter((week) => week.week <= completedThroughWeek).map((week) => week.week), + ); + }); + } + + const students = enrollments + .filter((enrollment) => enrollment.user.id !== instructor.id) + .map((enrollment) => { + const user = enrollment.user; + const progress = user.progress[0]; + const group = user.groupMemberships[0]?.group; + const groupName = group?.name ?? '미배정'; + const progressPercent = progress && progress.totalWeeks > 0 + ? Math.round((progress.completedWeeks / progress.totalWeeks) * 100) + : 0; + const issues: StudentIssueItem[] = []; + const completedWeekNumbers = completedWeekNumbersByGroupName.get(groupName) ?? []; + + completedWeekNumbers.forEach((weekNumber) => { + if (weekProgressByUserWeek.get(`${user.id}:${weekNumber}`)?.submittedAt) return; + const weekTitle = courseWeeks.find((week) => week.week === weekNumber)?.title ?? `${weekNumber}주차`; + issues.push({ + type: 'COMPLETED_WITHOUT_SUBMISSION', + title: `${weekNumber}주차 완료 후 미제출`, + detail: `${weekTitle} 주차가 완료 처리되었지만 제출 기록이 없습니다.`, + week: weekNumber, + }); + }); + + courseWeeks + .filter((week) => { + const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt); + const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week; + const isCompletedByGroup = completedWeekNumbers.includes(week.week); + return week.requiresSourceSubmission && !isSubmitted && (isPastByProgress || isCompletedByGroup); + }) + .forEach((week) => { + if (issues.some((issue) => issue.week === week.week && issue.type === 'COMPLETED_WITHOUT_SUBMISSION')) return; + issues.push({ + type: 'MISSING_SUBMISSION', + title: `${week.week}주차 미제출`, + detail: `${week.title} 주차의 소스 제출 기록이 없습니다.`, + week: week.week, + }); + }); + + if (progress && progress.averageScore > 0 && progress.averageScore < 80) { + issues.push({ + type: 'LOW_AVERAGE_SCORE', + title: '평균 점수 기준 미달', + detail: `현재 평균 점수 ${progress.averageScore}점으로 기준 80점 미만입니다.`, + }); + } + + return { + id: user.id, + name: user.name ?? user.email.split('@')[0], + email: user.email, + group: groupName, + week: progress && progress.completedWeeks > 0 ? `${progress.completedWeeks}주차` : '시작 전', + progress: progressPercent, + commits: progress?.commitCount ?? 0, + issues: issues.length, + issueItems: issues, + lastActive: this.formatRelativeTime(progress?.updatedAt ?? user.updatedAt), + online: false, + courseTitle: progress?.course.title ?? enrollment.course.title, + }; + }); + + return { + organization: organizationName + ? { + id: organizationId, + name: organizationName, + } + : null, + summary: this.toSummary(students), + students, + }; + } + + async listSubmissions(accessToken: string | undefined) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + + if (!organizationId && !organizationName) { + return { + organization: null, + summary: { + totalItems: 0, + submittedItems: 0, + pendingItems: 0, + notInstalledItems: 0, + totalCommits: 0, + }, + courses: [], + students: [], + items: [], + }; + } + + const enrollments = await this.prisma.studentEnrollment.findMany({ + where: { + status: 'ACTIVE', + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[], + user: { + AND: [ + { + OR: [ + organizationId ? { primaryOrganizationId: organizationId } : undefined, + organizationName ? { primaryOrganizationName: organizationName } : undefined, + ].filter(Boolean) as Prisma.UserWhereInput[], + }, + { + OR: [ + { authRole: null }, + { authRole: 'STUDENT' }, + { authRole: 'USER' }, + ], + }, + ], + }, + }, + include: { + user: { + include: { + groupMemberships: { + where: { + group: { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + }, + take: 1, + include: { group: true }, + }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + const students = enrollments + .map((enrollment) => enrollment.user) + .filter((user) => user.id !== instructor.id) + .map((user) => ({ + id: user.id, + name: user.name ?? user.email.split('@')[0], + email: user.email, + group: user.groupMemberships[0]?.group.name ?? '미배정', + })); + const studentIds = students.map((student) => student.id); + + let courses = await this.prisma.course.findMany({ + where: { + isActive: true, + OR: [ + organizationName ? { organizationName } : undefined, + { teacherId: instructor.id }, + ].filter(Boolean) as Prisma.CourseWhereInput[], + }, + orderBy: { createdAt: 'asc' }, + include: { + weeks: { + orderBy: { week: 'asc' }, + include: { + modules: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + }); + if (!courses.length && organizationName) { + const course = await this.prisma.course.findFirst({ + where: { + slug: 'futuredrive-autonomy-basic', + organizationName, + isActive: true, + }, + include: { + weeks: { + orderBy: { week: 'asc' }, + include: { + modules: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + }); + if (course) courses = [course]; + } + if (!courses.length) { + courses = await this.prisma.course.findMany({ + where: { isActive: true }, + orderBy: { createdAt: 'asc' }, + include: { + weeks: { + orderBy: { week: 'asc' }, + include: { + modules: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + }); + } + const weekIds = courses.flatMap((course) => course.weeks.map((week) => week.id)); + const moduleIds = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.map((module) => module.id))); + + const repositories = studentIds.length && moduleIds.length + ? await this.prisma.studioRepository.findMany({ + where: { + userId: { in: studentIds }, + }, + }) + : []; + const repositoryByStudentModule = new Map(repositories.map((repository) => [`${repository.userId}:${repository.moduleId}`, repository])); + const repositoryByStudentRepoName = new Map(repositories.map((repository) => [`${repository.userId}:${repository.repoName}`, repository])); + const progressByRepositoryId = new Map(); + const scores = studentIds.length && weekIds.length + ? await this.prisma.submissionTotalScore.findMany({ + where: { + studentId: { in: studentIds }, + weekId: { in: weekIds }, + }, + select: { + studentId: true, + weekId: true, + score: true, + }, + }) + : []; + const scoreByStudentWeek = new Map(scores.map((score) => [`${score.studentId}:${score.weekId}`, score.score])); + + await Promise.all(repositories.map(async (repository) => { + const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({ + commits: [], + finalFiles: [], + })); + progressByRepositoryId.set(repository.id, progress); + })); + + const items = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.flatMap((module) => students.map((student) => { + const expectedRepoName = this.buildStudioRepoName(student.email, week.week, module.name); + const repository = repositoryByStudentModule.get(`${student.id}:${module.id}`) + ?? repositoryByStudentRepoName.get(`${student.id}:${expectedRepoName}`); + const progress = repository ? progressByRepositoryId.get(repository.id) : null; + const commits = progress?.commits ?? []; + const submittedAt = commits[0]?.date ?? null; + const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING'; + const score = scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0; + + return { + id: `${student.id}:${module.id}`, + status, + submitted: status === 'SUBMITTED', + submittedAt, + commitCount: commits.length, + score, + course: { + id: course.id, + title: course.title, + slug: course.slug, + }, + week: { + id: week.id, + week: week.week, + title: week.title, + }, + module: { + id: module.id, + name: module.name, + folderName: module.folderName, + sortOrder: module.sortOrder, + }, + student, + repository: repository + ? { + id: repository.id, + owner: repository.repoOwner, + name: repository.repoName, + url: repository.repositoryUrl, + } + : null, + commits, + finalFiles: progress?.finalFiles ?? [], + }; + })))); + + return { + organization: organizationName + ? { + id: organizationId, + name: organizationName, + } + : null, + summary: { + totalItems: items.length, + submittedItems: items.filter((item) => item.status === 'SUBMITTED').length, + pendingItems: items.filter((item) => item.status === 'PENDING').length, + notInstalledItems: items.filter((item) => item.status === 'NOT_INSTALLED').length, + totalCommits: items.reduce((count, item) => count + item.commitCount, 0), + }, + courses: courses.map((course) => ({ + id: course.id, + title: course.title, + slug: course.slug, + weeks: course.weeks.map((week) => ({ + id: week.id, + week: week.week, + title: week.title, + modules: week.modules.map((module) => ({ + id: module.id, + name: module.name, + folderName: module.folderName, + sortOrder: module.sortOrder, + })), + })), + })), + students, + items, + }; + } + + async saveSubmissionScore(accessToken: string | undefined, input: SaveSubmissionScoreDto) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + const score = Number(input.score); + + if (!Number.isInteger(score) || score < 1 || score > 100) { + throw new BadRequestException('Score must be between 1 and 100'); + } + + const week = await this.prisma.courseWeek.findUnique({ + where: { id: input.weekId }, + include: { + course: true, + }, + }); + if (!week) throw new NotFoundException('Week not found'); + + const course = week.course; + if (course.teacherId !== instructor.id && course.organizationName !== organizationName) { + throw new ForbiddenException('Course is not accessible'); + } + + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { + userId: input.studentId, + courseId: course.id, + status: 'ACTIVE', + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[], + }, + select: { id: true }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + + const saved = await this.prisma.submissionTotalScore.upsert({ + where: { + studentId_weekId: { + studentId: input.studentId, + weekId: input.weekId, + }, + }, + create: { + courseId: course.id, + weekId: week.id, + studentId: input.studentId, + score, + }, + update: { + courseId: course.id, + weekId: week.id, + score, + }, + select: { + score: true, + }, + }); + + const average = await this.prisma.submissionTotalScore.aggregate({ + where: { + courseId: course.id, + studentId: input.studentId, + score: { gt: 0 }, + }, + _avg: { score: true }, + }); + const totalWeeks = await this.prisma.courseWeek.count({ where: { courseId: course.id } }); + const averageScore = Math.round(average._avg.score ?? 0); + + await this.prisma.courseStudentProgress.upsert({ + where: { + courseId_userId: { + courseId: course.id, + userId: input.studentId, + }, + }, + create: { + courseId: course.id, + userId: input.studentId, + totalWeeks, + averageScore, + }, + update: { + averageScore, + }, + }); + + return { score: saved.score }; + } + + async createStudent( + accessToken: string | undefined, + input: CreateInstructorStudentDto, + ) { + const { authUser, instructor } = await this.getInstructor(accessToken); + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationId || !organizationName) { + throw new BadRequestException('Instructor organization is required'); + } + + const email = input.email.trim().toLowerCase(); + const name = input.name.trim(); + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + let existingStudent = await this.prisma.user.findUnique({ + where: { email }, + }); + + const requestedGroupId = input.groupId?.trim(); + const groupId = requestedGroupId && requestedGroupId !== 'unassigned' + ? requestedGroupId + : null; + let group: { id: string } | null = null; + + if (groupId) { + group = await this.prisma.studentGroup.findFirst({ + where: { + id: groupId, + OR: [ + { organizationId }, + { organizationName }, + ], + }, + select: { id: true }, + }); + + if (!group) { + throw new BadRequestException('Invalid group'); + } + } + + if (!existingStudent) { + const createdAuthUser = await this.authService.createManagedUser( + accessToken, + { + email, + name, + password: this.configService.get( + 'STUDENT_DEFAULT_PASSWORD', + 'qwer1234', + ), + organizationId, + department: authUser.primaryAffiliation?.department ?? null, + position: '학생', + licenseCode: 'EDU', + }, + ); + + await this.grantLmsAppPermission(accessToken, createdAuthUser.user.id); + + existingStudent = await this.prisma.user.upsert({ + where: { email }, + update: this.toUserSyncData(createdAuthUser.user), + create: { + email, + ...this.toUserSyncData(createdAuthUser.user), + }, + }); + } + + await this.prisma.studentEnrollment.upsert({ + where: { + userId_courseId: { + userId: existingStudent.id, + courseId: course.id, + }, + }, + update: { + organizationId, + organizationName, + status: 'ACTIVE', + removedAt: null, + }, + create: { + userId: existingStudent.id, + courseId: course.id, + organizationId, + organizationName, + status: 'ACTIVE', + }, + }); + + if (group) { + await this.prisma.studentGroupMember.upsert({ + where: { + groupId_userId: { + groupId: group.id, + userId: existingStudent.id, + }, + }, + update: {}, + create: { + groupId: group.id, + userId: existingStudent.id, + }, + }); + } + + if (group && organizationName) { + await this.syncPlatformAccessPolicy(organizationId, organizationName); + } + return this.listStudents(accessToken, course.id); + } + + async removeStudent(accessToken: string | undefined, studentId: string) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + + if (!organizationId && !organizationName) { + throw new BadRequestException('Instructor organization is required'); + } + + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { + userId: studentId, + status: 'ACTIVE', + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[], + }, + select: { + id: true, + userId: true, + }, + }); + + if (!enrollment || enrollment.userId === instructor.id) { + throw new NotFoundException('Student enrollment not found'); + } + + await this.prisma.$transaction([ + this.prisma.studentEnrollment.update({ + where: { id: enrollment.id }, + data: { + status: 'REMOVED', + removedAt: new Date(), + }, + }), + this.prisma.studentGroupMember.deleteMany({ + where: { + userId: studentId, + group: { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + }, + }), + ]); + + if (organizationName) { + await this.syncPlatformAccessPolicy(organizationId, organizationName); + } + return this.listStudents(accessToken); + } + + async listGroups(accessToken: string | undefined, courseId?: string | null) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + + if (!organizationId && !organizationName) { + return { + summary: this.toGroupSummary([]), + groups: [], + }; + } + + const course = organizationName + ? await this.findInstructorCourse(organizationName, instructor.id, courseId) + : null; + const enrollmentScope: Prisma.StudentEnrollmentWhereInput = course + ? { courseId: course.id } + : { course: { teacherId: instructor.id } }; + + const groups = await this.prisma.studentGroup.findMany({ + where: { + AND: [ + { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + { + members: { + some: { + user: { + enrollments: { + some: { + status: 'ACTIVE', + ...enrollmentScope, + }, + }, + }, + }, + }, + }, + ], + }, + orderBy: { name: 'asc' }, + include: { + instructorPlatform: { + select: { + id: true, + displayName: true, + }, + }, + members: { + where: { + user: { + enrollments: { + some: { + status: 'ACTIVE', + ...enrollmentScope, + }, + }, + }, + }, + include: { + user: { + include: { + progress: { + orderBy: { updatedAt: 'desc' }, + take: 1, + }, + }, + }, + }, + }, + }, + }); + const unassignedStudents = await this.findUnassignedStudents(organizationId, organizationName, instructor.id, course?.id ?? null); + const platformOptions = await this.findPlatformOptions(organizationId, organizationName); + const platformOptionNames = platformOptions.map((platform) => platform.name); + + const responseGroups = groups.map((group) => { + const progresses = group.members + .map((member) => member.user.progress[0]) + .filter(Boolean); + const progress = progresses.length + ? Math.round( + progresses.reduce((sum, item) => { + return sum + (item.totalWeeks > 0 ? (item.completedWeeks / item.totalWeeks) * 100 : 0); + }, 0) / progresses.length, + ) + : 0; + + return { + id: group.id, + name: group.name, + week: `${group.week}주차`, + status: group.status, + members: group.members.map((member) => member.user.name ?? member.user.email.split('@')[0]), + membersDetailed: group.members.map((member) => ({ + id: member.user.id, + name: member.user.name ?? member.user.email.split('@')[0], + email: member.user.email, + })), + instructorPlatformId: group.instructorPlatformId, + platform: group.instructorPlatform?.displayName ?? (group.platformName && platformOptionNames.includes(group.platformName) ? group.platformName : '미배정'), + progress, + completedMissions: progresses.reduce((sum, item) => sum + item.completedWeeks, 0), + activeUsers: 0, + }; + }); + + return { + summary: this.toGroupSummary(responseGroups), + groups: responseGroups, + unassignedStudents, + platformOptions, + }; + } + + async listPlatforms(accessToken: string | undefined) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + + if (!organizationId && !organizationName) { + return { + summary: this.toPlatformSummary([]), + platforms: [], + masterPlatforms: await this.findMasterPlatforms(), + agents: [], + }; + } + + const where = this.organizationWhere( + organizationId, + organizationName, + ); + + const platforms = await this.prisma.instructorPlatform.findMany({ + where: { + ...where, + platform: { + is: { + NOT: [ + { platformKey: { contains: 'local', mode: 'insensitive' } }, + { platformKey: { contains: 'simulator', mode: 'insensitive' } }, + { displayName: { contains: '로컬', mode: 'insensitive' } }, + { displayName: { contains: '시뮬', mode: 'insensitive' } }, + { displayName: { contains: 'simulator', mode: 'insensitive' } }, + ], + }, + }, + }, + orderBy: { displayName: 'asc' }, + include: { platform: true }, + }); + + const platformIds = platforms.map((platform) => platform.id); + const assignedGroups = platformIds.length + ? await this.prisma.studentGroup.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: { in: platformIds }, + }, + include: { + members: { + take: 1, + include: { user: true }, + }, + }, + }) + : []; + + const groupByPlatform = new Map( + assignedGroups.map((group) => [group.instructorPlatformId, group]), + ); + const activeControlSessions = platformIds.length + ? await this.prisma.platformControlSession.findMany({ + where: { + instructorPlatformId: { in: platformIds }, + status: 'ACTIVE', + }, + include: { + holderUser: { + select: { + name: true, + email: true, + }, + }, + }, + }) + : []; + const activeControlByPlatform = new Map( + activeControlSessions + .filter((session) => session.instructorPlatformId) + .map((session) => [session.instructorPlatformId, session]), + ); + + const boundAgentValues = platforms.flatMap((platform) => { + const agentNodeId = this.readStringSetting(platform.studioSettingsOverride, 'agentNodeId'); + const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); + const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'); + return [agentNodeId, agentHostname, agentOwnerEmail].filter((value): value is string => Boolean(value)); + }); + const agents = boundAgentValues.length + ? await this.platformGatewayClient.listAgents(authUser.email).catch(() => []) + : []; + + const responsePlatforms = platforms.map((platform) => { + const assignedGroup = groupByPlatform.get(platform.id); + const activeControl = activeControlByPlatform.get(platform.id); + const agentNodeId = this.readStringSetting(platform.studioSettingsOverride, 'agentNodeId'); + const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); + const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'); + const agent = this.findBoundAgent(agents, { + nodeId: agentNodeId, + hostname: agentHostname, + ownerEmail: agentOwnerEmail, + }); + return { + id: platform.id, + masterPlatformId: platform.platformId, + name: platform.displayName, + type: this.toPlatformType(platform.platform.displayName), + status: activeControl ? '활성' : '대기', + battery: platform.battery, + uptime: this.formatUptime(platform.uptimeMinutes), + speed: `${Number(platform.speedMeters).toFixed(1)} m/s`, + errors: platform.errorCount, + group: assignedGroup?.name ?? null, + user: activeControl?.holderUser.name ?? activeControl?.holderUser.email ?? null, + emergencyStop: platform.emergencyStop, + vehicleType: platform.vehicleType, + wheelbaseMeters: Number(platform.wheelbaseMeters), + maxSpeedMetersPerSecond: Number(platform.maxSpeedMetersPerSecond), + maxSteeringAngleDegrees: Number(platform.maxSteeringAngleDegrees), + sensors: platform.sensors, + drivers: platform.drivers, + algorithms: platform.algorithms, + controlDurationMinutes: platform.controlDurationMinutes, + controlWarningMinutes: platform.controlWarningMinutes, + agent: agentNodeId || agentHostname || agentOwnerEmail + ? { + nodeId: agentNodeId, + hostname: agentHostname, + ownerEmail: agentOwnerEmail, + ip: agent?.ip ?? null, + connected: Boolean(agent?.connected && !agent.expired), + lastSeenAt: agent?.lastSeenAt ?? null, + } + : null, + }; + }); + + return { + summary: this.toPlatformSummary(responsePlatforms), + platforms: responsePlatforms, + masterPlatforms: await this.findMasterPlatforms(), + agents: [], + }; + } + + async listPlatformAgents(accessToken: string | undefined) { + const { authUser } = await this.getInstructor(accessToken); + const agents = await this.platformGatewayClient.listAgents(authUser.email).catch(() => []); + return { agents }; + } + + async getCurriculum(accessToken: string | undefined, groupId?: string | null, courseId?: string | null) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) { + return { + course: null, + weeks: [], + setting: null, + }; + } + + const course = await this.findInstructorCourse(organizationName, instructor.id, courseId); + if (!course) { + return { + course: null, + weeks: [], + setting: null, + }; + } + + const selectedGroup = await this.findOptionalInstructorGroup(groupId, organizationId, organizationName); + const setting = selectedGroup + ? await this.ensureCurriculumSetting( + organizationId, + organizationName, + selectedGroup.id, + course.id, + selectedGroup.week ?? course.weeks[0]?.week ?? 1, + course.weeks[0]?.topics[0]?.title ?? null, + ) + : null; + const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({ + where: { + organizationName, + courseId: course.id, + }, + }); + const visualizationPlatforms = await this.prisma.visualizationPlatform.findMany({ + where: { enabled: true }, + orderBy: { sortOrder: 'asc' }, + }); + const tagOptions = await this.findCurriculumTagOptions(); + const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({ + where: { + courseId: course.id, + enabled: true, + }, + include: { + platform: true, + }, + }); + const visualizationSelectionsByWeek = new Map(); + visualizationSelections.forEach((selection) => { + const items = visualizationSelectionsByWeek.get(selection.week) ?? []; + items.push(selection); + visualizationSelectionsByWeek.set(selection.week, items); + }); + const overrideByWeek = new Map(overrides.map((override) => [override.week, override])); + const baseWeekNumbers = new Set(course.weeks.map((week) => week.week)); + const baseWeeks = course.weeks.map((week) => { + const override = overrideByWeek.get(week.week); + const learningSteps = this.resolveLearningSteps( + override?.learningSteps, + this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title), + this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail), + this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''), + ); + const topics = learningSteps.map((step) => step.title); + const topicDetails = learningSteps.map((step) => step.description); + const topicVideoUrls = learningSteps.map((step) => step.videoUrl); + const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []); + const practiceContents = this.practiceContentsArray(override?.practiceContents) + ?? week.practices.map((practice) => ({ + title: practice.title, + description: practice.description, + })); + const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting); + + return { + week: week.week, + title: override?.title ?? week.title, + tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state), + progress: progressState.progress, + state: progressState.state, + topics, + duration: override?.duration ?? week.duration, + github: week.github, + detail: { + overview: override?.overview ?? week.overview, + learningSteps, + topicDetails, + topicVideoUrls, + practiceContents, + references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)), + exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)), + deliverable: override?.deliverable ?? week.deliverable, + requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission, + }, + modules: week.modules.map((module) => ({ + id: module.id, + sortOrder: module.sortOrder, + category: module.category, + name: module.name, + folderName: module.folderName, + templateRepositoryUrl: module.templateRepositoryUrl, + dockerImage: module.dockerImage, + enabled: !disabledModuleIdSet.has(module.id), + })), + visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({ + key: selection.platform.key, + type: selection.platform.type, + displayName: selection.platform.displayName, + description: selection.platform.description, + config: selection.platform.defaultConfig, + })), + }; + }); + const addedWeeks = overrides + .filter((override) => !baseWeekNumbers.has(override.week)) + .map((override) => { + const learningSteps = this.resolveLearningSteps( + override.learningSteps, + this.stringArray(override.topics) ?? ['학습단계 1'], + this.stringArray(override.topicDetails) ?? [''], + this.stringArray(override.topicVideoUrls) ?? [''], + ); + const topics = learningSteps.map((step) => step.title); + const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting); + return { + week: override.week, + title: override.title ?? `${override.week}주차 커리큘럼`, + tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state), + progress: progressState.progress, + state: progressState.state, + topics, + duration: override.duration ?? '-', + github: '-', + detail: { + overview: override.overview ?? '', + learningSteps, + topicDetails: learningSteps.map((step) => step.description), + topicVideoUrls: learningSteps.map((step) => step.videoUrl), + practiceContents: this.practiceContentsArray(override.practiceContents) ?? [], + references: [], + deliverable: override.deliverable ?? '', + requiresSourceSubmission: override.requiresSourceSubmission ?? true, + }, + modules: [], + visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({ + key: selection.platform.key, + type: selection.platform.type, + displayName: selection.platform.displayName, + description: selection.platform.description, + config: selection.platform.defaultConfig, + })), + }; + }); + + return { + course: { + id: course.id, + title: course.title, + subtitle: course.subtitle, + description: course.description, + organizationName: course.organizationName, + }, + weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week), + tagOptions, + visualizationPlatforms: visualizationPlatforms.map((platform) => ({ + key: platform.key, + type: platform.type, + displayName: platform.displayName, + description: platform.description, + config: platform.defaultConfig, + })), + setting: { + groupId: setting?.groupId ?? null, + activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? 1, + activeLearningStep: setting?.activeLearningStep ?? null, + }, + }; + } + + async updateCurriculum( + accessToken: string | undefined, + input: UpdateInstructorCurriculumDto, + ) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + + const selectedGroup = await this.findOptionalInstructorGroup(input.groupId, organizationId, organizationName); + const targetWeek = course.weeks.find((week) => week.week === input.activeWeek); + const existingOverride = await this.prisma.instructorCurriculumWeekOverride.findUnique({ + where: { + organizationName_courseId_week: { + organizationName, + courseId: course.id, + week: input.activeWeek, + }, + }, + }); + + const existingLearningSteps = this.resolveLearningSteps( + existingOverride?.learningSteps, + this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1'], + this.stringArray(existingOverride?.topicDetails) ?? targetWeek?.topics.map((topic) => topic.detail) ?? [''], + this.stringArray(existingOverride?.topicVideoUrls) ?? targetWeek?.topics.map(() => '') ?? [''], + ); + const inputLearningSteps = this.normalizeLearningSteps( + input.learningSteps, + input.topics, + input.topicDetails, + input.topicVideoUrls, + ); + const learningSteps = inputLearningSteps ?? existingLearningSteps; + const topicTitles = learningSteps.map((step) => step.title).filter(Boolean); + const activeLearningStep = input.completed + ? null + : input.activeLearningStep && topicTitles.includes(input.activeLearningStep) + ? input.activeLearningStep + : topicTitles[0] ?? null; + const applyToStudents = input.applyToStudents !== false; + if (applyToStudents && !selectedGroup) { + throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.'); + } + + const practiceContents = this.normalizePracticeContents(input.practiceContents); + + const disabledModuleIds = input.disabledModuleIds?.map((moduleId) => moduleId.trim()).filter(Boolean); + let selectedTagLabel: string | undefined; + if (input.tagLabel !== undefined) { + const tagLabel = input.tagLabel.trim(); + const tagOptions = await this.findCurriculumTagOptions(); + const tagOption = tagOptions.find((option) => option.label === tagLabel); + if (!tagOption) throw new BadRequestException('유효하지 않은 플랫폼 배지입니다.'); + selectedTagLabel = tagOption.label; + } + + if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.requiresSourceSubmission !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) { + await this.prisma.instructorCurriculumWeekOverride.upsert({ + where: { + organizationName_courseId_week: { + organizationName, + courseId: course.id, + week: input.activeWeek, + }, + }, + update: { + organizationId, + tagLabel: selectedTagLabel ?? existingOverride?.tagLabel ?? targetWeek?.tags[0] ?? '커리큘럼', + title: this.optionalTrim(input.title) ?? existingOverride?.title ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`, + duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-', + overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '', + deliverable: this.optionalTrim(input.deliverable) ?? existingOverride?.deliverable ?? targetWeek?.deliverable ?? '', + requiresSourceSubmission: input.requiresSourceSubmission ?? existingOverride?.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true, + learningSteps, + topics: topicTitles, + topicDetails: learningSteps.map((step) => step.description), + topicVideoUrls: learningSteps.map((step) => step.videoUrl), + practiceContents: practiceContents ?? this.practiceContentsArray(existingOverride?.practiceContents) ?? targetWeek?.practices.map((practice) => ({ + title: practice.title, + description: practice.description, + })) ?? [], + disabledModuleIds: disabledModuleIds ?? this.stringArray(existingOverride?.disabledModuleIds) ?? [], + }, + create: { + organizationId, + organizationName, + courseId: course.id, + week: input.activeWeek, + tagLabel: selectedTagLabel ?? targetWeek?.tags[0] ?? '커리큘럼', + title: this.optionalTrim(input.title) ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`, + duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-', + overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '', + deliverable: this.optionalTrim(input.deliverable) ?? targetWeek?.deliverable ?? '', + requiresSourceSubmission: input.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true, + learningSteps, + topics: topicTitles, + topicDetails: learningSteps.map((step) => step.description), + topicVideoUrls: learningSteps.map((step) => step.videoUrl), + practiceContents, + disabledModuleIds: disabledModuleIds ?? [], + }, + }); + } + + if (input.visualizationPlatformKeys) { + const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean))); + const validPlatforms = selectedKeys.length + ? await this.prisma.visualizationPlatform.findMany({ + where: { + key: { in: selectedKeys }, + enabled: true, + }, + select: { key: true }, + }) + : []; + const validKeySet = new Set(validPlatforms.map((platform) => platform.key)); + + await this.prisma.courseWeekVisualizationPlatform.deleteMany({ + where: { + courseId: course.id, + week: input.activeWeek, + platformKey: { notIn: selectedKeys }, + }, + }); + + await Promise.all(selectedKeys.filter((key) => validKeySet.has(key)).map((key) => ( + this.prisma.courseWeekVisualizationPlatform.upsert({ + where: { + courseId_week_platformKey: { + courseId: course.id, + week: input.activeWeek, + platformKey: key, + }, + }, + create: { + courseId: course.id, + week: input.activeWeek, + platformKey: key, + enabled: true, + }, + update: { + enabled: true, + }, + }) + ))); + } + + if (applyToStudents) { + const targetGroup = selectedGroup; + if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.'); + const existingSetting = await this.prisma.instructorCurriculumSetting.findFirst({ + where: { + organizationName, + courseId: course.id, + groupId: targetGroup.id, + }, + select: { id: true }, + }); + + if (existingSetting) { + await this.prisma.instructorCurriculumSetting.update({ + where: { id: existingSetting.id }, + data: { + organizationId, + courseId: course.id, + activeWeek: input.activeWeek, + activeLearningStep, + }, + }); + } else { + await this.prisma.instructorCurriculumSetting.create({ + data: { + organizationId, + organizationName, + groupId: targetGroup.id, + courseId: course.id, + activeWeek: input.activeWeek, + activeLearningStep, + }, + }); + } + + await this.chatGateway.notifyGroupMembers(targetGroup.id, 'curriculum.updated', { + type: 'curriculum.updated', + organizationName, + groupId: targetGroup.id, + groupName: targetGroup.name, + activeWeek: input.activeWeek, + activeLearningStep, + updatedAt: new Date().toISOString(), + }); + } + + return this.getCurriculum(accessToken, selectedGroup?.id ?? input.groupId ?? null, course.id); + } + + async deleteCurriculumWeek(accessToken: string | undefined, week: number) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const course = await this.findInstructorCourse(organizationName, instructor.id); + if (!course) throw new NotFoundException('Course not found'); + + const baseWeek = course.weeks.find((courseWeek) => courseWeek.week === week); + if (baseWeek) { + throw new ConflictException('Base curriculum week cannot be deleted'); + } + + const deleted = await this.prisma.instructorCurriculumWeekOverride.deleteMany({ + where: { + organizationName, + courseId: course.id, + week, + }, + }); + if (deleted.count === 0) throw new NotFoundException('Curriculum week not found'); + + const setting = await this.ensureCurriculumSetting( + organizationId, + organizationName, + null, + course.id, + course.weeks[0]?.week ?? 1, + course.weeks[0]?.topics[0]?.title ?? null, + ); + + if (setting.activeWeek === week) { + const remainingOverrides = await this.prisma.instructorCurriculumWeekOverride.findMany({ + where: { + organizationName, + courseId: course.id, + }, + orderBy: { week: 'asc' }, + }); + const fallbackBaseWeek = course.weeks[0]; + const fallbackOverride = remainingOverrides[0]; + const fallbackWeek = fallbackBaseWeek?.week ?? fallbackOverride?.week ?? 1; + const fallbackLearningStep = + fallbackBaseWeek?.topics[0]?.title ?? + this.stringArray(fallbackOverride?.topics)?.[0] ?? + null; + + await this.prisma.instructorCurriculumSetting.update({ + where: { id: setting.id }, + data: { + organizationId, + courseId: course.id, + activeWeek: fallbackWeek, + activeLearningStep: fallbackLearningStep, + }, + }); + + this.chatGateway.notifyOrganization(organizationName, 'curriculum.updated', { + type: 'curriculum.updated', + organizationName, + activeWeek: fallbackWeek, + activeLearningStep: fallbackLearningStep, + updatedAt: new Date().toISOString(), + }); + } + + return this.getCurriculum(accessToken, null, course.id); + } + + async uploadCurriculumReference( + accessToken: string | undefined, + input: { courseId?: string | null; week: number; title?: string | null; kind?: 'REFERENCE' | 'EXAMPLE_CODE' }, + file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer } | undefined, + ) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + if (!file?.buffer) throw new BadRequestException('파일이 필요합니다.'); + + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + const targetWeek = course.weeks.find((courseWeek) => courseWeek.week === input.week); + if (!targetWeek) throw new NotFoundException('Course week not found'); + const kind = input.kind ?? 'REFERENCE'; + + const originalFileName = this.normalizeUploadedFileName(file.originalname); + await mkdir(COURSE_REFERENCE_UPLOAD_ROOT, { recursive: true }); + const storedFileName = `${randomUUID()}${extname(originalFileName || '')}`; + await writeFile(join(COURSE_REFERENCE_UPLOAD_ROOT, storedFileName), file.buffer); + + const maxSortOrder = await this.prisma.courseWeekReference.aggregate({ + where: { weekId: targetWeek.id, kind }, + _max: { sortOrder: true }, + }); + await this.prisma.courseWeekReference.create({ + data: { + weekId: targetWeek.id, + kind, + title: this.optionalTrim(input.title) ?? originalFileName, + originalFileName, + storedFileName, + contentType: file.mimetype, + fileSizeBytes: file.size ?? file.buffer.length, + sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1, + }, + }); + + return this.getCurriculum(accessToken, null, course.id); + } + + async deleteCurriculumReference( + accessToken: string | undefined, + referenceId: string, + courseId?: string | null, + ) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const reference = await this.prisma.courseWeekReference.findUnique({ + where: { id: referenceId }, + include: { week: { include: { course: true } } }, + }); + if (!reference) throw new NotFoundException('Reference not found'); + if ( + reference.week.course.teacherId !== instructor.id || + reference.week.course.organizationName !== organizationName || + (courseId?.trim() && reference.week.courseId !== courseId.trim()) + ) { + throw new ForbiddenException('Reference is not owned by instructor'); + } + + await this.prisma.courseWeekReference.delete({ where: { id: reference.id } }); + await unlink(join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName)).catch(() => undefined); + + return this.getCurriculum(accessToken, null, reference.week.courseId); + } + + async createPlatform( + accessToken: string | undefined, + input: CreateInstructorPlatformDto, + ) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + if (!input.agentNodeId?.trim() || !input.agentHostname?.trim()) { + throw new BadRequestException('Agent binding is required'); + } + + const masterPlatform = await this.prisma.platform.findFirst({ + where: { + id: input.platformId, + isActive: true, + curriculumTagOnly: false, + }, + select: { + id: true, + displayName: true, + vehicleType: true, + wheelbaseMeters: true, + maxSpeedMetersPerSecond: true, + maxSteeringAngleDegrees: true, + sensors: true, + drivers: true, + algorithms: true, + defaultStudioSettings: true, + controlDurationMinutes: true, + controlWarningMinutes: true, + }, + }); + if (!masterPlatform) throw new NotFoundException('Platform master not found'); + + const baseName = this.toPlatformBaseName(masterPlatform.displayName); + const existing = await this.prisma.instructorPlatform.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + displayName: { + startsWith: baseName, + }, + }, + select: { + displayName: true, + instanceNo: true, + }, + }); + const usedNumbers = new Set( + existing.map((platform) => platform.instanceNo || this.parsePlatformNo(platform.displayName)), + ); + let nextNo = 1; + while (usedNumbers.has(nextNo)) nextNo += 1; + const displayName = this.normalizeDisplayName(input.displayName) ?? `${baseName} #${String(nextNo).padStart(2, '0')}`; + + const studioSettingsOverride = { + ...this.toJsonObject(masterPlatform.defaultStudioSettings), + ...(input.agentNodeId ? { agentNodeId: input.agentNodeId } : {}), + ...(input.agentHostname ? { agentHostname: input.agentHostname } : {}), + ...(input.agentOwnerEmail ? { agentOwnerEmail: input.agentOwnerEmail } : {}), + ...(input.agentHostname ? { gatewayPlatformId: input.agentHostname } : {}), + ...(input.sshUser?.trim() ? { sshUser: input.sshUser.trim() } : {}), + ...(input.sshPort?.trim() ? { sshPort: input.sshPort.trim() } : {}), + ...(input.workspaceRoot?.trim() ? { workspaceRoot: input.workspaceRoot.trim() } : {}), + }; + + try { + await this.prisma.instructorPlatform.create({ + data: { + organizationId, + organizationName, + platformId: masterPlatform.id, + displayName, + instanceNo: nextNo, + vehicleType: masterPlatform.vehicleType, + wheelbaseMeters: masterPlatform.wheelbaseMeters, + maxSpeedMetersPerSecond: masterPlatform.maxSpeedMetersPerSecond, + maxSteeringAngleDegrees: masterPlatform.maxSteeringAngleDegrees, + sensors: masterPlatform.sensors ?? [], + drivers: masterPlatform.drivers ?? [], + algorithms: masterPlatform.algorithms ?? {}, + studioSettingsOverride: studioSettingsOverride as Prisma.InputJsonValue, + controlDurationMinutes: masterPlatform.controlDurationMinutes, + controlWarningMinutes: masterPlatform.controlWarningMinutes, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('Platform name already exists'); + } + throw error; + } + + return this.listPlatforms(accessToken); + } + + async updatePlatform( + accessToken: string | undefined, + platformId: string, + input: UpdateInstructorPlatformDto, + ) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const displayName = this.normalizeDisplayName(input.displayName); + if (!displayName) throw new BadRequestException('Platform name is required'); + const controlDurationMinutes = this.toBoundedInt(input.controlDurationMinutes, 30, 1, 240); + const controlWarningMinutes = this.toBoundedInt(input.controlWarningMinutes, 5, 0, 60); + if (controlWarningMinutes >= controlDurationMinutes) { + throw new BadRequestException('Warning time must be shorter than control time'); + } + const vehicleType = this.normalizeDisplayName(input.vehicleType) ?? 'MK-mini'; + const wheelbaseMeters = this.toBoundedNumber(input.wheelbaseMeters, 0.325, 0, 10); + const maxSpeedMetersPerSecond = this.toBoundedNumber(input.maxSpeedMetersPerSecond, 7, 0, 100); + const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90); + const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true }); + const algorithms = this.normalizeAlgorithms(input.algorithms); + + const platform = await this.prisma.instructorPlatform.findFirst({ + where: { + id: platformId, + ...this.organizationWhere( + organizationId, + organizationName, + ), + }, + select: { + id: true, + displayName: true, + }, + }); + if (!platform) throw new NotFoundException('Platform not found'); + + try { + await this.prisma.$transaction([ + this.prisma.instructorPlatform.update({ + where: { id: platform.id }, + data: { + displayName, + vehicleType, + wheelbaseMeters, + maxSpeedMetersPerSecond, + maxSteeringAngleDegrees, + sensors, + algorithms, + controlDurationMinutes, + controlWarningMinutes, + }, + }), + this.prisma.studentGroup.updateMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: platform.id, + }, + data: { + platformName: displayName, + }, + }), + ]); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('Platform name already exists'); + } + throw error; + } + + return this.listPlatforms(accessToken); + } + + async assignPlatformToGroup( + accessToken: string | undefined, + platformId: string, + groupId: string, + force = false, + ) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const platform = await this.prisma.instructorPlatform.findFirst({ + where: { + id: platformId, + ...this.organizationWhere( + organizationId, + organizationName, + ), + }, + select: { + id: true, + displayName: true, + }, + }); + if (!platform) throw new NotFoundException('Platform not found'); + + const group = await this.prisma.studentGroup.findFirst({ + where: { + id: groupId, + ...this.organizationWhere( + organizationId, + organizationName, + ), + }, + select: { + id: true, + name: true, + instructorPlatformId: true, + }, + }); + if (!group) throw new NotFoundException('Group not found'); + if (group.instructorPlatformId && group.instructorPlatformId !== platform.id) { + throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.'); + } + + const activeSession = await this.prisma.platformControlSession.findFirst({ + where: { + instructorPlatformId: platform.id, + status: 'ACTIVE', + }, + orderBy: { startedAt: 'desc' }, + include: { + holderUser: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + if (activeSession && !force) { + throw new ConflictException({ + code: 'PLATFORM_IN_USE', + message: '현재 학생이 플랫폼을 사용 중입니다.', + activeSession: { + id: activeSession.id, + holderUserId: activeSession.holderUserId, + holderName: activeSession.holderUser?.name ?? activeSession.holderUser?.email ?? null, + holderEmail: activeSession.holderUser?.email ?? null, + startedAt: activeSession.startedAt, + endsAt: activeSession.endsAt, + }, + }); + } + + const now = new Date(); + await this.prisma.$transaction(async (tx) => { + await tx.studentGroup.updateMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: platform.id, + NOT: { id: group.id }, + }, + data: { + instructorPlatformId: null, + platformName: null, + }, + }); + await tx.studentGroup.update({ + where: { id: group.id }, + data: { + instructorPlatformId: platform.id, + platformName: platform.displayName, + }, + }); + if (activeSession && force) { + await tx.platformControlSession.updateMany({ + where: { + id: activeSession.id, + status: 'ACTIVE', + }, + data: { + status: 'RELEASED', + releasedAt: now, + endReason: 'GROUP_ASSIGNMENT_CHANGED', + }, + }); + await tx.platformControlQueueEntry.updateMany({ + where: { + instructorPlatformId: platform.id, + status: 'WAITING', + }, + data: { + status: 'CANCELED', + canceledAt: now, + }, + }); + } + }); + + if (activeSession && force) { + this.chatGateway.notifyUser(activeSession.holderUserId, { + type: 'platform.control.terminated', + sessionId: activeSession.id, + platformId: null, + instructorPlatformId: platform.id, + reason: 'GROUP_ASSIGNMENT_CHANGED', + message: `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`, + endedAt: now.toISOString(), + }); + } + await this.syncPlatformAccessPolicy(organizationId, organizationName); + return this.listPlatforms(accessToken); + } + + async deletePlatform(accessToken: string | undefined, platformId: string, force = false) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const platform = await this.prisma.instructorPlatform.findFirst({ + where: { + id: platformId, + ...this.organizationWhere( + organizationId, + organizationName, + ), + }, + select: { + id: true, + displayName: true, + }, + }); + if (!platform) throw new NotFoundException('Platform not found'); + + const activeSession = await this.prisma.platformControlSession.findFirst({ + where: { + instructorPlatformId: platform.id, + status: 'ACTIVE', + }, + orderBy: { startedAt: 'desc' }, + include: { + holderUser: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + if (activeSession && !force) { + throw new ConflictException({ + code: 'PLATFORM_IN_USE', + message: '현재 학생이 플랫폼을 사용 중입니다.', + activeSession: { + id: activeSession.id, + holderUserId: activeSession.holderUserId, + holderName: activeSession.holderUser?.name ?? activeSession.holderUser?.email ?? null, + holderEmail: activeSession.holderUser?.email ?? null, + startedAt: activeSession.startedAt, + endsAt: activeSession.endsAt, + }, + }); + } + + const now = new Date(); + const terminatedSessionNotice = activeSession && force + ? { + userId: activeSession.holderUserId, + sessionId: activeSession.id, + } + : null; + await this.prisma.$transaction([ + this.prisma.studentGroup.updateMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: platform.id, + }, + data: { + instructorPlatformId: null, + platformName: null, + }, + }), + this.prisma.platformControlSession.updateMany({ + where: { + instructorPlatformId: platform.id, + status: 'ACTIVE', + }, + data: { + status: 'RELEASED', + releasedAt: now, + endReason: 'PLATFORM_DELETED', + }, + }), + this.prisma.platformControlQueueEntry.updateMany({ + where: { + instructorPlatformId: platform.id, + status: 'WAITING', + }, + data: { + status: 'CANCELED', + canceledAt: now, + }, + }), + this.prisma.platformControlSession.updateMany({ + where: { + instructorPlatformId: platform.id, + }, + data: { + instructorPlatformId: null, + }, + }), + this.prisma.platformControlQueueEntry.updateMany({ + where: { + instructorPlatformId: platform.id, + }, + data: { + instructorPlatformId: null, + }, + }), + this.prisma.instructorPlatform.delete({ + where: { id: platform.id }, + }), + ]); + + await this.syncPlatformAccessPolicy(organizationId, organizationName); + if (terminatedSessionNotice) { + this.chatGateway.notifyUser(terminatedSessionNotice.userId, { + type: 'platform.control.terminated', + sessionId: terminatedSessionNotice.sessionId, + platformId: null, + instructorPlatformId: platform.id, + reason: 'PLATFORM_DELETED', + message: `${platform.displayName} 플랫폼 사용이 강사에 의해 종료되었습니다.`, + endedAt: now.toISOString(), + }); + } + return this.listPlatforms(accessToken); + } + + async createGroup(accessToken: string | undefined, input: SaveInstructorGroupDto) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const name = input.name.trim(); + if (!name) throw new BadRequestException('Group name is required'); + + const memberIds = await this.validateActiveStudentIds( + input.memberIds ?? [], + organizationId, + organizationName, + instructor.id, + input.courseId, + ); + const assignedPlatform = await this.resolveInstructorPlatformAssignment(input, organizationId, organizationName); + + try { + await this.prisma.$transaction(async (tx) => { + const group = await tx.studentGroup.create({ + data: { + organizationId, + organizationName, + name, + week: 1, + status: '진행중', + instructorPlatformId: assignedPlatform?.id ?? null, + platformName: assignedPlatform?.displayName ?? null, + }, + select: { id: true }, + }); + + if (memberIds.length > 0) { + await tx.studentGroupMember.deleteMany({ + where: { + userId: { in: memberIds }, + group: { + OR: [ + organizationId ? { organizationId } : undefined, + { organizationName }, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + }, + }); + await tx.studentGroupMember.createMany({ + data: memberIds.map((userId) => ({ + groupId: group.id, + userId, + })), + skipDuplicates: true, + }); + } + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('Group already exists'); + } + throw error; + } + + await this.syncPlatformAccessPolicy(organizationId, organizationName); + return this.listGroups(accessToken, input.courseId); + } + + async updateGroup( + accessToken: string | undefined, + groupId: string, + input: SaveInstructorGroupDto, + ) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const group = await this.findInstructorGroup(groupId, organizationId, organizationName); + const name = input.name.trim(); + if (!name) throw new BadRequestException('Group name is required'); + const memberIds = await this.validateActiveStudentIds( + input.memberIds ?? [], + organizationId, + organizationName, + instructor.id, + input.courseId, + ); + const assignedPlatform = await this.resolveInstructorPlatformAssignment(input, organizationId, organizationName); + const previousAssignment = await this.getGroupPlatformAssignment(group.id); + const termination = await this.prepareGroupPlatformTermination({ + groupId: group.id, + previousPlatformId: previousAssignment.instructorPlatformId, + previousPlatformName: previousAssignment.platformName, + previousMemberIds: previousAssignment.memberIds, + nextPlatformId: assignedPlatform?.id ?? null, + nextMemberIds: memberIds, + reason: 'GROUP_ASSIGNMENT_CHANGED', + message: '플랫폼 배정이 변경되어 사용이 종료되었습니다.', + }); + + try { + await this.prisma.$transaction(async (tx) => { + await tx.studentGroup.update({ + where: { id: group.id }, + data: { + name, + instructorPlatformId: assignedPlatform?.id ?? null, + platformName: assignedPlatform?.displayName ?? null, + }, + }); + await tx.studentGroupMember.deleteMany({ + where: { groupId: group.id }, + }); + if (memberIds.length > 0) { + await tx.studentGroupMember.deleteMany({ + where: { + userId: { in: memberIds }, + group: { + OR: [ + organizationId ? { organizationId } : undefined, + { organizationName }, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + }, + }); + await tx.studentGroupMember.createMany({ + data: memberIds.map((userId) => ({ + groupId: group.id, + userId, + })), + skipDuplicates: true, + }); + } + if (termination.sessionIds.length > 0) { + await tx.platformControlSession.updateMany({ + where: { + id: { in: termination.sessionIds }, + status: 'ACTIVE', + }, + data: { + status: 'RELEASED', + releasedAt: termination.endedAt, + endReason: termination.reason, + }, + }); + } + if (termination.queueUserIds.length > 0 && previousAssignment.instructorPlatformId) { + await tx.platformControlQueueEntry.updateMany({ + where: { + groupId: group.id, + instructorPlatformId: previousAssignment.instructorPlatformId, + status: 'WAITING', + userId: { in: termination.queueUserIds }, + }, + data: { + status: 'CANCELED', + canceledAt: termination.endedAt, + }, + }); + } + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('Group already exists'); + } + throw error; + } + + await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason); + return this.listGroups(accessToken, input.courseId); + } + + async deleteGroup(accessToken: string | undefined, groupId: string, courseId?: string | null) { + const { authUser } = await this.getInstructor(accessToken); + const organizationId = authUser.primaryAffiliation?.organizationId; + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + + const group = await this.findInstructorGroup(groupId, organizationId, organizationName); + const previousAssignment = await this.getGroupPlatformAssignment(group.id); + const termination = await this.prepareGroupPlatformTermination({ + groupId: group.id, + previousPlatformId: previousAssignment.instructorPlatformId, + previousPlatformName: previousAssignment.platformName, + previousMemberIds: previousAssignment.memberIds, + nextPlatformId: null, + nextMemberIds: [], + reason: 'GROUP_DELETED', + message: '그룹 배정이 삭제되어 플랫폼 사용이 종료되었습니다.', + }); + + await this.prisma.$transaction(async (tx) => { + if (termination.sessionIds.length > 0) { + await tx.platformControlSession.updateMany({ + where: { + id: { in: termination.sessionIds }, + status: 'ACTIVE', + }, + data: { + status: 'RELEASED', + releasedAt: termination.endedAt, + endReason: termination.reason, + }, + }); + } + if (termination.queueUserIds.length > 0 && previousAssignment.instructorPlatformId) { + await tx.platformControlQueueEntry.updateMany({ + where: { + groupId: group.id, + instructorPlatformId: previousAssignment.instructorPlatformId, + status: 'WAITING', + userId: { in: termination.queueUserIds }, + }, + data: { + status: 'CANCELED', + canceledAt: termination.endedAt, + }, + }); + } + await tx.studentGroupMember.deleteMany({ + where: { groupId: group.id }, + }); + await tx.studentGroup.delete({ + where: { id: group.id }, + }); + }); + + await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason); + return this.listGroups(accessToken, courseId); + } + + private async getGroupPlatformAssignment(groupId: string) { + const group = await this.prisma.studentGroup.findUnique({ + where: { id: groupId }, + select: { + instructorPlatformId: true, + instructorPlatform: { + select: { displayName: true }, + }, + members: { + select: { userId: true }, + }, + }, + }); + + return { + instructorPlatformId: group?.instructorPlatformId ?? null, + platformName: group?.instructorPlatform?.displayName ?? null, + memberIds: group?.members.map((member) => member.userId) ?? [], + }; + } + + private async prepareGroupPlatformTermination(input: { + groupId: string; + previousPlatformId: string | null; + previousPlatformName: string | null; + previousMemberIds: string[]; + nextPlatformId: string | null; + nextMemberIds: string[]; + reason: string; + message: string; + }) { + const endedAt = new Date(); + if (!input.previousPlatformId) { + return { + endedAt, + reason: input.reason, + sessionIds: [] as string[], + queueUserIds: [] as string[], + notices: [] as Array<{ + userId: string; + sessionId: string; + instructorPlatformId: string; + message: string; + }>, + }; + } + + const nextMemberIds = new Set(input.nextMemberIds); + const platformChanged = input.previousPlatformId !== input.nextPlatformId; + const affectedUserIds = platformChanged + ? input.previousMemberIds + : input.previousMemberIds.filter((userId) => !nextMemberIds.has(userId)); + + if (affectedUserIds.length === 0) { + return { + endedAt, + reason: input.reason, + sessionIds: [] as string[], + queueUserIds: [] as string[], + notices: [] as Array<{ + userId: string; + sessionId: string; + instructorPlatformId: string; + message: string; + }>, + }; + } + + const sessions = await this.prisma.platformControlSession.findMany({ + where: { + groupId: input.groupId, + instructorPlatformId: input.previousPlatformId, + holderUserId: { in: affectedUserIds }, + status: 'ACTIVE', + }, + select: { + id: true, + holderUserId: true, + instructorPlatformId: true, + }, + }); + const platformName = input.previousPlatformName ?? '플랫폼'; + + return { + endedAt, + reason: input.reason, + sessionIds: sessions.map((session) => session.id), + queueUserIds: affectedUserIds, + notices: sessions + .filter((session) => session.instructorPlatformId) + .map((session) => ({ + userId: session.holderUserId, + sessionId: session.id, + instructorPlatformId: session.instructorPlatformId as string, + message: `${platformName} ${input.message}`, + })), + }; + } + + private notifyPlatformTerminatedSessions( + notices: Array<{ + userId: string; + sessionId: string; + instructorPlatformId: string; + message: string; + }>, + endedAt: Date, + reason: string, + ) { + for (const notice of notices) { + this.chatGateway.notifyUser(notice.userId, { + type: 'platform.control.terminated', + sessionId: notice.sessionId, + platformId: null, + instructorPlatformId: notice.instructorPlatformId, + reason, + message: notice.message, + endedAt: endedAt.toISOString(), + }); + } + } + + private async syncPlatformAccessPolicy( + organizationId: string | undefined, + organizationName: string, + ) { + const groups = await this.prisma.studentGroup.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: { not: null }, + }, + include: { + instructorPlatform: { + select: { + id: true, + displayName: true, + studioSettingsOverride: true, + }, + }, + members: { + include: { + user: { + select: { + email: true, + }, + }, + }, + }, + }, + }); + + const assignments = groups + .map((group): GatewayAccessAssignment | null => { + const platform = group.instructorPlatform; + if (!platform) return null; + const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); + const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id; + const studentEmails = group.members + .map((member) => member.user.email) + .filter(Boolean); + if (!studentEmails.length) return null; + return { + platformId: gatewayPlatformId, + platformName: platform.displayName, + agentHostname, + agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'), + studentEmails, + }; + }) + .filter((assignment): assignment is GatewayAccessAssignment => Boolean(assignment)); + + await this.platformGatewayClient.syncAccess(assignments); + } + + private async findInstructorGroup( + groupId: string, + organizationId: string | undefined, + organizationName: string, + ) { + const group = await this.prisma.studentGroup.findFirst({ + where: { + id: groupId, + OR: [ + organizationId ? { organizationId } : undefined, + { organizationName }, + ].filter(Boolean) as Prisma.StudentGroupWhereInput[], + }, + select: { id: true }, + }); + + if (!group) throw new NotFoundException('Group not found'); + return group; + } + + private async validateActiveStudentIds( + memberIds: string[], + organizationId: string | undefined, + organizationName: string, + instructorId: string, + courseId?: string | null, + ) { + const uniqueIds = [...new Set(memberIds.filter(Boolean))]; + if (uniqueIds.length === 0) return []; + + const course = await this.findInstructorCourse(organizationName, instructorId, courseId); + const activeStudents = await this.prisma.studentEnrollment.findMany({ + where: { + userId: { in: uniqueIds }, + status: 'ACTIVE', + ...(course ? { courseId: course.id } : { course: { teacherId: instructorId } }), + }, + select: { userId: true }, + }); + const activeIds = activeStudents.map((student) => student.userId); + + if (activeIds.length !== uniqueIds.length) { + throw new BadRequestException('Invalid group member'); + } + + return activeIds; + } + + private async findUnassignedStudents( + organizationId: string | undefined, + organizationName: string | undefined, + instructorId: string, + courseId?: string | null, + ) { + if (!organizationName) return []; + const course = await this.findInstructorCourse(organizationName, instructorId, courseId); + const enrollmentScope: Prisma.StudentEnrollmentWhereInput = course + ? { courseId: course.id } + : { course: { teacherId: instructorId } }; + + const enrollments = await this.prisma.studentEnrollment.findMany({ + where: { + status: 'ACTIVE', + ...enrollmentScope, + user: { + AND: [ + { + OR: [ + { authRole: null }, + { authRole: 'STUDENT' }, + { authRole: 'USER' }, + ], + }, + { + groupMemberships: { + none: { + group: { + members: { + some: { + user: { + enrollments: { + some: { + status: 'ACTIVE', + ...enrollmentScope, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + orderBy: { updatedAt: 'desc' }, + include: { user: true }, + }); + + return enrollments.map((enrollment) => ({ + id: enrollment.user.id, + name: enrollment.user.name ?? enrollment.user.email.split('@')[0], + email: enrollment.user.email, + })); + } + + private async findPlatformOptions( + organizationId: string | undefined, + organizationName: string | undefined, + ) { + if (!organizationId && !organizationName) return []; + + const platforms = await this.prisma.instructorPlatform.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + platform: { + is: { + NOT: [ + { platformKey: { contains: 'local', mode: 'insensitive' } }, + { platformKey: { contains: 'simulator', mode: 'insensitive' } }, + { displayName: { contains: '로컬', mode: 'insensitive' } }, + { displayName: { contains: '시뮬', mode: 'insensitive' } }, + { displayName: { contains: 'simulator', mode: 'insensitive' } }, + ], + }, + }, + }, + orderBy: { displayName: 'asc' }, + select: { id: true, displayName: true }, + }); + + return platforms.map((platform) => ({ + id: platform.id, + name: platform.displayName, + })); + } + + private normalizePlatformName(platformName: string | null | undefined) { + const value = platformName?.trim(); + return value && value !== 'unassigned' && value !== '미배정' ? value : null; + } + + private async resolveInstructorPlatformAssignment( + input: Pick, + organizationId: string | undefined, + organizationName: string, + ) { + const instructorPlatformId = input.instructorPlatformId?.trim(); + const platformName = this.normalizePlatformName(input.platformName); + if (!instructorPlatformId && !platformName) return null; + + const platformSelector: Prisma.InstructorPlatformWhereInput = instructorPlatformId + ? { id: instructorPlatformId } + : { displayName: platformName as string }; + + const platform = await this.prisma.instructorPlatform.findFirst({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + ...platformSelector, + }, + select: { + id: true, + displayName: true, + }, + }); + if (!platform) throw new BadRequestException('Assigned platform not found'); + return platform; + } + + private normalizeDisplayName(displayName: string | null | undefined) { + const value = displayName?.trim().replace(/\s+/g, ' '); + return value || null; + } + + private readStringSetting(settings: unknown, key: string) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null; + const value = (settings as Record)[key]; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private findBoundAgent( + agents: GatewayAgent[], + binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null }, + ) { + const bindingTokens = [binding.nodeId, binding.hostname, binding.ownerEmail] + .map((value) => this.normalizeAgentToken(value)) + .filter(Boolean); + if (!bindingTokens.length) return null; + + return agents.find((agent) => { + const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip, agent.user] + .map((value) => this.normalizeAgentToken(value)) + .filter(Boolean); + return bindingTokens.some((bindingToken) => agentTokens.some((agentToken) => ( + agentToken === bindingToken || + agentToken.includes(bindingToken) || + bindingToken.includes(agentToken) + ))); + }) ?? null; + } + + private normalizeAgentToken(value: string | null | undefined) { + return value?.trim().toLowerCase().replace(/\s+/g, '-') ?? ''; + } + + private toJsonObject(settings: unknown): Record { + return settings && typeof settings === 'object' && !Array.isArray(settings) + ? { ...(settings as Record) } + : {}; + } + + private async findInstructorCourse(organizationName: string, instructorId: string, courseId?: string | null) { + return this.prisma.course.findFirst({ + where: { + isActive: true, + teacherId: instructorId, + ...(courseId?.trim() + ? { id: courseId.trim(), organizationName } + : { organizationName }), + }, + include: { + weeks: { + orderBy: { week: 'asc' }, + include: { + topics: { + orderBy: { sortOrder: 'asc' }, + }, + practices: { + orderBy: { sortOrder: 'asc' }, + }, + references: { + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }, + modules: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + platforms: { + orderBy: { sortOrder: 'asc' }, + include: { + platform: true, + }, + }, + }, + }); + } + + private async ensureCurriculumSetting( + organizationId: string | undefined, + organizationName: string, + groupId: string | null, + courseId: string, + activeWeek: number, + activeLearningStep: string | null, + ) { + const existing = await this.prisma.instructorCurriculumSetting.findFirst({ + where: { + organizationName, + courseId, + groupId, + }, + }); + if (existing) return existing; + + return this.prisma.instructorCurriculumSetting.create({ + data: { + organizationId, + organizationName, + groupId, + courseId, + activeWeek, + activeLearningStep, + }, + }); + } + + private async findOptionalInstructorGroup( + groupId: string | null | undefined, + organizationId: string | undefined, + organizationName: string, + ) { + const requestedGroupId = groupId?.trim(); + if (!requestedGroupId || requestedGroupId === 'all' || requestedGroupId === 'default') return null; + + const group = await this.prisma.studentGroup.findFirst({ + where: { + id: requestedGroupId, + organizationName, + ...(organizationId ? { organizationId } : {}), + }, + select: { + id: true, + name: true, + week: true, + }, + }); + if (!group) throw new BadRequestException('Invalid group'); + return group; + } + + private stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return value.filter((item): item is string => typeof item === 'string'); + } + + private resolveLearningSteps( + value: unknown, + topics: string[], + topicDetails: string[] = [], + topicVideoUrls: string[] = [], + ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> { + return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? []; + } + + private normalizeLearningSteps( + value?: unknown, + topics: string[] = [], + topicDetails: string[] = [], + topicVideoUrls: string[] = [], + ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null { + if (Array.isArray(value)) { + const steps = value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const record = item as Record; + const title = typeof record.title === 'string' ? record.title.trim() : ''; + const description = typeof record.description === 'string' ? record.description.trim() : ''; + const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : ''; + const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl); + const concepts = Array.isArray(record.concepts) + ? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean) + : []; + return title || description || videoUrl || concepts.length + ? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts } + : null; + }) + .filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item)); + return steps.length ? steps : null; + } + + const titles = topics.map((topic) => topic.trim()).filter(Boolean); + if (!titles.length) return null; + return titles.map((title, index) => { + const videoUrl = topicVideoUrls[index]?.trim() ?? ''; + return { + title, + description: topicDetails[index]?.trim() ?? '', + videoEnabled: Boolean(videoUrl), + videoUrl, + concepts: [], + }; + }); + } + + private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null { + if (!Array.isArray(value)) return null; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const record = item as Record; + const title = typeof record.title === 'string' ? record.title.trim() : ''; + const description = typeof record.description === 'string' ? record.description.trim() : ''; + return title || description ? { title, description } : null; + }) + .filter((item): item is { title: string; description: string } => Boolean(item)); + } + + private normalizePracticeContents(value: Array<{ title?: string; description?: string }> | undefined) { + if (!value) return undefined; + return value + .map((item) => ({ + title: item.title?.trim() ?? '', + description: item.description?.trim() ?? '', + })) + .filter((item) => item.title || item.description); + } + + private normalizeUploadedFileName(fileName: string) { + if (!fileName) return 'reference'; + const restored = Buffer.from(fileName, 'latin1').toString('utf8'); + return restored.includes('\uFFFD') ? fileName : restored; + } + + private serializeWeekReference( + reference: { + id: string; + kind: string; + title: string; + originalFileName: string; + contentType: string | null; + fileSizeBytes: number; + createdAt: Date; + }, + week: number, + ) { + return { + id: reference.id, + kind: reference.kind, + title: reference.title, + description: reference.originalFileName, + fileName: reference.originalFileName, + contentType: reference.contentType, + fileSizeBytes: reference.fileSizeBytes, + week, + weekNumber: week, + downloadUrl: `/curriculum/references/${reference.id}/download`, + createdAt: reference.createdAt.toISOString(), + }; + } + + private resolveWeekProgressState( + week: number, + baseState: string, + topics: string[], + setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined, + ) { + if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 }; + if (week < setting.activeWeek) return { state: 'done', progress: 100 }; + if (week > setting.activeWeek) return { state: 'locked', progress: 0 }; + + const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep); + return { + state: progress >= 100 ? 'done' : 'current', + progress, + }; + } + + private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) { + if (!topics.length) return activeLearningStep === null ? 100 : 0; + if (activeLearningStep === null) return 100; + const activeIndex = topics.findIndex((topic) => topic === activeLearningStep); + if (activeIndex < 0) return 0; + return Math.min(99, Math.round((activeIndex / topics.length) * 100)); + } + + private resolveWeekTags(tags: string[], state: string) { + const nextTags = tags.length ? [...tags] : ['커리큘럼']; + nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기'; + return nextTags; + } + + private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) { + const nextTags = tags.length ? [...tags] : ['커리큘럼']; + const label = tagLabel?.trim(); + if (label) nextTags[0] = label; + return nextTags; + } + + private async findCurriculumTagOptions() { + const platforms = await this.prisma.platform.findMany({ + where: { + isActive: true, + curriculumTagOnly: false, + NOT: [ + { platformKey: { contains: 'local', mode: 'insensitive' } }, + { displayName: { contains: '로컬', mode: 'insensitive' } }, + ], + }, + orderBy: { displayName: 'asc' }, + select: { + platformKey: true, + displayName: true, + }, + }); + const options = new Map(); + options.set('none', { key: 'none', label: '없음' }); + + platforms.forEach((platform) => { + const label = this.toPlatformBaseName(platform.displayName); + if (!label || options.has(label)) return; + options.set(label, { + key: platform.platformKey, + label, + }); + }); + + return Array.from(options.values()); + } + + private optionalTrim(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed || null; + } + + private async findMasterPlatforms() { + const platforms = await this.prisma.platform.findMany({ + where: { + isActive: true, + NOT: [ + { platformKey: { contains: 'local', mode: 'insensitive' } }, + { platformKey: { contains: 'simulator', mode: 'insensitive' } }, + { displayName: { contains: '로컬', mode: 'insensitive' } }, + { displayName: { contains: '시뮬', mode: 'insensitive' } }, + { displayName: { contains: 'simulator', mode: 'insensitive' } }, + ], + }, + orderBy: { displayName: 'asc' }, + select: { + id: true, + displayName: true, + description: true, + iconKey: true, + }, + }); + const byBaseName = new Map(); + + platforms.forEach((platform) => { + const baseName = this.toPlatformBaseName(platform.displayName); + if (!byBaseName.has(baseName)) { + byBaseName.set(baseName, { + id: platform.id, + name: baseName, + description: platform.description, + iconKey: platform.iconKey, + }); + } + }); + + return [...byBaseName.values()]; + } + + private organizationWhere( + organizationId: string | undefined, + organizationName: string | undefined, + ): T { + return { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean), + } as T; + } + + private toPlatformBaseName(displayName: string) { + return displayName.replace(/\s*#\d+$/i, '').trim(); + } + + private parsePlatformNo(displayName: string) { + const match = displayName.match(/#(\d+)$/); + return match ? Number(match[1]) : 0; + } + + private toPlatformType(displayName: string) { + return `${this.toPlatformBaseName(displayName)} 플랫폼`; + } + + private formatUptime(minutes: number) { + if (minutes < 60) return `${minutes}분`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`; + } + + private toBoundedInt( + value: number | undefined, + fallback: number, + min: number, + max: number, + ) { + const parsed = Number(value ?? fallback); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); + } + + private toBoundedNumber( + value: number | undefined, + fallback: number, + min: number, + max: number, + ) { + const parsed = Number(value ?? fallback); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); + } + + private normalizeNameDescriptionList( + items: Array<{ title?: string; description?: string; enabled?: boolean }> | undefined, + options: { withEnabled?: boolean } = {}, + ) { + if (!Array.isArray(items)) return []; + return items + .map((item) => { + const normalized = { + title: this.normalizeDisplayName(item.title) ?? '', + description: this.normalizeDisplayName(item.description) ?? '', + }; + return options.withEnabled + ? { ...normalized, enabled: item.enabled !== false } + : normalized; + }) + .filter((item) => item.title || item.description); + } + + private normalizeAlgorithms( + algorithms: { slam?: string; planning?: string; control?: string } | undefined, + ) { + return { + slam: this.normalizeDisplayName(algorithms?.slam) ?? 'Cartographer', + planning: this.normalizeDisplayName(algorithms?.planning) ?? 'A* + DWA', + control: this.normalizeDisplayName(algorithms?.control) ?? 'PID Control', + }; + } + + private async getInstructor(accessToken: string | undefined) { + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + + const { user } = await this.authService.me(accessToken); + const roles = new Set( + [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()), + ); + + if (![...INSTRUCTOR_ROLES].some((role) => roles.has(role))) { + throw new ForbiddenException('Instructor role required'); + } + + const instructor = await this.prisma.user.upsert({ + where: { email: user.email }, + update: this.toUserSyncData(user), + create: { + email: user.email, + ...this.toUserSyncData(user), + }, + }); + + return { authUser: user, instructor }; + } + + private toUserSyncData(user: AuthResponseUser) { + return { + authUserId: user.id, + name: user.name, + authRole: user.role, + primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null, + primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null, + primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null, + department: user.primaryAffiliation?.department ?? null, + position: user.primaryAffiliation?.position ?? null, + }; + } + + private async readSubmissionProgress(owner: string, repoName: string): Promise { + const commits = await this.readSubmissionCommits(owner, repoName); + return { + commits, + finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits), + }; + } + + private buildStudioRepoName(studentEmail: string, week: number, moduleName: string) { + return `${this.slug(studentEmail.split('@')[0] || studentEmail)}-week-${week}-${this.slug(moduleName)}`; + } + + private slug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'workspace'; + } + + private async readSubmissionCommits(owner: string, repoName: string): Promise { + const commits = await this.fetchGiteaJson( + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`, + ).catch(() => []); + + const submissionCommits = commits + .map((commit) => this.normalizeGiteaCommit(commit)) + .filter((commit): commit is Omit => Boolean(commit)) + .filter((commit) => this.isSubmissionCommit(commit)) + .slice(0, 20); + + return Promise.all(submissionCommits.map(async (commit) => ({ + ...commit, + files: await this.readGiteaCommitFiles(owner, repoName, commit.hash), + }))); + } + + private async readFinalSubmissionFiles(owner: string, repoName: string, commits: SubmissionCommit[]): Promise { + if (!commits.length) return []; + if (commits.length === 1) return commits[0].files; + + const latest = commits[0]; + const oldest = commits[commits.length - 1]; + const baseHash = await this.readGiteaCommitParent(owner, repoName, oldest.hash).catch(() => null); + if (baseHash) { + const diffText = await this.fetchGiteaText([ + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`, + `/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`, + ]).catch(() => ''); + const diffFiles = this.parseDiffFiles(diffText).filter((file) => !this.isIgnoredSubmissionFile(file.path)); + if (diffFiles.length) return diffFiles; + } + + const byPath = new Map(); + [...commits].reverse().forEach((commit) => { + commit.files.forEach((file) => { + if (file.status.toLowerCase() === 'deleted') byPath.delete(file.path); + else byPath.set(file.path, file); + }); + }); + return Array.from(byPath.values()); + } + + private async readGiteaCommitParent(owner: string, repoName: string, hash: string): Promise { + const detail = await this.fetchFirstGiteaJson>([ + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`, + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`, + ]); + const parents = Array.isArray(detail.parents) ? detail.parents : []; + const parent = parents[0]; + if (!parent || typeof parent !== 'object') return null; + const item = parent as Record; + return String(item.sha ?? item.id ?? '') || null; + } + + private async readGiteaCommitFiles(owner: string, repoName: string, hash: string): Promise { + const detail = await this.fetchFirstGiteaJson>([ + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`, + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`, + ]).catch(() => null); + const files = Array.isArray(detail?.files) ? detail.files : []; + const diffText = await this.fetchGiteaText([ + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`, + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`, + `/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.diff`, + `/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.patch`, + ]).catch(() => ''); + const diffFiles = this.parseDiffFiles(diffText); + const diffByPath = new Map(diffFiles.map((file) => [file.path, file])); + + if (files.length) { + return files.map((file) => { + const item = file as Record; + const path = String(item.filename ?? item.path ?? ''); + const diffFile = diffByPath.get(path); + return { + path, + status: String(item.status ?? diffFile?.status ?? 'modified'), + patch: String(item.patch ?? '') || diffFile?.patch || '', + }; + }).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path)); + } + + return diffFiles.filter((file) => !this.isIgnoredSubmissionFile(file.path)); + } + + private isIgnoredSubmissionFile(path: string): boolean { + return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json'; + } + + private normalizeGiteaCommit(commit: unknown): Omit | null { + if (!commit || typeof commit !== 'object') return null; + const item = commit as Record; + const nested = (item.commit && typeof item.commit === 'object' ? item.commit : {}) as Record; + const author = (nested.author && typeof nested.author === 'object' ? nested.author : {}) as Record; + const committer = (nested.committer && typeof nested.committer === 'object' ? nested.committer : {}) as Record; + const userAuthor = (item.author && typeof item.author === 'object' ? item.author : {}) as Record; + const hash = String(item.sha ?? item.id ?? ''); + if (!hash) return null; + const message = String(nested.message ?? item.message ?? '').split('\n')[0] ?? ''; + + return { + hash, + shortHash: hash.slice(0, 8), + message, + author: String(author.name ?? userAuthor.login ?? 'unknown'), + authorEmail: String(author.email ?? ''), + date: String(author.date ?? committer.date ?? item.created_at ?? '') || null, + }; + } + + private isSubmissionCommit(commit: Omit): boolean { + const botEmail = this.configService + .get('STUDIO_GIT_AUTHOR_EMAIL', DEFAULT_SUBMISSION_BOT_EMAIL) + .toLowerCase(); + return commit.authorEmail.toLowerCase() === botEmail || commit.message.includes('제출'); + } + + private async fetchGiteaJson(path: string): Promise { + const response = await this.fetchGitea(path); + return response.json() as Promise; + } + + private async fetchFirstGiteaJson(paths: string[]): Promise { + let lastError: unknown = null; + for (const path of paths) { + try { + return await this.fetchGiteaJson(path); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error ? lastError : new BadGatewayException('Gitea API request failed'); + } + + private async fetchGiteaText(paths: string[]): Promise { + for (const path of paths) { + const response = await this.fetchGitea(path).catch(() => null); + if (response?.ok) return response.text(); + } + return ''; + } + + private async fetchGitea(path: string): Promise { + const giteaBaseUrl = this.configService + .get('GITEA_BASE_URL', 'http://localhost:3000') + .replace(/\/+$/, ''); + const token = this.configService.get('GITEA_ADMIN_TOKEN', ''); + if (!token) throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured'); + + const response = await fetch(`${giteaBaseUrl}${path}`, { + headers: { + Authorization: `token ${token}`, + }, + }); + if (!response.ok) { + throw new BadGatewayException(await response.text()); + } + return response; + } + + private parseDiffFiles(diffText: string): SubmissionCommitFile[] { + const files: SubmissionCommitFile[] = []; + let current: SubmissionCommitFile | null = null; + + diffText.split('\n').forEach((line) => { + const match = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (match) { + current = { + path: match[2], + status: 'modified', + patch: '', + }; + files.push(current); + return; + } + if (!current) return; + if (line.startsWith('new file mode ')) current.status = 'added'; + if (line.startsWith('deleted file mode ')) current.status = 'deleted'; + current.patch = `${current.patch}${current.patch ? '\n' : ''}${line}`; + }); + + return files.filter((file) => file.path); + } + + private async grantLmsAppPermission(accessToken: string, authUserId: string) { + const launcherApiBaseUrl = this.configService + .get('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api') + .replace(/\/$/, ''); + const response = await fetch( + `${launcherApiBaseUrl}/apps/${encodeURIComponent('LMS')}/users/${encodeURIComponent(authUserId)}/permission`, + { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + canView: true, + canLaunch: true, + }), + }, + ).catch(() => null); + + if (!response?.ok) { + const payload = await response?.json().catch((): null => null); + throw new BadGatewayException({ + message: 'Launcher app permission request failed', + statusCode: response?.status, + error: payload, + }); + } + } + + private toSummary(students: Array<{ progress: number; online: boolean; issues: number }>) { + const total = students.length; + const averageProgress = total + ? Math.round(students.reduce((sum, student) => sum + student.progress, 0) / total) + : 0; + + return { + totalStudents: total, + onlineStudents: students.filter((student) => student.online).length, + averageProgress, + unresolvedIssues: students.reduce((sum, student) => sum + student.issues, 0), + }; + } + + private toGroupSummary( + groups: Array<{ members: string[]; platform: string; progress: number }>, + ) { + const totalGroups = groups.length; + const assignedPlatforms = groups.filter((group) => group.platform !== '미배정').length; + const totalStudents = groups.reduce((sum, group) => sum + group.members.length, 0); + const averageProgress = totalGroups + ? Math.round(groups.reduce((sum, group) => sum + group.progress, 0) / totalGroups) + : 0; + + return { + totalGroups, + assignedPlatforms, + totalStudents, + averageProgress, + }; + } + + private toPlatformSummary( + platforms: Array<{ status: string; errors: number }>, + ) { + return { + totalPlatforms: platforms.length, + activePlatforms: platforms.filter((platform) => platform.status === '활성').length, + standbyPlatforms: platforms.filter((platform) => platform.status === '대기').length, + errorPlatforms: platforms.filter((platform) => platform.errors > 0 || platform.status === '오류').length, + }; + } + + private formatRelativeTime(date: Date) { + const diffMs = Date.now() - date.getTime(); + const minutes = Math.max(0, Math.floor(diffMs / 60000)); + + if (minutes < 1) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + + const days = Math.floor(hours / 24); + return `${days}일 전`; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dd713be --- /dev/null +++ b/src/main.ts @@ -0,0 +1,40 @@ +import { RequestMethod, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; + +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + const port = configService.get('PORT', 4400); + const corsOrigin = configService.get( + 'CORS_ORIGIN', + 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176', + ); + + app.setGlobalPrefix('api', { + exclude: [ + { path: 'health', method: RequestMethod.GET }, + { path: 'health/db', method: RequestMethod.GET }, + ], + }); + app.enableCors({ + origin: + corsOrigin === '*' + ? true + : corsOrigin.split(',').map((origin) => origin.trim()), + credentials: true, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.listen(port, '0.0.0.0'); +} + +void bootstrap(); diff --git a/src/platform-control/platform-control.controller.ts b/src/platform-control/platform-control.controller.ts new file mode 100644 index 0000000..00fadb8 --- /dev/null +++ b/src/platform-control/platform-control.controller.ts @@ -0,0 +1,91 @@ +import { Body, Controller, Delete, Param, Post, Req } from '@nestjs/common'; +import { Request } from 'express'; + +import { PlatformControlService } from './platform-control.service'; + +@Controller('platform-control') +export class PlatformControlController { + constructor(private readonly platformControlService: PlatformControlService) {} + + @Post('platforms/statuses') + getPlatformStatuses( + @Req() request: Request, + @Body() body: { platformIds?: string[] }, + ) { + return this.platformControlService.getPlatformStatuses( + this.getBearerToken(request), + body.platformIds ?? [], + ); + } + + @Post('platforms/:platformId/request') + requestControl(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.requestControl( + this.getBearerToken(request), + platformId, + ); + } + + @Delete('platforms/:platformId/queue') + cancelQueue(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.cancelQueue( + this.getBearerToken(request), + platformId, + ); + } + + @Post('platforms/:platformId/release') + releaseControl(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.releaseControl( + this.getBearerToken(request), + platformId, + ); + } + + @Post('platforms/:platformId/connect') + connectPlatform(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.connectPlatform( + this.getBearerToken(request), + platformId, + ); + } + + @Post('platforms/:platformId/modules/list') + listRemoteModules(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.listRemoteModules( + this.getBearerToken(request), + platformId, + ); + } + + @Post('platforms/:platformId/modules/start') + startRemoteModule( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: { folderName?: string; rosDomainId?: string }, + ) { + return this.platformControlService.startRemoteModule( + this.getBearerToken(request), + platformId, + body, + ); + } + + @Post('platforms/:platformId/modules/stop') + stopRemoteModule( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: { containerKey?: string }, + ) { + return this.platformControlService.stopRemoteModule( + this.getBearerToken(request), + platformId, + body, + ); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/platform-control/platform-control.module.ts b/src/platform-control/platform-control.module.ts new file mode 100644 index 0000000..33953eb --- /dev/null +++ b/src/platform-control/platform-control.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { ChatModule } from '../chat/chat.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PlatformControlController } from './platform-control.controller'; +import { PlatformGatewayClient } from './platform-gateway.client'; +import { PlatformControlService } from './platform-control.service'; + +@Module({ + imports: [AuthModule, PrismaModule, ChatModule], + controllers: [PlatformControlController], + providers: [PlatformControlService, PlatformGatewayClient], + exports: [PlatformControlService, PlatformGatewayClient], +}) +export class PlatformControlModule {} diff --git a/src/platform-control/platform-control.service.ts b/src/platform-control/platform-control.service.ts new file mode 100644 index 0000000..e7bdd3d --- /dev/null +++ b/src/platform-control/platform-control.service.ts @@ -0,0 +1,921 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleDestroy, + OnModuleInit, + UnauthorizedException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { AuthService, type AuthResponseUser } from '../auth/auth.service'; +import { ChatGateway } from '../chat/chat.gateway'; +import { PrismaService } from '../prisma/prisma.service'; +import { PlatformGatewayClient } from './platform-gateway.client'; + +type PlatformResource = { + platformId: string | null; + instructorPlatformId: string | null; + title: string; + durationMinutes: number; + warningMinutes: number; +}; + +type PlatformControlState = { + status: 'AVAILABLE' | 'ACTIVE' | 'QUEUED' | 'IN_USE'; + action: string; + state: 'ready' | 'reserved' | 'locked'; + session: ReturnType | null; + queue: { + id: string; + position?: number; + requestedAt: string; + } | null; + warning: boolean; +}; + +@Injectable() +export class PlatformControlService implements OnModuleInit, OnModuleDestroy { + private timer: NodeJS.Timeout | null = null; + + constructor( + private readonly authService: AuthService, + private readonly chatGateway: ChatGateway, + private readonly platformGatewayClient: PlatformGatewayClient, + private readonly prisma: PrismaService, + ) {} + + onModuleInit() { + this.timer = setInterval(() => { + void this.processDueControlEvents(); + }, 30_000); + } + + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + + async requestControl(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + + await this.applyExpiredSessions(context); + + return this.prisma.$transaction(async (tx) => { + const active = await tx.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + status: 'ACTIVE', + }, + orderBy: { startedAt: 'asc' }, + }); + + if (!active) { + const session = await this.createSession(tx, resource, context, user.id, group?.id ?? null); + return { + result: 'GRANTED', + platformId: platformOptionId, + session: this.serializeSession(session), + queue: null, + }; + } + + if (active.holderUserId === user.id) { + return { + result: 'ALREADY_GRANTED', + platformId: platformOptionId, + session: this.serializeSession(active), + queue: null, + }; + } + + const existingQueue = await tx.platformControlQueueEntry.findFirst({ + where: { + ...this.resourceWhere(resource), + userId: user.id, + status: 'WAITING', + }, + orderBy: { requestedAt: 'asc' }, + }); + + const queue = existingQueue ?? await tx.platformControlQueueEntry.create({ + data: { + organizationId: context.organizationId ?? null, + organizationName: context.organizationName, + platformId: resource.platformId, + instructorPlatformId: resource.instructorPlatformId, + userId: user.id, + groupId: group?.id ?? null, + status: 'WAITING', + }, + }); + + const position = await tx.platformControlQueueEntry.count({ + where: { + ...this.resourceWhere(resource), + status: 'WAITING', + requestedAt: { lte: queue.requestedAt }, + }, + }); + + return { + result: 'QUEUED', + platformId: platformOptionId, + session: this.serializeSession(active), + queue: { + id: queue.id, + position, + requestedAt: queue.requestedAt.toISOString(), + }, + }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + async cancelQueue(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + + await this.prisma.platformControlQueueEntry.updateMany({ + where: { + ...this.resourceWhere(resource), + userId: user.id, + status: 'WAITING', + }, + data: { + status: 'CANCELED', + canceledAt: new Date(), + }, + }); + + return { result: 'CANCELED', platformId: platformOptionId }; + } + + async getPlatformStatuses(accessToken: string | undefined, platformOptionIds: string[]) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const uniqueIds = Array.from(new Set(platformOptionIds.map((id) => id.trim()).filter(Boolean))).slice(0, 30); + const resources = await Promise.all(uniqueIds.map(async (platformOptionId) => { + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null).catch(() => null); + return resource ? { platformOptionId, resource } : null; + })); + const validResources = resources.filter((entry): entry is { platformOptionId: string; resource: PlatformResource } => Boolean(entry)); + const controlStates = await this.getControlStates( + context, + user.id, + validResources.map((entry) => entry.resource), + ); + + const statuses = await Promise.all(validResources.map(async ({ platformOptionId, resource }) => { + if (resource.platformId && !resource.instructorPlatformId) { + const platform = await this.prisma.platform.findUnique({ + where: { id: resource.platformId }, + select: { platformKey: true, displayName: true }, + }); + if (platform && this.isLocalPlatform(platform)) { + return { + id: platformOptionId, + status: '사용 가능', + action: '연결', + state: 'ready', + control: null, + connection: null, + }; + } + } + + const control = controlStates.get(this.resourceKey(resource)) ?? this.emptyState('AVAILABLE'); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + const connection = gatewayPlatformId + ? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null) + : null; + const remoteUnavailable = Boolean(gatewayPlatformId && (!connection?.headscaleOnline || !connection?.agentHealthy)); + + return { + id: platformOptionId, + status: remoteUnavailable + ? '연결 끊김' + : control.status === 'AVAILABLE' + ? '사용 가능' + : this.toControlStatusLabel(control.status), + action: control.status === 'AVAILABLE' + ? '제어권 요청' + : control.action, + state: control.state, + control, + connection: gatewayPlatformId + ? { + registered: Boolean(connection), + headscaleOnline: Boolean(connection?.headscaleOnline), + agentHealthy: Boolean(connection?.agentHealthy), + agentUrl: connection?.agentUrl ?? null, + sshUser: connection?.sshUser ?? null, + workspaceRoot: connection?.workspaceRoot ?? null, + lastSeenAt: connection?.lastSeenAt ?? null, + message: connection?.message ?? null, + } + : null, + }; + })); + + return { platforms: statuses }; + } + + async releaseControl(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + + return this.prisma.$transaction(async (tx) => { + const active = await tx.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + holderUserId: user.id, + status: 'ACTIVE', + }, + }); + + if (!active) { + throw new NotFoundException('Active control session not found'); + } + + await tx.platformControlSession.update({ + where: { id: active.id }, + data: { + status: 'RELEASED', + releasedAt: new Date(), + endReason: 'USER_RELEASED', + }, + }); + + const next = await this.grantNextQueued(tx, resource, context); + if (next) this.notifyGranted(next); + + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (gatewayPlatformId) { + await this.platformGatewayClient.release({ + platformId: gatewayPlatformId, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.authRole, + }, + }); + } + + return { + result: 'RELEASED', + platformId: platformOptionId, + nextSession: next ? this.serializeSession(next) : null, + }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + async connectPlatform(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + + const active = await this.prisma.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + holderUserId: user.id, + status: 'ACTIVE', + }, + }); + if (!active) throw new ForbiddenException('Active control session required'); + + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) { + return { + mode: 'local', + gateway: null, + environmentConfigOverride: null, + }; + } + + const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + await this.platformGatewayClient.syncAccess([{ + platformId: gatewayPlatformId, + platformName: resource.title, + agentOwnerEmail, + studentEmails: authUser.email ? [authUser.email] : [], + }]); + + const ttlSeconds = Math.max(60, Math.floor((active.endsAt.getTime() - Date.now()) / 1000)); + const gateway = await this.platformGatewayClient.startSession({ + platformId: gatewayPlatformId, + agentOwnerEmail, + ttlSeconds, + user: { + id: user.id, + email: user.email || undefined, + name: user.name || undefined, + role: user.authRole || undefined, + }, + }) + .catch(() => null); + + return { + mode: gateway?.connection ? 'remote' : 'gateway-unavailable', + gateway, + environmentConfigOverride: gateway?.connection + ? { + target: 'remote-jetson', + agent: { + url: gateway.connection.agentUrl, + sshUser: gateway.connection.sshUser ?? null, + workspaceRoot: gateway.connection.workspaceRoot ?? null, + }, + bridge: { + env: { + BRIDGE_HTTP_URL: gateway.connection.bridgeHttpUrl, + BRIDGE_WS_URL: gateway.connection.bridgeWsUrl, + }, + }, + } + : null, + }; + } + + async listRemoteModules(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('Remote platform is required'); + const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + return this.platformGatewayClient.getModuleStatuses(gatewayPlatformId, agentOwnerEmail ?? authUser.email); + } + + async startRemoteModule( + accessToken: string | undefined, + platformOptionId: string, + input: { folderName?: string | null; rosDomainId?: string | null }, + ) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + await this.assertActiveControl(resource, user.id); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('Remote platform is required'); + const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + return this.platformGatewayClient.startModule(gatewayPlatformId, { + accountEmail: agentOwnerEmail ?? authUser.email, + folderName: input.folderName, + rosDomainId: input.rosDomainId, + }); + } + + async stopRemoteModule( + accessToken: string | undefined, + platformOptionId: string, + input: { containerKey?: string | null }, + ) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + await this.assertActiveControl(resource, user.id); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('Remote platform is required'); + const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + return this.platformGatewayClient.stopModule(gatewayPlatformId, { + accountEmail: agentOwnerEmail ?? authUser.email, + containerKey: input.containerKey, + }); + } + + async getControlStates( + context: { organizationId?: string; organizationName: string }, + currentUserId: string | null, + resources: PlatformResource[], + ) { + await this.applyExpiredSessions(context); + + const states = new Map(); + await Promise.all(resources.map(async (resource) => { + const active = await this.prisma.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + status: 'ACTIVE', + }, + include: { + holderUser: { select: { id: true, name: true, email: true } }, + }, + }); + const queue = currentUserId ? await this.prisma.platformControlQueueEntry.findFirst({ + where: { + ...this.resourceWhere(resource), + userId: currentUserId, + status: 'WAITING', + }, + }) : null; + + const key = this.resourceKey(resource); + if (!active) { + states.set(key, this.emptyState('AVAILABLE')); + return; + } + + if (active.holderUserId === currentUserId) { + states.set(key, { + status: 'ACTIVE', + action: '반납', + state: 'ready', + session: this.serializeSession(active), + queue: null, + warning: new Date() >= active.warningAt, + }); + return; + } + + states.set(key, { + status: queue ? 'QUEUED' : 'IN_USE', + action: queue ? '대기 취소' : '대기 예약', + state: 'reserved', + session: this.serializeSession(active), + queue: queue ? { + id: queue.id, + requestedAt: queue.requestedAt.toISOString(), + } : null, + warning: false, + }); + })); + + return states; + } + + private async applyExpiredSessions(context: { organizationId?: string; organizationName: string }) { + const now = new Date(); + const expired = await this.prisma.platformControlSession.findMany({ + where: { + ...this.organizationWhere( + context.organizationId, + context.organizationName, + ), + status: 'ACTIVE', + endsAt: { lte: now }, + }, + }); + + for (const session of expired) { + await this.prisma.$transaction(async (tx) => { + const current = await tx.platformControlSession.findFirst({ + where: { + id: session.id, + status: 'ACTIVE', + }, + }); + if (!current) return; + + await tx.platformControlSession.update({ + where: { id: current.id }, + data: { + status: 'EXPIRED', + releasedAt: now, + endReason: 'EXPIRED', + }, + }); + + await this.grantNextQueued(tx, { + platformId: current.platformId, + instructorPlatformId: current.instructorPlatformId, + title: '', + durationMinutes: 30, + warningMinutes: 5, + }, context); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + } + + private async assertActiveControl(resource: PlatformResource, userId: string) { + const active = await this.prisma.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + holderUserId: userId, + status: 'ACTIVE', + }, + }); + if (!active) throw new ForbiddenException('Active control session required'); + } + + private async processDueControlEvents() { + const now = new Date(); + const warningSessions = await this.prisma.platformControlSession.findMany({ + where: { + status: 'ACTIVE', + warningSentAt: null, + warningAt: { lte: now }, + endsAt: { gt: now }, + }, + }); + + for (const session of warningSessions) { + await this.prisma.platformControlSession.updateMany({ + where: { + id: session.id, + status: 'ACTIVE', + warningSentAt: null, + }, + data: { + warningSentAt: now, + }, + }); + this.chatGateway.notifyUser(session.holderUserId, { + type: 'platform.control.warning', + sessionId: session.id, + platformId: session.platformId, + instructorPlatformId: session.instructorPlatformId, + endsAt: session.endsAt.toISOString(), + warningAt: session.warningAt.toISOString(), + }); + } + + const expiredSessions = await this.prisma.platformControlSession.findMany({ + where: { + status: 'ACTIVE', + endsAt: { lte: now }, + }, + }); + + for (const session of expiredSessions) { + await this.prisma.$transaction(async (tx) => { + const current = await tx.platformControlSession.findFirst({ + where: { + id: session.id, + status: 'ACTIVE', + }, + }); + if (!current) return; + + await tx.platformControlSession.update({ + where: { id: current.id }, + data: { + status: 'EXPIRED', + releasedAt: now, + endReason: 'EXPIRED', + }, + }); + + const next = await this.grantNextQueued(tx, { + platformId: current.platformId, + instructorPlatformId: current.instructorPlatformId, + title: '', + durationMinutes: 30, + warningMinutes: 5, + }, { + organizationId: current.organizationId ?? undefined, + organizationName: current.organizationName, + }); + if (next) this.notifyGranted(next); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + } + + private async grantNextQueued( + tx: Prisma.TransactionClient, + resource: PlatformResource, + context: { organizationId?: string; organizationName: string }, + ) { + const next = await tx.platformControlQueueEntry.findFirst({ + where: { + ...this.resourceWhere(resource), + status: 'WAITING', + }, + orderBy: { requestedAt: 'asc' }, + }); + + if (!next) return null; + + await tx.platformControlQueueEntry.update({ + where: { id: next.id }, + data: { + status: 'GRANTED', + grantedAt: new Date(), + }, + }); + + const fullResource = await this.resolveResourceForGrant(tx, resource); + return this.createSession( + tx, + fullResource, + context, + next.userId, + next.groupId, + ); + } + + private async resolveResourceForGrant(tx: Prisma.TransactionClient, resource: PlatformResource) { + if (resource.instructorPlatformId) { + const platform = await tx.instructorPlatform.findUnique({ + where: { id: resource.instructorPlatformId }, + }); + if (platform) { + return { + ...resource, + durationMinutes: platform.controlDurationMinutes, + warningMinutes: platform.controlWarningMinutes, + }; + } + } + return resource; + } + + private async resolveGatewayPlatformId(resource: Pick) { + if (resource.instructorPlatformId) { + const instructorPlatform = await this.prisma.instructorPlatform.findUnique({ + where: { id: resource.instructorPlatformId }, + include: { platform: true }, + }); + if (!instructorPlatform) return null; + return this.readGatewayPlatformId(instructorPlatform.studioSettingsOverride) + ?? this.readGatewayPlatformId(instructorPlatform.platform.defaultStudioSettings) + ?? instructorPlatform.platform.platformKey; + } + + if (!resource.platformId) return null; + const platform = await this.prisma.platform.findUnique({ + where: { id: resource.platformId }, + }); + if (!platform || this.isLocalPlatform(platform)) return null; + return this.readGatewayPlatformId(platform.defaultStudioSettings) ?? platform.platformKey; + } + + private async resolveAgentOwnerEmail(resource: Pick) { + if (resource.instructorPlatformId) { + const instructorPlatform = await this.prisma.instructorPlatform.findUnique({ + where: { id: resource.instructorPlatformId }, + include: { platform: true }, + }); + if (!instructorPlatform) return null; + return this.readAgentOwnerEmail(instructorPlatform.studioSettingsOverride) + ?? this.readAgentOwnerEmail(instructorPlatform.platform.defaultStudioSettings); + } + + if (!resource.platformId) return null; + const platform = await this.prisma.platform.findUnique({ + where: { id: resource.platformId }, + }); + return this.readAgentOwnerEmail(platform?.defaultStudioSettings); + } + + private readAgentOwnerEmail(settings: unknown): string | null { + if (!settings || typeof settings !== 'object') return null; + const value = (settings as Record).agentOwnerEmail; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private readGatewayPlatformId(settings: unknown): string | null { + if (!settings || typeof settings !== 'object') return null; + const value = (settings as Record).gatewayPlatformId; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private createSession( + tx: Prisma.TransactionClient, + resource: PlatformResource, + context: { organizationId?: string; organizationName: string }, + userId: string, + groupId: string | null, + ) { + const startedAt = new Date(); + const endsAt = new Date(startedAt.getTime() + resource.durationMinutes * 60_000); + const warningAt = new Date(endsAt.getTime() - resource.warningMinutes * 60_000); + + return tx.platformControlSession.create({ + data: { + organizationId: context.organizationId ?? null, + organizationName: context.organizationName, + platformId: resource.platformId, + instructorPlatformId: resource.instructorPlatformId, + holderUserId: userId, + groupId, + status: 'ACTIVE', + startedAt, + endsAt, + warningAt, + }, + }); + } + + private async resolveResource( + platformOptionId: string, + context: { organizationId?: string; organizationName: string }, + assignedInstructorPlatformId: string | null, + ): Promise { + const instructorPlatform = await this.prisma.instructorPlatform.findFirst({ + where: { + id: platformOptionId, + ...this.organizationWhere( + context.organizationId, + context.organizationName, + ), + }, + include: { platform: true }, + }); + + if (instructorPlatform) { + if (assignedInstructorPlatformId !== instructorPlatform.id) { + throw new ForbiddenException('Platform is not assigned to your group'); + } + return { + platformId: null, + instructorPlatformId: instructorPlatform.id, + title: instructorPlatform.displayName, + durationMinutes: instructorPlatform.controlDurationMinutes, + warningMinutes: instructorPlatform.controlWarningMinutes, + }; + } + + const coursePlatform = await this.prisma.coursePlatform.findFirst({ + where: { + platformId: platformOptionId, + course: { + slug: 'futuredrive-autonomy-basic', + isActive: true, + }, + }, + include: { platform: true }, + }); + + if (!coursePlatform) { + throw new NotFoundException('Platform not found'); + } + + const isLocal = this.isLocalPlatform(coursePlatform.platform); + if (!isLocal) { + throw new ForbiddenException('Platform is not assigned to your group'); + } + + return { + platformId: coursePlatform.platformId, + instructorPlatformId: null, + title: coursePlatform.platform.displayName, + durationMinutes: 30, + warningMinutes: 5, + }; + } + + private async findStudentGroup( + userId: string, + context: { organizationId?: string; organizationName: string }, + ) { + return this.prisma.studentGroup.findFirst({ + where: { + ...this.organizationWhere( + context.organizationId, + context.organizationName, + ), + members: { + some: { userId }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + private async getStudent(accessToken: string | undefined) { + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + const { user } = await this.authService.me(accessToken); + const roles = [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()); + if (roles.some((role) => role === 'INSTRUCTOR' || role === 'LMS_ADMIN')) { + throw new ForbiddenException('Student role required'); + } + + const lmsUser = await this.prisma.user.upsert({ + where: { email: user.email }, + update: this.toUserSyncData(user), + create: { + email: user.email, + ...this.toUserSyncData(user), + }, + }); + + return { authUser: user, user: lmsUser }; + } + + private organizationContext(user: AuthResponseUser) { + const organizationName = user.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) { + throw new BadRequestException('Organization is required'); + } + return { + organizationId: user.primaryAffiliation?.organizationId, + organizationName, + }; + } + + private resourceWhere(resource: Pick) { + return resource.instructorPlatformId + ? { instructorPlatformId: resource.instructorPlatformId } + : { platformId: resource.platformId }; + } + + private resourceKey(resource: Pick) { + return resource.instructorPlatformId + ? `instructor:${resource.instructorPlatformId}` + : `platform:${resource.platformId}`; + } + + private emptyState(status: 'AVAILABLE'): PlatformControlState { + return { + status, + action: '제어권 요청', + state: 'ready', + session: null, + queue: null, + warning: false, + }; + } + + private toControlStatusLabel(status: string) { + if (status === 'ACTIVE') return '제어 중'; + if (status === 'QUEUED') return '대기 예약됨'; + if (status === 'IN_USE') return '다른 수강생 사용 중'; + return '사용 가능'; + } + + serializeSession(session: { + id: string; + holderUserId: string; + startedAt: Date; + endsAt: Date; + warningAt: Date; + holderUser?: { name: string | null; email: string } | null; + }) { + return { + id: session.id, + holderUserId: session.holderUserId, + holderName: session.holderUser?.name ?? session.holderUser?.email ?? null, + startedAt: session.startedAt.toISOString(), + endsAt: session.endsAt.toISOString(), + warningAt: session.warningAt.toISOString(), + }; + } + + private notifyGranted(session: { id: string; holderUserId: string; startedAt: Date; endsAt: Date; warningAt: Date }) { + this.chatGateway.notifyUser(session.holderUserId, { + type: 'platform.control.granted', + sessionId: session.id, + startedAt: session.startedAt.toISOString(), + endsAt: session.endsAt.toISOString(), + warningAt: session.warningAt.toISOString(), + }); + } + + makePlatformResource(platformId: string | null, instructorPlatformId: string | null): PlatformResource { + return { + platformId, + instructorPlatformId, + title: '', + durationMinutes: 30, + warningMinutes: 5, + }; + } + + resourceKeyForIds(platformId: string | null, instructorPlatformId: string | null) { + return this.resourceKey({ platformId, instructorPlatformId }); + } + + private organizationWhere( + organizationId: string | undefined, + organizationName: string | undefined, + ): T { + return { + OR: [ + organizationId ? { organizationId } : undefined, + organizationName ? { organizationName } : undefined, + ].filter(Boolean), + } as T; + } + + private isLocalPlatform(platform: { platformKey: string; displayName: string }) { + return platform.platformKey.toLowerCase().includes('local') || + platform.displayName.toLowerCase().includes('로컬'); + } + + private toUserSyncData(user: AuthResponseUser) { + return { + authUserId: user.id, + name: user.name, + authRole: user.role, + primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null, + primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null, + primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null, + department: user.primaryAffiliation?.department ?? null, + position: user.primaryAffiliation?.position ?? null, + }; + } +} diff --git a/src/platform-control/platform-gateway.client.ts b/src/platform-control/platform-gateway.client.ts new file mode 100644 index 0000000..43dbaba --- /dev/null +++ b/src/platform-control/platform-gateway.client.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; + +type GatewayConnection = { + agentUrl: string; + sshUser?: string | null; + workspaceRoot?: string | null; + bridgeHttpUrl: string; + bridgeWsUrl: string; +}; + +type GatewaySessionResponse = { + session?: unknown; + connection?: GatewayConnection; + error?: string; +}; + +export type GatewayAgent = { + id: string; + hostname: string; + name: string; + user: string; + tags: string[]; + ip: string; + sshUser?: string | null; + workspaceRoot?: string | null; + connected: boolean; + expired: boolean; + lastSeenAt: string | null; +}; + +export type GatewayPlatformConnectionStatus = { + platformId: string; + registered: boolean; + headscaleOnline: boolean; + agentHealthy: boolean; + agentUrl: string | null; + sshUser?: string | null; + workspaceRoot?: string | null; + lastSeenAt: string | null; + state: string; + message: string | null; +}; + +export type GatewayAccessAssignment = { + platformId: string; + platformName?: string | null; + agentHostname?: string | null; + agentOwnerEmail?: string | null; + studentEmails: string[]; +}; + +@Injectable() +export class PlatformGatewayClient { + private readonly logger = new Logger(PlatformGatewayClient.name); + private readonly baseUrl = (process.env.PLATFORM_GATEWAY_URL ?? '').replace(/\/+$/, ''); + private readonly token = process.env.PLATFORM_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? ''; + + get enabled() { + return Boolean(this.baseUrl && this.token); + } + + async startSession(input: { + platformId: string; + agentOwnerEmail?: string | null; + user: { id: string; email?: string; name?: string | null; role?: string | null }; + ttlSeconds: number; + }): Promise { + if (!this.enabled) return null; + return this.request('/api/lms/platform-sessions', { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async release(input: { + platformId: string; + user: { id: string; email?: string; name?: string | null; role?: string | null }; + }): Promise { + if (!this.enabled) return; + await this.request(`/api/lms/platforms/${encodeURIComponent(input.platformId)}/release`, { + method: 'POST', + body: JSON.stringify({ user: input.user }), + }).catch(() => undefined); + } + + async listAgents(accountEmail?: string | null): Promise { + if (!this.enabled) { + this.logger.warn(`Platform gateway is disabled. baseUrl=${this.baseUrl || '(empty)'} tokenConfigured=${Boolean(this.token)}`); + return []; + } + const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : ''; + const payload = await this.request<{ agents?: GatewayAgent[] }>(`/api/lms/agents${query}`, { + method: 'GET', + }); + return payload.agents ?? []; + } + + async getPlatformStatus(platformId: string, accountEmail?: string | null): Promise { + if (!this.enabled) return null; + const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : ''; + return this.request( + `/api/lms/platforms/${encodeURIComponent(platformId)}/status${query}`, + { method: 'GET' }, + ); + } + + async getModuleStatuses(platformId: string, accountEmail?: string | null): Promise { + if (!this.enabled) return null; + const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : ''; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/statuses${query}`, { + method: 'GET', + }); + } + + async startModule(platformId: string, input: { accountEmail?: string | null; folderName?: string | null; rosDomainId?: string | null }): Promise { + if (!this.enabled) return null; + const query = input.accountEmail ? `?accountEmail=${encodeURIComponent(input.accountEmail)}` : ''; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/start${query}`, { + method: 'POST', + body: JSON.stringify({ + folderName: input.folderName, + rosDomainId: input.rosDomainId, + }), + }); + } + + async stopModule(platformId: string, input: { accountEmail?: string | null; containerKey?: string | null }): Promise { + if (!this.enabled) return null; + const query = input.accountEmail ? `?accountEmail=${encodeURIComponent(input.accountEmail)}` : ''; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/stop${query}`, { + method: 'POST', + body: JSON.stringify({ + containerKey: input.containerKey, + }), + }); + } + + async syncAccess(assignments: GatewayAccessAssignment[]): Promise { + if (!this.enabled) return; + await this.request('/api/lms/access-sync', { + method: 'POST', + body: JSON.stringify({ assignments }), + }).catch((error) => { + this.logger.warn(`Platform access sync failed. message=${error instanceof Error ? error.message : String(error)}`); + }); + } + + private async request(path: string, init: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + 'x-lms-gateway-token': this.token, + ...(init.headers ?? {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const message = String(payload?.error ?? payload?.message ?? `platform_gateway_failed:${response.status}`); + this.logger.warn(`Platform gateway request failed. path=${path} status=${response.status} message=${message}`); + throw new Error(message); + } + return payload as T; + } +} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..54327ba --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/studio/studio.controller.ts b/src/studio/studio.controller.ts new file mode 100644 index 0000000..1cd2382 --- /dev/null +++ b/src/studio/studio.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Post, Req } from '@nestjs/common'; +import { Request } from 'express'; + +import { StudioService } from './studio.service'; + +@Controller('studio') +export class StudioController { + constructor(private readonly studioService: StudioService) {} + + @Post('repositories/prepare') + prepareRepository( + @Req() request: Request, + @Body() + body: { + courseId?: string; + week?: number; + moduleName?: string; + folderName?: string; + templateRepositoryUrl?: string; + }, + ) { + return this.studioService.prepareRepository(this.getBearerToken(request), body); + } + + @Post('submissions/record') + recordSubmission( + @Req() request: Request, + @Body() + body: { + courseId?: string; + week?: number; + completedWeeks?: number; + totalWeeks?: number; + commitCount?: number; + }, + ) { + return this.studioService.recordSubmission(this.getBearerToken(request), body); + } + + @Post('learning-time') + recordLearningTime( + @Req() request: Request, + @Body() + body: { + courseId?: string; + week?: number; + learningMinutes?: number; + learningMinutesDelta?: number; + }, + ) { + return this.studioService.recordLearningTime(this.getBearerToken(request), body); + } + + private getBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/studio/studio.module.ts b/src/studio/studio.module.ts new file mode 100644 index 0000000..4088ddd --- /dev/null +++ b/src/studio/studio.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { StudioController } from './studio.controller'; +import { StudioService } from './studio.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [StudioController], + providers: [StudioService], +}) +export class StudioModule {} diff --git a/src/studio/studio.service.ts b/src/studio/studio.service.ts new file mode 100644 index 0000000..419c2a7 --- /dev/null +++ b/src/studio/studio.service.ts @@ -0,0 +1,388 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { AuthService } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class StudioService { + private readonly giteaBaseUrl: string; + private readonly giteaToken: string; + private readonly studentOwner: string; + + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) { + this.giteaBaseUrl = this.configService + .get('GITEA_BASE_URL', 'http://localhost:3000') + .replace(/\/+$/, ''); + this.giteaToken = this.configService.get('GITEA_ADMIN_TOKEN', ''); + this.studentOwner = this.configService.get('GITEA_STUDENT_OWNER', 'student'); + } + + async prepareRepository( + accessToken: string | undefined, + input: { + courseId?: string; + week?: number; + moduleName?: string; + folderName?: string; + templateRepositoryUrl?: string; + }, + ) { + if (!this.giteaToken) { + throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured'); + } + + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + if (!input.week || input.week < 1) throw new BadRequestException('week is required'); + + const moduleName = input.moduleName?.trim(); + const folderName = input.folderName?.trim(); + if (!moduleName && !folderName) throw new BadRequestException('moduleName or folderName is required'); + + const lmsUser = await this.prisma.user.findUnique({ + where: { email: authUser.email }, + }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + + const courseId = input.courseId?.trim(); + if (courseId) { + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { + userId: lmsUser.id, + courseId, + status: 'ACTIVE', + }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + } + + const courseWeek = await this.prisma.courseWeek.findFirst({ + where: courseId + ? { + courseId, + week: input.week, + course: { isActive: true }, + } + : { + week: input.week, + course: { + slug: 'futuredrive-autonomy-basic', + isActive: true, + }, + }, + }); + if (!courseWeek) throw new NotFoundException('Course week not found'); + + const moduleConditions = [ + moduleName ? { name: moduleName } : undefined, + folderName ? { folderName } : undefined, + ].filter((condition): condition is { name: string } | { folderName: string } => Boolean(condition)); + const module = await this.prisma.courseWeekModule.findFirst({ + where: { + weekId: courseWeek.id, + OR: moduleConditions, + }, + }); + if (!module) throw new NotFoundException('Course week module not found'); + + const templateRepositoryUrl = + module.templateRepositoryUrl?.trim() || input.templateRepositoryUrl?.trim(); + if (!templateRepositoryUrl) { + throw new BadRequestException('templateRepositoryUrl is required'); + } + + const repoName = `${this.slug(authUser.email.split('@')[0] || authUser.email)}-week-${courseWeek.week}-${this.slug(module.name)}`; + const repositoryUrl = `${this.giteaBaseUrl}/${this.studentOwner}/${repoName}.git`; + + await this.ensureGiteaRepository(repoName, `Studio workspace for ${authUser.email} ${courseWeek.week}W ${module.name}`); + + const repository = await this.prisma.studioRepository.upsert({ + where: { + userId_moduleId: { + userId: lmsUser.id, + moduleId: module.id, + }, + }, + update: { + repoOwner: this.studentOwner, + repoName, + repositoryUrl, + templateRepositoryUrl, + }, + create: { + userId: lmsUser.id, + weekId: courseWeek.id, + moduleId: module.id, + repoOwner: this.studentOwner, + repoName, + repositoryUrl, + templateRepositoryUrl, + }, + }); + + const moduleWithArtifacts = module as typeof module & { + sourceType?: string | null; + prebuiltArtifacts?: unknown; + }; + + return { + id: repository.id, + owner: this.studentOwner, + repoName, + repositoryUrl, + repositoryUrlWithToken: this.withToken(repositoryUrl), + templateRepositoryUrl, + templateRepositoryUrlWithToken: this.withToken(templateRepositoryUrl), + sourceType: moduleWithArtifacts.sourceType ?? 'GIT', + prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [], + }; + } + + async recordSubmission( + accessToken: string | undefined, + input: { + courseId?: string; + week?: number; + completedWeeks?: number; + totalWeeks?: number; + commitCount?: number; + }, + ) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + + const lmsUser = await this.prisma.user.findUnique({ + where: { email: authUser.email }, + }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + + const courseId = input.courseId?.trim(); + if (!courseId) throw new BadRequestException('courseId is required'); + + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { + userId: lmsUser.id, + courseId, + status: 'ACTIVE', + }, + include: { + course: { + include: { + weeks: true, + }, + }, + }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + + const totalWeeks = Math.max(0, input.totalWeeks ?? enrollment.course.weeks.length); + const completedWeeks = Math.max(0, Math.min(totalWeeks, input.completedWeeks ?? Math.max(0, (input.week ?? 1) - 1))); + const commitCount = Math.max(0, input.commitCount ?? 0); + + const progress = await this.prisma.courseStudentProgress.upsert({ + where: { + courseId_userId: { + courseId, + userId: lmsUser.id, + }, + }, + update: { + completedWeeks, + totalWeeks, + commitCount: { + increment: commitCount, + }, + }, + create: { + courseId, + userId: lmsUser.id, + completedWeeks, + totalWeeks, + commitCount, + }, + }); + + const week = Math.max(0, Math.floor(input.week ?? 0)); + if (week > 0 && commitCount > 0) { + await this.prisma.courseStudentWeekProgress.upsert({ + where: { + courseId_userId_week: { + courseId, + userId: lmsUser.id, + week, + }, + }, + update: { + submittedAt: new Date(), + commitCount: { + increment: commitCount, + }, + }, + create: { + courseId, + userId: lmsUser.id, + week, + submittedAt: new Date(), + commitCount, + }, + }); + } + + return { + ok: true, + progress: { + completedWeeks: progress.completedWeeks, + totalWeeks: progress.totalWeeks, + commitCount: progress.commitCount, + }, + }; + } + + async recordLearningTime( + accessToken: string | undefined, + input: { + courseId?: string; + week?: number; + learningMinutes?: number; + learningMinutesDelta?: number; + }, + ) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + + const lmsUser = await this.prisma.user.findUnique({ + where: { email: authUser.email }, + }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + + const courseId = input.courseId?.trim(); + if (!courseId) throw new BadRequestException('courseId is required'); + + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { + userId: lmsUser.id, + courseId, + status: 'ACTIVE', + }, + include: { + course: { + include: { + weeks: true, + }, + }, + }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + + const learningMinutes = Math.max(0, Math.floor(input.learningMinutes ?? 0)); + const learningMinutesDelta = Math.max(0, Math.min(10, Math.floor(input.learningMinutesDelta ?? 0))); + const totalWeeks = enrollment.course.weeks.length; + const existingProgress = await this.prisma.courseStudentProgress.findUnique({ + where: { + courseId_userId: { + courseId, + userId: lmsUser.id, + }, + }, + }); + const previousLearningMinutes = existingProgress?.learningHours ?? 0; + const nextLearningMinutes = learningMinutesDelta > 0 + ? previousLearningMinutes + learningMinutesDelta + : Math.max(previousLearningMinutes, learningMinutes); + + const progress = await this.prisma.courseStudentProgress.upsert({ + where: { + courseId_userId: { + courseId, + userId: lmsUser.id, + }, + }, + update: { + totalWeeks, + learningHours: nextLearningMinutes, + }, + create: { + courseId, + userId: lmsUser.id, + completedWeeks: 0, + totalWeeks, + learningHours: nextLearningMinutes, + commitCount: 0, + }, + }); + + return { + ok: true, + progress: { + learningMinutes: progress.learningHours, + totalWeeks: progress.totalWeeks, + }, + }; + } + + private async ensureGiteaRepository(repoName: string, description: string) { + const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, { + headers: this.giteaHeaders(), + }); + + if (existingResponse.ok) return; + if (existingResponse.status !== 404) { + throw new BadGatewayException(await existingResponse.text()); + } + + const createResponse = await fetch(`${this.giteaBaseUrl}/api/v1/orgs/${encodeURIComponent(this.studentOwner)}/repos`, { + method: 'POST', + headers: { + ...this.giteaHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: repoName, + description, + private: true, + auto_init: false, + }), + }); + + if (!createResponse.ok && createResponse.status !== 409) { + throw new BadGatewayException(await createResponse.text()); + } + } + + private giteaHeaders() { + return { + Authorization: `token ${this.giteaToken}`, + }; + } + + private withToken(repositoryUrl: string) { + try { + const url = new URL(repositoryUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return repositoryUrl; + url.username = 'oauth2'; + url.password = this.giteaToken; + return url.toString(); + } catch { + return repositoryUrl; + } + } + + private slug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'workspace'; + } +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts new file mode 100644 index 0000000..fc0562c --- /dev/null +++ b/test/app.e2e-spec.ts @@ -0,0 +1,39 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PrismaService) + .useValue({ + $connect: jest.fn(), + $disconnect: jest.fn(), + $queryRaw: jest.fn().mockResolvedValue([{ '?column?': 1 }]), + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('/health (GET)', () => { + return request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect(({ body }) => { + expect(body.status).toBe('ok'); + }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3887c20 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "rootDir": "./src" + }, + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f55632c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "types": ["node", "jest"], + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictPropertyInitialization": false, + "noImplicitAny": true, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/uploads/course-references/445a247d-0ea2-4258-80d4-2bfdb97d4c81 b/uploads/course-references/445a247d-0ea2-4258-80d4-2bfdb97d4c81 new file mode 100644 index 0000000..9e87e07 --- /dev/null +++ b/uploads/course-references/445a247d-0ea2-4258-80d4-2bfdb97d4c81 @@ -0,0 +1,28 @@ +sqlite3 ~/lms-cloud-v1/data/lms.db "SELECT id, status, workspace_path FROM installed_modules;" + + + +ssh -i ~/다운로드/alex.pem ubuntu@ec2-3-34-125-15.ap-northeast-2.compute.amazonaws.com + + + + +rsync -avz --exclude 'node_modules' --exclude '.env' --exclude 'dist' -e "ssh -i ~/다운로드/alex.pem" ~/tmp/lms-cloud-fe-v1/ ubuntu@ec2-3-36-100-226.ap-northeast-2.compute.amazonaws.com:~/lms-cloud-fe-v1/ + + +rsync -avz --exclude 'node_modules' --exclude 'data' -e "ssh -i ~/다운로드/alex.pem" ~/tmp/lms-cloud-v1/ ubuntu@ec2-3-36-100-226.ap-northeast-2.compute.amazonaws.com:~/lms-cloud-v1/ + + + + + +sqlite3 ~/lms-cloud-v1/data/lms.db "SELECT im.id, mc.name FROM installed_modules im JOIN module_catalog mc ON im.catalog_id = mc.id;" + + + +http://ec2-43-200-181-207.ap-northeast-2.compute.amazonaws.com:3002/ + + + + vnstat -d + sudo iftop -i ens3