From 22c7b38e9ae1e346d33e3b929f2ce13348bcb0b6 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 23 Jun 2026 11:45:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D1=81=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20=C2=AB=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=C2=BB=20=D0=B2=20=D0=B2=D0=B5=D0=B1-=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено такое же действие, как [Z] в control-panel: POST /api/admin/reset-system (+ /reset-system/plan для предпросмотра), только admin. Общая логика вынесена в src/services/systemReset.js (classify/pickKeptAdmin/runReset) — реюзится CLI и эндпоинтом. Веб-эндпоинт безопаснее CLI: сохраняет ТЕКУЩЕГО админа (оператор остаётся залогинен), делает бэкап БД ДО сброса (wal_checkpoint + копия в data/backups/), требует body.confirm='СБРОС'. UI — «Опасная зона» в overview-секции: предпросмотр плана + ввод «СБРОС» + результат с именем бэкапа. db.js: добавлен db._path (нужен бэкапу при сбросе). Логика проверена смоуком на копии живой БД (16 юзеров удалено, контент сохранён, REASSIGN на админа, гейм-счётчики обнулены, 0 висячих FK). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/reset-system.js | 163 ++++----------------- backend/src/controllers/adminController.js | 56 ++++++- backend/src/db/db.js | 1 + backend/src/routes/admin.js | 4 + backend/src/services/systemReset.js | 141 ++++++++++++++++++ frontend/js/admin/sections/overview.js | 128 ++++++++++++++++ 6 files changed, 353 insertions(+), 140 deletions(-) create mode 100644 backend/src/services/systemReset.js diff --git a/backend/scripts/reset-system.js b/backend/scripts/reset-system.js index 8d6600f..4c86188 100644 --- a/backend/scripts/reset-system.js +++ b/backend/scripts/reset-system.js @@ -1,174 +1,61 @@ 'use strict'; /* ─────────────────────────────────────────────────────────────────────────── - reset-system.js — «ЧИСТЫЙ ЗАПУСК»: убрать всех пользователей и их активность, - СОХРАНИВ весь контент, настройки/права и одного админа. + reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js). - ⚠️ ДЕСТРУКТИВНО. Перед запуском СДЕЛАЙТЕ БЭКАП (control-panel «Бэкап БД» делает - это автоматически перед вызовом). По умолчанию — DRY-RUN (только показывает план). - - Что делает (--apply --confirm=RESET): - • Сохраняет ОДНОГО админа (min id среди role='admin') — чтобы не залочиться. - • REASSIGN: авторский контент (courses/tests/flashcard_decks/custom_sims/шаблоны/ - библиотека/lab-ссылки/board-шаблоны) переписывается на сохранённого админа — - иначе при удалении автора он бы каскадно удалился. - • WIPE: все аккаунты (кроме админа), классы, задания, сессии, сдачи, геймификация, - уведомления, прогресс, материалы, история классрума/викторин, доступы, логи. - • KEEP: учебники, вопросы, темы, уроки, exam-prep, симуляции, биохимия, красная - книга, магазин-товары, достижения-определения, роли/права, app_settings. - • Сбрасывает игровые счётчики у сохранённого админа (xp/coins/стрик/…). - • Неизвестные (не классифицированные) таблицы НЕ трогает + предупреждает. + ⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET. + Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически). + Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system). Запуск: - node backend/scripts/reset-system.js # DRY-RUN (план) + node backend/scripts/reset-system.js # план node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить ─────────────────────────────────────────────────────────────────────────── */ const { DatabaseSync } = require('node:sqlite'); const path = require('path'); +const reset = require('../src/services/systemReset'); const APPLY = process.argv.includes('--apply'); const CONFIRM = process.argv.includes('--confirm=RESET'); -/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */ -const REASSIGN = { - courses: 'created_by', tests: 'created_by', - flashcard_decks: 'user_id', custom_sims: 'owner_id', - course_templates: 'created_by', lesson_templates: 'created_by', - assignment_templates: 'created_by', lab_sim_links: 'created_by', - classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by', -}; - -/* Активность/организация — полностью очищается. */ -const WIPE = new Set([ - 'test_sessions', 'session_questions', 'user_answers', - 'exam_attempts', 'exam_mock_sessions', 'exam_user_plan', - 'assignments', 'assignment_sessions', 'assignment_completion', - 'submissions', 'submission_log', - 'classes', 'class_members', 'class_courses', - 'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions', - 'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted', - 'classroom_notes', 'classroom_pages', 'classroom_strokes', - 'live_sessions', 'live_answers', - 'content_access', - 'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases', - 'notifications', 'parent_notifications', 'parent_links', - 'student_materials', 'material_collections', - 'game_progress', - 'lesson_progress', 'lesson_comments', 'lesson_notes', - 'textbook_progress', 'textbook_bookmarks', 'bookmarks', - 'flashcard_reviews', 'flashcard_deck_access', - 'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway', - 'rb_user_collection', 'rb_user_quests', 'rb_sightings', - 'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache', - 'imggen_usage', - 'folder_access', 'file_access', - 'avatar_requests', - 'geometry_submissions', 'geometry_tasks', - 'security_events', 'error_log', 'admin_audit_log', - 'student_prep', - 'announcements', 'teacher_students', 'user_permissions', 'user_preferences', -]); - -/* Контент/конфиг — НЕ трогаем (явный список для контроля «неизвестных» таблиц). */ -const KEEP = new Set([ - 'subjects', 'questions', 'options', 'topics', - 'textbooks', 'textbook_chunks', - 'lessons', 'lesson_blocks', 'course_sections', - 'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests', - 'test_questions', 'flashcard_cards', 'lab_sims', - 'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions', - 'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions', - 'shop_items', 'achievements', - 'roles', 'role_permissions', 'app_settings', '_migrations', -]); - -/* Игровые счётчики сохранённого админа — обнуляем (чистый старт). */ -const ADMIN_RESET_SQL = - `UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0, - streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0, - pet_petting_streak = 0 WHERE id = ?`; - const DB = path.join(__dirname, '..', 'data', 'learnspace.db'); const db = new DatabaseSync(DB); -/* Сохраняемый админ — наименьший id среди админов. */ -const keptAdmin = db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); +const keptAdmin = reset.pickKeptAdmin(db); if (!keptAdmin) { console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.'); - db.close(); - process.exit(1); + db.close(); process.exit(1); } -const allTables = db.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" -).all().map(r => r.name); - -const cnt = t => { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return '?'; } }; - -/* Классификация + предупреждение о неизвестных. */ -const unknown = allTables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t)); - -console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'APPLY без --confirm=RESET → отказ') : 'DRY-RUN'}) ===`); +const plan = reset.classify(db); +console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`); console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`); -const totalUsers = cnt('users'); -console.log(`Пользователей сейчас: ${totalUsers} → останется 1 (админ), удалится ${totalUsers - 1}\n`); - +console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`); console.log('REASSIGN (контент → админу):'); -for (const [t, col] of Object.entries(REASSIGN)) console.log(` ${t.padEnd(22)} ${col.padEnd(12)} строк: ${cnt(t)}`); +plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`)); console.log('\nWIPE (полная очистка):'); -let wipeTotal = 0; -for (const t of WIPE) { const c = cnt(t); if (typeof c === 'number') wipeTotal += c; console.log(` ${t.padEnd(28)} строк: ${c}`); } -console.log(` — всего строк к удалению (без каскада users): ~${wipeTotal}`); -console.log(`\nKEEP (контент/конфиг сохраняется): ${KEEP.size} таблиц.`); -if (unknown.length) { - console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем — проверьте вручную): ${unknown.join(', ')}`); -} +plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`)); +console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`); +console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`); +if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`); if (!APPLY) { - console.log('\nDRY-RUN: ничего не изменено. Для выполнения: node backend/scripts/reset-system.js --apply --confirm=RESET\n'); - db.close(); - process.exit(0); + console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n'); + db.close(); process.exit(0); } if (!CONFIRM) { console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.'); - db.close(); - process.exit(1); + db.close(); process.exit(1); } -/* ── Выполнение ── */ -db.exec('PRAGMA foreign_keys = OFF'); // снимаем каскады — управляем удалением вручную, детерминированно -db.exec('BEGIN'); try { - // 1) Переназначить контент на сохранённого админа - for (const [t, col] of Object.entries(REASSIGN)) { - try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdmin.id, keptAdmin.id); } - catch (e) { console.error(` ! reassign ${t}: ${e.message}`); } - } - // 2) Очистить активность/организацию - for (const t of WIPE) { - try { db.prepare(`DELETE FROM "${t}"`).run(); } - catch (e) { console.error(` ! wipe ${t}: ${e.message}`); } - } - // 3) Удалить всех пользователей, кроме сохранённого админа - db.prepare('DELETE FROM users WHERE id != ?').run(keptAdmin.id); - // 4) Обнулить игровые счётчики у админа - db.prepare(ADMIN_RESET_SQL).run(keptAdmin.id); - db.exec('COMMIT'); + const res = reset.runReset(db, keptAdmin.id); + console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`); + console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`); + if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`); + console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`); } catch (e) { - db.exec('ROLLBACK'); - db.exec('PRAGMA foreign_keys = ON'); console.error('\n✗ Ошибка — откат, изменений нет:', e.message); - db.close(); - process.exit(1); + db.close(); process.exit(1); } -db.exec('PRAGMA foreign_keys = ON'); - -/* Проверка целостности + сжатие файла. */ -const fkBad = db.prepare('PRAGMA foreign_key_check').all(); -if (fkBad.length) console.log(`⚠️ foreign_key_check: ${fkBad.length} висячих ссылок (в неизвестных таблицах?) — проверьте.`); -try { db.exec('VACUUM'); } catch {} - -console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Осталось пользователей: ${cnt('users')} (только админ id=${keptAdmin.id}).`); -console.log(`✓ Контент сохранён: учебники ${cnt('textbooks')}, вопросы ${cnt('questions')}, тесты ${cnt('tests')}, курсы ${cnt('courses')}, exam-prep ${cnt('exam_tasks')}.`); -console.log(`\nВойдите под админом ${keptAdmin.email}. Не забудьте перезапустить сервер.\n`); db.close(); diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 8c37f9c..cfcea37 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -1,7 +1,10 @@ const db = require('../db/db'); +const fs = require('fs'); +const path = require('path'); const { stripTags } = require('../utils/sanitize'); const { audit } = require('../utils/audit'); const { purgeAccessFor } = require('../services/contentAccess'); +const sysReset = require('../services/systemReset'); /* ── Prepared statements ──────────────────────────────────────────────── */ const stmts = { @@ -586,6 +589,56 @@ function updateFreeStudentFeatures(req, res) { res.json({ ok: true }); } +/* ── GET /api/admin/reset-system/plan ────────────────────────────────── + План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */ +function getResetPlan(req, res) { + try { + const plan = sysReset.classify(db); + // Текущий админ остаётся залогиненным — сохраняем именно его, не min-id. + res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } }); + } catch (e) { + res.status(500).json({ error: 'Не удалось построить план: ' + e.message }); + } +} + +/* ── POST /api/admin/reset-system ────────────────────────────────────── + ⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET'). + Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе), + стирает остальных пользователей + активность, переназначает контент. */ +function resetSystem(req, res) { + const confirm = (req.body && req.body.confirm) || ''; + if (confirm !== 'СБРОС' && confirm !== 'RESET') { + return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' }); + } + const keptId = req.user.id; + // 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла). + let backupName = null; + try { + const dbPath = db._path; + if (!dbPath) throw new Error('путь к БД неизвестен'); + const backupsDir = path.join(path.dirname(dbPath), 'backups'); + if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); + try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ } + const d = new Date(); + const p2 = n => String(n).padStart(2, '0'); + const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`; + backupName = `learnspace-prereset-${ts}.db`; + fs.copyFileSync(dbPath, path.join(backupsDir, backupName)); + } catch (e) { + return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message }); + } + // 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы). + let summary; + try { + summary = sysReset.runReset(db, keptId); + } catch (e) { + return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName }); + } + // 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью). + try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {} + res.json({ ok: true, backup: backupName, ...summary }); +} + /* ── GET /api/admin/audit-log ───────────────────────────────────────── */ function getAuditLog(req, res) { const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); @@ -659,8 +712,6 @@ function clearSecurityLog(req, res) { /* ── GET /api/admin/health ─────────────────────────────────────────── */ const os = require('os'); -const path = require('path'); -const fs = require('fs'); const { execSync } = require('child_process'); const { monitorEventLoopDelay } = require('perf_hooks'); const sse = require('../sse'); @@ -1157,6 +1208,7 @@ module.exports = { getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, clearUserSessions, deleteSession, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, + getResetPlan, resetSystem, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics, getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, diff --git a/backend/src/db/db.js b/backend/src/db/db.js index 6a600b6..e4b1303 100644 --- a/backend/src/db/db.js +++ b/backend/src/db/db.js @@ -48,4 +48,5 @@ db.transaction = function transaction(fn) { }; }; +db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы) module.exports = db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 685e2e7..cbb7d57 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -13,6 +13,10 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF /* Everything below is admin-only */ router.use(requireRole('admin')); +/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */ +router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan); +router.post('/reset-system', requireRole('admin'), ctrl.resetSystem); + router.get('/assistant', ctrl.getAssistant); router.put('/assistant', ctrl.saveAssistant); router.post('/assistant/test', ctrl.testAssistant); diff --git a/backend/src/services/systemReset.js b/backend/src/services/systemReset.js new file mode 100644 index 0000000..c5e7945 --- /dev/null +++ b/backend/src/services/systemReset.js @@ -0,0 +1,141 @@ +'use strict'; +/* ─────────────────────────────────────────────────────────────────────────── + systemReset.js — общая логика «чистого запуска» (используют и CLI + backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system). + + ⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД. + + Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех + остальных пользователей + всю активность/организацию, сохранить контент/конфиг. + Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем. + ─────────────────────────────────────────────────────────────────────────── */ + +/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */ +const REASSIGN = { + courses: 'created_by', tests: 'created_by', + flashcard_decks: 'user_id', custom_sims: 'owner_id', + course_templates: 'created_by', lesson_templates: 'created_by', + assignment_templates: 'created_by', lab_sim_links: 'created_by', + classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by', +}; + +/* Активность/организация — полностью очищается. */ +const WIPE = new Set([ + 'test_sessions', 'session_questions', 'user_answers', + 'exam_attempts', 'exam_mock_sessions', 'exam_user_plan', + 'assignments', 'assignment_sessions', 'assignment_completion', + 'submissions', 'submission_log', + 'classes', 'class_members', 'class_courses', + 'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions', + 'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted', + 'classroom_notes', 'classroom_pages', 'classroom_strokes', + 'live_sessions', 'live_answers', + 'content_access', + 'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases', + 'notifications', 'parent_notifications', 'parent_links', + 'student_materials', 'material_collections', + 'game_progress', + 'lesson_progress', 'lesson_comments', 'lesson_notes', + 'textbook_progress', 'textbook_bookmarks', 'bookmarks', + 'flashcard_reviews', 'flashcard_deck_access', + 'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway', + 'rb_user_collection', 'rb_user_quests', 'rb_sightings', + 'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache', + 'imggen_usage', + 'folder_access', 'file_access', + 'avatar_requests', + 'geometry_submissions', 'geometry_tasks', + 'security_events', 'error_log', 'admin_audit_log', + 'student_prep', + 'announcements', 'teacher_students', 'user_permissions', 'user_preferences', +]); + +/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */ +const KEEP = new Set([ + 'subjects', 'questions', 'options', 'topics', + 'textbooks', 'textbook_chunks', + 'lessons', 'lesson_blocks', 'course_sections', + 'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests', + 'test_questions', 'flashcard_cards', 'lab_sims', + 'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions', + 'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions', + 'shop_items', 'achievements', + 'roles', 'role_permissions', 'app_settings', '_migrations', +]); + +const ADMIN_RESET_SQL = + `UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0, + streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0, + pet_petting_streak = 0 WHERE id = ?`; + +function allTables(db) { + return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .all().map(r => r.name); +} +function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } } + +/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */ +function pickKeptAdmin(db) { + return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null; +} + +/** План (без изменений): что переназначится / сотрётся / неизвестно. */ +function classify(db) { + const tables = allTables(db); + const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) })); + const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) })); + const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t)); + const totalUsers = rowCount(db, 'users'); + const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0); + return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows }; +} + +/** + * Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа, + * которого сохраняем (ему переназначается контент). Возвращает сводку. + * ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова. + */ +function runReset(db, keptAdminId) { + const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId); + if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом'); + + db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно + db.exec('BEGIN'); + try { + for (const [t, col] of Object.entries(REASSIGN)) { + try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ } + } + for (const t of WIPE) { + try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ } + } + const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId); + db.prepare(ADMIN_RESET_SQL).run(keptAdminId); + db.exec('COMMIT'); + var deletedUsers = del.changes; + } catch (e) { + db.exec('ROLLBACK'); + db.exec('PRAGMA foreign_keys = ON'); + throw e; + } + db.exec('PRAGMA foreign_keys = ON'); + let fkBad = 0; + try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {} + try { db.exec('VACUUM'); } catch {} + + return { + ok: true, + keptAdminId, + deletedUsers, + remainingUsers: rowCount(db, 'users'), + fkDangling: fkBad, + kept: { + textbooks: rowCount(db, 'textbooks'), + questions: rowCount(db, 'questions'), + tests: rowCount(db, 'tests'), + courses: rowCount(db, 'courses'), + exam_tasks: rowCount(db, 'exam_tasks'), + }, + }; +} + +module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset }; diff --git a/frontend/js/admin/sections/overview.js b/frontend/js/admin/sections/overview.js index 5054829..e225b0d 100644 --- a/frontend/js/admin/sections/overview.js +++ b/frontend/js/admin/sections/overview.js @@ -452,6 +452,24 @@ Audit log + +
Опасная зона
+
+
+
+ Сброс системы «чистый запуск» +
+
+ Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и + историю. Учебники, вопросы, тесты, курсы и настройки сохраняются — авторский контент + переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД. + Действие необратимо. +
+ +
`; /* ── wire quick-links via event delegation ───────────────── */ @@ -459,9 +477,119 @@ btn.addEventListener('click', function () { navigateTo(btn.dataset.go); }); }); + const resetBtn = el.querySelector('#ov-reset-system-btn'); + if (resetBtn) resetBtn.addEventListener('click', openResetModal); + if (window.lucide) lucide.createIcons({ nodes: [el] }); } + /* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */ + async function openResetModal() { + const e = LS.esc; + const m = LS.modal({ + title: 'Сброс системы — чистый запуск', + size: 'md', + content: '
Загрузка плана…
', + actions: [{ label: 'Отмена' }], + }); + + let plan; + try { + plan = await LS.api('/api/admin/reset-system/plan'); + } catch (err) { + m.setBody('
Не удалось загрузить план: ' + e(err.message) + '
'); + return; + } + + const kept = plan.keptAdmin || {}; + const delUsers = Math.max(0, (plan.totalUsers || 0) - 1); + const wipeRows = plan.wipeRows || 0; + const reassignRows = (plan.reassign || []).reduce(function (a, r) { + return a + (typeof r.rows === 'number' ? r.rows : 0); + }, 0); + const unknownNote = (plan.unknown && plan.unknown.length) + ? '
' + + 'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '
' + : ''; + + m.setBody( + '
' + + '
' + + 'Это действие необратимо. Перед сбросом будет создан бэкап БД.' + + '
' + + '
Останется один администратор:
' + + '
' + + '' + e(kept.name || '—') + ' · ' + e(kept.email || '') + + ' (вы)
' + + '
    ' + + '
  • Удалится пользователей: ' + delUsers + '
  • ' + + '
  • Очистится записей активности/организации: ~' + wipeRows + '
  • ' + + '
  • Контента переназначится на вас: ' + reassignRows + ' записей
  • ' + + '
  • Сохранится контент-таблиц: ' + (plan.keepCount || 0) + '
  • ' + + '
' + + unknownNote + + '
Для подтверждения введите СБРОС:
' + + '' + + '
' + ); + + const inp = m.body.querySelector('#ov-reset-confirm-inp'); + function syncBtn() { + const ok = inp && inp.value.trim() === 'СБРОС'; + const btn = document.getElementById('ov-reset-go'); + if (btn) btn.disabled = !ok; + } + + function setReadyActions() { + m.setActions([ + { label: 'Отмена' }, + { + label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false, + onClick: doReset, + }, + ]); + const btn = document.getElementById('ov-reset-go'); + if (btn) btn.disabled = true; + } + + async function doReset() { + const btn = document.getElementById('ov-reset-go'); + if (!inp || inp.value.trim() !== 'СБРОС') return; + if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; } + m.setError(''); + let res; + try { + res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } }); + } catch (err) { + m.setError('Ошибка: ' + (err.message || 'сброс не выполнен')); + if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; } + return; + } + m.setBody( + '
' + + '
' + + '
' + + '
Система сброшена
' + + '
' + + 'Удалено пользователей: ' + (res.deletedUsers || 0) + ', осталось: ' + + (res.remainingUsers || 1) + '.
' + + 'Бэкап сохранён: ' + LS.esc(res.backup || '—') + '' + + (res.fkDangling ? '
Висячих ссылок: ' + res.fkDangling + '' : '') + + '
' + + '
' + ); + m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]); + if (window.lucide) lucide.createIcons({ nodes: [m.body] }); + } + + setReadyActions(); + if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); } + } + async function load() { const el = document.getElementById('overview-content'); if (!el) return;