diff --git a/README.md b/README.md index c431690..d858d02 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ 4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`). **Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`: -`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3001`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). +`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3107`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). `docker compose -f docker-compose.dev.yml down` — остановка. diff --git a/backend/.env.example b/backend/.env.example index 26ae4f3..ff99dc0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,6 +24,20 @@ DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests JWT_SECRET=change_me_in_production -# A.1: HR login (Werkzeug password, staff by web_login) +# A.1: HR login (Werkzeug password, staff by web_login = username в public.users) +# В Docker (docker-compose.dev.yml) по умолчанию HR_AUTH=1 и HR_DATABASE_URL на hr_bot_test. # HR_AUTH=1 # HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test + +# V.8: API/UI назначения (POST /api/tests/:id/assign, каталог в карточке). В NODE_ENV=development +# включено без этого флага. В production: CLINIC_ASSIGNMENT_ENABLED=1 +# CLINIC_ASSIGNMENT_ENABLED=1 + +# D.3 — генерация черновика из импорта (POST /api/tests/import/document), OpenAI-совместимый API +# DEEPSEEK_API_KEY= → по умолчанию https://api.deepseek.com/v1, модель deepseek-chat +# OPENAI_API_KEY= → https://api.openai.com/v1, модель gpt-4o-mini (если нет ключа DeepSeek) +# LLM_BASE_URL= → переопределить (без /chat/completions) +# LLM_MODEL= +# LLM_NO_JSON=1 → убрать response_format, если API не принимает json_object +# DEEPSEEK_API_KEY= +# OPENAI_API_KEY= diff --git a/backend/Dockerfile b/backend/Dockerfile index e5ea8ab..bd9dcf1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,6 +3,6 @@ WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci COPY . . -EXPOSE 3001 +EXPOSE 3107 RUN chmod +x docker-entrypoint.sh ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 4c2ee35..0b0d19d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,18 +9,21 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", "multer": "^1.4.5-lts.1", + "pdf-parse": "^2.4.5", "pg": "^8.12.0" }, "devDependencies": { "eslint": "^8.57.0", - "prettier": "^3.3.3" + "prettier": "^3.3.3", + "supertest": "^7.2.2" } }, "node_modules/@eslint-community/eslint-utils": { @@ -124,24 +127,201 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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/@nodelib/fs.scandir": { @@ -182,6 +362,16 @@ "node": ">= 8" } }, + "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/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -189,11 +379,14 @@ "dev": true, "license": "ISC" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } }, "node_modules/accepts": { "version": "1.3.8", @@ -231,18 +424,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -264,6 +445,7 @@ "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" @@ -291,26 +473,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -324,26 +486,62 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "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/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/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" - }, - "engines": { - "node": ">= 10.0.0" + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -387,6 +585,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -481,15 +680,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -510,19 +700,34 @@ "dev": true, "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "bin": { - "color-support": "bin.js" + "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/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": { @@ -570,12 +775,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -631,6 +830,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "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/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -673,6 +879,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -693,11 +900,15 @@ "dev": true, "license": "MIT" }, - "node_modules/delegates": { + "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" + "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", @@ -718,15 +929,23 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "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/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -752,6 +971,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -781,12 +1009,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -826,6 +1048,22 @@ "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/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1087,6 +1325,13 @@ "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==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1182,6 +1427,41 @@ "dev": true, "license": "ISC" }, + "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/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", @@ -1200,34 +1480,11 @@ "node": ">= 0.6" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "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/function-bind": { @@ -1239,27 +1496,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1302,6 +1538,7 @@ "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", @@ -1388,11 +1625,21 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" + "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.2", @@ -1426,19 +1673,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1461,6 +1695,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1493,6 +1733,7 @@ "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", @@ -1524,15 +1765,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1625,6 +1857,48 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1670,6 +1944,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1735,28 +2018,48 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", "dependencies": { - "semver": "^6.0.0" + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" }, - "engines": { - "node": ">=8" + "bin": { + "mammoth": "bin/mammoth" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=12.0.0" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" } }, "node_modules/math-intrinsics": { @@ -1832,6 +2135,7 @@ "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" @@ -1849,52 +2153,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1948,60 +2206,6 @@ "node": ">= 0.6" } }, - "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2039,11 +2243,18 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2094,6 +2305,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2151,6 +2368,38 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -2394,20 +2643,6 @@ "node": ">= 0.8" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2434,6 +2669,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -2561,11 +2797,11 @@ "node": ">= 0.8.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -2668,12 +2904,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", - "license": "ISC" - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2683,6 +2913,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2700,33 +2936,11 @@ "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-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/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" @@ -2748,6 +2962,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/superagent/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/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/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2761,24 +3034,6 @@ "node": ">=8" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -2795,12 +3050,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2846,6 +3095,12 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2889,22 +3144,6 @@ "node": ">= 0.8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2921,15 +3160,6 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2944,8 +3174,18 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2955,12 +3195,6 @@ "node": ">=0.4" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3de2c58..cd04c6f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,8 +6,9 @@ "type": "module", "scripts": { "start": "node src/server.js", - "dev": "node --watch src/server.js", + "dev": "NODE_ENV=development node --watch src/server.js", "test": "node --test 'src/**/*.test.js'", + "test:integration": "CLINIC_TESTS_INTEGRATION=1 node --test 'src/**/*.test.js'", "migrate": "node src/db/migrate.js", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", @@ -17,17 +18,20 @@ "author": "", "license": "ISC", "dependencies": { - "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", "multer": "^1.4.5-lts.1", + "pdf-parse": "^2.4.5", "pg": "^8.12.0" }, "devDependencies": { "eslint": "^8.57.0", - "prettier": "^3.3.3" + "prettier": "^3.3.3", + "supertest": "^7.2.2" } } diff --git a/backend/src/apiSmoke.test.js b/backend/src/apiSmoke.test.js new file mode 100644 index 0000000..caa21fb --- /dev/null +++ b/backend/src/apiSmoke.test.js @@ -0,0 +1,26 @@ +/** + * V.9 — минимальные проверки HTTP без БД: health и 401 на защищённых маршрутах. + * Интеграции с Postgres — см. отдельные сценарии / ручной журнал. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from './app.js'; +import { RU } from './messages/ru.js'; + +const app = createApp(); + +test('GET /api/health — 200 и status ok', async () => { + const res = await request(app).get('/api/health').expect(200); + assert.equal(res.body.status, 'ok'); +}); + +test('GET /api/tests без cookie — 401', async () => { + const res = await request(app).get('/api/tests').expect(401); + assert.equal(res.body.error, RU.authRequired); +}); + +test('GET /api/__no_route__ — 404 на русском', async () => { + const res = await request(app).get('/api/__no_route__').expect(404); + assert.equal(res.body.error, RU.notFound); +}); diff --git a/backend/src/app.js b/backend/src/app.js index 0a89b41..9f48846 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser'; import dotenv from 'dotenv'; import authRoutes from './routes/auth.js'; import testsRoutes from './routes/tests.js'; +import { RU } from './messages/ru.js'; dotenv.config(); @@ -14,7 +15,11 @@ export function createApp() { ? process.env.FRONTEND_URL ? [process.env.FRONTEND_URL] : [] - : ['http://localhost:5173', 'http://localhost:3000']; + : [ + 'http://localhost:5173', + 'http://localhost:3000', + 'http://localhost:8080', + ]; app.use( cors({ origin: corsOrigins.length ? corsOrigins : true, @@ -35,11 +40,11 @@ export function createApp() { app.use((err, req, res, _next) => { console.error('Error:', err); res.status(err.status || 500).json({ - error: err.message || 'Internal Server Error', + error: err.message || RU.internal, }); }); app.use((req, res) => { - res.status(404).json({ error: 'Not found' }); + res.status(404).json({ error: RU.notFound }); }); return app; } diff --git a/backend/src/config/devAuthor.js b/backend/src/config/devAuthor.js new file mode 100644 index 0000000..e56897e --- /dev/null +++ b/backend/src/config/devAuthor.js @@ -0,0 +1,6 @@ +/** + * Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`). + */ +export function isTestAuthor(createdBy, userId) { + return createdBy === userId; +} diff --git a/backend/src/config/featureFlags.js b/backend/src/config/featureFlags.js new file mode 100644 index 0000000..cc61898 --- /dev/null +++ b/backend/src/config/featureFlags.js @@ -0,0 +1,18 @@ +/** + * Флаги продуктовых фич (env). В development ряд вещей включён по умолчанию. + */ + +/** API и UI: назначение тестов сотрудникам (каталог HR + POST /tests/:id/assign). */ +export function isAssignmentFeatureEnabled() { + if (process.env.NODE_ENV === 'development') { + return true; + } + const v = (process.env.CLINIC_ASSIGNMENT_ENABLED || '').toLowerCase(); + if (v === '1' || v === 'true' || v === 'yes') { + return true; + } + if (v === '0' || v === 'false' || v === 'no') { + return false; + } + return false; +} diff --git a/backend/src/integration/v9card1.test.js b/backend/src/integration/v9card1.test.js new file mode 100644 index 0000000..30b4053 --- /dev/null +++ b/backend/src/integration/v9card1.test.js @@ -0,0 +1,234 @@ +/** + * Card1 V.9: интеграция с реальной `clinic_tests` — старая попытка остаётся + * на снимке версии и старых `question_id` после форка (новая версия). + * + * Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`), + * `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import pg from 'pg'; +import bcrypt from 'bcryptjs'; +import { getPoolConfig } from '../db/poolConfig.js'; +import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; + +const { Pool } = pg; + +/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */ +let runDb = false; +if (process.env.CLINIC_TESTS_INTEGRATION === '1') { + const probe = new Pool({ + ...getPoolConfig(), + connectionTimeoutMillis: 2000, + }); + try { + await probe.query('SELECT 1'); + runDb = true; + } catch { + runDb = false; + } finally { + await probe.end(); + } +} + +const qPayload = (label) => ({ + title: 'V9 ' + label, + questions: [ + { + text: `Q ${label}`, + question_order: 1, + hasMultipleAnswers: false, + options: [ + { text: 'yes', isCorrect: true, option_order: 1 }, + { text: 'no', isCorrect: false, option_order: 2 }, + ], + }, + ], +}); + +/** + * @param {import('pg').Pool} pool + * @param {string} testId + * @param {string} [exceptUserId] + */ +async function purgeTestChain(pool, testId, exceptUserId) { + await pool.query( + `DELETE FROM user_answers WHERE attempt_id IN ( + SELECT id FROM test_attempts WHERE test_version_id IN ( + SELECT id FROM test_versions WHERE test_id = $1 + ) + )`, + [testId] + ); + await pool.query( + `DELETE FROM test_attempts WHERE test_version_id IN ( + SELECT id FROM test_versions WHERE test_id = $1 + )`, + [testId] + ); + await pool.query( + `DELETE FROM answer_options WHERE question_id IN ( + SELECT id FROM questions WHERE test_version_id IN ( + SELECT id FROM test_versions WHERE test_id = $1 + ) + )`, + [testId] + ); + await pool.query( + `DELETE FROM questions WHERE test_version_id IN ( + SELECT id FROM test_versions WHERE test_id = $1 + )`, + [testId] + ); + await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]); + await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]); + if (exceptUserId) { + await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]); + } +} + +test( + 'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)', + { skip: !runDb }, + async () => { + const pool = new Pool(getPoolConfig()); + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + let userId; + let testId; + try { + const { rows: u } = await pool.query( + `INSERT INTO users (login, password_hash, full_name, role, is_active) + VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`, + [`v9p-${suffix}`, bcrypt.hashSync('x', 4)] + ); + userId = u[0].id; + const c = await createTestWithVersion(pool, userId, { title: 'V9P' }); + testId = c.testId; + const { rows: v0 } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, + [testId] + ); + const vid0 = v0[0].id; + await saveTestDraft(pool, userId, testId, qPayload('A')); + const { rows: c1 } = await pool.query( + `SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, + [testId] + ); + assert.equal(c1[0].n, 1, 'должна остаться одна версия'); + const { rows: v1 } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, + [testId] + ); + assert.equal( + v1[0].id, + vid0, + 'id активной версии не меняется при нуле попыток' + ); + await saveTestDraft(pool, userId, testId, qPayload('B')); + const { rows: c2 } = await pool.query( + `SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, + [testId] + ); + assert.equal(c2[0].n, 1); + const { rows: v2 } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, + [testId] + ); + assert.equal(v2[0].id, vid0); + } finally { + if (userId && testId) { + await purgeTestChain(pool, testId, userId); + } + await pool.end(); + } + } +); + +test( + 'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id', + { skip: !runDb }, + async () => { + const pool = new Pool(getPoolConfig()); + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + let userId; + let testId; + let v1Id; + let q1Id; + let opt1Id; + let attemptId; + try { + const { rows: u } = await pool.query( + `INSERT INTO users (login, password_hash, full_name, role, is_active) + VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`, + [`v9f-${suffix}`, bcrypt.hashSync('x', 4)] + ); + userId = u[0].id; + const c = await createTestWithVersion(pool, userId, { title: 'V9F' }); + testId = c.testId; + await saveTestDraft(pool, userId, testId, qPayload('pre')); + + const { rows: tv0 } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, + [testId] + ); + v1Id = tv0[0].id; + const { rows: qu } = await pool.query( + `SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`, + [v1Id] + ); + q1Id = qu[0].id; + const { rows: op } = await pool.query( + `SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`, + [q1Id] + ); + opt1Id = op[0].id; + + const { rows: at } = await pool.query( + `INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed) + VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`, + [v1Id, userId] + ); + attemptId = at[0].id; + await pool.query( + `INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`, + [attemptId, q1Id, [opt1Id]] + ); + + const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork')); + assert.equal(out.forked, true, 'должна создаться новая версия после попытки'); + + const { rows: att } = await pool.query( + `SELECT test_version_id FROM test_attempts WHERE id = $1`, + [attemptId] + ); + assert.equal( + att[0].test_version_id, + v1Id, + 'попытка остаётся на версии, с которой проходили' + ); + const { rows: ua } = await pool.query( + `SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, + [attemptId] + ); + assert.equal(ua[0].question_id, q1Id); + assert.equal(ua[0].selected_options[0], opt1Id); + + const { rows: qExists } = await pool.query( + `SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`, + [q1Id, v1Id] + ); + assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии'); + + const { rows: active } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, + [testId] + ); + assert.notEqual(active[0].id, v1Id, 'новая версия — активна'); + } finally { + if (userId && testId) { + await purgeTestChain(pool, testId, userId); + } + await pool.end(); + } + } +); diff --git a/backend/src/messages/ru.js b/backend/src/messages/ru.js new file mode 100644 index 0000000..05d2a73 --- /dev/null +++ b/backend/src/messages/ru.js @@ -0,0 +1,41 @@ +/** Тексты ответов API для пользователей (русский). */ +export const RU = { + loginAndPasswordRequired: 'Укажите логин и пароль.', + invalidCredentials: 'Неверный логин или пароль.', + useHrLogin: 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).', + hrDatabaseUrlMissing: + 'База кадровой системы не настроена: задайте HR_DATABASE_URL на backend.', + hrDatabaseNotConfigured: 'База кадровой системы не настроена.', + noStaffForLogin: + 'К учётной записи не привязан сотрудник: в HR в карточке сотрудника должно совпадать поле веб-логина (web_login) с логином входа, как в кабинете сотрудника.', + loggedOut: 'Вы вышли из системы.', + logoutFailed: 'Не удалось выйти. Повторите попытку.', + userDataFailed: 'Не удалось загрузить данные пользователя.', + loginFailed: 'Ошибка входа. Повторите попытку.', + authRequired: 'Требуется вход в систему.', + tokenInvalid: 'Сессия истекла или недействительна. Войдите снова.', + userNotFound: 'Пользователь не найден.', + authError: 'Ошибка проверки доступа.', + insufficientPermissions: 'Недостаточно прав.', + departmentAccessDenied: 'Нет доступа к этому подразделению.', + notFound: 'Не найдено.', + fileFieldRequired: 'Прикрепите файл к полю file.', + uploadFailed: 'Не удалось принять файл.', + titleRequired: 'Укажите название.', + assignmentUserRequired: 'Передайте userId (UUID) или staffId (число, сотрудник из HR).', + assignmentUserOrStaff: 'Укажите только userId, или только staffId — не оба сразу.', + testNotFound: 'Тест не найден.', + forbidden: 'Доступ запрещён.', + versionNotFound: 'Версия не найдена.', + chainActiveRequired: 'Передайте chainActive: true/false в теле запроса.', + noActiveVersion: 'Нет активной версии теста.', + internal: 'Внутренняя ошибка сервера.', + fileTooLarge: 'Файл слишком большой (максимум 10 МБ).', + unsupportedFileType: + 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.', + attemptNotFound: 'Попытка не найдена.', + attemptNotInProgress: 'Попытка уже завершена или просрочена.', + attemptNotCompleted: 'Попытка ещё не завершена — подробный разбор доступен после отправки ответов.', + testHasNoQuestions: 'В активной версии нет вопросов. Добавьте вопросы и сохраните черновик.', + invalidOptionForQuestion: 'Выбран вариант ответа, не относящийся к вопросу.', +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 1ec2d21..dd28530 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -5,6 +5,7 @@ import { verifyToken } from '../utils/auth.js'; import { query } from '../db/db.js'; +import { RU } from '../messages/ru.js'; /** * Extract token from cookie @@ -24,13 +25,13 @@ export async function authenticate(req, res, next) { const token = getTokenFromCookie(req); if (!token) { - return res.status(401).json({ error: 'Authentication required' }); + return res.status(401).json({ error: RU.authRequired }); } const decoded = verifyToken(token); if (!decoded) { - return res.status(401).json({ error: 'Invalid or expired token' }); + return res.status(401).json({ error: RU.tokenInvalid }); } const result = await query( @@ -39,7 +40,7 @@ export async function authenticate(req, res, next) { ); if (result.rows.length === 0) { - return res.status(401).json({ error: 'User not found' }); + return res.status(401).json({ error: RU.userNotFound }); } const user = result.rows[0]; @@ -59,7 +60,7 @@ export async function authenticate(req, res, next) { next(); } catch (error) { console.error('Auth middleware error:', error); - return res.status(500).json({ error: 'Authentication error' }); + return res.status(500).json({ error: RU.authError }); } } @@ -73,11 +74,11 @@ export function requireRole(roles) { return (req, res, next) => { if (!req.user) { - return res.status(401).json({ error: 'Authentication required' }); + return res.status(401).json({ error: RU.authRequired }); } if (!allowedRoles.includes(req.user.role)) { - return res.status(403).json({ error: 'Insufficient permissions' }); + return res.status(403).json({ error: RU.insufficientPermissions }); } next(); @@ -93,7 +94,7 @@ export function requireRole(roles) { export function requireDepartment(departmentId) { return (req, res, next) => { if (!req.user) { - return res.status(401).json({ error: 'Authentication required' }); + return res.status(401).json({ error: RU.authRequired }); } // Admins can access all departments @@ -103,7 +104,7 @@ export function requireDepartment(departmentId) { // Managers can only access their department if (req.user.role === 'manager' && req.user.departmentId !== departmentId) { - return res.status(403).json({ error: 'Access denied to this department' }); + return res.status(403).json({ error: RU.departmentAccessDenied }); } next(); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d365a7f..98a44b0 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -11,6 +11,12 @@ import { isHrAuthEnabled, HR_MANAGED_PASSWORD_PLACEHOLDER, } from '../config/authConstants.js'; +import { RU } from '../messages/ru.js'; +import { + getAssignmentDirectory, + getHrDepartmentNames, +} from '../services/assignmentDirectoryService.js'; +import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; const router = express.Router(); @@ -18,12 +24,12 @@ router.post('/login', async (req, res) => { try { const { login, password } = req.body; if (!login || !password) { - return res.status(400).json({ error: 'Login and password are required' }); + return res.status(400).json({ error: RU.loginAndPasswordRequired }); } if (isHrAuthEnabled()) { if (!getHrPool()) { - return res.status(500).json({ error: 'HR_DATABASE_URL is not set' }); + return res.status(500).json({ error: RU.hrDatabaseUrlMissing }); } const u = await queryHr( `SELECT id, username, password_hash, role @@ -32,12 +38,12 @@ router.post('/login', async (req, res) => { [login] ); if (u.rows.length === 0 || !u.rows[0].password_hash) { - return res.status(401).json({ error: 'Invalid credentials' }); + return res.status(401).json({ error: RU.invalidCredentials }); } const row = u.rows[0]; const ok = await comparePassword(password, row.password_hash); if (!ok) { - return res.status(401).json({ error: 'Invalid credentials' }); + return res.status(401).json({ error: RU.invalidCredentials }); } const s = await queryHr( `SELECT id, fio FROM staff_members @@ -45,9 +51,7 @@ router.post('/login', async (req, res) => { [login] ); if (s.rows.length === 0) { - return res - .status(403) - .json({ error: 'No staff link for this login (web_login)' }); + return res.status(403).json({ error: RU.noStaffForLogin }); } const staffId = s.rows[0].id; const fio = s.rows[0].fio || login; @@ -93,15 +97,15 @@ router.post('/login', async (req, res) => { [login] ); if (result.rows.length === 0) { - return res.status(401).json({ error: 'Invalid credentials' }); + return res.status(401).json({ error: RU.invalidCredentials }); } const user = result.rows[0]; if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { - return res.status(401).json({ error: 'Use HR login' }); + return res.status(401).json({ error: RU.useHrLogin }); } const isValidPassword = await comparePassword(password, user.password_hash); if (!isValidPassword) { - return res.status(401).json({ error: 'Invalid credentials' }); + return res.status(401).json({ error: RU.invalidCredentials }); } const token = generateToken(user.id, user.role, user.department_id); res.cookie('token', token, { @@ -122,10 +126,10 @@ router.post('/login', async (req, res) => { }); } catch (error) { if (error.message?.includes('HR database not configured')) { - return res.status(500).json({ error: 'HR database not configured' }); + return res.status(500).json({ error: RU.hrDatabaseNotConfigured }); } console.error('Login error:', error); - return res.status(500).json({ error: 'Login failed' }); + return res.status(500).json({ error: RU.loginFailed }); } }); @@ -136,19 +140,48 @@ router.post('/logout', (req, res) => { secure: process.env.NODE_ENV === 'production', sameSite: 'strict', }); - res.json({ message: 'Logged out successfully' }); + res.json({ message: RU.loggedOut }); } catch (error) { console.error('Logout error:', error); - res.status(500).json({ error: 'Logout failed' }); + res.status(500).json({ error: RU.logoutFailed }); } }); router.get('/me', authenticate, async (req, res) => { try { - res.json({ user: req.user }); + const devUi = process.env.NODE_ENV === 'development'; + const assignmentUi = isAssignmentFeatureEnabled(); + res.json({ user: req.user, devUi, assignmentUi }); } catch (error) { console.error('Get current user error:', error); - res.status(500).json({ error: 'Failed to get user data' }); + res.status(500).json({ error: RU.userDataFailed }); + } +}); + +/** + * Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`. + * Query: q, department (имя отдела или __all__), clinic=all|with|without + */ +router.get('/dev/assignment-directory', authenticate, async (req, res) => { + if (!isAssignmentFeatureEnabled()) { + return res.status(404).json({ error: RU.notFound }); + } + try { + const q = typeof req.query.q === 'string' ? req.query.q : ''; + const department = typeof req.query.department === 'string' ? req.query.department : ''; + const c = req.query.clinic; + const clinicFilter = + c === 'with' || c === 'without' ? c : 'all'; + const { people, source } = await getAssignmentDirectory({ + q, + department, + clinicFilter, + }); + const departments = await getHrDepartmentNames(); + res.json({ people, source, departments }); + } catch (error) { + console.error('dev assignment directory:', error); + res.status(500).json({ error: RU.userDataFailed }); } }); diff --git a/backend/src/server.js b/backend/src/server.js index ba9de20..717f7c6 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,7 +1,7 @@ import { createApp } from './app.js'; const app = createApp(); -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 3107; app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); diff --git a/backend/src/services/aiEditorService.js b/backend/src/services/aiEditorService.js new file mode 100644 index 0000000..faf2fd2 --- /dev/null +++ b/backend/src/services/aiEditorService.js @@ -0,0 +1,197 @@ +/** + * Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI. + */ +import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; +import { + parseJsonFromLlmText, + validateAndNormalizeDraft, +} from './documentGenService.js'; + +/** + * @param {unknown} s + * @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]} + */ +export function parseAndValidateShape(s) { + if (!Array.isArray(s) || s.length === 0) { + const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].'); + e.status = 400; + throw e; + } + if (s.length > 40) { + const e = new Error('Не более 40 вопросов за раз.'); + e.status = 400; + throw e; + } + return s.map((row, i) => { + if (!row || typeof row !== 'object') { + const e = new Error(`shape[${i}]: ожидается объект.`); + e.status = 400; + throw e; + } + const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount)); + const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers); + if (!Number.isFinite(n) || n < 2 || n > 12) { + const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`); + e.status = 400; + throw e; + } + return { optionsCount: n, hasMultipleAnswers }; + }); +} + +/** + * @param {any} o parsed draft + * @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape + */ +export function assertDraftMatchesShape(o, shape) { + if (!o?.questions || !Array.isArray(o.questions)) { + const e = new Error('В ответе нет questions.'); + e.code = 'llm_shape'; + throw e; + } + if (o.questions.length !== shape.length) { + const e = new Error( + `Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.` + ); + e.code = 'llm_shape'; + throw e; + } + for (let i = 0; i < shape.length; i++) { + const q = o.questions[i]; + const sh = shape[i]; + if (!q?.options || !Array.isArray(q.options)) { + const e = new Error(`Вопрос ${i + 1}: нет options.`); + e.code = 'llm_shape'; + throw e; + } + if (q.options.length !== sh.optionsCount) { + const e = new Error( + `Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.` + ); + e.code = 'llm_shape'; + throw e; + } + if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) { + const e = new Error( + `Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.` + ); + e.code = 'llm_shape'; + throw e; + } + } +} + +/** + * @param {string} testTitle + * @param {string} testDescription + * @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape + */ +export async function generateFullTestByShape(testTitle, testDescription, shape) { + const cfg = getLlmConfig(); + if (!cfg) { + const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); + /** @type {any} */ (e).status = 503; + throw e; + } + const title = (testTitle || '').trim() || 'Тест'; + const desc = (testDescription || '').trim(); + const lines = shape.map( + (sh, i) => + `Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${ + sh.hasMultipleAnswers + ? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' + : 'ровно один верный вариант (hasMultipleAnswers: false).' + }` + ); + const system = + 'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.'; + const user = `Составь тест по теме. + +Название (можно уточнить, но смысл сохранить): ${title} +Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'} + +Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше): +${lines.join('\n')} + +Правила: варианты — осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного — ровна одна true.`; + + const raw = await chatCompletionTextContent(cfg, system, user, 0.35); + const parsed = parseJsonFromLlmText(raw); + const draft = validateAndNormalizeDraft(parsed); + assertDraftMatchesShape({ questions: draft.questions }, shape); + return { + title: draft.title, + description: draft.description, + questions: draft.questions, + }; +} + +/** + * Пустой вопрос → сгенерировать формулировки; непустой → переформулировать только текст вопроса. + * @param {string} testTitle + * @param {string} testDescription + * @param {string} questionText + * @param {number} optionsCount + * @param {boolean} hasMultipleAnswers + */ +export async function generateOrRephraseQuestion( + testTitle, + testDescription, + questionText, + optionsCount, + hasMultipleAnswers +) { + const cfg = getLlmConfig(); + if (!cfg) { + const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); + /** @type {any} */ (e).status = 503; + throw e; + } + const n = Math.floor(Number(optionsCount)); + if (!Number.isFinite(n) || n < 2 || n > 12) { + const e = new Error('optionsCount: от 2 до 12.'); + e.status = 400; + throw e; + } + const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim(); + const qt = (questionText || '').trim(); + + if (qt) { + const system = + 'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.'; + const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`; + const raw = await chatCompletionTextContent(cfg, system, user, 0.3); + const parsed = parseJsonFromLlmText(raw); + const text = String((/** @type {any} */ (parsed)).text ?? '').trim(); + if (!text) { + const e = new Error('Пустой text в ответе модели.'); + e.code = 'llm_shape'; + throw e; + } + return { mode: 'rephrase', text }; + } + + const system = + 'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.'; + const user = `Тема теста: ${topic} + +Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${ + hasMultipleAnswers + ? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).' + : 'false (ровно один isCorrect: true).' + }`; + const raw = await chatCompletionTextContent(cfg, system, user, 0.35); + const parsed = parseJsonFromLlmText(raw); + const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }]; + assertDraftMatchesShape({ questions: [parsed] }, shape); + const draft = validateAndNormalizeDraft({ + title: 'временно', + questions: [parsed], + }); + return { + mode: 'full', + text: draft.questions[0].text, + hasMultipleAnswers: draft.questions[0].hasMultipleAnswers, + options: draft.questions[0].options, + }; +} diff --git a/backend/src/services/aiEditorService.test.js b/backend/src/services/aiEditorService.test.js new file mode 100644 index 0000000..6742772 --- /dev/null +++ b/backend/src/services/aiEditorService.test.js @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseAndValidateShape } from './aiEditorService.js'; + +test('parseAndValidateShape: валидный ввод', () => { + const s = parseAndValidateShape([ + { optionsCount: 3, hasMultipleAnswers: false }, + { optionsCount: 2, hasMultipleAnswers: true }, + ]); + assert.equal(s.length, 2); + assert.equal(s[0].optionsCount, 3); + assert.equal(s[1].hasMultipleAnswers, true); +}); + +test('parseAndValidateShape: пусто — ошибка', () => { + assert.throws( + () => parseAndValidateShape([]), + /Передайте/ + ); +}); diff --git a/backend/src/services/assignmentDirectoryService.js b/backend/src/services/assignmentDirectoryService.js new file mode 100644 index 0000000..407341d --- /dev/null +++ b/backend/src/services/assignmentDirectoryService.js @@ -0,0 +1,125 @@ +/** + * Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id. + * Две БД — данные сливаем в Node. + */ +import { getHrPool, queryHr } from '../db/hrPool.js'; +import pool from '../db/db.js'; + +/** + * @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p + */ +export async function getAssignmentDirectory(p) { + const { rows: clinicByStaff } = await pool.query( + `SELECT id, staff_id, login, full_name + FROM users + WHERE is_active = true AND staff_id IS NOT NULL` + ); + const byStaff = new Map(); + for (const r of clinicByStaff) { + byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name }); + } + + if (!getHrPool()) { + const { rows } = await pool.query( + `SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin" + FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login` + ); + let people = rows.map((r) => ({ + staffId: r.staff_id, + fio: r.fio || r.webLogin, + webLogin: r.webLogin, + departments: '', + clinicUserId: r.id, + })); + const qx = (p.q || '').trim().toLowerCase(); + if (qx) { + people = people.filter( + (x) => + (x.fio && x.fio.toLowerCase().includes(qx)) || + (x.webLogin && x.webLogin.toLowerCase().includes(qx)) || + (x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx)) + ); + } + return { people, source: 'clinic' }; + } + + const q = (p.q || '').trim(); + const dept = (p.department || '').trim(); + const clinicFilter = p.clinicFilter || 'all'; + + const { rows: staffRows } = await queryHr( + `SELECT sm.id AS staff_id, sm.fio, sm.web_login + FROM staff_members sm`, + [] + ); + if (!staffRows.length) { + return { people: [], source: 'hr' }; + } + + const { rows: edRows } = await queryHr( + `SELECT staff_id, department FROM employees_departments + WHERE department IS NOT NULL AND trim(department) <> ''`, + [] + ); + const deptsByStaff = new Map(); + for (const r of edRows) { + if (!deptsByStaff.has(r.staff_id)) { + deptsByStaff.set(r.staff_id, new Set()); + } + deptsByStaff.get(r.staff_id).add(r.department); + } + + let people = staffRows.map((r) => { + const dset = deptsByStaff.get(r.staff_id); + const departments = dset + ? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ') + : ''; + const cu = byStaff.get(r.staff_id) || null; + return { + staffId: r.staff_id, + fio: r.fio || '—', + webLogin: r.web_login, + departments, + clinicUserId: cu ? cu.clinicUserId : null, + }; + }); + + if (q) { + const low = q.toLowerCase(); + people = people.filter( + (x) => + (x.fio && x.fio.toLowerCase().includes(low)) || + (x.webLogin && x.webLogin.toLowerCase().includes(low)) + ); + } + if (dept && dept !== '__all__') { + people = people.filter((x) => { + const s = deptsByStaff.get(x.staffId); + return s && s.has(dept); + }); + } + if (clinicFilter === 'with') { + people = people.filter((x) => x.clinicUserId != null); + } else if (clinicFilter === 'without') { + people = people.filter((x) => x.clinicUserId == null); + } + + people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru')); + return { people, source: 'hr' }; +} + +/** + * @returns {Promise} + */ +export async function getHrDepartmentNames() { + if (!getHrPool()) { + return []; + } + const { rows } = await queryHr( + `SELECT DISTINCT TRIM(department) AS d + FROM employees_departments + WHERE department IS NOT NULL AND TRIM(department) <> '' + ORDER BY 1` + ); + return rows.map((r) => r.d).filter(Boolean); +} diff --git a/backend/src/services/assignmentUserService.js b/backend/src/services/assignmentUserService.js new file mode 100644 index 0000000..aebe17e --- /dev/null +++ b/backend/src/services/assignmentUserService.js @@ -0,0 +1,64 @@ +/** + * Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid. + */ +import { queryHr, getHrPool } from '../db/hrPool.js'; +import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js'; +import { RU } from '../messages/ru.js'; + +/** + * @param {import('pg').Pool} pool + * @param {number} staffId + * @returns {Promise} uuid в clinic_tests.users + */ +export async function ensureClinicUserIdForStaff(pool, staffId) { + const n = Math.floor(Number(staffId)); + if (!Number.isFinite(n) || n < 1) { + const e = new Error(RU.assignmentUserRequired); + e.status = 400; + throw e; + } + const { rows: ex } = await pool.query( + `SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`, + [n] + ); + if (ex.length) { + return ex[0].id; + } + if (!getHrPool()) { + const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.'); + e.status = 400; + throw e; + } + const { rows: st } = await queryHr( + `SELECT id, fio, web_login FROM staff_members WHERE id = $1`, + [n] + ); + if (!st.length) { + const e = new Error('Сотрудник не найден в HR.'); + e.status = 400; + throw e; + } + const fio = st[0].fio || `staff #${n}`; + const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null; + let login = rawLogin; + if (!login) { + login = `staff_${n}@clinic.local`; + } + const { rows: taken } = await pool.query( + `SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`, + [login, n] + ); + if (taken.length) { + login = `staff_${n}@clinic.local`; + } + const ins = await pool.query( + `INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id) + VALUES ($1, $2, $3, 'employee', null, true, $4) + ON CONFLICT (staff_id) DO UPDATE SET + full_name = EXCLUDED.full_name, + is_active = true + RETURNING id`, + [login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n] + ); + return ins.rows[0].id; +} diff --git a/backend/src/services/documentExtractService.js b/backend/src/services/documentExtractService.js new file mode 100644 index 0000000..c1d5b83 --- /dev/null +++ b/backend/src/services/documentExtractService.js @@ -0,0 +1,66 @@ +/** + * D.2 — извлечение текста из PDF, DOCX, TXT (см. card1.md). + */ +import { readFile } from 'fs/promises'; +import { createRequire } from 'node:module'; +import mammoth from 'mammoth'; +import { RU } from '../messages/ru.js'; + +const require = createRequire(import.meta.url); +const pdfParse = require('pdf-parse'); + +/** @param {string} mime @param {string} [originalName] */ +export function resolveDocumentKind(mime, originalName = '') { + const m = (mime || '').toLowerCase(); + const n = originalName.toLowerCase(); + if (m === 'application/pdf' || n.endsWith('.pdf')) { + return 'pdf'; + } + if ( + m === + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + n.endsWith('.docx') + ) { + return 'docx'; + } + if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) { + return 'text'; + } + return null; +} + +/** + * @param {string} mimetype + * @param {string} filePath + * @param {string} [originalName] + * @returns {Promise} извлечённый плоский текст + */ +export async function extractTextFromFile(mimetype, filePath, originalName) { + const kind = resolveDocumentKind(mimetype, originalName); + if (!kind) { + const e = new Error(RU.unsupportedFileType); + e.status = 400; + throw e; + } + const buf = await readFile(filePath); + return extractTextFromBuffer(kind, buf); +} + +/** + * @param {'pdf'|'docx'|'text'} kind + * @param {Buffer} buffer + */ +export async function extractTextFromBuffer(kind, buffer) { + if (kind === 'text') { + return buffer.toString('utf8'); + } + if (kind === 'docx') { + const { value } = await mammoth.extractRawText({ buffer }); + return (value || '').replace(/\r\n/g, '\n').trim(); + } + if (kind === 'pdf') { + const data = await pdfParse(buffer); + return ((data && data.text) || '').replace(/\r\n/g, '\n').trim(); + } + return ''; +} diff --git a/backend/src/services/documentExtractService.test.js b/backend/src/services/documentExtractService.test.js new file mode 100644 index 0000000..baa0395 --- /dev/null +++ b/backend/src/services/documentExtractService.test.js @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractTextFromBuffer, + resolveDocumentKind, +} from './documentExtractService.js'; + +test('resolveDocumentKind: PDF по MIME и по имени', () => { + assert.equal(resolveDocumentKind('application/pdf'), 'pdf'); + assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf'); + assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf'); +}); + +test('resolveDocumentKind: docx, txt, неизвестно', () => { + assert.equal( + resolveDocumentKind( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ), + 'docx' + ); + assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text'); + assert.equal(resolveDocumentKind('', 'readme.md'), 'text'); + assert.equal(resolveDocumentKind('image/png'), null); + assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null); +}); + +test('extractTextFromBuffer: text UTF-8', async () => { + const t = await extractTextFromBuffer( + 'text', + Buffer.from('Проверка D.2', 'utf8') + ); + assert.equal(t, 'Проверка D.2'); +}); diff --git a/backend/src/services/documentGenService.js b/backend/src/services/documentGenService.js new file mode 100644 index 0000000..fe08c64 --- /dev/null +++ b/backend/src/services/documentGenService.js @@ -0,0 +1,176 @@ +/** + * D.3 — генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions). + * Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL. + */ +import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; + +const MAX_EXTRACT_CHARS = 14000; + +/** + * @param {string} text + * @returns {string} + */ +export function parseJsonFromLlmText(text) { + if (typeof text !== 'string' || !text.trim()) { + const e = new Error('Пустой ответ модели.'); + e.code = 'llm_empty'; + throw e; + } + let t = text.trim(); + const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t); + if (fence) { + t = fence[1].trim(); + } + let parsed; + try { + parsed = JSON.parse(t); + } catch (err) { + const e = new Error('Ответ модели не является корректным JSON.'); + e.code = 'llm_json_parse'; + throw e; + } + return parsed; +} + +/** + * @param {unknown} o + * @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }} + */ +export function validateAndNormalizeDraft(o) { + if (!o || typeof o !== 'object') { + const e = new Error('JSON не содержит объекта с данными.'); + e.code = 'llm_shape'; + throw e; + } + const title = String((/** @type {any} */ (o)).title ?? '').trim(); + if (!title) { + const e = new Error('В ответе нет поля title.'); + e.code = 'llm_shape'; + throw e; + } + const desc = (/** @type {any} */ (o)).description; + const description = + desc != null && String(desc).trim() ? String(desc).trim() : null; + const rawQs = (/** @type {any} */ (o)).questions; + if (!Array.isArray(rawQs) || rawQs.length === 0) { + const e = new Error('В ответе нет вопросов (questions).'); + e.code = 'llm_shape'; + throw e; + } + if (rawQs.length > 40) { + const e = new Error('Слишком много вопросов в ответе (макс. 40).'); + e.code = 'llm_shape'; + throw e; + } + const questions = rawQs.map((q, i) => { + if (!q || typeof q !== 'object') { + const e = new Error(`Вопрос ${i + 1}: неверный формат.`); + e.code = 'llm_shape'; + throw e; + } + const text = String((/** @type {any} */ (q)).text ?? '').trim(); + if (!text) { + const e = new Error(`Вопрос ${i + 1}: пустой текст.`); + e.code = 'llm_shape'; + throw e; + } + const hasMultipleAnswers = Boolean( + (/** @type {any} */ (q)).hasMultipleAnswers + ); + const rawOpts = (/** @type {any} */ (q)).options; + if (!Array.isArray(rawOpts) || rawOpts.length < 2) { + const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`); + e.code = 'llm_shape'; + throw e; + } + if (rawOpts.length > 12) { + const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`); + e.code = 'llm_shape'; + throw e; + } + const options = rawOpts.map((op, j) => { + if (!op || typeof op !== 'object') { + const e = new Error( + `Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.` + ); + e.code = 'llm_shape'; + throw e; + } + return { + text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`, + isCorrect: Boolean((/** @type {any} */ (op)).isCorrect), + }; + }); + const correctN = options.filter((x) => x.isCorrect).length; + if (correctN === 0) { + const e = new Error( + `Вопрос ${i + 1}: отметьте минимум один правильный вариант.` + ); + e.code = 'llm_shape'; + throw e; + } + if (!hasMultipleAnswers && correctN > 1) { + const e = new Error( + `Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.` + ); + e.code = 'llm_shape'; + throw e; + } + return { text, hasMultipleAnswers, options }; + }); + return { title, description, questions }; +} + +/** + * D.1/D.2/D.3 — ответ для POST /import/document (клиент не получает сырые ключи). + * @param {string} extractedText + */ +export async function generationForImportDocument(extractedText) { + const text = (extractedText || '').trim(); + if (!text) { + return { + available: false, + message: 'Нет извлечённого текста — нечего передавать в модель.', + }; + } + const cfg = getLlmConfig(); + if (!cfg) { + return { + available: false, + message: + 'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.', + textPreview: text.slice(0, 4000), + }; + } + const slice = + text.length > MAX_EXTRACT_CHARS + ? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]` + : text; + try { + const system = + 'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.'; + const user = + 'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice; + const raw = await chatCompletionTextContent(cfg, system, user, 0.25); + const parsed = parseJsonFromLlmText(raw); + const draft = validateAndNormalizeDraft(parsed); + return { + available: true, + message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`, + draft: { + title: draft.title, + description: draft.description, + questions: draft.questions, + }, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error'; + return { + available: false, + message: `Генерация не удалась: ${msg}`, + errorCode: code, + textPreview: text.slice(0, 4000), + }; + } +} diff --git a/backend/src/services/documentGenService.test.js b/backend/src/services/documentGenService.test.js new file mode 100644 index 0000000..fcfc4ad --- /dev/null +++ b/backend/src/services/documentGenService.test.js @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseJsonFromLlmText, + validateAndNormalizeDraft, +} from './documentGenService.js'; + +test('parseJsonFromLlmText: чистый JSON', () => { + const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}'); + assert.equal(o.title, 'T'); + assert.equal(o.questions.length, 1); +}); + +test('parseJsonFromLlmText: JSON в markdown-заборе', () => { + const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```'; + const o = parseJsonFromLlmText(raw); + assert.equal(o.title, 'X'); +}); + +test('parseJsonFromLlmText: невалидный JSON — ошибка', () => { + assert.throws( + () => parseJsonFromLlmText('not json'), + /JSON/i + ); +}); + +test('validateAndNormalizeDraft: валидный черновик', () => { + const d = validateAndNormalizeDraft({ + title: ' Экзамен ', + description: ' оп ', + questions: [ + { + text: '2+2?', + hasMultipleAnswers: false, + options: [ + { text: '4', isCorrect: true }, + { text: '5', isCorrect: false }, + ], + }, + ], + }); + assert.equal(d.title, 'Экзамен'); + assert.equal(d.description, 'оп'); + assert.equal(d.questions[0].options.length, 2); +}); + +test('validateAndNormalizeDraft: нет title', () => { + assert.throws( + () => + validateAndNormalizeDraft({ + questions: [ + { + text: 'Q', + options: [ + { text: 'a', isCorrect: true }, + { text: 'b', isCorrect: false }, + ], + }, + ], + }), + /title/i + ); +}); diff --git a/backend/src/services/llmClient.js b/backend/src/services/llmClient.js new file mode 100644 index 0000000..a3b9b7c --- /dev/null +++ b/backend/src/services/llmClient.js @@ -0,0 +1,98 @@ +/** + * OpenAI-совместимый Chat Completions. Общий для импорта и редактора. + */ + +/** + * @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }} + */ +export function getLlmConfig() { + if (process.env.DEEPSEEK_API_KEY) { + return { + provider: 'deepseek', + apiKey: process.env.DEEPSEEK_API_KEY, + baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace( + /\/+$/, + '' + ), + model: process.env.LLM_MODEL || 'deepseek-chat', + }; + } + if (process.env.OPENAI_API_KEY) { + return { + provider: 'openai', + apiKey: process.env.OPENAI_API_KEY, + baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace( + /\/+$/, + '' + ), + model: process.env.LLM_MODEL || 'gpt-4o-mini', + }; + } + return null; +} + +/** + * @param {{ baseUrl: string, apiKey: string, model: string }} cfg + * @param {string} system + * @param {string} user + * @param {number} [temperature] + * @returns {Promise} raw assistant message + */ +export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) { + const url = `${cfg.baseUrl}/chat/completions`; + const body = { + model: cfg.model, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + temperature, + }; + if (process.env.LLM_NO_JSON !== '1') { + body.response_format = { type: 'json_object' }; + } + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 120000); + let res; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${cfg.apiKey}`, + }, + body: JSON.stringify(body), + signal: ac.signal, + }); + } catch (e) { + if (e.name === 'AbortError') { + const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).'); + err.code = 'llm_timeout'; + throw err; + } + const err = new Error( + e instanceof Error ? e.message : 'Сбой сети при обращении к LLM' + ); + err.code = 'llm_network'; + throw err; + } finally { + clearTimeout(t); + } + if (!res.ok) { + const errText = await res.text(); + const err = new Error( + `LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}` + ); + err.code = 'llm_http'; + err.status = res.status; + throw err; + } + const data = await res.json(); + const content = data?.choices?.[0]?.message?.content; + if (typeof content !== 'string' || !content.trim()) { + const e = new Error('Пустой content в ответе API.'); + e.code = 'llm_empty'; + throw e; + } + return content; +} diff --git a/backend/src/services/testAttemptService.js b/backend/src/services/testAttemptService.js new file mode 100644 index 0000000..c45cda5 --- /dev/null +++ b/backend/src/services/testAttemptService.js @@ -0,0 +1,477 @@ +/** + * Прохождение теста: контент для игры, проверка ответов, завершение попытки. + */ +import { RU } from '../messages/ru.js'; +import { isTestAuthor } from '../config/devAuthor.js'; + +/** + * @param {import('pg').Pool|import('pg').PoolClient} db + * @param {string} testVersionId + * @param {{ includeCorrect: boolean }} opts + */ +export async function loadQuestionsForVersion(db, testVersionId, opts) { + const { rows: qrows } = await db.query( + `SELECT id, text, question_order, has_multiple_answers + FROM questions + WHERE test_version_id = $1 + ORDER BY question_order`, + [testVersionId] + ); + const out = []; + for (const row of qrows) { + const { rows: orows } = await db.query( + `SELECT id, text, is_correct, option_order + FROM answer_options + WHERE question_id = $1 + ORDER BY option_order`, + [row.id] + ); + const options = orows.map((o) => { + const base = { + id: o.id, + text: o.text, + optionOrder: o.option_order, + }; + if (opts.includeCorrect) { + return { ...base, isCorrect: o.is_correct }; + } + return base; + }); + out.push({ + id: row.id, + text: row.text, + questionOrder: row.question_order, + hasMultipleAnswers: row.has_multiple_answers, + options, + }); + } + return out; +} + +function sortUuidStrings(arr) { + return [...new Set(arr)].map(String).sort(); +} + +function sameSelection(selected, correctIds) { + const a = sortUuidStrings(selected); + const b = sortUuidStrings(correctIds); + if (a.length !== b.length) { + return false; + } + return a.every((x, i) => x === b[i]); +} + +/** + * @param {import('pg').Pool} pool + * @param {string} userId + * @param {string} testId + */ +export async function getEditorContent(pool, userId, testId) { + const { rows: tr } = await pool.query( + `SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by + FROM tests t WHERE t.id = $1`, + [testId] + ); + if (!tr.length) { + const e = new Error(RU.testNotFound); + e.status = 404; + throw e; + } + if (!isTestAuthor(tr[0].created_by, userId)) { + const e = new Error(RU.forbidden); + e.status = 403; + throw e; + } + const { rows: tv } = await pool.query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, + [testId] + ); + if (!tv.length) { + const e = new Error(RU.noActiveVersion); + e.status = 400; + throw e; + } + const versionId = tv[0].id; + const questions = await loadQuestionsForVersion(pool, versionId, { + includeCorrect: true, + }); + return { + test: { + id: tr[0].id, + title: tr[0].title, + description: tr[0].description, + passingThreshold: tr[0].passing_threshold, + }, + activeVersionId: versionId, + questions, + }; +} + +/** + * @param {import('pg').Pool} pool + * @param {string} userId + * @param {string} testId + * @param {string} attemptId + */ +export async function getPlayContent(pool, userId, testId, attemptId) { + const { rows: arows } = await pool.query( + `SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold + FROM test_attempts ta + INNER JOIN test_versions tv ON tv.id = ta.test_version_id + INNER JOIN tests t ON t.id = tv.test_id + WHERE ta.id = $1`, + [attemptId] + ); + if (!arows.length) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + const a = arows[0]; + if (a.test_id !== testId) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + if (a.user_id !== userId) { + const e = new Error(RU.forbidden); + e.status = 403; + throw e; + } + if (a.status !== 'in_progress') { + const e = new Error(RU.attemptNotInProgress); + e.status = 400; + throw e; + } + const questions = await loadQuestionsForVersion(pool, a.test_version_id, { + includeCorrect: false, + }); + return { + testTitle: a.title, + passingThreshold: a.passing_threshold, + attemptId: a.id, + questions, + }; +} + +/** + * @param {import('pg').Pool} pool + * @param {string} userId + * @param {string} testId + * @param {string} attemptId + * @param {Record | null | undefined} rawAnswers + */ +export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) { + const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: arows } = await client.query( + `SELECT id, user_id, status, test_version_id + FROM test_attempts + WHERE id = $1 + FOR UPDATE`, + [attemptId] + ); + if (!arows.length) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + const a0 = arows[0]; + const { rows: trows } = await client.query( + `SELECT t.passing_threshold, tv.test_id + FROM test_versions tv + INNER JOIN tests t ON t.id = tv.test_id + WHERE tv.id = $1`, + [a0.test_version_id] + ); + if (!trows.length) { + const e = new Error(RU.testNotFound); + e.status = 404; + throw e; + } + const link = trows[0]; + const a = { + test_id: link.test_id, + user_id: a0.user_id, + status: a0.status, + test_version_id: a0.test_version_id, + passing_threshold: link.passing_threshold, + }; + if (a.test_id !== testId) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + if (a.user_id !== userId) { + const e = new Error(RU.forbidden); + e.status = 403; + throw e; + } + if (a.status !== 'in_progress') { + const e = new Error(RU.attemptNotInProgress); + e.status = 400; + throw e; + } + const versionId = a.test_version_id; + const threshold = Number(a.passing_threshold) || 0; + + const { rows: qrows } = await client.query( + `SELECT id, has_multiple_answers + FROM questions + WHERE test_version_id = $1`, + [versionId] + ); + if (!qrows.length) { + const e = new Error(RU.testHasNoQuestions); + e.status = 400; + throw e; + } + + const { rows: allOpts } = await client.query( + `SELECT a.id, a.question_id, a.is_correct + FROM answer_options a + INNER JOIN questions q ON q.id = a.question_id + WHERE q.test_version_id = $1`, + [versionId] + ); + const byQuestion = new Map(); + for (const o of allOpts) { + if (!byQuestion.has(o.question_id)) { + byQuestion.set(o.question_id, { all: new Set(), correct: [] }); + } + const g = byQuestion.get(o.question_id); + g.all.add(String(o.id)); + if (o.is_correct) { + g.correct.push(String(o.id)); + } + } + + let correctCount = 0; + for (const q of qrows) { + const qid = String(q.id); + let selected = answers[qid] ?? answers[q.id]; + if (selected == null) { + selected = []; + } else if (!Array.isArray(selected)) { + selected = [String(selected)]; + } else { + selected = selected.map(String); + } + const g = byQuestion.get(q.id); + if (!g) { + continue; + } + for (const sid of selected) { + if (!g.all.has(sid)) { + const e = new Error(RU.invalidOptionForQuestion); + e.status = 400; + throw e; + } + } + if (sameSelection(selected, g.correct)) { + correctCount += 1; + } + } + const total = qrows.length; + const percent = (correctCount / total) * 100; + const passed = percent + 1e-9 >= threshold; + + await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]); + for (const q of qrows) { + const qid = String(q.id); + let selected = answers[qid] ?? answers[q.id] ?? []; + if (!Array.isArray(selected)) { + selected = [String(selected)]; + } else { + selected = selected.map(String); + } + await client.query( + `INSERT INTO user_answers (attempt_id, question_id, selected_options) + VALUES ($1, $2, $3::uuid[])`, + [attemptId, q.id, selected] + ); + } + await client.query( + `UPDATE test_attempts + SET status = 'completed', completed_at = CURRENT_TIMESTAMP, + correct_count = $2, total_questions = $3, passed = $4 + WHERE id = $1`, + [attemptId, correctCount, total, passed] + ); + await client.query('COMMIT'); + const base = { + attemptId, + correctCount, + totalQuestions: total, + percent: Math.round(percent * 10) / 10, + passed, + passingThreshold: threshold, + }; + const review = await buildReviewFromDb(pool, attemptId); + return { ...base, review }; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +/** + * Подробный разбор завершённой попытки (для API и ответа submit). + * @param {import('pg').Pool|import('pg').PoolClient} pool + * @param {string} attemptId + */ +export async function buildReviewFromDb(pool, attemptId) { + const { rows: arows } = await pool.query( + `SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, + ta.passed, ta.started_at, ta.completed_at, + t.id AS test_id, t.title, t.passing_threshold, + u.full_name AS attempter_name, u.login AS attempter_login + FROM test_attempts ta + INNER JOIN test_versions tv ON tv.id = ta.test_version_id + INNER JOIN tests t ON t.id = tv.test_id + INNER JOIN users u ON u.id = ta.user_id + WHERE ta.id = $1`, + [attemptId] + ); + if (!arows.length) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + const a = arows[0]; + if (a.status !== 'completed') { + const e = new Error(RU.attemptNotCompleted); + e.status = 400; + throw e; + } + const questions = await loadQuestionsForVersion(pool, a.test_version_id, { + includeCorrect: true, + }); + const { rows: uans } = await pool.query( + `SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, + [attemptId] + ); + const selByQ = new Map(); + for (const r of uans) { + selByQ.set(String(r.question_id), (r.selected_options || []).map(String)); + } + const threshold = Number(a.passing_threshold) || 0; + const total = a.total_questions || questions.length; + const percent = + total > 0 + ? Math.round(((a.correct_count || 0) / total) * 1000) / 10 + : 0; + const qOut = questions.map((q) => { + const selected = sortUuidStrings(selByQ.get(String(q.id)) || []); + const correctIdList = sortUuidStrings( + q.options.filter((o) => o.isCorrect).map((o) => String(o.id)) + ); + const isUserCorrect = sameSelection(selected, correctIdList); + const selectedSet = new Set(selected); + return { + id: q.id, + text: q.text, + hasMultipleAnswers: q.hasMultipleAnswers, + isUserCorrect, + options: q.options.map((o) => ({ + id: o.id, + text: o.text, + isCorrect: o.isCorrect, + selected: selectedSet.has(String(o.id)), + })), + }; + }); + return { + attemptId: a.id, + testId: a.test_id, + testTitle: a.title, + passingThreshold: threshold, + correctCount: a.correct_count, + totalQuestions: total, + percent, + passed: a.passed, + startedAt: a.started_at, + completedAt: a.completed_at, + attempterUserId: a.user_id, + attempterName: a.attempter_name, + attempterLogin: a.attempter_login, + questions: qOut, + }; +} + +/** + * Разбор попытки: владелец попытки или автор теста. + * @param {import('pg').Pool} pool + * @param {string} currentUserId + * @param {string} testId + * @param {string} attemptId + */ +export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) { + const { rows } = await pool.query( + `SELECT ta.user_id, t.created_by, tv.test_id + FROM test_attempts ta + INNER JOIN test_versions tv ON tv.id = ta.test_version_id + INNER JOIN tests t ON t.id = tv.test_id + WHERE ta.id = $1`, + [attemptId] + ); + if (!rows.length) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + const r0 = rows[0]; + if (r0.test_id !== testId) { + const e = new Error(RU.attemptNotFound); + e.status = 404; + throw e; + } + const isOwner = r0.user_id === currentUserId; + const isAuthor = isTestAuthor(r0.created_by, currentUserId); + if (!isOwner && !isAuthor) { + const e = new Error(RU.forbidden); + e.status = 403; + throw e; + } + return buildReviewFromDb(pool, attemptId); +} + +/** + * Список всех попыток по цепочке (все версии) — только автор. + * @param {import('pg').Pool} pool + * @param {string} authorId + * @param {string} testId + */ +export async function listTestAttemptsForAuthor(pool, authorId, testId) { + const { rows: t } = await pool.query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!t.length) { + const e = new Error(RU.testNotFound); + e.status = 404; + throw e; + } + if (!isTestAuthor(t[0].created_by, authorId)) { + const e = new Error(RU.forbidden); + e.status = 403; + throw e; + } + const { rows } = await pool.query( + `SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, + ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, + u.full_name AS attempter_name, u.login AS attempter_login + FROM test_attempts ta + INNER JOIN test_versions tv ON tv.id = ta.test_version_id + INNER JOIN users u ON u.id = ta.user_id + WHERE tv.test_id = $1 + ORDER BY ta.started_at DESC NULLS LAST + LIMIT 200`, + [testId] + ); + return rows; +} diff --git a/backend/src/services/testDraftService.js b/backend/src/services/testDraftService.js index 2889723..64cff00 100644 --- a/backend/src/services/testDraftService.js +++ b/backend/src/services/testDraftService.js @@ -2,6 +2,8 @@ * V.3 saveTestDraft, fork версии, контент вопросов. */ import { hasAnyAttemptForTest } from './testChainService.js'; +import { RU } from '../messages/ru.js'; +import { isTestAuthor } from '../config/devAuthor.js'; /** * @param {import('pg').PoolClient} client @@ -93,23 +95,25 @@ export async function replaceVersionContent(client, testVersionId, payload) { export async function forkNewVersion(client, testId) { const av = await getActiveVersionRow(client, testId); if (!av) { - throw new Error('no active version'); + throw new Error(RU.noActiveVersion); } const { rows: mx } = await client.query( `SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`, [testId] ); const nextV = (mx[0].v || 0) + 1; + // Сначала снять is_active с цепочки: частичный уникальный индекс + // uq_test_versions_one_active_per_test — не более одной true на test_id. + await client.query( + `UPDATE test_versions SET is_active = false WHERE test_id = $1`, + [testId] + ); const { rows: nv } = await client.query( `INSERT INTO test_versions (test_id, version, is_active, parent_id) VALUES ($1, $2, true, $3) RETURNING *`, [testId, nextV, av.id] ); const newRow = nv[0]; - await client.query( - `UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`, - [testId, newRow.id] - ); await copyQuestionTree(client, av.id, newRow.id); return newRow; } @@ -126,13 +130,13 @@ export async function saveTestDraft(pool, authorId, testId, payload) { [testId] ); if (!tr.length) { - const e = new Error('Test not found'); + const e = new Error(RU.testNotFound); e.status = 404; throw e; } const t = tr[0]; - if (t.created_by !== authorId) { - const e = new Error('Forbidden'); + if (!isTestAuthor(t.created_by, authorId)) { + const e = new Error(RU.forbidden); e.status = 403; throw e; } @@ -148,10 +152,20 @@ export async function saveTestDraft(pool, authorId, testId, payload) { [testId, payload.title ?? null, payload.description ?? null] ); } + if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) { + const raw = Number(payload.passingThreshold); + if (Number.isFinite(raw)) { + const pt = Math.max(0, Math.min(100, Math.round(raw))); + await client.query( + `UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, + [testId, pt] + ); + } + } const hasAttempts = await hasAnyAttemptForTest(client, testId); let versionRow = await getActiveVersionRow(client, testId); if (!versionRow) { - const e = new Error('No active version'); + const e = new Error(RU.noActiveVersion); e.status = 500; throw e; } diff --git a/backend/src/utils/auth.js b/backend/src/utils/auth.js index 13399c7..3476f99 100644 --- a/backend/src/utils/auth.js +++ b/backend/src/utils/auth.js @@ -3,7 +3,7 @@ * Password hashing and JWT token management */ -import bcrypt from 'bcrypt'; +import { hash, compare } from 'bcryptjs'; import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; import { checkWerkzeugPassword } from './werkzeugPassword.js'; @@ -14,7 +14,7 @@ dotenv.config(); const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; -// Salt rounds for bcrypt +// Salt rounds (bcryptjs — тот же формат $2*, без нативной сборки — проще Docker/ARM/musl) const SALT_ROUNDS = 10; /** @@ -23,7 +23,7 @@ const SALT_ROUNDS = 10; * @returns {Promise} Hashed password */ export async function hashPassword(password) { - return bcrypt.hash(password, SALT_ROUNDS); + return hash(password, SALT_ROUNDS); } /** @@ -43,7 +43,7 @@ export async function comparePassword(password, hash) { return checkWerkzeugPassword(hash, password); } if (hash.startsWith('$2')) { - return bcrypt.compare(password, hash); + return compare(password, hash); } return checkWerkzeugPassword(hash, password); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index beffa91..4df3331 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,15 +14,22 @@ services: context: ./backend dockerfile: Dockerfile container_name: testing_webapp_backend + # LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore) + env_file: + - ./backend/.env environment: DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev} # development: httpOnly-cookie без Secure (иначе на http://localhost:8080 логин не сработает) NODE_ENV: development FRONTEND_URL: http://localhost:8080 - # На хосте 3002, если 3001 занят локальным dev-сервером + # Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login. + # Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки). + HR_AUTH: ${HR_AUTH:-1} + HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test + # На хосте 3002, если 3107 занят локальным dev-сервером ports: - - "3002:3001" + - "3002:3107" networks: - app - postgres diff --git a/frontend/index.html b/frontend/index.html index 90e0721..42ef58e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,18 @@ - Клинические Тесты + + + + + + Система тестрования
diff --git a/frontend/nginx-default.conf b/frontend/nginx-default.conf index 319b454..9493312 100644 --- a/frontend/nginx-default.conf +++ b/frontend/nginx-default.conf @@ -6,8 +6,9 @@ server { location / { try_files $uri $uri/ /index.html; } + client_max_body_size 10m; location /api/ { - proxy_pass http://testing-backend:3001; + proxy_pass http://testing-backend:3107; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 57b94ea..02df75e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,15 +1,25 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import CabinetLayout from './components/CabinetLayout'; import Login from './pages/Login'; import TestsList from './pages/TestsList'; import TestDetail from './pages/TestDetail'; +import TestAttempt from './pages/TestAttempt'; +import TestAttemptReview from './pages/TestAttemptReview'; function App() { return ( } /> - } /> - } /> + }> + } /> + } /> + } + /> + } /> + } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index c17d23a..0d50db6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,12 +1,16 @@ const base = ''; export async function api(path, opts = {}) { + const isFormData = + typeof FormData !== 'undefined' && opts.body instanceof FormData; const r = await fetch(`${base}${path}`, { credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...(opts.headers || {}), - }, + headers: isFormData + ? { ...(opts.headers || {}) } + : { + 'Content-Type': 'application/json', + ...(opts.headers || {}), + }, ...opts, }); const text = await r.text(); diff --git a/frontend/src/components/AttemptReviewBlock.jsx b/frontend/src/components/AttemptReviewBlock.jsx new file mode 100644 index 0000000..2ed71ee --- /dev/null +++ b/frontend/src/components/AttemptReviewBlock.jsx @@ -0,0 +1,112 @@ +import { Link } from 'react-router-dom'; + +/** + * @param {{ review: { + * testTitle?: string, + * attempterName?: string, + * attempterLogin?: string, + * startedAt?: string, + * completedAt?: string, + * correctCount: number, + * totalQuestions: number, + * percent: number, + * passed: boolean, + * passingThreshold: number, + * questions: Array<{ + * id: string, + * text: string, + * isUserCorrect: boolean, + * options: Array<{ id: string, text: string, isCorrect: boolean, selected: boolean }> + * }> + * }, showAttempter?: boolean, backLink: { to: string, label: string } }} p + */ +export default function AttemptReviewBlock({ review, showAttempter, backLink }) { + if (!review?.questions?.length) { + return null; + } + return ( +
+ {showAttempter && (review.attempterName || review.attempterLogin) && ( +

+ Участник: {review.attempterName || '—'}{' '} + {review.attempterLogin && ( + + {review.attempterLogin} + + )} +

+ )} + {review.completedAt && ( +

+ Завершено: {new Date(review.completedAt).toLocaleString('ru-RU')} +

+ )} +
    + {review.questions.map((q, i) => ( +
  1. +

    {i + 1}. {q.text}

    +

    + {q.isUserCorrect ? 'Верно' : 'Ошибка'} +

    +
      + {q.options.map((o) => { + const mark = + o.selected && o.isCorrect + ? '✓ верно' + : o.selected && !o.isCorrect + ? '✗ выбрано' + : !o.selected && o.isCorrect + ? '— правильный вариант' + : ''; + return ( +
    • + + {o.text} + + {mark && ( + + {mark} + + )} +
    • + ); + })} +
    +
  2. + ))} +
+ {backLink && ( +

+ {backLink.label} +

+ )} +
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 6f4d655..9e716e6 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; +import './styles/cabinet-theme.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index e29e822..9a5e216 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -23,41 +23,59 @@ export default function Login() { } return ( -
-

Вход

-

- Локальный пользователь из clinic_tests (если HR_AUTH не - включён). -

-
-
- +
+
+
+
+ school +
+

Система тестрования

+

Войдите в систему

-
- + + {err && ( +
+ {err} +
+ )} + +
+ +
+ + setLogin(e.target.value)} + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ + +

+ Локальный пользователь в clinic_tests (если + отключён вход через персонал HR). +

- {err &&

{err}

} - - +
); } diff --git a/frontend/src/pages/TestAttempt.jsx b/frontend/src/pages/TestAttempt.jsx new file mode 100644 index 0000000..a1de022 --- /dev/null +++ b/frontend/src/pages/TestAttempt.jsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import AttemptReviewBlock from '../components/AttemptReviewBlock'; +import { api } from '../api'; + +export default function TestAttempt() { + const { id: testId, attemptId } = useParams(); + const nav = useNavigate(); + const [play, setPlay] = useState(null); + const [err, setErr] = useState(null); + const [submitErr, setSubmitErr] = useState(null); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + /** @type {Record} */ + const [selections, setSelections] = useState({}); + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setErr(null); + setLoading(true); + try { + const data = await api(`/api/tests/${testId}/attempts/${attemptId}/play`); + if (!cancelled) { + setPlay(data); + } + } catch (e) { + if (e.status === 401) { + nav('/login'); + return; + } + if (!cancelled) { + setErr(e.message); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [testId, attemptId, nav]); + + function toggleOption(questionId, optionId, hasMultiple) { + setSelections((prev) => { + const key = String(questionId); + const cur = prev[key] || []; + const id = String(optionId); + if (hasMultiple) { + if (cur.includes(id)) { + return { ...prev, [key]: cur.filter((x) => x !== id) }; + } + return { ...prev, [key]: [...cur, id] }; + } + return { ...prev, [key]: [id] }; + }); + } + + function isSelected(questionId, optionId) { + const s = selections[String(questionId)] || []; + return s.includes(String(optionId)); + } + + async function onSubmit() { + setSubmitErr(null); + setSending(true); + try { + const out = await api(`/api/tests/${testId}/attempts/${attemptId}/submit`, { + method: 'POST', + body: JSON.stringify({ answers: selections }), + }); + setResult(out); + } catch (e) { + setSubmitErr(e.message); + } finally { + setSending(false); + } + } + + if (loading) { + return

Загрузка вопросов…

; + } + if (err) { + return ( +
+

{err}

+

+ ← к карточке теста +

+
+ ); + } + if (result) { + return ( +
+

+ ← к карточке теста +

+

+ Результат +

+

+ Правильно: {result.correctCount} из {result.totalQuestions} ( + {result.percent}%). Порог: {result.passingThreshold}%. +

+

+ {result.passed + ? 'Тест пройден по порогу.' + : 'Порог не достигнут — при необходимости начните новую попытку на карточке теста.'} +

+ {result.review && ( + <> +

+ Разбор +

+ + {result.attemptId && ( +

+ + Полная страница разбора + +

+ )} + + )} +
+ + К настройкам теста + +
+
+ ); + } + + if (!play?.questions?.length) { + return ( +
+

В этой версии нет вопросов. Добавьте вопросы в черновике.

+ ← к карточке +
+ ); + } + + return ( +
+

+ ← к карточке теста +

+

+ {play.testTitle} +

+

+ Отметьте ответы и нажмите «Завершить». Порог для зачёта: {play.passingThreshold}%. +

+ +
    + {play.questions.map((q) => ( +
  1. +

    {q.text}

    +
      + {q.options.map((o) => { + const inputType = q.hasMultipleAnswers ? 'checkbox' : 'radio'; + const name = `q-${q.id}`; + return ( +
    • + +
    • + ); + })} +
    +
  2. + ))} +
+ + {submitErr && ( +

+ {submitErr} +

+ )} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/TestAttemptReview.jsx b/frontend/src/pages/TestAttemptReview.jsx new file mode 100644 index 0000000..3b40245 --- /dev/null +++ b/frontend/src/pages/TestAttemptReview.jsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api } from '../api'; +import AttemptReviewBlock from '../components/AttemptReviewBlock'; + +export default function TestAttemptReview() { + const { id: testId, attemptId } = useParams(); + const nav = useNavigate(); + const [review, setReview] = useState(null); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + setErr(null); + setLoading(true); + try { + const data = await api(`/api/tests/${testId}/attempts/${attemptId}/review`); + if (!cancelled) { + setReview(data); + } + } catch (e) { + if (e.status === 401) { + nav('/login'); + return; + } + if (!cancelled) { + setErr(e.message); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [testId, attemptId, nav]); + + if (loading) { + return

Загрузка разбора…

; + } + if (err) { + return ( +
+

{err}

+

+ ← к карточке теста +

+
+ ); + } + if (!review) { + return null; + } + + return ( +
+

+ ← к карточке теста +

+

+ Разбор попытки: {review.testTitle} +

+

+ Правильно: {review.correctCount} из {review.totalQuestions} ({review.percent} + %). Порог: {review.passingThreshold}%.{' '} + {review.passed ? ( + Зачёт. + ) : ( + Незачёт. + )} +

+ +
+ ); +} diff --git a/frontend/src/styles/cabinet-theme.css b/frontend/src/styles/cabinet-theme.css new file mode 100644 index 0000000..49d11ce --- /dev/null +++ b/frontend/src/styles/cabinet-theme.css @@ -0,0 +1,785 @@ +/* Match: HR_TG_Bot/tgFlaskForm .../cabinet/tailwind_config.js + cabinet/login.html */ +:root { + --surface: #ffffff; + --surface-container-low: #f3f8f9; + --surface-container: #eaf3f5; + --surface-container-high: #dfeef1; + --on-surface: #0d1b1d; + --on-surface-variant: #3d5357; + --primary: #007168; + --on-primary: #ffffff; + --primary-container: #56f1e0; + --on-primary-container: #00574f; + --secondary: #506965; + --secondary-container: #cce8e3; + --on-secondary-container: #3d5653; + --error: #af3d3b; + --outline-variant: #b9bc94; + --outline: #80835f; + --shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08); + --radius-card: 2rem; + --max-content: 42rem; /* max-w-2xl */ + color-scheme: light; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: light; +} + +body { + margin: 0; + min-height: 100dvh; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--surface-container-low); + color: var(--on-surface); + -webkit-tap-highlight-color: transparent; + line-height: 1.45; +} + +#root { + min-height: 100dvh; +} + +.font-headline, +h1, +h2, +h3 { + font-family: 'Manrope', 'Inter', sans-serif; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--on-surface); +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.25rem; + margin: 0 0 0.75rem; +} + +h3 { + font-size: 1.1rem; + margin: 1.25rem 0 0.5rem; +} + +a { + color: var(--primary); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} + +a:hover { + color: var(--on-primary-container); + text-decoration: underline; +} + +code, +.code-inline { + display: inline-block; + background: var(--secondary-container); + color: var(--on-primary-container); + padding: 1px 7px; + border-radius: 5px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + font-weight: 500; +} + +.text-muted, +.text-secondary { + color: var(--secondary); + font-size: 0.875rem; +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined', sans-serif; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; +} + +/* --- Login (cabinet/login.html) --- */ +.login-page { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: #ffffff; +} + +.login-shell { + width: 100%; + max-width: 24rem; /* max-w-sm */ + transition: max-width 0.2s ease-out; +} + +.login-logo { + text-align: center; + margin-bottom: 2rem; +} + +.login-logo__frame { + width: 4rem; + height: 4rem; + border-radius: 1.5rem; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1rem; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + border: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent); + overflow: hidden; +} + +.login-logo__frame .material-symbols-outlined { + font-size: 2.25rem; + color: var(--primary); +} + +.login-page h1 { + font-size: 1.5rem; + font-weight: 800; + color: var(--primary); + margin: 0 0 0.25rem; +} + +.login-subtitle { + color: var(--secondary); + font-size: 0.875rem; + margin: 0; +} + +.login-card { + background: #fff; + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + padding: 1.5rem; +} + +/* Form controls */ +.form-label { + display: block; + font-size: 0.9rem; + font-weight: 500; + color: var(--on-surface); + margin-bottom: 0.35rem; +} + +.form-input { + width: 100%; + padding: 11px 13px; + border: 1.5px solid var(--outline-variant); + border-radius: 0.75rem; + font-size: 15px; + font-family: inherit; + outline: none; + background: var(--surface-container-low); + color: var(--on-surface); + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.form-input::placeholder { + color: var(--on-surface-variant); + opacity: 0.7; +} + +.form-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12); + background: #fff; +} + +.form-field { + margin-bottom: 0.75rem; +} + +/* Buttons */ +.btn { + font-family: inherit; + font-size: 0.9375rem; + font-weight: 600; + padding: 0.55rem 1.1rem; + border-radius: 0.75rem; + border: 1.5px solid transparent; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.btn-primary { + background: var(--primary); + color: var(--on-primary); + width: 100%; + padding-top: 0.65rem; + padding-bottom: 0.65rem; + margin-top: 0.5rem; +} + +.btn-primary:hover { + background: #00645b; + filter: none; +} + +.btn-primary:active { + transform: scale(0.99); +} + +.btn-ghost { + background: transparent; + color: var(--primary); + border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); +} + +.btn-ghost:hover { + background: var(--surface-container); + border-color: var(--primary); +} + +.btn-ghost:disabled, +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--sm { + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + border-radius: 0.5rem; +} + +/* --- App shell (cabinet/base) --- */ +.cabinet-app { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--surface); +} + +.cabinet-page--center { + display: flex; + align-items: center; + justify-content: center; + min-height: 100dvh; + padding: 1.5rem; +} + +.cabinet-header { + position: sticky; + top: 0; + z-index: 20; + background: color-mix(in srgb, var(--surface) 88%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +.cabinet-header__inner { + max-width: var(--max-content); + margin: 0 auto; + padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.cabinet-brand { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--on-surface); + text-decoration: none; + min-width: 0; +} + +.cabinet-brand:hover { + text-decoration: none; + color: var(--on-surface); +} + +.cabinet-brand__icon { + font-size: 1.75rem; + color: var(--primary); + background: var(--surface-container-low); + border-radius: 0.75rem; + padding: 0.35rem; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + flex-shrink: 0; +} + +.cabinet-brand__title { + font-family: 'Manrope', 'Inter', sans-serif; + font-weight: 800; + font-size: 1rem; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.cabinet-brand__subtitle { + font-size: 0.7rem; + color: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.cabinet-header__actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.cabinet-user { + font-size: 0.8rem; + color: var(--on-surface-variant); + text-align: right; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: none; +} + +@media (min-width: 480px) { + .cabinet-user { + display: inline; + } +} + +.cabinet-user__role { + color: var(--secondary); + font-weight: 500; +} + +.cabinet-main { + flex: 1; + max-width: var(--max-content); + width: 100%; + margin: 0 auto; + padding: 1.25rem 1.25rem 2.5rem; +} + +/* Cards & lists */ +.surface-card { + background: var(--surface); + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + padding: 1rem 1.1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.list-stack { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.list-row { + display: block; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + padding: 0.9rem 1rem; + background: var(--surface); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.list-row:hover { + border-color: color-mix(in srgb, var(--primary) 35%, var(--outline-variant)); + box-shadow: 0 2px 12px rgba(0, 113, 104, 0.08); +} + +.list-row a { + text-decoration: none; + color: var(--on-surface); + font-weight: 600; +} + +.list-row a:hover { + color: var(--primary); + text-decoration: none; +} + +.list-row__meta { + color: var(--secondary); + font-size: 0.8rem; + display: block; + margin-top: 0.25rem; +} + +/* Вся плитка — одна ссылка */ +.list-row--action { + padding: 0; + overflow: hidden; +} + +.list-row--action .list-row__link { + display: block; + padding: 0.9rem 1rem; + text-decoration: none; + color: inherit; + outline-offset: 2px; + border-radius: inherit; + transition: background 0.12s ease; +} + +.list-row--action .list-row__link:hover { + background: color-mix(in srgb, var(--primary) 6%, transparent); + text-decoration: none; +} + +.list-row--action .list-row__title { + display: block; + color: var(--on-surface); + font-weight: 600; +} + +/* Список: слева ссылка на карточку, справа «Пройти» */ +.list-row--split { + display: flex; + flex-direction: row; + align-items: stretch; + padding: 0; + overflow: hidden; + gap: 0; +} + +.list-row--split .list-row__main { + flex: 1; + min-width: 0; +} + +.list-row--split .list-row__link { + display: block; + padding: 0.9rem 1rem; + text-decoration: none; + color: inherit; + outline-offset: 2px; + border-radius: 0.85rem 0 0 0.85rem; + transition: background 0.12s ease; +} + +.list-row--split .list-row__link:hover { + background: color-mix(in srgb, var(--primary) 6%, transparent); + text-decoration: none; +} + +.list-row--split .list-row__side { + display: flex; + align-items: center; + padding: 0.5rem 0.9rem 0.5rem 0; + flex-shrink: 0; + border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +.list-row--split .list-row__title { + display: block; + color: var(--on-surface); + font-weight: 600; +} + +@media (max-width: 520px) { + .list-row--split { + flex-wrap: wrap; + } + + .list-row--split .list-row__link { + border-radius: 0.85rem 0.85rem 0 0; + } + + .list-row--split .list-row__side { + width: 100%; + justify-content: flex-end; + border-left: none; + border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); + padding: 0.5rem 0.9rem 0.75rem; + } +} + +/* Карточка теста: визуальные блоки + сворачивание (удобно на узком экране) */ +.test-detail-page { + max-width: var(--max-content, 42rem); + margin: 0 auto; + padding-bottom: 1.5rem; +} + +.cabinet-brick { + margin-bottom: 1.1rem; +} + +.cabinet-brick--hero { + padding: 0.1rem 0 0.2rem; + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent); + margin-bottom: 1.25rem; +} + +.cabinet-disclosure { + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + background: var(--surface); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.cabinet-disclosure__summary { + cursor: pointer; + list-style: none; + user-select: none; + padding: 0.85rem 1rem 0.75rem; + font-size: 1.05rem; + border-radius: 1rem 1rem 0 0; + min-height: 2.75rem; + display: flex; + align-items: center; +} + +.cabinet-disclosure__summary::-webkit-details-marker { + display: none; +} + +.cabinet-disclosure__summary::after { + content: 'expand_more'; + font-family: 'Material Symbols Outlined', sans-serif; + margin-left: auto; + font-size: 1.25rem; + opacity: 0.55; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; + transition: transform 0.2s ease; +} + +.cabinet-disclosure[open] .cabinet-disclosure__summary::after { + transform: rotate(180deg); +} + +.cabinet-disclosure__body { + padding: 0 1rem 1.05rem; + border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +.cabinet-disclosure[open] .cabinet-disclosure__summary { + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent); +} + +/* Назначение: поиск + список */ +.assign-toolbar { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +@media (min-width: 520px) { + .assign-toolbar { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } + + .assign-toolbar .form-input { + flex: 1 1 160px; + min-width: 0; + } +} + +.assign-toolbar__search { + flex: 1 1 200px; +} + +.assign-list { + max-height: min(50vh, 22rem); + overflow: auto; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 0.75rem; + background: var(--surface-container-low); + -webkit-overflow-scrolling: touch; +} + +.assign-row { + display: flex; + gap: 0.5rem; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + cursor: pointer; + align-items: flex-start; +} + +.assign-row:last-child { + border-bottom: none; +} + +.assign-row--selected, +.assign-row:hover { + background: color-mix(in srgb, var(--primary) 8%, transparent); +} + +.assign-row__text { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + flex: 1; +} + +.assign-row__fio { + font-weight: 600; + color: var(--on-surface); + font-size: 0.95rem; + word-break: break-word; +} + +.assign-row__login { + font-size: 0.8rem; + color: var(--secondary); + font-family: ui-monospace, Menlo, monospace; +} + +.assign-row__meta { + font-size: 0.8rem; + color: var(--secondary); + line-height: 1.35; +} + +.create-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1.25rem 0; + align-items: center; +} + +.create-row .form-input { + flex: 1 1 12rem; + min-width: 0; +} + +.create-row .btn { + width: auto; + flex: 0 0 auto; +} + +.callout { + border-radius: 1rem; + padding: 0.75rem 1rem; + font-size: 0.9rem; + font-weight: 500; + margin: 0 0 1rem; +} + +.callout--warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +.callout--error { + background: #fff5f5; + border: 1px solid #fecaca; + color: #991b1b; +} + +.callout--success { + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #047857; +} + +.error-text { + color: var(--error); + font-size: 0.9rem; + margin: 0.5rem 0 0; +} + +.link-back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.9rem; + font-weight: 500; + margin: 0 0 1rem; +} + +/* Table (detail) */ +.table-cabinet { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} + +.table-cabinet th, +.table-cabinet td { + padding: 0.5rem 0.6rem; + text-align: left; + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent); + vertical-align: top; +} + +.table-cabinet th { + color: var(--secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.table-cabinet tr:last-child td { + border-bottom: none; +} + +.table-cabinet .mono { + font-size: 0.75rem; + word-break: break-all; + color: var(--on-surface-variant); +} + +.draft-block { + margin-top: 1.25rem; + padding: 1rem; + background: var(--surface-container); + border-radius: 1rem; + border: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +.draft-block .form-input { + margin-top: 0.25rem; +} + +.muted { + color: var(--secondary); + font-size: 0.875rem; +} + +.inline-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.inline-actions .btn { + width: auto; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4414e51..d935046 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,7 +7,7 @@ export default defineConfig({ port: 5173, proxy: { '/api': { - target: 'http://localhost:3001', + target: 'http://localhost:3107', changeOrigin: true, }, },