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:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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 для всех ошибок загрузки с кнопкой «Повторить»
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
Generated
+2043
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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],
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Seed: Математика — вопросы для тестов
|
||||
*
|
||||
* Источники:
|
||||
* — 21 вопрос ЦТ/ЦЭ (общие)
|
||||
* — 32 вопроса ЦТ 2021, Вариант 1 (A1–A18 + B1–B14)
|
||||
*
|
||||
* Запуск: 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 к. Остаток: 600−485=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
@@ -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 завершён!');
|
||||
@@ -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}`);
|
||||
@@ -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
@@ -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.');
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user