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 + +
' + LS.esc(res.backup || '—') + '' +
+ (res.fkDangling ? '