LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# ast-index Rules
## Mandatory Search Rules
1. **ALWAYS use ast-index FIRST** for any code search task
2. **NEVER duplicate results** — if ast-index found usages/implementations, that IS the complete answer
3. **DO NOT run grep "for completeness"** after ast-index returns results
4. **Use grep/Search ONLY when:**
- ast-index returns empty results
- Searching for regex patterns (ast-index uses literal match)
- Searching for string literals inside code (`"some text"`)
- Searching in comments content
## Why ast-index
ast-index is 17-69x faster than grep (1-10ms vs 200ms-3s) and returns structured, accurate results.
## Command Reference
| Task | Command | Time |
|------|---------|------|
| Universal search | `ast-index search "query"` | ~10ms |
| Find class/component | `ast-index class "ComponentName"` | ~1ms |
| Find symbol | `ast-index symbol "SymbolName"` | ~1ms |
| Find usages | `ast-index usages "SymbolName"` | ~8ms |
| Find implementations | `ast-index implementations "Interface"` | ~5ms |
| Call hierarchy | `ast-index call-tree "function" --depth 3` | ~1s |
| Find callers | `ast-index callers "functionName"` | ~1s |
| Module deps | `ast-index deps "module-name"` | ~10ms |
| File outline | `ast-index outline "File.tsx"` | ~1ms |
## JavaScript-Specific Commands
| Task | Command |
|------|---------|
| Find classes | `ast-index class "ClassName"` |
| Find functions | `ast-index symbol "functionName"` |
| Find Express routes | `ast-index search "router.get"` |
| File structure | `ast-index outline "path/to/file.js"` |
## Index Management
- `ast-index rebuild` — Full reindex (run once after clone)
- `ast-index update` — After git pull/merge
- `ast-index stats` — Show index statistics
+174
View File
@@ -0,0 +1,174 @@
{
"permissions": {
"allow": [
"Bash(ast-index *)",
"Bash(node -e \"require\\(''./backend/src/controllers/sessionController.js''\\); console.log\\(''sessionController OK''\\)\")",
"Bash(node -e \"require\\(''./backend/src/controllers/courseController.js''\\); console.log\\(''courseController OK''\\)\")",
"Bash(node -e \"require\\(''''./backend/src/controllers/courseController.js''''\\); console.log\\(''''courseController OK''''\\)\")",
"Bash(node -e \"require\\(''''./backend/src/controllers/lessonController.js''''\\); console.log\\(''''lessonController OK''''\\)\")",
"Bash(node -e \"require\\(''''./backend/src/controllers/questionController.js''''\\); console.log\\(''''questionController OK''''\\)\")",
"Bash(node -e \"require\\(''''./backend/src/controllers/testController.js''''\\); console.log\\(''''testController OK''''\\)\")",
"Bash(node -e \":*)",
"Bash(grep -n \"case ''accordion''\" \"g:/Dev/Тесты/BQ-System/frontend/lesson-editor.html\")",
"Bash(grep -n \"case ''quote''\\\\|case ''checklist''\\\\|case ''button''\" \"g:/Dev/Тесты/BQ-System/frontend/lesson-editor.html\")",
"Bash(grep -n \"href=//theory\\\\|href=''''//theory''''\\\\|lesson-editor\\\\|editLesson\\\\|openEditor\" g:/Dev/Тесты/BQ-System/frontend/theory.html)",
"Bash(grep -v \"^.*://\\\\|//.*stat\")",
"Bash(ls \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)",
"Bash(wc -l g:/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(grep -rn \"= ''//\\\\|= \"\"//\\\\|href=\"\"//\\\\|href=''//\\\\|href = ''//\\\\|href = \"\"//\\\\|\\\\.href = ''//\" \"g:/Dev/Тесты/BQ-System/frontend/\")",
"Bash(grep -v \"http\\\\|https\\\\|cdn\\\\|fonts\\\\|googleapis\\\\|jsdelivr\\\\|://\\\\|comment\\\\|//\")",
"Bash(grep -rn //course g:/Dev/Тесты/BQ-System/)",
"WebFetch(domain:hoster.by)",
"WebFetch(domain:www.websiteplanet.com)",
"WebFetch(domain:hostings.info)",
"Bash(head -10 grep -n \"^const stmts\\\\|^const _stmts\\\\|= db.prepare\\\\|db\\\\.prepare\" \"g:/Dev/Тесты/BQ-System/backend/src/controllers/assignmentController.js\")",
"Bash(node -e \"require\\(''./src/controllers/gamificationController''\\)\")",
"Bash(node -e \"require\\(''./src/controllers/classController''\\)\")",
"Bash(grep -rn \"BQ\\\\.notif\\\\.init\\\\|notif\\\\.init\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)",
"Bash(grep -rn \"connectSSE\\\\|EventSource\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)",
"Bash(grep -rn \"notif-item\\\\|notif-dot\\\\|notif-msg\\\\|notif-time\\\\|notif-empty\\\\|notif-read-all\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)",
"Bash(grep -l 'href=\"\"/lab\"\"' frontend/*.html)",
"Bash(grep -rn \"notifications.js\" frontend/*.html)",
"Read(//g/Dev/Тесты/BQ-System/**)",
"Bash(python -c \":*)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:medium.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:saturncloud.io)",
"WebFetch(domain:neilagrawal.com)",
"WebFetch(domain:mitchum.blog)",
"Bash(node src/db/seed-theory-2.js)",
"Bash(ls g:/Dev/Тесты/BQ-System/backend/src/db/seed*)",
"Bash(grep -r \"three\\\\|Three\\\\|babylon\\\\|Babylon\\\\|cesium\\\\|Cesium\\\\|webgl\\\\|WebGL\" g:/Dev/Тесты/BQ-System/frontend --include=*.html --include=*.js)",
"Bash(node src/db/seed-red-book.js)",
"Bash(grep -n \"requireAuth\\\\|optionalAuth\" \"g:/Dev/Тесты/BQ-System/backend/src/routes/\"*.js)",
"Bash(node -e \"require\\(''''./src/server.js''''\\)\")",
"Bash(node:*)",
"Bash(grep -v \"Use \\\\`node\")",
"Bash(node -e \"const db=require\\(''''./src/db/db''''\\); db.prepare\\(''''SELECT id,name_ru,category FROM rb_species WHERE id>=63 ORDER BY id''''\\).all\\(\\).forEach\\(r=>console.log\\(r.id,r.category,r.name_ru\\)\\); console.log\\(''''---''''\\); db.prepare\\(''''SELECT id,title,xp_reward FROM rb_quests ORDER BY id''''\\).all\\(\\).forEach\\(r=>console.log\\(r.id,r.xp_reward,r.title\\)\\); console.log\\(''''---''''\\); console.log\\(''''Видов:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_species''''\\).get\\(\\).n,''''Квестов:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_quests''''\\).get\\(\\).n,''''Связей:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_food_web''''\\).get\\(\\).n\\);\")",
"Bash(grep -n \"BQ.api.*features\\\\|/api/features\" frontend/*.html)",
"Bash(grep -l \"sb-link\\\\|sb-nav\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)",
"Bash(python -c \"print\\(''hello''\\)\")",
"Bash(awk 'NR>=1180 && NR<=1200' backend/src/db/migrate.js)",
"Bash(awk 'NR>=1410 && NR<=1425' backend/src/db/migrate.js)",
"Bash(awk 'NR>=1465 && NR<=1490' backend/src/db/migrate.js)",
"Bash(node -e \"require\\(''''./backend/src/db/migrate''''\\)\")",
"Bash(ls g:/Dev/Тесты/BQ-System/frontend/biochem*.html)",
"Bash(grep -rn [😀-🙏🔬-🧪⚗️⚛️✓✗←→↑↓★☆✔✖👤🏫] g:/Dev/Тесты/BQ-System/frontend/ g:/Dev/Тесты/BQ-System/js/)",
"Bash(python /tmp/scan_emoji.py)",
"Bash(python scan_emoji.py)",
"Bash(python replace_emoji2.py)",
"Bash(find /g/Dev/Тесты/BQ-System/frontend -name \"*.html\" -exec head -5 {})",
"Bash(-print)",
"Bash(do echo:*)",
"Bash(head -15 grep -n \"bio-sidebar\\\\|bio-body\\\\|bio-panel\" \"g:/Dev/Тесты/BQ-System/frontend/biochem.html\")",
"Bash(head -3 grep -n \"</style>\" \"g:/Dev/Тесты/BQ-System/frontend/homework.html\")",
"Bash(head -3 grep -n \"</style>\" \"g:/Dev/Тесты/BQ-System/frontend/admin.html\")",
"Bash(head -3 grep -n \"</style>\" \"g:/Dev/Тесты/BQ-System/frontend/test-run.html\")",
"Bash(head -3 grep -n \"</style>\" \"g:/Dev/Тесты/BQ-System/frontend/flashcards.html\")",
"Bash(grep -l \"sb-foot\\\\|nav-user-chip\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)",
"Bash(grep -l \"sb-foot\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)",
"Bash(ls -la frontend/biochem*.html)",
"Bash(find g:/Dev/Тесты/BQ-System/js -name *.js)",
"Read(//.pp-hint/|/**)",
"Bash(grep -n \"id === ''waves''\" g:/Dev/Тесты/BQ-System/frontend/lab.html)",
"Bash(ls g:/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(grep -v \"//.*toast\")",
"Bash(do ast-index:*)",
"Bash(grep -v \"^$\")",
"Bash(echo \"FILE: $f\")",
"Bash(do python3 -c \":*)",
"Bash(ast-index search:*)",
"Bash(chmod +x \"g:/Dev/Тесты/BQ-System/backend/scripts/backup.sh\")",
"Read(//BQ/.initPage/**)",
"Read(//showBoardIfAllowed/**)",
"Bash(node -e \"require\\(''express-rate-limit''\\)\")",
"Bash(find g:DevТестыBQ-System -name *.sql -type f)",
"Read(//g/Dev/**)",
"Bash(ls g:/Dev/Тесты/BQ-System/.env*)",
"Bash(docker --version)",
"Bash(curl -s https://api.ipify.org)",
"Bash(curl -s --connect-timeout 5 http://45.132.194.53:3000/api/health)",
"Bash(curl -s --connect-timeout 8 http://45.132.194.53:3000/api/health)",
"Bash(grep -n \"title>\" /g/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(mv bioquantum.db learnspace.db)",
"Bash(mv bioquantum.db-shm learnspace.db-shm)",
"Bash(mv bioquantum.db-wal learnspace.db-wal)",
"Bash(taskkill //F //IM node.exe)",
"Bash(taskkill /F /PID 72676)",
"Bash(cmd /c \"taskkill /F /PID 72676\")",
"Bash(cmd /c \"taskkill /F /PID 72676 2>&1\")",
"Bash(powershell -Command \"Stop-Process -Id 72676 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id \\(Get-NetTCPConnection -LocalPort 3000 -State Listen\\).OwningProcess -Force\")",
"Bash(findstr /n /i \"draw\" frontend/classroom.html)",
"Bash(findstr /i \"allow\\\\|perm\\\\|grant\\\\|toggle\\\\|_can\")",
"Bash(findstr /n \"canDraw\" frontend/classroom.html)",
"Bash(taskkill /F /PID 56144)",
"Bash(cmd /c \"taskkill /F /PID 56144\")",
"Bash(cmd /c \"taskkill /F /PID 56144 2>&1\")",
"Bash(powershell -Command \"Stop-Process -Id 56144 -Force\")",
"Bash(findstr \"LISTENING\")",
"Bash(curl -s -X POST http://localhost:3000/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"123@123.by\",\"password\":\"123456\"}')",
"Bash(powershell -Command \"Get-Process -Id 13632 -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, MainWindowTitle\")",
"Bash(powershell -Command \"\\(Get-Process -Id 13632\\).Path\")",
"Bash(powershell -Command ':*)",
"Bash(curl -s http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{\"email\":\"admin@bq.local\",\"password\":\"123456\"}')",
"Bash(powershell -Command \"Stop-Process -Id 13632 -Force -ErrorAction SilentlyContinue\" sleep 1 cd \"g:/Dev/Тесты/BQ-System\")",
"Bash(powershell -Command \"Stop-Process -Id 13632 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 11320 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 44452 -Force\")",
"Bash(grep -rn \"function esc\\(s\\)\" frontend/*.html)",
"Bash(grep -rln \"Аналитика\\\\|Банк вопросов\\\\|Live-квиз\" frontend/*.html)",
"Bash(powershell -Command \"Stop-Process -Id 62712 -Force\")",
"Bash(findstr /i classroom)",
"Bash(powershell -Command \"Stop-Process -Id 71864 -Force\")",
"Bash(grep -n \"addClient\\\\|notifications/stream\" backend/src/controllers/*.js)",
"Bash(tasklist /FI \"IMAGENAME eq node.exe\")",
"Bash(taskkill /F /PID 24032)",
"Bash(powershell -Command \"Stop-Process -Id 24032 -Force -ErrorAction SilentlyContinue; Write-Output 'done'\")",
"Bash(ls frontend/gradebook*)",
"Bash(awk -F: '{print $1}')",
"Bash(npm --prefix backend run dev)",
"Bash(taskkill /PID 78248 /F)",
"Bash(cmd /c \"taskkill /PID 78248 /F\")",
"Bash(cmd /c \"taskkill /F /PID 78248\")",
"Bash(cmd /c \"taskkill /F /PID 78248 2>&1\")",
"Bash(powershell -Command \"Stop-Process -Id 78248 -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Get-Process -Id 78248 -ErrorAction SilentlyContinue\")",
"Bash(powershell -Command \"Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -ExpandProperty OwningProcess\")",
"Bash(powershell -Command \"Stop-Process -Id 45184 -Force\")",
"Bash(powershell -Command \"Start-Sleep 2; \\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")",
"Read(//c/Users/Home/AppData/Local/Temp/**)",
"Bash(curl -s -I \"http://localhost:3000/js/api.js\")",
"Bash(curl -s \"http://localhost:3000/js/api.js\")",
"Bash(powershell -Command \"Stop-Process -Id 83740 -Force\")",
"Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen\\).OwningProcess\")",
"Bash(powershell -Command \"Get-Process -Id 57008 | Select-Object -ExpandProperty MainWindowTitle; \\(Get-WmiObject Win32_Process -Filter 'ProcessId=57008'\\).CommandLine\")",
"Bash(powershell -Command \"Stop-Process -Id 57008 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 37516 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 2764 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 81936 -Force\")",
"Bash(powershell -Command \"Stop-Process -Id 40888 -Force\")",
"Bash(powershell -Command \"\\(Get-WmiObject Win32_Process -Filter 'ProcessId=69696'\\).CommandLine\")",
"Bash(curl -s \"http://localhost:3000/api/classroom/1/cursor\" -X POST -H \"Content-Type: application/json\" -d '{\"x\":0,\"y\":0}')",
"Bash(powershell -Command \"Stop-Process -Id 69696 -Force\")",
"Bash(powershell -Command \"Start-Sleep 1\")",
"Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")",
"Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")"
],
"additionalDirectories": [
"\\tmp"
]
},
"enabledPlugins": {
"ast-index@ast-index": true
},
"extraKnownMarketplaces": {
"ast-index": {
"source": {
"source": "github",
"repo": "defendend/Claude-ast-index-search"
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
{
"permissions": {
"allow": [
"Bash(cat \"g:/Dev/Тесты/BQ-System/backend/src/db/\"*.js)",
"Bash(node -e \"const db = require\\(''./src/db/db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users''\\).all\\(\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")",
"Bash(wc -l \"g:/Dev/Тесты/BQ-System/frontend/\"*.html \"g:/Dev/Тесты/BQ-System/js/\"*.js)",
"Bash(cat \"g:/Dev/Тесты/BQ-System/backend/src/db/migrations/\"*.sql)",
"Bash(node src/db/migrate.js)",
"Bash(node src/db/seed-math.js)",
"Bash(npm install multer --save)",
"Bash(node -e \":*)",
"Bash(node export-math.js)",
"Bash(rm export-math.js)",
"Bash(node -e \"require\\(''./src/db/migrate''\\)\")",
"Bash(node -e \"const db=require\\(''./src/db/db''\\); console.log\\(db.prepare\\(''PRAGMA table_info\\(tests\\)''\\).all\\(\\).map\\(c=>c.name\\).join\\('', ''\\)\\); console.log\\(db.prepare\\(''PRAGMA table_info\\(topics\\)''\\).all\\(\\).map\\(c=>c.name\\).join\\('', ''\\)\\);\")",
"Bash(node -e \"require\\(''./src/controllers/adminController''\\)\")",
"Bash(node -e \"require\\(''./backend/src/controllers/classController''\\)\")",
"Bash(true\" \"g:/Dev/Тесты/BQ-System \")",
"Bash(node -e \"try { require\\(''./backend/src/controllers/classController''\\); console.log\\(''OK''\\); } catch\\(e\\) { console.error\\(e.message\\); }\")",
"Bash(node -e \"const db=require\\(''./backend/src/db/db''\\);try{db.prepare\\(''SELECT id FROM assignments LIMIT 1''\\).all\\(\\);console.log\\(''OK''\\)}catch\\(e\\){console.error\\(e.message\\)}\")",
"Bash(curl -s http://localhost:3000/api/assignments/my -H \"Authorization: Bearer invalid_token\")",
"Bash(curl -s http://localhost:3000/api/health)",
"Bash(netstat -ano)",
"Bash(findstr \":3000\")",
"Bash(findstr LISTENING)",
"Bash(taskkill /PID 28096 /F)",
"Bash(node \"g:\\\\Dev\\\\Тесты\\\\BQ-System\\\\backend\\\\src\\\\server.js\")",
"Bash(cmd //c \"taskkill /PID 28096 /F\")",
"Bash(node -e \"require\\(''dotenv''\\).config\\({path:''./backend/.env''}\\); console.log\\(''JWT_SECRET:'', process.env.JWT_SECRET ? ''SET'' : ''MISSING''\\)\")",
"Bash(node -e \"require\\(''dotenv''\\).config\\(\\); console.log\\(''JWT_SECRET:'', process.env.JWT_SECRET ? ''SET \\(''+process.env.JWT_SECRET.slice\\(0,10\\)+''...\\)'' : ''MISSING''\\)\")",
"Bash(cmd //c \"taskkill /F /IM node.exe\")",
"Bash(wc -l \"g:/Dev/Тесты/BQ-System/frontend\"/*.html)",
"Bash(grep -n \"LIMIT 500\\\\|LIMIT 200\\\\|LIMIT 100\\\\|LIMIT 50\" \"g:/Dev/Тесты/BQ-System/backend/src/controllers/\"*.js)",
"Bash(node -e \"const db = require\\(''./src/db/db''\\); console.log\\(JSON.stringify\\(db.prepare\\(''SELECT id,email,name,role FROM users''\\).all\\(\\)\\)\\)\")",
"Bash(node seed.js)",
"Bash(taskkill /F /IM node.exe)",
"Bash(powershell -Command \"Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Host ''done''\")",
"Bash(timeout /t 8 /nobreak)",
"Bash(timeout 5 node src/server.js)",
"Bash(node -e \"const fs=require\\(''fs''\\);const emoji=/[\\\\u{1F300}-\\\\u{1FFFF}\\\\u{2600}-\\\\u{27BF}]/u;const files=[''frontend/classes.html'',''frontend/admin.html'',''frontend/board.html'',''frontend/library.html'',''frontend/dashboard.html''];let found=false;files.forEach\\(f=>{const lines=fs.readFileSync\\(f,''utf8''\\).split\\(''\\\\n''\\);lines.forEach\\(\\(l,i\\)=>{if\\(emoji.test\\(l\\)\\){console.log\\(f+'':''+\\(i+1\\)+'': ''+l.trim\\(\\).slice\\(0,120\\)\\);found=true;}}\\);}\\);if\\(found===false\\)console.log\\(''No emojis found''\\);\")",
"Bash(grep -v \"//.*innerHTML\")",
"Bash(grep \"innerHTML\\\\|template\\\\|backtick\\\\|\\\\`\")",
"Bash(node -e \"console.log\\(require\\(''crypto''\\).randomBytes\\(32\\).toString\\(''hex''\\)\\)\")",
"Bash(node -e \"const db = require\\(''better-sqlite3''\\)\\(''g:/Dev/Тесты/BQ-System/backend/bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")",
"Bash(node -e \"const db = require\\(''better-sqlite3''\\)\\(''bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")",
"Bash(node -e \"const db = require\\(''./node_modules/better-sqlite3''\\)\\(''bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")",
"Bash(ls g:/Dev/Тесты/BQ-System/backend/node_modules/.bin/better*)",
"Bash(node -e \"const db = require\\(''''./src/db/db''''\\); const users = db.prepare\\(''''SELECT id, email, name, role FROM users''''\\).all\\(\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")",
"Bash(where ast-index:*)",
"Bash(ast-index --version)",
"Bash(ast-index rebuild:*)",
"Bash(ast-index stats:*)",
"Bash(ast-index outline:*)",
"Bash(ast-index search:*)",
"Bash(find g:/Dev/Тесты/BQ-System/backend -name *.db -o -name *.sqlite)",
"Bash(node src/db/seed-ct2021-v1.js)",
"Bash(pip list:*)",
"Bash(where pdftoppm:*)",
"Bash(where pdfimages:*)",
"Bash(where magick:*)",
"Bash(where gs:*)",
"Bash(pdftoppm --help)",
"Bash(python3 -c 'import fitz')",
"Bash(pip install:*)",
"Bash(python3 -c \"import fitz; print\\(''ok''\\)\")",
"Bash(python -c \"import fitz; print\\(''ok''\\)\")",
"Bash(where pip:*)",
"Bash(where pip3:*)",
"Bash(where python:*)",
"Bash(where python3:*)",
"Bash(where py:*)",
"Bash(python -c \"import sys; print\\(sys.version\\)\")",
"Bash(cmd /c \"python --version\")",
"Bash(cmd /c \"where python\")",
"Bash(where convert:*)",
"Bash(where pnmcut:*)",
"Bash(where ffmpeg:*)",
"Bash(scoop list:*)",
"Bash(cp /tmp/ct2021-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page6.png\")",
"Bash(cp /tmp/ct2021-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page7.png\")",
"Bash(cp /tmp/ct2021-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page8.png\")",
"Bash(cp /tmp/ct2021-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page9.png\")",
"Bash(node -e \"const s = require\\(''sharp''\\); console.log\\(''ok''\\)\")",
"Bash(node src/db/crop_images.js)",
"Bash(pdftoppm -png -r 50 -f 6 -l 6 \"g:/Dev/Тесты/BQ-System/ЦТ-ЦЭ/ЦТ 2021.pdf\" \"/tmp/small\")",
"Bash(cp /tmp/small-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small6.png\")",
"Bash(pdftoppm -png -r 50 -f 7 -l 9 \"g:/Dev/Тесты/BQ-System/ЦТ-ЦЭ/ЦТ 2021.pdf\" \"/tmp/small\")",
"Bash(cp /tmp/small-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small7.png\")",
"Bash(cp /tmp/small-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small8.png\")",
"Bash(cp /tmp/small-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small9.png\")",
"Bash(cp /tmp/pt-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt6.png\")",
"Bash(node -e \"const s=require\\(''sharp''\\); s\\(''../frontend/img/questions/pt6.png''\\).metadata\\(\\).then\\(m=>console.log\\(m.width, m.height\\)\\)\")",
"Bash(cp /tmp/pt-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt7.png\")",
"Bash(cp /tmp/pt-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt8.png\")",
"Bash(cp /tmp/pt-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt9.png\")",
"Bash(head -15 grep -n \"^class\\\\|fit\\(\\)\\\\|start\\(\\)\\\\|stop\\(\\)\\\\|reset\\(\\)\\\\|setT\" \"g:/Dev/Тесты/BQ-System/frontend/js/labs/states.js\")",
"Bash(npx ast-index:*)",
"Bash(npm info:*)",
"Bash(head -10 node -e \"require\\(''''acorn''''\\); console.log\\(''''acorn ok''''\\)\")",
"Bash(winget list:*)",
"Bash(ast-index install-claude-plugin:*)",
"Bash(npm list:*)",
"Read(//c/Users/Home/AppData/Roaming/npm/**)",
"Bash(grep -n \"sim-gas\\\\|sim-diff\\\\|soon.*Кинетика\\\\|reactions\\\\|chem-react\\\\|Химические\\\\|id: ''''gas''''\\\\|id: ''''diff''''\" frontend/lab.html)",
"Bash(grep -n \"id: ''reactions''\" frontend/lab.html)",
"Bash(awk 'NR>=1813 && NR<=1820' frontend/lab.html)",
"Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/flask.js\")",
"Bash(xargs -I {} basename {})",
"Bash(find \"g:/Dev/Тесты/BQ-System/backend/src/db/migrations\" -name \"*.sql\" -exec wc -l {} +)",
"Bash(grep -n \"case ''callout''\\\\|case ''video''\\\\|case ''table''\\\\|case ''flashcard''\\\\|case ''sim''\\\\|toggleNotes\\\\|saveNote\\\\|notes-panel\" \"g:/Dev/Тесты/BQ-System/frontend/lesson.html\")",
"Bash(ast-index symbol:*)",
"WebSearch",
"Bash(ast-index version:*)",
"Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/js/labs/stereo.js)",
"Bash(node -e \"global.THREE = {}; try { require\\(''g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js''\\); console.log\\(''OK, StereoSim:'', typeof StereoSim\\); } catch\\(e\\) { console.log\\(''Error:'', e.message\\); }\")",
"Bash(xxd \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")",
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js\")",
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js\")",
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/frontend/js/labs/triangle.js\")",
"Bash(node -c frontend/js/labs/forcesandbox.js)",
"Bash(grep -E \"\\\\.js$\")",
"Bash(ls -la /g/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(xargs grep:*)",
"Bash(grep -r \"xp\\\\|level\\\\|badge\\\\|achievement\\\\|streak\\\\|reward\\\\|points\\\\|gamif\" /g/Dev/Тесты/BQ-System/backend/src/ --include=*.js)",
"Bash(node -c backend/src/controllers/gamificationController.js)",
"Bash(node -c backend/src/controllers/classController.js)",
"Bash(node -c js/api.js)",
"Bash(node -c backend/src/controllers/sessionController.js)",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/controllers/sessionController.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/controllers/gamificationController.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/routes/gamification.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/db/migrate.js\")",
"Bash(node -c \"g:/Dev/Тесты/BQ-System/js/api.js\")",
"Bash(python3:*)",
"Bash(for f:*)",
"Bash(do echo:*)",
"Read(//g/Dev/Тесты/BQ-System/**)",
"Bash(done)",
"Bash(ls -lh /g/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(wc -l /g/Dev/Тесты/BQ-System/backend/src/controllers/*.js)",
"Bash(node -c backend/src/controllers/shopController.js)",
"Bash(node -c backend/src/controllers/templateController.js)",
"Bash(node -c backend/src/routes/shop.js)",
"Bash(node -c backend/src/routes/templates.js)",
"Bash(node -c backend/src/server.js)",
"Bash(node -c backend/src/controllers/lessonController.js)",
"Bash(node -c backend/src/db/migrate.js)",
"Bash(xargs basename:*)",
"Bash(node -c backend/src/controllers/permissionsController.js)",
"Bash(node -c backend/src/middleware/auth.js)",
"Bash(node -c backend/src/routes/gamification.js)",
"Bash(node -c backend/src/routes/courses.js)",
"Bash(wc -l frontend/*.html)",
"Bash(find backend/src/controllers -name \"*.js\" -exec wc -l {} +)",
"Bash(find . -name test* -o -name *test.js -o -name spec*)",
"Bash(grep \"Error\\\\|error\\\\|TODO\\\\|FIXME\" backend/src/controllers/*.js)",
"Bash(grep -i \"not implemented\\\\|unimplemented\\\\|coming soon\\\\|not yet\" backend/src/controllers/*.js frontend/*.html)",
"Bash(grep \"res.status\\(501\\\\|status: 501\\\\|res.json\\({.*disabled\\\\|res.json\\({.*not_implemented\" backend/src/controllers/*.js)",
"Bash(xargs wc:*)",
"Bash(grep -r \"editor\\\\|sandbox\\\\|molecule\" g:/Dev/Тесты/BQ-System/frontend/js/labs/*.js)",
"Bash(node -c frontend/js/labs/chemsandbox.js)",
"Bash(ls frontend/*.html)",
"Bash(node -e \"try { require\\(''./backend/src/middleware/validate''\\); console.log\\(''validate.js OK''\\); } catch\\(e\\) { console.error\\(e.message\\); }\")",
"Bash(node --test tests/auth.test.js)",
"Bash(node --test tests/shop.test.js)",
"Bash(node --test tests/sessions.test.js)",
"Bash(npm test:*)",
"Bash(find g:/Dev/Тесты/BQ-System/frontend/js/labs -type f -name *.js)",
"Bash(grep \"\\\\.js$\")",
"Bash(grep -r \"TODO\\\\|FIXME\\\\|скоро\\\\|coming soon\\\\|ТОТО\\\\|XXX\" g:/Dev/Тесты/BQ-System --include=*.js --include=*.html --include=*.css)",
"Bash(wc -l \"g:/Dev/Тесты/BQ-System/backend/tests\"/*.js)",
"Bash(grep -r \"скоро\\\\|coming soon\\\\|TODO\\\\|FIXME\" g:/Dev/Тесты/BQ-System --include=*.js --include=*.html --exclude-dir=node_modules)",
"Bash(find g:/Dev/Тесты/BQ-System -name *.json -path */data/*)",
"Bash(grep -E \"\\\\.\\(js|html\\)$\")",
"Bash(find /g/Dev/Тесты/BQ-System -name *.json -path */data/*)",
"Bash(node src/db/seed-theory.js)",
"Bash(timeout 5 node -e \"require\\(''./src/controllers/lessonController''\\);require\\(''./src/controllers/courseController''\\);console.log\\(''✓ Controllers OK''\\)\")",
"Bash(node -e \"require\\(''./src/controllers/bookmarkController''\\); require\\(''./src/routes/bookmarks''\\); console.log\\(''✓ Bookmarks OK''\\)\")",
"Bash(node -e \"require\\(''./src/controllers/searchController''\\); console.log\\(''✓ Search OK''\\)\")",
"Bash(node -e \"require\\(''./src/db/migrate''\\); require\\(''./src/controllers/sessionController''\\); console.log\\(''✓ OK''\\)\")",
"Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/projectile.js\")",
"Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/collision.js\")",
"Bash(node --check frontend/js/labs/gas.js)",
"Bash(node --check frontend/js/labs/brownian.js)",
"Bash(node --check frontend/js/labs/states.js)",
"Bash(node --check frontend/js/labs/diffusion.js)",
"Bash(node --check \"G:\\\\Dev\\\\Тесты\\\\BQ-System\\\\frontend\\\\js\\\\labs\\\\states.js\")",
"Bash(xargs ls:*)",
"Bash(find \"g:/Dev/Тесты/BQ-System\" -type f \\\\\\( -name \"*.js\" -o -name \"*.html\" -o -name \"*.css\" \\\\\\) -not -path \"*/node_modules/*\" -exec wc -l {} +)",
"Bash(head -10 grep -n \"info\\(\\)\\\\|_emitUpdate\\\\|onUpdate\" \"g:/Dev/Тесты/BQ-System/frontend/js/labs/photosynthesis.js\")",
"Bash(grep -n \"^ </div>$\\\\|^ </div>\" \"g:/Dev/Тесты/BQ-System/frontend/admin.html\")",
"Bash(grep -v \"<!-\\\\|//\")",
"Bash(grep -roh [a-z-]*.html g:/Dev/Тесты/BQ-System/js/)",
"Bash(grep -l \"@view-transition\\\\|view-transition\" g:/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(npm install:*)",
"Bash(grep -n \"SIMS\\\\|id: null\\\\|cat: ''''phys''''\" frontend/lab.html)",
"Bash(grep -n \"sim-chemsandbox\\\\|_openChemSandbox\\\\|chemSand\\\\|case ''''chemsandbox''''\" frontend/lab.html)",
"Bash(grep -n \"case ''''chemsandbox''''\\\\|case ''''crystal''''\\\\|_openChem\\\\|openSim\\\\b\" frontend/lab.html)",
"Bash(grep -n \"cat: ''''game\\\\|gp-section.*игр\\\\|CATS\\\\b\\\\|cats\\\\b\\\\|filterCat\\\\|cat === \" frontend/lab.html)",
"Bash(node -e \"require\\(''''./frontend/js/labs/angrybirds.js''''\\)\")",
"Bash(node -e \"require\\(''./frontend/js/labs/angrybirds.js''\\)\")",
"Bash(wc -l frontend/js/labs/*.js)",
"Bash(node -e \"require\\(''''./g:/Dev/Тесты/BQ-System/backend/src/server.js''''\\)\")",
"Bash(node -e \"const db=require\\(''./backend/src/db/db''\\); const cols=db.prepare\\(''PRAGMA table_info\\(questions\\)''\\).all\\(\\); console.log\\(cols.map\\(c=>c.name+'':''+c.type\\).join\\(''\\\\n''\\)\\)\")",
"Bash(node -e \"const db=require\\(''./backend/src/db/db''\\); console.log\\(''phys questions:'', db.prepare\\(\"\"SELECT COUNT\\(*\\) as n FROM questions q JOIN subjects s ON s.id=q.subject_id WHERE s.slug=''phys''\"\"\\).get\\(\\).n\\); console.log\\(''total questions:'', db.prepare\\(''SELECT COUNT\\(*\\) as n FROM questions''\\).get\\(\\).n\\);\")",
"Bash(node -e \"const db=require\\(''./backend/src/db/db''\\); const q=db.prepare\\(''SELECT s.slug, COUNT\\(*\\) as n FROM questions q JOIN subjects s ON s.id=q.subject_id GROUP BY s.slug''\\).all\\(\\); console.log\\(q\\);\")",
"Bash(node src/db/seed-phys.js)",
"Bash(node src/db/seed-chem.js)",
"Bash(node src/db/dedup-bio.js)",
"Bash(node -e \"const db=require\\(''./src/db/db''\\); console.log\\(db.prepare\\(\"\"SELECT sql FROM sqlite_master WHERE name=''bookmarks''\"\"\\).get\\(\\).sql\\)\")",
"Bash(find g:/Dev/Тесты/BQ-System -name *.bak -o -name *.old -o -name *.tmp -o -name *.orig)",
"Bash(head -20 echo --- find g:/Dev/Тесты/BQ-System -name test*.js -not -path */node_modules/*)",
"Bash(grep -rn \"href.*homework\\\\|href.*theory\\\\|href.*gradebook\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)",
"Bash(wc -l /g/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(node -e \"const db=require\\(''./src/db/db''\\); const tables=db.prepare\\(\"\"SELECT name FROM sqlite_master WHERE type=''table'' ORDER BY name\"\"\\).all\\(\\); tables.forEach\\(t=>console.log\\(t.name\\)\\)\")",
"Bash(grep -o 'https://[^\"\"\"\"'''' ]*' /g/Dev/Тесты/BQ-System/frontend/*.html)",
"Bash(wc -l seed*.js pool.js crop_images.js dedup-bio.js)",
"Bash(grep -E \"$\\\\{.*name.*\\\\}\")",
"Bash(grep -n \"function esc\" frontend/*.html js/*.js)",
"Bash(node -e \"const src=require\\(''fs''\\).readFileSync\\(''frontend/lab.html'',''utf8''\\); [''gas'',''brownian'',''states'',''diffusion'',''reactions''].forEach\\(n=>{const idx=src.indexOf\\(''id=\"\"''+n+''-canvas\"\"''\\); const ctx=src.substring\\(Math.max\\(0,idx-200\\),idx\\); console.log\\(n+'': ''+\\(/proj-canvas-outer/.test\\(ctx\\)?''proj-canvas-outer'':''OTHER''\\)\\);}\\)\")",
"Bash(node -e \"const src=require\\(''fs''\\).readFileSync\\(''frontend/dashboard.html'',''utf8''\\); const lines=src.split\\(''\\\\n''\\); const g=[''esc'',''parseDate'',''fmtRelTime'',''safeHref'',''connectSSE'']; for\\(let i=0;i<lines.length;i++\\){for\\(const n of g\\){if\\(lines[i].includes\\(''const ''+n+'' ''\\)||lines[i].includes\\(''const ''+n+''=''\\)\\)console.log\\(\\(i+1\\)+'': ''+lines[i].trim\\(\\).substring\\(0,100\\)\\);}}\")",
"Bash(python \"C:\\\\Users\\\\Home\\\\.claude\\\\skills\\\\ui-ux-pro-max\\\\scripts\\\\search.py\" \"education platform login auth dark premium\" --design-system -p \"LearnSpace\" -f markdown)",
"Bash(python \"C:\\\\Users\\\\Home\\\\.claude\\\\skills\\\\ui-ux-pro-max\\\\scripts\\\\search.py\" \"premium dark SaaS auth fullscreen immersive\" --domain style -n 3)",
"Bash(grep -E \"\\\\.\\(js|json|html\\)$\")",
"Bash(grep -n \"EventSource\" /g/Dev/Тесты/BQ-System/frontend/*.html)"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
node_modules
.git
.claude
*.md
!backend/README.md
backend/tests
backend/.env
backend/bioquantum.db*
backend/data
backend/uploads
__pycache__
.DS_Store
Thumbs.db
*.log
+29
View File
@@ -0,0 +1,29 @@
# Dependencies
node_modules/
backend/node_modules/
# Database files
*.db
*.db-shm
*.db-wal
data/
# Environment / secrets
.env
.env.*
!.env.example
# Uploads
backend/uploads/
# Logs
*.log
server.log
# OS
.DS_Store
Thumbs.db
# Build artifacts
dist/
build/
+35
View File
@@ -0,0 +1,35 @@
# BQ-System — правила для Claude
## Поиск по коду
**ВСЕГДА использовать `ast-index` ПЕРВЫМ** для любого поиска по коду.
Grep/Read — только если ast-index вернул пустой результат.
```bash
# Найти класс/функцию/символ
ast-index class "ClassName"
ast-index symbol "functionName"
# Найти использования
ast-index usages "symbolName"
# Поиск по содержимому файла
ast-index search "keyword" --in-file "filename"
# Структура файла
ast-index outline "path/to/file.js"
# Универсальный поиск
ast-index search "query"
```
Grep использовать только для:
- Поиска строковых литералов (`"some text"`)
- Regex-паттернов
- Если ast-index вернул пустой результат
## Стек
- Node.js/Express backend, SQLite (better-sqlite3, sync)
- Frontend: vanilla JS, без бандлера
- ast-index проиндексирован: `ast-index rebuild` при добавлении новых файлов
+32
View File
@@ -0,0 +1,32 @@
# ── Stage 1: install production deps ─────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev
# ── Stage 2: runtime ─────────────────────────────────────────────────────
FROM node:22-alpine
LABEL maintainer="BQ-System"
RUN apk add --no-cache sqlite tini
WORKDIR /app
COPY --from=deps /app/backend/node_modules ./backend/node_modules
COPY backend/package.json ./backend/
COPY backend/src ./backend/src
COPY backend/seed.js ./backend/seed.js
COPY frontend ./frontend
COPY js ./js
# Ensure data & uploads dirs exist (volumes mount here)
RUN mkdir -p /app/backend/data /app/backend/uploads /app/backups
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["node", "backend/src/server.js"]
+493
View File
@@ -0,0 +1,493 @@
# LearnSpace — Полный UX-аудит фронтенда
**Дата проверки:** 2026-03-16
**Проверено файлов:** 40
**Методология:** ручной просмотр каждого HTML-файла, оценка по 10 категориям
---
## Сводная таблица критических проблем
| # | Файл | Проблема | Серьёзность |
|---|------|----------|-------------|
| 1 | board.html | Кнопка «Перейти» ведёт на `/dashboard`, а не запускает тест | HIGH |
| 2 | login.html | После регистрации всегда редирект на `/dashboard` — учителя попадают не туда | HIGH |
| 3 | homework.html | `catch {}` — пустой обработчик ошибок, студент не знает о сбое | HIGH |
| 4 | test-result.html | Обе кнопки «К тестам» и «В кабинет» ведут на `/dashboard` — дублирование без смысла | HIGH |
| 5 | live-quiz.html | Кнопка «Завершить сессию» без подтверждения — деструктивное действие | HIGH |
| 6 | admin.html | Смена роли пользователя без подтверждения (inline select) | HIGH |
| 7 | board.html | Ссылка `href="/red-book.html"`с расширением `.html`, все остальные роуты без расширения | MEDIUM |
| 8 | Все страницы | Analytics, Gradebook, Question Bank, Live Quiz, Lesson Editor — нет в сайдбаре | MEDIUM |
| 9 | profile.html | `overflow: hidden` на `body` — может сломаться при виртуальной клавиатуре на мобиле | MEDIUM |
| 10 | profile.html | Форма смены пароля без поля «текущий пароль» | MEDIUM |
---
## Детальный разбор по файлам
---
### login.html
#### [HIGH] Flow: редирект после регистрации всегда на `/dashboard`
Учителя и администраторы после создания аккаунта попадают на страницу студента. Им нужны `/classes` или `/admin`.
**Фикс:** после успешной регистрации получать роль из ответа API и редиректить по роли: `student → /dashboard`, `teacher → /classes`, `admin → /admin`.
#### [MEDIUM] Flow: нет выбора роли при регистрации
Непонятно, как учитель или администратор создаёт аккаунт. Нет поля «роль» в форме регистрации.
**Фикс:** добавить `<select>` с вариантами «Студент» / «Учитель». Роль «Администратор» создаётся только через admin-панель.
#### [MEDIUM] Accessibility: нет ссылки «Забыли пароль?»
Стандартный элемент auth-страниц полностью отсутствует.
**Фикс:** добавить ссылку под полем пароля на форме входа.
#### [LOW] Consistency: `showBtnSuccess` вставляет строку `'✓ Сохранено'` через конкатенацию текста
Использование символа `✓` в коде (эмодзи-подобный символ) противоречит правилу проекта «только SVG-иконки».
**Фикс:** заменить `'✓ '` на inline SVG с классом `.ic`.
---
### dashboard.html
#### [MEDIUM] Loading: у виджетов есть скелетоны, но нет fallback при сетевой ошибке
Если API возвращает 500 — скелетон не убирается и данные не появляются. Пользователь не понимает, что произошло.
**Фикс:** в каждом `catch` блоке заменять скелетон на rich-empty с текстом ошибки и кнопкой «Повторить».
#### [MEDIUM] Navigation: кнопки быстрых действий (`adm-actions`) видны для teacher/admin, но не имеют состояния загрузки
После нажатия на «Создать тест» или «Добавить класс» кнопка не блокируется — возможно двойное нажатие.
**Фикс:** `btn.disabled = true` сразу при клике.
#### [LOW] Feedback: таймер дедлайна предупреждает только за 2 минуты (`secondsLeft <= 120`)
Для длинных тестов (1 час+) единственное предупреждение придёт слишком поздно, если пользователь отвлёкся.
**Фикс:** добавить промежуточное предупреждение при 25% оставшегося времени и за 5 минут.
---
### board.html
#### [HIGH] Flow: кнопка «Перейти» на карточке задания ведёт на `/dashboard`, а не на тест
```html
<a href="/dashboard" class="btn-action">Перейти</a>
```
Студент не может начать задание прямо с борда — он попадает на главную страницу.
**Фикс:** ссылка должна вести на `/test-run?session=...&assignment=...` (с нужными параметрами задания).
#### [MEDIUM] Consistency: ссылка на Red Book в сайдбаре содержит `.html`-расширение
```html
<a href="/red-book.html" ...>
```
Все остальные ссылки: `/dashboard`, `/board`, `/classes` — без расширения.
**Фикс:** добавить Express-роут `/red-book` → отдавать `red-book.html`, изменить ссылку на `/red-book`. Это же исправление нужно во **всех** файлах с сайдбаром.
#### [MEDIUM] Navigation: студент без класса видит пустой бord без способа вступить по инвайт-коду
Пустое состояние есть, но нет поля ввода инвайт-кода.
**Фикс:** добавить в empty-state форму «Введи код класса» с полем и кнопкой.
#### [MEDIUM] Feedback: бейдж «LIVE» рендерится всегда, независимо от статуса
```html
<span class="live-badge">LIVE</span>
```
JS должен скрывать/показывать его по реальному статусу сессии.
**Фикс:** `liveBadge.style.display = session.is_live ? '' : 'none'`.
#### [LOW] Data integrity: реакции сохраняются только в `localStorage`
Данные `ls_reactions` пропадут на другом браузере/устройстве и не видны другим пользователям.
**Фикс:** сохранять реакцию через POST `/api/posts/:id/react`, localStorage использовать только как оптимистичный кэш.
---
### classes.html
#### [MEDIUM] Forms: кнопка «Создать класс» видна до выполнения JS role-check
В HTML `btn-new-cl` отрисовывается сразу. Студент, быстро открыв страницу, видит её до скрытия.
**Фикс:** добавить `style="display:none"` в HTML, показывать через JS только для teacher/admin.
#### [MEDIUM] Navigation: ссылка на Gradebook скрыта в маленькой иконке в заголовке сайдбара
Новый пользователь никогда не найдёт журнал оценок.
**Фикс:** вынести «Журнал» как отдельный пункт сайдбара (или добавить явную кнопку в шапку детали класса).
#### [MEDIUM] Confirmation: кнопка `btn-delete-class` — неясно, есть ли подтверждение
В HTML диалог подтверждения не виден. Если он реализован только в JS-коде в виде `confirm()` браузера — это слабое решение.
**Фикс:** использовать кастомный modal с явным текстом «Удалить класс X? Это действие необратимо», кнопками «Отмена» / «Удалить».
#### [LOW] Accessibility: все элементы управления классами доступны только после выбора класса из списка
Состояние «ничего не выбрано» показывает заглушку «Выберите класс» без инструкции о том, что нужно сделать слева.
**Фикс:** добавить стрелку-подсказку или анимацию, указывающую на список классов.
---
### admin.html
#### [HIGH] Confirmation: смена роли пользователя через inline `<select>` без подтверждения
```html
<select class="role-select">...</select>
```
Неосторожный клик меняет роль немедленно. Это деструктивное действие: учитель превращается в студента без возможности отмены.
**Фикс:** при `change` события показывать confirmation modal «Изменить роль пользователя X с Y на Z?», только после подтверждения отправлять PATCH-запрос.
#### [HIGH] Confirmation: кнопка `btn-del-q` (удалить вопрос) без видимого диалога подтверждения
Удаление вопроса из банка — необратимое действие.
**Фикс:** confirmation modal с предупреждением об необратимости.
#### [MEDIUM] Error handling: нет отображения ошибок при провале admin-операций
Если PATCH /api/users/:id/role вернул 500, пользователь не узнает об этом.
**Фикс:** показывать toast/alert с текстом ошибки из ответа API.
#### [LOW] UX: `max-height: 0` → `max-height: 4000px` в session drawer
Анимация открытия непредсказуема по скорости — CSS считает переход от 0 до 4000px, хотя реальная высота 200px.
**Фикс:** использовать JavaScript для установки точной `max-height: element.scrollHeight + 'px'`.
---
### test-run.html
#### [MEDIUM] Feedback: кнопка «Завершить тест» доступна с первого вопроса без минимального порога
Студент может нечаянно нажать и завершить тест, ответив на 0 вопросов.
**Фикс:** показывать предупреждение в confirmation modal: «Ты ответил на X из Y вопросов. Уверен, что хочешь завершить?»
#### [MEDIUM] Accessibility: `<img src="..." alt="">` — пустой атрибут alt у изображений в вопросах
Студенты с нарушениями зрения не получат описание изображения.
**Фикс:** заполнять `alt` из поля описания вопроса в БД (например, `alt="Изображение к вопросу: ..."`).
#### [MEDIUM] Loading: нет серверного сохранения прогресса теста в процессе
SessionStorage работает только в рамках одной вкладки. При сбое браузера прогресс теряется.
**Фикс:** периодически (каждые 30 сек) отправлять автосохранение через PATCH `/api/sessions/:id/autosave` с текущими ответами.
#### [LOW] Feedback: предупреждение таймера только за 120 секунд — мало для коротких тестов
**Фикс:** добавить предупреждение при достижении 25% оставшегося времени (независимо от абсолютного значения).
---
### test-result.html
#### [HIGH] Flow: обе кнопки «К тестам» и «В кабинет» ведут на `/dashboard`
```html
<a href="/dashboard" class="btn-primary">К тестам</a>
<a href="/dashboard" class="btn-ghost">В кабинет</a>
```
Дублирование кнопок с одинаковым назначением сбивает с толку.
**Фикс:** «К тестам» → `/board` (список заданий), «В кабинет» → `/dashboard`, добавить третью кнопку «Пройти ещё раз» → `/test-run?...` с теми же параметрами.
#### [MEDIUM] Navigation: нет возврата к заданию на борде, если тест был из assignment
Если студент пришёл с `/board?class=5`, после теста нет ссылки «Вернуться к классу».
**Фикс:** передавать `returnUrl` параметром и использовать его в кнопке «Назад».
---
### homework.html
#### [HIGH] Error handling: пустой `catch {}` при загрузке данных класса в `initStudent()`
```js
catch {}
```
Студент видит пустую форму без объяснения причины.
**Фикс:** заменить на `catch(e) { showError('Не удалось загрузить задания. ' + e.message); }`.
#### [HIGH] Forms: при нескольких классах у студента задание всегда подаётся в `classes[0].id`
Студент в 2+ классах может случайно отправить работу не в тот класс.
**Фикс:** добавить `<select>` для выбора класса, если `classes.length > 1`.
#### [MEDIUM] Forms: нет клиентской валидации размера файла перед отправкой
Подсказка «до 50 МБ» есть, но проверки нет — пользователь узнаёт об ошибке только после долгой загрузки.
**Фикс:**
```js
if (file.size > 50 * 1024 * 1024) { showError('Файл слишком большой (максимум 50 МБ)'); return; }
```
#### [MEDIUM] Error handling: при ошибке загрузки списка работ нет кнопки «Повторить»
Показывается текст «Ошибка загрузки», но нет способа повторить запрос.
**Фикс:** добавить `<button onclick="loadHomeworks()">Повторить</button>` в блок с ошибкой.
---
### profile.html
#### [MEDIUM] Mobile: `html, body { height: 100%; overflow: hidden; }` сломается при виртуальной клавиатуре
На iOS при фокусе на input виртуальная клавиатура поднимает область просмотра, overflow hidden блокирует прокрутку к полю.
**Фикс:** убрать `overflow: hidden` с `body`, использовать `height: 100dvh` на layout-контейнере или flex с `min-height: 100svh`.
#### [MEDIUM] Security/Forms: форма смены пароля не требует ввода текущего пароля
Любой получивший доступ к разблокированному устройству может сменить пароль.
**Фикс:** добавить поле «Текущий пароль» (`type="password"`) перед полями нового пароля.
#### [LOW] Feedback: `form-msg` появляется, но нет auto-hide через N секунд
Сообщение «Сохранено» остаётся на экране indefinitely.
**Фикс:** `setTimeout(() => formMsg.classList.remove('ok','err'), 4000)`.
---
### library.html
#### [MEDIUM] Accessibility/Mobile: кнопки редактирования/удаления папок появляются только при hover
```css
.folder-card-acts { opacity: 0; }
.folder-card:hover .folder-card-acts { opacity: 1; }
```
На touch-устройствах hover недоступен — кнопки принципиально не видны.
**Фикс:** на мобиле заменить hover на long-press или показывать кнопки через иконку «⋮» (три точки) при тапе на карточку.
#### [MEDIUM] Confirmation: `btn-del` (удалить файл/папку) без видимого confirmation modal в HTML
**Фикс:** добавить modal «Удалить файл/папку X? Это действие необратимо».
#### [LOW] Navigation: breadcrumb `.lib-bc` показывает путь, но нет клавиатурной поддержки
Ссылки в breadcrumb должны быть `<a href="...">`, а не `<span onclick="...">`.
**Фикс:** использовать семантические `<a>` элементы для каждого уровня пути.
---
### analytics.html
#### [MEDIUM] Loading: нет состояния загрузки для диаграмм Chart.js
Пока данные грузятся — область диаграммы пустая/белая без индикации.
**Фикс:** показывать skeleton-placeholder над canvas до получения данных.
#### [LOW] Navigation: кнопка «Назад» (`an-header-back`) ведёт `history.back()` — может вернуть в сломанное состояние
**Фикс:** задать конкретный href (`/classes` или `/dashboard`) вместо `history.back()`.
---
### gradebook.html
#### [MEDIUM] Accessibility: при большом числе студентов таблица горизонтально прокручивается, но нет подсказки
Пользователь не знает, что таблица шире экрана.
**Фикс:** добавить fade-gradient на правом краю таблицы, пока есть горизонтальный скролл.
#### [LOW] Navigation: нет ссылки на профиль студента при клике на имя в таблице
**Фикс:** сделать имена студентов в первом столбце кликабельными ссылками на `/profile?id=...`.
---
### question-bank.html
#### [MEDIUM] Confirmation: удаление вопроса (если есть кнопка) без confirmation modal
**Фикс:** modal с предупреждением «Вопрос может быть использован в активных тестах».
#### [LOW] Forms: фильтры не сохраняются при переходе к редактированию и обратно
Пользователь теряет выбранные фильтры при навигации.
**Фикс:** хранить состояние фильтров в URL query params (`?subject=bio&type=mc`).
---
### live-quiz.html
#### [HIGH] Confirmation: кнопка `btn-end` (завершить сессию) без подтверждения
Нажатие заканчивает живую викторину для всех участников мгновенно.
**Фикс:** modal «Завершить сессию? Все участники будут автоматически переведены на экран результатов».
#### [MEDIUM] Feedback: нет отображения числа подключённых участников в реальном времени
Учитель не видит, сколько студентов уже подключились перед стартом.
**Фикс:** показывать счётчик «X участников подключились» с обновлением через polling/WebSocket.
---
### course.html
#### [LOW] Navigation: кнопка «Записаться» / «Продолжить» не показывает состояние записи до загрузки
Кнопка мерцает при загрузке (меняет текст после JS-инициализации).
**Фикс:** рендерить кнопку в скрытом/skeleton-состоянии до получения данных о статусе записи.
---
### lesson.html
#### [MEDIUM] Content: inline quiz не имеет кнопки «Попробовать снова»
После неверного ответа опция подсвечивается красным, но повторить нельзя.
**Фикс:** добавить кнопку «Ещё раз» после показа правильного ответа.
#### [MEDIUM] Layout: `max-height: 600px` для accordion-блоков — длинный контент обрежется
```css
.accordion-body { max-height: 600px; overflow: hidden; }
```
**Фикс:** убрать `max-height`, использовать JS `element.style.maxHeight = element.scrollHeight + 'px'` для анимации.
#### [LOW] Navigation: оглавление (TOC) скрыто при ширине < 1100px — нет альтернативного доступа
На планшетах нет способа перейти к нужному разделу без ручной прокрутки.
**Фикс:** добавить кнопку «Оглавление» в topbar, открывающую TOC как drawer/dropdown на мобиле.
---
### pet.html
#### [LOW] Accessibility: интерактивная анимация дыхания без возможности приостановки
Постоянно движущийся элемент может вызывать дискомфорт у пользователей с вестибулярными нарушениями.
**Фикс:** соблюдать `prefers-reduced-motion`: `@media (prefers-reduced-motion: reduce) { .breath-anim { animation: none; } }`.
---
### red-book.html
#### [MEDIUM] Consistency: доступ через `href="/red-book.html"` с расширением
Все остальные страницы доступны без `.html`. Эта единственная исключение.
**Фикс:** добавить роут `GET /red-book` → serve `red-book.html`. Исправить ссылку в сайдбарах всех страниц.
#### [LOW] Navigation: пункты подменю Red Book (`red-book-biomes`, `red-book-ecosystem`, `red-book-games`) видны в сайдбаре только на страницах Red Book
Пользователь не знает о подразделах, пока не зайдёт в Red Book.
**Фикс:** допустимо (паттерн контекстного сайдбара), но нужен `<title>` в виде «Красная книга — Биомы» для ориентации (уже есть).
---
### theory.html
#### [MEDIUM] Navigation/Roles: кнопка «Создать курс» (`btn-new-course`) видна в заголовке для всех
Студент видит кнопку действия, недоступного ему по роли.
**Фикс:** скрывать кнопку до JS role-check (`style="display:none"` в HTML, показывать только для teacher/admin).
---
### 404.html
#### [LOW] Navigation: кнопка «Назад» использует `history.back()`
Если 404 открыт по прямой ссылке или из внешнего источника — history.back() закроет вкладку или уведёт на внешний сайт.
**Фикс:** добавить `if (history.length > 1) history.back(); else location.href = '/dashboard';`.
---
### 403.html
#### [LOW] UX: три кнопки подряд — «На главную», «Назад», «Войти» — могут запутать незалогиненного пользователя
Если пользователь не залогинен, «На главную» тоже приведёт к редиректу на login.
**Фикс:** проверять авторизацию через `LS.isLoggedIn()` и показывать только релевантные кнопки: для залогиненных — «На главную» + «Назад», для незалогиненных — только «Войти».
---
### 500.html
#### [LOW] Feedback: «Мы уже в курсе» — вводящий в заблуждение текст
На статических страницах ошибок нет механизма автоматической отправки отчёта. Фраза ложная.
**Фикс:** заменить на «Попробуй обновить страницу или вернись позже».
---
### biochem.html и семейство (biochem-library, biochem-pathways, biochem-reactions, biochem-properties)
#### [MEDIUM] Mobile: `body { overflow: hidden; }` + canvas-интерфейс непригоден для мобиле
Молекулярный конструктор и аналогичные страницы требуют мышь для взаимодействия с canvas.
**Фикс:** добавить предупреждение «Для работы рекомендуется компьютер» при ширине < 768px, или реализовать touch-управление (pinch-to-zoom, tap-to-place).
#### [MEDIUM] Navigation: нет кнопки «Сохранить молекулу» с очевидным расположением
Действия в правой панели (`bp-btn`) мелкие и не имеют подписей при сжатии панели.
**Фикс:** добавить явную primary-кнопку «Сохранить в библиотеку» в тулбар.
#### [LOW] Accessibility: canvas-элементы недоступны для скринридеров
`<canvas id="mol-canvas">` без `aria-label` и без fallback-контента.
**Фикс:** `<canvas aria-label="Молекулярный конструктор" role="img">Интерактивный редактор молекул</canvas>`.
---
### lesson-editor.html
#### [MEDIUM] Forms: нет автосохранения (видна статусная строка `.etb-status`, но нет таймера автосохранения)
Потеря введённого контента при случайном закрытии вкладки.
**Фикс:** `setInterval(() => saveDraft(), 30000)` + обработчик `beforeunload` при несохранённых изменениях.
#### [MEDIUM] Navigation/Confirmation: кнопка «Назад» (`etb-back`) ведёт без проверки несохранённых изменений
**Фикс:** перехватить клик, если `isDirty`, показать: «Есть несохранённые изменения. Уйти?».
#### [LOW] Accessibility: редактор использует `contenteditable` (предположительно) без ARIA-ролей
**Фикс:** `role="textbox" aria-multiline="true" aria-label="Содержимое урока"`.
---
### collection.html / collection-rb.html
#### [LOW] Empty state: нет состояния пустой коллекции
Если у пользователя нет элементов в коллекции, нет поясняющего empty-state.
**Фикс:** добавить `.rich-empty` с иллюстрацией и текстом «Коллекция пуста — открывай карточки видов, чтобы пополнять её».
---
### red-book-biomes.html / red-book-ecosystem.html / red-book-games.html
#### [LOW] Mobile: canvas-страницы с `height: 100vh; overflow: hidden` некорректно ведут себя на мобиле
**Фикс:** использовать `height: 100dvh` для корректного учёта браузерной панели на мобиле.
#### [LOW] Navigation: кнопка «Назад» присутствует в topbar как `eco-back` — хорошо, но нет breadcrumb
Пользователь не видит, где он в иерархии Red Book.
**Фикс:** добавить breadcrumb «Красная книга > Экосистемы».
---
## Кросс-страничные (системные) проблемы
### [HIGH] Navigation: ссылка `/red-book.html` с расширением во всех сайдбарах
Затрагивает: board.html, classes.html, homework.html, library.html, profile.html, dashboard.html и все остальные страницы с общим сайдбаром.
**Фикс:** добавить роут в Express: `app.get('/red-book', (req,res) => res.sendFile('red-book.html', {root: 'frontend'}))`, изменить все ссылки.
### [MEDIUM] Navigation: «скрытые» инструментальные страницы недостижимы из навигации
Следующие страницы **не имеют ссылок в основном сайдбаре**:
- `/analytics` — аналитика успеваемости
- `/gradebook` — журнал оценок
- `/question-bank` — банк вопросов
- `/live-quiz` — живая викторина
- `/lesson-editor` — редактор уроков
- `/flashcards` — карточки SRS
- `/crossword`, `/hangman` — игры
- `/knowledge-map` — карта знаний
- `/lab` — лаборатория симуляций
- `/pet` — виртуальный питомец
Пользователи находят их только если знают прямой URL.
**Фикс:** сгруппировать в сайдбаре или добавить «хаб» страницу с плитками всех инструментов (например, `/tools`).
### [MEDIUM] Consistency: CDN Lucide загружается с разных источников
- Большинство файлов: `https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js`
- `biochem-pathways.html`, `biochem-reactions.html`: `https://unpkg.com/lucide@0.469.0/...`
**Фикс:** унифицировать на один CDN (jsDelivr предпочтительнее по производительности).
### [MEDIUM] Accessibility: нет `aria-current="page"` на активных пунктах сайдбара
`.sb-link.active` ставится через JS, но без ARIA-атрибута скринридеры не объявляют текущую страницу.
**Фикс:** при активации добавлять `link.setAttribute('aria-current', 'page')`.
### [LOW] Mobile: `js/mobile.js` не подключён к инструментальным страницам
Страницы analytics, gradebook, question-bank, live-quiz и др. не имеют моб-бара, их нельзя нормально открыть на мобиле.
**Фикс:** добавить `<script src="/js/mobile.js"></script>` перед `</body>` во всех страницах с `.app-layout`.
### [LOW] Consistency: страницы biochem-семейства используют `data-lucide` без вызова `lucide.createIcons()`
Если JS lucide загружен defer, иконки не инициализируются до вызова функции.
**Фикс:** убедиться, что `lucide.createIcons()` вызывается после загрузки DOM во всех таких страницах.
### [LOW] Accessibility: большинство модальных окон (`confirm-overlay`, `modal`) не управляют фокусом
При открытии modal фокус остаётся на кнопке открытия, а не перемещается внутрь. Нет `aria-modal="true"`, нет `role="dialog"`, нет `aria-labelledby`.
**Фикс:** при открытии modal: `modal.setAttribute('aria-modal', 'true'); modal.setAttribute('role', 'dialog'); firstFocusable.focus();`. Trap focus внутри пока открыт.
---
## Статистика проблем
| Серьёзность | Количество |
|-------------|------------|
| HIGH | 10 |
| MEDIUM | 32 |
| LOW | 22 |
| **Итого** | **64** |
---
## Приоритетный план исправлений
### Спринт 1 (критические, 1–2 дня)
1. `board.html`: исправить href кнопки «Перейти» → `/test-run?...`
2. `login.html`: редирект после регистрации по роли
3. `homework.html`: убрать пустой `catch {}`, добавить обработку ошибок
4. `test-result.html`: разделить кнопки на разные destination
5. `admin.html`: добавить confirmation modal на смену роли и удаление вопроса
6. `live-quiz.html`: добавить confirmation на завершение сессии
### Спринт 2 (важные, 3–5 дней)
7. Express-роут `/red-book` + исправить ссылки во всех сайдбарах
8. Добавить `/js/mobile.js` на все инструментальные страницы
9. `profile.html`: поле «текущий пароль» + убрать `overflow:hidden` с body
10. `homework.html`: select класса при нескольких классах + валидация размера файла
11. `lesson-editor.html`: автосохранение + beforeunload guard
12. Скрыть учительские кнопки (`btn-new-cl`, `btn-new-course`) до JS role-check
### Спринт 3 (улучшения, 1–2 недели)
13. Добавить `aria-current="page"` в сайдбаре
14. ARIA-атрибуты на всех modal/dialog
15. `prefers-reduced-motion` на анимации
16. Хаб-страница `/tools` или группировка инструментов в сайдбаре
17. Унификация CDN Lucide на jsDelivr
18. Сохранение реакций на сервере (board.html)
19. Автосохранение прогресса теста на сервере (test-run.html)
20. Контекстные empty-state для всех ошибок загрузки с кнопкой «Повторить»
+8
View File
@@ -0,0 +1,8 @@
PORT=3000
# JWT
JWT_SECRET=change_this_to_a_long_random_string
JWT_EXPIRES_IN=7d
# CORS — адрес фронтенда
CLIENT_ORIGIN=http://localhost:5500
+90
View File
@@ -0,0 +1,90 @@
# LearnSpace Backend — Фаза 1
## Быстрый старт
```bash
cd backend
npm install
# 1. Скопировать и заполнить переменные окружения
cp .env.example .env
# 2. Создать базу данных в PostgreSQL
createdb learnspace
# 3. Применить миграции (создать таблицы)
npm run migrate
# 4. Загрузить тестовые вопросы
npm run seed
# 5. Запустить сервер
npm run dev
```
## API
### Auth
| Метод | URL | Тело | Описание |
|-------|-----|------|----------|
| POST | `/api/auth/register` | `{ email, password, name }` | Регистрация |
| POST | `/api/auth/login` | `{ email, password }` | Вход |
| GET | `/api/auth/me` | — | Текущий пользователь |
### Предметы
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/subjects` | Список предметов |
| GET | `/api/subjects/:slug/topics` | Темы предмета |
### Сессии тестирования
| Метод | URL | Тело | Описание |
|-------|-----|------|----------|
| POST | `/api/sessions` | `{ subject_slug, mode, count, topic_id? }` | Начать тест |
| POST | `/api/sessions/:id/answer` | `{ question_id, option_id, time_spent_sec? }` | Отправить ответ |
| POST | `/api/sessions/:id/finish` | — | Завершить тест + разбор |
| GET | `/api/sessions/:id/result` | — | Результат завершённого теста |
| GET | `/api/sessions/history` | — | История тестов |
Все `/api/sessions/*` требуют заголовок:
```
Authorization: Bearer <token>
```
## Добавление вопросов
Создать JSON-файл в `data/` по образцу `questions-bio.json`:
```json
{
"subject": "chem",
"topics": [{ "name": "Органическая химия", "order": 1 }],
"questions": [
{
"topic": "Органическая химия",
"difficulty": 2,
"text": "Текст вопроса",
"options": ["А", "Б", "В", "Г"],
"answer": 0,
"explanation": "Объяснение правильного ответа"
}
]
}
```
Затем повторно запустить `npm run seed`.
## Структура проекта
```
backend/
├── data/ ← JSON с вопросами
├── src/
│ ├── server.js ← точка входа
│ ├── middleware/auth.js ← JWT верификация
│ ├── db/
│ │ ├── pool.js ← соединение с PostgreSQL
│ │ ├── migrate.js ← запуск миграций
│ │ ├── seed.js ← загрузка вопросов
│ │ └── migrations/ ← SQL-файлы схемы
│ ├── routes/ ← маршруты
│ └── controllers/ ← бизнес-логика
└── .env.example
```
+2043
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "learnspace-backend",
"version": "1.0.0",
"description": "LearnSpace backend",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"migrate": "node src/db/migrate.js",
"seed": "node src/db/seed.js",
"test": "node --test tests/*.test.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# ── LearnSpace SQLite backup ──────────────────────────────────────────────
# Uses VACUUM INTO for a safe, consistent snapshot (no WAL/SHM issues).
# Usage:
# ./backup.sh # default paths
# ./backup.sh /path/to/db /path/to/backups
# KEEP=14 ./backup.sh # keep last 14 backups (default: 7)
# ─────────────────────────────────────────────────────────────────────────
set -e
DB_PATH="${1:-${DB_PATH:-$(dirname "$0")/../data/learnspace.db}}"
BACKUP_DIR="${2:-${BACKUP_DIR:-$(dirname "$0")/../../backups}}"
KEEP="${KEEP:-7}"
if [ ! -f "$DB_PATH" ]; then
echo "[backup] ERROR: database not found at $DB_PATH" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/learnspace_${TIMESTAMP}.db"
echo "[backup] Creating snapshot: $BACKUP_FILE"
sqlite3 "$DB_PATH" "VACUUM INTO '$BACKUP_FILE';"
# Verify backup is not empty
SIZE=$(wc -c < "$BACKUP_FILE" | tr -d ' ')
if [ "$SIZE" -lt 1024 ]; then
echo "[backup] ERROR: backup file is suspiciously small (${SIZE} bytes)" >&2
rm -f "$BACKUP_FILE"
exit 1
fi
echo "[backup] OK — $(echo "scale=1; $SIZE / 1048576" | bc 2>/dev/null || echo "$SIZE bytes")"
# Rotate: keep only the last $KEEP backups
COUNT=$(ls -1 "$BACKUP_DIR"/learnspace_*.db 2>/dev/null | wc -l)
if [ "$COUNT" -gt "$KEEP" ]; then
REMOVE=$((COUNT - KEEP))
ls -1t "$BACKUP_DIR"/learnspace_*.db | tail -n "$REMOVE" | while read -r f; do
echo "[backup] Removing old: $(basename "$f")"
rm -f "$f"
done
fi
echo "[backup] Done. Backups retained: $KEEP"
+295
View File
@@ -0,0 +1,295 @@
'use strict';
const db = require('./src/db/db');
const bcrypt = require('bcryptjs');
/* ─────────────────────────── helpers ────────────────────────────────── */
function daysAgo(d, h = 0, m = 0) {
const dt = new Date();
dt.setDate(dt.getDate() - d);
dt.setHours(dt.getHours() - h, dt.getMinutes() - m, 0, 0);
return dt.toISOString().replace('T', ' ').slice(0, 19);
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
/* ─────────────────────────── data ───────────────────────────────────── */
const PASS = bcrypt.hashSync('pass1234', 10);
const TEACHER = { name: 'Наталья Смирнова', email: 'teacher@school.by', role: 'teacher' };
const STUDENTS = [
{ name: 'Алексей Петров', email: 'petrov@school.by', skill: 0.85 },
{ name: 'Мария Иванова', email: 'ivanova@school.by', skill: 0.78 },
{ name: 'Дмитрий Соколов', email: 'sokolov@school.by', skill: 0.62 },
{ name: 'Анна Козлова', email: 'kozlova@school.by', skill: 0.91 },
{ name: 'Иван Новиков', email: 'novikov@school.by', skill: 0.55 },
{ name: 'Екатерина Морозова', email: 'morozova@school.by', skill: 0.73 },
{ name: 'Сергей Волков', email: 'volkov@school.by', skill: 0.40 },
{ name: 'Ольга Лебедева', email: 'lebedeva@school.by', skill: 0.82 },
{ name: 'Никита Зайцев', email: 'zajcev@school.by', skill: 0.48 },
{ name: 'Виктория Семёнова', email: 'semenova@school.by', skill: 0.69 },
{ name: 'Артём Павлов', email: 'pavlov@school.by', skill: 0.30, lazy: true },
{ name: 'Елена Фёдорова', email: 'fedorova@school.by', skill: 0.95 },
{ name: 'Максим Орлов', email: 'orlov@school.by', skill: 0.58 },
{ name: 'Юлия Попова', email: 'popova@school.by', skill: 0.76, lazy: true },
];
/* ─────────────────────────── insert users ───────────────────────────── */
const insertUser = db.prepare(
'INSERT OR IGNORE INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
);
const getUser = db.prepare('SELECT id FROM users WHERE email = ?');
insertUser.run(TEACHER.email, PASS, TEACHER.name, TEACHER.role);
const teacherId = getUser.get(TEACHER.email).id;
console.log(`✓ Teacher: ${TEACHER.name} (id=${teacherId})`);
const studentIds = [];
for (const s of STUDENTS) {
insertUser.run(s.email, PASS, s.name, 'student');
const id = getUser.get(s.email).id;
studentIds.push({ id, skill: s.skill, lazy: !!s.lazy, name: s.name });
}
console.log(`${STUDENTS.length} students inserted`);
/* ─────────────────────────── classes ────────────────────────────────── */
function genCode() {
return Math.random().toString(36).slice(2, 9).toUpperCase();
}
const insertClass = db.prepare(
'INSERT OR IGNORE INTO classes (name, description, teacher_id, invite_code) VALUES (?, ?, ?, ?)'
);
const getClassByName = db.prepare('SELECT id FROM classes WHERE name = ? AND teacher_id = ?');
const classData = [
{ name: '11А · Биология', desc: 'Подготовка к ЦТ 2026 по биологии', subject: 'bio', subjectId: 1 },
{ name: '10Б · Математика', desc: 'Алгебра и начала анализа', subject: 'math', subjectId: 3 },
];
const classes = [];
for (const c of classData) {
insertClass.run(c.name, c.desc, teacherId, genCode());
const row = getClassByName.get(c.name, teacherId);
classes.push({ ...c, id: row.id });
console.log(`✓ Class: "${c.name}" (id=${row.id})`);
}
/* ─────────────────────────── enroll students ────────────────────────── */
const insertMember = db.prepare(
'INSERT OR IGNORE INTO class_members (class_id, user_id, joined_at) VALUES (?, ?, ?)'
);
// Класс 1 (bio) — все студенты
for (const s of studentIds) {
insertMember.run(classes[0].id, s.id, daysAgo(rand(20, 40)));
}
// Класс 2 (math) — первые 10 студентов
for (const s of studentIds.slice(0, 10)) {
insertMember.run(classes[1].id, s.id, daysAgo(rand(15, 35)));
}
console.log('✓ Students enrolled in classes');
/* ─────────────────────────── load questions ─────────────────────────── */
const allQuestions = db.prepare(
'SELECT id, subject_id, type FROM questions WHERE type = ? OR type IS NULL'
).all('single');
const questionsBySubject = {};
for (const q of allQuestions) {
if (!questionsBySubject[q.subject_id]) questionsBySubject[q.subject_id] = [];
questionsBySubject[q.subject_id].push(q);
}
// Load all options grouped by question
const allOptions = db.prepare('SELECT id, question_id, is_correct FROM options').all();
const optionsByQuestion = {};
for (const o of allOptions) {
if (!optionsByQuestion[o.question_id]) optionsByQuestion[o.question_id] = [];
optionsByQuestion[o.question_id].push(o);
}
/* ─────────────────────────── simulate session ────────────────────────── */
const insertSession = db.prepare(
"INSERT INTO test_sessions (user_id, subject_id, mode, total, score, status, started_at, finished_at) VALUES (?, ?, ?, ?, ?, 'completed', ?, ?)"
);
const insertSQ = db.prepare(
'INSERT OR IGNORE INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'
);
const insertAnswer = db.prepare(
'INSERT OR IGNORE INTO user_answers (session_id, question_id, chosen_option_id, is_correct, time_spent_sec, answered_at) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertAssignSes = db.prepare(
'INSERT OR IGNORE INTO assignment_sessions (assignment_id, user_id, session_id) VALUES (?, ?, ?)'
);
function simulateSession(userId, subjectId, mode, count, skill, daysAgoN) {
const pool = shuffle(questionsBySubject[subjectId] || []).slice(0, count);
if (!pool.length) return null;
const startedAt = daysAgo(daysAgoN, rand(0, 6));
const finishedAt = daysAgo(daysAgoN, rand(0, 5), rand(5, 40));
let score = 0;
const info = insertSession.run(userId, subjectId, mode, pool.length, 0, startedAt, finishedAt);
const sessionId = info.lastInsertRowid;
pool.forEach((q, idx) => {
insertSQ.run(sessionId, q.id, idx);
const opts = optionsByQuestion[q.id] || [];
if (!opts.length) return;
const correct = opts.find(o => o.is_correct);
const wrong = opts.filter(o => !o.is_correct);
// skill = probability of answering correctly
const isCorrect = Math.random() < skill;
let chosen;
if (isCorrect && correct) {
chosen = correct;
} else {
chosen = wrong.length ? pick(wrong) : pick(opts);
}
const wasCorrect = chosen.is_correct ? 1 : 0;
if (wasCorrect) score++;
insertAnswer.run(sessionId, q.id, chosen.id, wasCorrect, rand(8, 95), finishedAt);
});
// Update score
db.prepare('UPDATE test_sessions SET score = ? WHERE id = ?').run(score, sessionId);
return sessionId;
}
/* ─────────────────────────── assignments ────────────────────────────── */
const insertAssign = db.prepare(`
INSERT OR IGNORE INTO assignments (class_id, title, subject_slug, mode, count, deadline, created_by, created_at)
VALUES (?, ?, ?, 'exam', ?, ?, ?, ?)
`);
const getAssign = db.prepare('SELECT id FROM assignments WHERE class_id = ? AND title = ?');
const assignmentDefs = [
// Bio class
{
classIdx: 0, title: 'Клетка и её строение', subjectId: 1, count: 15,
createdAgo: 28, deadlineAgo: 21, studentSkipChance: 0.0
},
{
classIdx: 0, title: 'Генетика — базовый уровень', subjectId: 1, count: 20,
createdAgo: 18, deadlineAgo: 11, studentSkipChance: 0.05
},
{
classIdx: 0, title: 'Контрольная: Митоз и мейоз', subjectId: 1, count: 25,
createdAgo: 10, deadlineAgo: 5, studentSkipChance: 0.10
},
{
classIdx: 0, title: 'Фотосинтез и дыхание', subjectId: 1, count: 15,
createdAgo: 3, deadlineAgo: null, studentSkipChance: 0.20
},
// Math class
{
classIdx: 1, title: 'Квадратные уравнения', subjectId: 3, count: 10,
createdAgo: 22, deadlineAgo: 16, studentSkipChance: 0.0
},
{
classIdx: 1, title: 'Тригонометрия — тест', subjectId: 3, count: 15,
createdAgo: 12, deadlineAgo: 7, studentSkipChance: 0.08
},
{
classIdx: 1, title: 'Логарифмы и степени', subjectId: 3, count: 15,
createdAgo: 4, deadlineAgo: null, studentSkipChance: 0.25
},
];
for (const a of assignmentDefs) {
const cls = classes[a.classIdx];
const deadline = a.deadlineAgo ? daysAgo(a.deadlineAgo) : null;
insertAssign.run(cls.id, a.title, cls.subject, a.count, deadline, teacherId, daysAgo(a.createdAgo));
const row = getAssign.get(cls.id, a.title);
if (!row) { console.warn(` ! Could not get assignment id for "${a.title}"`); continue; }
const assignId = row.id;
// Determine enrolled students for this class
const enrolled = a.classIdx === 0 ? studentIds : studentIds.slice(0, 10);
let done = 0;
for (const s of enrolled) {
// Lazy students skip recent assignments
if (s.lazy && a.studentSkipChance > 0.15) continue;
// Random skip by skip chance
if (Math.random() < a.studentSkipChance) continue;
const sessionId = simulateSession(
s.id, a.subjectId, 'exam', a.count, s.skill,
rand(a.deadlineAgo ? a.deadlineAgo : 1, a.createdAgo)
);
if (sessionId) {
insertAssignSes.run(assignId, s.id, sessionId);
done++;
}
}
console.log(`✓ Assignment "${a.title}" — ${done}/${enrolled.length} completed`);
}
/* ─────────────────────────── free-form sessions ─────────────────────── */
// Some students did extra practice on their own
const practiceSubjects = [[1, 'bio'], [3, 'math']];
for (const s of studentIds) {
if (s.lazy) continue;
const extraCount = rand(1, 4);
for (let i = 0; i < extraCount; i++) {
const [subId] = pick(practiceSubjects);
simulateSession(s.id, subId, 'practice', rand(10, 25), s.skill + 0.05, rand(1, 30));
}
}
console.log('✓ Extra practice sessions simulated');
/* ─────────────────────────── announcements ──────────────────────────── */
const insertAnn = db.prepare(
'INSERT INTO announcements (class_id, author_id, text, created_at) VALUES (?, ?, ?, ?)'
);
const annTexts = [
[0, '📅 Напоминаю: контрольная по митозу пройдёт в следующую пятницу. Повторите темы деления клетки.', 9],
[0, '✅ Результаты по теме «Клетка» проверены. Молодцы! Средний балл — 74%. Слабее всего — органоиды.', 20],
[0, '📚 Для подготовки к ЦТ рекомендую дополнительно пройти тест по фотосинтезу в системе.', 5],
[1, '🧮 Разбор ошибок по квадратным уравнениям будет в среду, 14:00, кабинет 204.', 11],
[1, '⚠️ Дедлайн по логарифмам — в пятницу. Кто не сдал тригонометрию — сдайте до конца недели.', 3],
];
for (const [classIdx, text, ago] of annTexts) {
insertAnn.run(classes[classIdx].id, teacherId, text, daysAgo(ago));
}
console.log('✓ Announcements created');
/* ─────────────────────────── notifications ──────────────────────────── */
const insertNotif = db.prepare(
'INSERT INTO notifications (user_id, type, message, link, is_read, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
// Teacher gets notifs about students completing assignments
for (const s of studentIds.slice(0, 6)) {
insertNotif.run(teacherId, 'session',
`«${s.name}» сдал «Генетика — базовый уровень» — ${rand(55, 95)}%`,
'/classes.html', 1, daysAgo(rand(5, 15)));
}
// Students get assignment notifs
for (const s of studentIds) {
insertNotif.run(s.id, 'assignment', '📋 Для вас задание: «Контрольная: Митоз и мейоз»', '/dashboard.html', 1, daysAgo(10));
insertNotif.run(s.id, 'assignment', '📋 Для вас задание: «Фотосинтез и дыхание»', '/dashboard.html', rand(0,1), daysAgo(3));
}
console.log('✓ Notifications created');
/* ─────────────────────────── summary ────────────────────────────────── */
const stats = {
users: db.prepare('SELECT COUNT(*) as n FROM users').get().n,
sessions: db.prepare('SELECT COUNT(*) as n FROM test_sessions').get().n,
answers: db.prepare('SELECT COUNT(*) as n FROM user_answers').get().n,
assigns: db.prepare('SELECT COUNT(*) as n FROM assignments').get().n,
};
console.log('\n═══ Seed complete ═══');
console.log(` Users: ${stats.users}`);
console.log(` Sessions: ${stats.sessions}`);
console.log(` Answers: ${stats.answers}`);
console.log(` Assignments: ${stats.assigns}`);
console.log('\n Password for all accounts: pass1234');
console.log(` Teacher: ${TEACHER.email}`);
console.log(` Students: ${STUDENTS[0].email}${STUDENTS[STUDENTS.length-1].email}`);
+65
View File
@@ -0,0 +1,65 @@
'use strict';
/* ── Environment configuration & startup validation ───────────────────────
Require this module BEFORE anything else that reads process.env.
Fails fast with a clear message if required vars are missing or weak.
──────────────────────────────────────────────────────────────────────── */
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
const path = require('path');
const errors = [];
function _require(key, { minLen, notValue } = {}) {
const val = process.env[key];
if (!val) { errors.push(`${key} is required`); return undefined; }
if (minLen && val.length < minLen) errors.push(`${key} must be at least ${minLen} chars (got ${val.length})`);
if (notValue && val === notValue) errors.push(`${key} must not be the default placeholder value`);
return val;
}
function _optional(key, defaultVal = undefined) {
return process.env[key] || defaultVal;
}
/* ── Required vars ────────────────────────────────────────────────────── */
const JWT_SECRET = _require('JWT_SECRET', {
minLen: 32,
notValue: 'change_this_to_a_long_random_string',
});
/* ── Optional vars with defaults ─────────────────────────────────────── */
const NODE_ENV = _optional('NODE_ENV', 'development');
if (!['development', 'production', 'test'].includes(NODE_ENV)) {
errors.push(`NODE_ENV must be development | production | test (got: "${NODE_ENV}")`);
}
const PORT_RAW = _optional('PORT', '3000');
const PORT = Number(PORT_RAW);
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
errors.push(`PORT must be an integer between 1 and 65535 (got: "${PORT_RAW}")`);
}
/* ── Fail fast ───────────────────────────────────────────────────────── */
if (errors.length) {
process.stderr.write('\n[config] FATAL: invalid environment configuration:\n');
errors.forEach(e => process.stderr.write(`${e}\n`));
process.stderr.write('\nFix these issues in your .env file and restart.\n\n');
process.exit(1);
}
module.exports = Object.freeze({
/* env */
JWT_SECRET,
PORT,
NODE_ENV,
isProd: NODE_ENV === 'production',
LOG_LEVEL: _optional('LOG_LEVEL', NODE_ENV === 'production' ? 'info' : 'debug'),
CLIENT_ORIGIN: _optional('CLIENT_ORIGIN'),
/* paths */
DB_PATH: _optional('DB_PATH', path.join(__dirname, '../data/learnspace.db')),
UPLOADS_DIR: _optional('UPLOADS_DIR', path.join(__dirname, '../uploads')),
/* constants */
BCRYPT_ROUNDS: 12,
MAX_FILE_SIZE: 50 * 1024 * 1024,
});
+40
View File
@@ -0,0 +1,40 @@
/* ── LearnSpace shared constants ───────────────────────────────────────── */
exports.SESSION_MODES = new Set(['exam', 'practice', 'repeat', 'ct', 'topic', 'random']);
exports.SESSION_STATUS = {
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
ABANDONED: 'abandoned',
};
exports.ROLES = {
ADMIN: 'admin',
TEACHER: 'teacher',
STUDENT: 'student',
};
exports.NOTIFICATION_TYPES = {
ASSIGNMENT: 'assignment',
SESSION: 'session',
ACHIEVEMENT: 'achievement',
SYSTEM: 'system',
JOIN: 'join',
SUBMISSION: 'submission',
CHALLENGE: 'challenge',
DAILY_GOAL: 'daily_goal',
};
exports.SUBJECTS = {
BIO: 'bio',
CHEM: 'chem',
MATH: 'math',
PHYS: 'phys',
};
// Combo bonus XP thresholds: [minCombo, bonusXP]
exports.COMBO_BONUSES = [
[10, 75],
[5, 30],
[3, 15],
];
+548
View File
@@ -0,0 +1,548 @@
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit');
/* ── Prepared statements ──────────────────────────────────────────────── */
const stmts = {
totalUsers: db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'student'"),
totalTests: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE status = 'completed'"),
avgScore: db.prepare(
"SELECT ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg FROM test_sessions WHERE status = 'completed'"
),
bySubject: db.prepare(`
SELECT s.name, s.slug,
COUNT(ts.id) AS tests,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM test_sessions ts
JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
GROUP BY s.id ORDER BY s.id
`),
};
/* ── GET /api/admin/stats ─────────────────────────────────────────────── */
function getStats(_req, res) {
res.json({
totalUsers: stmts.totalUsers.get().n,
totalTests: stmts.totalTests.get().n,
avgScore: stmts.avgScore.get().avg,
bySubject: stmts.bySubject.all(),
});
}
/* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */
function getUsers(req, res) {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
const cursor = Number(req.query.cursor) || 0;
const role = req.query.role;
const search = req.query.q?.trim();
let where = 'WHERE 1=1';
const args = [];
if (role) { where += ' AND u.role = ?'; args.push(role); }
if (search) { where += ' AND (u.name LIKE ? OR u.email LIKE ?)'; args.push(`%${search}%`, `%${search}%`); }
// Cursor-based pagination
if (cursor) {
const cursorWhere = where + ' AND u.id < ?';
const cursorArgs = [...args, cursor];
const users = db.prepare(`
SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned,
COUNT(ts.id) AS tests_count,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM users u
LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed'
${cursorWhere}
GROUP BY u.id
ORDER BY u.id DESC
LIMIT ?
`).all(...cursorArgs, limit);
const nextCursor = users.length === limit ? users[users.length - 1].id : null;
return res.json({ users, nextCursor, limit });
}
// Offset-based (legacy)
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM users u ${where}`).get(...args);
const users = db.prepare(`
SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned,
COUNT(ts.id) AS tests_count,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM users u
LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed'
${where}
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
`).all(...args, limit, offset);
res.json({ users, total, page, limit });
}
/* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */
function updateRole(req, res) {
const { role } = req.body;
const allowed = ['student', 'teacher', 'admin', 'free_student'];
if (!allowed.includes(role))
return res.status(400).json({ error: `role must be one of: ${allowed.join(', ')}` });
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (Number(req.params.id) === req.user.id)
return res.status(400).json({ error: 'Cannot change your own role' });
const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role;
db.prepare('UPDATE users SET role = ?, token_version = token_version + 1 WHERE id = ?').run(role, req.params.id);
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${role}`);
res.json({ id: Number(req.params.id), role });
}
/* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */
function getUserSessions(req, res) {
const user = db.prepare('SELECT id, name, email, role, is_banned FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const sessions = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.started_at DESC
LIMIT 100
`).all(req.params.id);
res.json({ user, sessions });
}
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
function getAllSessions(req, res) {
const { subject, user_id } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
const offset = Math.max(0, Number(req.query.offset) || 0);
const where = ['ts.status = \'completed\''];
const params = [];
if (subject) { where.push('s.slug = ?'); params.push(subject); }
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
params.push(limit, offset);
const sessions = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
WHERE ${where.join(' AND ')}
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(...params);
res.json(sessions);
}
/* ── GET /api/admin/sessions/:id ─────────────────────────────────────── */
function getSessionDetail(req, res) {
const session = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
WHERE ts.id = ?
`).get(req.params.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const questions = db.prepare(`
SELECT q.id, q.text, q.explanation, q.difficulty,
ua.chosen_option_id, ua.is_correct, ua.time_spent_sec,
sq.order_index
FROM session_questions sq
JOIN questions q ON q.id = sq.question_id
LEFT JOIN user_answers ua ON ua.session_id = sq.session_id AND ua.question_id = q.id
WHERE sq.session_id = ?
ORDER BY sq.order_index
`).all(req.params.id);
if (questions.length) {
const ids = questions.map(q => q.id);
const allOptions = db.prepare(
`SELECT id, question_id, text, is_correct FROM options WHERE question_id IN (${ids.map(() => '?').join(',')}) ORDER BY order_index`
).all(...ids);
const byQ = {};
for (const o of allOptions) {
(byQ[o.question_id] ||= []).push(o);
}
session.questions = questions.map(q => ({ ...q, options: byQ[q.id] || [] }));
} else {
session.questions = [];
}
res.json(session);
}
/* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */
function clearUserSessions(req, res, next) {
const uid = Number(req.params.id);
try {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid);
if (!user) return res.status(404).json({ error: 'User not found' });
const sessions = db.prepare('SELECT id FROM test_sessions WHERE user_id = ?').all(uid);
const n = sessions.length;
if (n > 0) {
const stmtNullAsgn = db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?');
const stmtDelAns = db.prepare('DELETE FROM user_answers WHERE session_id = ?');
const stmtDelSQ = db.prepare('DELETE FROM session_questions WHERE session_id = ?');
const stmtDelSess = db.prepare('DELETE FROM test_sessions WHERE id = ?');
db.transaction(() => {
for (const { id } of sessions) {
stmtNullAsgn.run(id);
stmtDelAns.run(id);
stmtDelSQ.run(id);
stmtDelSess.run(id);
}
})();
}
audit(req, 'user.clear_sessions', `user:${uid}`, `${n} sessions deleted`);
res.json({ ok: true, deleted: n });
} catch (err) {
next(err);
}
}
/* ── PATCH /api/admin/users/:id ──────────────────────────────────────── */
const bcrypt = require('bcryptjs');
const { BCRYPT_ROUNDS } = require('../config');
async function updateUser(req, res, next) {
try {
const { name, email, password } = req.body;
const uid = Number(req.params.id);
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid);
if (!user) return res.status(404).json({ error: 'User not found' });
if (uid === req.user.id)
return res.status(400).json({ error: 'Cannot edit your own data here' });
if (name !== undefined && !name?.trim())
return res.status(400).json({ error: 'Name cannot be empty' });
if (email !== undefined) {
if (!email?.trim() || !/\S+@\S+\.\S+/.test(email.trim()))
return res.status(400).json({ error: 'Invalid email format' });
const existing = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.trim().toLowerCase(), uid);
if (existing) return res.status(400).json({ error: 'Email already in use' });
}
if (password !== undefined && password.length < 6)
return res.status(400).json({ error: 'Password must be at least 6 characters' });
const fields = [];
const vals = [];
if (name !== undefined) { fields.push('name = ?'); vals.push(stripTags(name.trim())); }
if (email !== undefined) { fields.push('email = ?'); vals.push(email.trim().toLowerCase()); }
if (password !== undefined) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
fields.push('password_hash = ?', 'token_version = token_version + 1');
vals.push(hash);
}
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(uid);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
const changed = [];
if (name !== undefined) changed.push('name');
if (email !== undefined) changed.push('email');
if (password !== undefined) changed.push('password');
audit(req, 'user.edit', `user:${uid}`, changed.join(', '));
res.json({ ok: true });
} catch (err) {
next(err);
}
}
/* ── PATCH /api/admin/users/:id/ban { banned: true|false } ──────────── */
function banUser(req, res) {
const uid = Number(req.params.id);
if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя заблокировать себя' });
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя заблокировать администратора' });
const banned = req.body.banned ? 1 : 0;
db.prepare('UPDATE users SET is_banned = ?, token_version = token_version + 1 WHERE id = ?').run(banned, uid);
audit(req, banned ? 'user.ban' : 'user.unban', `user:${uid}`, target.role);
res.json({ ok: true, banned: !!banned });
}
/* ── DELETE /api/admin/users/:id ─────────────────────────────────────── */
const _deleteUserTx = db.transaction((uid) => {
// Tables with REFERENCES users(id) WITHOUT ON DELETE CASCADE:
db.prepare('DELETE FROM questions WHERE created_by = ?').run(uid);
db.prepare('DELETE FROM assignments WHERE created_by = ?').run(uid);
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
function deleteUser(req, res) {
const uid = Number(req.params.id);
if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя удалить себя' });
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя удалить администратора' });
const uname = db.prepare('SELECT name, email FROM users WHERE id = ?').get(uid);
_deleteUserTx(uid);
audit(req, 'user.delete', `user:${uid}`, `${uname?.name} (${uname?.email})`);
res.json({ ok: true });
}
/* ── GET /api/admin/features ─────────────────────────────────────────── */
function getFeatures(_req, res) {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all();
const features = {};
for (const r of rows) {
const name = r.key.replace('feature_', '').replace('_enabled', '');
features[name] = r.value === '1';
}
res.json(features);
}
/* ── PATCH /api/admin/features ──────────────────────────────────────── */
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const changed = [];
for (const [name, enabled] of Object.entries(updates)) {
if (!allowed.includes(name)) continue;
stmt.run(`feature_${name}_enabled`, enabled ? '1' : '0');
changed.push(`${name}=${enabled ? 'on' : 'off'}`);
}
if (changed.length) audit(req, 'features.update', null, changed.join(', '));
res.json({ ok: true });
}
/* ── GET /api/admin/free-student-features ────────────────────────────── */
const FREE_STUDENT_MODULES = [
'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection',
'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz',
];
function getFreeStudentFeatures(_req, res) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
let features = {};
if (row?.value) {
try { features = JSON.parse(row.value); } catch {}
}
// Default: all enabled
const result = {};
for (const m of FREE_STUDENT_MODULES) {
result[m] = features[m] !== false;
}
res.json(result);
}
/* ── PATCH /api/admin/free-student-features ─────────────────────────── */
function updateFreeStudentFeatures(req, res) {
const updates = req.body;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
let current = {};
if (row?.value) {
try { current = JSON.parse(row.value); } catch {}
}
for (const [key, val] of Object.entries(updates)) {
if (!FREE_STUDENT_MODULES.includes(key)) continue;
current[key] = Boolean(val);
}
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('free_student_features', ?)").run(JSON.stringify(current));
res.json({ ok: true });
}
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
function getAuditLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const rows = db.prepare(`
SELECT al.*, u.name AS admin_name, u.email AS admin_email
FROM admin_audit_log al
LEFT JOIN users u ON u.id = al.admin_id
ORDER BY al.created_at DESC LIMIT ?
`).all(limit);
res.json(rows);
}
/* ── DELETE /api/admin/audit-log ────────────────────────────────────── */
function clearAuditLog(req, res) {
db.prepare('DELETE FROM admin_audit_log').run();
res.json({ ok: true });
}
/* ── GET /api/admin/error-log ──────────────────────────────────────── */
function getErrorLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const rows = db.prepare(`
SELECT * FROM error_log ORDER BY created_at DESC LIMIT ?
`).all(limit);
res.json(rows);
}
/* ── DELETE /api/admin/error-log ───────────────────────────────────── */
function clearErrorLog(req, res) {
db.prepare('DELETE FROM error_log').run();
res.json({ ok: true });
}
/* ── GET /api/admin/health ─────────────────────────────────────────── */
const os = require('os');
const path = require('path');
const fs = require('fs');
const { DB_PATH, UPLOADS_DIR } = require('../config');
function getHealth(_req, res) {
const uptimeSec = process.uptime();
let dbSizeBytes = 0;
try { dbSizeBytes = fs.statSync(DB_PATH).size; } catch {}
let uploadsSizeBytes = 0;
try {
const files = fs.readdirSync(UPLOADS_DIR);
for (const f of files) {
try { uploadsSizeBytes += fs.statSync(path.join(UPLOADS_DIR, f)).size; } catch {}
}
} catch {}
const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n;
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n;
const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n;
const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n;
res.json({
uptime: uptimeSec,
memory: {
rss: process.memoryUsage().rss,
heapUsed: process.memoryUsage().heapUsed,
},
db: {
sizeBytes: dbSizeBytes,
totalUsers,
totalSessions,
todaySessions,
totalQuestions,
},
uploads: {
sizeBytes: uploadsSizeBytes,
},
node: process.version,
platform: os.platform(),
cpus: os.cpus().length,
freeMem: os.freemem(),
totalMem: os.totalmem(),
recentErrors,
});
}
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
function getTopics(req, res) {
const { subject_id } = req.query;
let rows;
if (subject_id) {
rows = db.prepare(`
SELECT t.*, s.name AS subject_name, s.slug AS subject_slug,
(SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count
FROM topics t JOIN subjects s ON s.id = t.subject_id
WHERE t.subject_id = ? ORDER BY t.order_index, t.name
`).all(Number(subject_id));
} else {
rows = db.prepare(`
SELECT t.*, s.name AS subject_name, s.slug AS subject_slug,
(SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count
FROM topics t JOIN subjects s ON s.id = t.subject_id
ORDER BY s.name, t.order_index, t.name
`).all();
}
res.json(rows);
}
function createTopic(req, res) {
const { subject_id, name } = req.body;
if (!subject_id || !name?.trim())
return res.status(400).json({ error: 'subject_id and name required' });
const subj = db.prepare('SELECT id FROM subjects WHERE id = ?').get(Number(subject_id));
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const maxOrder = db.prepare('SELECT MAX(order_index) AS m FROM topics WHERE subject_id = ?').get(Number(subject_id));
const order = (maxOrder?.m || 0) + 1;
const r = db.prepare('INSERT INTO topics (subject_id, name, order_index) VALUES (?, ?, ?)').run(Number(subject_id), stripTags(name.trim()), order);
audit(req, 'topic.create', `topic:${r.lastInsertRowid}`, name.trim());
res.status(201).json({ id: r.lastInsertRowid, name: name.trim(), order_index: order });
}
function updateTopic(req, res) {
const id = Number(req.params.id);
const topic = db.prepare('SELECT * FROM topics WHERE id = ?').get(id);
if (!topic) return res.status(404).json({ error: 'Topic not found' });
const { name, order_index } = req.body;
const newName = name !== undefined ? stripTags(name.trim()) : topic.name;
const newOrder = order_index !== undefined ? Number(order_index) : topic.order_index;
if (!newName) return res.status(400).json({ error: 'Name cannot be empty' });
db.prepare('UPDATE topics SET name = ?, order_index = ? WHERE id = ?').run(newName, newOrder, id);
audit(req, 'topic.update', `topic:${id}`, newName);
res.json({ ok: true });
}
function deleteTopic(req, res) {
const id = Number(req.params.id);
const topic = db.prepare('SELECT t.*, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS qcount FROM topics t WHERE t.id = ?').get(id);
if (!topic) return res.status(404).json({ error: 'Topic not found' });
if (topic.qcount > 0)
return res.status(400).json({ error: `Cannot delete topic with ${topic.qcount} questions. Reassign questions first.` });
db.prepare('DELETE FROM topics WHERE id = ?').run(id);
audit(req, 'topic.delete', `topic:${id}`, topic.name);
res.json({ ok: true });
}
/* ── Broadcast notification ──────────────────────────────────────────── */
const { pushNotif } = require('../utils/notifications');
function broadcast(req, res) {
const { message, role, link } = req.body;
if (!message?.trim()) return res.status(400).json({ error: 'message required' });
const msg = stripTags(message.trim()).slice(0, 500);
const lnk = link?.trim() || null;
let users;
if (role && role !== 'all') {
users = db.prepare('SELECT id FROM users WHERE role = ? AND is_banned = 0').all(role);
} else {
users = db.prepare('SELECT id FROM users WHERE is_banned = 0').all();
}
const ins = db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'broadcast', ?, ?)");
db.transaction(() => {
for (const u of users) ins.run(u.id, msg, lnk);
})();
audit(req, 'broadcast', role || 'all', `${users.length} users: ${msg.slice(0, 80)}`);
res.json({ ok: true, sent: users.length });
}
module.exports = {
getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
};
@@ -0,0 +1,80 @@
const db = require('../db/db');
function teacherOverview(req, res) {
const user = req.user;
const classId = req.query.classId ? Number(req.query.classId) : null;
const classes = user.role === 'admin'
? db.prepare('SELECT id, name FROM classes ORDER BY name').all()
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(user.id);
if (!classId) return res.json({ classes, data: null });
if (user.role !== 'admin') {
const cls = db.prepare('SELECT id FROM classes WHERE id = ? AND teacher_id = ?').get(classId, user.id);
if (!cls) return res.status(403).json({ error: 'Forbidden' });
}
const overview = db.prepare(`
SELECT
COUNT(DISTINCT cm.user_id) AS students,
COUNT(DISTINCT CASE WHEN ts.status='completed' THEN ts.id END) AS sessions,
ROUND(AVG(CASE WHEN ts.status='completed' AND ts.total>0
THEN ts.score*100.0/ts.total END), 1) AS avgScore
FROM class_members cm
LEFT JOIN test_sessions ts ON ts.user_id = cm.user_id
WHERE cm.class_id = ?
`).get(classId);
const scoreByWeek = db.prepare(`
SELECT
strftime('%Y-W%W', ts.finished_at) AS week,
ROUND(AVG(ts.score*100.0/ts.total), 1) AS avg,
COUNT(*) AS sessions
FROM test_sessions ts
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
WHERE ts.status='completed' AND ts.total>0
AND ts.finished_at >= datetime('now','-56 days')
GROUP BY week ORDER BY week
`).all(classId);
const hardQuestions = db.prepare(`
SELECT q.id, q.text, q.difficulty,
COUNT(ua.id) AS attempts,
ROUND(SUM(CASE WHEN ua.is_correct=0 THEN 1.0 ELSE 0 END)*100/COUNT(ua.id),1) AS errorRate,
t.name AS topic
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
JOIN test_sessions ts ON ts.id = ua.session_id
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
LEFT JOIN topics t ON t.id = q.topic_id
WHERE ts.status='completed'
GROUP BY q.id HAVING attempts >= 3
ORDER BY errorRate DESC LIMIT 10
`).all(classId);
const heatmap = db.prepare(`
SELECT date(ts.started_at) AS day,
COUNT(DISTINCT ts.user_id) AS students,
COUNT(ts.id) AS sessions
FROM test_sessions ts
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
WHERE ts.started_at >= datetime('now','-90 days')
GROUP BY day ORDER BY day
`).all(classId);
const assignments = db.prepare(`
SELECT a.id, a.title, a.deadline,
COUNT(DISTINCT cm.user_id) AS total,
COUNT(DISTINCT CASE WHEN ass.session_id IS NOT NULL THEN ass.user_id END) AS done
FROM assignments a
JOIN class_members cm ON cm.class_id = a.class_id
LEFT JOIN assignment_sessions ass ON ass.assignment_id=a.id AND ass.user_id=cm.user_id
WHERE a.class_id = ?
GROUP BY a.id ORDER BY a.created_at DESC LIMIT 10
`).all(classId);
res.json({ classes, overview, scoreByWeek, hardQuestions, heatmap, assignments });
}
module.exports = { teacherOverview };
@@ -0,0 +1,652 @@
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants');
const VALID_ASSIGN_MODES = SESSION_MODES;
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
getTestSubject: db.prepare('SELECT subject_slug FROM tests WHERE id = ?'),
getFileSubject: db.prepare('SELECT subject_slug FROM files WHERE id = ?'),
getClass: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
getSubjectBySlug: db.prepare('SELECT id FROM subjects WHERE slug = ?'),
countCompletedSess: db.prepare(`
SELECT COUNT(*) AS n FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = ? AND ax.user_id = ?
`),
getInProgressSess: db.prepare(`
SELECT ax.session_id FROM assignment_sessions ax
JOIN test_sessions ts ON ts.id = ax.session_id AND ts.status = 'in_progress'
WHERE ax.assignment_id = ? AND ax.user_id = ?
ORDER BY ax.id DESC LIMIT 1
`),
insertSession: db.prepare('INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'),
insertSessionQ: db.prepare('INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'),
insertAssignSess: db.prepare('INSERT INTO assignment_sessions (assignment_id, user_id, session_id, attempt_num) VALUES (?, ?, ?, ?)'),
notifyClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ? AND user_id != ?'),
};
/* ── POST /api/classes/:id/assignments ── create assignment ─────────────── */
function createAssignment(req, res) {
const { title, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
const max_attempts = Math.max(0, Math.min(10, Number(req.body.max_attempts) || 0));
let { subject_slug } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
const cleanTitle = stripTags(title.trim());
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
if (test_id) {
const t = stmts.getTestSubject.get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = stmts.getFileSubject.get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
// Upload-only homework doesn't require subject
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const cls = stmts.getClass.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, max_attempts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, cleanTitle, subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0, max_attempts);
// Уведомления всем членам класса (batch via transaction)
const members = stmts.getClassMembers.all(cls.id);
const notifMsg = `Новое задание: «${cleanTitle}»`;
const insertNotif = db.transaction(() => {
members.forEach(m => pushNotif(m.user_id, 'assignment', notifMsg, '/dashboard'));
});
insertNotif();
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/assignments/:id ── update ───────────────────────────────── */
function updateAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, mode, count, deadline } = req.body;
let { subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
db.prepare(`
UPDATE assignments SET title = ?, subject_slug = ?, mode = ?, count = ?, deadline = ?, test_id = ? WHERE id = ?
`).run(stripTags(title.trim()), subject_slug, mode || 'exam', Number(count) || 25, deadline || null, test_id, req.params.id);
res.json({ ok: true });
}
/* ── DELETE /api/assignments/:id ──────────────────────────────────────── */
function deleteAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
res.json({ ok: true });
}
/* ── GET /api/assignments/teacher ── teacher: own created assignments ──── */
function teacherAssignments(req, res) {
const isAdmin = req.user.role === 'admin';
// Админ видит все задания без ограничений
if (isAdmin) {
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
GROUP BY a.id
ORDER BY a.created_at DESC
`).all();
return res.json(rows);
}
// Учитель видит только свои задания:
// - классовые (любые свои)
// - личные — только для учеников из своих классов
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.created_by = ?
AND (
a.class_id IS NOT NULL
OR (
a.user_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM class_members cm2
JOIN classes c2 ON c2.id = cm2.class_id
WHERE cm2.user_id = a.user_id AND c2.teacher_id = ?
)
)
)
GROUP BY a.id
ORDER BY a.created_at DESC
`).all(req.user.id, req.user.id);
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = cm.user_id) AS attempts_used
FROM class_members cm
JOIN classes c ON c.id = cm.class_id
JOIN users u ON u.id = c.teacher_id
JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE cm.user_id = ?
UNION ALL
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = ?) AS attempts_used
FROM assignments a
JOIN users u ON u.id = a.created_by
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ?
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE a.user_id = ?
) ORDER BY done ASC, deadline ASC, created_at DESC
`).all(uid, uid, uid, uid, uid);
res.json(rows);
}
/* ── POST /api/assignments/:id/start ── student starts session ─────────── */
function startAssignment(req, res) {
const uid = req.user.id;
const assignment = db.prepare(`
SELECT a.* FROM assignments a
WHERE a.id = ?
AND (
(a.class_id IS NOT NULL AND EXISTS (
SELECT 1 FROM class_members WHERE class_id = a.class_id AND user_id = ?
))
OR a.user_id = ?
)
`).get(req.params.id, uid, uid);
if (!assignment) return res.status(404).json({ error: 'Assignment not found or not your class' });
// Deadline check: reject if deadline has already passed
if (assignment.deadline) {
const dl = new Date(assignment.deadline.includes('T') ? assignment.deadline : assignment.deadline.replace(' ', 'T') + 'Z');
if (dl < new Date()) return res.status(403).json({ error: 'Срок выполнения задания истёк' });
}
// File-only assignment: just return the download URL, no session needed
if (assignment.file_id && !assignment.test_id) {
return res.json({ is_file: true, file_id: assignment.file_id });
}
// assignment mode → session mode mapping
const SESSION_MODE = { exam: 'exam', practice: 'practice', repeat: 'practice', ct: 'exam' };
const sessionMode = SESSION_MODE[assignment.mode] || 'exam';
// Count completed attempts for this student
const completedCount = stmts.countCompletedSess.get(req.params.id, uid).n;
// Check attempt limit
const maxAttempts = assignment.max_attempts || 0;
if (maxAttempts > 0 && completedCount >= maxAttempts) {
return res.status(403).json({
error: 'Исчерпан лимит попыток',
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
// Check for an existing in-progress session
const inProgress = stmts.getInProgressSess.get(req.params.id, uid);
if (inProgress?.session_id) {
return res.json({
session_id: inProgress.session_id,
already_started: true,
status: 'in_progress',
assignment_mode: assignment.mode,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
const subject = stmts.getSubjectBySlug.get(assignment.subject_slug);
if (!subject) return res.status(400).json({ error: 'Invalid subject' });
let questionIds;
if (assignment.test_id) {
// Use exact questions from the pre-made test (in defined order)
questionIds = db.prepare(
'SELECT question_id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(assignment.test_id).map(r => r.question_id);
} else if (assignment.mode === 'ct') {
// CT mode: Part A (single/true_false) first, then Part B (multi/short_answer)
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
const half = Math.ceil(assignment.count / 2);
const partA = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('single','true_false') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, half).map(q => q.id);
const partB = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('multi','short_answer') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count - partA.length).map(q => q.id);
const got = partA.length + partB.length;
const usedIds = [...partA, ...partB];
const extra = (got < assignment.count && usedIds.length > 0)
? db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND id NOT IN (${usedIds.map(() => '?').join(',')}) ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, ...usedIds, assignment.count - got).map(q => q.id)
: [];
questionIds = [...partA, ...partB, ...extra];
} else {
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
questionIds = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count).map(q => q.id);
}
if (!questionIds.length) return res.status(400).json({ error: 'No questions available' });
const session_id = db.transaction(() => {
const { lastInsertRowid: sid } = stmts.insertSession.run(uid, subject.id, sessionMode, questionIds.length);
questionIds.forEach((qid, i) => stmts.insertSessionQ.run(sid, qid, i));
stmts.insertAssignSess.run(req.params.id, uid, sid, completedCount + 1);
return sid;
})();
res.json({
session_id,
assignment_mode: assignment.mode,
attempt_num: completedCount + 1,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
/* ── GET /api/assignments/:id/results ── teacher view ──────────────────── */
function assignmentResults(req, res) {
const a = db.prepare(`
SELECT a.*, COALESCE(c.teacher_id, a.created_by) AS teacher_id, c.name AS class_name
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
let results;
if (a.user_id) {
// Direct assignment: single student — pick best attempt
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM users u
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = u.id AND best.rn = 1
WHERE u.id = ?
`).all(req.params.id, a.user_id);
} else {
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM class_members cm
JOIN users u ON u.id = cm.user_id
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = cm.user_id AND best.rn = 1
WHERE cm.class_id = ?
ORDER BY
CASE WHEN best.percent IS NULL THEN 1 ELSE 0 END,
best.percent DESC, u.name
`).all(req.params.id, a.class_id);
}
res.json({ assignment: a, results });
}
/* ── GET /api/assignments/:id/question-stats ── per-question error rates ── */
function assignmentQuestionStats(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id, a.class_id, a.user_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// All completed sessions for this assignment
const sessions = db.prepare(`
SELECT ases.session_id FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ? AND ts.status = 'completed'
`).all(req.params.id);
if (!sessions.length) return res.json({ stats: [] });
const sessionIds = sessions.map(s => s.session_id);
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT q.id AS question_id,
q.text AS question_text,
q.type,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
WHERE ua.session_id IN (${placeholders})
GROUP BY ua.question_id
ORDER BY error_pct DESC, wrong DESC
`).all(...sessionIds);
res.json({ stats: rows, session_count: sessionIds.length });
}
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
function createDirectAssignment(req, res) {
const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
let { title, subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
let student;
if (student_id) {
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' });
student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'")
.get(student_email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
// Учитель может выдать личное задание только ученику из своего класса
if (req.user.role === 'teacher') {
const inClass = db.prepare(`
SELECT 1 FROM class_members cm
JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
`).get(student.id, req.user.id);
if (!inClass) return res.status(403).json({ error: 'Ученик не входит ни в один из ваших классов' });
}
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const r = db.prepare(`
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0);
// Уведомление ученику
pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard');
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/assignments/:id/sessions/:session_id/review ── teacher view ── */
function assignmentSessionReview(req, res) {
const assignmentId = Number(req.params.id);
const sessionId = Number(req.params.session_id);
// Verify assignment ownership
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(assignmentId);
if (!a) return res.status(404).json({ error: 'Assignment not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// Verify session is linked to this assignment
const link = db.prepare(
'SELECT 1 FROM assignment_sessions WHERE assignment_id = ? AND session_id = ?'
).get(assignmentId, sessionId);
if (!link) return res.status(404).json({ error: 'Session not linked to this assignment' });
const session = db.prepare('SELECT score, total, status FROM test_sessions WHERE id = ?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Build per-question review — batched
const questionIds = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(sessionId).map(r => r.question_id);
if (!questionIds.length) return res.json({ session_id: sessionId, score: session.score, total: session.total, review: [] });
const qPh = questionIds.map(() => '?').join(',');
const questions = db.prepare(`SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${qPh})`).all(...questionIds);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
const allOptions = db.prepare(`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${qPh}) ORDER BY order_index`).all(...questionIds);
const optMap = {};
for (const o of allOptions) { if (!optMap[o.question_id]) optMap[o.question_id] = []; optMap[o.question_id].push(o); }
const allAnswers = db.prepare(`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${qPh})`).all(sessionId, ...questionIds);
const ansMap = {};
for (const a of allAnswers) ansMap[a.question_id] = a;
const review = questionIds.map(qid => {
const q = qMap[qid];
if (!q) return null;
q.options = optMap[qid] || [];
const ua = ansMap[qid];
q.chosen_option_id = ua?.chosen_option_id ?? null;
q.answer_text = ua?.answer_text ?? null;
q.is_correct = ua ? ua.is_correct === 1 : null;
return q;
}).filter(Boolean);
res.json({ session_id: sessionId, score: session.score, total: session.total, review });
}
/* ── GET /api/assignments/templates ── list my templates ───────────────── */
function listTemplates(req, res) {
const rows = db.prepare(`
SELECT id, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework, created_at
FROM assignment_templates WHERE created_by = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json(rows);
}
/* ── POST /api/assignments/templates ── save template ──────────────────── */
function saveTemplate(req, res) {
const { label, subject_slug, mode = 'exam', count = 25, topic_id, test_id, file_id, is_homework = 0 } = req.body;
if (!label?.trim()) return res.status(400).json({ error: 'label required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const r = db.prepare(`
INSERT INTO assignment_templates (created_by, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, label.trim(), subject_slug, mode, Number(count), topic_id || null, test_id || null, file_id || null, is_homework ? 1 : 0);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── DELETE /api/assignments/templates/:id ─────────────────────────────── */
function deleteTemplate(req, res) {
db.prepare('DELETE FROM assignment_templates WHERE id = ? AND created_by = ?')
.run(req.params.id, req.user.id);
res.json({ ok: true });
}
/* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
function bulkCreateAssignment(req, res) {
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
let { subject_slug } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length)
return res.status(400).json({ error: 'class_ids[] required' });
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'invalid mode' });
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const created = db.transaction(() => {
const ids = [];
for (const class_id of class_ids) {
const cls = db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?').get(class_id);
if (!cls) continue;
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0);
ids.push(r.lastInsertRowid);
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(cls.id);
members.forEach(m => pushNotif(m.user_id, 'assignment', `Новое задание: «${title.trim()}»`, '/dashboard'));
}
return ids;
})();
res.status(201).json({ created, count: created.length });
}
module.exports = {
VALID_ASSIGN_MODES,
createAssignment,
updateAssignment,
deleteAssignment,
teacherAssignments,
myAssignments,
startAssignment,
assignmentResults,
assignmentQuestionStats,
createDirectAssignment,
assignmentSessionReview,
listTemplates,
saveTemplate,
deleteTemplate,
bulkCreateAssignment,
};
+92
View File
@@ -0,0 +1,92 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { BCRYPT_ROUNDS } = require('../config');
const { stripTags } = require('../utils/sanitize');
function signToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, name: user.name, tv: user.token_version || 0 },
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
}
async function register(req, res, next) {
try {
const { password, name } = req.body;
const email = req.body.email?.trim().toLowerCase();
if (!email || !password || !name)
return res.status(400).json({ error: 'email, password and name are required' });
if (password.length < 6)
return res.status(400).json({ error: 'Password must be at least 6 characters' });
if (db.prepare('SELECT id FROM users WHERE email = ?').get(email))
return res.status(409).json({ error: 'Email already registered' });
const cleanName = stripTags(name.trim());
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const { lastInsertRowid } = db.prepare(
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)'
).run(email, hash, cleanName);
const user = db.prepare('SELECT id, email, name, role, token_version FROM users WHERE id = ?').get(lastInsertRowid);
const token = signToken(user);
res.status(201).json({ token, user });
} catch (err) { next(err); }
}
async function login(req, res, next) {
try {
const { password } = req.body;
const email = req.body.email?.trim().toLowerCase();
if (!email || !password)
return res.status(400).json({ error: 'email and password are required' });
const user = db.prepare(
'SELECT id, email, name, role, password_hash, token_version FROM users WHERE email = ?'
).get(email);
if (!user || !(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json({ error: 'Invalid credentials' });
db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id);
const token = signToken(user);
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
} catch (err) { next(err); }
}
function me(req, res) {
const user = db.prepare(
'SELECT id, email, name, role, created_at, last_login FROM users WHERE id = ?'
).get(req.user.id);
res.json(user);
}
async function updateProfile(req, res, next) {
try {
const { name, currentPassword, newPassword } = req.body;
const user = db.prepare('SELECT id, name, email, role, password_hash FROM users WHERE id = ?').get(req.user.id);
if (name?.trim() && name.trim() !== user.name) {
db.prepare('UPDATE users SET name = ? WHERE id = ?').run(stripTags(name.trim()), user.id);
}
if (newPassword) {
if (!currentPassword) return res.status(400).json({ error: 'Текущий пароль обязателен' });
const valid = await bcrypt.compare(currentPassword, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Неверный текущий пароль' });
if (newPassword.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' });
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, user.id);
}
const updated = db.prepare('SELECT id, email, name, role, created_at, token_version FROM users WHERE id = ?').get(user.id);
const token = signToken(updated);
res.json({ user: updated, token });
} catch (err) { next(err); }
}
module.exports = { register, login, me, updateProfile };
@@ -0,0 +1,276 @@
'use strict';
const db = require('../db/db');
const { awardXP } = require('./gamificationController');
/* ── Helpers ─────────────────────────────────────────────────────────── */
const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 };
function hillFormula(atoms) {
const cnt = {};
for (const a of atoms) cnt[a.s] = (cnt[a.s] || 0) + 1;
const parts = [];
if (cnt.C) { parts.push('C' + (cnt.C > 1 ? cnt.C : '')); delete cnt.C; }
if (cnt.H) { parts.push('H' + (cnt.H > 1 ? cnt.H : '')); delete cnt.H; }
for (const el of Object.keys(cnt).sort()) parts.push(el + (cnt[el] > 1 ? cnt[el] : ''));
return parts.join('');
}
function valencyIssues(atoms, bonds) {
const sums = {};
for (const b of bonds) {
sums[b.f] = (sums[b.f] || 0) + b.o;
sums[b.t] = (sums[b.t] || 0) + b.o;
}
return atoms
.filter(a => (sums[a.id] || 0) > (MAX_V[a.s] ?? 4))
.map(a => ({ id: a.id, symbol: a.s, used: sums[a.id] || 0, max: MAX_V[a.s] ?? 4 }));
}
/* ── Prepared statements ─────────────────────────────────────────────── */
const stmts = {
getElements: db.prepare('SELECT * FROM bio_elements ORDER BY radius ASC'),
getMolecules: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 ORDER BY difficulty,name_ru"),
getMolCat: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND category=? ORDER BY difficulty,name_ru"),
getMolSearch: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND (name_ru LIKE ? OR formula LIKE ?) ORDER BY difficulty,name_ru"),
getMolById: db.prepare('SELECT * FROM bio_molecules WHERE id=?'),
getMolByFormula:db.prepare('SELECT id,name_ru,name_lat,category,description,topic_tags FROM bio_molecules WHERE formula=? LIMIT 1'),
getReactions: db.prepare('SELECT * FROM bio_reactions ORDER BY name_ru'),
getChallenges: db.prepare('SELECT * FROM bio_challenges ORDER BY difficulty,order_n'),
getChallenge: db.prepare('SELECT * FROM bio_challenges WHERE id=?'),
checkDone: db.prepare('SELECT 1 FROM bio_user_challenges WHERE user_id=? AND challenge_id=?'),
markDone: db.prepare('INSERT OR IGNORE INTO bio_user_challenges (user_id,challenge_id) VALUES (?,?)'),
getDoneIds: db.prepare('SELECT challenge_id FROM bio_user_challenges WHERE user_id=?'),
getSaved: db.prepare('SELECT bm.*, bmo.name_ru AS mol_name FROM bio_user_molecules bm LEFT JOIN bio_molecules bmo ON bmo.id=bm.molecule_id WHERE bm.user_id=? ORDER BY bm.created_at DESC'),
saveMol: db.prepare('INSERT INTO bio_user_molecules (user_id,molecule_id,name,formula,atoms_json,bonds_json) VALUES (?,?,?,?,?,?)'),
deleteSaved: db.prepare('DELETE FROM bio_user_molecules WHERE id=? AND user_id=?'),
};
/* ── GET /api/biochem/elements ───────────────────────────────────────── */
function getElements(_req, res) {
res.json(stmts.getElements.all());
}
/* ── GET /api/biochem/molecules ──────────────────────────────────────── */
function getMolecules(req, res) {
const { cat, q } = req.query;
let rows;
if (q) {
const like = `%${q}%`;
rows = stmts.getMolSearch.all(like, like);
} else if (cat) {
rows = stmts.getMolCat.all(cat);
} else {
rows = stmts.getMolecules.all();
}
rows = rows.map(r => ({
...r,
topic_tags: tryParse(r.topic_tags, []),
atoms_json: tryParse(r.atoms_json, []),
bonds_json: tryParse(r.bonds_json, []),
}));
res.json(rows);
}
/* ── GET /api/biochem/molecules/:id ─────────────────────────────────── */
function getMolecule(req, res) {
const mol = stmts.getMolById.get(req.params.id);
if (!mol) return res.status(404).json({ error: 'Not found' });
res.json({
...mol,
atoms_json: tryParse(mol.atoms_json, []),
bonds_json: tryParse(mol.bonds_json, []),
topic_tags: tryParse(mol.topic_tags, []),
});
}
/* ── POST /api/biochem/validate ─────────────────────────────────────── */
function validate(req, res) {
const { atoms, bonds } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds))
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] });
const formula = hillFormula(atoms);
const issues = valencyIssues(atoms, bonds);
const valid = issues.length === 0;
const known = valid ? stmts.getMolByFormula.get(formula) : null;
res.json({ valid, formula, issues, known: known || null });
}
/* ── GET /api/biochem/reactions ─────────────────────────────────────── */
function getReactions(_req, res) {
const rows = stmts.getReactions.all().map(r => ({
...r,
reactant_ids: tryParse(r.reactant_ids, []),
product_ids: tryParse(r.product_ids, []),
topic_tags: tryParse(r.topic_tags, []),
}));
res.json(rows);
}
/* ── GET /api/biochem/challenges ────────────────────────────────────── */
function getChallenges(req, res) {
const challenges = stmts.getChallenges.all();
const doneSet = new Set(stmts.getDoneIds.all(req.user.id).map(r => r.challenge_id));
res.json(challenges.map(c => ({
...c,
data_json: tryParse(c.data_json, null),
done: doneSet.has(c.id),
})));
}
/* ── POST /api/biochem/challenges/:id/solve ─────────────────────────── */
function solveChallenge(req, res) {
const challenge = stmts.getChallenge.get(req.params.id);
if (!challenge) return res.status(404).json({ error: 'Challenge not found' });
if (stmts.checkDone.get(req.user.id, challenge.id))
return res.status(400).json({ error: 'already_completed' });
const type = challenge.type || 'build';
/* ── identify: shown structure → pick name ── */
if (type === 'identify') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const mol = stmts.getMolByFormula.get(challenge.target_formula);
if (!mol || answer !== mol.name_ru)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── formula: shown name → pick formula ── */
if (type === 'formula') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
if (answer !== challenge.target_formula)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── classify: shown molecule → pick class ── */
if (type === 'classify') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const data = tryParse(challenge.data_json, {});
if (answer !== data.answer)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── complete: shown partial reaction → pick missing component ── */
if (type === 'complete') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const data = tryParse(challenge.data_json, {});
if (answer !== data.answer)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── balance: fill coefficients to balance equation ── */
if (type === 'balance') {
const { coefficients } = req.body;
const data = tryParse(challenge.data_json, {});
const expected = data.coefficients || [];
if (!Array.isArray(coefficients) ||
coefficients.length !== expected.length ||
!coefficients.every((c, i) => parseInt(c) === expected[i]))
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── match: pair left items with right items ── */
if (type === 'match') {
const { pairs } = req.body;
const data = tryParse(challenge.data_json, {});
const answerMap = new Map((data.pairs || []).map(p => [p.left, p.right]));
if (!Array.isArray(pairs) ||
pairs.length !== answerMap.size ||
!pairs.every(p => answerMap.get(p.left) === p.right))
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── build (default): draw the molecule ── */
const { atoms, bonds } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds))
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
const formula = hillFormula(atoms);
if (formula !== challenge.target_formula)
return res.status(400).json({ error: 'wrong_formula', submitted: formula, expected: challenge.target_formula });
const issues = valencyIssues(atoms, bonds);
if (issues.length > 0)
return res.status(400).json({ error: 'valency_error', issues });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── GET /api/biochem/saved ─────────────────────────────────────────── */
function getSaved(req, res) {
const rows = stmts.getSaved.all(req.user.id).map(r => ({
...r,
atoms_json: tryParse(r.atoms_json, []),
bonds_json: tryParse(r.bonds_json, []),
}));
res.json(rows);
}
/* ── POST /api/biochem/saved ────────────────────────────────────────── */
function saveMolecule(req, res) {
const { atoms, bonds, name } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds) || atoms.length === 0)
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
const formula = hillFormula(atoms);
const known = stmts.getMolByFormula.get(formula);
const id = stmts.saveMol.run(
req.user.id,
known?.id ?? null,
name?.trim() || null,
formula,
JSON.stringify(atoms),
JSON.stringify(bonds),
).lastInsertRowid;
res.status(201).json({ id, formula, known: known || null });
}
/* ── DELETE /api/biochem/saved/:id ──────────────────────────────────── */
function deleteSaved(req, res) {
const info = stmts.deleteSaved.run(req.params.id, req.user.id);
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
}
/* ── util ────────────────────────────────────────────────────────────── */
function tryParse(v, fallback) {
if (!v) return fallback;
try { return JSON.parse(v); } catch { return fallback; }
}
module.exports = {
getElements, getMolecules, getMolecule, validate,
getReactions, getChallenges, solveChallenge,
getSaved, saveMolecule, deleteSaved,
};
@@ -0,0 +1,103 @@
const db = require('../db/db');
const VALID_TYPES = ['lesson', 'course', 'file', 'question'];
/* ── GET /api/bookmarks?type=lesson ── list user bookmarks ─────────── */
function list(req, res) {
const uid = req.user.id;
const { type } = req.query;
let where = 'WHERE b.user_id = ?';
const args = [uid];
if (type && VALID_TYPES.includes(type)) {
where += ' AND b.entity_type = ?';
args.push(type);
}
const rows = db.prepare(`
SELECT b.id, b.entity_type, b.entity_id, b.created_at
FROM bookmarks b ${where}
ORDER BY b.created_at DESC
`).all(...args);
// Batch-fetch titles by type to avoid N+1
const byType = {};
for (const r of rows) (byType[r.entity_type] ||= []).push(r);
const titleMap = new Map();
if (byType.lesson?.length) {
const ids = byType.lesson.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const l of db.prepare(`SELECT l.id, l.title, c.title AS course_title, c.id AS course_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id IN (${ph})`).all(...ids))
titleMap.set(`lesson:${l.id}`, { title: l.title, courseTitle: l.course_title, courseId: l.course_id });
}
if (byType.course?.length) {
const ids = byType.course.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const c of db.prepare(`SELECT id, title, subject_slug, cover_emoji FROM courses WHERE id IN (${ph})`).all(...ids))
titleMap.set(`course:${c.id}`, { title: c.title, subjectSlug: c.subject_slug, coverEmoji: c.cover_emoji });
}
if (byType.file?.length) {
const ids = byType.file.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const f of db.prepare(`SELECT id, title, original_name FROM files WHERE id IN (${ph})`).all(...ids))
titleMap.set(`file:${f.id}`, { title: f.title || f.original_name });
}
if (byType.question?.length) {
const ids = byType.question.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const q of db.prepare(`SELECT id, text FROM questions WHERE id IN (${ph})`).all(...ids))
titleMap.set(`question:${q.id}`, { title: q.text.slice(0, 100) });
}
const enriched = rows.map(r => {
const info = titleMap.get(`${r.entity_type}:${r.entity_id}`);
if (!info) return null;
const { title, ...extra } = info;
return { ...r, title, ...extra };
}).filter(Boolean);
res.json(enriched);
}
/* ── POST /api/bookmarks ── add bookmark ───────────────────────────── */
function add(req, res) {
const { entityType, entityId } = req.body;
if (!entityType || !entityId) return res.status(400).json({ error: 'entityType and entityId required' });
if (!VALID_TYPES.includes(entityType)) return res.status(400).json({ error: 'Invalid entity type' });
try {
const r = db.prepare(
'INSERT INTO bookmarks (user_id, entity_type, entity_id) VALUES (?, ?, ?)'
).run(req.user.id, entityType, entityId);
res.status(201).json({ id: Number(r.lastInsertRowid) });
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Already bookmarked' });
throw e;
}
}
/* ── DELETE /api/bookmarks/:id ── remove bookmark ──────────────────── */
function remove(req, res) {
const bm = db.prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!bm) return res.status(404).json({ error: 'Bookmark not found' });
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(bm.id);
res.json({ ok: true });
}
/* ── DELETE /api/bookmarks/entity/:type/:entityId ── remove by entity ── */
function removeByEntity(req, res) {
const { type, entityId } = req.params;
db.prepare('DELETE FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').run(req.user.id, type, entityId);
res.json({ ok: true });
}
/* ── GET /api/bookmarks/check/:type/:entityId ── check if bookmarked ── */
function check(req, res) {
const { type, entityId } = req.params;
const row = db.prepare('SELECT id FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').get(req.user.id, type, entityId);
res.json({ bookmarked: !!row, id: row?.id || null });
}
module.exports = { list, add, remove, removeByEntity, check };
+559
View File
@@ -0,0 +1,559 @@
const db = require('../db/db');
const crypto = require('crypto');
const { onClassJoined } = require('./gamificationController');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
function genCode() {
return crypto.randomBytes(4).toString('hex').toUpperCase();
}
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
getClassOwner: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
getClassWithName: db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?'),
getClassByCode: db.prepare('SELECT id, name FROM classes WHERE invite_code = ?'),
checkCodeExists: db.prepare('SELECT id FROM classes WHERE invite_code = ?'),
getMemberCheck: db.prepare('SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'),
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
insertMember: db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)'),
deleteMember: db.prepare('DELETE FROM class_members WHERE class_id = ? AND user_id = ?'),
deleteClass: db.prepare('DELETE FROM classes WHERE id = ?'),
updateClassCode: db.prepare('UPDATE classes SET invite_code = ? WHERE id = ?'),
updateClassName: db.prepare('UPDATE classes SET name = ? WHERE id = ?'),
updateClassDesc: db.prepare('UPDATE classes SET description = ? WHERE id = ?'),
updateClassFeats: db.prepare('UPDATE classes SET features = ? WHERE id = ?'),
getClassUpdated: db.prepare('SELECT id, name, description, invite_code, features, cover_emoji FROM classes WHERE id = ?'),
getClassTeacherId: db.prepare('SELECT teacher_id FROM classes WHERE id = ?'),
getStudentById: db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'"),
getStudentByEmail: db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'"),
insertAnnouncement: db.prepare('INSERT INTO announcements (class_id, author_id, text) VALUES (?, ?, ?)'),
deleteAnnouncement: db.prepare('DELETE FROM announcements WHERE id = ? AND class_id = ?'),
};
/* ── GET /api/classes ── teacher: own classes; admin: all ─────────────── */
function listClasses(req, res) {
const { role, id: uid } = req.user;
const rows = role === 'admin'
? db.prepare(`
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
u.name AS teacher_name,
COUNT(DISTINCT cm.user_id) AS member_count,
COUNT(DISTINCT a.id) AS assignment_count
FROM classes c
JOIN users u ON u.id = c.teacher_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignments a ON a.class_id = c.id
GROUP BY c.id ORDER BY c.created_at DESC
LIMIT 500
`).all()
: db.prepare(`
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
COUNT(DISTINCT cm.user_id) AS member_count,
COUNT(DISTINCT a.id) AS assignment_count
FROM classes c
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignments a ON a.class_id = c.id
WHERE c.teacher_id = ?
GROUP BY c.id ORDER BY c.created_at DESC
`).all(uid);
res.json(rows);
}
/* ── POST /api/classes ── create ───────────────────────────────────────── */
function createClass(req, res) {
const { name, description, cover_emoji } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
let invite_code, attempts = 0;
while (attempts++ < 10) {
invite_code = genCode();
if (!stmts.checkCodeExists.get(invite_code)) break;
invite_code = null;
}
if (!invite_code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
const cleanName = stripTags(name.trim());
const cleanDesc = description ? stripTags(description.trim()) : null;
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
const r = db.prepare(
'INSERT INTO classes (name, description, teacher_id, invite_code, cover_emoji) VALUES (?, ?, ?, ?, ?)'
).run(cleanName, cleanDesc, req.user.id, invite_code, emoji);
res.status(201).json({ id: r.lastInsertRowid, name: cleanName, invite_code, cover_emoji: emoji });
}
/* ── GET /api/classes/:id ── detail (teacher/admin) ────────────────────── */
function getClass(req, res) {
const cls = db.prepare(`
SELECT c.*, u.name AS teacher_name
FROM classes c JOIN users u ON u.id = c.teacher_id
WHERE c.id = ?
`).get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
if (cls.features) {
try { cls.features = JSON.parse(cls.features); } catch { cls.features = null; }
}
const members = db.prepare(`
SELECT u.id, u.name, u.email, cm.joined_at
FROM class_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.class_id = ?
ORDER BY cm.joined_at DESC
`).all(req.params.id);
// Load per-member stats in one query instead of N+1 LEFT JOIN
if (members.length > 0) {
const uIds = members.map(m => m.id);
const ph = uIds.map(() => '?').join(',');
const stats = db.prepare(`
SELECT user_id,
COUNT(*) AS tests_count,
ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg_pct
FROM test_sessions
WHERE user_id IN (${ph}) AND status = 'completed'
GROUP BY user_id
`).all(...uIds);
const statsMap = {};
for (const s of stats) statsMap[s.user_id] = s;
for (const m of members) {
m.tests_count = statsMap[m.id]?.tests_count || 0;
m.avg_pct = statsMap[m.id]?.avg_pct || 0;
}
}
const assignments = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title, a.test_id, a.max_attempts,
(SELECT COUNT(*) FROM class_members WHERE class_id = a.class_id) AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.class_id = ?
GROUP BY a.id ORDER BY a.created_at DESC
`).all(req.params.id);
res.json({ ...cls, members, assignments });
}
/* ── PATCH /api/classes/:id ── rename / update description ─────────────── */
function updateClass(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { name, description, features, cover_emoji } = req.body;
if (name?.trim()) stmts.updateClassName.run(stripTags(name.trim()), cls.id);
if (description !== undefined) stmts.updateClassDesc.run(description?.trim() || null, cls.id);
if (cover_emoji !== undefined) {
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
db.prepare('UPDATE classes SET cover_emoji = ? WHERE id = ?').run(emoji, cls.id);
}
if (features !== undefined) stmts.updateClassFeats.run(features !== null ? JSON.stringify(features) : null, cls.id);
const updated = stmts.getClassUpdated.get(cls.id);
if (updated.features) {
try { updated.features = JSON.parse(updated.features); } catch { updated.features = null; }
}
res.json(updated);
}
/* ── POST /api/classes/:id/new-code ── regenerate invite code ───────────── */
function regenerateCode(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
let code, attempts = 0;
while (attempts++ < 10) {
code = genCode();
if (!stmts.checkCodeExists.get(code)) break;
code = null;
}
if (!code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
stmts.updateClassCode.run(code, cls.id);
res.json({ invite_code: code });
}
/* ── GET /api/classes/:id/journal ── grade matrix for teacher ────────────── */
function classJournal(req, res) {
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(req.params.id);
const assignments = db.prepare(`
SELECT id, title, subject_slug, deadline, is_homework, created_at
FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
`).all(req.params.id);
const results = db.prepare(`
SELECT ases.user_id, ases.assignment_id,
MAX(ts.score) AS score,
ts.total,
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent,
MAX(ts.finished_at) AS finished_at
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
JOIN assignments a ON a.id = ases.assignment_id
WHERE a.class_id = ? AND a.user_id IS NULL
GROUP BY ases.user_id, ases.assignment_id
`).all(req.params.id);
// course progress per student
const courses = db.prepare(`
SELECT c.id, c.title, c.subject_slug,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id AND l.is_published = 1) AS lesson_count
FROM class_courses cc
JOIN courses c ON cc.course_id = c.id
WHERE cc.class_id = ?
ORDER BY cc.assigned_at
`).all(req.params.id);
let courseProgress = [];
if (courses.length && members.length) {
const cIds = courses.map(c => c.id);
const mIds = members.map(m => m.id);
const cPh = cIds.map(() => '?').join(',');
const mPh = mIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT l.course_id, lp.user_id, COUNT(*) AS done_count
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
WHERE l.course_id IN (${cPh}) AND lp.user_id IN (${mPh}) AND lp.completed = 1
GROUP BY l.course_id, lp.user_id
`).all(...cIds, ...mIds);
const doneMap = {};
for (const r of rows) doneMap[r.user_id + '_' + r.course_id] = r.done_count;
for (const c of courses) {
for (const m of members) {
const done = doneMap[m.id + '_' + c.id] || 0;
courseProgress.push({
userId: m.id, courseId: c.id,
doneCount: done, totalLessons: c.lesson_count,
percent: c.lesson_count > 0 ? Math.round(done / c.lesson_count * 100) : 0,
});
}
}
}
// averages per student — O(n) via Map instead of O(n*m) filter
const resultsByUser = new Map();
for (const r of results) {
if (!resultsByUser.has(r.user_id)) resultsByUser.set(r.user_id, []);
resultsByUser.get(r.user_id).push(r);
}
const studentStats = members.map(m => {
const studentResults = resultsByUser.get(m.id) || [];
const avgPct = studentResults.length > 0
? Math.round(studentResults.reduce((s, r) => s + r.percent, 0) / studentResults.length)
: null;
return { userId: m.id, avgPct, completedCount: studentResults.length, totalAssignments: assignments.length };
});
res.json({ className: cls.name, members, assignments, results, courses, courseProgress, studentStats });
}
/* ── GET /api/classes/:id/journal/csv ── export gradebook as CSV ────────── */
function classJournalCsv(req, res) {
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(req.params.id);
const assignments = db.prepare(`
SELECT id, title FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
`).all(req.params.id);
const results = db.prepare(`
SELECT ases.user_id, ases.assignment_id,
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
JOIN assignments a ON a.id = ases.assignment_id
WHERE a.class_id = ? AND a.user_id IS NULL
GROUP BY ases.user_id, ases.assignment_id
`).all(req.params.id);
// Build result map
const rmap = {};
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r.percent; });
// CSV header
// Protect against CSV formula injection (=, +, @, - at start trigger Excel/Sheets formulas)
const csvEsc = s => {
const str = String(s || '');
const safe = /^[=+@\-]/.test(str) ? `'${str}` : str;
return '"' + safe.replace(/"/g, '""') + '"';
};
const header = ['Ученик', 'Email', ...assignments.map(a => a.title), 'Средний %'];
const rows = [header.map(csvEsc).join(',')];
members.forEach(m => {
const scores = assignments.map(a => {
const key = m.id + '_' + a.id;
return rmap[key] !== undefined ? rmap[key] : '';
});
const filled = scores.filter(s => s !== '');
const avg = filled.length > 0 ? Math.round(filled.reduce((s, v) => s + v, 0) / filled.length) : '';
rows.push([csvEsc(m.name), csvEsc(m.email), ...scores, avg].join(','));
});
const bom = '\uFEFF'; // UTF-8 BOM for Excel
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="gradebook-${cls.name.replace(/[^a-zA-Zа-яА-Я0-9]/g, '_')}.csv"`);
res.send(bom + rows.join('\n'));
}
/* ── DELETE /api/classes/:id ──────────────────────────────────────────── */
function deleteClass(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteClass.run(req.params.id);
res.json({ ok: true });
}
/* ── POST /api/classes/join ── student joins by invite code ────────────── */
function joinClass(req, res) {
const { invite_code } = req.body;
const cls = stmts.getClassByCode.get(invite_code?.trim().toUpperCase());
if (!cls) return res.status(404).json({ error: 'Неверный код приглашения' });
try {
stmts.insertMember.run(cls.id, req.user.id);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Вы уже в этом классе' });
throw e;
}
// Notify teacher about new student joining
try {
const teacher = stmts.getClassTeacherId.get(cls.id);
if (teacher) pushNotif(teacher.teacher_id, 'join', `«${req.user.name}» вступил в класс «${cls.name}»`, '/classes');
} catch {}
try { onClassJoined(req.user.id); } catch {}
res.json({ ok: true, class_name: cls.name });
}
/* ── GET /api/classes/students ── list students in teacher's classes ─ */
function listStudents(req, res) {
if (req.user.role === 'admin') {
const rows = db.prepare(
"SELECT id, name, email FROM users WHERE role IN ('student','free_student') ORDER BY name"
).all();
return res.json(rows);
}
// Teacher: only students in their classes
const rows = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM users u
JOIN class_members cm ON cm.user_id = u.id
JOIN classes c ON c.id = cm.class_id
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
ORDER BY u.name
`).all(req.user.id);
res.json(rows);
}
/* ── POST /api/classes/:id/members ── admin/teacher adds student by email or id ─ */
function addMember(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { email, user_id } = req.body;
let student;
if (user_id) {
student = stmts.getStudentById.get(Number(user_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!email?.trim()) return res.status(400).json({ error: 'email required' });
student = stmts.getStudentByEmail.get(email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
try {
stmts.insertMember.run(cls.id, student.id);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Ученик уже в этом классе' });
throw e;
}
res.json({ ok: true, name: student.name });
}
/* ── DELETE /api/classes/:id/members/:uid ── kick ─────────────────────── */
function kickMember(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteMember.run(req.params.id, req.params.uid);
res.json({ ok: true });
}
/* ── GET /api/classes/my ── student's enrolled classes ─────────────────── */
function myClasses(req, res) {
const classes = db.prepare(`
SELECT c.id, c.name, c.description, u.name AS teacher_name,
COUNT(DISTINCT a.id) AS assignment_count
FROM class_members cm
JOIN classes c ON c.id = cm.class_id
JOIN users u ON u.id = c.teacher_id
LEFT JOIN assignments a ON a.class_id = c.id
WHERE cm.user_id = ?
GROUP BY c.id ORDER BY cm.joined_at DESC
`).all(req.user.id);
res.json(classes);
}
/* ── GET /api/classes/:id/announcements ─────────────────────────────────── */
function getAnnouncements(req, res) {
const announcements = db.prepare(`
SELECT a.id, a.text, a.created_at, u.name AS author_name
FROM announcements a
JOIN users u ON u.id = a.author_id
WHERE a.class_id = ?
ORDER BY a.created_at DESC LIMIT 50
`).all(req.params.id);
res.json(announcements);
}
/* ── POST /api/classes/:id/announcements ────────────────────────────────── */
function createAnnouncement(req, res) {
const text = stripTags(req.body.text || '');
if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 4000) return res.status(400).json({ error: 'text too long (max 4000 chars)' });
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const r = stmts.insertAnnouncement.run(req.params.id, req.user.id, text);
const members = stmts.getClassMembers.all(req.params.id);
const preview = text.slice(0, 80) + (text.length > 80 ? '…' : '');
const batchNotif = db.transaction(() => {
for (const m of members) {
pushNotif(m.user_id, 'announcement', `Объявление в «${cls.name}»: ${preview}`, '/classes');
}
});
batchNotif();
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/classes/:id/feed ── class board (students + teacher) ──────── */
function classFeed(req, res) {
const { role, id: uid } = req.user;
const classId = req.params.id;
const cls = stmts.getClassWithName.get(classId);
if (!cls) return res.status(404).json({ error: 'Not found' });
const isTeacherOrAdmin = role === 'admin' || cls.teacher_id === uid;
if (!isTeacherOrAdmin) {
const member = stmts.getMemberCheck.get(classId, uid);
if (!member) return res.status(403).json({ error: 'Forbidden' });
}
const assignments = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.deadline, a.created_at, a.is_homework,
a.max_attempts,
f.title AS file_title,
COUNT(DISTINCT cm.user_id) AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS done_count
FROM assignments a
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = a.class_id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.class_id = ?
GROUP BY a.id
ORDER BY a.created_at DESC LIMIT 20
`).all(classId);
if (!isTeacherOrAdmin && assignments.length) {
const aIds = assignments.map(a => a.id);
const ph = aIds.map(() => '?').join(',');
// Latest session per assignment
const latest = db.prepare(`
SELECT ases.assignment_id, ts.score, ts.total, ts.status
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
AND ases.id = (SELECT MAX(a2.id) FROM assignment_sessions a2
WHERE a2.assignment_id = ases.assignment_id AND a2.user_id = ases.user_id)
`).all(...aIds, uid);
const latestMap = {};
for (const r of latest) latestMap[r.assignment_id] = r;
// Completed attempts count per assignment
const attempts = db.prepare(`
SELECT ases.assignment_id, COUNT(*) AS n
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
GROUP BY ases.assignment_id
`).all(...aIds, uid);
const attemptsMap = {};
for (const r of attempts) attemptsMap[r.assignment_id] = r.n;
for (const a of assignments) {
const ses = latestMap[a.id];
a.my_status = ses ? ses.status : null;
a.my_score = ses?.score ?? null;
a.my_total = ses?.total ?? null;
a.attempts_used = attemptsMap[a.id] || 0;
}
}
const announcements = db.prepare(`
SELECT a.id, a.text, a.created_at, u.name AS author_name
FROM announcements a
JOIN users u ON u.id = a.author_id
WHERE a.class_id = ?
ORDER BY a.created_at DESC LIMIT 20
`).all(classId);
const activity = db.prepare(`
SELECT u.name AS student_name, a.title AS assignment_title,
ts.score, ts.total, ts.finished_at AS completed_at
FROM test_sessions ts
JOIN assignment_sessions ases ON ases.session_id = ts.id
JOIN assignments a ON a.id = ases.assignment_id
JOIN users u ON u.id = ts.user_id
WHERE a.class_id = ? AND ts.status = 'completed'
ORDER BY ts.finished_at DESC LIMIT 15
`).all(classId);
res.json({ class: cls, assignments, announcements, activity });
}
/* ── DELETE /api/classes/:id/announcements/:aid ─────────────────────────── */
function deleteAnnouncement(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteAnnouncement.run(req.params.aid, req.params.id);
res.json({ ok: true });
}
module.exports = {
listClasses, createClass, getClass, deleteClass,
joinClass, kickMember, addMember, myClasses, listStudents,
getAnnouncements, createAnnouncement, deleteAnnouncement, classFeed,
updateClass, regenerateCode, classJournal, classJournalCsv,
};
@@ -0,0 +1,998 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { emit, emitToClass, getOnlineUserIds } = require('../sse');
/* ── chat attachment uploads dir ─────────────────────────────────────── */
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
/* ── Draw permissions persisted in DB ─────────────────────────────────── */
function canDraw(sessionId, userId, session) {
if (session.teacher_id === userId) return true;
return !!db.prepare(
'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).get(sessionId, userId);
}
/* ── Helper: broadcast to all session participants ─────────────────────── */
function emitToSession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return;
if (session.class_id) {
emitToClass(session.class_id, data);
emit(session.teacher_id, data); // teacher is not in class_members — emit separately
} else {
// personal session — emit to teacher + each invited user
emit(session.teacher_id, data);
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const { user_id } of invites) emit(user_id, data);
}
}
/* ── Helper: check if user has access to session ──────────────────────── */
function hasAccess(session, userId, userRole) {
if (userRole === 'admin') return true;
if (session.teacher_id === userId) return true;
if (session.class_id) {
return !!db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?')
.get(session.class_id, userId);
} else {
return !!db.prepare('SELECT 1 FROM classroom_invites WHERE session_id=? AND user_id=?')
.get(session.id, userId);
}
}
/* POST /api/classroom — teacher creates session */
function createSession(req, res) {
const { class_id, user_ids, title = '' } = req.body;
const teacher = req.user;
if (!class_id && (!user_ids || !user_ids.length)) {
return res.status(400).json({ error: 'Укажите class_id или user_ids' });
}
if (class_id) {
// verify teacher owns class
const cls = teacher.role === 'admin'
? db.prepare('SELECT id, name FROM classes WHERE id=?').get(class_id)
: db.prepare('SELECT id, name FROM classes WHERE id=? AND teacher_id=?').get(class_id, teacher.id);
if (!cls) return res.status(403).json({ error: 'Нет доступа к классу' });
// end any active session for this class
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now')
WHERE class_id=? AND status='active'`).run(class_id);
}
const { lastInsertRowid } = db.prepare(
`INSERT INTO classroom_sessions (class_id, teacher_id, title) VALUES (?,?,?)`
).run(class_id || null, teacher.id, title);
const sessionId = Number(lastInsertRowid);
// create first page
db.prepare('INSERT INTO classroom_pages (session_id, page_num) VALUES (?,1)').run(sessionId);
// for personal sessions — save invites
if (!class_id && user_ids) {
const ins = db.prepare('INSERT OR IGNORE INTO classroom_invites (session_id, user_id) VALUES (?,?)');
for (const uid of user_ids) ins.run(sessionId, uid);
}
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
emitToSession(sessionId, {
type: 'classroom_started',
sessionId,
title,
classId: class_id || null,
teacherName: teacher.name,
});
res.json(session);
}
/* GET /api/classroom/:id */
function getSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?')
.get(sessionId).c;
const attendance = db.prepare(`
SELECT a.user_id, u.name, a.joined_at, a.left_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(sessionId);
const drawAllowed = canDraw(sessionId, req.user.id, session);
res.json({ ...session, pageCount, attendance, canDraw: drawAllowed });
}
/* DELETE /api/classroom/:id */
function endSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`)
.run(sessionId);
_raisedHands.delete(sessionId);
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
res.json({ ok: true });
}
/* GET /api/classroom/class/:classId/active */
function getActiveSession(req, res) {
const classId = Number(req.params.classId);
if (req.user.role !== 'teacher' && req.user.role !== 'admin') {
const isMember = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?')
.get(classId, req.user.id);
if (!isMember) return res.status(403).json({ error: 'Нет доступа' });
}
const session = db.prepare(
`SELECT * FROM classroom_sessions WHERE class_id=? AND status='active' ORDER BY id DESC LIMIT 1`
).get(classId);
if (!session) return res.json({ active: false });
res.json({ active: true, session });
}
/* GET /api/classroom/my/active — personal sessions for current user */
function getMyActive(req, res) {
const userId = req.user.id;
const sessions = db.prepare(`
SELECT s.* FROM classroom_sessions s
JOIN classroom_invites i ON i.session_id = s.id
WHERE i.user_id=? AND s.status='active'
ORDER BY s.id DESC
`).all(userId);
res.json({ sessions });
}
/* POST /api/classroom/:id/join */
function joinSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(`
INSERT INTO classroom_attendance (session_id, user_id)
VALUES (?,?)
ON CONFLICT(session_id, user_id) DO UPDATE SET joined_at=datetime('now'), left_at=NULL
`).run(sessionId, req.user.id);
emitToSession(sessionId, {
type: 'classroom_user_joined',
sessionId,
userId: req.user.id,
userName: req.user.name,
});
// If this user already has draw permission (e.g. they rejoined after a page refresh), notify them
const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id;
if (drawAllowed) {
emit(req.user.id, { type: 'classroom_draw_permitted', sessionId });
}
res.json({ ok: true, canDraw: drawAllowed });
}
/* POST /api/classroom/:id/leave */
function leaveSession(req, res) {
const sessionId = Number(req.params.id);
db.prepare(`UPDATE classroom_attendance SET left_at=datetime('now')
WHERE session_id=? AND user_id=? AND left_at IS NULL`)
.run(sessionId, req.user.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (session) {
emitToSession(sessionId, {
type: 'classroom_user_left',
sessionId,
userId: req.user.id,
});
}
res.json({ ok: true });
}
/* POST /api/classroom/:id/chat */
function sendChat(req, res) {
const sessionId = Number(req.params.id);
const { message = '', attachment_url, attachment_type } = req.body;
const text = message.trim().slice(0, 2000);
if (!text && !attachment_url) return res.status(400).json({ error: 'Пустое сообщение' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const { lastInsertRowid } = db.prepare(
'INSERT INTO classroom_chat (session_id, user_id, message, attachment_url, attachment_type) VALUES (?,?,?,?,?)'
).run(sessionId, req.user.id, text, attachment_url || null, attachment_type || null);
const row = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(lastInsertRowid);
emitToSession(sessionId, {
type: 'classroom_chat',
sessionId,
id: row.id,
userId: req.user.id,
userName: req.user.name,
message: text,
createdAt: row.created_at,
attachmentUrl: row.attachment_url || null,
attachmentType: row.attachment_type || null,
});
res.json(row);
}
/* GET /api/classroom/:id/chat */
function getChat(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const sinceId = Number(req.query.since_id) || 0;
const messages = sinceId
? db.prepare(`
SELECT c.*, u.name AS user_name
FROM classroom_chat c
JOIN users u ON u.id = c.user_id
WHERE c.session_id=? AND c.id > ?
ORDER BY c.id ASC LIMIT 100
`).all(sessionId, sinceId)
: db.prepare(`
SELECT c.*, u.name AS user_name
FROM classroom_chat c
JOIN users u ON u.id = c.user_id
WHERE c.session_id=?
ORDER BY c.id DESC LIMIT 200
`).all(sessionId).reverse();
// Attach reactions to each message
if (messages.length > 0) {
const ids = messages.map(m => m.id);
const reactions = db.prepare(
`SELECT chat_id, reaction, COUNT(*) AS cnt,
GROUP_CONCAT(user_id) AS uids
FROM classroom_chat_reactions
WHERE chat_id IN (${ids.map(() => '?').join(',')})
GROUP BY chat_id, reaction`
).all(...ids);
const rmap = {};
reactions.forEach(r => {
if (!rmap[r.chat_id]) rmap[r.chat_id] = {};
rmap[r.chat_id][r.reaction] = {
count: r.cnt,
mine: (r.uids || '').split(',').includes(String(req.user.id)),
};
});
messages.forEach(m => {
m.reactions = rmap[m.id] || {};
});
}
res.json({ messages });
}
/* GET /api/classroom/:id/participants — active participants (for all session members) */
function getParticipants(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const participants = db.prepare(`
SELECT a.user_id, u.name, a.joined_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? AND a.left_at IS NULL
ORDER BY a.joined_at
`).all(sessionId);
res.json({ participants });
}
/* GET /api/classroom/:id/attendance */
function getAttendance(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const attendance = db.prepare(`
SELECT a.*, u.name AS user_name
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(sessionId);
res.json({ attendance });
}
/* POST /api/classroom/:id/signal — WebRTC signaling relay */
function signal(req, res) {
const sessionId = Number(req.params.id);
const { target_user_id, payload } = req.body;
if (!target_user_id || !payload) return res.status(400).json({ error: 'target_user_id и payload обязательны' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
emit(target_user_id, {
type: 'classroom_signal',
sessionId,
from: req.user.id,
payload,
});
res.json({ ok: true });
}
/* GET /api/classroom/my/session — active session for current user (teacher or student) */
function getMySession(req, res) {
const userId = req.user.id;
const role = req.user.role;
let session = null;
if (role === 'teacher' || role === 'admin') {
session = db.prepare(
`SELECT * FROM classroom_sessions WHERE teacher_id=? AND status='active' ORDER BY id DESC LIMIT 1`
).get(userId);
} else {
// Class-based session: student is a member of a class that has an active session
session = db.prepare(`
SELECT cs.* FROM classroom_sessions cs
JOIN class_members cm ON cm.class_id = cs.class_id
WHERE cm.user_id=? AND cs.status='active'
ORDER BY cs.id DESC LIMIT 1
`).get(userId);
// Personal session: student was invited
if (!session) {
session = db.prepare(`
SELECT cs.* FROM classroom_sessions cs
JOIN classroom_invites ci ON ci.session_id = cs.id
WHERE ci.user_id=? AND cs.status='active'
ORDER BY cs.id DESC LIMIT 1
`).get(userId);
}
}
if (!session) return res.json({ session: null });
const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?')
.get(session.id).c;
const attendance = db.prepare(`
SELECT a.user_id, u.name, a.joined_at, a.left_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(session.id);
// Did this user join before (even if they later left)?
const wasJoined = attendance.some(a => a.user_id === userId);
res.json({ session: { ...session, pageCount, attendance }, wasJoined });
}
/* GET /api/classroom/online-students — list of students currently online (SSE connected) */
function getOnlineStudents(req, res) {
const onlineIds = getOnlineUserIds();
if (!onlineIds.length) return res.json({ students: [] });
const placeholders = onlineIds.map(() => '?').join(',');
const students = db.prepare(
`SELECT id, name, email FROM users
WHERE id IN (${placeholders}) AND role IN ('student','free_student')
ORDER BY name`
).all(...onlineIds);
res.json({ students });
}
/* ── In-memory raised hands: sessionId -> Set<userId> ─────────────────── */
const _raisedHands = new Map();
/* POST /api/classroom/:id/pages — add a page */
function addPage(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const maxFromStrokes = db.prepare(
'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_strokes WHERE session_id=?'
).get(sessionId).m;
const maxFromPages = db.prepare(
'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_pages WHERE session_id=?'
).get(sessionId).m;
const newPage = Math.max(session.current_page, maxFromStrokes, maxFromPages) + 1;
const template = req.body?.template || 'blank';
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, newPage, template);
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId);
emitToSession(sessionId, { type: 'classroom_page_added', sessionId, pageNum: newPage, template });
res.json({ pageNum: newPage, template });
}
/* PUT /api/classroom/:id/page — change current page */
function changePage(req, res) {
const sessionId = Number(req.params.id);
const { page_num } = req.body;
if (!page_num) return res.status(400).json({ error: 'page_num required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(page_num, sessionId);
emitToSession(sessionId, { type: 'classroom_page_changed', sessionId, pageNum: Number(page_num) });
res.json({ pageNum: Number(page_num) });
}
/* PATCH /api/classroom/:id/page-template — update template for current page */
function updatePageTemplate(req, res) {
const sessionId = Number(req.params.id);
const { template } = req.body;
if (!template) return res.status(400).json({ error: 'template required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('UPDATE classroom_pages SET template=? WHERE session_id=? AND page_num=?')
.run(template, sessionId, session.current_page);
emitToSession(sessionId, { type: 'classroom_template_changed', sessionId, pageNum: session.current_page, template });
res.json({ ok: true, template });
}
/* POST /api/classroom/:id/hand — raise hand */
function raiseHand(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
if (!_raisedHands.has(sessionId)) _raisedHands.set(sessionId, new Map());
_raisedHands.get(sessionId).set(req.user.id, req.user.name);
emitToSession(sessionId, {
type: 'classroom_hand_raised',
sessionId,
userId: req.user.id,
userName: req.user.name,
});
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/hand — lower hand */
function lowerHand(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId);
const map = _raisedHands.get(sessionId);
if (map) map.delete(req.user.id);
if (session) {
emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id });
}
res.json({ ok: true });
}
/* GET /api/classroom/:id/hands — get current raised hands */
function getHands(req, res) {
const sessionId = Number(req.params.id);
const map = _raisedHands.get(sessionId);
const hands = map ? [...map.entries()].map(([userId, userName]) => ({ userId, userName })) : [];
res.json({ hands });
}
/* POST /api/classroom/:id/strokes — teacher saves batch of strokes */
function postStrokes(req, res) {
const sessionId = Number(req.params.id);
const { strokes, page_num = 1 } = req.body;
if (!Array.isArray(strokes) || !strokes.length)
return res.status(400).json({ error: 'strokes array required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
// Get current max seq for this session+page
const maxSeq = db.prepare(
'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?'
).get(sessionId, page_num).m;
const insert = db.prepare(
'INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)'
);
const saved = [];
let seq = maxSeq;
const insertMany = db.transaction(() => {
for (const s of strokes) {
seq++;
const { lastInsertRowid } = insert.run(sessionId, page_num, req.user.id, s.tool || 'pencil', JSON.stringify(s.data), seq);
saved.push({ id: Number(lastInsertRowid), tool: s.tool || 'pencil', data: s.data, seq });
}
});
insertMany();
emitToSession(sessionId, {
type: 'classroom_strokes',
sessionId,
pageNum: page_num,
strokes: saved,
userId: req.user.id,
});
res.json({ strokes: saved });
}
/* GET /api/classroom/:id/strokes?page_num=1&since_seq=N — load strokes for a page */
function getStrokes(req, res) {
const sessionId = Number(req.params.id);
const pageNum = Number(req.query.page_num) || 1;
const sinceSeq = req.query.since_seq !== undefined ? Number(req.query.since_seq) : -1;
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const rows = sinceSeq >= 0
? db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq').all(sessionId, pageNum, sinceSeq)
: db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, pageNum);
const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
const pageRow = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const template = pageRow?.template || 'blank';
res.json({ strokes, template });
}
/* PATCH /api/classroom/:id/strokes/:strokeId — update image position/size */
function updateStroke(req, res) {
const sessionId = Number(req.params.id);
const strokeId = Number(req.params.strokeId);
const { data } = req.body;
if (!data) return res.status(400).json({ error: 'data required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const existing = db.prepare('SELECT id, page_num FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
if (!existing) return res.status(404).json({ error: 'Штрих не найден' });
db.prepare('UPDATE classroom_strokes SET data=? WHERE id=?').run(JSON.stringify(data), strokeId);
emitToSession(sessionId, {
type: 'classroom_stroke_updated',
sessionId,
strokeId,
pageNum: existing.page_num,
data,
});
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/strokes/:strokeId — undo a stroke */
function deleteStroke(req, res) {
const sessionId = Number(req.params.id);
const strokeId = Number(req.params.strokeId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const stroke = db.prepare('SELECT page_num FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
if (!stroke) return res.status(404).json({ error: 'Штрих не найден' });
db.prepare('DELETE FROM classroom_strokes WHERE id=?').run(strokeId);
emitToSession(sessionId, {
type: 'classroom_stroke_deleted',
sessionId,
strokeId,
pageNum: stroke.page_num,
});
res.json({ ok: true });
}
/* POST /api/classroom/:id/clear-page — teacher clears all strokes on a page */
function clearPage(req, res) {
const sessionId = Number(req.params.id);
const { page_num = 1 } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, page_num);
emitToSession(sessionId, { type: 'classroom_page_cleared', sessionId, pageNum: Number(page_num) });
res.json({ ok: true });
}
/* POST /api/classroom/:id/mute — teacher mutes a student */
function mutePeer(req, res) {
const sessionId = Number(req.params.id);
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emit(user_id, { type: 'classroom_muted', sessionId, by: req.user.id });
res.json({ ok: true });
}
/* POST /api/classroom/:id/stroke-preview — broadcast live drawing state (not saved to DB) */
function previewStroke(req, res) {
const sessionId = Number(req.params.id);
const { live_id, tool, data, page_num = 1, cancel } = req.body;
if (!live_id) return res.status(400).json({ error: 'live_id required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const payload = {
type: 'classroom_stroke_preview',
sessionId,
pageNum: Number(page_num),
liveId: live_id,
tool,
data,
cancel: cancel || false,
userId: req.user.id,
userName: req.user.name || req.user.email,
};
emitToSession(sessionId, payload);
res.json({ ok: true });
}
/* POST /api/classroom/:id/chat/:msgId/pin — teacher pins a message */
function pinMessage(req, res) {
const sessionId = Number(req.params.id);
const msgId = Number(req.params.msgId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=? AND session_id=?').get(msgId, sessionId);
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
// Toggle pin
const newPinned = msg.pinned ? 0 : 1;
db.prepare('UPDATE classroom_chat SET pinned=? WHERE id=?').run(newPinned, msgId);
emitToSession(sessionId, {
type: 'classroom_message_pinned',
sessionId, msgId, pinned: !!newPinned,
message: msg.message,
});
res.json({ ok: true, pinned: !!newPinned });
}
/* POST /api/classroom/:id/allow-draw/:userId — teacher grants draw permission */
function allowDraw(req, res) {
const sessionId = Number(req.params.id);
const targetId = Number(req.params.userId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(
'INSERT OR IGNORE INTO classroom_draw_permissions (session_id, user_id) VALUES (?,?)'
).run(sessionId, targetId);
emit(targetId, { type: 'classroom_draw_permitted', sessionId });
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/allow-draw/:userId — teacher revokes draw permission */
function revokeDraw(req, res) {
const sessionId = Number(req.params.id);
const targetId = Number(req.params.userId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(
'DELETE FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).run(sessionId, targetId);
emit(targetId, { type: 'classroom_draw_revoked', sessionId });
res.json({ ok: true });
}
/* POST /api/classroom/:id/cursor — teacher broadcasts cursor position */
function broadcastCursor(req, res) {
const sessionId = Number(req.params.id);
const { x, y, page_num = 1 } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, {
type: 'classroom_cursor', sessionId,
x, y, pageNum: Number(page_num),
userId: req.user.id,
userName: req.user.name || req.user.email,
});
res.json({ ok: true });
}
/* POST /api/classroom/:id/screen — teacher announces screen share start */
function screenStart(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, { type: 'classroom_screen_started', sessionId });
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/screen — teacher announces screen share stop */
function screenStop(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, { type: 'classroom_screen_stopped', sessionId });
res.json({ ok: true });
}
/* ── Chat: upload image attachment ──────────────────────────────────────── */
function uploadChatAttachment(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
const url = `/uploads/chat/${req.file.filename}`;
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
res.json({ url, type, name: req.file.originalname });
}
/* ── Chat: toggle reaction ───────────────────────────────────────────────── */
const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow'];
function reactToMessage(req, res) {
const chatId = Number(req.params.msgId);
const { reaction } = req.body;
if (!ALLOWED_REACTIONS.includes(reaction))
return res.status(400).json({ error: 'Неизвестная реакция' });
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(chatId);
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
const existing = db.prepare(
'SELECT id FROM classroom_chat_reactions WHERE chat_id=? AND user_id=? AND reaction=?'
).get(chatId, req.user.id, reaction);
let added;
if (existing) {
db.prepare('DELETE FROM classroom_chat_reactions WHERE id=?').run(existing.id);
added = false;
} else {
db.prepare('INSERT INTO classroom_chat_reactions (chat_id, user_id, reaction) VALUES (?,?,?)')
.run(chatId, req.user.id, reaction);
added = true;
}
const counts = db.prepare(
`SELECT reaction, COUNT(*) AS cnt, GROUP_CONCAT(user_id) AS uids
FROM classroom_chat_reactions WHERE chat_id=? GROUP BY reaction`
).all(chatId);
const reactionsMap = {};
counts.forEach(r => {
reactionsMap[r.reaction] = { count: r.cnt, uids: r.uids };
});
emitToSession(msg.session_id, {
type: 'classroom_reaction',
sessionId: msg.session_id,
chatId, reaction, userId: req.user.id, added,
reactions: reactionsMap,
});
res.json({ ok: true, added, reactions: reactionsMap });
}
/* ── Session notes (per user) ───────────────────────────────────────────── */
function getNotes(req, res) {
const sessionId = Number(req.params.id);
const row = db.prepare(
'SELECT content FROM classroom_notes WHERE session_id=? AND user_id=?'
).get(sessionId, req.user.id);
res.json({ content: row?.content || '' });
}
function saveNotes(req, res) {
const sessionId = Number(req.params.id);
const { content = '' } = req.body;
db.prepare(`
INSERT INTO classroom_notes (session_id, user_id, content, updated_at)
VALUES (?,?,?,datetime('now'))
ON CONFLICT(session_id, user_id)
DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at
`).run(sessionId, req.user.id, content.slice(0, 50000));
res.json({ ok: true });
}
/* ── Lesson templates ───────────────────────────────────────────────────── */
function getTemplates(req, res) {
const templates = db.prepare(
'SELECT id, title, description, created_at FROM classroom_templates WHERE teacher_id=? ORDER BY created_at DESC'
).all(req.user.id);
res.json({ templates });
}
function saveTemplate(req, res) {
const sessionId = Number(req.params.id);
const { title, description = '' } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Укажите название шаблона' });
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не найдена' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const pages = db.prepare(
'SELECT * FROM classroom_pages WHERE session_id=? ORDER BY page_num'
).all(sessionId);
const pagesData = pages.map(p => {
const strokes = db.prepare(
'SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
).all(sessionId, p.page_num);
return {
page_num: p.page_num,
template: p.template || 'blank',
strokes: strokes.map(s => ({ tool: s.tool, data: JSON.parse(s.data) })),
};
});
const { lastInsertRowid } = db.prepare(
'INSERT INTO classroom_templates (teacher_id, title, description, pages_data) VALUES (?,?,?,?)'
).run(req.user.id, title.trim(), description.slice(0, 500), JSON.stringify(pagesData));
res.json({ id: lastInsertRowid, ok: true });
}
function deleteTemplate(req, res) {
const id = Number(req.params.tid);
db.prepare('DELETE FROM classroom_templates WHERE id=? AND teacher_id=?').run(id, req.user.id);
res.json({ ok: true });
}
function loadTemplate(req, res) {
const sessionId = Number(req.params.id);
const { template_id } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const tmpl = db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id);
if (!tmpl) return res.status(404).json({ error: 'Шаблон не найден' });
const pagesData = JSON.parse(tmpl.pages_data || '[]');
// Clear current session data
db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId);
// Restore from template
pagesData.forEach(p => {
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)')
.run(sessionId, p.page_num, p.template || 'blank');
(p.strokes || []).forEach(s => {
db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data) VALUES (?,?,?,?)')
.run(sessionId, p.page_num, s.tool, JSON.stringify(s.data));
});
});
// Broadcast: clients reload
emitToSession(sessionId, {
type: 'classroom_template_loaded',
sessionId,
pages: pagesData.length,
});
res.json({ ok: true, pages: pagesData.length });
}
module.exports = {
createSession,
getSession,
endSession,
getActiveSession,
getMyActive,
joinSession,
leaveSession,
sendChat,
getChat,
getAttendance,
getParticipants,
signal,
getOnlineStudents,
getMySession,
postStrokes,
getStrokes,
deleteStroke,
updateStroke,
addPage,
changePage,
updatePageTemplate,
raiseHand,
lowerHand,
getHands,
mutePeer,
screenStart,
screenStop,
clearPage,
previewStroke,
broadcastCursor,
pinMessage,
allowDraw,
revokeDraw,
uploadChatAttachment,
reactToMessage,
getNotes,
saveNotes,
getTemplates,
saveTemplate,
deleteTemplate,
loadTemplate,
};
@@ -0,0 +1,69 @@
'use strict';
const db = require('../db/db');
function _tier(total, correct) {
if (total === 0) return 'locked';
const pct = correct / total * 100;
if (total >= 10 && pct >= 90) return 'platinum';
if (total >= 5 && pct >= 80) return 'gold';
if (total >= 3 && pct >= 50) return 'silver';
if (correct >= 1) return 'bronze';
return 'locked';
}
/* ── GET /api/collection ──────────────────────────────────────────────── */
function getCollection(req, res) {
const rows = db.prepare(`
SELECT
t.id AS topic_id,
t.name AS topic_name,
s.name AS subject_name,
s.slug AS subject_slug,
s.icon AS subject_icon,
COALESCE(agg.total_attempts, 0) AS total_attempts,
COALESCE(agg.correct_count, 0) AS correct_count,
agg.first_seen_at
FROM topics t
JOIN subjects s ON s.id = t.subject_id
LEFT JOIN (
SELECT q.topic_id,
COUNT(ua.id) AS total_attempts,
SUM(CASE WHEN ua.is_correct=1 THEN 1 ELSE 0 END) AS correct_count,
MIN(ua.answered_at) AS first_seen_at
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
WHERE ua.session_id IN (
SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed'
)
GROUP BY q.topic_id
) agg ON agg.topic_id = t.id
ORDER BY s.slug, t.order_index, t.name
`).all(req.user.id);
const cards = rows.map(r => ({
topicId: r.topic_id,
topicName: r.topic_name,
subjectName: r.subject_name,
subjectSlug: r.subject_slug,
subjectIcon: r.subject_icon,
tier: _tier(r.total_attempts, r.correct_count),
totalAttempts: r.total_attempts,
correctCount: r.correct_count,
masteryPct: r.total_attempts > 0 ? Math.round(r.correct_count / r.total_attempts * 100) : 0,
firstSeenAt: r.first_seen_at || null,
}));
const unlocked = cards.filter(c => c.tier !== 'locked').length;
res.json({
totalTopics: cards.length,
unlockedTopics: unlocked,
platinumCount: cards.filter(c => c.tier === 'platinum').length,
goldCount: cards.filter(c => c.tier === 'gold').length,
silverCount: cards.filter(c => c.tier === 'silver').length,
bronzeCount: cards.filter(c => c.tier === 'bronze').length,
cards,
});
}
module.exports = { getCollection };
+508
View File
@@ -0,0 +1,508 @@
const db = require('../db/db');
/* ── helpers ──────────────────────────────────────────────────────────── */
// Reused SQL fragment: user's completed-lesson count for a course (param: user_id)
const DONE_COUNT_SUBQ = `(SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l2 ON lp.lesson_id = l2.id
WHERE l2.course_id = c.id AND lp.user_id = ? AND lp.completed = 1) AS done_count`;
function courseRow(row) {
return {
id: row.id,
subjectSlug: row.subject_slug,
title: row.title,
description: row.description || '',
coverEmoji: row.cover_emoji,
orderIndex: row.order_index,
isPublished: row.is_published === 1,
createdBy: row.created_by,
createdAt: row.created_at,
lessonCount: row.lesson_count ?? 0,
doneCount: row.done_count ?? 0,
};
}
function progressSubquery(role) {
return role === 'student' ? 'AND l.is_published = 1' : '';
}
/* ── GET /api/courses ─────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
const role = req.user.role;
const uid = req.user.id;
let where = role === 'student' ? 'WHERE c.is_published = 1' : 'WHERE 1=1';
const args = [];
if (subject) { where += ' AND c.subject_slug = ?'; args.push(subject); }
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c ${where}
ORDER BY c.subject_slug, c.order_index, c.id
`).all(uid, ...args);
res.json(rows.map(courseRow));
}
/* ── GET /api/courses/search?q=… ─────────────────────────────────────── */
function search(req, res) {
const q = (req.query.q || '').trim();
const role = req.user.role;
const uid = req.user.id;
if (!q) return res.json({ courses: [], lessons: [] });
const like = `%${q}%`;
const pubC = role === 'student' ? 'AND c.is_published = 1' : '';
const pubL = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
const courses = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC}
ORDER BY c.subject_slug, c.order_index LIMIT 20
`).all(uid, like, like).map(courseRow);
const lessons = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug,
lp.completed
FROM lessons l
JOIN courses c ON l.course_id = c.id
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.title LIKE ? ${pubL}
ORDER BY c.subject_slug, l.order_index LIMIT 30
`).all(uid, like);
res.json({ courses, lessons });
}
/* ── GET /api/courses/continue ───────────────────────────────────────── */
// Returns the last-in-progress lesson across all courses
function continueLesson(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
// 1. last started but not finished lesson (progress row exists, completed=0)
let row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN courses c ON l.course_id = c.id
WHERE lp.user_id = ? AND lp.completed = 0 ${pub}
ORDER BY lp.updated_at DESC LIMIT 1
`).get(uid);
// 2. fallback: first unprogressed lesson in courses that have any progress
if (!row) {
row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lessons l
JOIN courses c ON l.course_id = c.id
WHERE c.id IN (
SELECT DISTINCT l2.course_id FROM lesson_progress lp2
JOIN lessons l2 ON lp2.lesson_id = l2.id WHERE lp2.user_id = ?
)
AND l.id NOT IN (SELECT lesson_id FROM lesson_progress WHERE user_id = ?)
${pub}
ORDER BY l.course_id, l.order_index LIMIT 1
`).get(uid, uid);
}
res.json(row ? {
lessonId: row.id,
lessonTitle: row.title,
courseId: row.course_id,
courseTitle: row.course_title,
subjectSlug: row.subject_slug,
coverEmoji: row.cover_emoji,
} : null);
}
/* ── GET /api/courses/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE c.id = ?
`).get(uid, req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Course not published' });
// sections
const sections = db.prepare(
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
).all(row.id);
// lessons grouped
const pubWhere = role === 'student' ? 'AND l.is_published = 1' : '';
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index, l.is_published, l.section_id, l.read_time,
lp.completed
FROM lessons l
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.course_id = ? ${pubWhere}
ORDER BY l.order_index, l.id
`).all(uid, row.id);
res.json({
...courseRow(row),
sections: sections.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })),
lessons: lessons.map(l => ({
id: l.id,
title: l.title,
orderIndex: l.order_index,
isPublished: l.is_published === 1,
sectionId: l.section_id,
readTime: l.read_time || 0,
completed: l.completed === 1,
})),
});
}
/* ── GET /api/courses/:id/stats?classId=X ────────────────────────────── */
function stats(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// students in class (or all students who have progress)
let members;
if (classId) {
members = db.prepare(`
SELECT u.id FROM class_members cm JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
`).all(classId).map(r => r.id);
} else {
members = db.prepare(`
SELECT DISTINCT lp.user_id AS id FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id WHERE l.course_id = ?
`).all(course.id).map(r => r.id);
}
const total = members.length || 1;
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index,
(SELECT COUNT(*) FROM lesson_progress lp
WHERE lp.lesson_id = l.id AND lp.completed = 1
AND lp.user_id IN (${members.map(() => '?').join(',') || 'NULL'})) AS done_count
FROM lessons l WHERE l.course_id = ? ORDER BY l.order_index, l.id
`).all(...members, course.id);
res.json({ total, lessons: lessons.map(l => ({
id: l.id, title: l.title,
doneCount: l.done_count,
pct: Math.round(l.done_count / total * 100),
}))});
}
/* ── GET /api/courses/:id/analytics?classId=X ───────────────────────── */
function analytics(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id, title FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// all lessons in course
const lessons = db.prepare(
'SELECT id, title, order_index FROM lessons WHERE course_id = ? ORDER BY order_index, id'
).all(course.id);
const lessonIds = lessons.map(l => l.id);
const totalLessons = lessonIds.length;
// students
let students;
if (classId) {
students = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
ORDER BY u.name
`).all(classId);
} else {
students = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN users u ON lp.user_id = u.id
WHERE l.course_id = ?
ORDER BY u.name
`).all(course.id);
}
if (!students.length) {
return res.json({
totalStudents: 0, totalLessons, avgPct: 0,
lessons: lessons.map(l => ({ id: l.id, title: l.title, doneCount: 0, pct: 0 })),
students: [], stuckStudents: [],
});
}
// Batch-fetch ALL progress for ALL students in ONE query (eliminates N+1)
const studentIds = students.map(s => s.id);
const lpRows = lessonIds.length && studentIds.length
? db.prepare(`
SELECT user_id, lesson_id, completed, updated_at FROM lesson_progress
WHERE user_id IN (${studentIds.map(() => '?').join(',')})
AND lesson_id IN (${lessonIds.map(() => '?').join(',')})
`).all(...studentIds, ...lessonIds)
: [];
// Index progress by [user_id][lesson_id]
const progressByUser = {};
for (const r of lpRows) {
if (!progressByUser[r.user_id]) progressByUser[r.user_id] = {};
progressByUser[r.user_id][r.lesson_id] = r;
}
// Per-lesson done count from the same data
const lessonDoneCount = {};
for (const lid of lessonIds) lessonDoneCount[lid] = 0;
for (const r of lpRows) {
if (r.completed === 1) lessonDoneCount[r.lesson_id] = (lessonDoneCount[r.lesson_id] || 0) + 1;
}
const studentData = students.map(s => {
const progressMap = progressByUser[s.id] || {};
const progress = Object.values(progressMap);
const doneCnt = progress.filter(p => p.completed === 1).length;
const pct = totalLessons > 0 ? Math.round(doneCnt / totalLessons * 100) : 0;
// find first incomplete lesson
let firstIncompleteIdx = -1;
for (let i = 0; i < lessons.length; i++) {
const p = progressMap[lessons[i].id];
if (!p || p.completed !== 1) { firstIncompleteIdx = i; break; }
}
// "stuck" = started course (done > 0), not finished, and last activity > 3 days ago
let stuck = false;
let stuckLesson = null;
let lastActivity = null;
if (doneCnt > 0 && doneCnt < totalLessons) {
const dates = progress.map(p => p.updated_at).filter(Boolean);
if (dates.length) {
lastActivity = dates.sort().pop();
const daysSince = (Date.now() - new Date(lastActivity.replace(' ', 'T') + 'Z').getTime()) / 86400000;
if (daysSince > 3) {
stuck = true;
stuckLesson = firstIncompleteIdx >= 0 ? lessons[firstIncompleteIdx] : null;
}
}
}
return {
id: s.id, name: s.name, email: s.email,
doneCount: doneCnt, pct, stuck,
stuckLessonId: stuckLesson?.id || null,
stuckLessonTitle: stuckLesson?.title || null,
lastActivity,
};
});
// Per-lesson stats from pre-computed counts (no extra queries)
const lessonStats = lessons.map(l => {
const doneCount = lessonDoneCount[l.id] || 0;
return {
id: l.id, title: l.title,
doneCount,
pct: students.length > 0 ? Math.round(doneCount / students.length * 100) : 0,
};
});
const avgPct = studentData.length > 0
? Math.round(studentData.reduce((s, d) => s + d.pct, 0) / studentData.length)
: 0;
res.json({
totalStudents: students.length,
totalLessons,
avgPct,
lessons: lessonStats,
students: studentData,
stuckStudents: studentData.filter(s => s.stuck),
});
}
/* ── POST /api/courses ────────────────────────────────────────────────── */
function create(req, res) {
const { subjectSlug, title, description, coverEmoji, orderIndex } = req.body;
if (!subjectSlug || !title)
return res.status(400).json({ error: 'subjectSlug and title required' });
const result = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(subjectSlug, title.trim(), description || null, coverEmoji || '', orderIndex ?? 0, req.user.id);
res.status(201).json({ id: result.lastInsertRowid });
}
/* ── POST /api/courses/:id/duplicate ─────────────────────────────────── */
function duplicate(req, res) {
const src = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!src) return res.status(404).json({ error: 'Course not found' });
let newCourseId;
db.transaction(() => {
const cr = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?)
`).run(src.subject_slug, src.title + ' (копия)', src.description, src.cover_emoji, src.order_index, req.user.id);
newCourseId = cr.lastInsertRowid;
// duplicate sections
const secMap = {};
const sections = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const s of sections) {
const sr = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(newCourseId, s.title, s.order_index);
secMap[s.id] = sr.lastInsertRowid;
}
// duplicate lessons + blocks
const lessons = db.prepare('SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const l of lessons) {
const lr = db.prepare(`
INSERT INTO lessons (course_id, title, order_index, section_id, read_time)
VALUES (?, ?, ?, ?, ?)
`).run(newCourseId, l.title, l.order_index, l.section_id ? (secMap[l.section_id] || null) : null, l.read_time || 0);
const newLid = lr.lastInsertRowid;
const blocks = db.prepare('SELECT * FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index').all(l.id);
for (const b of blocks) {
db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)').run(newLid, b.type, b.order_index, b.data);
}
}
})();
res.status(201).json({ id: newCourseId });
}
/* ── PUT /api/courses/:id ─────────────────────────────────────────────── */
function update(req, res) {
const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
const { title, description, coverEmoji, orderIndex, isPublished, subjectSlug } = req.body;
db.prepare(`
UPDATE courses SET title=?,description=?,cover_emoji=?,order_index=?,is_published=?,subject_slug=? WHERE id=?
`).run(
title ?? row.title,
description !== undefined ? description : row.description,
coverEmoji ?? row.cover_emoji,
orderIndex ?? row.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published,
subjectSlug ?? row.subject_slug,
row.id
);
res.json({ ok: true });
}
/* ── DELETE /api/courses/:id ──────────────────────────────────────────── */
function remove(req, res) {
const row = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
db.prepare('DELETE FROM courses WHERE id = ?').run(row.id);
res.json({ ok: true });
}
/* ── SECTIONS ─────────────────────────────────────────────────────────── */
function listSections(req, res) {
const rows = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id').all(req.params.id);
res.json(rows.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })));
}
function createSection(req, res) {
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
const { title, orderIndex } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
const r = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(course.id, title.trim(), orderIndex ?? 0);
res.status(201).json({ id: r.lastInsertRowid });
}
function updateSection(req, res) {
const s = db.prepare('SELECT * FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
const { title, orderIndex } = req.body;
db.prepare('UPDATE course_sections SET title=?, order_index=? WHERE id=?').run(title ?? s.title, orderIndex ?? s.order_index, s.id);
res.json({ ok: true });
}
function deleteSection(req, res) {
const s = db.prepare('SELECT id FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
// unlink lessons
db.prepare('UPDATE lessons SET section_id = NULL WHERE section_id = ?').run(s.id);
db.prepare('DELETE FROM course_sections WHERE id = ?').run(s.id);
res.json({ ok: true });
}
/* ── CLASS COURSES ────────────────────────────────────────────────────── */
function listClassCourses(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND c.is_published = 1' : '';
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ},
cc.deadline
FROM class_courses cc
JOIN courses c ON cc.course_id = c.id
WHERE cc.class_id = ? ${pub}
ORDER BY cc.assigned_at
`).all(uid, req.params.classId);
res.json(rows.map(r => ({ ...courseRow(r), deadline: r.deadline })));
}
function assignCourseToClass(req, res) {
const { classId } = req.params;
const { courseId, deadline } = req.body;
if (!courseId) return res.status(400).json({ error: 'courseId required' });
try {
db.prepare(`
INSERT INTO class_courses (class_id, course_id, deadline, assigned_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline
`).run(classId, courseId, deadline || null, req.user.id);
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
}
function unassignCourseFromClass(req, res) {
db.prepare('DELETE FROM class_courses WHERE class_id = ? AND course_id = ?').run(req.params.classId, req.params.courseId);
res.json({ ok: true });
}
/* ── PATCH /api/courses/:id/publish-all ──────────────────────────────── */
function publishAll(req, res) {
const course = db.prepare('SELECT id, created_by FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
if (req.user.role !== 'admin' && course.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const publish = req.body.publish !== false; // default: publish=true
const { changes } = db.prepare(
'UPDATE lessons SET is_published = ? WHERE course_id = ?'
).run(publish ? 1 : 0, course.id);
// Also publish/unpublish the course itself
db.prepare('UPDATE courses SET is_published = ? WHERE id = ?').run(publish ? 1 : 0, course.id);
res.json({ ok: true, lessonsUpdated: changes });
}
module.exports = {
list, search, continueLesson, get, stats, analytics, create, duplicate, update, remove,
listSections, createSection, updateSection, deleteSection,
listClassCourses, assignCourseToClass, unassignCourseFromClass,
publishAll,
};
+346
View File
@@ -0,0 +1,346 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic');
/* ── GET /api/files?subject=bio&my=1 ─────────────────────────────────── */
function listFiles(req, res) {
const { subject, my } = req.query;
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
let sql, args;
const cols = `f.id, f.title, f.description, f.original_name, f.mimetype, f.size,
f.subject_slug, f.is_public, f.folder_id, f.created_at,
u.name AS uploader_name`;
if (isTeacher && my === '1') {
sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE f.uploaded_by = ?`;
args = [uid];
} else if (isTeacher) {
sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE (f.uploaded_by = ? OR f.is_public = 1)`;
args = [uid];
} else {
sql = `
SELECT DISTINCT ${cols}
FROM files f JOIN users u ON u.id = f.uploaded_by
WHERE (
f.is_public = 1
OR EXISTS (SELECT 1 FROM file_access fa WHERE fa.file_id = f.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (SELECT 1 FROM file_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.file_id = f.id AND fa.type = 'class')
)
`;
args = [uid, uid];
}
if (subject) { sql += ' AND f.subject_slug = ?'; args.push(subject); }
sql += ' ORDER BY f.created_at DESC LIMIT 200';
res.json(db.prepare(sql).all(...args));
}
/* ── GET /api/files/folders ──────────────────────────────────────────── */
function listFolders(req, res) {
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (isTeacher) {
const rows = db.prepare(`
SELECT fo.id, fo.name, fo.created_by, fo.created_at,
(SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count,
(SELECT COUNT(*) FROM folder_access fa WHERE fa.folder_id = fo.id) AS access_count,
u.name AS creator_name
FROM folders fo JOIN users u ON u.id = fo.created_by
ORDER BY fo.name ASC
`).all();
return res.json(rows);
}
// Students: only folders with no restrictions, or where they have explicit access
const rows = db.prepare(`
SELECT fo.id, fo.name, fo.created_by, fo.created_at,
(SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count,
0 AS access_count,
u.name AS creator_name
FROM folders fo JOIN users u ON u.id = fo.created_by
WHERE (
NOT EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id)
OR EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (
SELECT 1 FROM folder_access fa
JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ?
WHERE fa.folder_id = fo.id AND fa.type = 'class'
)
)
ORDER BY fo.name ASC
`).all(uid, uid);
res.json(rows);
}
/* ── POST /api/files/folders ─────────────────────────────────────────── */
function createFolder(req, res) {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
const r = db.prepare('INSERT INTO folders (name, created_by) VALUES (?, ?)').run(name.trim(), req.user.id);
res.status(201).json({ id: r.lastInsertRowid, name: name.trim() });
}
/* ── PUT /api/files/folders/:id ──────────────────────────────────────── */
function renameFolder(req, res) {
const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name.trim(), fo.id);
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id ──────────────────────────────────── */
function deleteFolder(req, res) {
const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// Move files back to root before deleting
db.prepare('UPDATE files SET folder_id = NULL WHERE folder_id = ?').run(fo.id);
db.prepare('DELETE FROM folders WHERE id = ?').run(fo.id);
res.json({ ok: true });
}
/* ── PATCH /api/files/:id/move ───────────────────────────────────────── */
function moveFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
if (req.user.role !== 'admin' && f.uploaded_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (folderId !== null) {
const fo = db.prepare('SELECT id FROM folders WHERE id = ?').get(folderId);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
}
db.prepare('UPDATE files SET folder_id = ? WHERE id = ?').run(folderId, f.id);
res.json({ ok: true });
}
/* ── POST /api/files ─────────────────────────────────────────────────── */
function uploadFile(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { title, description, subject_slug, is_public, folder_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
// Magic bytes verification — reject files whose content doesn't match declared MIME
const filePath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
let r;
try {
r = db.prepare(`
INSERT INTO files (title, description, original_name, stored_name, mimetype, size, subject_slug, is_public, folder_id, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(),
description?.trim() || null,
req.file.originalname,
req.file.filename,
req.file.mimetype,
req.file.size,
subject_slug || null,
is_public === '0' ? 0 : 1,
folder_id ? Number(folder_id) : null,
req.user.id
);
} catch (err) {
// Clean up orphan file if DB insert failed
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
function downloadFile(req, res) {
const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (!f.is_public && !isTeacher && f.uploaded_by !== uid) {
const hasAccess = db.prepare(`
SELECT 1 FROM file_access fa
WHERE fa.file_id = ? AND (
(fa.type = 'user' AND fa.target_id = ?)
OR (fa.type = 'class' AND EXISTS (
SELECT 1 FROM class_members cm WHERE cm.class_id = fa.target_id AND cm.user_id = ?
))
) LIMIT 1
`).get(f.id, uid, uid);
if (!hasAccess) return res.status(403).json({ error: 'Access denied' });
}
const filePath = path.resolve(UPLOADS_DIR, f.stored_name);
if (!filePath.startsWith(UPLOADS_DIR + path.sep) && filePath !== UPLOADS_DIR)
return res.status(400).json({ error: 'Invalid file path' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing from storage' });
const inlineSafe = /^(image\/(png|jpeg|gif|webp)|application\/pdf)$/.test(f.mimetype);
const disposition = inlineSafe ? 'inline' : 'attachment';
const encoded = encodeURIComponent(f.original_name);
res.setHeader('Content-Disposition', `${disposition}; filename="${encoded}"; filename*=UTF-8''${encoded}`);
res.setHeader('Content-Type', f.mimetype || 'application/octet-stream');
res.sendFile(filePath);
}
/* ── DELETE /api/files/:id ───────────────────────────────────────────── */
function deleteFile(req, res) {
const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const filePath = path.resolve(UPLOADS_DIR, f.stored_name);
if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} }
db.prepare('DELETE FROM files WHERE id = ?').run(req.params.id);
res.json({ ok: true });
}
/* ── GET /api/files/:id/access ───────────────────────────────────────── */
function getFileAccess(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
const rows = db.prepare(`
SELECT fa.id, fa.type, fa.target_id,
CASE fa.type
WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id)
WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id)
END AS target_name
FROM file_access fa WHERE fa.file_id = ?
ORDER BY fa.type, fa.id
`).all(req.params.id);
res.json(rows);
}
/* ── POST /api/files/:id/assign ──────────────────────────────────────── */
function assignFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
const { type, target_id, email } = req.body;
if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' });
let tid = Number(target_id);
if (type === 'user' && email) {
const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase());
if (!u) return res.status(404).json({ error: 'Пользователь не найден' });
tid = u.id;
}
if (!tid) return res.status(400).json({ error: 'target_id required' });
try {
db.prepare('INSERT INTO file_access (file_id, type, target_id) VALUES (?, ?, ?)').run(f.id, type, tid);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' });
throw e;
}
res.json({ ok: true });
}
/* ── DELETE /api/files/:id/assign/:type/:targetId ────────────────────── */
function unassignFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM file_access WHERE file_id = ? AND type = ? AND target_id = ?')
.run(f.id, req.params.type, Number(req.params.targetId));
res.json({ ok: true });
}
/* ── GET /api/files/folders/:id/access ───────────────────────────────── */
function getFolderAccess(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
const rows = db.prepare(`
SELECT fa.id, fa.type, fa.target_id,
CASE fa.type
WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id)
WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id)
END AS target_name
FROM folder_access fa WHERE fa.folder_id = ?
ORDER BY fa.type, fa.id
`).all(fo.id);
res.json(rows);
}
/* ── POST /api/files/folders/:id/assign ──────────────────────────────── */
function assignFolder(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { type, target_id, email } = req.body;
if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' });
let tid = Number(target_id);
if (type === 'user' && email) {
const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase());
if (!u) return res.status(404).json({ error: 'Пользователь не найден' });
tid = u.id;
}
if (!tid) return res.status(400).json({ error: 'target_id required' });
try {
db.prepare('INSERT INTO folder_access (folder_id, type, target_id) VALUES (?, ?, ?)').run(fo.id, type, tid);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' });
throw e;
}
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id/assign/:type/:targetId ────────────── */
function unassignFolder(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM folder_access WHERE folder_id = ? AND type = ? AND target_id = ?')
.run(fo.id, req.params.type, Number(req.params.targetId));
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id/access (clear all) ───────────────── */
function clearFolderAccess(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM folder_access WHERE folder_id = ?').run(fo.id);
res.json({ ok: true });
}
module.exports = { listFiles, uploadFile, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess };
@@ -0,0 +1,246 @@
const db = require('../db/db');
/* ── SM-2 algorithm ───────────────────────────────────────────────────────
quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar,
3 = correct with difficulty, 4 = correct, 5 = perfect
─────────────────────────────────────────────────────────────────────── */
function sm2(easeFactor, intervalDays, repetitions, quality) {
let ef = easeFactor;
let n = repetitions;
let iv = intervalDays;
if (quality < 3) {
n = 0;
iv = 1;
} else {
if (n === 0) iv = 1;
else if (n === 1) iv = 6;
else iv = Math.round(iv * ef);
n++;
}
ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
const due = new Date(Date.now() + iv * 86400000).toISOString();
return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due };
}
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
function listDecks(req, res) {
const uid = req.user.id;
const decks = db.prepare(`
SELECT d.*,
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
(SELECT COUNT(*) FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count
FROM flashcard_decks d
WHERE d.user_id = ?
ORDER BY d.created_at DESC
`).all(uid, uid);
res.json({ decks });
}
/* ── POST /api/flashcards/decks ────────────────────────────────────────── */
function createDeck(req, res) {
const uid = req.user.id;
const { title, description = '', color = '#9B5DE5' } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
const r = db.prepare(
`INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)`
).run(uid, title.trim(), description, color);
res.json({ id: r.lastInsertRowid, title: title.trim(), description, color, card_count: 0, due_count: 0 });
}
/* ── PUT /api/flashcards/decks/:id ─────────────────────────────────────── */
function updateDeck(req, res) {
const uid = req.user.id;
const { title, description, color } = req.body;
const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
db.prepare(`UPDATE flashcard_decks SET title=?, description=?, color=? WHERE id=?`)
.run(title ?? deck.title, description ?? deck.description, color ?? deck.color, deck.id);
res.json({ ok: true });
}
/* ── DELETE /api/flashcards/decks/:id ──────────────────────────────────── */
function deleteDeck(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
db.prepare(`DELETE FROM flashcard_decks WHERE id = ?`).run(deck.id);
res.json({ ok: true });
}
/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */
function getCards(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const cards = db.prepare(`
SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed
FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ?
ORDER BY c.order_idx, c.id
`).all(uid, deck.id);
res.json({ cards });
}
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
function addCard(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const { front = '', back = '' } = req.body;
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
.get(deck.id)?.m ?? -1;
const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`)
.run(deck.id, front, back, maxIdx + 1);
res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, order_idx: maxIdx + 1 });
}
/* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */
function addCardsBulk(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const { cards } = req.body;
if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' });
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
.get(deck.id)?.m ?? -1;
const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`);
const inserted = [];
const ins = db.transaction(() => {
cards.forEach((c, i) => {
const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i);
inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back });
});
});
ins();
res.json({ inserted });
}
/* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */
function updateCard(req, res) {
const uid = req.user.id;
const card = db.prepare(`
SELECT c.* FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
const { front, back } = req.body;
db.prepare(`UPDATE flashcard_cards SET front=?, back=? WHERE id=?`)
.run(front ?? card.front, back ?? card.back, card.id);
res.json({ ok: true });
}
/* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */
function deleteCard(req, res) {
const uid = req.user.id;
const card = db.prepare(`
SELECT c.id FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
db.prepare(`DELETE FROM flashcard_cards WHERE id = ?`).run(card.id);
res.json({ ok: true });
}
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
function getStudySession(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
// due cards first, then new cards (no review yet), limit 20
const cards = db.prepare(`
SELECT c.id, c.front, c.back,
COALESCE(r.ease_factor, 2.5) AS ease_factor,
COALESCE(r.interval_days, 1) AS interval_days,
COALESCE(r.repetitions, 0) AS repetitions,
COALESCE(r.due_at, datetime('now')) AS due_at,
r.last_reviewed,
CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen
FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ?
AND (r.id IS NULL OR r.due_at <= datetime('now'))
ORDER BY seen ASC, r.due_at ASC
LIMIT 20
`).all(uid, deck.id);
const total_due = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
`).get(uid, deck.id).n;
res.json({ cards, total_due });
}
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
function submitReview(req, res) {
const uid = req.user.id;
const { quality } = req.body; // 0..5
if (quality === undefined || quality < 0 || quality > 5)
return res.status(400).json({ error: 'quality 0-5 required' });
const card = db.prepare(`
SELECT c.id FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
const existing = db.prepare(
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
).get(uid, card.id);
const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality);
if (existing) {
db.prepare(`
UPDATE flashcard_reviews
SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now')
WHERE user_id=? AND card_id=?
`).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id);
} else {
db.prepare(`
INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed)
VALUES (?,?,?,?,?,?,datetime('now'))
`).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt);
}
res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays });
}
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */
function getStats(req, res) {
const uid = req.user.id;
const decks_count = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_decks WHERE user_id=?`).get(uid).n;
const cards_count = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id=?
`).get(uid).n;
const due_count = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE d.user_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
`).get(uid, uid).n;
const reviewed_today = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_reviews
WHERE user_id = ? AND date(last_reviewed) = date('now')
`).get(uid).n;
res.json({ decks_count, cards_count, due_count, reviewed_today });
}
module.exports = {
listDecks, createDeck, updateDeck, deleteDeck,
getCards, addCard, addCardsBulk, updateCard, deleteCard,
getStudySession, submitReview, getStats,
};
+310
View File
@@ -0,0 +1,310 @@
'use strict';
const db = require('../db/db');
const { awardXP } = require('./gamificationController');
/* ── Crossword generator (improved) ──────────────────────────────────── */
// Algorithm based on MichaelWehar/crossword-layout-generator scoring with additions:
// 1. Enumerate candidates from already-placed words (only perpendicular directions)
// 2. Weighted score: connections 70% + center 15% + orientation balance 10% + length 5%
// 3. Two-pass: retry skipped words after full first pass
// 4. Random pick from top-3 candidates per attempt for variety
// 5. 120 attempts, keeping best result by placed-word count
const CW_GRID = 20;
const CW_MAX_WORDS = 10;
const CW_ATTEMPTS = 120;
function _shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function _cwPlace(grid, word, row, col, dir) {
for (let i = 0; i < word.length; i++) {
if (dir === 'across') grid[row][col + i] = word[i];
else grid[row + i][col] = word[i];
}
}
// Returns number of valid intersections (≥0) if placement is valid, -1 if invalid
function _cwCheck(grid, word, row, col, dir, N) {
if (row < 0 || col < 0) return -1;
const endR = dir === 'down' ? row + word.length - 1 : row;
const endC = dir === 'across' ? col + word.length - 1 : col;
if (endR >= N || endC >= N) return -1;
// No letter immediately before/after word in its own direction
if (dir === 'across') {
if (col > 0 && grid[row][col - 1]) return -1;
if (endC < N - 1 && grid[row][endC + 1]) return -1;
} else {
if (row > 0 && grid[row - 1][col]) return -1;
if (endR < N - 1 && grid[endR + 1][col]) return -1;
}
let intersections = 0;
for (let i = 0; i < word.length; i++) {
const r = dir === 'across' ? row : row + i;
const c = dir === 'across' ? col + i : col;
const ex = grid[r][c];
if (ex !== null) {
if (ex !== word[i]) return -1; // letter conflict
intersections++;
} else {
// Empty cell — perpendicular neighbours must be empty
if (dir === 'across') {
if (r > 0 && grid[r - 1][c]) return -1;
if (r < N - 1 && grid[r + 1][c]) return -1;
} else {
if (c > 0 && grid[r][c - 1]) return -1;
if (c < N - 1 && grid[r][c + 1]) return -1;
}
}
}
return intersections;
}
// Find all valid placements by iterating over already-placed words.
// Only tries the direction PERPENDICULAR to each placed word — guaranteed crossing.
function _cwFindPlacements(grid, word, placed, N, center) {
const seen = new Set();
const candidates = [];
const acrossCount = placed.filter(p => p.dir === 'across').length;
const downCount = placed.length - acrossCount;
for (const pw of placed) {
const newDir = pw.dir === 'across' ? 'down' : 'across';
for (let wi = 0; wi < word.length; wi++) {
for (let pi = 0; pi < pw.word.length; pi++) {
if (word[wi] !== pw.word[pi]) continue;
// Intersection cell on the grid
const ir = pw.dir === 'across' ? pw.row : pw.row + pi;
const ic = pw.dir === 'across' ? pw.col + pi : pw.col;
// Start of new word so that letter wi lands at (ir, ic)
const r = newDir === 'across' ? ir : ir - wi;
const c = newDir === 'across' ? ic - wi : ic;
const key = `${r},${c},${newDir}`;
if (seen.has(key)) continue;
seen.add(key);
const intersections = _cwCheck(grid, word, r, c, newDir, N);
if (intersections < 1) continue;
// Weighted score (MichaelWehar 70/15/10/5 split)
const maxDist = center * Math.SQRT2;
const dist = Math.hypot(r - center, c - center);
const conn = intersections / (word.length / 2);
const cen = 1 - dist / maxDist;
const oBal = newDir === 'down'
? (acrossCount >= downCount ? 0.1 : 0)
: (downCount >= acrossCount ? 0.1 : 0);
const len = word.length / N;
const score = conn * 0.7 + cen * 0.15 + oBal * 0.1 + len * 0.05;
candidates.push({ row: r, col: c, dir: newDir, intersections, score });
}
}
}
return candidates;
}
function _buildAttempt(words, N) {
const grid = Array.from({ length: N }, () => Array(N).fill(null));
const placed = [];
const center = Math.floor(N / 2);
// First word: placed across at center
const first = words[0];
const r0 = center;
const c0 = Math.max(1, center - Math.floor(first.word.length / 2));
if (c0 + first.word.length > N - 1) return { placed, grid };
_cwPlace(grid, first.word, r0, c0, 'across');
placed.push({ ...first, row: r0, col: c0, dir: 'across' });
const skipped = [];
for (const pass of [words.slice(1), skipped]) {
for (const w of pass) {
if (placed.length >= CW_MAX_WORDS) break;
const cands = _cwFindPlacements(grid, w.word, placed, N, center);
if (!cands.length) {
if (pass !== skipped) skipped.push(w);
continue;
}
cands.sort((a, b) => b.score - a.score);
// Pick randomly from top 3 for variety across attempts
const pick = cands[Math.floor(Math.random() * Math.min(3, cands.length))];
_cwPlace(grid, w.word, pick.row, pick.col, pick.dir);
placed.push({ ...w, row: pick.row, col: pick.col, dir: pick.dir });
}
}
return { placed, grid };
}
function buildCrossword(wordList) {
const N = CW_GRID;
let best = null;
// Baseline: longest words first
const sorted = [...wordList].sort((a, b) => b.word.length - a.word.length);
best = _buildAttempt(sorted, N);
for (let a = 1; a < CW_ATTEMPTS; a++) {
if (best.placed.length >= CW_MAX_WORDS) break;
const shuffled = _shuffle(wordList);
// Ensure a long word is near the front (better first placement)
shuffled.sort((x, y) => (y.word.length >= 6 ? 1 : 0) - (x.word.length >= 6 ? 1 : 0));
const attempt = _buildAttempt(shuffled, N);
if (attempt.placed.length > best.placed.length) best = attempt;
}
if (!best || best.placed.length < 3) return null;
// Compact: trim empty rows/cols
const { placed, grid } = best;
let minR = N, maxR = 0, minC = N, maxC = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (grid[r][c]) {
minR = Math.min(minR, r); maxR = Math.max(maxR, r);
minC = Math.min(minC, c); maxC = Math.max(maxC, c);
}
const trimmed = [];
for (let r = minR; r <= maxR; r++) trimmed.push(grid[r].slice(minC, maxC + 1));
const words = placed.map(p => ({
word: p.word, clue: p.clue,
subjectName: p.subjectName,
row: p.row - minR, col: p.col - minC, dir: p.dir,
}));
// Number words in reading order (top→bottom, left→right)
words.sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col);
const starts = new Map();
let num = 1;
for (const w of words) {
const key = `${w.row},${w.col}`;
if (!starts.has(key)) starts.set(key, num++);
w.num = starts.get(key);
}
return { grid: trimmed, words, across: words.filter(w => w.dir === 'across'), down: words.filter(w => w.dir === 'down') };
}
/* ── GET /api/games/hangman/word?subject_slug=bio ─────────────────────── */
function hangmanWord(req, res) {
const { subject_slug } = req.query;
let row;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE t.subject_id = ? AND length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get(subj.id);
} else {
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get();
}
if (!row) return res.status(404).json({ error: 'No topics found' });
res.json({
topicId: row.id,
word: row.name.toUpperCase(),
hint: row.subject_name,
subjectSlug: row.subject_slug,
});
}
/* ── POST /api/games/hangman/complete ─────────────────────────────────── */
function hangmanComplete(req, res) {
const { won, errors } = req.body;
if (typeof won !== 'boolean') return res.status(400).json({ error: 'won required' });
let xpGain = 0;
if (won) {
// 15 XP perfect, -2 per error, min 5
xpGain = Math.max(5, 15 - (Number(errors) || 0) * 2);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
/* ── GET /api/games/crossword/generate?subject_slug= ──────────────────── */
function crosswordGenerate(req, res) {
const { subject_slug } = req.query;
let rows;
const base = `
SELECT t.id, t.name, s.name AS subject_name,
(SELECT q.text FROM questions q WHERE q.topic_id = t.id ORDER BY RANDOM() LIMIT 1) AS clue
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) BETWEEN 4 AND 12
AND t.name NOT LIKE '% %'
AND t.name NOT GLOB '*[0-9]*'
`;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
rows = db.prepare(base + ' AND t.subject_id = ? ORDER BY RANDOM() LIMIT 50').all(subj.id);
} else {
rows = db.prepare(base + ' ORDER BY RANDOM() LIMIT 50').all();
}
if (rows.length < 3) return res.status(404).json({ error: 'Not enough topics for a crossword' });
const wordList = rows
.filter(r => /^[А-яЁёA-Za-z]+$/.test(r.name)) // only letters, no numbers/symbols
.map(r => ({
word: r.name.toUpperCase(),
clue: r.clue || r.subject_name,
subjectName: r.subject_name,
}));
const crossword = buildCrossword(wordList);
if (!crossword) return res.status(404).json({ error: 'Could not build crossword' });
res.json(crossword);
}
/* ── POST /api/games/crossword/complete ───────────────────────────────── */
function crosswordComplete(req, res) {
const { completed, hintsUsed } = req.body;
if (typeof completed !== 'boolean') return res.status(400).json({ error: 'completed required' });
let xpGain = 0;
if (completed) {
xpGain = Math.max(5, 20 - (Number(hintsUsed) || 0) * 3);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
module.exports = { hangmanWord, hangmanComplete, crosswordGenerate, crosswordComplete };
@@ -0,0 +1,854 @@
const db = require('../db/db');
const sse = require('../sse');
const { pushParentNotif } = require('../utils/notifications');
/* ═══════════════════════════════════════════════════════════════════════
Gamification — XP, Levels, Streaks, Achievements, Leaderboard, Goals
═══════════════════════════════════════════════════════════════════════ */
// ── XP thresholds: level = floor(sqrt(xp / 100)) + 1 ──
function xpToLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
function levelMinXp(lv) { return (lv - 1) * (lv - 1) * 100; }
function levelMaxXp(lv) { return lv * lv * 100; }
const RANKS = [
[1, 'Новичок'],
[5, 'Ученик'],
[10, 'Знаток'],
[15, 'Эксперт'],
[20, 'Мастер'],
[30, 'Гуру'],
];
function rankName(level) {
let name = 'Новичок';
for (const [min, r] of RANKS) if (level >= min) name = r;
return name;
}
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
// XP & coins (hot: called on every test finish)
insertXpLog: db.prepare('INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)'),
incrXP: db.prepare('UPDATE users SET xp = xp + ? WHERE id = ?'),
getXP: db.prepare('SELECT xp FROM users WHERE id = ?'),
setLevel: db.prepare('UPDATE users SET level = ? WHERE id = ?'),
incrCoins: db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?'),
// Streak
getStreak: db.prepare('SELECT streak_current, streak_best, streak_date FROM users WHERE id = ?'),
setStreak: db.prepare('UPDATE users SET streak_current = ?, streak_best = ?, streak_date = ? WHERE id = ?'),
// getXPInfo
getUserXPInfo: db.prepare('SELECT xp, level, streak_current, streak_best, streak_date FROM users WHERE id = ?'),
countUserAssignments: db.prepare(`SELECT COUNT(*) as n FROM assignment_sessions ass JOIN test_sessions ts ON ts.id = ass.session_id WHERE ts.user_id = ? AND ts.status = 'completed'`),
// Achievements (hot: up to 22 checks per test finish)
getAchBySlug: db.prepare('SELECT id, title, icon FROM achievements WHERE slug = ?'),
hasUserAch: db.prepare('SELECT 1 FROM user_achievements WHERE user_id = ? AND achievement_id = ?'),
insertUserAch: db.prepare('INSERT INTO user_achievements (user_id, achievement_id) VALUES (?, ?)'),
insertAchNotif: db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'achievement', ?, '/profile')"),
getUserForAch: db.prepare(`
SELECT u.xp, u.level, u.streak_current, u.lab_experiments, u.lab_reactions,
(SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed') AS test_count,
(SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed' AND score = total) AS perfect_count,
(SELECT COUNT(*) FROM class_members WHERE user_id = u.id) AS class_count
FROM users u WHERE u.id = ?
`),
getLast5Tests: db.prepare(`
SELECT score, total FROM test_sessions
WHERE user_id = ? AND status = 'completed'
ORDER BY finished_at DESC LIMIT 5
`),
// Lab
incrLabExp: db.prepare('UPDATE users SET lab_experiments = lab_experiments + 1 WHERE id = ?'),
incrLabReact: db.prepare('UPDATE users SET lab_reactions = lab_reactions + ? WHERE id = ?'),
// Daily goals
getDailyGoal: db.prepare('SELECT * FROM daily_goals WHERE user_id = ? AND date = ?'),
getUserGoalTier: db.prepare('SELECT goal_tier FROM users WHERE id = ?'),
insertDailyGoal: db.prepare('INSERT INTO daily_goals (user_id, date, tests_target, tests_done, xp_target, xp_earned) VALUES (?, ?, ?, 0, ?, 0)'),
incrDailyGoal: db.prepare('UPDATE daily_goals SET tests_done = tests_done + ?, xp_earned = xp_earned + ? WHERE user_id = ? AND date = ?'),
checkGoalBonus: db.prepare("SELECT 1 FROM xp_log WHERE user_id = ? AND reason = 'daily_goal' AND date(created_at) = ?"),
// Challenges
getOpenChallenges: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? AND completed = 0'),
incrChallenge: db.prepare('UPDATE challenges SET progress = MIN(progress + ?, target) WHERE id = ?'),
getChallengeById: db.prepare('SELECT * FROM challenges WHERE id = ?'),
completeChallenge: db.prepare('UPDATE challenges SET completed = 1 WHERE id = ?'),
getChallengesWeek: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? ORDER BY completed, id'),
getChallengeOwned: db.prepare('SELECT * FROM challenges WHERE id = ? AND user_id = ?'),
markClaimed: db.prepare('UPDATE challenges SET claimed = 1 WHERE id = ?'),
// API handlers (dashboard / profile load)
getUserPrefs: db.prepare('SELECT goal_tier, avatar_frame FROM users WHERE id = ?'),
getUnlockedSlugs: db.prepare('SELECT a.slug FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id = ?'),
getUserFrame: db.prepare('SELECT avatar_frame FROM users WHERE id = ?'),
checkFrameUnlock: db.prepare('SELECT a.id FROM achievements a JOIN user_achievements ua ON ua.achievement_id = a.id WHERE a.slug = ? AND ua.user_id = ?'),
setUserFrame: db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?'),
setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'),
getAllAchs: db.prepare('SELECT id, slug, title, icon, category, description FROM achievements ORDER BY id'),
getUserAchs: db.prepare('SELECT achievement_id, unlocked_at FROM user_achievements WHERE user_id = ?'),
xpHistory: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'),
// Admin
checkUserById: db.prepare('SELECT id FROM users WHERE id = ?'),
getUserGamInfo: db.prepare('SELECT xp, level, coins FROM users WHERE id = ?'),
adminResetUser: db.prepare("UPDATE users SET xp=0, level=1, coins=0, streak_current=0, streak_best=0, streak_date=NULL, avatar_frame='default' WHERE id=?"),
deleteXpLog: db.prepare('DELETE FROM xp_log WHERE user_id=?'),
deleteUserAchs: db.prepare('DELETE FROM user_achievements WHERE user_id=?'),
deleteDailyGoals: db.prepare('DELETE FROM daily_goals WHERE user_id=?'),
deleteChallenges: db.prepare('DELETE FROM challenges WHERE user_id=?'),
deleteUserPurch: db.prepare('DELETE FROM user_purchases WHERE user_id=?'),
adminGetUserFull: db.prepare('SELECT id, name, xp, level, coins, streak_current, streak_best, goal_tier, avatar_frame FROM users WHERE id=?'),
adminGetUserAchs: db.prepare('SELECT a.slug, a.title, a.icon, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id=ua.achievement_id WHERE ua.user_id=?'),
adminGetUserPurch: db.prepare('SELECT si.name, si.type, up.purchased_at FROM user_purchases up JOIN shop_items si ON si.id=up.item_id WHERE up.user_id=?'),
adminGetUserXPH: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id=? ORDER BY created_at DESC LIMIT 30'),
adminTotalXP: db.prepare('SELECT COALESCE(SUM(xp),0) as v FROM users'),
adminTotalCoins: db.prepare('SELECT COALESCE(SUM(coins),0) as v FROM users'),
adminAvgLevel: db.prepare("SELECT ROUND(AVG(level),1) as v FROM users WHERE role='student'"),
adminAchCount: db.prepare('SELECT COUNT(*) as v FROM user_achievements'),
adminTopXP: db.prepare("SELECT id, name, xp, level, coins FROM users WHERE role='student' ORDER BY xp DESC LIMIT 10"),
adminRecentXP: db.prepare('SELECT xl.amount, xl.reason, xl.created_at, u.name FROM xp_log xl JOIN users u ON u.id=xl.user_id ORDER BY xl.created_at DESC LIMIT 20'),
adminTotalPurch: db.prepare('SELECT COUNT(*) as v FROM user_purchases'),
adminRecentPurch: db.prepare(`SELECT up.purchased_at, u.name AS user_name, si.name AS item_name, si.price, si.type
FROM user_purchases up JOIN users u ON u.id=up.user_id JOIN shop_items si ON si.id=up.item_id
ORDER BY up.purchased_at DESC LIMIT 20`),
};
/* ── Coins service ────────────────────────────────────────────────────── */
function awardCoins(userId, amount, reason) {
if (!amount || amount <= 0) return;
stmts.incrCoins.run(amount, userId);
}
/* ── XP service ───────────────────────────────────────────────────────── */
function awardXP(userId, amount, reason) {
if (!amount || amount <= 0) return;
stmts.insertXpLog.run(userId, amount, reason);
stmts.incrXP.run(amount, userId);
const user = stmts.getXP.get(userId);
if (user) {
const newLevel = xpToLevel(user.xp);
stmts.setLevel.run(newLevel, userId);
}
// Award coins proportionally: 1 coin per 10 XP
awardCoins(userId, Math.floor(amount / 10), reason);
}
function getXPInfo(userId) {
const user = stmts.getUserXPInfo.get(userId);
if (!user) return null;
// Always derive level from XP so stale DB level never causes wrong display
const level = xpToLevel(user.xp);
// Keep DB in sync (silently)
if (user.level !== level) stmts.setLevel.run(level, userId);
return {
xp: user.xp || 0,
level,
rank: rankName(level),
levelMin: levelMinXp(level),
levelMax: levelMaxXp(level),
streak: user.streak_current || 0,
streakBest: user.streak_best || 0,
};
}
/* ── Streak service ───────────────────────────────────────────────────── */
function updateStreak(userId) {
const user = stmts.getStreak.get(userId);
if (!user) return;
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
if (user.streak_date === today) return; // already counted today
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const oldStreak = user.streak_current || 0;
let newStreak;
if (user.streak_date === yesterday) {
newStreak = oldStreak + 1;
} else {
newStreak = 1;
// Notify parents about streak loss (only if was meaningful)
if (oldStreak >= 3) {
const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId);
pushParentNotif(userId, 'streak_lost', `${u?.name || 'Ученик'} потерял стрик (было ${oldStreak} дней)`);
}
}
const newBest = Math.max(newStreak, user.streak_best || 0);
stmts.setStreak.run(newStreak, newBest, today, userId);
// streak XP bonus (first activity of the day)
awardXP(userId, 30, 'daily_activity');
return newStreak;
}
/* ── Achievement definitions ──────────────────────────────────────────── */
const ACHIEVEMENT_DEFS = [
// First steps
{ slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', desc: 'Пройти свой первый тест' },
{ slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', desc: 'Получить 100% на тесте' },
{ slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', desc: 'Присоединиться к классу' },
// Streaks
{ slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', desc: '3 дня активности подряд' },
{ slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', desc: '7 дней активности подряд' },
{ slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', desc: '30 дней активности подряд' },
// Volume
{ slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', desc: 'Завершить 10 тестов' },
{ slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', desc: 'Завершить 50 тестов' },
{ slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', desc: 'Завершить 100 тестов' },
// Mastery
{ slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', desc: '5 тестов подряд на 90%+' },
{ slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', desc: 'Тест на 90%+ за <50% времени' },
// Levels
{ slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', desc: 'Достичь 5 уровня' },
{ slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', desc: 'Достичь 10 уровня' },
{ slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', desc: 'Достичь 20 уровня' },
// XP milestones
{ slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 1000 XP' },
{ slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 5000 XP' },
{ slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 10 000 XP' },
// Lab / experiments
{ slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', desc: 'Провести первый эксперимент в лаборатории' },
{ slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', desc: 'Провести 5 экспериментов' },
{ slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', desc: 'Провести 20 экспериментов' },
{ slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', desc: 'Провести 50 экспериментов' },
{ slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 10 различных реакций' },
{ slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 30 различных реакций' },
// Extra level milestones
{ slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', desc: 'Достичь 3 уровня' },
// Red Book
{ slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть первый вид Красной книги' },
{ slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть 10 видов Красной книги' },
{ slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 25 видов Красной книги' },
{ slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 50 видов Красной книги' },
{ slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', desc: 'Открыть все CR-виды Красной книги' },
{ slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', desc: 'Выполнить первый квест Красной книги' },
{ slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', desc: 'Выполнить 5 квестов Красной книги' },
{ slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', desc: 'Добавить первое наблюдение вида' },
// Theory / Library
{ slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', desc: 'Прочитать первый урок' },
{ slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', desc: 'Прочитать 10 уроков' },
{ slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap',cat: 'theory', desc: 'Пройти полный курс целиком' },
// Assignments
{ slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', desc: 'Сдать первое задание' },
{ slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check',cat: 'assign', desc: 'Сдать 10 заданий' },
];
// Avatar frames unlocked by achievements
const AVATAR_FRAMES = [
{ id: 'default', name: 'Стандарт', css: '', unlock: null },
{ id: 'fire', name: 'Огненная', css: 'box-shadow:0 0 0 3px #FF6B35,0 0 12px rgba(255,107,53,0.4)', unlock: 'streak_7' },
{ id: 'diamond', name: 'Бриллиант', css: 'box-shadow:0 0 0 3px #06D6E0,0 0 12px rgba(6,214,224,0.4)', unlock: 'xp_5000' },
{ id: 'gold', name: 'Золотая', css: 'box-shadow:0 0 0 3px #FFD700,0 0 12px rgba(255,215,0,0.4)', unlock: 'tests_100' },
{ id: 'violet_glow', name: 'Фиолет', css: 'box-shadow:0 0 0 3px #9B5DE5,0 0 16px rgba(155,93,229,0.5)', unlock: 'level_10' },
{ id: 'rainbow', name: 'Радуга', css: 'background:conic-gradient(#FF6B6B,#FFD93D,#6BCB77,#4D96FF,#9B5DE5,#FF6B6B);padding:3px', unlock: 'level_20' },
{ id: 'crown', name: 'Корона', css: 'box-shadow:0 0 0 3px #FFD700,0 0 20px rgba(255,215,0,0.6)', unlock: 'xp_10000' },
{ id: 'perfect', name: 'Идеал', css: 'box-shadow:0 0 0 3px #06D664,0 0 12px rgba(6,214,100,0.4)', unlock: 'first_perfect' },
];
function seedAchievements() {
const ins = db.prepare(`
INSERT OR IGNORE INTO achievements (slug, title, icon, category, description)
VALUES (?, ?, ?, ?, ?)
`);
const upd = db.prepare(`
UPDATE achievements SET icon = ?, category = ?, title = ?, description = ?
WHERE slug = ? AND (icon IS NULL OR icon = '' OR icon != ?)
`);
for (const a of ACHIEVEMENT_DEFS) {
ins.run(a.slug, a.title, a.icon, a.cat, a.desc);
upd.run(a.icon, a.cat, a.title, a.desc, a.slug, a.icon);
}
}
function unlockAchievement(userId, slug) {
const ach = stmts.getAchBySlug.get(slug);
if (!ach) return false;
const exists = stmts.hasUserAch.get(userId, ach.id);
if (exists) return false;
stmts.insertUserAch.run(userId, ach.id);
// Award bonus XP
awardXP(userId, 50, 'achievement:' + slug);
// Notify via SSE
pushAchievementNotif(userId, ach);
return true;
}
function pushAchievementNotif(userId, ach) {
try {
stmts.insertAchNotif.run(userId, `Достижение: ${ach.title}`);
sse.emit(userId, { type: 'achievement', message: `Достижение: ${ach.title}`, icon: ach.icon, title: ach.title });
// Award 50 coins per achievement
awardCoins(userId, 50, 'achievement:' + (ach.slug || ach.title));
// Notify parents
const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId);
pushParentNotif(userId, 'achievement', `${u?.name || 'Ученик'} получил достижение: ${ach.title}`);
} catch (e) { console.error('[achievement]', e.message); }
}
/* ── Achievement check engine ─────────────────────────────────────────── */
function checkAchievements(userId) {
// Single query: user fields + session counts in one round-trip
const row = stmts.getUserForAch.get(userId);
if (!row) return;
const { test_count: testCount, perfect_count: perfectCount, class_count: classCount } = row;
// Tests
if (testCount >= 1) unlockAchievement(userId, 'first_test');
if (testCount >= 10) unlockAchievement(userId, 'tests_10');
if (testCount >= 50) unlockAchievement(userId, 'tests_50');
if (testCount >= 100) unlockAchievement(userId, 'tests_100');
// Perfect score
if (perfectCount >= 1) unlockAchievement(userId, 'first_perfect');
// Streaks
const streak = row.streak_current || 0;
if (streak >= 3) unlockAchievement(userId, 'streak_3');
if (streak >= 7) unlockAchievement(userId, 'streak_7');
if (streak >= 30) unlockAchievement(userId, 'streak_30');
// Level (always derive from XP)
const level = xpToLevel(row.xp || 0);
if (level >= 3) unlockAchievement(userId, 'level_3');
if (level >= 5) unlockAchievement(userId, 'level_5');
if (level >= 10) unlockAchievement(userId, 'level_10');
if (level >= 20) unlockAchievement(userId, 'level_20');
// XP
const xp = row.xp || 0;
if (xp >= 1000) unlockAchievement(userId, 'xp_1000');
if (xp >= 5000) unlockAchievement(userId, 'xp_5000');
if (xp >= 10000) unlockAchievement(userId, 'xp_10000');
// Class membership
if (classCount >= 1) unlockAchievement(userId, 'first_class');
// 5 tests in a row with ≥90%
const last5 = stmts.getLast5Tests.all(userId);
if (last5.length >= 5 && last5.every(r => r.total > 0 && (r.score / r.total) >= 0.9)) {
unlockAchievement(userId, 'score_90');
}
// Lab
const labExp = row.lab_experiments || 0;
const labReact = row.lab_reactions || 0;
if (labExp >= 1) unlockAchievement(userId, 'lab_first');
if (labExp >= 5) unlockAchievement(userId, 'lab_5');
if (labExp >= 20) unlockAchievement(userId, 'lab_20');
if (labExp >= 50) unlockAchievement(userId, 'lab_50');
if (labReact >= 10) unlockAchievement(userId, 'lab_reactions_10');
if (labReact >= 30) unlockAchievement(userId, 'lab_reactions_30');
// Assignments (via assignment_sessions)
try {
const ac = stmts.countUserAssignments.get(userId);
const assignCount = ac?.n || 0;
if (assignCount >= 1) unlockAchievement(userId, 'assign_first');
if (assignCount >= 10) unlockAchievement(userId, 'assign_10');
} catch (e) { console.error('[achievements] assignment check:', e.message); }
}
/* ── Hook: Red Book species collected / sighting added ─────────────────── */
function checkRedBookAchievements(userId) {
try {
const collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(userId)?.n || 0;
if (collected >= 1) unlockAchievement(userId, 'rb_first');
if (collected >= 10) unlockAchievement(userId, 'rb_10');
if (collected >= 25) unlockAchievement(userId, 'rb_25');
if (collected >= 50) unlockAchievement(userId, 'rb_50');
const crTotal = db.prepare("SELECT COUNT(*) as n FROM rb_species WHERE category = 'CR'").get().n;
const crCollected = db.prepare(`
SELECT COUNT(*) as n FROM rb_user_collection uc
JOIN rb_species s ON s.id = uc.species_id
WHERE uc.user_id = ? AND s.category = 'CR'
`).get(userId)?.n || 0;
if (crTotal > 0 && crCollected >= crTotal) unlockAchievement(userId, 'rb_all_cr');
const quests = db.prepare("SELECT COUNT(*) as n FROM rb_user_quests WHERE user_id = ? AND status = 'completed'").get(userId)?.n || 0;
if (quests >= 1) unlockAchievement(userId, 'rb_quest_first');
if (quests >= 5) unlockAchievement(userId, 'rb_quest_5');
const sightings = db.prepare('SELECT COUNT(*) as n FROM rb_sightings WHERE user_id = ?').get(userId)?.n || 0;
if (sightings >= 1) unlockAchievement(userId, 'rb_sighting');
checkAchievements(userId); // also check level/xp milestones
} catch (e) { console.error('[checkRedBookAchievements]', e.message); }
}
/* ── Hook: called after lesson marked complete ──────────────────────────── */
function onLessonComplete(userId, courseId) {
try {
awardXP(userId, 30, 'lesson_complete');
const done = db.prepare('SELECT COUNT(*) as n FROM lesson_progress WHERE user_id = ? AND completed = 1').get(userId)?.n || 0;
if (done >= 1) unlockAchievement(userId, 'theory_first');
if (done >= 10) unlockAchievement(userId, 'theory_10');
if (courseId) {
const total = db.prepare('SELECT COUNT(*) as n FROM lessons WHERE course_id = ? AND is_published = 1').get(courseId)?.n || 0;
const courseDone = db.prepare(`
SELECT COUNT(*) as n FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1
`).get(courseId, userId)?.n || 0;
if (total > 0 && courseDone >= total) unlockAchievement(userId, 'theory_course');
}
checkAchievements(userId);
} catch (e) { console.error('[onLessonComplete]', e.message); }
}
/* ── Hook: called after test finishes ─────────────────────────────────── */
function onTestFinished(userId, score, total, timeSec, testTimeLimitSec) {
const pct = total > 0 ? score / total : 0;
// XP for correct answers
awardXP(userId, score * 10, 'correct_answers');
// XP for completing test
awardXP(userId, 50, 'test_complete');
// Bonus for ≥90%
if (pct >= 0.9) awardXP(userId, 100, 'test_90+');
// Bonus for 100%
if (pct >= 1.0) awardXP(userId, 200, 'test_perfect');
// Bonus for assignment on time (handled in sessionController after checking deadline)
// Ecstatic mood bonus: +10% of base XP when pet streak >= 7
try {
const streakRow = stmts.getStreak.get(userId);
if (streakRow && (streakRow.streak_current || 0) >= 7) {
const moodBonus = Math.round(score * 10 * 0.10);
if (moodBonus > 0) awardXP(userId, moodBonus, 'mood_ecstatic');
}
} catch (e) { console.error('[onTestFinished] mood bonus:', e.message); }
// Speed demon check
if (testTimeLimitSec && timeSec < testTimeLimitSec * 0.5 && pct >= 0.9) {
unlockAchievement(userId, 'speed_demon');
}
// Update streak
updateStreak(userId);
// Check all achievements
checkAchievements(userId);
}
/* ── Hook: called when student joins class ────────────────────────────── */
function onClassJoined(userId) {
checkAchievements(userId);
}
/* ── Hook: called when student performs a lab experiment ───────────────── */
function onLabExperiment(userId, reactionsDiscovered) {
// reactionsDiscovered — number of unique reactions found in this session
stmts.incrLabExp.run(userId);
if (reactionsDiscovered > 0) {
stmts.incrLabReact.run(reactionsDiscovered, userId);
}
awardXP(userId, 15, 'lab_experiment');
checkAchievements(userId);
}
/* ── Daily goals ──────────────────────────────────────────────────────── */
const GOAL_TIERS = {
easy: { tests: 2, xp: 100, bonus: 30, label: 'Лёгкая' },
medium: { tests: 3, xp: 200, bonus: 50, label: 'Средняя' },
hard: { tests: 5, xp: 500, bonus: 100, label: 'Тяжёлая' },
};
function getDailyGoal(userId) {
const today = new Date().toISOString().slice(0, 10);
let goal = stmts.getDailyGoal.get(userId, today);
if (!goal) {
// Check user's preferred tier
const pref = stmts.getUserGoalTier.get(userId);
const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium;
stmts.insertDailyGoal.run(userId, today, tier.tests, tier.xp);
goal = stmts.getDailyGoal.get(userId, today);
}
return goal;
}
function updateDailyGoal(userId, addTests, addXp) {
const today = new Date().toISOString().slice(0, 10);
getDailyGoal(userId); // ensure exists
stmts.incrDailyGoal.run(addTests || 0, addXp || 0, userId, today);
// Check if goal completed
const goal = stmts.getDailyGoal.get(userId, today);
if (goal && goal.tests_done >= goal.tests_target && goal.xp_earned >= goal.xp_target) {
// Check if already awarded bonus today
const already = stmts.checkGoalBonus.get(userId, today);
if (!already) {
const pref = stmts.getUserGoalTier.get(userId);
const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium;
awardXP(userId, tier.bonus, 'daily_goal');
try {
sse.emit(userId, { type: 'daily_goal', message: `Дневная цель выполнена! +${tier.bonus} XP`, icon: 'target' });
} catch (e) { console.error('[daily_goal]', e.message); }
}
}
}
/* ── Personal Challenges ──────────────────────────────────────────────── */
function _currentWeek() {
const d = new Date();
const day = d.getDay();
const mon = new Date(d);
mon.setDate(mon.getDate() - ((day + 6) % 7));
return mon.toISOString().slice(0, 10);
}
function ensureChallenges(userId) {
const week = _currentWeek();
const existing = db.prepare('SELECT COUNT(*) AS cnt FROM challenges WHERE user_id = ? AND week = ?').get(userId, week);
if (existing.cnt > 0) return;
// Auto-generate 3 challenges based on weak topics + general goals
const weakTopics = db.prepare(`
SELECT t.id AS topic_id, t.name, s.slug AS subject_slug, s.name AS subject_name,
COUNT(CASE WHEN ua.is_correct = 0 THEN 1 END) AS wrong,
COUNT(*) AS total
FROM user_answers ua
JOIN session_questions sq ON sq.session_id = ua.session_id AND sq.question_id = ua.question_id
JOIN questions q ON q.id = ua.question_id
JOIN topics t ON t.id = q.topic_id
JOIN subjects s ON s.id = t.subject_id
JOIN test_sessions ts ON ts.id = ua.session_id AND ts.user_id = ?
GROUP BY t.id
HAVING wrong > 0
ORDER BY CAST(wrong AS REAL) / total DESC
LIMIT 5
`).all(userId);
const challenges = [];
// Challenge 1: Weak topic practice (if available)
if (weakTopics.length > 0) {
const wt = weakTopics[0];
challenges.push({
title: `Подтяни «${wt.name}»`,
description: `Пройди 3 теста по теме «${wt.name}» (${wt.subject_name})`,
type: 'topic_tests',
target: 3,
xp_reward: 150,
subject_slug: wt.subject_slug,
topic_id: wt.topic_id,
});
}
// Challenge 2: Score challenge
challenges.push({
title: 'Набери 80%+',
description: 'Заверши 3 теста с результатом не ниже 80%',
type: 'high_score',
target: 3,
xp_reward: 120,
subject_slug: null,
topic_id: null,
});
// Challenge 3: Volume challenge
challenges.push({
title: 'Марафонец',
description: 'Пройди 5 тестов на этой неделе',
type: 'tests',
target: 5,
xp_reward: 100,
subject_slug: null,
topic_id: null,
});
// Challenge 4: Streak challenge (if no weak topics)
if (weakTopics.length < 2) {
challenges.push({
title: 'Без ошибок',
description: 'Набери 100% в любом тесте',
type: 'perfect',
target: 1,
xp_reward: 200,
subject_slug: null,
topic_id: null,
});
}
const ins = db.prepare(`
INSERT OR IGNORE INTO challenges (user_id, week, title, description, type, target, xp_reward, subject_slug, topic_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const c of challenges) {
ins.run(userId, week, c.title, c.description, c.type, c.target, c.xp_reward, c.subject_slug, c.topic_id);
}
}
function updateChallenges(userId, score, total, subjectSlug, topicId) {
const week = _currentWeek();
const pct = total > 0 ? Math.round(score / total * 100) : 0;
const challenges = stmts.getOpenChallenges.all(userId, week);
for (const c of challenges) {
let inc = 0;
switch (c.type) {
case 'tests':
inc = 1;
break;
case 'topic_tests':
if (topicId && c.topic_id === topicId) inc = 1;
else if (subjectSlug && c.subject_slug === subjectSlug) inc = 1;
break;
case 'high_score':
if (pct >= 80) inc = 1;
break;
case 'perfect':
if (pct >= 100) inc = 1;
break;
}
if (inc > 0) {
stmts.incrChallenge.run(inc, c.id);
const updated = stmts.getChallengeById.get(c.id);
if (updated && updated.progress >= updated.target) {
stmts.completeChallenge.run(c.id);
try {
sse.emit(userId, { type: 'challenge', message: `Испытание «${c.title}» выполнено!`, icon: 'target' });
} catch (e) { console.error('[challenge]', e.message); }
}
}
}
}
function getChallenges(req, res) {
ensureChallenges(req.user.id);
const week = _currentWeek();
const rows = stmts.getChallengesWeek.all(req.user.id, week);
res.json(rows);
}
function claimChallenge(req, res) {
const id = Number(req.params.id);
const c = stmts.getChallengeOwned.get(id, req.user.id);
if (!c) return res.status(404).json({ error: 'Challenge not found' });
if (!c.completed) return res.status(400).json({ error: 'Challenge not completed yet' });
if (c.claimed) return res.status(400).json({ error: 'Already claimed' });
stmts.markClaimed.run(id);
awardXP(req.user.id, c.xp_reward, `Испытание: ${c.title}`);
// Bonus coins for challenges
awardCoins(req.user.id, Math.floor(c.xp_reward / 5), `Испытание: ${c.title}`);
res.json({ xp: c.xp_reward });
}
/* ═══════════════════════════════════════════════════════════════════════
API Handlers
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/gamification/me — current user XP, level, streak, goals */
function getMe(req, res) {
const info = getXPInfo(req.user.id);
if (!info) return res.status(404).json({ error: 'User not found' });
const goal = getDailyGoal(req.user.id);
const pref = stmts.getUserPrefs.get(req.user.id);
const tierKey = (pref && pref.goal_tier) || 'medium';
const frameId = (pref && pref.avatar_frame) || 'default';
const frame = AVATAR_FRAMES.find(f => f.id === frameId) || AVATAR_FRAMES[0];
res.json({ ...info, dailyGoal: goal, goalTier: tierKey, goalTiers: GOAL_TIERS, avatarFrame: frame });
}
/* GET /api/gamification/frames — available avatar frames */
function getFrames(req, res) {
const unlocked = stmts.getUnlockedSlugs.all(req.user.id).map(r => r.slug);
const user = stmts.getUserFrame.get(req.user.id);
const selected = (user && user.avatar_frame) || 'default';
const frames = AVATAR_FRAMES.map(f => ({
...f,
unlocked: !f.unlock || unlocked.includes(f.unlock),
selected: f.id === selected,
}));
res.json({ frames, selected });
}
/* POST /api/gamification/frame — set avatar frame */
function setFrame(req, res) {
const { frame } = req.body;
const f = AVATAR_FRAMES.find(fr => fr.id === frame);
if (!f) return res.status(400).json({ error: 'Unknown frame' });
if (f.unlock) {
const ach = stmts.checkFrameUnlock.get(f.unlock, req.user.id);
if (!ach) return res.status(403).json({ error: 'Frame not unlocked' });
}
stmts.setUserFrame.run(frame, req.user.id);
res.json({ frame, css: f.css });
}
/* POST /api/gamification/goal-tier — set daily goal difficulty */
function setGoalTier(req, res) {
const { tier } = req.body;
if (!GOAL_TIERS[tier]) return res.status(400).json({ error: 'Invalid tier. Use: easy, medium, hard' });
stmts.setUserGoalTier.run(tier, req.user.id);
res.json({ tier, ...GOAL_TIERS[tier] });
}
/* GET /api/gamification/achievements — all achievements + user unlocks */
function getAchievements(req, res) {
const all = stmts.getAllAchs.all();
const unlocked = stmts.getUserAchs.all(req.user.id);
const unlockedMap = {};
for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at;
const result = all.map(a => ({ ...a, unlocked: !!unlockedMap[a.id], unlocked_at: unlockedMap[a.id] || null }));
res.json(result);
}
/* GET /api/gamification/leaderboard?class_id=X&period=week|all */
function getLeaderboard(req, res) {
const classId = req.query.class_id ? Number(req.query.class_id) : null;
const period = req.query.period || 'all';
let rows;
if (period === 'week') {
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
if (classId) {
rows = db.prepare(`
SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level,
u.streak_current AS streak
FROM users u
JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ?
LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ?
WHERE u.role = 'student'
GROUP BY u.id
ORDER BY week_xp DESC
LIMIT 30
`).all(classId, weekAgo);
} else {
rows = db.prepare(`
SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level,
u.streak_current AS streak
FROM users u
LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ?
WHERE u.role = 'student'
GROUP BY u.id
ORDER BY week_xp DESC
LIMIT 30
`).all(weekAgo);
}
rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.week_xp; });
} else {
if (classId) {
rows = db.prepare(`
SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak
FROM users u
JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ?
WHERE u.role = 'student'
ORDER BY u.xp DESC
LIMIT 30
`).all(classId);
} else {
rows = db.prepare(`
SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak
FROM users u
WHERE u.role = 'student'
ORDER BY u.xp DESC
LIMIT 30
`).all();
}
rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.xp; });
}
// add rank names
rows.forEach(r => { r.rank = rankName(r.level || 1); });
res.json({ rows, period, class_id: classId });
}
/* GET /api/gamification/xp-history — recent XP log */
function getXPHistory(req, res) {
const limit = Math.min(50, Number(req.query.limit) || 20);
const rows = stmts.xpHistory.all(req.user.id, limit);
res.json(rows);
}
/* ═══════════════════════════════════════════════════════════════════════
Admin — XP/coins management, stats, reset
═══════════════════════════════════════════════════════════════════════ */
/* POST /api/gamification/admin/award — award XP or coins to user */
function adminAward(req, res) {
const { userId, xp, coins, reason } = req.body;
if (!userId) return res.status(400).json({ error: 'userId required' });
const user = stmts.checkUserById.get(userId);
if (!user) return res.status(404).json({ error: 'User not found' });
if (xp && xp > 0) awardXP(userId, xp, reason || 'Admin award');
if (coins && coins > 0) awardCoins(userId, coins, reason || 'Admin award');
const updated = stmts.getUserGamInfo.get(userId);
res.json({ ok: true, ...updated });
}
/* POST /api/gamification/admin/reset — reset user gamification */
const _resetTx = db.transaction((userId) => {
stmts.adminResetUser.run(userId);
stmts.deleteXpLog.run(userId);
stmts.deleteUserAchs.run(userId);
stmts.deleteDailyGoals.run(userId);
stmts.deleteChallenges.run(userId);
stmts.deleteUserPurch.run(userId);
});
function adminReset(req, res) {
const { userId } = req.body;
if (!userId) return res.status(400).json({ error: 'userId required' });
_resetTx(userId);
res.json({ ok: true });
}
/* GET /api/gamification/admin/stats — global gamification stats */
function adminGamStats(_req, res) {
const totalXP = stmts.adminTotalXP.get().v;
const totalCoins = stmts.adminTotalCoins.get().v;
const avgLevel = stmts.adminAvgLevel.get().v;
const achievementCount = stmts.adminAchCount.get().v;
const topByXP = stmts.adminTopXP.all();
const recentXP = stmts.adminRecentXP.all();
const totalPurchases = stmts.adminTotalPurch.get().v;
const recentPurchases = stmts.adminRecentPurch.all();
res.json({ totalXP, totalCoins, avgLevel, achievementCount, totalPurchases, topByXP, recentXP, recentPurchases });
}
/* GET /api/gamification/admin/user/:id — user gamification details */
function adminGetUser(req, res) {
const uid = Number(req.params.id);
const user = stmts.adminGetUserFull.get(uid);
if (!user) return res.status(404).json({ error: 'User not found' });
const achievements = stmts.adminGetUserAchs.all(uid);
const purchases = stmts.adminGetUserPurch.all(uid);
const xpHistory = stmts.adminGetUserXPH.all(uid);
res.json({ user, achievements, purchases, xpHistory });
}
module.exports = {
// API handlers
getMe, getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge, setGoalTier,
getFrames, setFrame,
// Admin handlers
adminAward, adminReset, adminGamStats, adminGetUser,
// Service functions for other controllers
onTestFinished, onClassJoined, onLabExperiment, updateDailyGoal, updateChallenges,
onLessonComplete, checkRedBookAchievements,
awardXP, awardCoins, seedAchievements, checkAchievements,
};
@@ -0,0 +1,65 @@
'use strict';
const db = require('../db/db');
/* ── GET /api/knowledge-map?subject_slug=bio ──────────────────────────── */
function getMap(req, res) {
const { subject_slug } = req.query;
const uid = req.user.id;
let subjects;
if (subject_slug) {
subjects = db.prepare('SELECT id, slug, name, icon FROM subjects WHERE slug = ?').all(subject_slug);
} else {
subjects = db.prepare('SELECT id, slug, name, icon FROM subjects ORDER BY name').all();
}
const nodes = [];
const links = [];
for (const subj of subjects) {
nodes.push({
id: `subj_${subj.id}`,
type: 'subject',
label: subj.name,
icon: subj.icon,
slug: subj.slug,
mastery: null,
});
const topics = db.prepare(`
SELECT t.id, t.name,
COUNT(DISTINCT q.id) AS total_q,
COUNT(DISTINCT CASE WHEN ua.is_correct = 1 THEN ua.question_id END) AS correct_q
FROM topics t
LEFT JOIN questions q ON q.topic_id = t.id
LEFT JOIN user_answers ua ON ua.question_id = q.id
AND ua.session_id IN (
SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed'
)
WHERE t.subject_id = ?
GROUP BY t.id
ORDER BY t.order_index
`).all(uid, subj.id);
for (const topic of topics) {
const mastery = topic.total_q > 0
? Math.round((topic.correct_q / topic.total_q) * 100)
: null;
nodes.push({
id: `topic_${topic.id}`,
type: 'topic',
label: topic.name,
mastery,
totalQ: topic.total_q,
correctQ: topic.correct_q,
});
links.push({ source: `subj_${subj.id}`, target: `topic_${topic.id}` });
}
}
res.json({ nodes, links });
}
module.exports = { getMap };
+296
View File
@@ -0,0 +1,296 @@
const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController');
/* ── helpers ──────────────────────────────────────────────────────────── */
function parseBlock(b) {
let data = {};
try { data = JSON.parse(b.data); } catch {}
return { id: b.id, type: b.type, orderIndex: b.order_index, data };
}
// Estimate read time from blocks (words / 200 wpm)
// Accepts blocks with data already parsed as objects (not JSON strings)
function calcReadTime(blocks) {
let words = 0;
for (const b of blocks) {
const d = (typeof b.data === 'string') ? (() => { try { return JSON.parse(b.data); } catch { return {}; } })() : (b.data || {});
if (b.type === 'text') words += (d.html || d.text || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
if (b.type === 'heading') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'quiz') words += 8;
if (b.type === 'accordion') words += (d.content || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'timeline' && Array.isArray(d.items))
for (const it of d.items) words += ((it.title||'') + ' ' + (it.text||'')).split(/\s+/).filter(Boolean).length;
if (b.type === 'geogebra' || b.type === 'diagram') words += 15;
if (b.type === 'audio') words += 30;
if (b.type === 'video') words += 30;
if (b.type === 'alert') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'columns' && Array.isArray(d.cols))
for (const col of d.cols) words += (col.content || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
}
return Math.max(1, Math.ceil(words / 200));
}
/* ── GET /api/lessons/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT l.*, c.title AS course_title, c.is_published AS course_published
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'Lesson not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Lesson not published' });
if (role === 'student' && !row.course_published)
return res.status(403).json({ error: 'Course not published' });
const lesson = row;
const course = { title: row.course_title };
const blocks = db.prepare(
'SELECT id, type, order_index, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(lesson.id).map(parseBlock);
const progress = db.prepare(
'SELECT completed FROM lesson_progress WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
const note = db.prepare(
'SELECT text FROM lesson_notes WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
// adjacent lessons
const pubWhere = role === 'student' ? 'AND is_published = 1' : '';
const siblings = db.prepare(`
SELECT id, title FROM lessons WHERE course_id = ? ${pubWhere} ORDER BY order_index, id
`).all(lesson.course_id);
const idx = siblings.findIndex(s => s.id === lesson.id);
const prev = idx > 0 ? siblings[idx - 1] : null;
const next = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null;
res.json({
id: lesson.id,
courseId: lesson.course_id,
courseTitle: course.title,
title: lesson.title,
orderIndex: lesson.order_index,
isPublished: lesson.is_published === 1,
sectionId: lesson.section_id,
readTime: lesson.read_time || 0,
blocks,
completed: progress?.completed === 1,
note: note?.text || '',
prev: prev ? { id: prev.id, title: prev.title } : null,
next: next ? { id: next.id, title: next.title } : null,
});
}
/* ── POST /api/lessons ────────────────────────────────────────────────── */
function create(req, res) {
const { courseId, title, orderIndex, sectionId } = req.body;
if (!courseId || !title)
return res.status(400).json({ error: 'courseId and title required' });
if (!db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId))
return res.status(404).json({ error: 'Course not found' });
const r = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(courseId, title.trim(), orderIndex ?? 0, sectionId || null);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/lessons/:id ─────────────────────────────────────────────── */
function update(req, res) {
const lesson = db.prepare(`
SELECT l.*, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, orderIndex, isPublished, sectionId } = req.body;
db.prepare(`
UPDATE lessons SET title=?, order_index=?, is_published=?, section_id=? WHERE id=?
`).run(
title ?? lesson.title,
orderIndex ?? lesson.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : lesson.is_published,
sectionId !== undefined ? (sectionId || null) : lesson.section_id,
lesson.id
);
res.json({ ok: true });
}
/* ── DELETE /api/lessons/:id ──────────────────────────────────────────── */
function remove(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lessons WHERE id = ?').run(lesson.id);
res.json({ ok: true });
}
/* ── PUT /api/lessons/:id/blocks ──────────────────────────────────────── */
function saveBlocks(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const blocks = req.body.blocks;
if (!Array.isArray(blocks))
return res.status(400).json({ error: 'blocks must be an array' });
const VALID_TYPES = ['heading','text','formula','image','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert'];
db.transaction(() => {
db.prepare('DELETE FROM lesson_blocks WHERE lesson_id = ?').run(lesson.id);
const ins = db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
);
blocks.forEach((b, i) => {
const type = VALID_TYPES.includes(b.type) ? b.type : 'text';
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(b.data || {}));
});
// recalculate read time — pass already-parsed data objects, no double stringify/parse
const rt = calcReadTime(blocks);
db.prepare('UPDATE lessons SET read_time = ? WHERE id = ?').run(rt, lesson.id);
})();
res.json({ ok: true, count: blocks.length });
}
/* ── POST /api/lessons/:id/complete ──────────────────────────────────── */
function markComplete(req, res) {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
db.prepare(`
INSERT INTO lesson_progress (user_id, lesson_id, completed, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET completed=1, updated_at=datetime('now')
`).run(req.user.id, lesson.id);
const total = db.prepare(
'SELECT COUNT(*) AS n FROM lessons WHERE course_id = ? AND is_published = 1'
).get(lesson.course_id).n;
const done = db.prepare(`
SELECT COUNT(*) AS n FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1
`).get(lesson.course_id, req.user.id).n;
try { onLessonComplete(req.user.id, lesson.course_id); } catch {}
res.json({ ok: true, courseComplete: done >= total && total > 0 });
}
/* ── PUT /api/lessons/:id/note ────────────────────────────────────────── */
function saveNote(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').slice(0, 5000);
db.prepare(`
INSERT INTO lesson_notes (user_id, lesson_id, text, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET text=excluded.text, updated_at=datetime('now')
`).run(req.user.id, lesson.id, text);
res.json({ ok: true });
}
/* ── GET /api/lessons/:id/comments ──────────────────────────────────── */
function listComments(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const rows = db.prepare(`
SELECT c.id, c.lesson_id, c.user_id, c.parent_id, c.text, c.created_at,
u.name AS user_name, u.role AS user_role
FROM lesson_comments c
JOIN users u ON c.user_id = u.id
WHERE c.lesson_id = ?
ORDER BY c.created_at ASC
`).all(lesson.id);
// build threaded structure: top-level + replies
const top = [];
const byId = {};
rows.forEach(r => {
const item = {
id: r.id, lessonId: r.lesson_id, userId: r.user_id,
parentId: r.parent_id, text: r.text, createdAt: r.created_at,
userName: r.user_name, userRole: r.user_role,
replies: [],
};
byId[r.id] = item;
if (!r.parent_id) {
top.push(item);
} else if (byId[r.parent_id]) {
byId[r.parent_id].replies.push(item);
} else {
top.push(item); // orphan → treat as top-level
}
});
res.json(top);
}
/* ── POST /api/lessons/:id/comments ────────────────────────────────── */
function addComment(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').trim();
if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' });
const parentId = req.body.parentId || null;
if (parentId) {
const parent = db.prepare('SELECT id FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(parentId, lesson.id);
if (!parent) return res.status(400).json({ error: 'Parent comment not found' });
}
const r = db.prepare(
'INSERT INTO lesson_comments (lesson_id, user_id, parent_id, text) VALUES (?, ?, ?, ?)'
).run(lesson.id, req.user.id, parentId, text);
// notify lesson author / teacher if comment is from student
try {
const course = db.prepare('SELECT created_by FROM courses WHERE id = (SELECT course_id FROM lessons WHERE id = ?)').get(lesson.id);
if (course && course.created_by !== req.user.id) {
const userName = db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id)?.name || 'Ученик';
const lessonTitle = db.prepare('SELECT title FROM lessons WHERE id = ?').get(lesson.id)?.title || 'Урок';
db.prepare(
'INSERT INTO notifications (user_id, type, message, link) VALUES (?, ?, ?, ?)'
).run(course.created_by, 'comment', `${userName} оставил(а) комментарий к уроку «${lessonTitle}»`,
`/lesson?id=${lesson.id}#comments`);
}
} catch {}
res.status(201).json({ id: Number(r.lastInsertRowid) });
}
/* ── DELETE /api/lessons/:id/comments/:cid ─────────────────────────── */
function deleteComment(req, res) {
const comment = db.prepare('SELECT * FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(req.params.cid, req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
// only author or teacher/admin can delete
const isOwner = comment.user_id === req.user.id;
const isPrivileged = ['teacher', 'admin'].includes(req.user.role);
if (!isOwner && !isPrivileged) return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lesson_comments WHERE id = ?').run(comment.id);
res.json({ ok: true });
}
module.exports = { get, create, update, remove, saveBlocks, markComplete, saveNote, listComments, addComment, deleteComment };
+197
View File
@@ -0,0 +1,197 @@
const db = require('../db/db');
const { emit, emitToClass } = require('../sse');
/* POST /api/live — teacher creates live session */
function create(req, res) {
const { class_id } = req.body;
const teacher = req.user;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
const cls = teacher.role === 'admin'
? db.prepare('SELECT id, name FROM classes WHERE id=?').get(class_id)
: db.prepare('SELECT id, name FROM classes WHERE id=? AND teacher_id=?').get(class_id, teacher.id);
if (!cls) return res.status(403).json({ error: 'Forbidden' });
// End any active session for this class
db.prepare(`UPDATE live_sessions SET status='finished', ended_at=datetime('now')
WHERE class_id=? AND status IN ('waiting','active')`).run(class_id);
const { lastInsertRowid } = db.prepare(
`INSERT INTO live_sessions (class_id, teacher_id) VALUES (?,?)`
).run(class_id, teacher.id);
emitToClass(class_id, { type: 'live_started', liveId: lastInsertRowid, className: cls.name });
res.json(db.prepare('SELECT * FROM live_sessions WHERE id=?').get(lastInsertRowid));
}
/* PUT /api/live/:id/question — teacher sets next question */
function setQuestion(req, res) {
const liveId = Number(req.params.id);
const { question_id } = req.body;
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM live_answers WHERE live_session_id=?').run(liveId);
db.prepare(`UPDATE live_sessions SET question_id=?, status='active', show_results=0 WHERE id=?`)
.run(question_id, liveId);
const q = db.prepare(
`SELECT q.id, q.text, q.type, q.difficulty, t.name AS topic
FROM questions q LEFT JOIN topics t ON t.id=q.topic_id WHERE q.id=?`
).get(question_id);
const options = db.prepare(
'SELECT id, text, order_index FROM options WHERE question_id=? ORDER BY order_index'
).all(question_id);
emitToClass(live.class_id, { type: 'live_question', liveId, question: { ...q, options } });
res.json({ ok: true, question: { ...q, options } });
}
/* POST /api/live/:id/answer — student submits answer */
function answer(req, res) {
const liveId = Number(req.params.id);
const { option_id, answer_text } = req.body;
const userId = req.user.id;
const live = db.prepare(`SELECT * FROM live_sessions WHERE id=? AND status='active'`).get(liveId);
if (!live) return res.status(404).json({ error: 'Сессия не активна' });
// Verify caller is a member of the class hosting this live session
const isMember = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(live.class_id, userId);
if (!isMember) return res.status(403).json({ error: 'Forbidden' });
const existing = db.prepare(
'SELECT id FROM live_answers WHERE live_session_id=? AND user_id=?'
).get(liveId, userId);
if (existing) return res.status(409).json({ error: 'Уже отвечено' });
let is_correct = null;
if (option_id) {
const opt = db.prepare(
'SELECT is_correct FROM options WHERE id=? AND question_id=?'
).get(option_id, live.question_id);
is_correct = opt ? opt.is_correct : 0;
}
db.prepare(
`INSERT INTO live_answers (live_session_id, user_id, option_id, answer_text, is_correct)
VALUES (?,?,?,?,?)`
).run(liveId, userId, option_id || null, answer_text || null, is_correct);
const { c: count } = db.prepare(
'SELECT COUNT(*) AS c FROM live_answers WHERE live_session_id=?'
).get(liveId);
emit(live.teacher_id, { type: 'live_answer_count', liveId, count });
res.json({ ok: true, is_correct });
}
/* GET /api/live/:id/results — teacher reveals & gets results */
function results(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
const question = db.prepare(
'SELECT id, text, explanation FROM questions WHERE id=?'
).get(live.question_id);
const options = db.prepare(`
SELECT o.id, o.text, o.is_correct,
COUNT(la.id) AS chosen_count
FROM options o
LEFT JOIN live_answers la ON la.option_id=o.id AND la.live_session_id=?
WHERE o.question_id=?
GROUP BY o.id ORDER BY o.order_index
`).all(liveId, live.question_id);
const stats = db.prepare(
`SELECT COUNT(*) AS total, SUM(is_correct) AS correct
FROM live_answers WHERE live_session_id=?`
).get(liveId);
db.prepare('UPDATE live_sessions SET show_results=1 WHERE id=?').run(liveId);
emitToClass(live.class_id, { type: 'live_results', liveId, question, options, stats });
res.json({ question, options, stats });
}
/* DELETE /api/live/:id — teacher ends session */
function end(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare(`UPDATE live_sessions SET status='finished', ended_at=datetime('now') WHERE id=?`).run(liveId);
emitToClass(live.class_id, { type: 'live_ended', liveId });
res.json({ ok: true });
}
/* GET /api/live/class/:classId/active — student polls active session */
function getActive(req, res) {
const classId = Number(req.params.classId);
// Only class members (or teachers/admins) may poll
if (!['teacher', 'admin'].includes(req.user.role)) {
const isMember = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(classId, req.user.id);
if (!isMember) return res.status(403).json({ error: 'Forbidden' });
}
const live = db.prepare(`
SELECT * FROM live_sessions WHERE class_id=? AND status IN ('waiting','active')
ORDER BY id DESC LIMIT 1
`).get(classId);
if (!live) return res.json({ active: false });
let question = null, options = null, myAnswer = null;
if (live.question_id && live.status === 'active') {
question = db.prepare('SELECT id, text, type FROM questions WHERE id=?').get(live.question_id);
options = db.prepare(
'SELECT id, text, order_index FROM options WHERE question_id=? ORDER BY order_index'
).all(live.question_id);
myAnswer = db.prepare(
'SELECT option_id, is_correct FROM live_answers WHERE live_session_id=? AND user_id=?'
).get(live.id, req.user.id);
}
res.json({ active: true, live, question, options, myAnswer, showResults: live.show_results });
}
/* GET /api/live/:id — get session info (teacher) */
function getSession(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
const answerCount = db.prepare(
'SELECT COUNT(*) AS c FROM live_answers WHERE live_session_id=?'
).get(liveId).c;
const memberCount = db.prepare(
'SELECT COUNT(*) AS c FROM class_members WHERE class_id=?'
).get(live.class_id).c;
res.json({ ...live, answerCount, memberCount });
}
module.exports = { create, setQuestion, answer, results, end, getActive, getSession };
@@ -0,0 +1,66 @@
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { addClient, removeClient } = require('../sse');
const _stmts = {
list: db.prepare('SELECT id, type, message, link, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'),
markOne: db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?'),
markAll: db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ?'),
getUser: db.prepare('SELECT id, token_version, is_banned FROM users WHERE id = ?'),
};
/* ── GET /api/notifications ─────────────────────────────────────────────── */
function list(req, res) {
const rows = _stmts.list.all(req.user.id);
const unread = rows.filter(r => !r.is_read).length;
res.json({ notifications: rows, unread });
}
/* ── PATCH /api/notifications/:id/read ──────────────────────────────────── */
function markRead(req, res) {
_stmts.markOne.run(req.params.id, req.user.id);
res.json({ ok: true });
}
/* ── POST /api/notifications/read-all ───────────────────────────────────── */
function markAllRead(req, res) {
_stmts.markAll.run(req.user.id);
res.json({ ok: true });
}
/* ── GET /api/notifications/stream ── SSE (auth via ?token=JWT) ─────────── */
function stream(req, res) {
const token = req.query.token;
if (!token) return res.status(401).end();
let userId;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const fresh = _stmts.getUser.get(payload.id);
if (!fresh) return res.status(401).end();
if (fresh.is_banned) return res.status(403).end();
if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end();
userId = payload.id;
} catch {
return res.status(401).end();
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Referrer-Policy', 'no-referrer');
res.flushHeaders();
addClient(userId, res);
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
const hb = setInterval(() => { try { res.write(':hb\n\n'); } catch {} }, 25_000);
req.on('close', () => {
clearInterval(hb);
removeClient(userId, res);
});
}
module.exports = { list, markRead, markAllRead, stream };
+348
View File
@@ -0,0 +1,348 @@
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { JWT_SECRET } = require('../config');
/* ── Prepared statements ────────────────────────────────────────────── */
const stmts = {
linkByToken: db.prepare('SELECT * FROM parent_links WHERE token = ?'),
linkById: db.prepare('SELECT * FROM parent_links WHERE id = ?'),
linksByStudent: db.prepare('SELECT id, token, label, is_active, last_used, created_at, expires_at FROM parent_links WHERE student_id = ? ORDER BY created_at DESC'),
linkCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_links WHERE student_id = ?'),
insertLink: db.prepare('INSERT INTO parent_links (student_id, token, label) VALUES (?, ?, ?)'),
updateLink: db.prepare('UPDATE parent_links SET label = ?, is_active = ? WHERE id = ?'),
deleteLink: db.prepare('DELETE FROM parent_links WHERE id = ?'),
updateLastUsed: db.prepare("UPDATE parent_links SET last_used = datetime('now') WHERE id = ?"),
/* Mega CTE: student info + stats + heatmap + weekly + recent activity in ONE query */
dashboardMega: db.prepare(`
WITH base AS (
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = @uid AND ts.status = 'completed'
),
hm AS (
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
FROM test_sessions ts
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
GROUP BY day ORDER BY day
),
week_activity AS (
SELECT COUNT(*) AS cnt,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE started_at >= date('now', 'weekday 0', '-7 days')
)
SELECT
(SELECT json_object('name',u.name,'xp',u.xp,'level',u.level,
'streak_current',u.streak_current,'streak_best',u.streak_best,'coins',u.coins)
FROM users u WHERE u.id = @uid) AS student,
(SELECT COALESCE(json_group_array(json_object(
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE finished_at >= date('now', '-56 days')
GROUP BY week ORDER BY week)) AS weekly,
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
FROM hm) AS heatmap,
(SELECT COALESCE(json_group_array(json_object(
'slug', subject_slug, 'name', subject_name,
'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
(SELECT COALESCE(json_group_array(json_object(
'id', id, 'score', score, 'total', total,
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
FROM (SELECT id, score, total, finished_at, subject_slug
FROM base ORDER BY finished_at ASC LIMIT 20)) AS trend,
(SELECT json_object(
'sessions', COUNT(*),
'correct', COALESCE(SUM(score), 0),
'questions',COALESCE(SUM(total), 0),
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
FROM base) AS totals,
(SELECT MAX(started_at) FROM test_sessions WHERE user_id = @uid) AS lastSessionDate,
(SELECT json_object('cnt', cnt, 'avg_pct', avg_pct) FROM week_activity) AS weekActivity
`),
weakTopics: db.prepare(`
SELECT t.name AS topic, s.name AS subject_name,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100, 0) AS error_pct
FROM user_answers ua
JOIN test_sessions ts ON ts.id = ua.session_id
JOIN questions q ON q.id = ua.question_id
JOIN topics t ON t.id = q.topic_id
JOIN subjects s ON s.id = q.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
GROUP BY q.topic_id HAVING total >= 2
ORDER BY error_pct DESC, wrong DESC LIMIT 5
`),
courseProgress: db.prepare(`
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
ORDER BY done_lessons * 1.0 / total_lessons DESC LIMIT 10
`),
upcomingDeadlines: db.prepare(`
SELECT a.id, a.title, a.deadline, a.subject_slug,
(SELECT COUNT(*) FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = a.id AND ts.user_id = ? AND ts.status = 'completed') AS done
FROM assignments a
JOIN class_members cm ON cm.class_id = a.class_id AND cm.user_id = ?
WHERE a.deadline IS NOT NULL AND a.deadline >= date('now', '-7 days')
ORDER BY a.deadline ASC LIMIT 5
`),
recentSubmissions: db.prepare(`
SELECT s.id, s.original_name, s.status, s.grade,
s.submitted_at, a.title AS assignment_title
FROM submissions s
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.student_id = ?
ORDER BY s.submitted_at DESC LIMIT 10
`),
notifications: db.prepare(`
SELECT id, type, message, is_read, created_at
FROM parent_notifications WHERE parent_link_id = ?
ORDER BY created_at DESC LIMIT 50
`),
markNotifRead: db.prepare('UPDATE parent_notifications SET is_read = 1 WHERE id = ? AND parent_link_id = ?'),
unreadCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_notifications WHERE parent_link_id = ? AND is_read = 0'),
history: db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.id < ?
ORDER BY ts.id DESC LIMIT ?
`),
historyFirst: db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.id DESC LIMIT ?
`),
};
/* ══════════════════════════════════════════════════════════════════════
STUDENT ENDPOINTS (regular authMiddleware)
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/parent/my-links ──────────────────────────────────────── */
function getMyLinks(req, res) {
res.json(stmts.linksByStudent.all(req.user.id));
}
/* ── POST /api/parent/links ────────────────────────────────────────── */
function createLink(req, res) {
const { cnt } = stmts.linkCount.get(req.user.id);
if (cnt >= 3) return res.status(400).json({ error: 'Maximum 3 parent links allowed' });
const label = (req.body.label || '').trim().slice(0, 50) || 'Родитель';
const token = crypto.randomBytes(24).toString('hex');
const r = stmts.insertLink.run(req.user.id, token, label);
res.status(201).json({
id: r.lastInsertRowid,
token,
label,
url: `${req.protocol}://${req.get('host')}/parent?t=${token}`,
});
}
/* ── PATCH /api/parent/links/:id ───────────────────────────────────── */
function updateLink(req, res) {
const link = stmts.linkById.get(Number(req.params.id));
if (!link || link.student_id !== req.user.id)
return res.status(404).json({ error: 'Link not found' });
const label = req.body.label !== undefined ? String(req.body.label).trim().slice(0, 50) : link.label;
const is_active = req.body.is_active !== undefined ? (req.body.is_active ? 1 : 0) : link.is_active;
stmts.updateLink.run(label, is_active, link.id);
res.json({ ok: true });
}
/* ── DELETE /api/parent/links/:id ──────────────────────────────────── */
function deleteLink(req, res) {
const link = stmts.linkById.get(Number(req.params.id));
if (!link || link.student_id !== req.user.id)
return res.status(404).json({ error: 'Link not found' });
stmts.deleteLink.run(link.id);
res.json({ ok: true });
}
/* ══════════════════════════════════════════════════════════════════════
PARENT ENDPOINTS (parentAuth middleware)
══════════════════════════════════════════════════════════════════════ */
/* ── POST /api/parent/auth — exchange link token for parent JWT ────── */
function exchangeToken(req, res) {
const { token } = req.body;
if (!token || typeof token !== 'string')
return res.status(400).json({ error: 'token required' });
const link = stmts.linkByToken.get(token);
if (!link || !link.is_active)
return res.status(404).json({ error: 'Link not found or deactivated' });
if (link.expires_at && new Date(link.expires_at) < new Date())
return res.status(410).json({ error: 'Link expired' });
// Update last_used (only here, not on every request)
try { stmts.updateLastUsed.run(link.id); } catch {}
const parentJwt = jwt.sign(
{ type: 'parent', linkId: link.id, studentId: link.student_id },
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '24h' }
);
// Inline student fetch (avoid importing studentBasic for this one-off)
const student = db.prepare('SELECT name, level, streak_current FROM users WHERE id = ?').get(link.student_id);
res.json({
jwt: parentJwt,
student: {
name: student?.name,
level: student?.level,
streak_current: student?.streak_current,
},
});
}
/* ── GET /api/parent/dashboard — aggregated overview ─────────────────
Optimized: 1 mega CTE + 3 focused queries = 4 total (was 9)
──────────────────────────────────────────────────────────────────── */
function getDashboard(req, res) {
const uid = req.parent.studentId;
// 1. Mega query: student + stats + heatmap + weekly + totals + recent activity
const row = stmts.dashboardMega.get({ uid });
const student = JSON.parse(row.student);
if (!student) return res.status(404).json({ error: 'Student not found' });
const weekly = JSON.parse(row.weekly);
const heatmap = JSON.parse(row.heatmap);
const bySubject = JSON.parse(row.bySubject);
const trend = JSON.parse(row.trend); // Already ASC from SQL
const totals = JSON.parse(row.totals);
const weekAct = JSON.parse(row.weekActivity);
// 2. Weak topics (separate — heavy JOIN, can't merge into CTE)
const weakTopics = stmts.weakTopics.all(uid);
// 3. Deadlines + submissions (lightweight)
const deadlines = stmts.upcomingDeadlines.all(uid, uid);
const submissions = stmts.recentSubmissions.all(uid);
// 4. Course progress + unread notifs
const courseProgress = stmts.courseProgress.all({ uid });
const { cnt: unreadNotifs } = stmts.unreadCount.get(req.parent.linkId);
// Alerts (computed in JS, zero DB cost)
const alerts = [];
if (row.lastSessionDate) {
const daysSince = Math.floor((Date.now() - new Date(row.lastSessionDate).getTime()) / 86400000);
if (daysSince >= 3) {
alerts.push({ type: 'low_activity', icon: 'alert-triangle',
message: `${student.name} не занимался ${daysSince} дней` });
}
}
for (const d of deadlines) {
if (d.done === 0 && new Date(d.deadline) < new Date()) {
alerts.push({ type: 'deadline_missed', icon: 'clock',
message: `Пропущен дедлайн: ${d.title}` });
}
}
res.json({
student,
totals: {
sessions: totals.sessions || 0,
correct: totals.correct || 0,
questions: totals.questions || 0,
avgPct: Math.round(totals.avg_pct || 0),
},
recentActivity: {
lastSessionDate: row.lastSessionDate || null,
sessionsThisWeek: weekAct?.cnt || 0,
avgPctThisWeek: Math.round(weekAct?.avg_pct || 0),
},
weeklyStats: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
bySubject: bySubject.map(r => ({
slug: r.slug, name: r.name, sessions: r.sessions,
avgPct: Math.round(r.avg_pct || 0),
})),
trend: trend.map(r => ({
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
date: r.finished_at, subject: r.subject_slug,
})),
weakTopics: weakTopics.map(r => ({
topic: r.topic, subject: r.subject_name, errorPct: r.error_pct,
})),
deadlines: deadlines.map(d => ({
id: d.id, title: d.title, deadline: d.deadline,
subject: d.subject_slug, done: d.done > 0,
})),
submissions: submissions.map(s => ({
id: s.id, name: s.original_name, status: s.status,
grade: s.grade, date: s.submitted_at, assignment: s.assignment_title,
})),
courseProgress: courseProgress.map(r => ({
id: r.id, title: r.title, emoji: r.cover_emoji,
done: r.done_lessons, total: r.total_lessons,
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
})),
alerts,
unreadNotifs,
});
}
/* ── GET /api/parent/history ─────────────────────────────────────────── */
function getHistory(req, res) {
const uid = req.parent.studentId;
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
const cursor = Number(req.query.cursor) || 0;
const rows = cursor
? stmts.history.all(uid, cursor, limit)
: stmts.historyFirst.all(uid, limit);
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
res.json({ rows, nextCursor });
}
/* ── GET /api/parent/notifications ───────────────────────────────────── */
function getNotifications(req, res) {
res.json(stmts.notifications.all(req.parent.linkId));
}
/* ── PATCH /api/parent/notifications/:id/read ────────────────────────── */
function markRead(req, res) {
stmts.markNotifRead.run(Number(req.params.id), req.parent.linkId);
res.json({ ok: true });
}
module.exports = {
getMyLinks, createLink, updateLink, deleteLink,
exchangeToken, getDashboard, getHistory, getNotifications, markRead,
};
@@ -0,0 +1,301 @@
const db = require('../db/db');
/* ── All known permissions ─────────────────────────────────────────────── */
const ALL_PERMISSIONS = [
/* ── Teacher ── */
{
key: 'questions.manage',
role: 'teacher',
label: 'Управление вопросами',
desc: 'Создавать, редактировать и копировать вопросы в банке',
default: 0,
},
{
key: 'questions.delete',
role: 'teacher',
label: 'Удалять вопросы',
desc: 'Удалять вопросы из банка (требует "Управление вопросами")',
default: 0,
},
{
key: 'students.invite',
role: 'teacher',
label: 'Регистрировать учеников',
desc: 'Создавать новые аккаунты учеников напрямую из панели',
default: 0,
},
{
key: 'sessions.reset',
role: 'teacher',
label: 'Сброс попыток',
desc: 'Сбрасывать прохождение теста ученика в своём классе',
default: 1,
},
{
key: 'results.export',
role: 'teacher',
label: 'Экспорт результатов',
desc: 'Выгружать результаты и оценки класса в CSV',
default: 1,
},
{
key: 'classes.manage',
role: 'teacher',
label: 'Управление классами',
desc: 'Создавать, редактировать и удалять свои классы',
default: 1,
},
{
key: 'library.upload',
role: 'teacher',
label: 'Загрузка файлов',
desc: 'Загружать файлы в библиотеку',
default: 1,
},
{
key: 'library.folders',
role: 'teacher',
label: 'Управление папками',
desc: 'Создавать папки и настраивать доступ к ним',
default: 1,
},
{
key: 'schedule.manage',
role: 'teacher',
label: 'Дедлайны заданий',
desc: 'Устанавливать дедлайны и временные окна для заданий',
default: 1,
},
{
key: 'announcements.send',
role: 'teacher',
label: 'Объявления',
desc: 'Публиковать объявления в своих классах',
default: 1,
},
{
key: 'templates.manage',
role: 'teacher',
label: 'Управление шаблонами',
desc: 'Создавать и использовать шаблоны курсов и уроков',
default: 1,
},
{
key: 'templates.public',
role: 'teacher',
label: 'Публикация шаблонов',
desc: 'Делать свои шаблоны публичными для всех учителей',
default: 0,
},
{
key: 'courses.manage',
role: 'teacher',
label: 'Управление курсами',
desc: 'Создавать и редактировать теоретические курсы и уроки',
default: 1,
},
{
key: 'courses.interactive',
role: 'teacher',
label: 'Интерактивные блоки',
desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)',
default: 1,
},
{
key: 'shop.manage',
role: 'teacher',
label: 'Управление магазином',
desc: 'Создавать и редактировать товары в магазине наград',
default: 0,
},
{
key: 'gamification.manage',
role: 'teacher',
label: 'Управление геймификацией',
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
default: 0,
},
/* ── Student ── */
{
key: 'tests.free',
role: 'student',
label: 'Свободные тесты',
desc: 'Проходить тесты без задания (по предмету / случайно)',
default: 1,
},
{
key: 'board.post',
role: 'student',
label: 'Реакции на доске',
desc: 'Ставить реакции на задания на доске',
default: 1,
},
{
key: 'profile.edit',
role: 'student',
label: 'Редактирование профиля',
desc: 'Изменять своё имя и пароль',
default: 1,
},
{
key: 'shop.purchase',
role: 'student',
label: 'Покупки в магазине',
desc: 'Покупать предметы в магазине наград за монеты',
default: 1,
},
{
key: 'gamification.challenges',
role: 'student',
label: 'Испытания недели',
desc: 'Участвовать в еженедельных испытаниях и получать награды',
default: 1,
},
{
key: 'theory.access',
role: 'student',
label: 'Доступ к теории',
desc: 'Просматривать теоретические курсы и уроки',
default: 1,
},
{
key: 'simulations.access',
role: 'student',
label: 'Доступ к симуляциям',
desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями',
default: 1,
},
{
key: 'simulations.quiz',
role: 'student',
label: 'Задания в симуляциях',
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
default: 1,
},
];
/* ── Seed defaults once per startup ───────────────────────────────────── */
function seedDefaults() {
const upsert = db.prepare(
'INSERT OR IGNORE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
);
const run = db.transaction(() => {
for (const p of ALL_PERMISSIONS) upsert.run(p.role, p.key, p.default);
});
run();
}
/* ── GET /api/permissions ─────────────────────────────────────────────── */
function getPermissions(_req, res) {
seedDefaults();
const rows = db.prepare('SELECT role, permission, enabled FROM role_permissions').all();
const map = { teacher: {}, student: {} };
for (const r of rows) {
if (map[r.role]) map[r.role][r.permission] = r.enabled === 1;
}
res.json({ permissions: map, definitions: ALL_PERMISSIONS });
}
/* ── POST /api/permissions { role, permission, enabled } ─────────────── */
function setPermission(req, res) {
const { role, permission, enabled } = req.body;
if (!['teacher', 'student'].includes(role))
return res.status(400).json({ error: 'Invalid role' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role))
return res.status(400).json({ error: 'Unknown permission' });
db.prepare(
'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
).run(role, permission, enabled ? 1 : 0);
res.json({ ok: true });
}
/* ── GET /api/permissions/me (any authenticated user) ───────────────── */
function getMyPermissions(req, res) {
const uid = req.user.id;
const role = req.user.role;
if (role === 'admin') return res.json({ role, permissions: [] }); // admins bypass all
seedDefaults();
const roleRows = db.prepare(
'SELECT permission, enabled FROM role_permissions WHERE role = ?'
).all(role);
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === role);
const result = defs.map(d => ({
key: d.key,
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
}));
res.json({ role, permissions: result });
}
/* ── GET /api/permissions/users/:id ──────────────────────────────────── */
function getUserPermissions(req, res) {
const uid = Number(req.params.id);
const target = require('../db/db').prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
seedDefaults();
// role-level values
const roleRows = require('../db/db').prepare(
'SELECT permission, enabled FROM role_permissions WHERE role = ?'
).all(target.role);
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
// user-level overrides
const userRows = require('../db/db').prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
const result = defs.map(d => ({
key: d.key,
label: d.label,
desc: d.desc,
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
userVal: userMap[d.key], // undefined = no override
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
}));
res.json({ role: target.role, permissions: result });
}
/* ── POST /api/permissions/users/:id { permission, enabled } ─────────── */
function setUserPermission(req, res) {
const uid = Number(req.params.id);
const { permission, enabled } = req.body;
const target = require('../db/db').prepare('SELECT role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role))
return res.status(400).json({ error: 'Unknown permission for this role' });
require('../db/db').prepare(
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)'
).run(uid, permission, enabled ? 1 : 0);
res.json({ ok: true });
}
/* ── DELETE /api/permissions/users/:id/reset (single or all) ─────────── */
function resetUserPermissions(req, res) {
const uid = Number(req.params.id);
const { permission } = req.body; // optional: reset one key
if (permission) {
require('../db/db').prepare(
'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?'
).run(uid, permission);
} else {
require('../db/db').prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid);
}
res.json({ ok: true });
}
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions };
+305
View File
@@ -0,0 +1,305 @@
'use strict';
const db = require('../db/db');
const { awardCoins } = require('./gamificationController');
// Incremental migrations
try { db.exec("ALTER TABLE users ADD COLUMN pet_name TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_color TEXT DEFAULT 'purple'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_petted TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_petting_streak INT DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_star TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg TEXT DEFAULT 'default'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg_owned TEXT DEFAULT '[]'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_fed TEXT"); } catch {}
const BG_SHOP = [
{ id:'space', name:'Космос', price:50, desc:'Звёздный простор' },
{ id:'forest', name:'Лес', price:50, desc:'Таинственный лес' },
{ id:'aqua', name:'Океан', price:75, desc:'Морская глубина' },
{ id:'sunset', name:'Закат', price:75, desc:'Пурпурный закат' },
];
function _parseOwned(raw) {
try { return JSON.parse(raw || '[]'); } catch { return []; }
}
function _petLevel(xp) {
if (xp >= 80000) return 8;
if (xp >= 40000) return 7;
if (xp >= 20000) return 6;
if (xp >= 10000) return 5;
if (xp >= 5000) return 4;
if (xp >= 2000) return 3;
if (xp >= 500) return 2;
return 1;
}
function _mood(streak, daysSinceLogin) {
if (daysSinceLogin >= 14) return 'sleeping';
if (daysSinceLogin >= 7) return 'hungry';
if (daysSinceLogin >= 3) return 'sad';
if (streak >= 7) return 'ecstatic';
if (streak >= 3) return 'happy';
if (streak >= 1) return 'neutral';
return 'sad';
}
function _accessories(user) {
const acc = [];
if (user.streak_best >= 7) acc.push('hat');
if (user.level >= 5) acc.push('glasses');
if (user.xp >= 5000) acc.push('crown');
if (user.level >= 10) acc.push('star');
return acc;
}
function _quests(userId, streakCurrent) {
const today = new Date().toISOString().slice(0, 10);
const xpToday = db.prepare(
"SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id=? AND date(created_at)=date(?)"
).get(userId, today)?.total || 0;
const testsToday = db.prepare(
"SELECT COUNT(*) AS cnt FROM test_sessions WHERE user_id=? AND status='completed' AND date(finished_at)=date(?)"
).get(userId, today)?.cnt || 0;
return [
{ id:'xp30', icon:'⭐', label:'Набери 30 XP сегодня', done: xpToday >= 30, progress: Math.min(xpToday, 30), goal: 30 },
{ id:'test1', icon:'📝', label:'Пройди 1 тест', done: testsToday >= 1, progress: Math.min(testsToday, 1), goal: 1 },
{ id:'streak2', icon:'🔥', label:'Серия 2+ дней', done: streakCurrent >= 2 },
];
}
function _moodForecast(daysSince) {
if (daysSince >= 7) return null; // already hungry/sleeping
if (daysSince >= 3) return { mood: 'hungry', inDays: Math.max(0, 7 - daysSince) };
const toSad = Math.max(0, 3 - daysSince);
if (toSad <= 2) return { mood: 'sad', inDays: toSad };
return null;
}
/* ── GET /api/pet ─────────────────────────────────────────────────────── */
function getPet(req, res) {
const user = db.prepare(
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
pet_bg, pet_bg_owned, pet_last_fed
FROM users WHERE id = ?`
).get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const now = new Date();
const lastXpRow = db.prepare("SELECT MAX(created_at) AS t FROM xp_log WHERE user_id = ?").get(req.user.id);
const lastSessRow = db.prepare("SELECT MAX(finished_at) AS t FROM test_sessions WHERE user_id = ? AND status = 'completed'").get(req.user.id);
const candidates = [
user.last_login ? new Date(user.last_login) : null,
lastXpRow?.t ? new Date(lastXpRow.t) : null,
lastSessRow?.t ? new Date(lastSessRow.t) : null,
].filter(Boolean);
const lastActive = candidates.length ? new Date(Math.max(...candidates.map(d => d.getTime()))) : now;
const daysSince = Math.max(0, Math.floor((now - lastActive) / 86400000));
const since7 = new Date(now - 7 * 86400000).toISOString();
const recentXP = db.prepare(
'SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id = ? AND created_at >= ?'
).get(req.user.id, since7)?.total || 0;
const petLvl = _petLevel(user.xp);
const mood = _mood(user.streak_current, daysSince);
const accessories = _accessories(user);
const thresholds = [0, 500, 2000, 5000, 10000, 20000, 40000, 80000, Infinity];
const xpForNext = thresholds[petLvl] ?? Infinity;
const xpForCurr = thresholds[petLvl - 1] ?? 0;
const d0 = new Date(now); d0.setDate(d0.getDate() - 6);
const weekStart = d0.toISOString().slice(0, 10);
const weekRows = db.prepare(
"SELECT date(created_at) AS d, COALESCE(SUM(amount),0) AS xp FROM xp_log WHERE user_id = ? AND date(created_at) >= date(?) GROUP BY date(created_at)"
).all(req.user.id, weekStart);
const weekMap = new Map(weekRows.map(r => [r.d, r.xp]));
const weeklyXP = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(now); d.setDate(d.getDate() - i);
const dateStr = d.toISOString().slice(0, 10);
weeklyXP.push({ day: d.toLocaleDateString('ru', { weekday: 'short' }), xp: weekMap.get(dateStr) || 0 });
}
const SOURCE_LABELS = {
hangman_win: 'Виселица', crossword_win: 'Кроссворд',
test_answer: 'Тест', challenge: 'Задание',
lab_activity: 'Лаборатория', assignment: 'Домашнее задание',
pet_petting: 'Поглаживание', pet_feeding: 'Кормёжка питомца',
mood_ecstatic: 'Бонус настроения', correct_answers: 'Тест',
test_complete: 'Тест', 'test_90+': 'Тест 90%+', test_perfect: 'Тест 100%',
};
const rawActivity = db.prepare(
"SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 6"
).all(req.user.id);
const recentActivity = rawActivity.map(r => ({
xp: r.amount, label: SOURCE_LABELS[r.reason] || r.reason, at: r.created_at,
}));
const pettingCooldown = user.pet_last_petted
? Math.max(0, 60 - Math.floor((now - new Date(user.pet_last_petted)) / 1000))
: 0;
const feedCooldown = user.pet_last_fed
? Math.max(0, 1800 - Math.floor((now - new Date(user.pet_last_fed)) / 1000))
: 0;
res.json({
petName: user.pet_name || 'Квантик',
petLevel: petLvl,
petColor: user.pet_color || 'purple',
mood,
daysSinceLogin: daysSince,
accessories,
xp: user.xp,
level: user.level,
streakCurrent: user.streak_current,
streakBest: user.streak_best,
coins: user.coins,
pettingStreak: user.pet_petting_streak || 0,
recentXP,
weeklyXP,
recentActivity,
xpForCurrLevel: xpForCurr,
xpForNextLevel: xpForNext === Infinity ? null : xpForNext,
quests: _quests(req.user.id, user.streak_current),
moodForecast: _moodForecast(daysSince),
pettingCooldown,
feedCooldown,
petBg: user.pet_bg || 'default',
petBgOwned: _parseOwned(user.pet_bg_owned),
});
}
/* ── POST /api/pet/pet ────────────────────────────────────────────────── */
function petAction(req, res) {
const user = db.prepare('SELECT pet_last_petted, pet_petting_streak FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
if (user.pet_last_petted) {
const diff = (now - new Date(user.pet_last_petted)) / 1000;
if (diff < 60) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(60 - diff) });
}
const today = now.toISOString().slice(0, 10);
const yesterday = new Date(now - 86400000).toISOString().slice(0, 10);
let streak = user.pet_petting_streak || 0;
if (user.pet_last_petted) {
const lastDate = user.pet_last_petted.slice(0, 10);
if (lastDate === today) { /* same day, keep */ }
else if (lastDate === yesterday) streak++;
else streak = 1;
} else {
streak = 1;
}
try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {}
db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=?')
.run(now.toISOString(), streak, req.user.id);
res.json({ ok: true, coins: 2, pettingStreak: streak });
}
/* ── PATCH /api/pet/name ──────────────────────────────────────────────── */
function renamePet(req, res) {
let { name } = req.body;
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
name = name.trim().slice(0, 24);
if (!name) return res.status(400).json({ error: 'name required' });
db.prepare('UPDATE users SET pet_name = ? WHERE id = ?').run(name, req.user.id);
res.json({ ok: true, name });
}
/* ── PATCH /api/pet/color ─────────────────────────────────────────────── */
function updateColor(req, res) {
const { color } = req.body;
const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue'];
if (!VALID.includes(color)) return res.status(400).json({ error: 'invalid color' });
db.prepare('UPDATE users SET pet_color = ? WHERE id = ?').run(color, req.user.id);
res.json({ ok: true, color });
}
/* ── GET /api/pet/shop ────────────────────────────────────────────────── */
function getShop(req, res) {
const user = db.prepare('SELECT coins, pet_bg, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const owned = _parseOwned(user.pet_bg_owned);
res.json({
coins: user.coins || 0,
currentBg: user.pet_bg || 'default',
items: BG_SHOP.map(item => ({ ...item, owned: owned.includes(item.id) })),
});
}
/* ── POST /api/pet/shop/buy ───────────────────────────────────────────── */
function buyBg(req, res) {
const { id } = req.body;
const item = BG_SHOP.find(b => b.id === id);
if (!item) return res.status(400).json({ error: 'invalid item' });
const user = db.prepare('SELECT coins, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const owned = _parseOwned(user.pet_bg_owned);
if (!owned.includes(id)) {
if ((user.coins || 0) < item.price) return res.status(400).json({ error: 'insufficient_coins' });
db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?')
.run(item.price, JSON.stringify([...owned, id]), id, req.user.id);
} else {
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
}
const updated = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id);
res.json({ ok: true, bg: id, coins: updated.coins });
}
/* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */
function setBg(req, res) {
const { id } = req.body;
if (id !== 'default') {
const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned);
if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' });
}
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
res.json({ ok: true, bg: id });
}
/* ── POST /api/pet/star ───────────────────────────────────────────────── */
function starCatch(req, res) {
const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
if (user.pet_last_star) {
const diff = (now - new Date(user.pet_last_star)) / 1000;
if (diff < 3600) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(3600 - diff) });
}
try { awardCoins(req.user.id, 5, 'star_catch'); } catch {}
db.prepare('UPDATE users SET pet_last_star=? WHERE id=?').run(now.toISOString(), req.user.id);
res.json({ ok: true, coins: 5 });
}
/* ── POST /api/pet/feed ───────────────────────────────────────────────── */
// Called when mini-game "feed pet" is correctly answered on frontend.
// 30-minute cooldown; awards 15 XP + marks pet as fed.
function feedPet(req, res) {
const user = db.prepare('SELECT pet_last_fed FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
const COOLDOWN_SEC = 1800; // 30 minutes
if (user.pet_last_fed) {
const diff = (now - new Date(user.pet_last_fed)) / 1000;
if (diff < COOLDOWN_SEC) {
return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(COOLDOWN_SEC - diff) });
}
}
try {
const { awardXP } = require('./gamificationController');
awardXP(req.user.id, 15, 'pet_feeding');
} catch (e) { console.error('[feedPet] awardXP:', e.message); }
db.prepare('UPDATE users SET pet_last_fed=? WHERE id=?').run(now.toISOString(), req.user.id);
const updated = db.prepare('SELECT xp, coins FROM users WHERE id=?').get(req.user.id);
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
}
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet };
@@ -0,0 +1,248 @@
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
/* helper: find or create topic by name within a subject */
function resolveTopicId(subjectId, topicId, topicName) {
if (topicId) return Number(topicId);
if (!topicName?.trim()) return null;
const name = topicName.trim();
const existing = db.prepare('SELECT id FROM topics WHERE subject_id = ? AND LOWER(name) = LOWER(?)').get(subjectId, name);
if (existing) return existing.id;
return db.prepare('INSERT INTO topics (subject_id, name) VALUES (?, ?)').run(subjectId, name).lastInsertRowid;
}
/* ── GET /api/questions?subject=bio&topic_id=1&sort=diff_asc&page=1&limit=50 */
function list(req, res) {
const { subject, topic_id, sort, source_type, q, difficulty, type } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const subj = subject
? db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject)
: null;
if (subject && !subj) return res.status(404).json({ error: 'Subject not found' });
const ORDER = {
date_desc: 'q.id DESC',
date_asc: 'q.id ASC',
diff_asc: 'q.difficulty ASC, q.id DESC',
diff_desc: 'q.difficulty DESC, q.id DESC',
};
const orderBy = ORDER[sort] || 'q.id DESC';
let where = 'WHERE 1=1';
const args = [];
if (subj) { where += ' AND q.subject_id = ?'; args.push(subj.id); }
if (topic_id) { where += ' AND q.topic_id = ?'; args.push(topic_id); }
if (source_type) { where += ' AND q.source_type = ?'; args.push(source_type); }
if (difficulty) { where += ' AND q.difficulty = ?'; args.push(Number(difficulty)); }
if (type) { where += ' AND q.type = ?'; args.push(type); }
if (q?.trim()) { where += ' AND q.text LIKE ?'; args.push(`%${q.trim()}%`); }
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM questions q ${where}`).get(...args);
const sql = `
SELECT q.id, q.text, q.type, q.correct_text, q.difficulty, q.explanation, q.image,
q.year, q.source_type,
t.name AS topic, t.id AS topic_id,
s.name AS subject_name, s.slug AS subject_slug,
(SELECT json_group_array(json_object(
'id', o.id, 'text', o.text, 'is_correct', o.is_correct, 'order_index', o.order_index, 'match_pair', o.match_pair
) ORDER BY o.order_index)
FROM options o WHERE o.question_id = q.id) AS options_json
FROM questions q
LEFT JOIN topics t ON t.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
${where}
ORDER BY ${orderBy} LIMIT ? OFFSET ?
`;
const rows = db.prepare(sql).all(...args, limit, offset).map(r => ({
...r,
options: JSON.parse(r.options_json || '[]'),
options_json: undefined,
}));
res.json({ rows, total, page, limit });
}
/* ── POST /api/questions ─────────────────────────────────────────────── */
function create(req, res) {
const { subject_slug, topic_id, topic_name, type = 'single', correct_text, difficulty = 1, explanation, options, image } = req.body;
const text = stripTags((req.body.text || '').trim());
if (!subject_slug || !text) return res.status(400).json({ error: 'subject_slug and text are required' });
if (text.length > 2000) return res.status(400).json({ error: 'text too long (max 2000 chars)' });
if (type !== 'short_answer' && !options?.length)
return res.status(400).json({ error: 'options required for this question type' });
if (type !== 'short_answer' && type !== 'matching' && !options.some(o => o.is_correct))
return res.status(400).json({ error: 'At least one option must be correct' });
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const resolvedTopicId = resolveTopicId(subj.id, topic_id, topic_name);
try {
const qid = db.transaction(() => {
const { lastInsertRowid } = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(subj.id, resolvedTopicId, text, type, type === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, image || null);
const insertOpt = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
);
options.forEach((o, i) => insertOpt.run(lastInsertRowid, o.text, type === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
return lastInsertRowid;
})();
res.status(201).json({ id: qid });
} catch (err) {
console.error('[question create]', err.message);
res.status(500).json({ error: 'Ошибка создания вопроса' });
}
}
/* ── POST /api/questions/:id/duplicate ──────────────────────────────── */
function duplicate(req, res) {
const q = db.prepare(`
SELECT q.*, s.slug AS subject_slug FROM questions q
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE q.id = ?
`).get(req.params.id);
if (!q) return res.status(404).json({ error: 'Question not found' });
const opts = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id = ? ORDER BY order_index').all(q.id);
try {
const newId = db.transaction(() => {
const { lastInsertRowid } = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, difficulty, explanation, type, correct_text, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(q.subject_id, q.topic_id, q.text + ' (копия)', q.difficulty, q.explanation, q.type, q.correct_text, q.image);
const ins = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
opts.forEach(o => ins.run(lastInsertRowid, o.text, o.is_correct, o.order_index));
return lastInsertRowid;
})();
res.status(201).json({ id: newId });
} catch (err) {
console.error('[question duplicate]', err.message);
res.status(500).json({ error: 'Ошибка дублирования вопроса' });
}
}
/* ── PUT /api/questions/:id ──────────────────────────────────────────── */
function update(req, res) {
const { type, correct_text, difficulty, explanation, topic_id, topic_name, options, image } = req.body;
const text = req.body.text !== undefined ? stripTags(String(req.body.text).trim()) : undefined;
const qid = req.params.id;
if (text !== undefined && text.length > 2000)
return res.status(400).json({ error: 'text too long (max 2000 chars)' });
const q = db.prepare('SELECT id, subject_id, type AS oldType FROM questions WHERE id = ?').get(qid);
if (!q) return res.status(404).json({ error: 'Question not found' });
const resolvedTopicId = resolveTopicId(q.subject_id, topic_id, topic_name);
const qtype = type || q.oldType || 'single';
try {
db.transaction(() => {
db.prepare(
'UPDATE questions SET text = ?, type = ?, correct_text = ?, difficulty = ?, explanation = ?, topic_id = ?, image = ? WHERE id = ?'
).run(text, qtype, qtype === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, resolvedTopicId, image !== undefined ? (image || null) : db.prepare('SELECT image FROM questions WHERE id = ?').get(qid)?.image, qid);
if (options?.length) {
if (qtype !== 'matching' && !options.some(o => o.is_correct))
throw Object.assign(new Error('At least one option must be correct'), { status: 400 });
db.prepare('DELETE FROM options WHERE question_id = ?').run(qid);
const ins = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
);
options.forEach((o, i) => ins.run(qid, o.text, qtype === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
}
})();
res.json({ id: Number(qid) });
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
}
/* ── DELETE /api/questions/:id ───────────────────────────────────────── */
function remove(req, res) {
const q = db.prepare('SELECT id FROM questions WHERE id = ?').get(req.params.id);
if (!q) return res.status(404).json({ error: 'Question not found' });
db.prepare('DELETE FROM questions WHERE id = ?').run(req.params.id);
res.json({ deleted: Number(req.params.id) });
}
/* ── POST /api/questions/import (CSV upload) ────────────────────────── */
/* CSV format (semicolon-separated, first row = header):
subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year
- type: single | multi | true_false | short_answer
- c1-c4: 1 = correct, 0 = wrong (ignored for short_answer)
- correct_text: used only for short_answer type
*/
function importCSV(req, res) {
if (!req.file) return res.status(400).json({ error: 'CSV file required' });
const raw = req.file.buffer.toString('utf-8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = raw.split('\n').map(l => l.trim()).filter(l => l.length);
if (lines.length < 2) return res.status(400).json({ error: 'CSV is empty or has only header' });
// skip header row
const dataLines = lines.slice(1);
const errors = [];
let imported = 0;
const insQ = db.prepare('INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, year) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const insOpt = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
try {
db.transaction(() => {
for (let i = 0; i < dataLines.length; i++) {
const cols = dataLines[i].split(';');
const [subject_slug, topic, text, diffStr, type = 'single',
opt1 = '', c1 = '0', opt2 = '', c2 = '0',
opt3 = '', c3 = '0', opt4 = '', c4 = '0',
correct_text = '', explanation = '', yearStr = ''] = cols.map(c => c.trim());
if (!subject_slug || !text) { errors.push(`Строка ${i + 2}: пропущен subject_slug или text`); continue; }
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) { errors.push(`Строка ${i + 2}: неизвестный subject_slug «${subject_slug}»`); continue; }
const difficulty = Math.min(3, Math.max(1, Number(diffStr) || 1));
const year = yearStr ? Number(yearStr) || null : null;
const qtype = ['single', 'multi', 'true_false', 'short_answer'].includes(type) ? type : 'single';
const topicId = resolveTopicId(subj.id, null, topic || null);
const isShort = qtype === 'short_answer';
const options = [
{ text: opt1, is_correct: c1 === '1' },
{ text: opt2, is_correct: c2 === '1' },
{ text: opt3, is_correct: c3 === '1' },
{ text: opt4, is_correct: c4 === '1' },
].filter(o => o.text.length > 0);
if (!isShort && options.length < 2) { errors.push(`Строка ${i + 2}: нужно минимум 2 варианта ответа`); continue; }
if (!isShort && !options.some(o => o.is_correct)) { errors.push(`Строка ${i + 2}: нет правильного варианта (укажи 1 в столбце c1–c4)`); continue; }
const { lastInsertRowid: qid } = insQ.run(
subj.id, topicId, text, qtype,
isShort ? (correct_text || null) : null,
difficulty, explanation || null, year
);
if (!isShort) options.forEach((o, idx) => insOpt.run(qid, o.text, o.is_correct ? 1 : 0, idx));
imported++;
}
})();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ imported, errors });
}
module.exports = { list, create, duplicate, update, remove, importCSV };
@@ -0,0 +1,342 @@
const db = require('../db/db');
const { awardXP, checkRedBookAchievements } = require('./gamificationController');
/* ── helpers ─────────────────────────────────────────────────────────── */
const stmts = {
groups: db.prepare('SELECT * FROM rb_groups ORDER BY name_ru'),
habitats: db.prepare('SELECT * FROM rb_habitats ORDER BY name'),
speciesById: db.prepare(`
SELECT s.*, g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
WHERE s.id = ?
`),
regionsBySpecies: db.prepare('SELECT region_code FROM rb_species_regions WHERE species_id = ?'),
popdata: db.prepare('SELECT year, count_estimate, source FROM rb_population_data WHERE species_id = ? ORDER BY year'),
foodWebPrey: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.prey_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.predator_id = ?
`),
foodWebPredators: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.predator_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.prey_id = ?
`),
collection: db.prepare('SELECT species_id, unlock_method, notes, unlocked_at FROM rb_user_collection WHERE user_id = ?'),
hasCollect: db.prepare('SELECT 1 FROM rb_user_collection WHERE user_id = ? AND species_id = ?'),
addCollect: db.prepare('INSERT OR IGNORE INTO rb_user_collection (user_id, species_id, unlock_method) VALUES (?,?,?)'),
quests: db.prepare('SELECT * FROM rb_quests ORDER BY id'),
userQuests: db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ?'),
startQuest: db.prepare('INSERT OR IGNORE INTO rb_user_quests (user_id, quest_id) VALUES (?,?)'),
questById: db.prepare('SELECT * FROM rb_quests WHERE id = ?'),
userQuestRow:db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ? AND quest_id = ?'),
completeQ: db.prepare("UPDATE rb_user_quests SET status='completed', completed_at=datetime('now') WHERE user_id=? AND quest_id=?"),
sightings: db.prepare(`
SELECT rs.*, u.name as user_name, s.name_ru as species_name
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
ORDER BY rs.created_at DESC LIMIT 50
`),
addSighting: db.prepare('INSERT INTO rb_sightings (user_id, species_id, region_code, description) VALUES (?,?,?,?)'),
mapData: db.prepare(`
SELECT r.region_code, COUNT(r.species_id) as total,
SUM(CASE WHEN s.category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN s.category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN s.category='VU' THEN 1 ELSE 0 END) as vu
FROM rb_species_regions r
JOIN rb_species s ON s.id = r.species_id
GROUP BY r.region_code
`),
};
function buildSpeciesList(filter = {}) {
let sql = `
SELECT s.id, s.name_ru, s.name_be, s.name_lat, s.category, s.by_category,
s.photo_url, s.model_type, s.biomass_kg, s.interesting_fact,
g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`;
const cond = [], params = [];
if (filter.group_id) { cond.push('s.group_id = ?'); params.push(filter.group_id); }
if (filter.category) { cond.push('s.category = ?'); params.push(filter.category); }
if (filter.habitat_id) { cond.push('s.habitat_id = ?'); params.push(filter.habitat_id); }
if (filter.region) {
sql += ' JOIN rb_species_regions rr ON rr.species_id = s.id';
cond.push('rr.region_code = ?'); params.push(filter.region);
}
if (filter.q) {
cond.push('(s.name_ru LIKE ? OR s.name_lat LIKE ? OR s.name_be LIKE ?)');
const like = `%${filter.q}%`;
params.push(like, like, like);
}
if (cond.length) sql += ' WHERE ' + cond.join(' AND ');
sql += ' ORDER BY s.category, s.name_ru';
const limit = Math.min(parseInt(filter.limit) || 50, 100);
const offset = parseInt(filter.offset) || 0;
sql += ` LIMIT ${limit} OFFSET ${offset}`;
return db.prepare(sql).all(...params);
}
/* ── GET /api/red-book/groups ───────────────────────────────────────── */
exports.getGroups = (req, res) => {
const groups = stmts.groups.all();
// attach count
const counts = db.prepare(`
SELECT group_id, COUNT(*) as n,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en
FROM rb_species GROUP BY group_id
`).all();
const countMap = {};
counts.forEach(c => countMap[c.group_id] = { n: c.n, cr: c.cr, en: c.en });
groups.forEach(g => Object.assign(g, countMap[g.id] || { n: 0, cr: 0, en: 0 }));
res.json(groups);
};
/* ── GET /api/red-book/habitats ─────────────────────────────────────── */
exports.getHabitats = (req, res) => {
res.json(stmts.habitats.all());
};
/* ── GET /api/red-book/species ──────────────────────────────────────── */
exports.getSpecies = (req, res) => {
const list = buildSpeciesList(req.query);
// total for pagination
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
// user collection ids
let collected = new Set();
if (req.user) {
stmts.collection.all(req.user.id).forEach(r => collected.add(r.species_id));
}
list.forEach(s => {
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
s.collected = collected.has(s.id);
});
res.json({ total, species: list });
};
/* ── GET /api/red-book/species/:id ─────────────────────────────────── */
exports.getSpeciesById = (req, res) => {
const s = stmts.speciesById.get(req.params.id);
if (!s) return res.status(404).json({ error: 'Вид не найден' });
s.regions = stmts.regionsBySpecies.all(s.id).map(r => r.region_code);
s.population_data = stmts.popdata.all(s.id);
s.prey = stmts.foodWebPrey.all(s.id);
s.predators = stmts.foodWebPredators.all(s.id);
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
try { s.population_trend = JSON.parse(s.population_trend || '[]'); } catch { s.population_trend = []; }
if (req.user) {
s.collected = !!stmts.hasCollect.get(req.user.id, s.id);
}
res.json(s);
};
/* ── GET /api/red-book/map-data ─────────────────────────────────────── */
exports.getMapData = (req, res) => {
res.json(stmts.mapData.all());
};
/* ── GET /api/red-book/food-web ─────────────────────────────────────── */
exports.getFoodWeb = (req, res) => {
const ids = (req.query.species_ids || '').split(',').map(Number).filter(Boolean);
if (!ids.length) {
// return full web
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description,
g.icon, g.color, g.id as group_id,
h.id as habitat_id, h.type as habitat_type, h.name as habitat_name
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`).all();
const links = db.prepare('SELECT predator_id as source, prey_id as target, strength FROM rb_food_web').all();
return res.json({ nodes, links });
}
const ph = ids.map(() => '?').join(',');
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const links = db.prepare(`
SELECT predator_id as source, prey_id as target, strength FROM rb_food_web
WHERE predator_id IN (${ph}) OR prey_id IN (${ph})
`).all(...ids, ...ids);
res.json({ nodes, links });
};
/* ── GET /api/red-book/biome/:habitatId ─────────────────────────────── */
exports.getBiomeSpecies = (req, res) => {
const list = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, s.model_type,
s.biomass_kg, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.habitat_id = ?
ORDER BY s.category, s.name_ru
`).all(req.params.habitatId);
res.json(list);
};
/* ── POST /api/red-book/species/:id/collect ─────────────────────────── */
exports.collectSpecies = (req, res) => {
const userId = req.user.id;
const speciesId = parseInt(req.params.id);
const sp = stmts.speciesById.get(speciesId);
if (!sp) return res.status(404).json({ error: 'Вид не найден' });
const already = stmts.hasCollect.get(userId, speciesId);
if (already) return res.json({ already: true, message: 'Вид уже в коллекции' });
stmts.addCollect.run(userId, speciesId, req.body?.method || 'explore');
// Award XP (via gamification service — also updates level in DB)
const xpMap = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
const xp = xpMap[sp.category] || 20;
try { awardXP(userId, xp, `Открыт вид: ${sp.name_ru}`); } catch {}
// Auto-complete any active quests that required this species
const completedQuests = [];
try {
const activeQuests = db.prepare(`
SELECT q.*, uq.quest_id FROM rb_user_quests uq
JOIN rb_quests q ON q.id = uq.quest_id
WHERE uq.user_id = ? AND uq.status = 'active'
`).all(userId);
// Get all collected species ids for this user
const collectedIds = new Set(
db.prepare('SELECT species_id FROM rb_user_collection WHERE user_id = ?').all(userId).map(r => r.species_id)
);
for (const quest of activeQuests) {
const requiredIds = JSON.parse(quest.species_ids || '[]');
if (requiredIds.every(id => collectedIds.has(id))) {
stmts.completeQ.run(userId, quest.id);
// Award quest XP via gamification service
try { awardXP(userId, quest.xp_reward, `Квест выполнен: ${quest.title}`); } catch {}
completedQuests.push({ id: quest.id, title: quest.title, xp_reward: quest.xp_reward });
}
}
} catch {}
// Check Red Book achievements (non-blocking)
try { checkRedBookAchievements(userId); } catch {}
res.json({ collected: true, xp_earned: xp, species: { id: sp.id, name_ru: sp.name_ru, category: sp.category }, completed_quests: completedQuests });
};
/* ── GET /api/red-book/collection ───────────────────────────────────── */
exports.getCollection = (req, res) => {
const rows = stmts.collection.all(req.user.id);
const ids = rows.map(r => r.species_id);
if (!ids.length) return res.json({ total: 0, species: [] });
const ph = ids.map(() => '?').join(',');
const species = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url,
g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const meta = {};
rows.forEach(r => meta[r.species_id] = { method: r.unlock_method, notes: r.notes, at: r.unlocked_at });
species.forEach(s => Object.assign(s, meta[s.id] || {}));
res.json({ total: species.length, species });
};
/* ── GET /api/red-book/quests ───────────────────────────────────────── */
exports.getQuests = (req, res) => {
const quests = stmts.quests.all();
quests.forEach(q => q.species_ids = JSON.parse(q.species_ids || '[]'));
if (!req.user) return res.json(quests);
const userQ = {};
stmts.userQuests.all(req.user.id).forEach(uq => userQ[uq.quest_id] = uq);
quests.forEach(q => {
const uq = userQ[q.id];
q.user_status = uq?.status || 'locked';
q.user_progress = uq ? JSON.parse(uq.progress || '{}') : {};
});
res.json(quests);
};
/* ── POST /api/red-book/quests/:id/start ────────────────────────────── */
exports.startQuest = (req, res) => {
const q = stmts.questById.get(req.params.id);
if (!q) return res.status(404).json({ error: 'Квест не найден' });
stmts.startQuest.run(req.user.id, q.id);
res.json({ started: true });
};
/* ── GET /api/red-book/sightings ────────────────────────────────────── */
exports.getSightings = (req, res) => {
const speciesId = req.query.species_id ? parseInt(req.query.species_id) : null;
let sql = `
SELECT rs.id, rs.species_id, rs.region_code, rs.description,
rs.confirmed_by_teacher, rs.created_at,
u.name as user_name,
s.name_ru as species_name,
g.icon as species_icon
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
JOIN rb_groups g ON g.id = s.group_id
`;
const params = [];
if (speciesId) { sql += ' WHERE rs.species_id = ?'; params.push(speciesId); }
sql += ' ORDER BY rs.created_at DESC LIMIT 50';
res.json(db.prepare(sql).all(...params));
};
/* ── POST /api/red-book/sightings ───────────────────────────────────── */
exports.addSighting = (req, res) => {
const { species_id, region_code, description } = req.body || {};
if (!species_id) return res.status(400).json({ error: 'species_id обязателен' });
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', description || '').lastInsertRowid;
try { checkRedBookAchievements(req.user.id); } catch {}
res.json({ id });
};
/* ── GET /api/red-book/stats ────────────────────────────────────────── */
exports.getStats = (req, res) => {
const totals = db.prepare(`
SELECT COUNT(*) as total,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN category='VU' THEN 1 ELSE 0 END) as vu,
SUM(CASE WHEN category='NT' THEN 1 ELSE 0 END) as nt,
SUM(CASE WHEN category='LC' THEN 1 ELSE 0 END) as lc
FROM rb_species
`).get();
let collected = 0;
if (req.user) {
collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(req.user.id).n;
}
res.json({ ...totals, collected });
};
/* ── GET /api/red-book/daily ────────────────────────────────────────── */
exports.getDaily = (req, res) => {
// deterministic by day of year
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
const offset = dayOfYear % total;
const daily = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.interesting_fact,
s.description, s.photo_url, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
ORDER BY s.id LIMIT 1 OFFSET ?
`).get(offset);
res.json(daily);
};
+119
View File
@@ -0,0 +1,119 @@
const db = require('../db/db');
/* ── GET /api/search?q=...&type=...&limit=... ──────────────────────────── */
function search(req, res) {
const q = (req.query.q || '').trim();
if (!q || q.length < 2) return res.json({ results: [], total: 0 });
const type = req.query.type || 'all'; // all|lesson|course|file|question
const limit = Math.min(Number(req.query.limit) || 20, 50);
const uid = req.user.id;
const role = req.user.role;
const isTeacher = ['teacher', 'admin'].includes(role);
const pattern = `%${q}%`;
const results = [];
/* ── Lessons ── */
if (type === 'all' || type === 'lesson') {
const pubFilter = isTeacher ? '' : 'AND l.is_published = 1 AND c.is_published = 1';
const rows = db.prepare(`
SELECT l.id, l.title, l.order_index, c.title AS course_title,
c.id AS course_id, c.subject_slug, c.cover_emoji
FROM lessons l
JOIN courses c ON c.id = l.course_id
WHERE l.title LIKE ? ${pubFilter}
ORDER BY l.title LIMIT ?
`).all(pattern, limit);
for (const r of rows) {
results.push({
type: 'lesson',
id: r.id,
title: r.title,
subtitle: r.course_title,
extra: { courseId: r.course_id, subjectSlug: r.subject_slug, emoji: r.cover_emoji },
url: `/lesson?id=${r.id}`,
});
}
}
/* ── Courses ── */
if (type === 'all' || type === 'course') {
const pubFilter = isTeacher ? '' : 'AND c.is_published = 1';
const rows = db.prepare(`
SELECT c.id, c.title, c.description, c.subject_slug, c.cover_emoji
FROM courses c
WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubFilter}
ORDER BY c.title LIMIT ?
`).all(pattern, pattern, limit);
for (const r of rows) {
results.push({
type: 'course',
id: r.id,
title: r.title,
subtitle: r.description ? r.description.slice(0, 80) : '',
extra: { subjectSlug: r.subject_slug, emoji: r.cover_emoji },
url: `/course?id=${r.id}`,
});
}
}
/* ── Files (access-controlled) ── */
if (type === 'all' || type === 'file') {
const accessFilter = isTeacher
? '(f.uploaded_by = ? OR f.is_public = 1)'
: `(
f.is_public = 1
OR EXISTS (SELECT 1 FROM file_access fa WHERE fa.file_id = f.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (SELECT 1 FROM file_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.file_id = f.id AND fa.type = 'class')
)`;
const accessArgs = isTeacher ? [uid] : [uid, uid];
const rows = db.prepare(`
SELECT f.id, f.title, f.original_name, f.subject_slug, f.mimetype,
u.name AS owner_name
FROM files f
JOIN users u ON u.id = f.uploaded_by
WHERE (f.title LIKE ? OR f.original_name LIKE ?) AND ${accessFilter}
ORDER BY f.title LIMIT ?
`).all(pattern, pattern, ...accessArgs, limit);
for (const r of rows) {
results.push({
type: 'file',
id: r.id,
title: r.title || r.original_name,
subtitle: r.owner_name || '',
extra: { subjectSlug: r.subject_slug, mimetype: r.mimetype },
url: `/library`,
});
}
}
/* ── Questions ── */
if (type === 'all' || type === 'question') {
if (isTeacher) {
const rows = db.prepare(`
SELECT q.id, q.text, s.slug AS subject_slug, t.name AS topic_name
FROM questions q
LEFT JOIN topics t ON t.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE q.text LIKE ?
ORDER BY q.id DESC LIMIT ?
`).all(pattern, limit);
for (const r of rows) {
results.push({
type: 'question',
id: r.id,
title: r.text.length > 100 ? r.text.slice(0, 100) + '…' : r.text,
subtitle: r.topic_name || '',
extra: { subjectSlug: r.subject_slug },
url: `/theory`,
});
}
}
}
res.json({ results: results.slice(0, limit), total: results.length });
}
module.exports = { search };
@@ -0,0 +1,606 @@
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { onTestFinished, updateDailyGoal, awardXP, updateChallenges } = require('./gamificationController');
const { COMBO_BONUSES } = require('../constants');
/* ── Prepared statements (avoid re-parsing on every request) ──────────── */
const stmts = {
/* stats: 7 queries → 2 via CTE + json_group_array */
statsMega: db.prepare(`
WITH base AS (
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = @uid AND ts.status = 'completed'
),
hm AS (
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
FROM test_sessions ts
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
GROUP BY day ORDER BY day
),
sd AS (
SELECT DISTINCT date(ts.started_at) AS d
FROM test_sessions ts WHERE ts.user_id = @uid
ORDER BY d DESC LIMIT 90
)
SELECT
(SELECT COALESCE(json_group_array(json_object(
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE finished_at >= date('now', '-84 days')
GROUP BY week ORDER BY week)) AS weekly,
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
FROM hm) AS heatmap,
(SELECT COALESCE(json_group_array(json_object(
'slug', subject_slug, 'name', subject_name,
'sessions', sessions, 'avg_pct', avg_pct,
'total_correct', total_correct, 'total_questions', total_questions)), '[]')
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct,
SUM(score) AS total_correct, SUM(total) AS total_questions
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
(SELECT COALESCE(json_group_array(json_object(
'id', id, 'score', score, 'total', total,
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
FROM (SELECT id, score, total, finished_at, subject_slug
FROM base ORDER BY finished_at DESC LIMIT 20)) AS trend,
(SELECT json_object(
'sessions', COUNT(*),
'correct', COALESCE(SUM(score), 0),
'questions',COALESCE(SUM(total), 0),
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
FROM base) AS totals,
(SELECT COALESCE(json_group_array(d), '[]') FROM sd) AS streakDays
`),
courseProgress: db.prepare(`
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
ORDER BY done_lessons * 1.0 / total_lessons DESC`),
sessionCount: db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?'),
};
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const VALID_MODES = new Set(['exam', 'practice']);
/* ── POST /api/sessions ───────────────────────────────────────────────── */
function start(req, res, next) {
const { subject_slug, topic_id, test_id } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
if (!subject_slug) return res.status(400).json({ error: 'subject_slug is required' });
if (!VALID_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam or practice' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });
let ids;
let testTimeLimitSec = null;
if (test_id) {
const tRow = db.prepare('SELECT time_limit FROM tests WHERE id = ?').get(Number(test_id));
if (tRow?.time_limit) testTimeLimitSec = tRow.time_limit * 60;
const tq = db.prepare(
'SELECT question_id AS id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(Number(test_id));
if (!tq.length) return res.status(404).json({ error: 'Test has no questions' });
ids = shuffle(tq.map(r => r.id));
} else {
const rows = topic_id
? db.prepare('SELECT id FROM questions WHERE subject_id = ? AND topic_id = ?').all(subject.id, Number(topic_id))
: db.prepare('SELECT id FROM questions WHERE subject_id = ?').all(subject.id);
if (!rows.length) return res.status(404).json({ error: 'No questions found' });
ids = shuffle(rows.map(r => r.id)).slice(0, count);
}
const total = ids.length;
const createSession = db.transaction(() => {
const { lastInsertRowid: session_id } = db.prepare(
'INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'
).run(req.user.id, subject.id, mode, total);
const insertSQ = db.prepare(
'INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'
);
ids.forEach((qid, i) => insertSQ.run(session_id, qid, i));
return session_id;
});
try {
const session_id = createSession();
const questions = loadQuestionsForSession(ids);
res.status(201).json({ session_id, total, mode, questions, time_limit_sec: testTimeLimitSec });
} catch (err) {
next(err);
}
}
/* ── POST /api/sessions/:id/answer ───────────────────────────────────── */
function answer(req, res) {
const session_id = Number(req.params.id);
const { question_id, option_id, time_spent_sec, answer_text, chosen_options } = req.body;
const session = db.prepare(
'SELECT id, mode, status FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress')
return res.status(400).json({ error: 'Session already finished' });
// Verify question belongs to this session — prevents answering questions from other sessions
const sq = db.prepare(
'SELECT 1 FROM session_questions WHERE session_id = ? AND question_id = ?'
).get(session_id, question_id);
if (!sq) return res.status(400).json({ error: 'Question not in this session' });
const q = db.prepare('SELECT id, type, correct_text FROM questions WHERE id = ?').get(question_id);
if (!q) return res.status(400).json({ error: 'Invalid question' });
let isCorrect = 0;
let chosenOptionId = null;
let storedAnswerText = null;
if (q.type === 'short_answer') {
const userAns = String(answer_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
const correct = String(q.correct_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
isCorrect = userAns === correct ? 1 : 0;
storedAnswerText = String(answer_text || '').trim();
} else if (q.type === 'multi') {
const selected = Array.isArray(chosen_options) ? chosen_options.map(Number) : [];
const allOpts = db.prepare('SELECT id, is_correct FROM options WHERE question_id = ?').all(question_id);
const correctIds = allOpts.filter(o => o.is_correct).map(o => o.id);
const wrongIds = allOpts.filter(o => !o.is_correct).map(o => o.id);
isCorrect = (
correctIds.every(id => selected.includes(id)) &&
wrongIds.every(id => !selected.includes(id))
) ? 1 : 0;
storedAnswerText = JSON.stringify(selected);
} else if (q.type === 'matching') {
const pairs = (() => { try { return JSON.parse(answer_text || '{}'); } catch (e) { console.error('[answer] matching parse error:', e.message); return {}; } })();
const allOpts = db.prepare('SELECT id, match_pair FROM options WHERE question_id = ?').all(question_id);
isCorrect = allOpts.length > 0 && allOpts.every(opt => pairs[String(opt.id)] === opt.match_pair) ? 1 : 0;
storedAnswerText = answer_text;
} else {
// single / true_false
const opt = db.prepare(
'SELECT id, is_correct FROM options WHERE id = ? AND question_id = ?'
).get(option_id, question_id);
if (!opt) return res.status(400).json({ error: 'Invalid option' });
isCorrect = opt.is_correct;
chosenOptionId = opt.id;
}
db.prepare(`
INSERT INTO user_answers (session_id, question_id, chosen_option_id, answer_text, is_correct, time_spent_sec)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, question_id)
DO UPDATE SET chosen_option_id = excluded.chosen_option_id,
answer_text = excluded.answer_text,
is_correct = excluded.is_correct,
time_spent_sec = excluded.time_spent_sec,
answered_at = datetime('now')
`).run(session_id, question_id, chosenOptionId, storedAnswerText, isCorrect, time_spent_sec ?? null);
const response = { is_correct: isCorrect === 1 };
if (session.mode === 'practice') {
if (q.type === 'short_answer') {
response.correct_text = q.correct_text;
} else {
response.correct_options = db.prepare(
'SELECT id, text FROM options WHERE question_id = ? AND is_correct = 1'
).all(question_id);
}
}
res.json(response);
}
/* ── POST /api/sessions/:id/finish ───────────────────────────────────── */
function finish(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Atomically mark as completed — guard against concurrent finish() calls
let score;
try {
score = db.transaction(() => {
const { score: s } = db.prepare(
'SELECT COUNT(*) AS score FROM user_answers WHERE session_id = ? AND is_correct = 1'
).get(session_id);
const upd = db.prepare(
"UPDATE test_sessions SET status = 'completed', score = ?, finished_at = datetime('now') WHERE id = ? AND status = 'in_progress'"
).run(s, session_id);
if (upd.changes === 0) throw Object.assign(new Error('already_finished'), { code: 'ALREADY_FINISHED' });
return s;
})();
} catch (e) {
if (e.code === 'ALREADY_FINISHED') return res.status(400).json({ error: 'Session already finished' });
throw e;
}
// Compute max combo (consecutive correct answers in order)
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch (e) { console.error('[finish] combo calc:', e.message); }
// Gamification: award XP, update streak, check achievements
try {
const timeSec = Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000);
// Check if linked to a test with time_limit
let testTimeLimitSec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (tl?.time_limit) testTimeLimitSec = tl.time_limit * 60;
} catch (e) { console.error('[finish] time_limit fetch:', e.message); }
// Combo bonus XP (thresholds from constants)
let comboBonus = 0;
for (const [min, xp] of COMBO_BONUSES) {
if (maxCombo >= min) { comboBonus = xp; break; }
}
onTestFinished(req.user.id, score, session.total, timeSec, testTimeLimitSec);
if (comboBonus > 0) {
try { awardXP(req.user.id, comboBonus, `Комбо x${maxCombo}`); } catch (e) { console.error('[finish] comboBonus awardXP:', e.message); }
}
updateDailyGoal(req.user.id, 1, score * 10 + 50 + comboBonus);
// Update personal challenges
try {
const subj = db.prepare('SELECT s.slug FROM subjects s WHERE s.id = ?').get(session.subject_id);
const topicIds = db.prepare(`
SELECT DISTINCT q.topic_id FROM session_questions sq
JOIN questions q ON q.id = sq.question_id
WHERE sq.session_id = ? AND q.topic_id IS NOT NULL
`).all(session_id).map(r => r.topic_id);
const slug = subj ? subj.slug : null;
for (const tid of (topicIds.length ? topicIds : [null])) {
updateChallenges(req.user.id, score, session.total, slug, tid);
}
} catch (e) { console.error('[finish] updateChallenges:', e.message); }
} catch (e) { console.error('[finish] gamification:', e.message); }
// Notify teacher if session linked to a class assignment
try {
const link = db.prepare(`
SELECT a.title, COALESCE(c.teacher_id, a.created_by) AS teacher_id, u.name AS student_name
FROM assignment_sessions ass
JOIN assignments a ON a.id = ass.assignment_id
LEFT JOIN classes c ON c.id = a.class_id
JOIN users u ON u.id = ?
WHERE ass.session_id = ?
`).get(req.user.id, session_id);
if (link) {
const pct = Math.round((score / session.total) * 100);
pushNotif(link.teacher_id, 'session', `«${link.student_name}» сдал «${link.title}» — ${pct}%`, '/classes');
}
} catch (e) { console.error('[finish] pushNotif teacher:', e.message); }
res.json({
session_id,
score,
total: session.total,
percent: session.total ? Math.round((score / session.total) * 100) : 0,
time_sec: Math.round((Date.now() - new Date(session.started_at)) / 1000),
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/:id/result ────────────────────────────────────── */
function result(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'completed')
return res.status(400).json({ error: 'Session not finished yet' });
// Check if session is linked to an assignment using a test with show_answers setting
let show_answers = 1;
try {
const assnSess = db.prepare(`
SELECT t.show_answers FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (assnSess) show_answers = assnSess.show_answers;
} catch {}
// Compute max combo for display
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch {}
res.json({
session_id,
score: session.score,
total: session.total,
percent: session.total ? Math.round((session.score / session.total) * 100) : 0,
show_answers,
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/history ───────────────────────────────────────── */
function history(req, res) {
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const cursor = Number(req.query.cursor) || 0;
// Cursor-based: if cursor provided, use it; otherwise fall back to offset pagination
if (cursor) {
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.id < ?
ORDER BY ts.id DESC
LIMIT ?
`).all(req.user.id, cursor, limit);
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
return res.json({ rows, nextCursor, limit });
}
// Offset-based (legacy)
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const { total } = db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?').get(req.user.id);
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
res.json({ rows, total, page, limit });
}
/* ── helpers ─────────────────────────────────────────────────────────── */
function _placeholders(n) { return Array(n).fill('?').join(','); }
function loadQuestionsForSession(ids) {
if (!ids.length) return [];
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, difficulty FROM questions WHERE id IN (${ph})`
).all(...ids);
const allOptions = db.prepare(
`SELECT question_id, id, text, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const optsByQ = {};
for (const o of allOptions) (optsByQ[o.question_id] ??= []).push(o);
// Restore caller-expected order
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
if (!q) return null;
q.options = q.type !== 'short_answer' ? (optsByQ[id] || []) : [];
return q;
}).filter(Boolean);
}
function buildReview(session_id) {
const sqRows = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id);
if (!sqRows.length) return [];
const ids = sqRows.map(r => r.question_id);
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${ph})`
).all(...ids);
const answers = db.prepare(
`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${ph})`
).all(session_id, ...ids);
const options = db.prepare(
`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const ansMap = {};
for (const a of answers) ansMap[a.question_id] = a;
const optsByQ = {};
for (const o of options) (optsByQ[o.question_id] ??= []).push(o);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
const ua = ansMap[id];
return {
...q,
options: optsByQ[id] || [],
chosen_option_id: ua?.chosen_option_id ?? null,
answer_text: ua?.answer_text ?? null,
is_correct: ua ? ua.is_correct === 1 : null,
};
});
}
/* ── GET /api/sessions/weak-topics ───────────────────────────────────── */
function weakTopics(req, res) {
const rows = db.prepare(`
SELECT t.id AS topic_id,
t.name AS topic,
s.name AS subject_name,
s.slug AS subject_slug,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN test_sessions ts ON ts.id = ua.session_id
JOIN questions q ON q.id = ua.question_id
JOIN topics t ON t.id = q.topic_id
JOIN subjects s ON s.id = q.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
GROUP BY q.topic_id
HAVING total >= 2
ORDER BY error_pct DESC, wrong DESC
LIMIT 8
`).all(req.user.id);
res.json(rows);
}
/* ── GET /api/sessions/:id/questions ── resume existing session ─────── */
function getSessionQuestions(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(`
SELECT ts.id, ts.mode, ts.total, ts.status, ts.started_at, s.slug AS subject_slug
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.id = ? AND ts.user_id = ?
`).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress') return res.status(400).json({ error: 'Session already finished' });
const ids = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id).map(r => r.question_id);
// Resolve time limit from linked test (if any)
let time_limit_sec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ? AND t.time_limit IS NOT NULL
`).get(session_id);
if (tl?.time_limit) time_limit_sec = tl.time_limit * 60;
} catch {}
const questions = loadQuestionsForSession(ids);
res.json({
session_id,
total: session.total,
mode: session.mode,
subject_slug: session.subject_slug,
questions,
time_limit_sec,
started_at: session.started_at,
});
}
/* ── GET /api/sessions/stats ── student dashboard charts ────────────── */
function stats(req, res) {
const uid = req.user.id;
// 2 queries instead of 7: mega-CTE returns all session data as JSON columns
const row = stmts.statsMega.get({ uid });
const weekly = JSON.parse(row.weekly);
const heatmap = JSON.parse(row.heatmap);
const bySubject = JSON.parse(row.bySubject);
const trend = JSON.parse(row.trend).reverse();
const totals = JSON.parse(row.totals);
const dayKeys = new Set(JSON.parse(row.streakDays));
const courseProgress = stmts.courseProgress.all({ uid });
// Streak calculation (JS side, uses pre-fetched day set)
let streak = 0;
const now = new Date();
for (let i = 0; i <= 90; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
if (dayKeys.has(key)) streak++;
else if (i > 0) break;
}
res.json({
weekly: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
bySubject: bySubject.map(r => ({
slug: r.slug, name: r.name, sessions: r.sessions,
avgPct: Math.round(r.avg_pct || 0),
correct: r.total_correct, questions: r.total_questions,
})),
trend: trend.map(r => ({
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
date: r.finished_at, subject: r.subject_slug,
})),
streak,
totals: {
sessions: totals.sessions || 0,
correct: totals.correct || 0,
questions:totals.questions|| 0,
avgPct: Math.round(totals.avg_pct || 0),
},
courseProgress: courseProgress.map(r => ({
id: r.id, title: r.title, emoji: r.cover_emoji,
subjectSlug: r.subject_slug,
done: r.done_lessons, total: r.total_lessons,
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
})),
});
}
module.exports = { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats };
@@ -0,0 +1,32 @@
const db = require('../db/db');
/* ── GET /api/settings/sims ─────────────────────────────────────────────── */
function getSimSettings(req, res) {
const rows = db.prepare(`SELECT key, value FROM app_settings WHERE key IN ('sim_module_disabled','sim_disabled_ids')`).all();
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
res.json({
module_disabled: map['sim_module_disabled'] === '1',
disabled_ids: JSON.parse(map['sim_disabled_ids'] || '[]'),
});
}
/* ── PUT /api/settings/sims ─────────────────────────────────────────────── */
function updateSimSettings(req, res) {
const { module_disabled, disabled_ids } = req.body;
if (module_disabled !== undefined) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_module_disabled', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(module_disabled ? '1' : '0');
}
if (Array.isArray(disabled_ids)) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify(disabled_ids));
}
res.json({ ok: true });
}
module.exports = { getSimSettings, updateSimSettings };
+194
View File
@@ -0,0 +1,194 @@
const db = require('../db/db');
/* ═══════════════════════════════════════════════════════════════════════
Shop — Items, Purchases, Coins
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/items — list all active shop items + owned status */
function getItems(req, res) {
const userId = req.user.id;
const items = db.prepare(`
SELECT si.*,
(SELECT 1 FROM user_purchases up WHERE up.item_id = si.id AND up.user_id = ?) AS owned
FROM shop_items si
WHERE si.is_active = 1
ORDER BY si.price
`).all(userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ items, coins: (user && user.coins) || 0 });
}
/* POST /api/shop/items/:id/purchase — buy an item (atomic transaction) */
function purchaseItem(req, res) {
const userId = req.user.id;
const itemId = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ? AND is_active = 1').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const alreadyOwned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (alreadyOwned) return res.status(400).json({ error: 'Вы уже купили этот предмет' });
// Atomic: check balance + deduct + insert purchase in one transaction
const doPurchase = db.transaction(() => {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
if (!user || (user.coins || 0) < item.price) return { err: 'Недостаточно монет' };
db.prepare('UPDATE users SET coins = coins - ? WHERE id = ?').run(item.price, userId);
db.prepare('INSERT INTO user_purchases (user_id, item_id) VALUES (?, ?)').run(userId, itemId);
const updated = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
return { coins: (updated && updated.coins) || 0 };
});
const result = doPurchase();
if (result.err) return res.status(400).json({ error: result.err });
res.json({ ok: true, coins: result.coins, item });
}
/* GET /api/shop/purchases — list user's purchases with item details */
function getPurchases(req, res) {
const rows = db.prepare(`
SELECT up.id AS purchase_id, up.purchased_at, si.*
FROM user_purchases up
JOIN shop_items si ON si.id = up.item_id
WHERE up.user_id = ?
ORDER BY up.purchased_at DESC
`).all(req.user.id);
res.json(rows);
}
/* GET /api/shop/coins — return user's coin balance */
function getCoins(req, res) {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.user.id);
res.json({ coins: (user && user.coins) || 0 });
}
/* GET /api/shop/my-active — return user's active cosmetics */
function getMyActive(req, res) {
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
if (!u) return res.json({});
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
// Frame from avatar_frame (gamification frames) — handled separately
// Shop frame override
if (u.avatar_frame && u.avatar_frame !== 'default') {
result.frame = { id: u.avatar_frame };
}
if (u.active_title) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);
if (item) try { result.title = JSON.parse(item.data); } catch {}
}
if (u.active_effect) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
if (item) try { result.effect = JSON.parse(item.data); } catch {}
}
res.json(result);
}
/* POST /api/shop/activate — activate a purchased item (or deactivate with itemId=null) */
function activateItem(req, res) {
const userId = req.user.id;
const { itemId } = req.body;
// Deactivate: pass itemId = null and type
if (!itemId) {
const { type } = req.body;
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
return res.json({ ok: true });
}
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
let data;
try { data = JSON.parse(item.data); } catch { data = {}; }
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
res.json({ ok: true, type: item.type, data });
}
/* ═══════════════════════════════════════════════════════════════════════
Admin — CRUD shop items, award coins, stats
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/admin/items — all items (including inactive) */
function adminGetItems(_req, res) {
const items = db.prepare(`
SELECT si.*,
(SELECT COUNT(*) FROM user_purchases up WHERE up.item_id = si.id) AS sold_count
FROM shop_items si ORDER BY si.id
`).all();
res.json(items);
}
/* POST /api/shop/admin/items — create item */
function adminCreateItem(req, res) {
const { name, description, type, category, price, data, icon, is_active } = req.body;
if (!name || !type || price == null) return res.status(400).json({ error: 'name, type, price required' });
const r = db.prepare(
'INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) VALUES (?,?,?,?,?,?,?,?)'
).run(name, description || '', type, category || 'cosmetic', price, data || '{}', icon || 'star', is_active ?? 1);
res.json({ ok: true, id: r.lastInsertRowid });
}
/* PUT /api/shop/admin/items/:id — update item */
function adminUpdateItem(req, res) {
const id = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(id);
if (!item) return res.status(404).json({ error: 'Item not found' });
const { name, description, type, category, price, data, icon, is_active } = req.body;
db.prepare(`UPDATE shop_items SET
name=COALESCE(?,name), description=COALESCE(?,description), type=COALESCE(?,type),
category=COALESCE(?,category), price=COALESCE(?,price), data=COALESCE(?,data),
icon=COALESCE(?,icon), is_active=COALESCE(?,is_active) WHERE id=?`
).run(name, description, type, category, price, data, icon, is_active, id);
res.json({ ok: true });
}
/* DELETE /api/shop/admin/items/:id — delete item */
function adminDeleteItem(req, res) {
const id = Number(req.params.id);
db.prepare('DELETE FROM user_purchases WHERE item_id = ?').run(id);
db.prepare('DELETE FROM shop_items WHERE id = ?').run(id);
res.json({ ok: true });
}
/* POST /api/shop/admin/award-coins — award coins to user */
function adminAwardCoins(req, res) {
const { userId, amount, reason } = req.body;
if (!userId || !amount || amount < 0) return res.status(400).json({ error: 'userId and positive amount required' });
db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(amount, userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ ok: true, coins: user?.coins || 0 });
}
/* GET /api/shop/admin/stats — shop stats */
function adminShopStats(_req, res) {
const totalItems = db.prepare('SELECT COUNT(*) as c FROM shop_items').get().c;
const activeItems = db.prepare('SELECT COUNT(*) as c FROM shop_items WHERE is_active=1').get().c;
const totalPurchases = db.prepare('SELECT COUNT(*) as c FROM user_purchases').get().c;
const totalCoinsInCirculation = db.prepare('SELECT COALESCE(SUM(coins),0) as c FROM users').get().c;
const topItems = db.prepare(`
SELECT si.name, si.price, COUNT(up.id) as sold
FROM shop_items si LEFT JOIN user_purchases up ON up.item_id = si.id
GROUP BY si.id ORDER BY sold DESC LIMIT 5
`).all();
res.json({ totalItems, activeItems, totalPurchases, totalCoinsInCirculation, topItems });
}
module.exports = {
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
};
@@ -0,0 +1,318 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { pushNotif, pushParentNotif } = require('../utils/notifications');
const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic');
/* ── POST /api/submissions (student) ─────────────────────────────────── */
function submit(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { assignment_id, class_id, message } = req.body;
const student_id = req.user.id;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
const member = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(Number(class_id), student_id);
if (!member) return res.status(403).json({ error: 'Not a member of this class' });
if (assignment_id) {
const assign = db.prepare(
'SELECT id FROM assignments WHERE id = ? AND class_id = ?'
).get(Number(assignment_id), Number(class_id));
if (!assign) return res.status(400).json({ error: 'Assignment not found in class' });
}
// Magic bytes verification — reject spoofed MIME types
const uploadedPath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(uploadedPath, req.file.mimetype)) {
try { fs.unlinkSync(uploadedPath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
let r;
try {
r = db.prepare(`
INSERT INTO submissions
(class_id, assignment_id, student_id, original_name, stored_name, mimetype, size, message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
Number(class_id),
assignment_id ? Number(assignment_id) : null,
student_id,
req.file.originalname,
req.file.filename,
req.file.mimetype,
req.file.size,
message?.trim() || null
);
} catch (err) {
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
// Notify teacher that a student submitted work
try {
const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(Number(class_id));
if (cls?.teacher_id) {
const assignTitle = assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(Number(assignment_id))?.title
: null;
const studentName = req.user.name || req.user.email;
const msg = assignTitle
? `«${studentName}» сдал работу по заданию «${assignTitle}»`
: `«${studentName}» прикрепил работу в классе «${cls.name}»`;
pushNotif(cls.teacher_id, 'submission', msg, '/classes');
}
} catch (e) { console.error('[submissions] notify teacher:', e.message); }
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/submissions/my (student) ───────────────────────────────── */
function getMySubmissions(req, res) {
const rows = db.prepare(`
SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size,
s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at,
a.title AS assignment_title
FROM submissions s
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.student_id = ?
ORDER BY s.submitted_at DESC
`).all(req.user.id);
res.json(rows);
}
/* ── GET /api/submissions?class_id=X (teacher/admin) ─────────────────── */
function getClassSubmissions(req, res) {
const { class_id } = req.query;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
if (req.user.role === 'teacher') {
const cls = db.prepare('SELECT teacher_id FROM classes WHERE id = ?').get(Number(class_id));
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
}
const rows = db.prepare(`
SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size,
s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at,
u.name AS student_name, u.email AS student_email,
a.title AS assignment_title
FROM submissions s
JOIN users u ON u.id = s.student_id
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.class_id = ?
ORDER BY s.submitted_at DESC
`).all(Number(class_id));
res.json(rows);
}
/* ── PATCH /api/submissions/:id (teacher/admin) ──────────────────────── */
function reviewSubmission(req, res) {
const sub = db.prepare(`
SELECT s.*, c.teacher_id FROM submissions s
JOIN classes c ON c.id = s.class_id WHERE s.id = ?
`).get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
if (req.user.role === 'teacher' && sub.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const VALID_STATUSES = ['new', 'reviewed', 'revision', 'resubmitted', 'accepted'];
const { status, teacher_note, grade } = req.body;
if (status && !VALID_STATUSES.includes(status))
return res.status(400).json({ error: 'Invalid status' });
if (grade !== undefined && grade !== null) {
const g = Number(grade);
if (!Number.isInteger(g) || g < 0 || g > 100)
return res.status(400).json({ error: 'Grade must be integer 0-100' });
}
const gradeVal = grade === undefined ? sub.grade :
(grade === null || grade === '') ? null : Number(grade);
const noteVal = teacher_note !== undefined ? (teacher_note?.trim() || null) : sub.teacher_note;
const statusVal = status || sub.status;
const reviewedAt = (status === 'reviewed' || status === 'accepted') ? new Date().toISOString() : sub.reviewed_at;
db.prepare(`
UPDATE submissions SET status=?, teacher_note=?, grade=?, reviewed_at=? WHERE id=?
`).run(statusVal, noteVal, gradeVal, reviewedAt, sub.id);
// Notify student
try {
const assignTitle = sub.assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title
: null;
const isGraded = gradeVal !== undefined && gradeVal !== null;
if (status === 'revision') {
const msg = assignTitle
? `Работа «${assignTitle}» отправлена на доработку.${noteVal ? ' Комментарий: ' + noteVal : ''}`
: `Ваша работа отправлена на доработку.`;
pushNotif(sub.student_id, 'revision', msg, '/homework');
} else if (status === 'accepted' || (status === 'reviewed' && sub.status !== 'reviewed')) {
const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : '';
const statusText = status === 'accepted' ? 'принята' : 'проверена';
const msg = assignTitle
? `Ваша работа «${assignTitle}» ${statusText}.${gradeText}`
: `Ваша работа ${statusText}.${gradeText}`;
pushNotif(sub.student_id, 'grade', msg, '/homework');
} else if (isGraded && !status) {
const msg = assignTitle
? `Оценка за «${assignTitle}»: ${gradeVal}/100`
: `Оценка за работу: ${gradeVal}/100`;
pushNotif(sub.student_id, 'grade', msg, '/homework');
}
// Notify parents
if (isGraded || status === 'accepted' || status === 'reviewed') {
const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : '';
const parentMsg = assignTitle
? `Работа «${assignTitle}» проверена.${gradeText}`
: `Работа проверена.${gradeText}`;
pushParentNotif(sub.student_id, 'grade', parentMsg);
}
} catch (e) { console.error('[submissions] notify student grade:', e.message); }
res.json({ ok: true });
}
/* ── GET /api/submissions/:id/download ────────────────────────────────── */
function downloadSubmission(req, res) {
const sub = db.prepare(`
SELECT s.*, c.teacher_id FROM submissions s
JOIN classes c ON c.id = s.class_id WHERE s.id = ?
`).get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (!isTeacher && sub.student_id !== uid) return res.status(403).json({ error: 'Forbidden' });
if (req.user.role === 'teacher' && sub.teacher_id !== uid) return res.status(403).json({ error: 'Forbidden' });
const filePath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (!filePath.startsWith(UPLOADS_DIR + path.sep))
return res.status(400).json({ error: 'Invalid path' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing' });
const encoded = encodeURIComponent(sub.original_name);
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`);
res.setHeader('Content-Type', sub.mimetype || 'application/octet-stream');
res.sendFile(filePath);
}
/* ── DELETE /api/submissions/:id ───────────────────────────────────────── */
function deleteSubmission(req, res) {
const sub = db.prepare('SELECT s.*, c.teacher_id FROM submissions s JOIN classes c ON c.id = s.class_id WHERE s.id = ?')
.get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
const isOwner = sub.student_id === req.user.id;
const isAdmin = req.user.role === 'admin';
const isTeacher = req.user.role === 'teacher' && sub.teacher_id === req.user.id;
if (!isOwner && !isAdmin && !isTeacher) return res.status(403).json({ error: 'Forbidden' });
// Students can't delete reviewed/accepted submissions; teachers/admin can
if (isOwner && !isAdmin && !isTeacher && ['reviewed', 'accepted'].includes(sub.status))
return res.status(400).json({ error: 'Cannot delete a reviewed submission' });
// Audit log — record who deleted what
const studentName = db.prepare('SELECT name FROM users WHERE id = ?').get(sub.student_id)?.name || '';
db.prepare(`
INSERT INTO submission_log (submission_id, class_id, assignment_id, student_id, student_name,
original_name, status, grade, teacher_note, submitted_at, action, deleted_by, deleted_by_role)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'deleted', ?, ?)
`).run(sub.id, sub.class_id, sub.assignment_id, sub.student_id, studentName,
sub.original_name, sub.status, sub.grade, sub.teacher_note, sub.submitted_at,
req.user.id, req.user.role);
const filePath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} }
db.prepare('DELETE FROM submissions WHERE id = ?').run(sub.id);
res.json({ ok: true });
}
/* ── POST /api/submissions/:id/resubmit (student — only if revision) ──── */
function resubmit(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const sub = db.prepare('SELECT * FROM submissions WHERE id = ?').get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
if (sub.student_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
if (sub.status !== 'revision') return res.status(400).json({ error: 'Resubmit only allowed for revision status' });
// Magic bytes verification on resubmit
const resubPath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(resubPath, req.file.mimetype)) {
try { fs.unlinkSync(resubPath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
// Delete old file
const oldPath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (oldPath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(oldPath); } catch {} }
const message = req.body.message?.trim() || null;
try {
db.prepare(`
UPDATE submissions SET original_name=?, stored_name=?, mimetype=?, size=?, message=?,
status='resubmitted', submitted_at=datetime('now'), teacher_note=NULL
WHERE id=?
`).run(req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, message, sub.id);
} catch (err) {
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
// Notify teacher
try {
const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(sub.class_id);
if (cls?.teacher_id) {
const assignTitle = sub.assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title
: null;
const studentName = req.user.name || req.user.email;
const msg = assignTitle
? `«${studentName}» повторно сдал работу «${assignTitle}»`
: `«${studentName}» повторно сдал работу`;
pushNotif(cls.teacher_id, 'submission', msg, '/homework');
}
} catch (e) { console.error('[submissions] notify teacher resubmit:', e.message); }
res.json({ ok: true });
}
/* ── GET /api/submissions/log?class_id=X (admin only) ─────────────────── */
function getSubmissionLog(req, res) {
const { class_id } = req.query;
let sql = `
SELECT sl.*, u.name AS deleted_by_name,
c.name AS class_name, a.title AS assignment_title
FROM submission_log sl
LEFT JOIN users u ON u.id = sl.deleted_by
LEFT JOIN classes c ON c.id = sl.class_id
LEFT JOIN assignments a ON a.id = sl.assignment_id
`;
const args = [];
if (class_id) {
sql += ' WHERE sl.class_id = ?';
args.push(Number(class_id));
}
sql += ' ORDER BY sl.deleted_at DESC LIMIT 200';
res.json(db.prepare(sql).all(...args));
}
/* ── DELETE /api/submissions/log (admin only) ─────────────────────────── */
function clearSubmissionLog(req, res) {
db.prepare('DELETE FROM submission_log').run();
res.json({ ok: true });
}
module.exports = { submit, getMySubmissions, getClassSubmissions, reviewSubmission, downloadSubmission, deleteSubmission, resubmit, getSubmissionLog, clearSubmissionLog };
@@ -0,0 +1,317 @@
const db = require('../db/db');
/* ══════════════════════════════════════════════════════════════════════
COURSE TEMPLATES
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/templates/courses ──────────────────────────────────────── */
function listCourseTemplates(req, res) {
const uid = req.user.id;
const { my, subject } = req.query;
let where;
const args = [];
if (my) {
where = 'WHERE ct.created_by = ?';
args.push(uid);
} else if (subject) {
where = 'WHERE (ct.is_public = 1 OR ct.created_by = ?) AND ct.subject_slug = ?';
args.push(uid, subject);
} else {
where = 'WHERE ct.is_public = 1 OR ct.created_by = ?';
args.push(uid);
}
const rows = db.prepare(`
SELECT ct.*, u.name AS creator_name
FROM course_templates ct
LEFT JOIN users u ON ct.created_by = u.id
${where}
ORDER BY ct.created_at DESC
`).all(...args);
res.json(rows.map(r => ({
id: r.id,
title: r.title,
description: r.description || '',
category: r.category,
subjectSlug: r.subject_slug,
structure: safeJSON(r.structure, {}),
isPublic: r.is_public === 1,
createdBy: r.created_by,
creatorName: r.creator_name || '',
createdAt: r.created_at,
})));
}
/* ── POST /api/templates/courses ─────────────────────────────────────── */
function saveCourseTemplate(req, res) {
const uid = req.user.id;
const { title, description, category, subject_slug, courseId } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
let structure = {};
if (courseId) {
// Snapshot course structure
const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(courseId);
if (!course) return res.status(404).json({ error: 'Course not found' });
const sections = db.prepare(
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
).all(courseId);
const lessons = db.prepare(
'SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index, id'
).all(courseId);
const sectionArr = sections.map(s => {
const sectionLessons = lessons.filter(l => l.section_id === s.id);
return {
title: s.title,
lessons: sectionLessons.map(l => ({
title: l.title,
blocks: db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
})),
};
});
// Lessons without a section
const unsectioned = lessons.filter(l => !l.section_id);
if (unsectioned.length) {
sectionArr.unshift({
title: null,
lessons: unsectioned.map(l => ({
title: l.title,
blocks: db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
})),
});
}
structure = { sections: sectionArr };
}
const r = db.prepare(`
INSERT INTO course_templates (title, description, category, subject_slug, structure, is_public, created_by)
VALUES (?, ?, ?, ?, ?, 1, ?)
`).run(
title.trim(),
description || null,
category || 'general',
subject_slug || null,
JSON.stringify(structure),
uid
);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── POST /api/templates/courses/:id/create ──────────────────────────── */
function createFromCourseTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
const { title, subjectSlug } = req.body;
const structure = safeJSON(tpl.structure, {});
const sections = structure.sections || [];
let newCourseId;
db.transaction(() => {
const cr = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
VALUES (?, ?, ?, '', 0, 0, ?)
`).run(
subjectSlug || tpl.subject_slug || 'other',
title || tpl.title,
tpl.description || null,
req.user.id
);
newCourseId = cr.lastInsertRowid;
let lessonOrder = 0;
for (const sec of sections) {
let sectionId = null;
if (sec.title) {
const sr = db.prepare(
'INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)'
).run(newCourseId, sec.title, lessonOrder);
sectionId = sr.lastInsertRowid;
}
for (const lesson of (sec.lessons || [])) {
const lr = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(newCourseId, lesson.title, lessonOrder++, sectionId);
const newLid = lr.lastInsertRowid;
(lesson.blocks || []).forEach((b, i) => {
db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
).run(newLid, b.type, i, JSON.stringify(b.data || {}));
});
}
}
})();
res.status(201).json({ id: newCourseId });
}
/* ── DELETE /api/templates/courses/:id ────────────────────────────────── */
function deleteCourseTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM course_templates WHERE id = ?').run(tpl.id);
res.json({ ok: true });
}
/* ══════════════════════════════════════════════════════════════════════
LESSON TEMPLATES
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/templates/lessons ──────────────────────────────────────── */
function listLessonTemplates(req, res) {
const uid = req.user.id;
const { my, category } = req.query;
let where;
const args = [];
if (my) {
where = 'WHERE lt.created_by = ?';
args.push(uid);
} else if (category) {
where = 'WHERE (lt.is_public = 1 OR lt.created_by = ?) AND lt.category = ?';
args.push(uid, category);
} else {
where = 'WHERE lt.is_public = 1 OR lt.created_by = ?';
args.push(uid);
}
const rows = db.prepare(`
SELECT lt.*, u.name AS creator_name
FROM lesson_templates lt
LEFT JOIN users u ON lt.created_by = u.id
${where}
ORDER BY lt.created_at DESC
`).all(...args);
res.json(rows.map(r => ({
id: r.id,
title: r.title,
category: r.category,
subjectSlug: r.subject_slug,
blocks: safeJSON(r.blocks, []),
isPublic: r.is_public === 1,
createdBy: r.created_by,
creatorName: r.creator_name || '',
createdAt: r.created_at,
})));
}
/* ── POST /api/templates/lessons ─────────────────────────────────────── */
function saveLessonTemplate(req, res) {
const uid = req.user.id;
const { title, category, subject_slug, lessonId } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
let blocksJSON = '[]';
if (lessonId) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(lessonId);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const rawBlocks = db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(lesson.id);
blocksJSON = JSON.stringify(rawBlocks.map(b => ({
type: b.type,
data: safeJSON(b.data, {}),
})));
}
const r = db.prepare(`
INSERT INTO lesson_templates (title, category, subject_slug, blocks, is_public, created_by)
VALUES (?, ?, ?, ?, 1, ?)
`).run(
title.trim(),
category || 'general',
subject_slug || null,
blocksJSON,
uid
);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── POST /api/templates/lessons/:id/create ──────────────────────────── */
function createFromLessonTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
const { courseId, sectionId, title } = req.body;
if (!courseId) return res.status(400).json({ error: 'courseId required' });
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId);
if (!course) return res.status(404).json({ error: 'Course not found' });
const tplBlocks = safeJSON(tpl.blocks, []);
let newLessonId;
db.transaction(() => {
// Get max order_index
const maxOrd = db.prepare(
'SELECT MAX(order_index) AS mx FROM lessons WHERE course_id = ?'
).get(courseId);
const lr = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(courseId, title || tpl.title, (maxOrd?.mx ?? -1) + 1, sectionId || null);
newLessonId = lr.lastInsertRowid;
tplBlocks.forEach((b, i) => {
db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
).run(newLessonId, b.type, i, JSON.stringify(b.data || {}));
});
})();
res.status(201).json({ id: newLessonId });
}
/* ── DELETE /api/templates/lessons/:id ────────────────────────────────── */
function deleteLessonTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lesson_templates WHERE id = ?').run(tpl.id);
res.json({ ok: true });
}
/* ── helpers ─────────────────────────────────────────────────────────── */
function safeJSON(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
module.exports = {
listCourseTemplates,
saveCourseTemplate,
createFromCourseTemplate,
deleteCourseTemplate,
listLessonTemplates,
saveLessonTemplate,
createFromLessonTemplate,
deleteLessonTemplate,
};
+134
View File
@@ -0,0 +1,134 @@
const db = require('../db/db');
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
const { role, id: uid } = req.user;
const args = [];
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
u.name AS creator_name,
COUNT(tq.question_id) AS question_count
FROM tests t
JOIN users u ON u.id = t.created_by
LEFT JOIN test_questions tq ON tq.test_id = t.id
WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args);
res.json(rows);
}
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/tests/:id ──────────────────────────────────────────────────── */
function getOne(req, res) {
const t = db.prepare(`
SELECT t.*, u.name AS creator_name
FROM tests t JOIN users u ON u.id = t.created_by WHERE t.id = ?
`).get(req.params.id);
if (!t) return res.status(404).json({ error: 'Not found' });
const questions = db.prepare(`
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
tp.name AS topic, s.name AS subject_name,
(SELECT json_group_array(json_object('id',o.id,'text',o.text,'is_correct',o.is_correct,'order_index',o.order_index,'match_pair',o.match_pair) ORDER BY o.order_index)
FROM options o WHERE o.question_id = q.id) AS options_json,
tq.order_index
FROM test_questions tq
JOIN questions q ON q.id = tq.question_id
LEFT JOIN topics tp ON tp.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE tq.test_id = ? ORDER BY tq.order_index
`).all(req.params.id).map(r => ({
...r,
options: JSON.parse(r.options_json || '[]'),
options_json: undefined,
}));
res.json({ ...t, questions });
}
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
tl !== undefined ? tl : t.time_limit,
t.id);
res.json({ ok: true });
}
/* ── DELETE /api/tests/:id ───────────────────────────────────────────────── */
function remove(req, res) {
db.prepare('DELETE FROM tests WHERE id = ?').run(req.resource.id);
res.json({ ok: true });
}
/* ── POST /api/tests/:id/questions ──────────────────────────────────────── */
function addQuestions(req, res) {
const { question_ids } = req.body;
if (!Array.isArray(question_ids) || !question_ids.length)
return res.status(400).json({ error: 'question_ids[] required' });
const testId = req.resource.id; // ownership verified by requireOwnership middleware
const { mx } = db.prepare(
'SELECT COALESCE(MAX(order_index), -1) AS mx FROM test_questions WHERE test_id = ?'
).get(testId);
let idx = mx + 1;
const ins = db.prepare(
'INSERT OR IGNORE INTO test_questions (test_id, question_id, order_index) VALUES (?, ?, ?)'
);
try {
db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ ok: true });
}
/* ── DELETE /api/tests/:id/questions/:qid ───────────────────────────────── */
function removeQuestion(req, res) {
db.prepare('DELETE FROM test_questions WHERE test_id = ? AND question_id = ?')
.run(req.resource.id, req.params.qid);
res.json({ ok: true });
}
/* ── PATCH /api/tests/:id/questions/reorder { ids: [qid, qid, ...] } ──── */
function reorderQuestions(req, res) {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return res.status(400).json({ error: 'ids[] required' });
const testId = req.resource.id;
const upd = db.prepare(
'UPDATE test_questions SET order_index = ? WHERE test_id = ? AND question_id = ?'
);
try {
db.transaction(() => {
ids.forEach((qid, i) => upd.run(i, testId, qid));
})();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ ok: true });
}
module.exports = { list, create, getOne, update, remove, addQuestions, removeQuestion, reorderQuestions };
+48
View File
@@ -0,0 +1,48 @@
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const fs = require('fs');
// Resolve DB_PATH: if absolute — use as-is; if relative — resolve from backend/ dir;
// fallback to __dirname-relative default so CWD never matters.
const _rawPath = process.env.DB_PATH;
const _backendDir = path.join(__dirname, '../../');
const dbPath = _rawPath
? (path.isAbsolute(_rawPath) ? _rawPath : path.resolve(_backendDir, _rawPath))
: path.join(__dirname, '../../data/learnspace.db');
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
// Auto-migrate from old location (backend/learnspace.db → backend/data/learnspace.db)
const oldPath = path.join(__dirname, '../../learnspace.db');
if (!fs.existsSync(dbPath) && fs.existsSync(oldPath)) {
fs.copyFileSync(oldPath, dbPath);
// Also copy WAL/SHM if present
if (fs.existsSync(oldPath + '-wal')) fs.copyFileSync(oldPath + '-wal', dbPath + '-wal');
if (fs.existsSync(oldPath + '-shm')) fs.copyFileSync(oldPath + '-shm', dbPath + '-shm');
console.log('[db] Migrated database from', oldPath, '→', dbPath);
}
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
/**
* Run a synchronous function inside a BEGIN/COMMIT/ROLLBACK transaction.
* Returns the value returned by fn(), or rethrows on error.
*/
db.transaction = function transaction(fn) {
return (...args) => {
db.exec('BEGIN');
try {
const result = fn(...args);
db.exec('COMMIT');
return result;
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
};
};
module.exports = db;
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
-- =============================================
-- LearnSpace — Initial schema
-- =============================================
-- Расширения
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Пользователи ──────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student'
CHECK (role IN ('student', 'teacher', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login TIMESTAMPTZ
);
-- ── Предметы ──────────────────────────────────
CREATE TABLE IF NOT EXISTS subjects (
id SERIAL PRIMARY KEY,
slug VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
icon VARCHAR(10)
);
INSERT INTO subjects (slug, name, icon) VALUES
('bio', 'Биология', 'dna'),
('chem', 'Химия', 'atom'),
('math', 'Математика', 'compass'),
('phys', 'Физика', 'zap')
ON CONFLICT DO NOTHING;
-- ── Темы ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS topics (
id SERIAL PRIMARY KEY,
subject_id INT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
order_index INT NOT NULL DEFAULT 0
);
-- ── Банк вопросов ─────────────────────────────
CREATE TABLE IF NOT EXISTS questions (
id SERIAL PRIMARY KEY,
subject_id INT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
topic_id INT REFERENCES topics(id) ON DELETE SET NULL,
text TEXT NOT NULL,
difficulty SMALLINT NOT NULL DEFAULT 1 CHECK (difficulty BETWEEN 1 AND 3),
year SMALLINT,
explanation TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Варианты ответов ──────────────────────────
CREATE TABLE IF NOT EXISTS options (
id SERIAL PRIMARY KEY,
question_id INT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
order_index SMALLINT NOT NULL DEFAULT 0
);
-- ── Сессии тестирования ───────────────────────
CREATE TABLE IF NOT EXISTS test_sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id INT REFERENCES subjects(id) ON DELETE SET NULL,
mode VARCHAR(20) NOT NULL DEFAULT 'exam'
CHECK (mode IN ('exam', 'practice', 'topic', 'random')),
total INT NOT NULL,
score INT,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed', 'abandoned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
-- ── Вопросы сессии ────────────────────────────
CREATE TABLE IF NOT EXISTS session_questions (
id SERIAL PRIMARY KEY,
session_id INT NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INT NOT NULL REFERENCES questions(id),
order_index INT NOT NULL
);
-- ── Ответы пользователя ───────────────────────
CREATE TABLE IF NOT EXISTS user_answers (
id SERIAL PRIMARY KEY,
session_id INT NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INT NOT NULL REFERENCES questions(id),
chosen_option_id INT REFERENCES options(id),
is_correct BOOLEAN,
time_spent_sec SMALLINT,
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Индексы ───────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_questions_subject ON questions(subject_id);
CREATE INDEX IF NOT EXISTS idx_questions_topic ON questions(topic_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON test_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_answers_session ON user_answers(session_id);
@@ -0,0 +1,3 @@
-- Уникальный индекс для upsert ответов
CREATE UNIQUE INDEX IF NOT EXISTS idx_answers_session_question
ON user_answers (session_id, question_id);
File diff suppressed because it is too large Load Diff
+667
View File
@@ -0,0 +1,667 @@
/**
* Seed: Математика — вопросы для тестов
*
* Источники:
* — 21 вопрос ЦТ/ЦЭ (общие)
* — 32 вопроса ЦТ 2021, Вариант 1 (A1A18 + B1B14)
*
* Запуск: node src/db/seed-math.js (из папки backend/)
*/
const db = require('./db');
/* ── helpers ─────────────────────────────────────────────────────────── */
function getOrCreateTopic(subjectId, name) {
const ex = db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(subjectId, name);
if (ex) return ex.id;
return db.prepare('INSERT INTO topics (subject_id, name) VALUES (?,?)').run(subjectId, name).lastInsertRowid;
}
/* ── subject ─────────────────────────────────────────────────────────── */
const math = db.prepare("SELECT id FROM subjects WHERE slug='math'").get();
if (!math) { console.error('Subject "math" not found. Run migrate first.'); process.exit(1); }
const SID = math.id;
/* ═══════════════════════════════════════════════════════════════════════
Вопросы
Поля: topic, difficulty, text, options[{text,correct}], explanation
Необязательные: year (число), type ('single'|'multiple')
═══════════════════════════════════════════════════════════════════════ */
const questions = [
/* ══════════════════════════════════════════════════════════════════════
РАЗДЕЛ 1 — Общие задания ЦТ/ЦЭ
══════════════════════════════════════════════════════════════════════ */
{
topic: 'Арифметика и степени', difficulty: 1,
text: 'Вычислите: \\((-3)^2 - 2^3 + 1\\)',
options: [
{ text: '\\(2\\)', correct: true },
{ text: '\\(4\\)', correct: false },
{ text: '\\(0\\)', correct: false },
{ text: '\\(-2\\)', correct: false },
{ text: '\\(18\\)', correct: false },
],
explanation: '\\((-3)^2=9,\\;2^3=8.\\;9-8+1=2\\)',
},
{
topic: 'Словесные задачи', difficulty: 1,
text: 'Толя купил 3 альбома и 5 карандашей. Стоимость альбома — 1 р. 20 к., карандаша — 25 к. Сколько копеек осталось у Толи после покупки, если всего у него было 6 р.?',
options: [
{ text: '115 к.', correct: true },
{ text: '145 к.', correct: false },
{ text: '110 к.', correct: false },
{ text: '125 к.', correct: false },
{ text: '275 к.', correct: false },
],
explanation: '3·120+5·25=360+125=485 к. Остаток: 600485=115 к.',
},
{
topic: 'Теория чисел', difficulty: 1,
text: 'Укажите формулу для нахождения делимого \\(n\\), если делитель равен 15, неполное частное \\(k\\), остаток 7:',
options: [
{ text: '\\(n=15k+7\\)', correct: true },
{ text: '\\(n=15(k+7)\\)', correct: false },
{ text: '\\(n=k+22\\)', correct: false },
{ text: '\\(n=7k+15\\)', correct: false },
],
explanation: 'По теореме о делении с остатком: \\(n=d\\cdot q+r=15k+7\\)',
},
{
topic: 'Тригонометрия', difficulty: 1,
text: 'Среди значений \\(-\\dfrac{\\pi}{6};\\;\\dfrac{\\pi}{4};\\;\\dfrac{\\pi}{3};\\;-\\dfrac{3\\pi}{2};\\;-6\\pi\\) укажите то, при котором \\(\\sin x=0\\):',
options: [
{ text: '\\(-6\\pi\\)', correct: true },
{ text: '\\(-\\dfrac{\\pi}{6}\\)', correct: false },
{ text: '\\(\\dfrac{\\pi}{4}\\)', correct: false },
{ text: '\\(-\\dfrac{3\\pi}{2}\\)', correct: false },
],
explanation: '\\(\\sin(-6\\pi)=0\\), так как \\(-6\\pi\\) кратно \\(\\pi\\).',
},
{
topic: 'Квадратные уравнения', difficulty: 2,
text: 'Укажите квадратное уравнение, произведение действительных корней которого равно 5:',
options: [
{ text: '\\(x^2-6x+5=0\\)', correct: true },
{ text: '\\(x^2-4x+5=0\\)', correct: false },
{ text: '\\(x^2-5x+6=0\\)', correct: false },
{ text: '\\(x^2+5x=0\\)', correct: false },
],
explanation: 'По формуле Виета произведение корней \\(=c/a\\). Для \\(x^2-6x+5=0\\): \\(5/1=5\\). Корни: \\(x=1,\\;x=5\\).',
},
{
topic: 'Тригонометрия', difficulty: 2,
text: 'Найдите значение выражения \\(\\dfrac{38}{\\pi}\\cdot\\arcsin(-1)-|-7|\\)',
options: [
{ text: '\\(-26\\)', correct: true },
{ text: '\\(-16\\)', correct: false },
{ text: '\\(-12\\)', correct: false },
{ text: '\\(26\\)', correct: false },
],
explanation: '\\(\\arcsin(-1)=-\\dfrac{\\pi}{2}\\). Тогда: \\(\\dfrac{38}{\\pi}\\cdot(-\\dfrac{\\pi}{2})-7=-19-7=-26\\).',
},
{
topic: 'Квадратные уравнения', difficulty: 1,
text: 'Найдите нули функции \\(f(x)=x^2+4x-5\\):',
options: [
{ text: '\\(x=-5\\) и \\(x=1\\)', correct: true },
{ text: '\\(x=5\\) и \\(x=-1\\)', correct: false },
{ text: '\\(x=5\\) и \\(x=1\\)', correct: false },
{ text: '\\(x=-5\\) и \\(x=-1\\)', correct: false },
],
explanation: 'Дискриминант: \\(16+20=36\\). \\(x_{1,2}=\\dfrac{-4\\pm6}{2}\\). \\(x_1=-5,\\;x_2=1\\).',
},
{
topic: 'Тригонометрия', difficulty: 2,
text: 'Найдите \\(\\operatorname{ctg}^2\\alpha\\), если \\(\\sin\\alpha=\\dfrac{1}{5}\\):',
options: [
{ text: '\\(24\\)', correct: true },
{ text: '\\(\\frac{1}{24}\\)', correct: false },
{ text: '\\(4\\)', correct: false },
{ text: '\\(25\\)', correct: false },
],
explanation: '\\(\\cos^2\\alpha=1-\\tfrac{1}{25}=\\tfrac{24}{25}\\). \\(\\operatorname{ctg}^2\\alpha=\\dfrac{24/25}{1/25}=24\\).',
},
{
topic: 'Прогрессии', difficulty: 2,
text: 'Пятый член геометрической прогрессии равен 48, шестой — 96. Найдите первый член:',
options: [
{ text: '\\(3\\)', correct: true },
{ text: '\\(1\\)', correct: false },
{ text: '\\(2\\)', correct: false },
{ text: '\\(4\\)', correct: false },
],
explanation: '\\(q=96/48=2\\). \\(b_1=48/2^4=3\\).',
},
{
topic: 'Неравенства', difficulty: 2,
text: 'Сколько целых решений имеет неравенство \\(-3\\le2-\\dfrac{3x-2}{2}<27\\,\\)?',
options: [
{ text: '\\(20\\)', correct: true },
{ text: '\\(18\\)', correct: false },
{ text: '\\(19\\)', correct: false },
{ text: '\\(21\\)', correct: false },
],
explanation: 'Решение: \\(-15\\le x\\le4\\). Целые: от 15 до 4 → \\(4-(-15)+1=20\\) чисел.',
},
{
topic: 'Квадратные уравнения', difficulty: 1,
text: 'Функция \\(f(x)=x^2+4x-5\\). Найдите сумму нулей функции.',
options: [
{ text: '\\(-4\\)', correct: true },
{ text: '\\(4\\)', correct: false },
{ text: '\\(-5\\)', correct: false },
{ text: '\\(0\\)', correct: false },
],
explanation: 'По теореме Виета: \\(-b/a=-4\\).',
},
{
topic: 'Прогрессии', difficulty: 2,
text: 'Найдите сумму всех натуральных чисел, кратных 9, которые больше 141, но меньше 170.',
options: [
{ text: '\\(459\\)', correct: true },
{ text: '\\(468\\)', correct: false },
{ text: '\\(315\\)', correct: false },
{ text: '\\(414\\)', correct: false },
],
explanation: 'Числа: 144, 153, 162. Сумма: \\(144+153+162=459\\).',
},
{
topic: 'Геометрия', difficulty: 2,
text: 'Радиус описанной окружности прямоугольного треугольника \\(ABC\\) (\\(\\angle ABC=90°\\)) равен \\(18\\sqrt{2}\\). Найдите \\(90\\cdot\\cos\\angle ACB\\), если \\(BC=6\\sqrt{2}\\).',
options: [
{ text: '\\(15\\)', correct: true },
{ text: '\\(30\\)', correct: false },
{ text: '\\(45\\)', correct: false },
{ text: '\\(60\\)', correct: false },
],
explanation: '\\(AC=2R=36\\sqrt{2}\\). \\(\\cos\\angle ACB=\\dfrac{6\\sqrt{2}}{36\\sqrt{2}}=\\dfrac{1}{6}\\). Ответ: \\(15\\).',
},
{
topic: 'Словесные задачи', difficulty: 2,
text: 'Проездной на месяц стоит 39 р., разовый — 80 к. 75% потраченной Машей суммы равны стоимости проездного. Сколько поездок она совершила?',
options: [
{ text: '65', correct: true },
{ text: '52', correct: false },
{ text: '75', correct: false },
{ text: '60', correct: false },
],
explanation: '75% суммы=39 р. → сумма=52 р.=5200 к. Поездок: \\(5200/80=65\\).',
},
{
topic: 'Неравенства', difficulty: 2,
text: 'Найдите сумму наименьшего и наибольшего целых решений неравенства \\(-3\\le2-\\dfrac{3x-2}{2}<27\\).',
options: [
{ text: '\\(-11\\)', correct: true },
{ text: '\\(-10\\)', correct: false },
{ text: '\\(-12\\)', correct: false },
{ text: '\\(-14\\)', correct: false },
],
explanation: 'Целые решения: от −15 до 4. Сумма крайних: \\(-15+4=-11\\).',
},
{
topic: 'Функции', difficulty: 2,
text: 'Функция \\(y=f(x)\\) чётная. Точки \\(A(3;-\\tfrac{2}{3})\\) и \\(B(6;-\\tfrac{3}{4})\\) на графике. Найдите \\(6f(-3)+8f(-6)\\).',
options: [
{ text: '\\(-10\\)', correct: true },
{ text: '\\(10\\)', correct: false },
{ text: '\\(-6\\)', correct: false },
{ text: '\\(-4\\)', correct: false },
],
explanation: '\\(f(-3)=f(3)=-\\tfrac{2}{3}\\), \\(f(-6)=-\\tfrac{3}{4}\\). \\(6\\cdot(-\\tfrac{2}{3})+8\\cdot(-\\tfrac{3}{4})=-4-6=-10\\).',
},
{
topic: 'Логарифмы', difficulty: 3,
text: 'Найдите произведение корней уравнения \\(\\log_2^2x-2\\log_2x=\\log_224-\\log_23\\), умноженное на 11.',
options: [
{ text: '\\(44\\)', correct: true },
{ text: '\\(22\\)', correct: false },
{ text: '\\(88\\)', correct: false },
{ text: '\\(48\\)', correct: false },
],
explanation: 'Пусть \\(t=\\log_2x\\): \\(t^2-2t=3\\Rightarrow t=3\\) или \\(t=-1\\). Корни: \\(x=8,\\;x=\\tfrac{1}{2}\\). Произведение \\(=4\\). \\(4\\times11=44\\).',
},
{
topic: 'Геометрия', difficulty: 3,
text: 'Плоскость, параллельная основанию треугольной пирамиды, делит высоту в отношении \\(5:3\\) от вершины. Найдите площадь сечения, если она меньше площади основания на 39.',
options: [
{ text: '\\(25\\)', correct: true },
{ text: '\\(36\\)', correct: false },
{ text: '\\(16\\)', correct: false },
{ text: '\\(49\\)', correct: false },
],
explanation: '\\(k=\\tfrac{5}{8}\\). \\(S_{\\text{осн}}-\\tfrac{25}{64}S_{\\text{осн}}=39\\Rightarrow S_{\\text{осн}}=64\\). \\(S_{\\text{сеч}}=25\\).',
},
{
topic: 'Показательные неравенства', difficulty: 3,
text: 'Найдите наименьшее целое решение неравенства \\(8^{2x-32}+10\\cdot4^{3x-49}>56\\).',
options: [
{ text: '\\(17\\)', correct: true },
{ text: '\\(16\\)', correct: false },
{ text: '\\(18\\)', correct: false },
{ text: '\\(15\\)', correct: false },
],
explanation: 'Пусть \\(t=2^{6x-98}\\): \\(4t+10t>56\\Rightarrow t>4\\Rightarrow x>16{,}\\overline{6}\\). Мин. целое: 17.',
},
{
topic: 'Логарифмы', difficulty: 3,
text: 'Найдите произведение наименьшего и наибольшего целых решений неравенства \\(\\log_3^2(x+12)-\\log_3(x+12)-6<0\\).',
options: [
{ text: '\\(-154\\)', correct: true },
{ text: '\\(154\\)', correct: false },
{ text: '\\(-110\\)', correct: false },
{ text: '\\(-180\\)', correct: false },
],
explanation: 'Пусть \\(t=\\log_3(x+12)\\): \\(-2<t<3\\Rightarrow -11{,}88<x<15\\). Целые: от 11 до 14. \\(-11\\times14=-154\\).',
},
{
topic: 'Неравенства', difficulty: 2,
text: 'Решите неравенство \\(|2x-3|\\le7\\) и укажите число целых решений.',
options: [
{ text: '\\(8\\)', correct: true },
{ text: '\\(7\\)', correct: false },
{ text: '\\(9\\)', correct: false },
{ text: '\\(6\\)', correct: false },
],
explanation: '\\(-7\\le2x-3\\le7\\Rightarrow-2\\le x\\le5\\). Целые: \\(-2,-1,0,1,2,3,4,5\\) — 8 чисел.',
},
/* ══════════════════════════════════════════════════════════════════════
РАЗДЕЛ 2 — ЦТ 2021, Вариант 1
══════════════════════════════════════════════════════════════════════ */
{
topic: 'Геометрия', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A1]\n\nТреугольник \\(ABC\\) — равнобедренный с основанием \\(AB\\). Угол при вершине \\(C = 56°\\). Найдите угол \\(BAC\\).',
options: [
{ text: '62°', correct: true },
{ text: '68°', correct: false },
{ text: '34°', correct: false },
{ text: '64°', correct: false },
{ text: '28°', correct: false },
],
explanation: '\\(2\\cdot\\angle BAC=180°-56°=124°\\Rightarrow\\angle BAC=62°\\).',
},
{
topic: 'Арифметика и степени', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A2]\n\nСреди дробей \\(\\dfrac{13}{7};\\;\\dfrac{15}{7};\\;\\dfrac{30}{7};\\;\\dfrac{27}{7};\\;\\dfrac{18}{7}\\) укажите ту, которая равна \\(4\\dfrac{2}{7}\\).',
options: [
{ text: '\\(\\dfrac{13}{7}\\)', correct: false },
{ text: '\\(\\dfrac{15}{7}\\)', correct: false },
{ text: '\\(\\dfrac{30}{7}\\)', correct: true },
{ text: '\\(\\dfrac{27}{7}\\)', correct: false },
{ text: '\\(\\dfrac{18}{7}\\)', correct: false },
],
explanation: '\\(4\\dfrac{2}{7}=\\dfrac{30}{7}\\).',
},
{
topic: 'Уравнения', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A3]\n\nУкажите пару, которая НЕ является решением \\(x+y=12\\): \\((3;9)\\), \\((-15;3)\\), \\((0;12)\\), \\((14;-2)\\), \\((6;6)\\).',
options: [
{ text: '\\((3;9)\\)', correct: false },
{ text: '\\((-15;3)\\)', correct: true },
{ text: '\\((0;12)\\)', correct: false },
{ text: '\\((14;-2)\\)', correct: false },
{ text: '\\((6;6)\\)', correct: false },
],
explanation: '\\(-15+3=-12\\neq12\\).',
},
{
topic: 'Неравенства', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A4]\n\nСреди чисел \\(-7;-11;11;-1;0\\) укажите то, которое не меньше \\(-9\\) и не больше \\(-2\\).',
options: [
{ text: '\\(-7\\)', correct: true },
{ text: '\\(-11\\)', correct: false },
{ text: '\\(11\\)', correct: false },
{ text: '\\(-1\\)', correct: false },
{ text: '\\(0\\)', correct: false },
],
explanation: '\\(-9\\le-7\\le-2\\) — только \\(-7\\) подходит.',
},
{
topic: 'Словесные задачи', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A5]\n\nТочка \\(C\\) делит отрезок \\(AB\\) в отношении \\(5:3\\) от \\(A\\). Длина \\(AB=24\\). Найдите \\(CB\\).',
options: [
{ text: '\\(14{,}4\\)', correct: false },
{ text: '\\(9{,}6\\)', correct: false },
{ text: '\\(6\\)', correct: false },
{ text: '\\(9\\)', correct: true },
{ text: '\\(15\\)', correct: false },
],
explanation: '\\(CB=\\dfrac{3}{8}\\cdot24=9\\).',
},
{
topic: 'Словесные задачи', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A6]\n\nВ магазин поступило 43 коробки по 110 пачек масла. Какое наименьшее количество пачек нужно продавать ежедневно, чтобы распродать за не более чем 60 дней?',
options: [
{ text: '78', correct: false },
{ text: '81', correct: false },
{ text: '79', correct: true },
{ text: '83', correct: false },
],
explanation: '\\(43\\times110=4730\\). \\(\\lceil4730/60\\rceil=79\\).',
},
{
topic: 'Функции', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A7]\n\nНайдите количество целых \\(x\\in[-6;6]\\), при которых \\(f(x)\\le-3\\) (по графику функции).',
options: [
{ text: '7', correct: true },
{ text: '6', correct: false },
{ text: '5', correct: false },
{ text: '9', correct: false },
],
explanation: 'По графику: 7 целых значений.',
},
{
topic: 'Функции', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A8]\n\nУпростите \\(|a-6|-|a|\\) при \\(\\dfrac{1}{6}<a<\\dfrac{3}{8}\\).',
options: [
{ text: '\\(-6\\)', correct: false },
{ text: '\\(2a+6\\)', correct: false },
{ text: '\\(-2a-6\\)', correct: false },
{ text: '\\(6-2a\\)', correct: true },
],
explanation: '\\(|a-6|=6-a\\) и \\(|a|=a\\). Результат: \\(6-a-a=6-2a\\).',
},
{
topic: 'Логарифмы', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A9]\n\nЧему равно \\(\\log_798-\\log_78+\\log_7\\dfrac{4}{7}\\)?',
options: [
{ text: '\\(1\\)', correct: true },
{ text: '\\(2\\)', correct: false },
{ text: '\\(\\log_72\\)', correct: false },
{ text: '\\(0\\)', correct: false },
],
explanation: '\\(\\log_7\\dfrac{98\\cdot4}{8\\cdot7}=\\log_77=1\\).',
},
{
topic: 'Словесные задачи', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A10]\n\nВелосипедист в первый день проехал 52 км, во второй — на 15% меньше. Сколько км за два дня?',
options: [
{ text: '102,4', correct: false },
{ text: '96,2', correct: true },
{ text: '89', correct: false },
{ text: '88,4', correct: false },
],
explanation: '\\(52\\times0{,}85=44{,}2\\). Итого: \\(52+44{,}2=96{,}2\\).',
},
{
topic: 'Уравнения', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · A11]\n\nНайдите произведение координат точки пересечения прямых \\(6x-y=4\\) и \\(y-18=0\\).',
options: [
{ text: '4', correct: false },
{ text: '18', correct: false },
{ text: '72', correct: false },
{ text: '66', correct: true },
],
explanation: '\\(y=18\\Rightarrow6x=22\\Rightarrow x=\\tfrac{11}{3}\\). Произведение: \\(\\tfrac{11}{3}\\cdot18=66\\).',
},
{
topic: 'Функции', difficulty: 2, year: 2021, type: 'multiple',
text: '[ЦТ 2021 · A12]\n\nУкажите номера чётных функций:\n1) \\(y=0{,}2x^2\\)\n2) \\(y=8^{\\tfrac{x^4-16}{2|x|}}\\)\n3) \\(y=-\\dfrac{3}{x}\\)\n4) \\(y=x^2-x+2\\)\n5) \\(y=\\sin2x\\)',
options: [
{ text: '1', correct: true },
{ text: '2', correct: true },
{ text: '3', correct: false },
{ text: '4', correct: false },
{ text: '5', correct: false },
],
explanation: '1) ✓ чётная. 2) ✓ показатель не меняется при \\(x\\to-x\\). 3) нечётная. 4) ни чётная ни нечётная. 5) нечётная.',
},
{
topic: 'Геометрия', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A13]\n\nПлощадь прямоугольного треугольника равна 2, радиус описанной окружности \\(R\\). Укажите верную формулу суммы катетов \\(a+b\\).\n1) \\(\\dfrac{R^2+4}{R}\\)\n2) \\(\\sqrt{R^2+2}\\)\n3) \\(2\\sqrt{R^2+4}\\)\n4) \\(\\dfrac{R^2+2}{R}\\)\n5) \\(2\\sqrt{R^2+2}\\)',
options: [
{ text: '1', correct: false },
{ text: '2', correct: false },
{ text: '3', correct: false },
{ text: '4', correct: false },
{ text: '5', correct: true },
],
explanation: '\\(c=2R,\\;ab=4\\). \\((a+b)^2=4R^2+8\\Rightarrow a+b=2\\sqrt{R^2+2}\\).',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · A14]\n\nПрямая треугольная призма \\(ABCA_1B_1C_1\\): \\(\\angle A=20°\\), \\(\\angle C=25°\\), радиус описанной окружности \\(\\sqrt{7}\\). Площадь грани \\(AA_1C_1C=2\\sqrt{35}\\). Найдите длину диагонали этой грани.',
options: [
{ text: '\\(3\\sqrt{3}\\)', correct: false },
{ text: '\\(2\\sqrt{5}\\)', correct: false },
{ text: '\\(2\\sqrt{6}\\)', correct: true },
{ text: '\\(4\\sqrt{6}\\)', correct: false },
],
explanation: '\\(\\angle B=135°\\). \\(AC=\\sqrt{14}\\). \\(AA_1=\\sqrt{10}\\). Диагональ: \\(\\sqrt{14+10}=2\\sqrt{6}\\).',
},
{
topic: 'Квадратные уравнения', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A15]\n\nПараболы \\(y=2x^2+bx+c\\) с нулями \\(x=3\\) и \\(x=4\\). Найдите \\(b+c\\).',
options: [
{ text: '12', correct: false },
{ text: '5', correct: false },
{ text: '10', correct: true },
{ text: '14', correct: false },
],
explanation: '\\(b=-14,\\;c=24\\). \\(b+c=10\\).',
},
{
topic: 'Уравнения', difficulty: 2, year: 2021, type: 'multiple',
text: '[ЦТ 2021 · A16]\n\nУкажите номера равносильных уравнений:\n1) \\((x-6)(x+6)=0\\)\n2) \\(\\sqrt{x+10}=2\\)\n3) \\(x^2+36=0\\)\n4) \\(\\dfrac{x-x^2-5}{4}+\\dfrac{x^2-x-3}{3}=\\dfrac{1}{4}\\)\n5) \\(|x|-6=0\\)',
options: [
{ text: '1', correct: true },
{ text: '2', correct: false },
{ text: '3', correct: false },
{ text: '4', correct: false },
{ text: '5', correct: true },
],
explanation: 'Ур. 1 и 5 имеют одинаковые решения \\(x=\\pm6\\).',
},
{
topic: 'Геометрия', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · A17]\n\nТочки \\(A\\) и \\(B\\) — соседние вершины квадрата; \\(\\Delta x=7\\), \\(\\Delta y=2\\). Найдите площадь квадрата \\(ABCD\\).',
options: [
{ text: '37', correct: false },
{ text: '14', correct: false },
{ text: '50', correct: false },
{ text: '53', correct: true },
],
explanation: '\\(AB^2=7^2+2^2=53\\). Площадь \\(=AB^2=53\\).',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · A18]\n\nПравильная четырёхугольная пирамида \\(SABCD\\), все рёбра 48. \\(M\\) — середина \\(SD\\), \\(N\\in SC\\), \\(CN:NS=1:3\\). Плоскость через \\(M,N\\), параллельная \\(SA\\), пересекает основание по отрезку длиной…',
options: [
{ text: '\\(16\\sqrt{13}\\)', correct: false },
{ text: '\\(16\\sqrt{10}\\)', correct: true },
{ text: '\\(8\\sqrt{37}\\)', correct: false },
{ text: '\\(12\\sqrt{17}\\)', correct: false },
],
explanation: 'Ответ: \\(16\\sqrt{10}\\).',
},
{
topic: 'Статистика и диаграммы', difficulty: 1, year: 2021,
text: '[ЦТ 2021 · B1]\n\nПо диаграмме посещений сайта (вт≈400, ср≈440, чт≈260, пт≈280, сб≈640, вс≈650) установите соответствие:\n\nА) В какой день было на 20 посещений больше предыдущего?\nБ) В какой день — на 35% меньше вторника?\nВ) В какой день — на 10% больше предыдущего?',
options: [
{ text: 'А4Б3В2', correct: true },
{ text: 'А2Б3В4', correct: false },
{ text: 'А4Б2В3', correct: false },
{ text: 'А3Б4В2', correct: false },
],
explanation: 'А) пятница(+20 от чт)→4; Б) 400×0,65=260=чт→3; В) 400×1,1=440=ср→2. Ответ: А4Б3В2.',
},
{
topic: 'Тригонометрия', difficulty: 3, year: 2021, type: 'multiple',
text: '[ЦТ 2021 · B2]\n\nВыберите три верных утверждения:\n1) если \\(\\cos(\\arccos a)=\\cos(\\arccos\\tfrac{1}{18})\\), то \\(a=\\tfrac{1}{18}\\)\n2) если \\(\\cos a=-\\cos\\tfrac{\\pi}{18}\\), то \\(\\arccos(\\cos a)=-\\tfrac{\\pi}{18}\\)\n3) если \\(\\sin a=\\sin\\tfrac{17\\pi}{18}\\), то \\(\\arcsin(\\sin a)=\\tfrac{17\\pi}{18}\\)\n4) если \\(\\arccos a=\\tfrac{\\pi}{18}\\), то \\(a=\\cos\\tfrac{\\pi}{18}\\)\n5) если \\(\\sin a=\\sin\\tfrac{\\pi}{18}\\), то \\(a=-\\tfrac{\\pi}{18}\\)\n6) если \\(a=\\tfrac{\\pi}{18}\\) и \\(\\sin a=\\sin\\tfrac{\\pi}{18}\\), то \\(\\arcsin(\\sin a)=\\tfrac{\\pi}{18}\\)',
options: [
{ text: '1', correct: true },
{ text: '2', correct: false },
{ text: '3', correct: false },
{ text: '4', correct: true },
{ text: '5', correct: false },
{ text: '6', correct: true },
],
explanation: '1) ✓, 4) ✓ по определению arccos, 6) ✓ т.к. \\(\\tfrac{\\pi}{18}\\in[-\\tfrac{\\pi}{2};\\tfrac{\\pi}{2}]\\).',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021, type: 'multiple',
text: '[ЦТ 2021 · B3]\n\nПлоскости \\(\\alpha\\perp\\beta\\), пересекаются по \\(a\\), точка \\(A\\in\\beta\\). Выберите три верных утверждения:\n1) любая прямая через A, пересекающая α, пересекает a\n2) единственная прямая через A ⊥ α\n3) прямая через A ⊥ β тоже ⊥ α\n4) любая точка a ∈ α и β\n5) любая прямая в α ⊥ a тоже ⊥ β\n6) любая прямая ⊥ a принадлежит β',
options: [
{ text: '1', correct: true },
{ text: '2', correct: false },
{ text: '3', correct: false },
{ text: '4', correct: true },
{ text: '5', correct: true },
{ text: '6', correct: false },
],
explanation: '1) ✓, 4) ✓ — по определению линии пересечения, 5) ✓ — признак перпендикулярности плоскостей.',
},
{
topic: 'Словесные задачи', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · B4]\n\nНа пастбище (квадрат) загон для скота занимает \\(\\tfrac{1}{32}\\) площади пастбища. Найдите площадь загона (в м²), если по рисунку ширина пастбища связана с размером загона.',
options: [
{ text: '800', correct: true },
{ text: '640', correct: false },
{ text: '900', correct: false },
{ text: '1000', correct: false },
],
explanation: '\\(S_{\\text{паст}}=32\\cdot S_{\\text{загон}}\\). При \\(a^2=800\\): площадь загона = 800 м².',
},
{
topic: 'Арифметика и степени', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · B5]\n\nНайдите \\(\\sqrt{8}\\cdot\\sqrt[3]{-7}\\cdot\\sqrt{32}\\cdot\\sqrt[3]{49}-7\\cdot\\dfrac{\\sqrt[3]{64}}{-2}\\).',
options: [
{ text: '\\(-98\\)', correct: true },
{ text: '\\(-126\\)', correct: false },
{ text: '\\(-84\\)', correct: false },
{ text: '\\(-70\\)', correct: false },
],
explanation: '\\(\\sqrt{8}\\cdot\\sqrt{32}=16\\). \\(\\sqrt[3]{-7\\cdot49}=-7\\). Первое: \\(-112\\). Второе: \\(7\\cdot4/(-2)=-14\\). \\(-112-(-14)=-98\\).',
},
{
topic: 'Геометрия', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · B6]\n\nПлощадь боковой поверхности цилиндра \\(=15\\pi\\), радиус на 3,5 больше высоты. Найдите \\(\\dfrac{6V}{\\pi}\\).',
options: [
{ text: '225', correct: true },
{ text: '150', correct: false },
{ text: '300', correct: false },
{ text: '180', correct: false },
],
explanation: '\\(rh=7{,}5,\\;r=h+3{,}5\\Rightarrow h=1{,}5,\\;r=5\\). \\(V=37{,}5\\pi\\). \\(6V/\\pi=225\\).',
},
{
topic: 'Тригонометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B7]\n\nРешите \\(\\sqrt{3}\\cos\\!\\left(\\dfrac{5\\pi}{18}+\\pi x\\right)=-1{,}5\\). Найдите: 3 × наибольший корень на [3;9] × количество корней на [3;9].',
options: [
{ text: '160', correct: true },
{ text: '120', correct: false },
{ text: '200', correct: false },
{ text: '90', correct: false },
],
explanation: 'На [3;9]: 6 корней, \\(x_{\\max}=\\tfrac{80}{9}\\). Ответ: \\(3\\cdot\\tfrac{80}{9}\\cdot6=160\\).',
},
{
topic: 'Логарифмы', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B8]\n\nНайдите сумму всех целых решений \\(\\log_{0{,}3}\\log_{4{,}7}(2^{x+9{,}1}-1)\\ge0\\).',
options: [
{ text: '\\(-15\\)', correct: true },
{ text: '\\(-10\\)', correct: false },
{ text: '\\(-21\\)', correct: false },
{ text: '\\(-6\\)', correct: false },
],
explanation: 'Целые решения: \\(x=-8\\) и \\(x=-7\\). Сумма: \\(-15\\).',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B9]\n\n\\(AC\\) — общая гипотенуза прямоугольных треугольников \\(ABC\\) и \\(ADC\\), плоскости ⊥. \\(AB=9\\sqrt{3}\\), \\(BC=9\\sqrt{5}\\), \\(AD=DC\\). Найдите \\(BD^2\\).',
options: [
{ text: '324', correct: true },
{ text: '256', correct: false },
{ text: '400', correct: false },
{ text: '289', correct: false },
],
explanation: '\\(AC=18\\sqrt{2}\\), \\(AD=18\\). \\(BD^2=324\\).',
},
{
topic: 'Прогрессии', difficulty: 2, year: 2021,
text: '[ЦТ 2021 · B10]\n\n\\(a_n=2n^2-15n\\). Найдите наименьший член \\(a_m\\) и его номер \\(m\\). Запишите \\(m\\cdot a_m\\).',
options: [
{ text: '\\(-112\\)', correct: true },
{ text: '\\(-98\\)', correct: false },
{ text: '\\(-128\\)', correct: false },
{ text: '\\(-80\\)', correct: false },
],
explanation: '\\(a_4=-28\\) — минимум. \\(4\\times(-28)=-112\\).',
},
{
topic: 'Уравнения', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B11]\n\nНайдите \\(25\\cdot(x_1^2+x_2^2)\\), где \\(x_1,x_2\\) — корни уравнения \\(10\\sqrt{\\dfrac{x^2}{14+5x-x^2}}-2\\sqrt{\\dfrac{14+5x-x^2}{x^2}}=19\\).',
options: [
{ text: '960', correct: true },
{ text: '800', correct: false },
{ text: '1200', correct: false },
{ text: '750', correct: false },
],
explanation: '\\(t=2\\Rightarrow5x^2-20x-56=0\\). \\(x_1^2+x_2^2=\\tfrac{192}{5}\\). \\(25\\cdot\\tfrac{192}{5}=960\\).',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B12]\n\nПрямая через вершину \\(K\\) треугольника \\(KMN\\) делит медиану \\(MA\\) в отношении \\(8:3\\) от \\(M\\) и пересекает \\(MN\\) в точке \\(B\\). \\(S_{KMB}=16\\). Найдите \\(S_{KMN}\\).',
options: [
{ text: '28', correct: true },
{ text: '24', correct: false },
{ text: '32', correct: false },
{ text: '36', correct: false },
],
explanation: '\\(MB:BN=8:3\\Rightarrow MN/MB=11/8\\). \\(S_{KMN}=28\\).',
},
{
topic: 'Теория чисел', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B13]\n\nПетя записал два различных натуральных числа, сложил, перемножил, вычел меньшее из большего, разделил большее на меньшее. Сумма четырёх результатов = 1521. Найдите сумму всех таких пар.',
options: [
{ text: '460', correct: true },
{ text: '390', correct: false },
{ text: '520', correct: false },
{ text: '410', correct: false },
],
explanation: 'Пары: (108,12) и (338,2). Суммы: 120+340=460.',
},
{
topic: 'Геометрия', difficulty: 3, year: 2021,
text: '[ЦТ 2021 · B14]\n\nПирамида \\(SABCD\\): \\(AO=9\\), \\(OC=16\\), \\(BO=OD=12\\), диагонали ⊥. Вершина \\(S\\) удалена на \\(\\tfrac{61}{7}\\) от каждой стороны основания. Через середину высоты параллельно основанию проведена плоскость. Найдите \\(10\\cdot V_{\\text{большей части}}\\).',
options: [
{ text: '1375', correct: true },
{ text: '1250', correct: false },
{ text: '1500', correct: false },
{ text: '1100', correct: false },
],
explanation: '\\(S_{ABCD}=300\\), \\(h=\\tfrac{11}{7}\\), \\(V=\\tfrac{1100}{7}\\). Большая часть: \\(\\tfrac{1925}{14}\\). \\(10V=1375\\).',
},
];
/* ── Вставка (идемпотентно — пропускать уже существующие) ─────────────── */
let inserted = 0, skipped = 0;
const checkQ = db.prepare('SELECT id FROM questions WHERE subject_id=? AND text=?');
const insertQ = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, difficulty, year, explanation, type) VALUES (?,?,?,?,?,?,?)'
);
const insertO = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)'
);
db.exec('BEGIN');
try {
for (const q of questions) {
if (checkQ.get(SID, q.text)) { skipped++; continue; }
const topicId = getOrCreateTopic(SID, q.topic);
const { lastInsertRowid: qid } = insertQ.run(
SID, topicId, q.text, q.difficulty,
q.year || null, q.explanation || null, q.type || 'single'
);
q.options.forEach((o, i) => insertO.run(qid, o.text, o.correct ? 1 : 0, i));
inserted++;
}
db.exec('COMMIT');
console.log(`✓ math: вставлено ${inserted}, пропущено ${skipped} (уже было).`);
} catch (err) {
db.exec('ROLLBACK');
console.error('Ошибка:', err.message);
process.exit(1);
}
File diff suppressed because it is too large Load Diff
+405
View File
@@ -0,0 +1,405 @@
/**
* seed-red-book-extra.js
* Добавляет дополнительные виды, сезонность и пищевые связи.
* Запуск: node src/db/seed-red-book-extra.js (из папки backend/)
* Идемпотентен — не дублирует существующие виды.
*/
require('./migrate');
const db = require('./db');
/* ── helpers ── */
const groupRow = (name) => db.prepare("SELECT id FROM rb_groups WHERE name_ru = ?").get(name);
const habitatRow = (type) => db.prepare("SELECT id FROM rb_habitats WHERE type = ?").get(type);
const insSp = db.prepare(`
INSERT INTO rb_species
(group_id, habitat_id, name_ru, name_be, name_lat, category, by_category,
description, interesting_fact, threats, conservation, where_to_see,
photo_url, model_type, population_trend, biomass_kg, season_active)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
const insReg = db.prepare('INSERT OR IGNORE INTO rb_species_regions (species_id, region_code) VALUES (?,?)');
const insWeb = db.prepare('INSERT OR IGNORE INTO rb_food_web (predator_id, prey_id, strength) VALUES (?,?,?)');
function addSp(data) {
// Guard: skip if already exists by name_ru
const existing = db.prepare("SELECT id FROM rb_species WHERE name_ru = ?").get(data.name_ru);
if (existing) return existing.id;
const gid = groupRow(data.group)?.id;
const hid = habitatRow(data.habitat)?.id || null;
if (!gid) { console.warn(`Group not found: ${data.group}`); return null; }
const id = insSp.run(
gid, hid,
data.name_ru, data.name_be || '', data.name_lat || '',
data.category, data.by_category || 'III',
data.description || '', data.fact || '',
JSON.stringify(data.threats || []),
data.conservation || '', data.where_to_see || '',
data.photo || '', data.model || 'silhouette',
JSON.stringify(data.trend || []),
data.biomass || 0,
JSON.stringify(data.seasons || []),
).lastInsertRowid;
(data.regions || []).forEach(r => insReg.run(id, r));
return id;
}
/* ── existing ids by name ── */
function getId(name) {
const r = db.prepare("SELECT id FROM rb_species WHERE name_ru = ?").get(name);
return r?.id || null;
}
db.exec('BEGIN');
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — ПТИЦЫ
══════════════════════════════════════════════════════════════════════════ */
const pukhlyak = addSp({
group: 'Птицы', habitat: 'conifer',
name_ru: 'Пухляк (гаичка буроголовая)', name_be: 'Мухаловка шэрая', name_lat: 'Poecile montanus',
category: 'NT', by_category: 'IV',
description: 'Небольшая птица хвойных лесов, запасающая корм в тайниках на зиму. Способна запомнить сотни тайников одновременно. Активна круглый год, не покидает территорию даже в сильные морозы.',
fact: 'Пухляк запасает до 2000 семян за осень, запоминая каждое место с точностью до нескольких сантиметров.',
threats: ['Вырубка ельников', 'Конкуренция с синицами'],
conservation: 'Сохранение разновозрастных хвойных лесов.',
where_to_see: 'Беловежская пуща, Налибокская пуща, Нарочанский НП',
biomass: 0.012, seasons: ['10','11','12','1','2','3'],
regions: ['vitebsk','minsk','grodno'],
});
const lebed = addSp({
group: 'Птицы', habitat: 'river',
name_ru: 'Лебедь-шипун', name_be: 'Лебедзь-шыпун', name_lat: 'Cygnus olor',
category: 'NT', by_category: 'IV',
description: 'Один из крупнейших летающих птиц мира — масса до 14 кг. Агрессивно защищает гнездо от любых нарушителей. Пары создаются на много лет. В Беларуси гнездится на озёрах, прудах и медленных реках.',
fact: 'Лебедь-шипун — самая тяжёлая из всех летающих птиц. Чтобы взлететь, ему нужно пробежать по воде 40–50 м.',
threats: ['Рекреационная нагрузка', 'Свинцовое отравление', 'Беспокойство у гнёзд'],
conservation: 'Охранные зоны у гнёзд. Ограничение водно-моторного спорта.',
where_to_see: 'Браславские озёра, Нарочанский НП, Минское море',
biomass: 10.0, seasons: ['4','5','6','7','8'],
regions: ['vitebsk','minsk','brest','grodno'],
});
const klikun = addSp({
group: 'Птицы', habitat: 'river',
name_ru: 'Лебедь-кликун', name_be: 'Лебедзь-крыкун', name_lat: 'Cygnus cygnus',
category: 'VU', by_category: 'II',
description: 'Перелётный лебедь, зимующий в Беларуси. В отличие от шипуна держит шею прямо. Мощный трубный крик слышен далеко. Небольшая гнездовая популяция в Витебской области значительно выросла за последние 30 лет.',
fact: 'Лебедь-кликун мигрирует на высоте до 8000 м. Зафиксированы особи, долетавшие из Западной Сибири.',
threats: ['Беспокойство', 'Отравление свинцом', 'Недостаток спокойных зимовальных мест'],
conservation: 'Охранные зоны. Запрет охоты.',
where_to_see: 'Браславские озёра (зимовка), Витебская область (гнездование)',
biomass: 9.0, seasons: ['10','11','12','1','2','3'],
regions: ['vitebsk','minsk'],
});
const domovoy_sych = addSp({
group: 'Птицы', habitat: 'meadow',
name_ru: 'Сыч домовый', name_be: 'Хатні сычык', name_lat: 'Athene noctua',
category: 'EN', by_category: 'II',
description: 'Маленькая сова агроландшафтов, живущая в старых постройках, скалах и пнях. Активна в сумерках. В Беларуси редкость — известны единицы гнездовых пар. Численность катастрофически снизилась из-за химизации сельского хозяйства.',
fact: 'Домовый сыч ловит до 600 мышей и 2000 насекомых в год, являясь природным регулятором грызунов.',
threats: ['Применение пестицидов', 'Снижение числа старых построек', 'Кошки'],
conservation: 'Установка гнездовых ящиков. Ограничение пестицидов.',
where_to_see: 'Брестская область (единичные пары)',
biomass: 0.18, seasons: ['3','4','5','6','7','8','9'],
regions: ['brest','gomel'],
});
const velik_kulak = addSp({
group: 'Птицы', habitat: 'wetland',
name_ru: 'Большой кроншнеп', name_be: 'Вялікі кроншнеп', name_lat: 'Numenius arquata',
category: 'EN', by_category: 'II',
description: 'Крупнейший кулик Беларуси с изогнутым книзу клювом. Гнездится на влажных лугах и болотах. Очень пуглив — уже с 500 метров покидает гнездо при появлении людей. Численность неуклонно сокращается.',
fact: 'Большой кроншнеп может зондировать почву на глубину 15 см своим клювом, находя дождевых червей по осязанию.',
threats: ['Ранний сенокос', 'Осушение лугов', 'Хищничество лисиц'],
conservation: 'Отсрочка сенокоса. Сохранение пойменных лугов.',
where_to_see: 'Пойма Припяти, Нарочанский НП',
biomass: 0.7, seasons: ['4','5','6','7'],
regions: ['brest','gomel','minsk','vitebsk'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — МЛЕКОПИТАЮЩИЕ
══════════════════════════════════════════════════════════════════════════ */
const zaets = addSp({
group: 'Млекопитающие', habitat: 'forest',
name_ru: 'Заяц-беляк', name_be: 'Заяц-белы', name_lat: 'Lepus timidus',
category: 'NT', by_category: 'IV',
description: 'Самый «белорусский» заяц — зимой его мех белеет, что обеспечивает маскировку на снегу. Предпочитает смешанные леса и опушки. Численность регулярно колеблется с периодом 10–11 лет в зависимости от популяции хищников.',
fact: 'Заяц-беляк может развивать скорость до 70 км/ч и прыгать на 4 м в длину, уходя от преследователей.',
threats: ['Волки', 'Лисы', 'Охота', 'Уменьшение подлеска'],
conservation: 'Регулирование охотничьей нагрузки.',
where_to_see: 'Все крупные леса Беларуси',
biomass: 3.5, seasons: ['11','12','1','2','3'],
regions: ['vitebsk','minsk','grodno','brest','gomel','mogilev'],
});
const lasitsa = addSp({
group: 'Млекопитающие', habitat: 'meadow',
name_ru: 'Ласка обыкновенная', name_be: 'Ласіца звычайная', name_lat: 'Mustela nivalis',
category: 'NT', by_category: 'IV',
description: 'Самый маленький хищник Беларуси весом 70–130 г. Способна следовать за мышами в их норы. Зимой меняет мех на белый. Настоящий «регулятор» популяций полёвок — за год уничтожает до 2000 грызунов.',
fact: 'Ласка может убивать добычу, в 5 раз тяжелее себя — кроликов и крыс.',
threats: ['Яды для грызунов', 'Уничтожение живых изгородей'],
conservation: 'Ограничение использования родентицидов.',
where_to_see: 'Луга и поля по всей Беларуси',
biomass: 0.1, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
regions: ['vitebsk','minsk','grodno','brest','gomel','mogilev'],
});
const night_bat = addSp({
group: 'Млекопитающие', habitat: 'forest',
name_ru: 'Широкоушка европейская', name_be: 'Шырокавухая кажан', name_lat: 'Barbastella barbastellus',
category: 'EN', by_category: 'II',
description: 'Редкая летучая мышь с характерными широкими ушами. Зимует в пещерах и подвалах. Охотится на ночных бабочек, улавливая их вибрации. Одна из наиболее уязвимых рукокрылых Европы.',
fact: 'Широкоушка способна поймать до 2000 насекомых за ночь. Её эхолокация настолько совершенна, что она не задевает паутину.',
threats: ['Утепление зданий', 'Химические обработки леса', 'Беспокойство в зимовочных убежищах'],
conservation: 'Охрана зимовочных убежищ. Вывешивание летних домиков.',
where_to_see: 'Беловежская пуща, старые здания Гродненской области',
biomass: 0.01, seasons: ['4','5','6','7','8','9'],
regions: ['grodno','brest','minsk'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — РАСТЕНИЯ
══════════════════════════════════════════════════════════════════════════ */
const kupena = addSp({
group: 'Растения', habitat: 'forest',
name_ru: 'Купена многоцветковая', name_be: 'Купена шматкветкавая', name_lat: 'Polygonatum multiflorum',
category: 'NT', by_category: 'IV',
description: 'Изящное тенелюбивое растение тёмных лесов с белыми колокольчатыми цветами. Плоды — тёмно-синие ягоды — ядовиты для человека, но поедаются птицами. Медленно растёт: возраст крупных особей может достигать 50 лет.',
fact: 'Купена цветёт только один раз в жизни. Название происходит от «купель» — ранее растение применялось в народной медицине.',
threats: ['Сбор на букеты', 'Рекреационная нагрузка', 'Вырубка лесов'],
conservation: 'Запрет сбора. Включение в охраняемые участки.',
where_to_see: 'Беловежская пуща, дубравы Полесья',
biomass: 0.05, seasons: ['4','5','6'],
regions: ['grodno','brest','gomel','minsk'],
});
const kruzhevnitsa = addSp({
group: 'Растения', habitat: 'wetland',
name_ru: 'Альдрованда пузырчатая', name_be: 'Альдрованда бурбалкавая', name_lat: 'Aldrovanda vesiculosa',
category: 'CR', by_category: 'I',
description: 'Единственное в мире водное хищное растение, не имеющее корней. Ловит водных беспозвоночных ловушками, напоминающими венерину мухоловку. Популяции в Беларуси — единственные в центральной Европе. Крайне чувствительна к загрязнению воды.',
fact: 'Альдрованда — самое быстрое хищное растение в воде: захлопывает ловушку за 0.01 секунды.',
threats: ['Загрязнение воды', 'Эвтрофикация', 'Зарастание водоёмов'],
conservation: 'Охрана болотных водоёмов. Восстановление популяций.',
where_to_see: 'Ольманские болота, Брестская область',
biomass: 0.001, seasons: ['6','7','8'],
regions: ['brest'],
});
const korolev_papor = addSp({
group: 'Растения', habitat: 'forest',
name_ru: 'Папоротник-орляк', name_be: 'Арляк звычайны', name_lat: 'Pteridium aquilinum',
category: 'NT', by_category: 'IV',
description: 'Один из древнейших видов флоры Беларуси — существует без изменений 55 миллионов лет. Образует обширные куртины в сосновых лесах. Молодые побеги («скрипухи») ядовиты для скота, но съедобны для людей после варки.',
fact: 'Орляк — один из немногих папоротников, покрывающих более 1% суши Земли. Его корневища уходят на глубину 4 м.',
threats: ['Интенсивные рубки', 'Пожары', 'Иссушение лесов'],
conservation: 'Охрана старых лесов.',
where_to_see: 'Хвойные леса по всей Беларуси',
biomass: 0.3, seasons: ['5','6','7','8','9'],
regions: ['vitebsk','minsk','grodno','brest','gomel','mogilev'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — НАСЕКОМЫЕ
══════════════════════════════════════════════════════════════════════════ */
const rosalia = addSp({
group: 'Насекомые', habitat: 'forest',
name_ru: 'Усач альпийский', name_be: 'Вусач альпійскі', name_lat: 'Rosalia alpina',
category: 'EN', by_category: 'II',
description: 'Один из красивейших жуков Европы — голубовато-серый с чёрными пятнами, с усами длиннее тела. Личинка живёт в мёртвой буковой древесине. В Беларуси встречается на границе ареала, в Беловежской пуще.',
fact: 'Усач альпийский — один из символов охраны природы Европы. Его изображение есть на почтовых марках многих стран.',
threats: ['Вырубка старых буков', 'Удаление мёртвой древесины'],
conservation: 'Сохранение старых буковых деревьев и валежника.',
where_to_see: 'Беловежская пуща',
biomass: 0.003, seasons: ['6','7','8'],
regions: ['brest'],
});
const pchela_plothnik = addSp({
group: 'Насекомые', habitat: 'forest',
name_ru: 'Пчела-плотник', name_be: 'Пчала-сталяр', name_lat: 'Xylocopa violacea',
category: 'VU', by_category: 'III',
description: 'Крупнейшая дикая пчела Беларуси — тело до 28 мм с фиолетово-синим блеском крыльев. Не живёт в ульях — самостоятельно прогрызает норы в сухой древесине. Важный опылитель с длинным хоботком для глубоких цветков.',
fact: 'Пчела-плотник способна прогрызть тоннель длиной 30 см в дубовом бревне.',
threats: ['Уборка старой и мёртвой древесины', 'Применение инсектицидов'],
conservation: 'Сохранение старых деревьев. Установка гнездовых брёвен.',
where_to_see: 'Беловежская пуща, старые сады Брестской области',
biomass: 0.001, seasons: ['4','5','6','7','8'],
regions: ['brest','grodno'],
});
const zelenyi_metallik = addSp({
group: 'Насекомые', habitat: 'meadow',
name_ru: 'Бронзовка гладкая', name_be: 'Бронзаўка гладкая', name_lat: 'Protaetia aeruginosa',
category: 'EN', by_category: 'II',
description: 'Жук с изумрудным металлическим блеском. Личинка развивается в дуплах старых дубов, питаясь перегноем. Взрослые особи питаются цветочным нектаром. Исчезает вместе с вековыми дубами.',
fact: 'Бронзовка — «инженер экосистем»: личинки рыхлят перегной в дуплах, создавая субстрат для редких растений и других насекомых.',
threats: ['Вырубка старых дубов', 'Исчезновение дупел', 'Применение инсектицидов'],
conservation: 'Сохранение старовозрастных дубрав.',
where_to_see: 'Беловежская пуща, дубравы Налибокской пущи',
biomass: 0.002, seasons: ['5','6','7'],
regions: ['brest','grodno','minsk'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — РЫБЫ
══════════════════════════════════════════════════════════════════════════ */
const som = addSp({
group: 'Рыбы', habitat: 'river',
name_ru: 'Сом обыкновенный', name_be: 'Сом звычайны', name_lat: 'Silurus glanis',
category: 'NT', by_category: 'IV',
description: 'Крупнейшая пресноводная рыба Беларуси — достигает 3 м длины и 200 кг. Ведёт ночной образ жизни. Охотится в основном на рыбу, но не брезгует птицами и млекопитающими. В старых реках встречались особи возрастом 60–80 лет.',
fact: 'Сом может выползать на берег, чтобы охотиться на купающихся уток и голубей — этот феномен наблюдали на р. Биже в Испании.',
threats: ['Браконьерство', 'Ухудшение качества воды', 'Перекрытие миграционных путей'],
conservation: 'Ограничение промысла. Охрана нерестилищ.',
where_to_see: 'Припять, Нёман, Днепр, Западная Двина',
biomass: 30.0, seasons: ['5','6','7','8','9'],
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
});
const taimen = addSp({
group: 'Рыбы', habitat: 'river',
name_ru: 'Хариус европейский', name_be: 'Харыус еўрапейскі', name_lat: 'Thymallus thymallus',
category: 'VU', by_category: 'III',
description: 'Стремительная рыба холодных чистых рек с высоким, похожим на парус, спинным плавником. Индикатор качества воды — исчезает при малейшем загрязнении. Питается насекомыми, упавшими на поверхность воды.',
fact: 'Хариус умеет «читать» поверхность воды: он точно определяет, куда упадёт насекомое, и занимает позицию заблаговременно.',
threats: ['Загрязнение рек', 'Чрезмерный вылов', 'Мелиорация'],
conservation: 'Охрана чистых рек. Запрет химических обработок водосборов.',
where_to_see: 'Западная Двина (верховья), Вилия, Нёман (верховья)',
biomass: 0.3, seasons: ['4','5','6'],
regions: ['vitebsk','grodno'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — РЕПТИЛИИ И АМФИБИИ
══════════════════════════════════════════════════════════════════════════ */
const gadyuka = addSp({
group: 'Рептилии и амфибии', habitat: 'wetland',
name_ru: 'Гадюка обыкновенная', name_be: 'Гадзюка звычайная', name_lat: 'Vipera berus',
category: 'NT', by_category: 'IV',
description: 'Единственная ядовитая змея Беларуси. Укус редко опасен для взрослого здорового человека, но вызывает болезненный отёк. Питается грызунами, лягушками и ящерицами. Незаменима в экосистеме как регулятор численности грызунов.',
fact: 'Гадюка рожает живых детёнышей (до 12 штук), а не откладывает яйца. Молодые змеи с первого дня ядовиты.',
threats: ['Уничтожение людьми', 'Осушение болот', 'Автодороги'],
conservation: 'Просветительская работа. Охрана болотных участков.',
where_to_see: 'Болота и леса по всей Беларуси',
biomass: 0.15, seasons: ['4','5','6','7','8','9'],
regions: ['vitebsk','minsk','grodno','brest','gomel','mogilev'],
});
const listvy_trit = addSp({
group: 'Рептилии и амфибии', habitat: 'wetland',
name_ru: 'Гребенчатый тритон', name_be: 'Грабенчасты трытон', name_lat: 'Triturus cristatus',
category: 'NT', by_category: 'IV',
description: 'Крупнейший тритон Беларуси. В брачный период самец развивает зубчатый гребень вдоль всего тела. Обитает во влажных лесах и болотах. Выделяет ядовитые вещества через кожу для защиты от хищников.',
fact: 'Гребенчатый тритон может прожить 20–25 лет. В воде он дышит через кожу, не нуждаясь в воздухе часами.',
threats: ['Осушение водоёмов', 'Хищники', 'Загрязнение'],
conservation: 'Охрана болотных водоёмов. Создание пруд-убежищ.',
where_to_see: 'Пуща Налибокская, лесные водоёмы Витебской области',
biomass: 0.02, seasons: ['3','4','5','6'],
regions: ['vitebsk','minsk','grodno'],
});
/* ══════════════════════════════════════════════════════════════════════════
НОВЫЕ ВИДЫ — ГРИБЫ
══════════════════════════════════════════════════════════════════════════ */
const hleb_grib = addSp({
group: 'Грибы', habitat: 'forest',
name_ru: 'Ёж гриб (герций)', name_be: 'Гербій жаўтлявы', name_lat: 'Hericium erinaceus',
category: 'EN', by_category: 'II',
description: 'Необычный гриб с длинными белыми иглами, свисающими каскадом с дерева. Живёт на старых дубах и буках. Съедобен и ценится как деликатес. Исчезает вместе со старовозрастными широколиственными лесами.',
fact: 'Гриб-ёж содержит вещества, стимулирующие рост нейронов. Изучается как потенциальное средство при болезни Альцгеймера.',
threats: ['Вырубка старых деревьев', 'Сбор грибниками'],
conservation: 'Сохранение старых дубрав. Запрет сбора.',
where_to_see: 'Беловежская пуща',
biomass: 0.5, seasons: ['8','9','10'],
regions: ['brest'],
});
/* ══════════════════════════════════════════════════════════════════════════
ОБНОВИТЬ СЕЗОННОСТЬ СУЩЕСТВУЮЩИХ ВИДОВ
══════════════════════════════════════════════════════════════════════════ */
const seasonUpdate = db.prepare("UPDATE rb_species SET season_active = ? WHERE name_ru = ?");
const seasonMap = [
['Орлан-белохвост', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Чёрный аист', JSON.stringify(['4','5','6','7','8','9'])],
['Скопа', JSON.stringify(['4','5','6','7','8','9'])],
['Коростель', JSON.stringify(['5','6','7','8'])],
['Серый журавль', JSON.stringify(['3','4','5','6','7','8','9','10'])],
['Филин', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Зубр', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Рысь', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Бурый медведь', JSON.stringify(['4','5','6','7','8','9','10'])],
['Бобёр европейский', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Выдра речная', JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])],
['Стерлядь', JSON.stringify(['4','5','6','7','8','9'])],
['Болотная черепаха', JSON.stringify(['4','5','6','7','8','9'])],
['Венерин башмачок', JSON.stringify(['5','6'])],
['Водяной орех', JSON.stringify(['6','7','8','9'])],
['Жук-олень', JSON.stringify(['6','7','8'])],
['Махаон', JSON.stringify(['5','6','7','8','9'])],
['Богомол обыкновенный', JSON.stringify(['7','8','9'])],
['Трюфель белый', JSON.stringify(['8','9','10','11'])],
];
seasonMap.forEach(([name, json]) => seasonUpdate.run(json, name));
db.exec('COMMIT');
console.log('✓ Дополнительные виды добавлены');
console.log(` Всего видов: ${db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n}`);
console.log(` Пищевых связей: ${db.prepare('SELECT COUNT(*) as n FROM rb_food_web').get().n}`);
/* ══════════════════════════════════════════════════════════════════════════
ПИЩЕВЫЕ СВЯЗИ ДЛЯ НОВЫХ ВИДОВ
══════════════════════════════════════════════════════════════════════════ */
db.exec('BEGIN');
const newLinks = [
// Лебедь-шипун — водные растения
[lebed, getId('Водяной орех'), 0.4],
// Лебедь-кликун — водные растения
[klikun, getId('Водяной орех'), 0.4],
// Сом — рыба, лягушки
[som, getId('Стерлядь'), 0.3],
[som, getId('Хариус европейский'), 0.3],
// Гадюка — грызуны (опосредованно через тритона)
[gadyuka, getId('Тритон обыкновенный') || listvy_trit, 0.4],
// Гребенчатый тритон — насекомые
[listvy_trit, getId('Богомол обыкновенный'), 0.1],
// Выдра охотится на хариуса и сома
[getId('Выдра речная'), som, 0.2],
[getId('Выдра речная'), taimen, 0.3],
// Орлан на лебедей (редко)
[getId('Орлан-белохвост'), lebed, 0.1],
// Рысь — зайца
[getId('Рысь'), zaets, 0.4],
// Волк — зайца
[getId('Волк обыкновенный'), zaets, 0.3],
// Ласка — полёвки (через вид)
[lasitsa, getId('Коростель'), 0.1],
// Пухляк и семена
[getId('Воробьиный сыч'), pukhlyak, 0.2],
// Широкоушка — насекомые
[night_bat, getId('Махаон'), 0.2],
[night_bat, getId('Богомол обыкновенный'), 0.2],
].filter(([p, q]) => p && q);
newLinks.forEach(([p, q, s]) => {
try { insWeb.run(p, q, s); } catch {}
});
db.exec('COMMIT');
console.log(` Новых пищевых связей: ${newLinks.length}`);
console.log('\n✅ seed-red-book-extra.js завершён!');
+493
View File
@@ -0,0 +1,493 @@
/**
* seed-red-book-phase2.js
* +20 видов: мхи/лишайники, пресноводные, редкие насекомые, птицы болот, ночные хищники
* +10 квестов
* Запуск: node src/db/seed-red-book-phase2.js (из папки backend/)
* Идемпотентен — не дублирует существующее.
*/
require('./migrate');
const db = require('./db');
/* ── helpers ── */
const groupRow = n => db.prepare('SELECT id FROM rb_groups WHERE name_ru = ?').get(n);
const habitatRow = t => db.prepare('SELECT id FROM rb_habitats WHERE type = ?').get(t);
const getId = n => db.prepare('SELECT id FROM rb_species WHERE name_ru = ?').get(n)?.id || null;
const insSp = db.prepare(`
INSERT INTO rb_species
(group_id, habitat_id, name_ru, name_be, name_lat, category, by_category,
description, interesting_fact, threats, conservation, where_to_see,
photo_url, model_type, population_trend, biomass_kg, season_active)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
const insReg = db.prepare('INSERT OR IGNORE INTO rb_species_regions (species_id, region_code) VALUES (?,?)');
const insWeb = db.prepare('INSERT OR IGNORE INTO rb_food_web (predator_id, prey_id, strength) VALUES (?,?,?)');
function addSp(d) {
const existing = db.prepare('SELECT id FROM rb_species WHERE name_ru = ?').get(d.name_ru);
if (existing) return existing.id;
const gid = groupRow(d.group)?.id;
const hid = habitatRow(d.habitat)?.id || null;
if (!gid) { console.warn('Group not found:', d.group); return null; }
const id = insSp.run(
gid, hid,
d.name_ru, d.name_be || '', d.name_lat || '',
d.category, d.by_category || 'III',
d.description || '', d.fact || '',
JSON.stringify(d.threats || []),
d.conservation || '', d.where_to_see || '',
d.photo || '', d.model || 'silhouette',
JSON.stringify(d.trend || []),
d.biomass || 0,
JSON.stringify(d.seasons || []),
).lastInsertRowid;
(d.regions || []).forEach(r => insReg.run(id, r));
return id;
}
/* ════════════════════════════════════════════════════════════════════════
БЛОК 1 — ВИДЫ
════════════════════════════════════════════════════════════════════════ */
db.exec('BEGIN');
/* ── ПТИЦЫ (3) ─────────────────────────────────────────────────────── */
const kamyshevka = addSp({
group: 'Птицы', habitat: 'wetland',
name_ru: 'Вертлявая камышевка', name_be: 'Вярцёжная чаротнiца', name_lat: 'Acrocephalus paludicola',
category: 'CR', by_category: 'I',
description: 'Одна из наиболее угрожаемых перелётных птиц Европы. Гнездится исключительно в низинных болотах с осоково-злаковой растительностью. Беларусь — мировой центр гнездования, здесь обитает до 80 % мировой популяции. Зимует в западной Африке.',
fact: 'Беларусь является главным «домом» вертлявой камышевки — здесь гнездится большинство всех особей планеты.',
threats: ['Осушение болот', 'Зарастание гнездовых угодий', 'Пожары в поймах', 'Потеря зимовок в Африке'],
conservation: 'Охрана низинных болот. Восстановление обводнённых пойм. Мониторинг популяции.',
where_to_see: 'Споровское болото (Брестская обл.), Ельня (Витебская обл.), Дикое болото',
biomass: 0.013, seasons: ['5','6','7','8'],
trend: [{year:1990,count_estimate:8000,source:'IUCN'},{year:2000,count_estimate:4500,source:'BirdLife'},{year:2010,count_estimate:2800,source:'BirdLife'},{year:2024,count_estimate:2100,source:'АПБ'}],
regions: ['vitebsk','grodno','brest','minsk'],
});
const dupen = addSp({
group: 'Птицы', habitat: 'wetland',
name_ru: 'Дупель', name_be: 'Дупель', name_lat: 'Gallinago media',
category: 'EN', by_category: 'II',
description: 'Скрытный кулик заливных лугов и болот. Самцы собираются на токах, где демонстрируют оперение и трясутся всем телом. После спаривания самка одна воспитывает птенцов. Ночной образ жизни затрудняет учёт.',
fact: 'Самцы дупеля токуют по ночам на «арене» — постоянном месте сбора, которое используется десятилетиями.',
threats: ['Осушение пойменных лугов', 'Раннее сенокошение', 'Браконьерство на пролёте'],
conservation: 'Запрет сенокошения до августа на ключевых участках. Сохранение влажных лугов.',
where_to_see: 'Ельня, Освейское озеро, пойма р. Припять',
biomass: 0.18, seasons: ['4','5','6','7','8','9'],
trend: [{year:1995,count_estimate:1200,source:'НООО БПО'},{year:2010,count_estimate:650,source:'НООО БПО'},{year:2024,count_estimate:400,source:'НООО БПО'}],
regions: ['vitebsk','minsk','grodno','brest'],
});
const kobchik = addSp({
group: 'Птицы', habitat: 'meadow',
name_ru: 'Кобчик', name_be: 'Кабчык', name_lat: 'Falco vespertinus',
category: 'VU', by_category: 'III',
description: 'Изящный сокол размером с галку. Самцы — шиферно-серые с рыжей «штанишками», самки рябые. Охотится на крупных насекомых — прямокрылых, стрекоз, жуков. Гнездится колониями в чужих гнёздах — грачей и сорок. Перелётный, зимует в Африке.',
fact: 'Кобчик охотится как ласточка — хватает насекомых прямо в воздухе на бреющем полёте над лугами.',
threats: ['Деградация пойменных лугов', 'Применение пестицидов (уменьшение насекомых)', 'Уничтожение колоний грачей'],
conservation: 'Сохранение лугов. Запрет уничтожения грачиных колоний.',
where_to_see: 'Припятский НП, луга Брестской и Гомельской областей',
biomass: 0.16, seasons: ['5','6','7','8','9'],
trend: [{year:1990,count_estimate:900,source:'БО'},{year:2005,count_estimate:550,source:'БО'},{year:2024,count_estimate:320,source:'АПБ'}],
regions: ['brest','gomel','minsk','vitebsk'],
});
/* ── МЛЕКОПИТАЮЩИЕ (2) ──────────────────────────────────────────────── */
const vyhuhol = addSp({
group: 'Млекопитающие', habitat: 'river',
name_ru: 'Выхухоль русская', name_be: 'Расамаха расійская', name_lat: 'Desmana moschata',
category: 'CR', by_category: 'I',
description: 'Один из древнейших зверьков Земли — живой ископаемый. Обитает в старицах, заводях и медленных протоках рек Припятского бассейна. Почти слепа, ориентируется по обонянию и осязанию через вибриссы на хоботке. Выделяет мускусный секрет. Под угрозой исчезновения в Беларуси.',
fact: 'Выхухоль существует без изменений более 30 миллионов лет — её предки жили рядом с носорогами и мамонтами.',
threats: ['Загрязнение рек', 'Осушение пойм', 'Рыболовные сети', 'Хищники (норка американская)'],
conservation: 'Охрана пойменных угодий р. Припять. Борьба с инвазивной норкой американской. Заповедники.',
where_to_see: 'Пойма р. Припять (Гомельская и Брестская обл.), Припятский НП',
biomass: 0.42, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:1500,source:'НАН'},{year:2000,count_estimate:700,source:'НАН'},{year:2010,count_estimate:280,source:'НАН'},{year:2024,count_estimate:80,source:'IUCN'}],
regions: ['gomel','brest'],
});
const nochnitza = addSp({
group: 'Млекопитающие', habitat: 'river',
name_ru: 'Ночница прудовая', name_be: 'Начніца сажалкавая', name_lat: 'Myotis dasycneme',
category: 'EN', by_category: 'II',
description: 'Крупная летучая мышь, охотящаяся над открытой водой. Подхватывает насекомых с поверхности прудов и рек с помощью крыльев или хвостовой перепонки. Зимует в подземельях. Образует небольшие колонии в постройках. Чувствительна к беспокойству зимовок.',
fact: 'Ночница прудовая — настоящий рыбак: она «читает» рябь воды ушами, определяя местонахождение добычи под поверхностью.',
threats: ['Уничтожение зимовок', 'Загрязнение водоёмов', 'Ветроэнергетика', 'Применение пестицидов'],
conservation: 'Охрана зимовок (подземелья, форты). Мониторинг колоний. Сохранение прибрежных водоёмов.',
where_to_see: 'Гродненские подземелья, водохранилища Минской обл., Беловежская пуща',
biomass: 0.016, seasons: ['5','6','7','8','9'],
trend: [{year:2000,count_estimate:600,source:'Bat Conservation'},{year:2015,count_estimate:350,source:'БО'},{year:2024,count_estimate:220,source:'БО'}],
regions: ['minsk','grodno','brest','vitebsk'],
});
/* ── РАСТЕНИЯ (2) ───────────────────────────────────────────────────── */
const yatrish = addSp({
group: 'Растения', habitat: 'meadow',
name_ru: 'Ятрышник шлемоносный', name_be: 'Ятрышнік шлемавідны', name_lat: 'Orchis militaris',
category: 'VU', by_category: 'III',
description: 'Редкая наземная орхидея с необычными лилово-розовыми соцветиями, лепестки которых складываются в форму человечка с «руками» и «ногами». Растёт на известняковых лугах и в светлых лесах. Требует симбиоза с почвенными грибами для прорастания семян.',
fact: 'Семена ятрышника невесомы — в 1 грамме до 50 000 штук. Но без почвенного гриба-симбионта ни одно не прорастёт.',
threats: ['Распашка и застройка лугов', 'Выпас скота', 'Сбор растений'],
conservation: 'Охрана мест обитания. Запрет сбора. Создание питомников.',
where_to_see: 'Мозырские гряды, Новогрудская возвышенность, меловые склоны р. Сож',
biomass: 0.04, seasons: ['5','6'],
trend: [{year:1990,count_estimate:3200,source:'НАН'},{year:2010,count_estimate:1800,source:'НАН'},{year:2024,count_estimate:950,source:'НАН'}],
regions: ['brest','grodno','minsk','gomel'],
});
const rosynka = addSp({
group: 'Растения', habitat: 'wetland',
name_ru: 'Росянка английская', name_be: 'Расіца англійская', name_lat: 'Drosera anglica',
category: 'EN', by_category: 'II',
description: 'Насекомоядное растение сфагновых болот. Длинные листья-ловушки с липкими железистыми ворсинками ловят и переваривают мух и комаров. Получает азот и фосфор из добычи, компенсируя бедность болотной почвы. Крупнейшая из белорусских росянок.',
fact: 'Росянка английская переваривает насекомых за 24–48 часов, выделяя ферменты, аналогичные желудочному соку животных.',
threats: ['Осушение верховых болот', 'Торфоразработка', 'Изменение гидрологии'],
conservation: 'Сохранение верховых сфагновых болот. Контроль торфоразработок.',
where_to_see: 'Ельня, Обстерно, Мох Великий (Витебская обл.)',
biomass: 0.008, seasons: ['6','7','8'],
trend: [{year:1990,count_estimate:18000,source:'НАН'},{year:2010,count_estimate:9000,source:'НАН'},{year:2024,count_estimate:5200,source:'НАН'}],
regions: ['vitebsk','grodno','minsk'],
});
/* ── НАСЕКОМЫЕ (4) ──────────────────────────────────────────────────── */
const zhuk_olen = addSp({
group: 'Насекомые', habitat: 'forest',
name_ru: 'Жук-олень', name_be: 'Жук-алень', name_lat: 'Lucanus cervus',
category: 'EN', by_category: 'II',
description: 'Крупнейший жук Европы: самцы достигают 8 см. «Рога» — гипертрофированные верхние челюсти-мандибулы. Используются в турнирах за самку. Личинка живёт 5–8 лет в гнилой древесине дуба, питаясь разложившейся древесиной. Взрослый жук живёт всего 3–4 недели.',
fact: 'Самец жука-оленя в рыцарском поединке захватывает соперника рогами и сбрасывает с ветки — как настоящий турнир средневековых рыцарей.',
threats: ['Вырубка старых дубрав', 'Удаление пней и гниющей древесины', 'Уличное освещение (дезориентирует)'],
conservation: 'Сохранение старых дубрав и пней. Создание «мёртвой древесины» в парках.',
where_to_see: 'Беловежская пуща, Налибокская пуща, Гродненская пуща',
biomass: 0.006, seasons: ['6','7','8'],
trend: [{year:1990,count_estimate:12000,source:'НАН'},{year:2010,count_estimate:5500,source:'НАН'},{year:2024,count_estimate:2800,source:'НАН'}],
regions: ['brest','grodno','minsk'],
});
const dozorshik = addSp({
group: 'Насекомые', habitat: 'wetland',
name_ru: 'Дозорщик-повелитель', name_be: 'Дазорца-ўладар', name_lat: 'Anax imperator',
category: 'VU', by_category: 'III',
description: 'Крупнейшая стрекоза Беларуси с размахом крыльев до 10 см. Самцы патрулируют водоёмы, агрессивно охраняя территорию. Охотится на других стрекоз, бабочек и даже небольших рыб. Личинка — активный хищник, живущий в воде 2–3 года.',
fact: 'Дозорщик-повелитель преследует и ловит добычу с точностью до 97% — лучший результат среди всех хищников Земли.',
threats: ['Эвтрофикация водоёмов', 'Осушение болот', 'Применение пестицидов'],
conservation: 'Охрана водно-болотных угодий. Ограничение применения инсектицидов.',
where_to_see: 'Нарочанский НП, Споровское болото, пруды Брестской обл.',
biomass: 0.0009, seasons: ['6','7','8','9'],
trend: [{year:2000,count_estimate:5000,source:'ЭО'},{year:2015,count_estimate:2800,source:'ЭО'},{year:2024,count_estimate:1600,source:'ЭО'}],
regions: ['brest','gomel','grodno','minsk'],
});
const shmel = addSp({
group: 'Насекомые', habitat: 'meadow',
name_ru: 'Шмель моховой', name_be: 'Шмель імшысты', name_lat: 'Bombus muscorum',
category: 'EN', by_category: 'II',
description: 'Рыжевато-жёлтый шмель сырых лугов и болот. Один из немногих видов шмелей, гнездящихся на поверхности — в старых птичьих гнёздах или кустах мха. Важнейший опылитель растений заболоченных угодий. Страдает от потери местообитаний сильнее других шмелей.',
fact: 'Шмель моховой — «горячая машина»: перед полётом в холодный день он вибрирует мышцами, разогревая тело до +35°C при температуре воздуха около 0°C.',
threats: ['Интенсификация сельского хозяйства', 'Осушение лугов', 'Применение пестицидов', 'Болезни (Nosema)'],
conservation: 'Сохранение разнотравных сырых лугов. Запрет инсектицидов на природных территориях.',
where_to_see: 'Пойменные луга Полесья, заказник «Ельня», Витебские озёра',
biomass: 0.0004, seasons: ['4','5','6','7','8','9'],
trend: [{year:1990,count_estimate:50000,source:'ЭО'},{year:2010,count_estimate:18000,source:'ЭО'},{year:2024,count_estimate:7000,source:'ЭО'}],
regions: ['vitebsk','minsk','grodno','brest'],
});
const krasotEl = addSp({
group: 'Насекомые', habitat: 'forest',
name_ru: 'Красотел пахучий', name_be: 'Прыгажун духмяны', name_lat: 'Calosoma sycophanta',
category: 'VU', by_category: 'III',
description: 'Охотник за гусеницами: жук поднимается на деревья в погоне за добычей и может съесть до 400 гусениц за сезон. Окраска переливается всеми цветами радуги — синим, зелёным, золотистым. При угрозе выбрызгивает едкую жидкость с резким запахом.',
fact: 'Красотел пахучий — сознательный лесник: он специально охотится на видах-вредителях (шелкопряд, непарный шелкопряд), защищая лес от объедания.',
threats: ['Применение инсектицидов', 'Вырубка широколиственных лесов', 'Сбор коллекционерами'],
conservation: 'Отказ от химических методов борьбы с вредителями. Охрана широколиственных лесов.',
where_to_see: 'Беловежская пуща, Налибокская пуща, широколиственные леса Гродненщины',
biomass: 0.003, seasons: ['5','6','7','8'],
trend: [{year:1990,count_estimate:8000,source:'НАН'},{year:2010,count_estimate:3500,source:'НАН'},{year:2024,count_estimate:1800,source:'НАН'}],
regions: ['brest','grodno','minsk','vitebsk'],
});
/* ── РЫБЫ (3) ───────────────────────────────────────────────────────── */
const forel = addSp({
group: 'Рыбы', habitat: 'river',
name_ru: 'Форель ручьевая', name_be: 'Стронга ручайная', name_lat: 'Salmo trutta fario',
category: 'VU', by_category: 'III',
description: 'Пёстрая хищная рыба холодных прозрачных ручьёв и малых рек. Индикатор чистоты воды: погибает при малейшем загрязнении. Требует хорошую аэрацию и чистый гравийный субстрат для нереста. В Беларуси — на южной границе ареала, обитает только в реках Нарочанского бассейна.',
fact: 'Форель настолько требовательна к кислороду, что может жить только там, где вода кристально чистая. Там, где есть форель — вода питьевого качества.',
threats: ['Загрязнение рек', 'Нагрев воды', 'Браконьерство', 'Мелиорация'],
conservation: 'Охрана малых рек. Контроль качества воды. Ограничение рыбалки. Искусственное разведение.',
where_to_see: 'Реки Нарочь, Страча, Сервечь (Минская обл.), р. Щара (Гродненская обл.)',
biomass: 0.35, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:4200,source:'ВТ'},{year:2005,count_estimate:2100,source:'ВТ'},{year:2024,count_estimate:900,source:'ВТ'}],
regions: ['vitebsk','grodno','minsk'],
});
const bystryanka = addSp({
group: 'Рыбы', habitat: 'river',
name_ru: 'Быстрянка обыкновенная', name_be: 'Быстранка звычайная', name_lat: 'Alburnoides bipunctatus',
category: 'EN', by_category: 'II',
description: 'Маленькая серебристая рыбка быстрых чистых рек с выраженным течением. Держится у поверхности стайками, питаясь насекомыми. Чувствительна к загрязнению воды. Один из исчезающих видов пресноводных рыб Беларуси, встречается единично в реках западной части страны.',
fact: 'Быстрянка — «наземная» рыба: до 80% её рациона составляют упавшие в воду насекомые — комары, мухи, падёнки.',
threats: ['Загрязнение рек', 'Зарегулирование стока', 'Конкуренция с инвазивными видами'],
conservation: 'Охрана малых рек с быстрым течением. Мониторинг популяции.',
where_to_see: 'Реки Зельвянка, Россь, Нёман (Гродненская обл.)',
biomass: 0.018, seasons: ['4','5','6','7','8','9','10'],
trend: [{year:1990,count_estimate:15000,source:'НАН'},{year:2010,count_estimate:5000,source:'НАН'},{year:2024,count_estimate:1200,source:'НАН'}],
regions: ['grodno','brest','minsk'],
});
const umbra = addSp({
group: 'Рыбы', habitat: 'wetland',
name_ru: 'Умбра европейская', name_be: 'Умбра еўрапейская', name_lat: 'Umbra krameri',
category: 'CR', by_category: 'I',
description: 'Реликтовая рыбка — живой свидетель ледникового периода. Обитает только в медленно текущих водотоках и болотных канавах Полесья. Может переносить высыхание, зарываясь в ил. Дышит атмосферным воздухом при нехватке кислорода. Крупнейшая популяция в Европе — в Припятском полесье.',
fact: 'Умбра — одна из немногих рыб, которая пережила все ледниковые периоды, прячась в рефугиях болот. Ей более 5 миллионов лет.',
threats: ['Осушение болот', 'Углубление канав', 'Загрязнение воды', 'Вселение хищных рыб'],
conservation: 'Охрана болотных водотоков Полесья. Мониторинг популяции. Разведение в неволе.',
where_to_see: 'Припятский НП, болота Столинского района (Брестская обл.), пойма Ствиги',
biomass: 0.025, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:3500,source:'НАН'},{year:2005,count_estimate:1200,source:'НАН'},{year:2024,count_estimate:300,source:'IUCN'}],
regions: ['brest','gomel'],
});
/* ── ГРИБЫ (2) ──────────────────────────────────────────────────────── */
const felhodon = addSp({
group: 'Грибы', habitat: 'conifer',
name_ru: 'Феллодон слитый', name_be: 'Фелодон злiты', name_lat: 'Phellodon confluens',
category: 'EN', by_category: 'II',
description: 'Редкий коралловидный гриб хвойных и смешанных лесов. Несколько плодовых тел часто сливаются в одну неправильную форму, откуда и название. Серовато-белый, с тонкими иглами на нижней поверхности шляпки. Индикатор старых ненарушенных лесов. Занесён в Красные книги 12 стран.',
fact: 'Феллодон слитый растёт только в лесах, которым не менее 150 лет — он служит биологическим маркером древних экосистем.',
threats: ['Рубки главного пользования', 'Нарушение лесной подстилки', 'Рекреационная нагрузка'],
conservation: 'Сохранение старовозрастных лесов. Организация микозаповедников.',
where_to_see: 'Беловежская пуща, Налибокская пуща, хвойные леса Витебской обл.',
biomass: 0.06, seasons: ['8','9','10'],
trend: [{year:1990,count_estimate:200,source:'НАН'},{year:2010,count_estimate:85,source:'НАН'},{year:2024,count_estimate:40,source:'НАН'}],
regions: ['vitebsk','minsk','grodno','brest'],
});
const gidnellum = addSp({
group: 'Грибы', habitat: 'forest',
name_ru: 'Гиднеллум синеющий', name_be: 'Гiднелум сiнеючы', name_lat: 'Hydnellum caeruleum',
category: 'VU', by_category: 'III',
description: 'Необычный ежовый гриб с сине-фиолетовым или голубовато-серым молодым плодовым телом, темнеющим до тёмно-коричневого. Растёт в мшистых хвойных лесах, образуя микоризу с соснами и елями. Очень горький на вкус, несъедобен. Исчезает при нарушении лесной экосистемы.',
fact: 'Гиднеллум синеющий настолько редок, что находка даже одного плодового тела считается значимым событием для микологов всей Европы.',
threats: ['Лесозаготовки', 'Удаление мохового покрова', 'Загрязнение атмосферного воздуха'],
conservation: 'Выявление и охрана местонахождений. Сохранение мшистых ельников.',
where_to_see: 'Налибокская пуща, Борисовско-Березинский резерват',
biomass: 0.04, seasons: ['8','9','10'],
trend: [{year:2000,count_estimate:60,source:'НАН'},{year:2015,count_estimate:28,source:'НАН'},{year:2024,count_estimate:15,source:'НАН'}],
regions: ['vitebsk','minsk','grodno'],
});
/* ── МХИ И ЛИШАЙНИКИ (4) ────────────────────────────────────────────── */
const buksbaumiya = addSp({
group: 'Мхи и лишайники', habitat: 'conifer',
name_ru: 'Буксбаумия безлистная', name_be: 'Буксбаумiя бязлістая', name_lat: 'Buxbaumia aphylla',
category: 'EN', by_category: 'II',
description: 'Один из самых необычных мхов: почти без листьев, с единственным крупным непропорциональным спорангием на тонкой ножке. Споровая коробочка напоминает панцирь черепахи. Растёт на гниющих пнях хвойных деревьев. Исчезает вместе с исчезновением гниющей древесины в заготовительных лесах.',
fact: 'Буксбаумия безлистная проводит почти всю жизнь как невидимая нить микоризы — листовая часть появляется лишь на несколько недель для спороношения.',
threats: ['Уборка гнилой древесины', 'Интенсивные рубки', 'Нарушение лесной подстилки'],
conservation: 'Сохранение пней и гниющей древесины в лесах. Организация «мёртвого дерева» в заказниках.',
where_to_see: 'Ельники Витебской и Минской обл., Беловежская пуща',
biomass: 0.001, seasons: ['9','10','11','3','4'],
trend: [{year:1990,count_estimate:800,source:'НАН'},{year:2010,count_estimate:320,source:'НАН'},{year:2024,count_estimate:150,source:'НАН'}],
regions: ['vitebsk','minsk','grodno','brest'],
});
const meesiya = addSp({
group: 'Мхи и лишайники', habitat: 'wetland',
name_ru: 'Меезия трёхгранная', name_be: 'Меезiя трохгранная', name_lat: 'Meesia triquetra',
category: 'VU', by_category: 'III',
description: 'Болотный мох с характерными трёхгранными листьями, образующий плотные дернины в основаниях сфагновых кочек. Вид-индикатор ненарушенных верховых болот, чувствителен к изменению гидрологического режима. В Беларуси находится на южной границе ареала и встречается крайне редко.',
fact: 'Меезия трёхгранная может фиксировать свидетельства климата за последние тысячи лет — учёные читают историю болота по слоям этого мха как по книге.',
threats: ['Осушение болот', 'Добыча торфа', 'Изменение уровня грунтовых вод'],
conservation: 'Охрана верховых болот. Поддержание высокого уровня воды в болотных комплексах.',
where_to_see: 'Ельня (Витебская обл.), Мох Великий, Освейское болото',
biomass: 0.002, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:500,source:'НАН'},{year:2010,count_estimate:200,source:'НАН'},{year:2024,count_estimate:90,source:'НАН'}],
regions: ['vitebsk','minsk'],
});
const usnea = addSp({
group: 'Мхи и лишайники', habitat: 'conifer',
name_ru: 'Уснея длиннейшая', name_be: 'Уснея найдаўжэйшая', name_lat: 'Usnea longissima',
category: 'CR', by_category: 'I',
description: 'Бородатый лишайник, свисающий с ветвей хвойных деревьев нитями длиной до 3 метров. Требует исключительно чистого воздуха и высокой влажности. Был широко распространён в старых лесах Беларуси, но исчез почти повсеместно из-за загрязнения воздуха и вырубок. Сегодня — в единичных местонахождениях.',
fact: 'Уснея длиннейшая растёт со скоростью 1–2 мм в год, а её трёхметровые нити означают столетие жизни. Найти её — значит найти вековой нетронутый лес.',
threats: ['Загрязнение воздуха', 'Вырубка старых хвойных лесов', 'Изменение климата'],
conservation: 'Строгая охрана мест произрастания. Мониторинг качества воздуха. Охрана старолесий.',
where_to_see: 'Налибокская пуща, отдельные участки Витебской обл.',
biomass: 0.012, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:120,source:'НАН'},{year:2005,count_estimate:35,source:'НАН'},{year:2024,count_estimate:8,source:'НАН'}],
regions: ['vitebsk','grodno'],
});
const peltigera = addSp({
group: 'Мхи и лишайники', habitat: 'forest',
name_ru: 'Пельтигера горизонтальная', name_be: 'Пельцiгера гарызантальная', name_lat: 'Peltigera horizontalis',
category: 'VU', by_category: 'III',
description: 'Крупный листоватый лишайник с горизонтально распластанным слоевищем, вырастающим до 20 см в диаметре. Серо-голубой сверху, с заметными нитями гиф и цианобактерий на нижней поверхности. Растёт на замшелых стволах и основаниях деревьев в старых широколиственных лесах. Фиксирует атмосферный азот.',
fact: 'Пельтигера — симбиоз гриба, водорослей и цианобактерий в одном организме. Она единственная из лишайников способна удобрять почву, фиксируя воздушный азот.',
threats: ['Вырубка широколиственных лесов', 'Рекреационное вытаптывание', 'Загрязнение воздуха'],
conservation: 'Сохранение старых широколиственных лесов. Ограничение рекреационной нагрузки.',
where_to_see: 'Беловежская пуща, Налибокская пуща, старые широколиственные леса Гродненщины',
biomass: 0.006, seasons: ['1','2','3','4','5','6','7','8','9','10','11','12'],
trend: [{year:1990,count_estimate:350,source:'НАН'},{year:2010,count_estimate:140,source:'НАН'},{year:2024,count_estimate:70,source:'НАН'}],
regions: ['vitebsk','minsk','grodno','brest'],
});
db.exec('COMMIT');
console.log('✓ Виды добавлены');
console.log(` Всего видов: ${db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n}`);
/* ════════════════════════════════════════════════════════════════════════
БЛОК 2 — ПИЩЕВЫЕ СВЯЗИ
════════════════════════════════════════════════════════════════════════ */
db.exec('BEGIN');
const links = [
// Орлан охотится на лебедей (более активно)
[getId('Орлан-белохвост'), getId('Лебедь-шипун'), 0.25],
[getId('Орлан-белохвост'), getId('Лебедь-кликун'), 0.20],
// Кобчик ест насекомых
[kobchik, getId('Аполлон'), 0.50],
[kobchik, getId('Богомол обыкновенный'), 0.30],
[kobchik, shmel, 0.50],
[kobchik, dozorshik, 0.40],
// Вертлявая камышевка ест насекомых болот
[kamyshevka, shmel, 0.20],
[kamyshevka, dozorshik, 0.15],
// Красотел ест бабочек/жуков
[krasotEl, getId('Аполлон'), 0.60],
[krasotEl, getId('Махаон'), 0.50],
[krasotEl, zhuk_olen, 0.30],
// Форель ест быстрянку и другую рыбу
[forel, bystryanka, 0.80],
[forel, getId('Хариус европейский'), 0.40],
[forel, getId('Ручьевая минога'), 0.30],
// Умбра — мелкий хищник
[umbra, bystryanka, 0.30],
// Крупные рыбы едят форель
[getId('Сом обыкновенный'), forel, 0.40],
[getId('Выдра речная'), forel, 0.60],
[getId('Выдра речная'), bystryanka, 0.30],
// Норка и выдра охотятся на выхухоль
[getId('Европейская норка'), vyhuhol, 0.60],
[getId('Выдра речная'), vyhuhol, 0.20],
// Ночница ест насекомых над водой
[nochnitza, shmel, 0.30],
[nochnitza, dozorshik, 0.40],
// Жук-олень поедает соки деревьев (связь через растения если есть)
// Дупель и камышевка — косвенные связи через экосистему
].filter(([p, q]) => p && q);
links.forEach(([p, q, s]) => {
try { insWeb.run(p, q, s); } catch {}
});
db.exec('COMMIT');
console.log(` Пищевых связей добавлено: ${links.length}`);
console.log(` Всего пищевых связей: ${db.prepare('SELECT COUNT(*) as n FROM rb_food_web').get().n}`);
/* ════════════════════════════════════════════════════════════════════════
БЛОК 3 — КВЕСТЫ (10 новых)
════════════════════════════════════════════════════════════════════════ */
db.exec('BEGIN');
function addQuest(title, description, speciesNames, xp, badge) {
const existing = db.prepare('SELECT id FROM rb_quests WHERE title = ?').get(title);
if (existing) { console.log(` Квест уже существует: ${title}`); return; }
const ids = speciesNames.map(n => getId(n)).filter(Boolean);
if (ids.length < speciesNames.length) {
console.warn(` Квест "${title}": не найдены виды!`, speciesNames.filter(n => !getId(n)));
}
db.prepare('INSERT INTO rb_quests (title, description, species_ids, xp_reward, badge_slug) VALUES (?,?,?,?,?)').run(
title, description, JSON.stringify(ids), xp, badge
);
}
addQuest(
'Птицы болот',
'Болота Беларуси — дом для редчайших птиц Европы. Найдите серого журавля, большого подорлика, большого кроншнепа, вертлявую камышевку и дупеля. Узнайте, почему без болот эти виды исчезнут с планеты.',
['Серый журавль', 'Большой подорлик', 'Большой кроншнеп', 'Вертлявая камышевка', 'Дупель'],
220, 'quest_bog_birds'
);
addQuest(
'Ночные хищники',
'Пока мы спим, лес живёт своей жизнью. Исследуйте тайных обитателей ночи: воробьиного и домового сычей, широкоушку и прудовую ночницу. Узнайте, как эхолокация превращает темноту в охотничьи угодья.',
['Воробьиный сыч', 'Сыч домовый', 'Широкоушка европейская', 'Ночница прудовая'],
180, 'quest_night_hunters'
);
addQuest(
'Диковинки Витебщины',
'Витебская область — северный форпост Беларуси со своими уникальными видами. Откройте краснозобую казарку, лобелию Дортмана, вертлявую камышевку, форель ручьевую и уснею длиннейшую — виды, которых больше нигде не встретить.',
['Краснозобая казарка', 'Лобелия Дортмана', 'Вертлявая камышевка', 'Форель ручьевая', 'Уснея длиннейшая'],
230, 'quest_vitebsk'
);
addQuest(
'Царство грибов',
'Мир грибов гораздо больше того, что мы видим. Трюфель, решёточник, спарасис, ёж гриб, феллодон и гиднеллум — это не еда, это живая история леса. Найдите всех шестерых и узнайте, почему грибы — хранители экосистемы.',
['Трюфель летний', 'Решёточник красный', 'Спарасис курчавый (грибная капуста)', 'Ёж гриб (герций)', 'Феллодон слитый', 'Гиднеллум синеющий'],
300, 'quest_fungi'
);
addQuest(
'Мир мхов и лишайников',
'Мхи и лишайники — первооткрыватели суши, они появились раньше динозавров. Найдите лобарию, кладонию, буксбаумию, меезию, уснею и пельтигеру — шесть хранителей нетронутых лесов и болот Беларуси.',
['Лобария лёгочная', 'Кладония звёздчатая', 'Буксбаумия безлистная', 'Меезия трёхгранная', 'Уснея длиннейшая', 'Пельтигера горизонтальная'],
280, 'quest_mosses'
);
addQuest(
'Легенды Полесья',
'Полесье — болотный рай, один из последних в Европе. Его населяют легендарные жители: европейская норка, болотная черепаха, альдрованда, выхухоль русская и умбра европейская — реликты, пережившие ледниковые периоды.',
['Европейская норка', 'Черепаха болотная', 'Альдрованда пузырчатая', 'Выхухоль русская', 'Умбра европейская'],
350, 'quest_polesye'
);
addQuest(
'Хранители леса',
'Лес держится на хищниках. Рысь, бурый медведь, жук-олень, усач альпийский и красотел пахучий — звенья одной цепи. Когда исчезает один хищник, лес начинает умирать. Узнайте, как они связаны между собой.',
['Рысь евразийская', 'Бурый медведь', 'Жук-олень', 'Усач альпийский', 'Красотел пахучий'],
250, 'quest_forest_guard'
);
addQuest(
'Тайны реки',
'Белорусские реки скрывают живых ископаемых. Выдра, стерлядь, форель, быстрянка и умбра европейская — каждый вид-индикатор показывает, здорова ли река. Найдите их всех и прочтите историю наших вод.',
['Речная выдра', 'Стерлядь', 'Форель ручьевая', 'Быстрянка обыкновенная', 'Умбра европейская'],
220, 'quest_river2'
);
addQuest(
'Летающие над водой',
'Вода с небесами — место встречи орлана-белохвоста, скопы, двух видов лебедей и дозорщика-повелителя. Все они связаны с водой и исчезнут вместе с ней. Откройте пятёрку хозяев белорусских озёр.',
['Орлан-белохвост', 'Скопа', 'Лебедь-шипун', 'Лебедь-кликун', 'Дозорщик-повелитель'],
200, 'quest_water_fliers'
);
addQuest(
'Краса болот',
'Болотные растения — хищники, ловушки и редкие орхидеи. Пальчатокоренник, шейхцерия, альдрованда, вертлявая камышевка и росянка английская живут в мире, где каждая капля воды на счету. Исследуйте болото изнутри.',
['Пальчатокоренник мясокрасный', 'Шейхцерия болотная', 'Альдрованда пузырчатая', 'Вертлявая камышевка', 'Росянка английская'],
230, 'quest_bog_beauty'
);
db.exec('COMMIT');
console.log('✓ Квесты добавлены');
console.log(` Всего квестов: ${db.prepare('SELECT COUNT(*) as n FROM rb_quests').get().n}`);
console.log('\n✅ seed-red-book-phase2.js завершён!');
console.log(` Видов: ${db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n}`);
console.log(` Квестов: ${db.prepare('SELECT COUNT(*) as n FROM rb_quests').get().n}`);
console.log(` Пищевых связей: ${db.prepare('SELECT COUNT(*) as n FROM rb_food_web').get().n}`);
+807
View File
@@ -0,0 +1,807 @@
/**
* Seed: Красная книга Республики Беларусь
* 80 видов, 8 групп, пищевая сеть, популяционные тренды
* Запуск: node src/db/seed-red-book.js (из папки backend/)
*/
require('./migrate');
const db = require('./db');
/* ══════════════════════════════════════════════════════════════════════════
GUARD — пропустить если уже засеяно
══════════════════════════════════════════════════════════════════════════ */
const already = db.prepare("SELECT COUNT(*) as n FROM rb_species").get();
if (already.n > 0) {
console.log(`Красная книга: уже засеяно ${already.n} видов, пропускаем.`);
process.exit(0);
}
/* ══════════════════════════════════════════════════════════════════════════
ГРУППЫ
══════════════════════════════════════════════════════════════════════════ */
const insGroup = db.prepare('INSERT INTO rb_groups (name_ru, name_lat, icon, color) VALUES (?,?,?,?)');
const groups = {};
[
['Птицы', 'Aves', '🦅', '#0369a1'],
['Млекопитающие', 'Mammalia', '🦌', '#92400e'],
['Растения', 'Plantae', '🌿', '#166534'],
['Насекомые', 'Insecta', '🦋', '#b45309'],
['Рыбы', 'Pisces', '🐟', '#0e7490'],
['Рептилии и амфибии','Reptilia/Amphibia','🦎','#4d7c0f'],
['Грибы', 'Fungi', '🍄', '#7c3aed'],
['Мхи и лишайники', 'Bryophyta', '🌱', '#065f46'],
].forEach(([name_ru, name_lat, icon, color]) => {
const id = insGroup.run(name_ru, name_lat, icon, color).lastInsertRowid;
groups[name_ru] = id;
});
console.log('✓ Группы');
/* ══════════════════════════════════════════════════════════════════════════
БИОМЫ
══════════════════════════════════════════════════════════════════════════ */
const insHabitat = db.prepare('INSERT INTO rb_habitats (name, type, description, sound_file) VALUES (?,?,?,?)');
const habitats = {};
[
['Широколиственный лес', 'forest', 'Дубравы и грабово-дубовые леса Полесья и центральной Беларуси', 'forest.mp3'],
['Хвойный лес', 'conifer', 'Сосновые и еловые боры, тайга северной Беларуси', 'conifer.mp3'],
['Болото', 'wetland', 'Верховые и низинные болота, крупнейшие в Европе', 'wetland.mp3'],
['Река и озеро', 'river', 'Пресноводные водоёмы: Припять, Нёман, Западная Двина', 'river.mp3'],
['Луг и поле', 'meadow', 'Заливные и суходольные луга, агроландшафты', 'meadow.mp3'],
].forEach(([name, type, description, sound_file]) => {
const id = insHabitat.run(name, type, description, sound_file).lastInsertRowid;
habitats[name] = id;
});
console.log('✓ Биомы');
/* ══════════════════════════════════════════════════════════════════════════
ВИДЫ
══════════════════════════════════════════════════════════════════════════ */
const insSp = db.prepare(`
INSERT INTO rb_species
(group_id, habitat_id, name_ru, name_be, name_lat, category, by_category,
description, interesting_fact, threats, conservation, where_to_see,
photo_url, model_type, population_trend, biomass_kg)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
const insReg = db.prepare('INSERT OR IGNORE INTO rb_species_regions (species_id, region_code) VALUES (?,?)');
const insPop = db.prepare('INSERT INTO rb_population_data (species_id, year, count_estimate, source) VALUES (?,?,?,?)');
function sp(data) {
const id = insSp.run(
groups[data.group], habitats[data.habitat] || null,
data.name_ru, data.name_be || '', data.name_lat || '',
data.category, data.by_category || 'III',
data.description || '', data.fact || '',
JSON.stringify(data.threats || []),
data.conservation || '', data.where_to_see || '',
data.photo || '', data.model || 'silhouette',
JSON.stringify(data.trend || []),
data.biomass || 0
).lastInsertRowid;
(data.regions || []).forEach(r => insReg.run(id, r));
(data.popdata || []).forEach(([year, count, src]) => insPop.run(id, year, count, src || 'КК РБ'));
return id;
}
const ids = {};
db.exec('BEGIN');
try { // species block
/* ── ПТИЦЫ ─────────────────────────────────────────────────────────────── */
ids.orlan = sp({
group: 'Птицы', habitat: 'Река и озеро',
name_ru: 'Орлан-белохвост', name_be: 'Арлан-белахвост', name_lat: 'Haliaeetus albicilla',
category: 'NT', by_category: 'III',
description: 'Крупнейший хищник среди белорусских орлиных. Размах крыльев достигает 240 см. Гнездится у крупных водоёмов, охотится на рыбу и уток. Пара занимает одно гнездо десятилетиями, достраивая его каждый год — масса гнезда может превышать тонну.',
fact: 'Орлан развивает скорость пикирования до 100 км/ч. Гнездо одной пары в Беловежской пуще весило 600 кг.',
threats: ['Беспокойство у гнёзд', 'Отравление свинцом из охотничьей дроби', 'Вырубка леса у водоёмов'],
conservation: 'Охраняется в заповедниках. Программа мониторинга гнёзд. Запрет охоты.',
where_to_see: 'НП «Припятский», НП «Браславские озёра», Беловежская пуща',
model: 'procedural', biomass: 5.5,
regions: ['brest','gomel','vitebsk','grodno','minsk','mogilev'],
popdata: [[1990,150,'КК РБ 1993'],[2000,200,'КК РБ 2004'],[2010,280,'КК РБ 2015'],[2024,350,'Мониторинг 2024']],
trend: [{year:1990,count:150},{year:2000,count:200},{year:2010,count:280},{year:2024,count:350}],
});
ids.chorny_aist = sp({
group: 'Птицы', habitat: 'Широколиственный лес',
name_ru: 'Чёрный аист', name_be: 'Чорны бусел', name_lat: 'Ciconia nigra',
category: 'NT', by_category: 'III',
description: 'В отличие от белого аиста, чёрный предпочитает глухие леса с ручьями и реками. Оперение с зелёным и пурпурным металлическим блеском. Зимует в Африке южнее Сахары. Чрезвычайно осторожен — при беспокойстве бросает гнездо.',
fact: 'Чёрный аист преодолевает до 10 000 км во время миграции. В Беларуси гнездится около 900 пар.',
threats: ['Вырубка старовозрастных лесов', 'Осушение болот', 'Беспокойство у гнёзд'],
conservation: 'Охраняемые зоны вокруг гнёзд радиусом 200 м. Сохранение старовозрастных деревьев.',
where_to_see: 'Беловежская пуща, НП «Припятский», ПЗ «Налибокский»',
model: 'procedural', biomass: 3.0,
regions: ['brest','gomel','grodno','minsk','vitebsk'],
popdata: [[1990,600,'КК РБ 1993'],[2005,800,'КК РБ 2004'],[2015,850,'КК РБ 2015'],[2024,920,'Мониторинг 2024']],
trend: [{year:1990,count:600},{year:2005,count:800},{year:2015,count:850},{year:2024,count:920}],
});
ids.skopa = sp({
group: 'Птицы', habitat: 'Река и озеро',
name_ru: 'Скопа', name_be: 'Скапа', name_lat: 'Pandion haliaetus',
category: 'VU', by_category: 'II',
description: 'Уникальный хищник, питающийся исключительно рыбой. Ныряет в воду на глубину до 1 м. Пальцы покрыты шипами для удержания скользкой добычи. Строит гнёзда на вершинах сухих деревьев или опорах ЛЭП.',
fact: 'Скопа — единственная хищная птица мира, охотящаяся только на рыбу и обитающая на всех континентах кроме Антарктиды.',
threats: ['Деградация рыбных ресурсов', 'Беспокойство у гнёзд', 'Гибель на ЛЭП'],
conservation: 'Установка искусственных гнездовых платформ. Охранные зоны у гнёзд.',
where_to_see: 'Браславские озёра, НП «Нарочанский», озёра Витебской области',
model: 'procedural', biomass: 1.8,
regions: ['vitebsk','minsk','grodno','brest'],
popdata: [[1990,50,'КК РБ 1993'],[2005,70,'КК РБ 2004'],[2015,90,'КК РБ 2015'],[2024,110,'Мониторинг 2024']],
trend: [{year:1990,count:50},{year:2005,count:70},{year:2015,count:90},{year:2024,count:110}],
});
ids.dergach = sp({
group: 'Птицы', habitat: 'Луг и поле',
name_ru: 'Коростель', name_be: 'Дзяргач', name_lat: 'Crex crex',
category: 'VU', by_category: 'III',
description: 'Скрытная птица влажных лугов. Слышать его легко — самец кричит «дёрг-дёрг» всю ночь, но увидеть почти невозможно. Прилетает поздно весной из Африки. Численность резко падает из-за раннего сенокоса.',
fact: 'Коростель бежит быстрее, чем летит. Его крик слышен за 1,5 км в тихую ночь.',
threats: ['Ранний механизированный сенокос', 'Осушение лугов', 'Хищники'],
conservation: 'Рекомендации по срокам сенокоса (после 1 августа). Сохранение пойменных лугов.',
where_to_see: 'Пойма Припяти, Нарочанский НП, луга Брестской области',
biomass: 0.15,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
popdata: [[1990,5000,'КК РБ 1993'],[2005,3500,'КК РБ 2004'],[2015,2800,'КК РБ 2015'],[2024,2200,'Мониторинг 2024']],
trend: [{year:1990,count:5000},{year:2005,count:3500},{year:2015,count:2800},{year:2024,count:2200}],
});
ids.zhuravl = sp({
group: 'Птицы', habitat: 'Болото',
name_ru: 'Серый журавль', name_be: 'Шэры жураўль', name_lat: 'Grus grus',
category: 'NT', by_category: 'IV',
description: 'Символ белорусских болот. Пары создаются на всю жизнь. Весной и осенью собирается на полях в стаи до нескольких тысяч птиц. Токование — один из самых красивых ритуалов среди птиц: прыжки, взмахи крыльями, трубный крик.',
fact: 'Журавль живёт до 25 лет в дикой природе. Беларусь — один из главных «журавлиных» регионов Европы с населением ~20 000 пар.',
threats: ['Осушение болот', 'Беспокойство в период гнездования'],
conservation: 'Сохранение болотных массивов. Охраняемые территории на токовищах.',
where_to_see: 'НП «Припятский», Ельня, Освейское болото',
model: 'procedural', biomass: 5.0,
regions: ['vitebsk','minsk','gomel','brest','grodno','mogilev'],
trend: [{year:1990,count:15000},{year:2010,count:18000},{year:2024,count:20000}],
});
ids.zmeeyed = sp({
group: 'Птицы', habitat: 'Широколиственный лес',
name_ru: 'Змееяд', name_be: 'Змеяед', name_lat: 'Circaetus gallicus',
category: 'EN', by_category: 'II',
description: 'Специализированный охотник на рептилий. Питается почти исключительно змеями, в том числе гадюками. Иммунитет к яду позволяет заглатывать змею живой. Редчайший в Беларуси — гнездятся единицы пар.',
fact: 'Змееяд способен заглотить гадюку длиной 1 м. В Беларуси гнездится не более 15–20 пар.',
threats: ['Вырубка старых лесов', 'Снижение численности рептилий', 'Беспокойство'],
conservation: 'Охрана известных гнёзд. Мониторинг численности.',
where_to_see: 'ПЗ «Полесский», Беловежская пуща',
biomass: 1.9,
regions: ['brest','gomel'],
popdata: [[2000,12,'КК РБ 2004'],[2015,15,'КК РБ 2015'],[2024,18,'Мониторинг 2024']],
trend: [{year:2000,count:12},{year:2015,count:15},{year:2024,count:18}],
});
ids.vorobiny_sych = sp({
group: 'Птицы', habitat: 'Хвойный лес',
name_ru: 'Воробьиный сыч', name_be: 'Верабіны сычык', name_lat: 'Glaucidium passerinum',
category: 'VU', by_category: 'III',
description: 'Самая маленькая сова Европы — размером с дрозда. Активна как днём, так и ночью. Делает запасы пищи в дуплах на зиму. Голос — монотонный свист, слышный далеко в лесу.',
fact: 'Несмотря на размер с воробья, воробьиный сыч охотится на добычу крупнее себя — полёвок и мелких птиц.',
threats: ['Вырубка старых ельников', 'Исчезновение дупел'],
conservation: 'Сохранение старовозрастных ельников. Вывешивание искусственных дупел.',
where_to_see: 'Налибокская пуща, Беловежская пуща, Ельнянский заказник',
biomass: 0.08,
regions: ['grodno','vitebsk','minsk'],
});
ids.krasnozobaya_kazarka = sp({
group: 'Птицы', habitat: 'Река и озеро',
name_ru: 'Краснозобая казарка', name_be: 'Чырванашыйная казарка', name_lat: 'Branta ruficollis',
category: 'EN', by_category: 'II',
description: 'Одна из самых красивых арктических гусей. В Беларуси встречается только во время миграций — зимует на Чёрном море. Численность катастрофически сокращается из-за охоты на зимовках.',
fact: 'Краснозобая казарка гнездится исключительно в тундре Западной Сибири. Беларусь — транзитный коридор миграции.',
threats: ['Охота на зимовках', 'Изменение климата', 'Беспокойство на миграционных стоянках'],
conservation: 'Запрет охоты. Охрана мест остановок на миграции.',
where_to_see: 'Полесские водохранилища, озёра Витебщины (пролёт)',
biomass: 1.5,
regions: ['gomel','brest','vitebsk'],
});
ids.filin = sp({
group: 'Птицы', habitat: 'Хвойный лес',
name_ru: 'Филин', name_be: 'Пугач', name_lat: 'Bubo bubo',
category: 'VU', by_category: 'II',
description: 'Крупнейшая сова планеты. Ночной охотник, способный атаковать добычу весом до 3 кг. Охотничий участок пары — до 80 км². Низкое токование самца слышно за 4 км в тихую ночь.',
fact: 'Филин — единственная птица, которая регулярно охотится на других хищников: ловит ястребов и даже молодых орланов.',
threats: ['Фактор беспокойства', 'Гибель на ЛЭП', 'Оскудение кормовой базы'],
conservation: 'Охрана гнёзд. Изоляция ЛЭП. Мониторинг популяции.',
where_to_see: 'Беловежская пуща, ПЗ «Налибокский», НП «Припятский»',
model: 'procedural', biomass: 2.7,
regions: ['brest','grodno','gomel','minsk'],
trend: [{year:1990,count:200},{year:2010,count:250},{year:2024,count:280}],
});
ids.zhuravl_seryi2 = sp({
group: 'Птицы', habitat: 'Болото',
name_ru: 'Большой подорлик', name_be: 'Вялікі арол-крычун', name_lat: 'Clanga clanga',
category: 'EN', by_category: 'II',
description: 'Один из самых редких орлов Европы. Гнездится в старых лиственных лесах рядом с болотами и поймами. Питается амфибиями, мышевидными грызунами. Особенно уязвим из-за низкой плодовитости — одно яйцо в кладке.',
fact: 'В мире гнездится менее 10 000 пар большого подорлика. Беларусь — один из ключевых регионов гнездования в Европе.',
threats: ['Осушение болот и пойм', 'Вырубка пойменных лесов', 'Беспокойство'],
conservation: 'Охрана гнёзд. Сохранение болотных массивов.',
where_to_see: 'НП «Припятский», Полесский радиационно-экологический заповедник',
biomass: 2.0,
regions: ['gomel','brest','minsk'],
popdata: [[2000,80,'КК РБ 2004'],[2015,90,'КК РБ 2015'],[2024,100,'Мониторинг 2024']],
trend: [{year:2000,count:80},{year:2015,count:90},{year:2024,count:100}],
});
/* ── МЛЕКОПИТАЮЩИЕ ──────────────────────────────────────────────────────── */
ids.zubr = sp({
group: 'Млекопитающие', habitat: 'Широколиственный лес',
name_ru: 'Зубр европейский', name_be: 'Зубр', name_lat: 'Bison bonasus',
category: 'VU', by_category: 'III',
description: 'Крупнейшее наземное млекопитающее Европы. Масса самца достигает 920 кг при высоте в холке 188 см. Вид был полностью истреблён в дикой природе к 1927 году — всё нынешнее поголовье (около 7000 особей в мире) происходит от 12 зубров из зоопарков. Беларусь сыграла ключевую роль в восстановлении вида.',
fact: 'Зубр может прыгнуть в высоту до 2 м. Беловежская пуща — колыбель восстановления этого вида.',
threats: ['Инбридинг из-за малой генетической базы', 'Болезни', 'Браконьерство'],
conservation: 'Беловежская пуща — ключевой центр разведения. Программы реинтродукции в Европе.',
where_to_see: 'Беловежская пуща (основная популяция), Налибокская пуща',
model: 'procedural', biomass: 650,
regions: ['brest','grodno','minsk','vitebsk'],
popdata: [[1927,0,'Летопись'],[1960,150,'КК РБ'],[1990,850,'КК РБ 1993'],[2010,1500,'КК РБ 2015'],[2024,2000,'Мониторинг 2024']],
trend: [{year:1927,count:0},{year:1960,count:150},{year:1990,count:850},{year:2010,count:1500},{year:2024,count:2000}],
});
ids.rys = sp({
group: 'Млекопитающие', habitat: 'Хвойный лес',
name_ru: 'Рысь евразийская', name_be: 'Рысь', name_lat: 'Lynx lynx',
category: 'NT', by_category: 'III',
description: 'Крупнейший кошачий хищник Европы. Ведёт одиночный ночной образ жизни. Охотится главным образом на косуль и зайцев, реже на других оленей. Огромные лапы работают как снегоступы — рысь не проваливается в снег глубиной до 50 см.',
fact: 'Рысь может прыгнуть на расстояние 7 м. Она единственная крупная кошка, живущая севернее 60° с.ш.',
threats: ['Браконьерство', 'Фрагментация лесных массивов', 'Снижение кормовой базы'],
conservation: 'Полный запрет охоты. Охрана крупных лесных массивов. GPS-мониторинг.',
where_to_see: 'Налибокская пуща, Беловежская пуща, Освейский заказник',
model: 'procedural', biomass: 22,
regions: ['grodno','minsk','vitebsk','brest'],
popdata: [[1990,350,'КК РБ 1993'],[2005,500,'КК РБ 2004'],[2015,650,'КК РБ 2015'],[2024,700,'Мониторинг 2024']],
trend: [{year:1990,count:350},{year:2005,count:500},{year:2015,count:650},{year:2024,count:700}],
});
ids.medved = sp({
group: 'Млекопитающие', habitat: 'Хвойный лес',
name_ru: 'Бурый медведь', name_be: 'Буры мядзведзь', name_lat: 'Ursus arctos',
category: 'NT', by_category: 'III',
description: 'Крупнейший хищник фауны Беларуси. Всеяден — рацион на 80% состоит из растительной пищи. Залегает в берлогу с ноября по март. В Беларуси обитает на северо-востоке, сохранились лишь периферийные популяции.',
fact: 'Медведь чует запах еды с расстояния 20 км. Самка рожает медвежат в берлоге прямо в январе, весящих всего 500 г.',
threats: ['Браконьерство', 'Фрагментация лесов', 'Деградация кормовой базы'],
conservation: 'Запрет охоты. Охрана берлог. Мониторинг численности.',
where_to_see: 'Полоцкий район Витебской области, Освейский заказник',
model: 'procedural', biomass: 180,
regions: ['vitebsk','mogilev'],
popdata: [[1990,80,'КК РБ 1993'],[2005,70,'КК РБ 2004'],[2015,65,'КК РБ 2015'],[2024,60,'Мониторинг 2024']],
trend: [{year:1990,count:80},{year:2005,count:70},{year:2015,count:65},{year:2024,count:60}],
});
ids.norka = sp({
group: 'Млекопитающие', habitat: 'Река и озеро',
name_ru: 'Европейская норка', name_be: 'Еўрапейская норка', name_lat: 'Mustela lutreola',
category: 'CR', by_category: 'I',
description: 'Один из самых редких хищников Европы. Конкурентно вытеснена завезённой американской норкой. Держится у небольших рек с захламлёнными берегами. Отличается от американской норкой белым пятном на верхней губе.',
fact: 'Европейская норка занесена в Красную книгу МСОП как находящаяся под критической угрозой исчезновения. В Беларуси обитает менее 500 особей.',
threats: ['Конкуренция с американской норкой', 'Охота/отлов', 'Деградация береговой растительности'],
conservation: 'Программа разведения в неволе. Отлов и стерилизация американской норки. Реинтродукция.',
where_to_see: 'Полесье, бассейн Немана',
biomass: 0.6,
regions: ['brest','gomel','grodno'],
popdata: [[1990,2000,'КК РБ 1993'],[2005,800,'КК РБ 2004'],[2015,400,'КК РБ 2015'],[2024,300,'Мониторинг 2024']],
trend: [{year:1990,count:2000},{year:2005,count:800},{year:2015,count:400},{year:2024,count:300}],
});
ids.vydra = sp({
group: 'Млекопитающие', habitat: 'Река и озеро',
name_ru: 'Речная выдра', name_be: 'Выдра', name_lat: 'Lutra lutra',
category: 'NT', by_category: 'III',
description: 'Полуводный хищник семейства куньих. Мастер плавания — способна задерживать дыхание на 4 минуты. Поедает рыбу, раков, лягушек. Индикатор чистоты водоёмов: там, где живёт выдра, вода чистая.',
fact: 'Выдра скользит по снегу и льду на брюхе, развивая скорость до 25 км/ч. Каждый день ей нужно съесть 15% своего веса.',
threats: ['Загрязнение водоёмов', 'Браконьерство', 'Вылов в рыболовные снасти'],
conservation: 'Охрана водоохранных зон. Запрет охоты. Очистка водоёмов.',
where_to_see: 'Реки по всей Беларуси, НП «Припятский»',
biomass: 9,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
trend: [{year:1990,count:2500},{year:2010,count:3000},{year:2024,count:3200}],
});
ids.bober = sp({
group: 'Млекопитающие', habitat: 'Река и озеро',
name_ru: 'Речной бобёр', name_be: 'Рачны бабёр', name_lat: 'Castor fiber',
category: 'LC', by_category: 'IV',
description: 'Крупнейший грызун Беларуси. Инженер экосистем: строит плотины, создаёт водоёмы, меняет ландшафт. Был истреблён к XIX веку и успешно реинтродуцирован. Сейчас численность в Беларуси — одна из крупнейших в Европе.',
fact: 'Бобёр — природный мелиоратор: его плотины повышают уровень грунтовых вод, создают новые биотопы для других видов.',
threats: ['Охота', 'Уничтожение прибрежной растительности'],
conservation: 'Регулирование охоты. Охрана пойменных лесов.',
where_to_see: 'Повсеместно у рек и каналов Беларуси',
biomass: 25,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
trend: [{year:1960,count:1000},{year:1990,count:20000},{year:2010,count:50000},{year:2024,count:60000}],
});
ids.zubatka = sp({
group: 'Млекопитающие', habitat: 'Широколиственный лес',
name_ru: 'Соня лесная', name_be: 'Лясная соня', name_lat: 'Dryomys nitedula',
category: 'VU', by_category: 'III',
description: 'Небольшой ночной грызун смешанных и лиственных лесов. Спит 7–8 месяцев в году — дольше всех млекопитающих Беларуси. Гнездо строит в дуплах или развилках ветвей на высоте 2–5 м.',
fact: 'Перед зимней спячкой соня увеличивает массу тела вдвое, накапливая жир. Температура тела во сне падает до 1°C.',
threats: ['Вырубка лиственных лесов', 'Снижение урожая орехов и желудей'],
conservation: 'Охрана старолесий. Вывешивание дуплянок.',
where_to_see: 'Беловежская пуща, Налибокская пуща',
biomass: 0.03,
regions: ['brest','grodno','minsk'],
});
ids.kosulya = sp({
group: 'Млекопитающие', habitat: 'Широколиственный лес',
name_ru: 'Косуля европейская', name_be: 'Еўрапейская казуля', name_lat: 'Capreolus capreolus',
category: 'LC', by_category: 'IV',
description: 'Самый распространённый олень Беларуси. Важнейший элемент пищевой цепи — основная добыча волка, рыси и лисы. Рожки самец сбрасывает ежегодно в ноябре. Летом держится одиночно, зимой образует небольшие группы.',
fact: 'Косуля — чемпион среди оленей по скорости: развивает до 60 км/ч. Рогатый самец защищает территорию площадью до 60 га.',
threats: ['Интенсивная охота', 'Браконьерство', 'Хищники'],
conservation: 'Регулирование охотничьей нагрузки. Мониторинг численности.',
where_to_see: 'Лесные массивы по всей Беларуси',
model: 'procedural', biomass: 25,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
trend: [{year:1990,count:80000},{year:2010,count:100000},{year:2024,count:110000}],
});
ids.volk = sp({
group: 'Млекопитающие', habitat: 'Хвойный лес',
name_ru: 'Волк', name_be: 'Воўк', name_lat: 'Canis lupus',
category: 'LC', by_category: 'IV',
description: 'Крупнейший хищник семейства псовых. Живёт стаями 5–12 особей с чёткой иерархией. Регулирует численность копытных, оздоровляя их популяции. В Беларуси отношение к волку противоречивое — он и символ дикой природы, и угроза для скота.',
fact: 'Волк за сутки способен пробежать до 70 км. Воя стаи достаточно, чтобы пометить территорию площадью в сотни км².',
threats: ['Истребление как вредителя', 'Фрагментация ареала'],
conservation: 'Мониторинг. Компенсации за ущерб скоту.',
where_to_see: 'Леса по всей Беларуси',
model: 'procedural', biomass: 40,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
trend: [{year:1990,count:1800},{year:2005,count:1500},{year:2015,count:1700},{year:2024,count:2000}],
});
/* ── РАСТЕНИЯ ────────────────────────────────────────────────────────────── */
ids.venerina = sp({
group: 'Растения', habitat: 'Широколиственный лес',
name_ru: 'Венерин башмачок настоящий', name_be: 'Венерын чаравічак', name_lat: 'Cypripedium calceolus',
category: 'EN', by_category: 'II',
description: 'Красивейшая дикая орхидея Беларуси. Цветок напоминает туфельку. Растёт в тенистых лиственных лесах. Живёт до 100 лет, но впервые цветёт только в 15–17 лет. Опыляется только мелкими пчёлами рода Andrena.',
fact: 'Венерин башмачок — самая долгоживущая травянистая орхидея. Одно растение в Германии было документально прослежено 100 лет на одном месте.',
threats: ['Незаконный сбор', 'Вырубка лесов', 'Уплотнение почвы', 'Зарастание кустарником'],
conservation: 'Охрана местонахождений. Культивирование в ботанических садах. Запрет сбора.',
where_to_see: 'Беловежская пуща (ур. Новый Двор), Налибокская пуща',
biomass: 0.05,
regions: ['brest','grodno','minsk','vitebsk'],
popdata: [[1990,5000,'КК РБ 1993'],[2005,3000,'КК РБ 2004'],[2015,2000,'КК РБ 2015'],[2024,1800,'Мониторинг 2024']],
trend: [{year:1990,count:5000},{year:2005,count:3000},{year:2015,count:2000},{year:2024,count:1800}],
});
ids.vodyanoy_oreh = sp({
group: 'Растения', habitat: 'Река и озеро',
name_ru: 'Водяной орех плавающий', name_be: 'Плывучы вадзяны арэх', name_lat: 'Trapa natans',
category: 'CR', by_category: 'I',
description: 'Реликт третичной флоры, живший на Земле ещё 100 миллионов лет назад. Плавающие листья образуют красивую розетку. Плоды съедобны, богаты крахмалом. Сохранился лишь в нескольких озёрах Полесья.',
fact: 'Водяной орех пережил динозавров. Его плоды с четырьмя «рогами» служили пищей людям ещё в каменном веке.',
threats: ['Загрязнение и эвтрофикация озёр', 'Колебания уровня воды', 'Конкуренция с другими водными растениями'],
conservation: 'Охрана озёр. Ограничение хозяйственной деятельности. Культивирование.',
where_to_see: 'Оз. Червоное (Брестская обл.), оз. Белое (Полесье)',
biomass: 0.02,
regions: ['brest','gomel'],
popdata: [[1990,15000,'КК РБ 1993'],[2005,8000,'КК РБ 2004'],[2015,4000,'КК РБ 2015'],[2024,2500,'Мониторинг 2024']],
trend: [{year:1990,count:15000},{year:2005,count:8000},{year:2015,count:4000},{year:2024,count:2500}],
});
ids.palchatokorenik = sp({
group: 'Растения', habitat: 'Болото',
name_ru: 'Пальчатокоренник мясокрасный', name_be: 'Мяснякраснаты пальчатакарэнік', name_lat: 'Dactylorhiza incarnata',
category: 'VU', by_category: 'III',
description: 'Болотная орхидея с плотным колосом розово-лиловых цветков. Растёт на низинных болотах и заболоченных лугах. Микоризный гриб необходим для прорастания семян — без него орхидея не может развиться.',
fact: 'Семена орхидей настолько малы (0,002 мм), что видны только под микроскопом. В одной коробочке содержится до 10 000 семян.',
threats: ['Осушение болот', 'Зарастание кустарником', 'Выпас скота'],
conservation: 'Охрана болотных массивов. Запрет мелиорации.',
where_to_see: 'Полесье, Налибокская пуща, Осиповичский район',
biomass: 0.03,
regions: ['brest','gomel','grodno','minsk'],
});
ids.molochai = sp({
group: 'Растения', habitat: 'Луг и поле',
name_ru: 'Прострел луговой', name_be: 'Сонца-трава', name_lat: 'Pulsatilla pratensis',
category: 'VU', by_category: 'III',
description: 'Один из первых весенних цветков. Тёмно-фиолетовые бокаловидные цветки появляются ещё при снеге. Покрыт шелковистыми волосками — защита от холода. Сильно сократился из-за сбора на букеты и распашки лугов.',
fact: 'Прострел — символ весны в Беларуси. Второе название «сон-трава» — легенда гласит, что заснувший рядом с ним видит вещие сны.',
threats: ['Сбор букетов', 'Распашка луговин', 'Сенокос до плодоношения'],
conservation: 'Запрет сбора. Охрана луговых заказников.',
where_to_see: 'Сосновые боры, остепнённые луга по всей Беларуси',
biomass: 0.01,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
});
ids.lobelia = sp({
group: 'Растения', habitat: 'Река и озеро',
name_ru: 'Лобелия Дортмана', name_be: 'Лобелія Дортмана', name_lat: 'Lobelia dortmanna',
category: 'EN', by_category: 'II',
description: 'Водное растение олиготрофных озёр. Растёт на песчаном дне на глубине 0,5–3 м. Цветонос возвышается над водой. Сверхчувствителен к загрязнению — индикатор кристально чистой воды.',
fact: 'Лобелия Дортмана поглощает CO₂ через корни из донных отложений, а не из воды — уникальный для растений способ.',
threats: ['Эвтрофикация озёр', 'Загрязнение воды', 'Рекреационная нагрузка'],
conservation: 'Охрана чистых озёр. Ограничение рекреации.',
where_to_see: 'Браславские озёра, озёра Нарочанской группы',
biomass: 0.01,
regions: ['vitebsk','minsk','grodno'],
});
ids.soldanella = sp({
group: 'Растения', habitat: 'Широколиственный лес',
name_ru: 'Лунник оживающий', name_be: 'Лунарыя ажываючая', name_lat: 'Lunaria rediviva',
category: 'VU', by_category: 'III',
description: 'Многолетнее растение тенистых влажных лесов. Серебристые эллиптические стручки используют для зимних букетов. Редчайшее в Беларуси — известно лишь несколько популяций.',
fact: 'Название «лунник» происходит от формы плодов — круглых, как луна. После высыхания они становятся прозрачными и серебристыми.',
threats: ['Сбор для флористики', 'Вырубка тенистых лесов'],
conservation: 'Запрет сбора. Охрана местонахождений.',
where_to_see: 'Налибокская пуща, Беловежская пуща',
biomass: 0.1,
regions: ['grodno','minsk','brest'],
});
ids.matik = sp({
group: 'Растения', habitat: 'Болото',
name_ru: 'Шейхцерия болотная', name_be: 'Балотная шэйхцэрыя', name_lat: 'Scheuchzeria palustris',
category: 'VU', by_category: 'III',
description: 'Реликтовое травянистое растение верховых болот. Растёт в сфагновых зарослях у мочажин. Индикатор ненарушенных болотных экосистем. При осушении болот исчезает первым.',
fact: 'Шейхцерия существует более 10 000 лет — с конца ледникового периода — практически не изменившись.',
threats: ['Осушение болот', 'Торфоразработки'],
conservation: 'Охрана верховых болот. Запрет торфоразработок.',
where_to_see: 'Ельнянский заказник, Освейское болото',
biomass: 0.01,
regions: ['vitebsk','minsk','gomel'],
});
/* ── НАСЕКОМЫЕ ────────────────────────────────────────────────────────────── */
ids.makhаon = sp({
group: 'Насекомые', habitat: 'Луг и поле',
name_ru: 'Махаон', name_be: 'Махаон', name_lat: 'Papilio machaon',
category: 'NT', by_category: 'IV',
description: 'Одна из крупнейших и красивейших бабочек Беларуси. Размах крыльев до 95 мм. Жёлтые крылья с чёрным рисунком и синей каймой. Гусеница поедает листья зонтичных растений. Стал редок из-за распашки лугов с дикими зонтичными.',
fact: 'Хвостики-отростки на задних крыльях отвлекают хищников — птицы атакуют «ненастоящую голову».',
threats: ['Распашка лугов с зонтичными растениями', 'Применение пестицидов', 'Сбор коллекционерами'],
conservation: 'Сохранение лугов с дикими зонтичными. Запрет отлова.',
where_to_see: 'Луга и опушки по всей Беларуси, Полесье',
model: 'procedural', biomass: 0.002,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
trend: [{year:1990,count:100000},{year:2010,count:50000},{year:2024,count:30000}],
});
ids.podolik = sp({
group: 'Насекомые', habitat: 'Широколиственный лес',
name_ru: 'Жук-олень', name_be: 'Жук-алень', name_lat: 'Lucanus cervus',
category: 'VU', by_category: 'II',
description: 'Крупнейший жук Беларуси. Самцы достигают 9 см. «Рога» — увеличенные жвала, используемые в турнирных боях. Личинка развивается в мёртвой древесине дуба 5–8 лет. Исчезает вместе со старыми дубравами.',
fact: 'Жук-олень умеет летать, несмотря на тяжёлую «броню». Взрослый жук не ест — живёт всего 1–2 месяца за счёт жировых запасов.',
threats: ['Вырубка старых дубрав', 'Уборка мёртвой древесины', 'Декоративный сбор'],
conservation: 'Сохранение старовозрастных дубрав. Оставление валежника.',
where_to_see: 'Беловежская пуща, Налибокская пуща',
model: 'procedural', biomass: 0.008,
regions: ['brest','grodno','minsk'],
});
ids.apollon = sp({
group: 'Насекомые', habitat: 'Луг и поле',
name_ru: 'Аполлон', name_be: 'Апалон', name_lat: 'Parnassius apollo',
category: 'EN', by_category: 'I',
description: 'Крупная дневная бабочка с полупрозрачными белыми крыльями и красными глазками. Считается одним из красивейших насекомых Европы. В Беларуси сохранились единичные изолированные популяции. Гусеница питается очитком.',
fact: 'Аполлон — первая бабочка, включённая в Конвенцию CITES (запрет международной торговли). Его коллекционируют вопреки всем запретам.',
threats: ['Зарастание каменистых склонов кустарником', 'Коллекционирование', 'Применение пестицидов'],
conservation: 'Строгая охрана популяций. Борьба с зарастанием склонов.',
where_to_see: 'Окрестности Гродно (единственное местонахождение в РБ)',
biomass: 0.003,
regions: ['grodno'],
popdata: [[2000,500,'КК РБ 2004'],[2015,300,'КК РБ 2015'],[2024,200,'Мониторинг 2024']],
trend: [{year:2000,count:500},{year:2015,count:300},{year:2024,count:200}],
});
ids.bogomol = sp({
group: 'Насекомые', habitat: 'Луг и поле',
name_ru: 'Богомол обыкновенный', name_be: 'Звычайны багамол', name_lat: 'Mantis religiosa',
category: 'EN', by_category: 'II',
description: 'Самый известный хищный кузнечик. Передние ноги — ловчий аппарат, реагирующий быстрее, чем успевает моргнуть человек. Самка после спаривания поедает самца. В Беларуси — на северной границе ареала.',
fact: 'Богомол — единственное насекомое, способное поворачивать голову на 180°. Реакция захвата добычи занимает 0,05 секунды.',
threats: ['Применение пестицидов', 'Распашка сухих лугов', 'Изменение климата'],
conservation: 'Охрана сухих травянистых местообитаний.',
where_to_see: 'Южная Беларусь — Брестская, Гомельская обл.',
biomass: 0.004,
regions: ['brest','gomel'],
});
ids.stag_beetle2 = sp({
group: 'Насекомые', habitat: 'Широколиственный лес',
name_ru: 'Усач мускусный', name_be: 'Мускусны вусач', name_lat: 'Aromia moschata',
category: 'VU', by_category: 'III',
description: 'Ярко-зелёный жук с металлическим блеском. Издаёт сильный запах мускуса, слышный за несколько метров. Личинки развиваются в старых ивах. Встречается вдоль рек с ивняком.',
fact: 'Мускусный усач — один из немногих жуков, использующих химическую сигнализацию для общения с партнёрами.',
threats: ['Обрезка и уборка старых ив', 'Осушение пойм'],
conservation: 'Сохранение старых ив вдоль рек.',
where_to_see: 'Поймы рек Полесья, долина Немана',
biomass: 0.004,
regions: ['brest','gomel','grodno','minsk'],
});
/* ── РЫБЫ ─────────────────────────────────────────────────────────────────── */
ids.sterlyadj = sp({
group: 'Рыбы', habitat: 'Река и озеро',
name_ru: 'Стерлядь', name_be: 'Асётр-стэрлядзь', name_lat: 'Acipenser ruthenus',
category: 'EN', by_category: 'II',
description: 'Самый мелкий осетр. Реликт доледникового периода — осетры появились 250 миллионов лет назад. В Беларуси исчезала в XX веке и восстанавливается искусственным зарыблением. Ценнейшая промысловая рыба.',
fact: 'Осетры — живые ископаемые. Их форма тела не менялась 200 миллионов лет. Стерлядь доживает до 30 лет.',
threats: ['Браконьерство', 'Строительство плотин', 'Загрязнение рек', 'Изъятие из русел гравия'],
conservation: 'Искусственное воспроизводство. Рыборазводные заводы. Запрет вылова.',
where_to_see: 'Р. Днепр, р. Припять, р. Сож (редко)',
biomass: 0.8,
regions: ['gomel','mogilev','brest'],
popdata: [[1990,100,'КК РБ 1993'],[2010,500,'после зарыбления'],[2024,1500,'Мониторинг 2024']],
trend: [{year:1990,count:100},{year:2010,count:500},{year:2024,count:1500}],
});
ids.minog = sp({
group: 'Рыбы', habitat: 'Река и озеро',
name_ru: 'Ручьевая минога', name_be: 'Ручаёвая мінога', name_lat: 'Lampetra planeri',
category: 'VU', by_category: 'III',
description: 'Древнейший позвоночный — миноги появились 360 миллионов лет назад, ещё до динозавров. Не имеет челюстей. Личинка (амоцет) живёт в иле 3–7 лет с закрытыми глазами. Взрослая минога не питается и гибнет после нереста.',
fact: 'Ручьевая минога — «живое ископаемое», практически не изменившееся за 360 миллионов лет. Её называют самым примитивным позвоночным.',
threats: ['Загрязнение малых рек', 'Заиление нерестилищ', 'Мелиорация'],
conservation: 'Охрана малых рек. Ограничение мелиорации.',
where_to_see: 'Чистые малые реки Витебской, Гродненской областей',
biomass: 0.02,
regions: ['vitebsk','grodno','minsk','mogilev'],
});
ids.usatch = sp({
group: 'Рыбы', habitat: 'Река и озеро',
name_ru: 'Усач обыкновенный', name_be: 'Марэна', name_lat: 'Barbus barbus',
category: 'VU', by_category: 'III',
description: 'Крупная реофильная рыба быстрых рек. Достигает 90 см. Ищет пищу на дне с помощью чувствительных усиков. Нерестится на каменистых перекатах — именно их исчезновение главная угроза.',
fact: 'Икра усача ядовита для теплокровных — содержит ихтиотоксин. Местные рыбаки знали об этом и никогда её не ели.',
threats: ['Добыча гравия', 'Строительство плотин', 'Загрязнение'],
conservation: 'Сохранение каменистых перекатов. Запрет добычи гравия.',
where_to_see: 'Р. Западный Буг, р. Нёман',
biomass: 1.5,
regions: ['brest','grodno'],
});
ids.rylets = sp({
group: 'Рыбы', habitat: 'Река и озеро',
name_ru: 'Подуст обыкновенный', name_be: 'Падуст', name_lat: 'Chondrostoma nasus',
category: 'EN', by_category: 'II',
description: 'Стайная рыба горных и предгорных рек. В Беларуси — на восточной границе ареала. Питается водорослями и органикой, соскребая их нижнечелюстным роговым чехликом со дна.',
fact: 'Подуст чистит дно реки, поедая водоросли — выполняет роль «пылесоса» экосистемы.',
threats: ['Загрязнение', 'Уничтожение нерестовых субстратов', 'Строительство плотин'],
conservation: 'Охрана нерестилищ. Запрет строительства.',
where_to_see: 'Р. Западный Буг (редко)',
biomass: 0.3,
regions: ['brest'],
});
/* ── РЕПТИЛИИ И АМФИБИИ ──────────────────────────────────────────────────── */
ids.cherepaha = sp({
group: 'Рептилии и амфибии', habitat: 'Болото',
name_ru: 'Черепаха болотная', name_be: 'Балотная чарапаха', name_lat: 'Emys orbicularis',
category: 'VU', by_category: 'II',
description: 'Единственная дикая черепаха Беларуси. Греется на берегах заболоченных водоёмов. При опасности мгновенно ныряет. Живёт до 100 лет. Откладывает яйца в прогретую почву. Самая северная черепаха Европы.',
fact: 'Черепаха болотная определяет пол потомства температурой гнезда: при +28°C рождаются самки, при +26°C — самцы.',
threats: ['Осушение болот', 'Уничтожение берегов', 'Гибель на дорогах'],
conservation: 'Охрана водно-болотных угодий. Туннели под дорогами.',
where_to_see: 'Полесье (Брестская, Гомельская обл.)',
model: 'procedural', biomass: 0.5,
regions: ['brest','gomel','grodno'],
popdata: [[1990,10000,'КК РБ 1993'],[2005,7000,'КК РБ 2004'],[2015,5000,'КК РБ 2015'],[2024,4000,'Мониторинг 2024']],
trend: [{year:1990,count:10000},{year:2005,count:7000},{year:2015,count:5000},{year:2024,count:4000}],
});
ids.medyanka = sp({
group: 'Рептилии и амфибии', habitat: 'Широколиственный лес',
name_ru: 'Медянка', name_be: 'Медзянка', name_lat: 'Coronella austriaca',
category: 'VU', by_category: 'III',
description: 'Небольшая безвредная змея. Часто принимается за гадюку и уничтожается. Специализируется на ящерицах — конкурирует с гадюкой за пищу, но не ядовита. Гладкая чешуя с медным блеском.',
fact: 'Медянка сжимает добычу как удав — потому что ящерицы не боятся яда. Укус человека абсолютно безвреден.',
threats: ['Уничтожение из-за путаницы с гадюкой', 'Уничтожение сухих опушек'],
conservation: 'Просветительская работа. Охрана сухих опушек и вырубок.',
where_to_see: 'Сухие сосновые леса Полесья',
biomass: 0.05,
regions: ['brest','gomel','grodno','minsk'],
});
ids.trit = sp({
group: 'Рептилии и амфибии', habitat: 'Болото',
name_ru: 'Тритон гребенчатый', name_be: 'Грэбеньчасты трытон', name_lat: 'Triturus cristatus',
category: 'NT', by_category: 'III',
description: 'Крупнейший тритон Беларуси. В брачный период самец развивает высокий зубчатый гребень от затылка до хвоста. Днём прячется в укрытиях, ночью охотится. Живёт у лесных водоёмов.',
fact: 'Гребень самца — не для плавания, а для привлечения самок. В воде он играет роль «хвоста павлина».',
threats: ['Мелиорация', 'Гибель на дорогах', 'Инвазивные рыбы в водоёмах'],
conservation: 'Охрана прудов и болот. Ограничение вселения рыбы.',
where_to_see: 'Лесные пруды по всей Беларуси',
biomass: 0.02,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
});
/* ── ГРИБЫ ────────────────────────────────────────────────────────────────── */
ids.truffe = sp({
group: 'Грибы', habitat: 'Широколиственный лес',
name_ru: 'Трюфель летний', name_be: 'Летні трувель', name_lat: 'Tuber aestivum',
category: 'EN', by_category: 'I',
description: 'Подземный гриб — деликатес с неповторимым ароматом. Растёт в симбиозе с корнями дуба и бука. Плодовое тело полностью под землёй. В Беларуси — редчайший вид, известны единичные находки.',
fact: 'Аромат трюфеля обусловлен андростенолом — феромоном, похожим на половой гормон кабана. Именно поэтому их традиционно ищут со свиньями.',
threats: ['Вырубка дубрав', 'Уплотнение почвы', 'Сбор до созревания спор'],
conservation: 'Охрана старых дубрав. Запрет сбора.',
where_to_see: 'Беловежская пуща (единичные находки)',
biomass: 0.1,
regions: ['brest'],
});
ids.mukhomor = sp({
group: 'Грибы', habitat: 'Хвойный лес',
name_ru: 'Решёточник красный', name_be: 'Чырвоная кратчатка', name_lat: 'Clathrus ruber',
category: 'CR', by_category: 'I',
description: 'Невероятный гриб — красная ажурная решётка. Вылупляется из белого яйца за несколько часов. Тухлый запах привлекает мух, распространяющих споры. Средиземноморский вид, в Беларуси единственная находка в 2019 г.',
fact: 'Решёточник — один из самых необычных грибов мира. Его рост занимает всего 4–6 часов. Живёт 1–2 дня.',
threats: ['Недостаточность тепла', 'Уничтожение при сборе'],
conservation: 'Строгая охрана единственного местонахождения.',
where_to_see: 'Гродненский район (единственная находка)',
biomass: 0.05,
regions: ['grodno'],
});
ids.sparassis = sp({
group: 'Грибы', habitat: 'Хвойный лес',
name_ru: 'Спарасис курчавый (грибная капуста)', name_be: 'Кучаравы спарасіс', name_lat: 'Sparassis crispa',
category: 'VU', by_category: 'III',
description: 'Гриб, похожий на кочан цветной капусты кремового цвета. Диаметр до 50 см, вес до 5 кг. Растёт у оснований старых сосен. Съедобен и очень вкусен. Обладает противоопухолевыми свойствами.',
fact: 'Спарасис содержит β-1,3-глюкан — вещество, доказанно усиливающее иммунитет и применяемое в японской медицине.',
threats: ['Вырубка старых сосновых лесов', 'Сбор до созревания спор'],
conservation: 'Сохранение старых сосняков. Ограничение сбора.',
where_to_see: 'Старые сосняки Беларуси',
biomass: 1.5,
regions: ['brest','gomel','grodno','minsk','vitebsk','mogilev'],
});
/* ── МХИ И ЛИШАЙНИКИ ─────────────────────────────────────────────────────── */
ids.lobaria = sp({
group: 'Мхи и лишайники', habitat: 'Широколиственный лес',
name_ru: 'Лобария лёгочная', name_be: 'Лёгачная лобарыя', name_lat: 'Lobaria pulmonaria',
category: 'VU', by_category: 'III',
description: 'Крупный лишайник — симбиоз гриба, водоросли и цианобактерии. Таллом похож на лёгочную ткань. Растёт только в старых нетронутых лесах с постоянной влажностью. Индикатор экологической ценности леса.',
fact: 'Лобария лёгочная фиксирует атмосферный азот. Раньше её применяли при болезнях лёгких — отсюда и название.',
threats: ['Вырубка старолесий', 'Загрязнение воздуха (SO₂)', 'Изменение микроклимата'],
conservation: 'Охрана старовозрастных лесов.',
where_to_see: 'Беловежская пуща, Налибокская пуща',
biomass: 0.05,
regions: ['brest','grodno'],
});
ids.kladon = sp({
group: 'Мхи и лишайники', habitat: 'Хвойный лес',
name_ru: 'Кладония звёздчатая', name_be: 'Зорчатая кладонія', name_lat: 'Cladonia stellaris',
category: 'VU', by_category: 'III',
description: 'Напочвенный кустистый лишайник, образующий белые кочки в сосновых борах. Один из компонентов оленьего мха — важный корм для оленей зимой. Растёт крайне медленно — 1–3 мм в год.',
fact: 'Ковёр из кладонии в сосняке может быть возрастом 200–300 лет. Протяжённость "колонии" до 10 м.',
threats: ['Пожары', 'Вытаптывание', 'Загрязнение воздуха'],
conservation: 'Охрана сухих сосняков. Противопожарные меры.',
where_to_see: 'Сухие боры Витебской, Минской, Гомельской областей',
biomass: 0.1,
regions: ['vitebsk','minsk','gomel','brest'],
});
db.exec('COMMIT');
console.log(`✓ Виды засеяны (${Object.keys(ids).length} видов)`);
} catch (e) {
db.exec('ROLLBACK');
console.error('Ошибка при вставке видов:', e.message);
process.exit(1);
}
/* ══════════════════════════════════════════════════════════════════════════
ПИЩЕВАЯ СЕТЬ
══════════════════════════════════════════════════════════════════════════ */
const insWeb = db.prepare('INSERT OR IGNORE INTO rb_food_web (predator_id, prey_id, strength) VALUES (?,?,?)');
db.exec('BEGIN');
const web = [
// Орлан-белохвост охотится на рыб, водоплавающих птиц
[ids.orlan, ids.sterlyadj, 0.6],
[ids.orlan, ids.usatch, 0.3],
[ids.orlan, ids.krasnozobaya_kazarka, 0.1],
// Скопа — только рыба
[ids.skopa, ids.sterlyadj, 0.7],
[ids.skopa, ids.usatch, 0.3],
[ids.skopa, ids.rylets, 0.2],
// Змееяд — рептилии
[ids.zmeeyed, ids.medyanka, 0.7],
[ids.zmeeyed, ids.cherepaha, 0.1],
// Филин
[ids.filin, ids.kosulya, 0.2],
[ids.filin, ids.bober, 0.1],
[ids.filin, ids.vydra, 0.1],
// Большой подорлик
[ids.zhuravl_seryi2, ids.trit, 0.5],
[ids.zhuravl_seryi2, ids.cherepaha, 0.3],
[ids.zhuravl_seryi2, ids.minog, 0.2],
// Рысь — косули, заяц, бобёр
[ids.rys, ids.kosulya, 0.8],
[ids.rys, ids.bober, 0.1],
[ids.rys, ids.zubatka, 0.05],
// Волк
[ids.volk, ids.kosulya, 0.7],
[ids.volk, ids.zubr, 0.1],
[ids.volk, ids.bober, 0.2],
// Выдра — рыба, лягушки
[ids.vydra, ids.sterlyadj, 0.4],
[ids.vydra, ids.minog, 0.3],
[ids.vydra, ids.usatch, 0.3],
// Норка — рыба, раки
[ids.norka, ids.rylets, 0.4],
[ids.norka, ids.minog, 0.3],
[ids.norka, ids.trit, 0.3],
// Бурый медведь — всеяден
[ids.medved, ids.bober, 0.15],
[ids.medved, ids.lobelia, 0.2],
// Журавль — насекомые, лягушки, зерно
[ids.zhuravl, ids.trit, 0.3],
[ids.zhuravl, ids.bogomol, 0.2],
// Черный аист — рыба, лягушки
[ids.chorny_aist, ids.minog, 0.4],
[ids.chorny_aist, ids.sterlyadj, 0.2],
[ids.chorny_aist, ids.trit, 0.4],
// Коростель — насекомые
[ids.dergach, ids.makhаon, 0.1],
[ids.dergach, ids.bogomol, 0.1],
// Жук-олень — мёртвая дубовая древесина
[ids.podolik, ids.lobaria, 0.1],
];
web.forEach(([p, q, s]) => insWeb.run(p, q, s));
db.exec('COMMIT');
console.log(`✓ Пищевая сеть: ${web.length} связей`);
/* ══════════════════════════════════════════════════════════════════════════
КВЕСТЫ
══════════════════════════════════════════════════════════════════════════ */
const insQ = db.prepare('INSERT INTO rb_quests (title, description, species_ids, xp_reward, badge_slug) VALUES (?,?,?,?,?)');
[
[
'Спасти зубра',
'Узнайте историю зубра — от истребления до триумфального возвращения. Пройдите 5 этапов: история вида, генетика, пищевая сеть, ареал и современная охрана.',
JSON.stringify([ids.zubr, ids.kosulya, ids.volk, ids.rys]),
200, 'quest_zubr'
],
[
'Путь чёрного аиста',
'Проследите миграцию чёрного аиста из белорусских болот в Африку. Изучите биомы, угрозы на маршруте и меры охраны.',
JSON.stringify([ids.chorny_aist, ids.zhuravl, ids.cherepaha, ids.trit]),
180, 'quest_black_stork'
],
[
'Река живёт',
'Стерлядь — символ здоровья белорусских рек. Узнайте, почему исчезают осетры и как восстановить их популяцию.',
JSON.stringify([ids.sterlyadj, ids.minog, ids.usatch, ids.vydra, ids.norka]),
160, 'quest_river'
],
[
'Последний лес',
'Беловежская пуща — последний первобытный лес Европы. Исследуйте её обитателей и поймите, почему этот лес уникален.',
JSON.stringify([ids.zubr, ids.rys, ids.filin, ids.venerina, ids.podolik, ids.truffe]),
250, 'quest_forest'
],
].forEach(args => insQ.run(...args));
console.log('✓ Квесты');
console.log('\n✅ seed-red-book.js завершён успешно!');
console.log(` Видов: ${Object.keys(ids).length}`);
console.log(' Запустите: node src/db/migrate.js && node src/db/seed-red-book.js');
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const db = require('./db');
const dataDir = path.join(__dirname, '../../data');
const files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json'));
for (const file of files) {
const data = JSON.parse(fs.readFileSync(path.join(dataDir, file), 'utf8'));
console.log(`\nSeeding: ${file} (subject: ${data.subject})`);
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(data.subject);
if (!subject) {
console.error(`Subject "${data.subject}" not found. Run migrate first.`);
continue;
}
const subject_id = subject.id;
/* topics */
const topicMap = {};
const insertTopic = db.prepare(
'INSERT OR IGNORE INTO topics (subject_id, name, order_index) VALUES (?, ?, ?)'
);
for (const t of data.topics) {
insertTopic.run(subject_id, t.name, t.order);
const row = db.prepare('SELECT id FROM topics WHERE subject_id = ? AND name = ?')
.get(subject_id, t.name);
topicMap[t.name] = row.id;
}
/* questions + options inside a transaction (idempotent — skip existing by text) */
const checkQ = db.prepare('SELECT id FROM questions WHERE subject_id = ? AND text = ?');
const insertQ = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, difficulty, explanation) VALUES (?, ?, ?, ?, ?)'
);
const insertO = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)'
);
let added = 0, skipped = 0;
db.exec('BEGIN');
try {
for (const q of data.questions) {
if (checkQ.get(subject_id, q.text)) { skipped++; continue; }
const topic_id = topicMap[q.topic] ?? null;
insertQ.run(subject_id, topic_id, q.text, q.difficulty, q.explanation ?? null);
const { id: question_id } = db.prepare('SELECT last_insert_rowid() AS id').get();
for (let i = 0; i < q.options.length; i++) {
insertO.run(question_id, q.options[i], i === q.answer ? 1 : 0, i);
}
process.stdout.write('.');
added++;
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
console.log(`\n✓ Добавлено: ${added}, пропущено (уже есть): ${skipped}`);
}
console.log('\nSeed complete.');
+154
View File
@@ -0,0 +1,154 @@
const jwt = require('jsonwebtoken');
const db = require('../db/db');
/* ── Default values for role_permissions (mirrors permissionsController) ── */
const PERM_DEFAULTS = {
teacher: {
'questions.manage': false,
'questions.delete': false,
'students.invite': false,
'sessions.reset': true,
'results.export': true,
'classes.manage': true,
'library.upload': true,
'library.folders': true,
'schedule.manage': true,
'announcements.send': true,
'templates.manage': true,
'templates.public': false,
'courses.manage': true,
'courses.interactive': true,
'shop.manage': false,
'gamification.manage': false,
},
student: {
'tests.free': true,
'board.post': true,
'profile.edit': true,
'shop.purchase': true,
'gamification.challenges': true,
'theory.access': true,
'simulations.access': true,
'simulations.quiz': true,
},
free_student: {
'tests.free': true,
'board.post': true,
'profile.edit': true,
'shop.purchase': true,
'gamification.challenges': true,
'theory.access': true,
'simulations.access': true,
'simulations.quiz': true,
},
};
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// Re-fetch role + token_version from DB so changes take effect immediately
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
if (!fresh) return res.status(401).json({ error: 'User not found' });
if (fresh.is_banned) return res.status(403).json({ error: 'Аккаунт заблокирован' });
// Invalidate tokens issued before password change / role change.
// If DB has token_version set, token MUST carry matching tv.
// (payload.tv === undefined means old token without version — also revoke)
if (fresh.token_version != null && payload.tv !== fresh.token_version) {
return res.status(401).json({ error: 'Token revoked — please log in again' });
}
req.user = { ...payload, role: fresh.role };
next();
} catch {
res.status(401).json({ error: 'Token invalid or expired' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
/* ── Check permission; user override → role override → hardcoded default ── */
function requirePermission(key) {
return (req, res, next) => {
if (req.user?.role === 'admin') return next();
const role = req.user?.role;
const uid = req.user?.id;
if (!role) return res.status(401).json({ error: 'Unauthorized' });
// 1. User-level override
const userRow = db.prepare(
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
).get(uid, key);
if (userRow !== undefined) {
return userRow.enabled === 1 ? next() : res.status(403).json({ error: 'Permission denied' });
}
// 2. Role-level
const roleRow = db.prepare(
'SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?'
).get(role, key);
const enabled = roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false);
if (enabled) return next();
return res.status(403).json({ error: 'Permission denied' });
};
}
/* ── Parent link JWT auth (separate from user auth) ───────────────────── */
function parentAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer '))
return res.status(401).json({ error: 'Unauthorized' });
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
if (payload.type !== 'parent')
return res.status(401).json({ error: 'Invalid token type' });
const link = db.prepare(
'SELECT id, student_id, is_active, expires_at FROM parent_links WHERE id = ?'
).get(payload.linkId);
if (!link || !link.is_active)
return res.status(401).json({ error: 'Link revoked' });
if (link.expires_at && new Date(link.expires_at) < new Date())
return res.status(401).json({ error: 'Link expired' });
req.parent = { linkId: link.id, studentId: link.student_id };
next();
} catch {
res.status(401).json({ error: 'Token invalid or expired' });
}
}
/* Alias: requireAuth = authMiddleware */
const requireAuth = authMiddleware;
/* optionalAuth: попытаться установить req.user, но не блокировать при отсутствии токена */
function optionalAuth(req, res, next) {
const header = req.headers.authorization || '';
if (!header.startsWith('Bearer ')) return next();
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
if (fresh && !fresh.is_banned) {
if (fresh.token_version == null || payload.tv === fresh.token_version) {
req.user = { ...payload, role: fresh.role };
}
}
} catch {}
next();
}
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, parentAuth };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
/* ── Error handling middleware ─────────────────────────────────────────────
requestId — attach X-Request-Id to every request (use early in middleware chain)
errorHandler — 4-arg Express error handler (use as last app.use)
──────────────────────────────────────────────────────────────────────── */
const crypto = require('crypto');
const logger = require('../utils/logger');
let _errorLogStmt = null;
function getErrorLogStmt() {
if (!_errorLogStmt) {
try {
const db = require('../db/db');
_errorLogStmt = db.prepare(
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
);
} catch {}
}
return _errorLogStmt;
}
/**
* Attaches a unique request ID to req.requestId and sets X-Request-Id header.
* Honour an incoming X-Request-Id from trusted proxies/gateways when present.
*/
function requestId(req, res, next) {
const id = req.headers['x-request-id'] || crypto.randomUUID();
req.requestId = id;
res.setHeader('X-Request-Id', id);
next();
}
/**
* Global error handler — must be registered AFTER all routes.
*
* Classifies errors:
* operational (4xx) — expected client errors → warn level, message returned as-is
* programmer (5xx) — unexpected bugs → error level, stack logged, message hidden in prod
*/
function errorHandler(err, req, res, _next) {
const status = err.status || err.statusCode || 500;
const isOperational = status >= 400 && status < 500;
const isProd = process.env.NODE_ENV === 'production';
const meta = {
requestId: req.requestId,
method: req.method,
path: req.path,
status,
userId: req.user?.id,
role: req.user?.role,
};
if (isOperational) {
logger.warn(err.message || 'Client error', meta);
} else {
logger.error(err.message || 'Unhandled error', {
...meta,
stack: !isProd ? err.stack : undefined,
});
// Persist to error_log table for admin dashboard
try {
const s = getErrorLogStmt();
if (s) s.run('error', (err.message || 'Unknown').slice(0, 1000), (err.stack || '').slice(0, 4000), req.path, req.method, req.user?.id || null);
} catch {}
}
const message = isProd && !isOperational
? 'Internal server error'
: (err.message || 'Server error');
if (res.headersSent) return;
res.status(status).json({ error: message, requestId: req.requestId });
}
module.exports = { requestId, errorHandler };
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const db = require('../db/db');
/**
* Factory: returns middleware that loads a resource by req.params[paramKey],
* verifies ownership against req.user.id, and attaches the row as req.resource.
*
* Simple usage (table lookup):
* requireOwnership({ table: 'tests', ownerField: 'created_by' })
*
* Complex usage (JOIN / aliased field):
* requireOwnership({
* fetchFn: id => db.prepare(`
* SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
* FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
* `).get(id),
* ownerField: 'teacher_id',
* })
*
* Options:
* table — DB table name for `SELECT * FROM {table} WHERE id = ?`
* fetchFn — (id) => row|undefined (alternative to `table`)
* ownerField — row field compared to req.user.id (required)
* paramKey — req.params key for the record ID (default: 'id')
* adminBypass — admin role always passes (default: true)
*/
const ALLOWED_TABLES = new Set(['tests','classes','assignments','questions','courses','lessons','files','folders','shop_items','live_sessions']);
function requireOwnership({ table, fetchFn, ownerField, paramKey = 'id', adminBypass = true }) {
if (table && !ALLOWED_TABLES.has(table)) throw new Error(`requireOwnership: unknown table "${table}"`);
// Pre-compile statement once at middleware creation (server startup)
const stmt = table ? db.prepare(`SELECT * FROM ${table} WHERE id = ?`) : null;
const fetch = fetchFn || (id => stmt.get(id));
return (req, res, next) => {
const row = fetch(req.params[paramKey]);
if (!row) return res.status(404).json({ error: 'Not found' });
if (adminBypass && req.user?.role === 'admin') {
req.resource = row;
return next();
}
if (row[ownerField] !== req.user?.id) {
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = row;
next();
};
}
module.exports = { requireOwnership };
+40
View File
@@ -0,0 +1,40 @@
/* Simple in-memory rate limiter — no external dependency needed */
// Clean stale entries every 5 minutes across all stores
const _allStores = new Set();
setInterval(() => {
const now = Date.now();
for (const store of _allStores) {
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key);
}
}
}, 5 * 60 * 1000).unref();
module.exports = function rateLimit({ windowMs = 60_000, max = 10, message = 'Too many requests, please try again later' } = {}) {
// Skip rate limiting in test environment
if (process.env.NODE_ENV === 'test') return (_req, _res, next) => next();
// Each rateLimit() call gets its own isolated store — counters don't bleed between limiters
const store = new Map();
_allStores.add(store);
return (req, res, next) => {
const key = req.ip || req.socket?.remoteAddress || 'unknown';
const now = Date.now();
let entry = store.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
}
entry.count++;
store.set(key, entry);
if (entry.count > max) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
res.set('Retry-After', retryAfter);
return res.status(429).json({ error: message });
}
next();
};
};
+89
View File
@@ -0,0 +1,89 @@
/* ── Lightweight input validation middleware (no external deps) ─────────── */
/**
* validate(schema) — Express middleware factory.
*
* Schema format:
* { body: { field: { type, required, min, max, minLen, maxLen, match, oneOf, custom } },
* params: { ... },
* query: { ... } }
*
* Supported rule keys:
* type — 'string' | 'number' | 'boolean' | 'object' | 'array'
* required — true/false (default false)
* min / max — for numbers
* minLen / maxLen — for strings
* match — RegExp
* oneOf — array of allowed values
* custom — fn(value) => string|null (return error string or null)
*/
function validate(schema) {
return (req, res, next) => {
const errors = [];
for (const source of ['body', 'params', 'query']) {
const rules = schema[source];
if (!rules) continue;
const data = req[source] || {};
for (const [field, rule] of Object.entries(rules)) {
const val = data[field];
const label = `${source}.${field}`;
// Required check
if (rule.required && (val === undefined || val === null || val === '')) {
errors.push(`${label} обязателен`);
continue;
}
// Skip optional absent fields
if (val === undefined || val === null) continue;
// Type check
if (rule.type) {
const t = rule.type;
if (t === 'array' && !Array.isArray(val)) {
errors.push(`${label} должен быть массивом`);
continue;
}
if (t !== 'array' && typeof val !== t) {
errors.push(`${label} должен быть типа ${t}`);
continue;
}
}
// Number constraints
if (typeof val === 'number') {
if (rule.min !== undefined && val < rule.min) errors.push(`${label} минимум ${rule.min}`);
if (rule.max !== undefined && val > rule.max) errors.push(`${label} максимум ${rule.max}`);
if (rule.integer && !Number.isInteger(val)) errors.push(`${label} должен быть целым числом`);
}
// String constraints
if (typeof val === 'string') {
if (rule.minLen !== undefined && val.length < rule.minLen) errors.push(`${label} минимум ${rule.minLen} символов`);
if (rule.maxLen !== undefined && val.length > rule.maxLen) errors.push(`${label} максимум ${rule.maxLen} символов`);
if (rule.match && !rule.match.test(val)) errors.push(`${label} неверный формат`);
}
// Enum
if (rule.oneOf && !rule.oneOf.includes(val)) {
errors.push(`${label} должен быть одним из: ${rule.oneOf.join(', ')}`);
}
// Custom validator
if (rule.custom) {
const err = rule.custom(val);
if (err) errors.push(`${label}: ${err}`);
}
}
}
if (errors.length) {
return res.status(400).json({ error: errors.join('; ') });
}
next();
};
}
module.exports = validate;
+48
View File
@@ -0,0 +1,48 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/adminController');
router.use(authMiddleware);
/* Features — teachers may read (need to know what's enabled for their classes) */
router.get('/features', requireRole('admin', 'teacher'), ctrl.getFeatures);
router.patch('/features', requireRole('admin'), ctrl.updateFeatures);
router.get('/free-student-features', requireRole('admin', 'teacher'), ctrl.getFreeStudentFeatures);
router.patch('/free-student-features', requireRole('admin'), ctrl.updateFreeStudentFeatures);
/* Everything below is admin-only */
router.use(requireRole('admin'));
router.get('/stats', ctrl.getStats);
router.get('/users', ctrl.getUsers);
router.patch('/users/:id/role', ctrl.updateRole);
router.get('/users/:id/sessions', ctrl.getUserSessions);
router.delete('/users/:id/sessions', ctrl.clearUserSessions);
router.post('/users/:id/sessions/clear', ctrl.clearUserSessions);
router.patch('/users/:id', ctrl.updateUser);
router.patch('/users/:id/ban', ctrl.banUser);
router.delete('/users/:id', ctrl.deleteUser);
router.get('/sessions', ctrl.getAllSessions);
router.get('/sessions/:id', ctrl.getSessionDetail);
/* Audit log */
router.get('/audit-log', ctrl.getAuditLog);
router.delete('/audit-log', ctrl.clearAuditLog);
/* Error log */
router.get('/error-log', ctrl.getErrorLog);
router.delete('/error-log', ctrl.clearErrorLog);
/* System health */
router.get('/health', ctrl.getHealth);
/* Topics CRUD */
router.get('/topics', ctrl.getTopics);
router.post('/topics', ctrl.createTopic);
router.patch('/topics/:id', ctrl.updateTopic);
router.delete('/topics/:id', ctrl.deleteTopic);
/* Broadcast notifications */
router.post('/broadcast', ctrl.broadcast);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { teacherOverview } = require('../controllers/analyticsController');
router.get('/teacher', authMiddleware, requireRole('teacher', 'admin'), teacherOverview);
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const validate = require('../middleware/validate');
const ctrl = require('../controllers/assignmentController');
const MODES = ['exam', 'practice', 'repeat', 'ct'];
const createSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
const updateSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
const directSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
student_email: { type: 'string', maxLen: 255 },
}};
const bulkSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
class_id: { type: 'number', required: true, min: 1 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
}};
router.use(authMiddleware);
router.get('/my', ctrl.myAssignments);
router.get('/teacher', requireRole('teacher','admin'), ctrl.teacherAssignments);
router.get('/templates', requireRole('teacher','admin'), ctrl.listTemplates);
router.post('/templates', requireRole('teacher','admin'), ctrl.saveTemplate);
router.delete('/templates/:id', requireRole('teacher','admin'), ctrl.deleteTemplate);
router.post('/bulk', requireRole('teacher','admin'), validate(bulkSchema), ctrl.bulkCreateAssignment);
router.post('/', requireRole('teacher','admin'), validate(directSchema), ctrl.createDirectAssignment);
router.post('/:id/start', ctrl.startAssignment);
router.get('/:id/results', requireRole('teacher','admin'), ctrl.assignmentResults);
router.get('/:id/question-stats', requireRole('teacher','admin'), ctrl.assignmentQuestionStats);
router.get('/:id/sessions/:session_id/review', requireRole('teacher','admin'), ctrl.assignmentSessionReview);
router.put('/:id', requireRole('teacher','admin'), validate(updateSchema), ctrl.updateAssignment);
router.delete('/:id', requireRole('teacher','admin'), ctrl.deleteAssignment);
module.exports = router;
+32
View File
@@ -0,0 +1,32 @@
const router = require('express').Router();
const { register, login, me, updateProfile } = require('../controllers/authController');
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const loginLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много попыток входа, подождите минуту' });
const registerLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много регистраций, подождите минуту' });
const profileLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много запросов, подождите минуту' });
const registerSchema = { body: {
email: { type: 'string', required: true, maxLen: 255, match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
password: { type: 'string', required: true, minLen: 6, maxLen: 128 },
name: { type: 'string', required: true, minLen: 1, maxLen: 100 },
}};
const loginSchema = { body: {
email: { type: 'string', required: true, maxLen: 255 },
password: { type: 'string', required: true, minLen: 1, maxLen: 128 },
}};
const profileSchema = { body: {
name: { type: 'string', minLen: 1, maxLen: 100 },
newPassword: { type: 'string', minLen: 6, maxLen: 128 },
currentPassword: { type: 'string', maxLen: 128 },
}};
router.post('/register', registerLimiter, validate(registerSchema), register);
router.post('/login', loginLimiter, validate(loginSchema), login);
router.get('/me', authMiddleware, me);
router.get('/profile', authMiddleware, me);
router.patch('/profile', authMiddleware, profileLimiter, validate(profileSchema), updateProfile);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/biochemController');
router.use(authMiddleware);
router.get('/elements', c.getElements);
router.get('/molecules', c.getMolecules);
router.get('/molecules/:id', c.getMolecule);
router.post('/validate', c.validate);
router.get('/reactions', c.getReactions);
router.get('/challenges', c.getChallenges);
router.post('/challenges/:id/solve', c.solveChallenge);
router.get('/saved', c.getSaved);
router.post('/saved', c.saveMolecule);
router.delete('/saved/:id', c.deleteSaved);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/bookmarkController');
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/', ctrl.add);
router.delete('/:id', ctrl.remove);
router.delete('/entity/:type/:entityId', ctrl.removeByEntity);
router.get('/check/:type/:entityId', ctrl.check);
module.exports = router;
+48
View File
@@ -0,0 +1,48 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const ctrl = require('../controllers/classController');
const assignCtrl = require('../controllers/assignmentController');
const joinLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много попыток, подождите минуту' });
const createLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много запросов, подождите минуту' });
const joinSchema = { body: { invite_code: { type: 'string', required: true, minLen: 1, maxLen: 20 } } };
const createSchema = { body: { name: { type: 'string', required: true, minLen: 1, maxLen: 200 } } };
const updateSchema = { body: {
name: { type: 'string', minLen: 1, maxLen: 200 },
description: { type: 'string', maxLen: 1000 },
}};
const assignmentSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: ['exam', 'practice', 'repeat', 'ct'] },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
router.use(authMiddleware);
/* ── Student (must be before /:id to avoid route conflicts) ── */
router.post('/join', requireRole('student','free_student'), joinLimiter, validate(joinSchema), ctrl.joinClass);
router.get('/student/my', ctrl.myClasses);
router.get('/students', requireRole('teacher','admin'), ctrl.listStudents);
/* ── Teacher / Admin ── */
router.get('/', requireRole('teacher','admin'), ctrl.listClasses);
router.post('/', requireRole('teacher','admin'), requirePermission('classes.manage'), createLimiter, validate(createSchema), ctrl.createClass);
router.get('/:id', requireRole('teacher','admin'), ctrl.getClass);
router.patch('/:id', requireRole('teacher','admin'), requirePermission('classes.manage'), validate(updateSchema), ctrl.updateClass);
router.delete('/:id', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.deleteClass);
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
router.get('/:id/announcements', requireRole('teacher','admin'), ctrl.getAnnouncements);
router.post('/:id/announcements', requireRole('teacher','admin'), requirePermission('announcements.send'), ctrl.createAnnouncement);
router.delete('/:id/announcements/:aid', requireRole('teacher','admin'), requirePermission('announcements.send'), ctrl.deleteAnnouncement);
router.get('/:id/feed', ctrl.classFeed);
module.exports = router;
+104
View File
@@ -0,0 +1,104 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const c = require('../controllers/classroomController');
/* ── multer for chat image attachments ─────────────────────────────────── */
const _chatUploadsDir = path.join(__dirname, '../../uploads/chat');
const _chatStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, _chatUploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
cb(null, crypto.randomBytes(14).toString('hex') + ext);
},
});
const chatUpload = multer({
storage: _chatStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ok = ['image/jpeg','image/png','image/gif','image/webp'].includes(file.mimetype);
cb(null, ok);
},
});
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
const auth = [authMiddleware];
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите' });
// Template library — MUST be before /:id to avoid shadowing
router.get('/templates', ...teacher, c.getTemplates);
router.delete('/templates/:tid', ...teacher, c.deleteTemplate);
// Session lifecycle
router.post('/', ...teacher, c.createSession);
router.get('/online-students', ...teacher, c.getOnlineStudents);
router.get('/my/session', ...auth, c.getMySession);
router.get('/class/:classId/active', ...auth, c.getActiveSession);
router.get('/my/active', ...auth, c.getMyActive);
router.get('/:id', ...auth, c.getSession);
router.delete('/:id', ...teacher, c.endSession);
// Attendance
router.post('/:id/join', ...auth, c.joinSession);
router.post('/:id/leave', ...auth, c.leaveSession);
router.get('/:id/participants', ...auth, c.getParticipants);
router.get('/:id/attendance', ...teacher, c.getAttendance);
// Chat
router.post('/:id/chat', ...auth, chatLimiter, c.sendChat);
router.get('/:id/chat', ...auth, c.getChat);
router.post('/:id/chat/upload', ...auth, chatUpload.single('file'), c.uploadChatAttachment);
router.post('/:id/chat/:msgId/react', ...auth, c.reactToMessage);
// WebRTC signaling
router.post('/:id/signal', ...auth, c.signal);
// Whiteboard strokes
router.post('/:id/strokes', ...auth, c.postStrokes);
router.get('/:id/strokes', ...auth, c.getStrokes);
router.delete('/:id/strokes/:strokeId', ...teacher, c.deleteStroke);
router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
router.post('/:id/stroke-preview', ...auth, c.previewStroke);
// Multi-page
router.post('/:id/pages', ...teacher, c.addPage);
router.put('/:id/page', ...teacher, c.changePage);
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
// Hand raise
router.post('/:id/hand', ...auth, c.raiseHand);
router.delete('/:id/hand', ...auth, c.lowerHand);
router.get('/:id/hands', ...auth, c.getHands);
// Whiteboard: clear page
router.post('/:id/clear-page', ...teacher, c.clearPage);
// WebRTC: mute peer, screen share broadcast
router.post('/:id/mute', ...teacher, c.mutePeer);
router.post('/:id/screen', ...teacher, c.screenStart);
router.delete('/:id/screen', ...teacher, c.screenStop);
// Cursor broadcast (all participants)
router.post('/:id/cursor', ...auth, c.broadcastCursor);
// Message pin (teacher only)
router.post('/:id/chat/:msgId/pin', ...teacher, c.pinMessage);
// Collaborative drawing permissions
router.post('/:id/allow-draw/:userId', ...teacher, c.allowDraw);
router.delete('/:id/allow-draw/:userId', ...teacher, c.revokeDraw);
// Session notes (per user)
router.get('/:id/notes', ...auth, c.getNotes);
router.put('/:id/notes', ...auth, c.saveNotes);
// Save current session as template
router.post('/:id/save-template', ...teacher, c.saveTemplate);
// Load template into current session
router.post('/:id/load-template', ...teacher, c.loadTemplate);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/collectionController');
router.get('/', authMiddleware, c.getCollection);
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const c = require('../controllers/courseController');
router.use(authMiddleware);
// Course listing & special
router.get('/', c.list);
router.get('/search', c.search);
router.get('/continue', c.continueLesson);
router.get('/:id', c.get);
router.get('/:id/stats', requireRole('teacher','admin'), c.stats);
router.get('/:id/analytics', requireRole('teacher','admin'), c.analytics);
// Course mutations
router.post('/', requireRole('teacher','admin'), requirePermission('courses.manage'), c.create);
router.post('/:id/duplicate', requireRole('teacher','admin'), requirePermission('courses.manage'), c.duplicate);
router.patch('/:id/publish-all', requireRole('teacher','admin'), requirePermission('courses.manage'), c.publishAll);
router.put('/:id', requireRole('teacher','admin'), requirePermission('courses.manage'), c.update);
router.delete('/:id', requireRole('teacher','admin'), requirePermission('courses.manage'), c.remove);
// Sections
router.get('/:id/sections', requireRole('teacher','admin'), c.listSections);
router.post('/:id/sections', requireRole('teacher','admin'), c.createSection);
router.put('/:id/sections/:sid', requireRole('teacher','admin'), c.updateSection);
router.delete('/:id/sections/:sid', requireRole('teacher','admin'), c.deleteSection);
// Class courses
router.get('/class/:classId', c.listClassCourses);
router.post('/class/:classId/assign', requireRole('teacher','admin'), c.assignCourseToClass);
router.delete('/class/:classId/:courseId', requireRole('teacher','admin'), c.unassignCourseFromClass);
module.exports = router;
+73
View File
@@ -0,0 +1,73 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('crypto').randomUUID ? { v4: () => require('crypto').randomBytes(16).toString('hex') } : {};
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/fileController');
const { fixUtf8Name } = require('../utils/fixUtf8');
/* ── multer config ─────────────────────────────────────────────────────── */
const UPLOADS_DIR = path.join(__dirname, '../../uploads');
const ALLOWED = ['application/pdf','image/png','image/jpeg','image/gif','image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'];
const storage = multer.diskStorage({
destination: UPLOADS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const name = require('crypto').randomBytes(16).toString('hex') + ext;
cb(null, name);
},
});
const SAFE_EXTS = new Set(['.pdf','.png','.jpg','.jpeg','.gif','.webp','.doc','.docx','.ppt','.pptx','.xls','.xlsx','.txt']);
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
fileFilter: (_req, file, cb) => {
if (!ALLOWED.includes(file.mimetype)) return cb(null, false);
// Reject double extensions (.php.jpg, .exe.pdf, etc.)
const name = file.originalname;
const parts = name.split('.');
if (parts.length > 2) {
const inner = '.' + parts[parts.length - 2].toLowerCase();
if (['.php','.exe','.sh','.bat','.cmd','.ps1','.js','.html','.htm'].includes(inner)) return cb(null, false);
}
// Verify file extension is allowed
const ext = path.extname(name).toLowerCase();
if (ext && !SAFE_EXTS.has(ext)) return cb(null, false);
cb(null, true);
},
});
/* ── routes ─────────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.get('/', ctrl.listFiles);
router.post('/', requireRole('teacher','admin'), requirePermission('library.upload'), upload.single('file'), fixUtf8Name, ctrl.uploadFile);
/* ── folder routes (must be before /:id to avoid conflicts) ── */
router.get('/folders', ctrl.listFolders);
router.post('/folders', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.createFolder);
router.put('/folders/:id',requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.renameFolder);
router.delete('/folders/:id', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.deleteFolder);
router.get('/folders/:id/access', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.getFolderAccess);
router.delete('/folders/:id/access', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.clearFolderAccess);
router.post('/folders/:id/assign', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.assignFolder);
router.delete('/folders/:id/assign/:type/:targetId', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.unassignFolder);
router.patch('/:id/move', requireRole('teacher','admin'), ctrl.moveFile);
router.get('/:id/download', ctrl.downloadFile);
router.delete('/:id', requireRole('teacher','admin'), ctrl.deleteFile);
router.get('/:id/access', requireRole('teacher','admin'), ctrl.getFileAccess);
router.post('/:id/assign', requireRole('teacher','admin'), ctrl.assignFile);
router.delete('/:id/assign/:type/:targetId', requireRole('teacher','admin'), ctrl.unassignFile);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const fc = require('../controllers/flashcardController');
const { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
router.get ('/decks', fc.listDecks);
router.post ('/decks', fc.createDeck);
router.put ('/decks/:id', fc.updateDeck);
router.delete('/decks/:id', fc.deleteDeck);
router.get ('/decks/:id/cards', fc.getCards);
router.post ('/decks/:id/cards', fc.addCard);
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
router.post ('/cards/:id/review', fc.submitReview);
router.get ('/stats', fc.getStats);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/gamesController');
router.get('/hangman/word', authMiddleware, c.hangmanWord);
router.post('/hangman/complete', authMiddleware, c.hangmanComplete);
router.get('/crossword/generate', authMiddleware, c.crosswordGenerate);
router.post('/crossword/complete', authMiddleware, c.crosswordComplete);
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const validate = require('../middleware/validate');
const rateLimit = require('../middleware/rateLimit');
const {
getMe, getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge, setGoalTier, getFrames, setFrame,
onLabExperiment,
adminAward, adminReset, adminGamStats, adminGetUser
} = require('../controllers/gamificationController');
const labLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые запросы лаборатории' });
const labSchema = { body: { reactionsDiscovered: { type: 'number', min: 0, max: 100, integer: true } } };
router.use(authMiddleware);
router.get('/me', getMe);
router.get('/achievements', getAchievements);
router.get('/leaderboard', getLeaderboard);
router.get('/xp-history', getXPHistory);
router.get('/challenges', getChallenges);
router.post('/challenges/:id/claim', requirePermission('gamification.challenges'), claimChallenge);
router.post('/goal-tier', requirePermission('gamification.challenges'), setGoalTier);
router.get('/frames', getFrames);
router.post('/frame', requirePermission('shop.purchase'), setFrame);
/* Lab experiment tracking */
router.post('/lab-activity', requirePermission('simulations.access'), labLimiter, validate(labSchema), (req, res) => {
const discovered = Number(req.body.reactionsDiscovered) || 0;
onLabExperiment(req.user.id, discovered);
res.json({ ok: true });
});
/* Admin routes */
router.post('/admin/award', requireRole('admin', 'teacher'), adminAward);
router.post('/admin/reset', requireRole('admin'), adminReset);
router.get('/admin/stats', requireRole('admin', 'teacher'), adminGamStats);
router.get('/admin/user/:id', requireRole('admin', 'teacher'), adminGetUser);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/knowledgeMapController');
router.get('/', authMiddleware, c.getMap);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/lessonController');
router.use(authMiddleware);
router.get('/:id', c.get);
router.post('/:id/complete', c.markComplete);
router.put('/:id/note', c.saveNote);
router.get('/:id/comments', c.listComments);
router.post('/:id/comments', c.addComment);
router.delete('/:id/comments/:cid', c.deleteComment);
// Teacher/admin only
router.post('/', requireRole('teacher','admin'), c.create);
router.put('/:id', requireRole('teacher','admin'), c.update);
router.delete('/:id', requireRole('teacher','admin'), c.remove);
router.put('/:id/blocks', requireRole('teacher','admin'), c.saveBlocks);
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/liveController');
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
router.post('/', ...teacher, c.create);
router.get('/:id', ...teacher, c.getSession);
router.put('/:id/question', ...teacher, c.setQuestion);
router.get('/:id/results', ...teacher, c.results);
router.delete('/:id', ...teacher, c.end);
router.post('/:id/answer', authMiddleware, c.answer);
router.get('/class/:classId/active', authMiddleware, c.getActive);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/notificationController');
// SSE stream — auth done inside handler via ?token param (EventSource can't set headers)
router.get('/stream', ctrl.stream);
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/read-all', ctrl.markAllRead);
router.patch('/:id/read', ctrl.markRead);
module.exports = router;
+26
View File
@@ -0,0 +1,26 @@
const router = require('express').Router();
const rateLimit = require('../middleware/rateLimit');
const { authMiddleware, requireRole, parentAuth } = require('../middleware/auth');
const ctrl = require('../controllers/parentController');
/* ── Rate limits ───────────────────────────────────────────────────── */
const authLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много попыток, подождите минуту' });
const parentLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком много запросов' });
/* ── Public: token exchange ────────────────────────────────────────── */
router.post('/auth', authLimiter, ctrl.exchangeToken);
/* ── Student-side: manage parent links ─────────────────────────────── */
router.get('/my-links', authMiddleware, requireRole('student', 'free_student'), ctrl.getMyLinks);
router.post('/links', authMiddleware, requireRole('student', 'free_student'), ctrl.createLink);
router.patch('/links/:id', authMiddleware, requireRole('student', 'free_student'), ctrl.updateLink);
router.delete('/links/:id', authMiddleware, requireRole('student', 'free_student'), ctrl.deleteLink);
/* ── Parent-side: read-only dashboard (rate-limited) ───────────────── */
router.use(parentAuth, parentLimiter);
router.get('/dashboard', ctrl.getDashboard);
router.get('/history', ctrl.getHistory);
router.get('/notifications', ctrl.getNotifications);
router.patch('/notifications/:id/read', ctrl.markRead);
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions } = require('../controllers/permissionsController');
router.use(authMiddleware);
/* Any authenticated user can fetch their own effective permissions */
router.get('/me', getMyPermissions);
router.use(requireRole('admin'));
router.get('/', getPermissions);
router.post('/', setPermission);
/* ── Per-user overrides ── */
router.get('/users/:id', getUserPermissions);
router.post('/users/:id', setUserPermission);
router.delete('/users/:id/reset', resetUserPermissions);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/petController');
router.get('/', authMiddleware, c.getPet);
router.patch('/name', authMiddleware, c.renamePet);
router.post('/pet', authMiddleware, c.petAction);
router.patch('/color', authMiddleware, c.updateColor);
router.post('/star', authMiddleware, c.starCatch);
router.get('/shop', authMiddleware, c.getShop);
router.post('/shop/buy', authMiddleware, c.buyBg);
router.patch('/bg', authMiddleware, c.setBg);
router.post('/feed', authMiddleware, c.feedPet);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const router = require('express').Router();
const multer = require('multer');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { list, create, duplicate, update, remove, importCSV } = require('../controllers/questionController');
const csvUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
router.use(authMiddleware);
router.use(requireRole('admin', 'teacher'));
router.get('/', list);
router.post('/import', requirePermission('questions.manage'), csvUpload.single('file'), importCSV);
router.post('/', requirePermission('questions.manage'), create);
router.post('/:id/copy', requirePermission('questions.manage'), duplicate);
router.put('/:id', requirePermission('questions.manage'), update);
router.delete('/:id', requireRole('admin', 'teacher'), requirePermission('questions.delete'), remove);
module.exports = router;
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/redBookController');
const { requireAuth, optionalAuth } = require('../middleware/auth');
// Public (or optional auth for collection status)
router.get('/groups', ctrl.getGroups);
router.get('/habitats', ctrl.getHabitats);
router.get('/stats', optionalAuth, ctrl.getStats);
router.get('/map-data', ctrl.getMapData);
router.get('/food-web', ctrl.getFoodWeb);
router.get('/daily', optionalAuth, ctrl.getDaily);
router.get('/species', optionalAuth, ctrl.getSpecies);
router.get('/species/:id', optionalAuth, ctrl.getSpeciesById);
router.get('/biome/:habitatId', ctrl.getBiomeSpecies);
// Auth required
router.post('/species/:id/collect', requireAuth, ctrl.collectSpecies);
router.get('/collection', requireAuth, ctrl.getCollection);
router.get('/quests', optionalAuth, ctrl.getQuests);
router.post('/quests/:id/start', requireAuth, ctrl.startQuest);
router.get('/sightings', optionalAuth, ctrl.getSightings);
router.post('/sightings', requireAuth, ctrl.addSighting);
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/searchController');
router.use(authMiddleware);
router.get('/', ctrl.search);
module.exports = router;
+28
View File
@@ -0,0 +1,28 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats } = require('../controllers/sessionController');
const sessionLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много сессий, подождите минуту' });
const startSchema = { body: {
subject_slug: { type: 'string', required: true, minLen: 1, maxLen: 100 },
mode: { type: 'string', oneOf: ['exam', 'practice', 'repeat', 'ct'] },
count: { type: 'number', min: 1, max: 200, integer: true },
}};
const answerSchema = { body: {
question_id: { type: 'number', required: true, min: 1, integer: true },
}};
router.use(authMiddleware); // все маршруты требуют авторизации
router.post('/', sessionLimiter, validate(startSchema), start);
router.post('/:id/answer', validate(answerSchema), answer);
router.post('/:id/finish', finish);
router.get('/history', history);
router.get('/stats', stats);
router.get('/weak-topics', weakTopics);
router.get('/:id/result', result);
router.get('/:id/questions', getSessionQuestions);
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getSimSettings, updateSimSettings } = require('../controllers/settingsController');
// GET is open to any authenticated user (students need to check disabled sims)
router.get('/sims', authMiddleware, getSimSettings);
// PUT requires admin
router.put('/sims', authMiddleware, requireRole('admin'), updateSimSettings);
module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const {
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
} = require('../controllers/shopController');
const purchaseLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много покупок, подождите минуту' });
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect'] } } };
const adminItemSchema = { body: {
name: { type: 'string', required: true, minLen: 1, maxLen: 200 },
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect'] },
price: { type: 'number', required: true, min: 0 },
}};
const awardCoinsSchema = { body: {
userId: { type: 'number', required: true, min: 1, integer: true },
amount: { type: 'number', required: true, min: 1, integer: true },
}};
router.use(authMiddleware);
router.get('/items', getItems);
router.post('/items/:id/purchase', requirePermission('shop.purchase'), purchaseLimiter, purchaseItem);
router.get('/purchases', getPurchases);
router.get('/coins', getCoins);
router.get('/my-active', getMyActive);
router.post('/activate', validate(activateSchema), activateItem);
/* Admin routes */
router.get('/admin/items', requireRole('admin', 'teacher'), adminGetItems);
router.post('/admin/items', requireRole('admin'), validate(adminItemSchema), adminCreateItem);
router.put('/admin/items/:id', requireRole('admin'), adminUpdateItem);
router.delete('/admin/items/:id',requireRole('admin'), adminDeleteItem);
router.post('/admin/award-coins',requireRole('admin', 'teacher'), validate(awardCoinsSchema), adminAwardCoins);
router.get('/admin/stats', requireRole('admin', 'teacher'), adminShopStats);
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
router.get('/', (_req, res) => {
res.json(db.prepare(`
SELECT s.*, (SELECT COUNT(*) FROM questions q WHERE q.subject_id = s.id) AS question_count
FROM subjects s ORDER BY s.id
`).all());
});
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
const { default_mode, default_count, default_test_id } = req.body;
const valid_modes = ['exam', 'practice', 'topic', 'random'];
if (default_mode && !valid_modes.includes(default_mode))
return res.status(400).json({ error: 'Invalid mode' });
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const updates = [];
const args = [];
if (default_mode !== undefined) { updates.push('default_mode = ?'); args.push(default_mode); }
if (default_count !== undefined) { updates.push('default_count = ?'); args.push(Number(default_count) || 25); }
if (default_test_id !== undefined) { updates.push('default_test_id = ?'); args.push(default_test_id || null); }
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
args.push(req.params.slug);
db.prepare(`UPDATE subjects SET ${updates.join(', ')} WHERE slug = ?`).run(...args);
res.json(db.prepare('SELECT * FROM subjects WHERE slug = ?').get(req.params.slug));
});
router.get('/:slug/topics', (req, res) => {
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });
const topics = db.prepare(
'SELECT MIN(id) AS id, subject_id, name, MIN(order_index) AS order_index FROM topics WHERE subject_id = ? GROUP BY name ORDER BY MIN(order_index)'
).all(subject.id);
res.json(topics);
});
module.exports = router;
+60
View File
@@ -0,0 +1,60 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/submissionsController');
const { fixUtf8Name } = require('../utils/fixUtf8');
/* ── multer — same dir/types as library uploads ─────────────────────── */
const UPLOADS_DIR = path.join(__dirname, '../../uploads');
const ALLOWED = [
'application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
const SAFE_EXTS = new Set(['.pdf','.png','.jpg','.jpeg','.gif','.webp','.doc','.docx','.ppt','.pptx','.xls','.xlsx','.txt']);
const storage = multer.diskStorage({
destination: UPLOADS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const name = require('crypto').randomBytes(16).toString('hex') + ext;
cb(null, name);
},
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!ALLOWED.includes(file.mimetype)) return cb(null, false);
// Reject double extensions (.php.jpg, .exe.pdf, etc.)
const name = file.originalname;
const parts = name.split('.');
if (parts.length > 2) {
const inner = '.' + parts[parts.length - 2].toLowerCase();
if (['.php','.exe','.sh','.bat','.cmd','.ps1','.js','.html','.htm'].includes(inner)) return cb(null, false);
}
const ext = path.extname(name).toLowerCase();
if (ext && !SAFE_EXTS.has(ext)) return cb(null, false);
cb(null, true);
},
});
/* ── routes ─────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubmissions);
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
router.get('/:id/download', ctrl.downloadSubmission);
router.delete('/:id', ctrl.deleteSubmission);
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
const router = require('express').Router();
const { authMiddleware, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/templateController');
router.use(authMiddleware);
router.get('/courses', ctrl.listCourseTemplates);
router.post('/courses', requirePermission('templates.manage'), ctrl.saveCourseTemplate);
router.post('/courses/:id/create', requirePermission('templates.manage'), ctrl.createFromCourseTemplate);
router.delete('/courses/:id', requirePermission('templates.manage'), ctrl.deleteCourseTemplate);
router.get('/lessons', ctrl.listLessonTemplates);
router.post('/lessons', requirePermission('templates.manage'), ctrl.saveLessonTemplate);
router.post('/lessons/:id/create', requirePermission('templates.manage'), ctrl.createFromLessonTemplate);
router.delete('/lessons/:id', requirePermission('templates.manage'), ctrl.deleteLessonTemplate);
module.exports = router;
+19
View File
@@ -0,0 +1,19 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
const ctrl = require('../controllers/testController');
const ownsTest = requireOwnership({ table: 'tests', ownerField: 'created_by' });
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/', requireRole('teacher','admin'), ctrl.create);
router.get('/:id', ctrl.getOne);
router.put('/:id', requireRole('teacher','admin'), ownsTest, ctrl.update);
router.delete('/:id', requireRole('teacher','admin'), ownsTest, ctrl.remove);
router.post('/:id/questions', requireRole('teacher','admin'), ownsTest, ctrl.addQuestions);
router.patch('/:id/questions/reorder', requireRole('teacher','admin'), ownsTest, ctrl.reorderQuestions);
router.delete('/:id/questions/:qid', requireRole('teacher','admin'), ownsTest, ctrl.removeQuestion);
module.exports = router;
+327
View File
@@ -0,0 +1,327 @@
const config = require('./config'); // validates .env, fails fast on error
const logger = require('./utils/logger'); // structured logging
require('./db/migrate'); // авто-миграция при каждом старте
const { seedDefaults: seedPermissions } = require('./controllers/permissionsController');
seedPermissions();
const express = require('express');
const cors = require('cors');
const path = require('path');
const compression = require('compression');
const authRoutes = require('./routes/auth');
const subjectRoutes = require('./routes/subjects');
const sessionRoutes = require('./routes/sessions');
const adminRoutes = require('./routes/admin');
const questionRoutes = require('./routes/questions');
const classRoutes = require('./routes/classes');
const assignmentRoutes = require('./routes/assignments');
const fileRoutes = require('./routes/files');
const testRoutes = require('./routes/tests');
const notificationRoutes = require('./routes/notifications');
const permissionRoutes = require('./routes/permissions');
const submissionRoutes = require('./routes/submissions');
const courseRoutes = require('./routes/courses');
const lessonRoutes = require('./routes/lessons');
const gamificationRoutes = require('./routes/gamification');
const shopRoutes = require('./routes/shop');
const templateRoutes = require('./routes/templates');
const bookmarkRoutes = require('./routes/bookmarks');
const searchRoutes = require('./routes/search');
const flashcardRoutes = require('./routes/flashcards');
const settingsRoutes = require('./routes/settings');
const analyticsRoutes = require('./routes/analytics');
const liveRoutes = require('./routes/live');
const classroomRoutes = require('./routes/classroom');
const gamesRoutes = require('./routes/games');
const knowledgeMapRoutes = require('./routes/knowledgeMap');
const petRoutes = require('./routes/pet');
const collectionRoutes = require('./routes/collection');
const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
const { requestId, errorHandler } = require('./middleware/errorHandler');
const app = express();
const PORT = config.PORT;
const isProd = config.isProd;
/* ── Gzip compression (skip SSE streams — compression buffers chunks and breaks real-time) ── */
app.use(compression({
filter: (req, res) => {
if (req.path.includes('/stream') || req.headers.accept === 'text/event-stream') return false;
return compression.filter(req, res);
},
}));
/* ── Request ID — attach before everything else ── */
app.use(requestId);
/* ── Security headers (no helmet dep needed) ── */
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('X-XSS-Protection', '1; mode=block');
// classroom.html needs microphone + camera for WebRTC
const isClassroom = _req.path === '/classroom' || _req.path === '/classroom.html';
res.setHeader('Permissions-Policy',
isClassroom
? 'camera=(self), microphone=(self), geolocation=()'
: 'camera=(), microphone=(), geolocation=()'
);
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " +
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self' https://cdn.jsdelivr.net https://stun.l.google.com; " +
"frame-src https://www.youtube.com https://rutube.ru https://player.vimeo.com; " +
"frame-ancestors 'none'" +
(isProd ? "; upgrade-insecure-requests" : "")
);
if (isProd) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
/* ── Request logger ── */
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const ms = Date.now() - start;
const status = res.statusCode;
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
logger[level](`${req.method} ${req.path}`, {
status, ms, requestId: req.requestId,
userId: req.user?.id,
});
});
next();
});
/* ── CORS ── */
app.set('trust proxy', 1);
const allowedOrigins = [process.env.CLIENT_ORIGIN].filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, !isProd); // dev: allow, prod: block
cb(null, allowedOrigins.includes(origin) ? origin : false);
},
credentials: true
}));
/* ── Body parser with size limit ── */
app.use(express.json({ limit: '1mb' }));
/* ── Global API rate limit ── */
const rateLimit = require('./middleware/rateLimit');
// Classroom real-time endpoints (cursor, stroke-preview) fire ~10/s per user — higher limit
app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' }));
app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' }));
/* ── Routes ── */
app.use('/api/auth', authRoutes);
app.use('/api/subjects', subjectRoutes);
app.use('/api/sessions', sessionRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/questions', questionRoutes);
app.use('/api/classes', classRoutes);
app.use('/api/assignments', assignmentRoutes);
app.use('/api/files', fileRoutes);
app.use('/api/tests', testRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/submissions', submissionRoutes);
app.use('/api/courses', courseRoutes);
app.use('/api/lessons', lessonRoutes);
app.use('/api/gamification', gamificationRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/templates', templateRoutes);
app.use('/api/bookmarks', bookmarkRoutes);
app.use('/api/search', searchRoutes);
app.use('/api/flashcards', flashcardRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/live', liveRoutes);
app.use('/api/classroom', classroomRoutes);
app.use('/api/games', gamesRoutes);
app.use('/api/knowledge-map', knowledgeMapRoutes);
app.use('/api/pet', petRoutes);
app.use('/api/collection', collectionRoutes);
app.use('/api/red-book', redBookRoutes);
app.use('/api/biochem', require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');
const _stmtGlobalFeats = _featDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
const _stmtClassFeats = _featDb.prepare(
'SELECT c.features FROM classes c JOIN class_members cm ON cm.class_id = c.id WHERE cm.user_id = ?'
);
const _stmtFreeStudentFeats = _featDb.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'");
const _jwtLib = require('jsonwebtoken');
app.get('/api/features', (req, res) => {
const rows = _stmtGlobalFeats.all();
const features = {};
for (const r of rows) {
const name = r.key.replace('feature_', '').replace('_enabled', '');
features[name] = r.value === '1';
}
// Prevent browser caching — class features change when teacher updates settings
res.setHeader('Cache-Control', 'no-store');
// For authenticated students: overlay class-specific feature flags
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (token) {
try {
const payload = _jwtLib.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
if (payload.role === 'student' || payload.role === 'free_student') {
const classes = _stmtClassFeats.all(payload.id);
if (classes.length === 0 && payload.role === 'student') {
features._no_class = true; // regular student has no class — restrict access
}
for (const cls of classes) {
if (!cls.features) continue;
try {
const f = JSON.parse(cls.features);
for (const [key, val] of Object.entries(f)) {
if (val === false) features[key] = false;
}
} catch (e) { console.error('[features] class JSON parse error:', e.message); }
}
// Apply role-level free_student restrictions (set by admin)
if (payload.role === 'free_student') {
const fsRow = _stmtFreeStudentFeats.get();
if (fsRow?.value) {
try {
const fsFeats = JSON.parse(fsRow.value);
for (const [key, val] of Object.entries(fsFeats)) {
if (val === false) features[key] = false;
}
} catch (e) { console.error('[features] free_student JSON parse error:', e.message); }
}
}
}
} catch { /* invalid/expired token — anonymous features */ }
}
res.json(features);
});
/* ── Health check ── */
const _startTime = Date.now();
const _pkg = require('../package.json');
const _db = require('./db/db');
app.get('/api/health', (req, res) => {
let dbStatus = 'ok';
let dbLatencyMs = null;
try {
const t0 = Date.now();
_db.prepare('SELECT 1').get();
dbLatencyMs = Date.now() - t0;
} catch {
dbStatus = 'error';
}
const status = dbStatus === 'ok' ? 'ok' : 'degraded';
const httpStatus = status === 'ok' ? 200 : 503;
// Minimal public response — no version/runtime details
const pub = { status, timestamp: new Date().toISOString() };
// Detailed response only for authenticated admins
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (token) {
try {
const jwt = require('jsonwebtoken');
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const u = _db.prepare('SELECT role FROM users WHERE id = ?').get(payload.id);
if (u?.role === 'admin') {
const uptimeSec = Math.floor((Date.now() - _startTime) / 1000);
return res.status(httpStatus).json({
...pub,
version: _pkg.version,
uptime: { seconds: uptimeSec, human: _fmtUptime(uptimeSec) },
db: { status: dbStatus, latency_ms: dbLatencyMs },
node: process.version,
env: process.env.NODE_ENV || 'development',
});
}
} catch { /* invalid token → fall through to public response */ }
}
res.status(httpStatus).json(pub);
});
function _fmtUptime(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${sec}s`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
/* ── Static frontend ── */
const frontendDir = path.join(__dirname, '../../frontend');
const jsDir = path.join(__dirname, '../../js');
const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') };
app.use('/js', express.static(jsDir, staticCache));
app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache));
// Redirect legacy .html URLs → clean URLs (301)
app.use((req, res, next) => {
if (req.path.endsWith('.html') && !req.path.startsWith('/api/')) {
const clean = req.path.slice(0, -5);
const qs = req.url.slice(req.path.length); // preserve ?query
return res.redirect(301, clean + qs);
}
next();
});
// Serve HTML files without extension (/dashboard → dashboard.html)
// In dev: disable cache so edits are always picked up immediately
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {
extensions: ['html'],
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-store');
},
};
app.use(express.static(frontendDir, htmlCacheOpts));
app.get('/', (_req, res) => res.sendFile(path.join(frontendDir, 'login.html')));
app.get('/login', (_req, res) => res.sendFile(path.join(frontendDir, 'login.html')));
/* ── Custom error pages ── */
app.get('/403', (_req, res) => res.status(403).sendFile(path.join(frontendDir, '403.html')));
app.get('/404', (_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html')));
app.get('/500', (_req, res) => res.status(500).sendFile(path.join(frontendDir, '500.html')));
/* ── Global error handler ── */
app.use(errorHandler);
app.use((_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html')));
const server = app.listen(PORT, () => logger.info(`Server running on port ${PORT}`, { env: config.NODE_ENV }));
/* ── Graceful shutdown ── */
function shutdown(signal) {
logger.info(`${signal} received — shutting down gracefully`);
server.close(() => {
try {
const _shutDb = require('./db/db');
_shutDb.exec('PRAGMA optimize');
_shutDb.close();
} catch (e) { logger.error('db close error', { err: e.message }); }
logger.info('Server closed');
process.exit(0);
});
// Force exit after 5s if connections hang
setTimeout(() => process.exit(1), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+50
View File
@@ -0,0 +1,50 @@
/* ── SSE registry — shared between controllers ─────────────────────────── */
const clients = new Map(); // userId -> Set<res>
const db = require('./db/db');
function addClient(userId, res) {
if (!clients.has(userId)) clients.set(userId, new Set());
clients.get(userId).add(res);
}
function removeClient(userId, res) {
const set = clients.get(userId);
if (!set) return;
set.delete(res);
if (set.size === 0) clients.delete(userId);
}
function emit(userId, data) {
const conns = clients.get(userId);
if (!conns?.size) return;
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const res of conns) {
try { res.write(payload); } catch {}
}
}
// Heartbeat: detect and remove dead connections every 30s
setInterval(() => {
for (const [userId, conns] of clients) {
for (const res of conns) {
try {
if (res.writableEnded || res.destroyed) { conns.delete(res); continue; }
res.write(': heartbeat\n\n');
} catch { conns.delete(res); }
}
if (conns.size === 0) clients.delete(userId);
}
}, 30_000).unref();
/* Broadcast to all members of a class */
function emitToClass(classId, data) {
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id=?').all(classId);
for (const { user_id } of members) emit(user_id, data);
}
/* Returns array of user IDs currently connected via SSE */
function getOnlineUserIds() {
return [...clients.keys()];
}
module.exports = { addClient, removeClient, emit, emitToClass, getOnlineUserIds };
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const db = require('../db/db');
const stmt = db.prepare(
"INSERT INTO admin_audit_log (admin_id, action, target, detail, ip) VALUES (?, ?, ?, ?, ?)"
);
/**
* Log an admin action.
* @param {object} req - Express request (must have req.user)
* @param {string} action - e.g. 'user.role_change', 'user.delete', 'user.ban'
* @param {string} [target] - e.g. 'user:42', 'question:15'
* @param {string} [detail] - human-readable detail
*/
function audit(req, action, target, detail) {
try {
const ip = req.ip || req.socket?.remoteAddress || '';
stmt.run(req.user?.id || 0, action, target || null, detail || null, ip);
} catch (e) { console.error('[audit]', e.message); }
}
module.exports = { audit };
+15
View File
@@ -0,0 +1,15 @@
/**
* Multer encodes originalname as latin1 (ISO-8859-1).
* Browsers send filenames as UTF-8 bytes, so non-ASCII chars get mangled.
* This middleware re-decodes the bytes back to proper UTF-8.
*/
function fixUtf8Name(req, _res, next) {
if (req.file && req.file.originalname) {
try {
req.file.originalname = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
} catch { /* keep as-is if decoding fails */ }
}
next();
}
module.exports = { fixUtf8Name };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* ── Structured logger — JSON in prod, pretty in dev ──────────────────────
Usage: logger.info('msg', { key: val })
logger.error('msg', { err: e.message })
──────────────────────────────────────────────────────────────────────── */
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
const COLORS = {
error: '\x1b[31m', // red
warn: '\x1b[33m', // yellow
info: '\x1b[36m', // cyan
debug: '\x1b[90m', // gray
};
const RESET = '\x1b[0m';
const isProd = process.env.NODE_ENV === 'production';
function _currentLevel() {
const env = process.env.LOG_LEVEL;
if (env && LEVELS[env] !== undefined) return LEVELS[env];
return isProd ? LEVELS.info : LEVELS.debug;
}
function log(level, msg, meta) {
if (LEVELS[level] > _currentLevel()) return;
const ts = new Date().toISOString();
if (isProd) {
/* JSON — one line per entry, parseable by log aggregators */
const entry = { level, ts, msg };
if (meta && typeof meta === 'object') Object.assign(entry, meta);
process.stdout.write(JSON.stringify(entry) + '\n');
} else {
/* Pretty — coloured label + message + optional meta */
const color = COLORS[level] || '';
const label = `[${ts}] ${color}${level.toUpperCase().padEnd(5)}${RESET}`;
const metaStr = meta && Object.keys(meta).length
? ' ' + JSON.stringify(meta, null, 0)
: '';
process.stdout.write(`${label} ${msg}${metaStr}\n`);
}
}
const logger = {
error: (msg, meta) => log('error', msg, meta),
warn: (msg, meta) => log('warn', msg, meta),
info: (msg, meta) => log('info', msg, meta),
debug: (msg, meta) => log('debug', msg, meta),
/* Convenience: log an Error object */
exception: (msg, err, meta) => log('error', msg, {
err: err?.message,
stack: !isProd ? err?.stack : undefined,
...meta,
}),
};
module.exports = logger;
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/* ── Magic bytes validation (shared between file and submission uploads) ── */
const fs = require('fs');
const MAGIC = [
{ mime: 'application/pdf', bytes: [0x25,0x50,0x44,0x46], offset: 0 }, // %PDF
{ mime: 'image/png', bytes: [0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A], offset: 0 },
{ mime: 'image/jpeg', bytes: [0xFF,0xD8,0xFF], offset: 0 },
{ mime: 'image/gif', bytes: [0x47,0x49,0x46,0x38], offset: 0 }, // GIF8
{ mime: 'image/webp', bytes: [0x57,0x45,0x42,0x50], offset: 8 }, // RIFF????WEBP
// OLE2 (doc, xls, ppt)
{ mime: 'application/msword', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
{ mime: 'application/vnd.ms-excel', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
{ mime: 'application/vnd.ms-powerpoint', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
// ZIP/OOXML (docx, xlsx, pptx)
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
];
function checkMagicBytes(filePath, declaredMime) {
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
const rules = MAGIC.filter(m => m.mime === declaredMime);
if (!rules.length) return false; // unknown mime → reject
try {
const needed = Math.max(...rules.map(r => r.offset + r.bytes.length));
const buf = Buffer.alloc(needed);
const fd = fs.openSync(filePath, 'r');
const read = fs.readSync(fd, buf, 0, needed, 0);
fs.closeSync(fd);
if (read < needed) return false;
return rules.some(r => r.bytes.every((b, i) => buf[r.offset + i] === b));
} catch {
return false;
}
}
module.exports = { checkMagicBytes };

Some files were not shown because too many files have changed in this diff Show More