diff --git a/backend/package.json b/backend/package.json index 7d48806..93ebe90 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,9 @@ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", - "migrate": "node src/db/migrate.js", + "migrate": "node src/db/migrations-runner.js", + "migrate:bootstrap": "node src/db/migrations-runner.js bootstrap", + "migrate:legacy": "node src/db/migrate.js", "seed": "node src/db/seed.js", "seed:permissions": "node src/db/seed-permissions.js", "lint:routes": "node scripts/check-route-auth.js", diff --git a/backend/src/db/migrations-runner.js b/backend/src/db/migrations-runner.js new file mode 100644 index 0000000..52acbe6 --- /dev/null +++ b/backend/src/db/migrations-runner.js @@ -0,0 +1,83 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +const MIGRATIONS_DIR = path.join(__dirname, 'migrations'); + +function init() { + db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} + +function listFiles() { + if (!fs.existsSync(MIGRATIONS_DIR)) return []; + return fs.readdirSync(MIGRATIONS_DIR) + .filter(f => f.endsWith('.sql')) + .sort(); +} + +function applied() { + return new Set(db.prepare('SELECT filename FROM _migrations').all().map(r => r.filename)); +} + +function run() { + init(); + const done = applied(); + const files = listFiles(); + const pending = files.filter(f => !done.has(f)); + + if (pending.length === 0) { + console.log('[migrate] Nothing to apply β€” schema is up to date'); + return; + } + + for (const file of pending) { + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + console.log(`[migrate] Applying ${file}...`); + try { + db.exec('BEGIN'); + db.exec(sql); + db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run(file); + db.exec('COMMIT'); + console.log(`[migrate] OK: ${file}`); + } catch (e) { + db.exec('ROLLBACK'); + console.error(`[migrate] FAILED at ${file}: ${e.message}`); + process.exit(1); + } + } + + console.log(`[migrate] Applied ${pending.length} migration(s)`); +} + +/* Mark 000_baseline.sql as applied on existing DBs without running it. + Run once per environment after first deploy of this system. */ +function markBaseline() { + init(); + const hasUsers = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get(); + if (hasUsers) { + const done = applied(); + if (!done.has('000_baseline.sql')) { + db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run('000_baseline.sql'); + console.log('[migrate] Marked 000_baseline.sql as applied (existing DB β€” not re-run)'); + } else { + console.log('[migrate] 000_baseline.sql already marked as applied'); + } + } else { + console.log('[migrate] No users table found β€” run `npm run migrate` first to initialize schema'); + process.exit(1); + } +} + +if (require.main === module) { + const cmd = process.argv[2]; + if (cmd === 'bootstrap') markBaseline(); + else run(); +} + +module.exports = { run, markBaseline }; diff --git a/backend/src/db/migrations/000_baseline.sql b/backend/src/db/migrations/000_baseline.sql new file mode 100644 index 0000000..33bae6d --- /dev/null +++ b/backend/src/db/migrations/000_baseline.sql @@ -0,0 +1,1017 @@ +CREATE TABLE achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT 'πŸ†', + category TEXT NOT NULL DEFAULT 'general', + description TEXT + ); + +CREATE TABLE admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target TEXT, + detail TEXT, + ip TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ); + +CREATE TABLE "assignment_sessions" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id INTEGER REFERENCES test_sessions(id) ON DELETE SET NULL, + attempt_num INTEGER NOT NULL DEFAULT 1, + first_seen_at TEXT + ); + +CREATE TABLE assignment_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + subject_slug TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'exam', + count INTEGER NOT NULL DEFAULT 25, + topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL, + test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL, + file_id INTEGER REFERENCES files(id) ON DELETE SET NULL, + is_homework INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE "assignments" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER REFERENCES classes(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + subject_slug TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'exam', + count INTEGER NOT NULL DEFAULT 25, + topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL, + deadline TEXT, + created_by INTEGER NOT NULL REFERENCES users(id), + test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , file_id INTEGER REFERENCES files(id) ON DELETE SET NULL, is_homework INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE avatar_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_id INTEGER REFERENCES users(id), + reject_msg TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT + ); + +CREATE TABLE bio_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'build', + target_formula TEXT NOT NULL DEFAULT '', + hint TEXT DEFAULT NULL, + xp_reward INTEGER NOT NULL DEFAULT 50, + difficulty INTEGER NOT NULL DEFAULT 1, + topic_tag TEXT DEFAULT NULL, + order_n INTEGER NOT NULL DEFAULT 0 + , data_json TEXT DEFAULT NULL); + +CREATE TABLE bio_elements ( + symbol TEXT PRIMARY KEY, + name_ru TEXT NOT NULL, + valency_max INTEGER NOT NULL DEFAULT 1, + valency_opts TEXT NOT NULL DEFAULT '[1]', + color TEXT NOT NULL DEFAULT '#888888', + radius INTEGER NOT NULL DEFAULT 20, + mass REAL NOT NULL DEFAULT 1.0 + ); + +CREATE TABLE bio_molecules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + formula TEXT NOT NULL, + name_ru TEXT NOT NULL, + name_lat TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'inorganic', + difficulty INTEGER NOT NULL DEFAULT 1, + description TEXT NOT NULL DEFAULT '', + atoms_json TEXT NOT NULL DEFAULT '[]', + bonds_json TEXT NOT NULL DEFAULT '[]', + is_library INTEGER NOT NULL DEFAULT 1, + topic_tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE bio_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + equation TEXT NOT NULL, + name_ru TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'synthesis', + description TEXT NOT NULL DEFAULT '', + reactant_ids TEXT NOT NULL DEFAULT '[]', + product_ids TEXT NOT NULL DEFAULT '[]', + conditions TEXT DEFAULT NULL, + energy_kj REAL DEFAULT NULL, + topic_tags TEXT NOT NULL DEFAULT '[]' + ); + +CREATE TABLE bio_user_challenges ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge_id INTEGER NOT NULL REFERENCES bio_challenges(id) ON DELETE CASCADE, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, challenge_id) + ); + +CREATE TABLE bio_user_molecules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + molecule_id INTEGER REFERENCES bio_molecules(id), + name TEXT DEFAULT NULL, + formula TEXT NOT NULL, + atoms_json TEXT NOT NULL, + bonds_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL CHECK (entity_type IN ('lesson','course','file','question')), + entity_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, entity_type, entity_id) + ); + +CREATE TABLE challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'tests', + target INTEGER NOT NULL DEFAULT 3, + progress INTEGER NOT NULL DEFAULT 0, + xp_reward INTEGER NOT NULL DEFAULT 100, + subject_slug TEXT, + topic_id INTEGER, + completed INTEGER NOT NULL DEFAULT 0, + claimed INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, week, title) + ); + +CREATE TABLE class_courses ( + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + deadline TEXT, + assigned_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + assigned_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (class_id, course_id) + ); + +CREATE TABLE class_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (class_id, user_id) + ); + +CREATE TABLE classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + teacher_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invite_code TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , features TEXT, cover_emoji TEXT NOT NULL DEFAULT ''); + +CREATE TABLE classroom_attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + left_at TEXT, + UNIQUE(session_id, user_id) + ); + +CREATE TABLE classroom_chat ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , pinned INTEGER NOT NULL DEFAULT 0, attachment_url TEXT, attachment_type TEXT); + +CREATE TABLE classroom_chat_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL REFERENCES classroom_chat(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + reaction TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(chat_id, user_id, reaction) + ); + +CREATE TABLE classroom_draw_permissions ( + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + PRIMARY KEY (session_id, user_id) + ); + +CREATE TABLE classroom_hands ( + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + PRIMARY KEY (session_id, user_id) + ); + +CREATE TABLE classroom_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + UNIQUE(session_id, user_id) + ); + +CREATE TABLE classroom_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(session_id, user_id) + ); + +CREATE TABLE classroom_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + page_num INTEGER NOT NULL DEFAULT 1, + bg_color TEXT NOT NULL DEFAULT '#ffffff', template TEXT NOT NULL DEFAULT 'blank', name TEXT, + UNIQUE(session_id, page_num) + ); + +CREATE TABLE classroom_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER REFERENCES classes(id) ON DELETE CASCADE, + teacher_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + current_page INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT + , board_theme TEXT NOT NULL DEFAULT 'chalkboard'); + +CREATE TABLE classroom_strokes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + page_num INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users(id), + tool TEXT NOT NULL DEFAULT 'pencil', + data TEXT NOT NULL, + seq INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE classroom_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + teacher_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + description TEXT DEFAULT '', + pages_data TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE course_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0 + ); + +CREATE TABLE course_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'general', + subject_slug TEXT, + structure TEXT NOT NULL DEFAULT '{}', + is_public INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE courses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_slug TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + cover_emoji TEXT NOT NULL DEFAULT 'πŸ“š', + order_index INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE daily_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + tests_target INTEGER NOT NULL DEFAULT 3, + tests_done INTEGER NOT NULL DEFAULT 0, + xp_target INTEGER NOT NULL DEFAULT 200, + xp_earned INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, date) + ); + +CREATE TABLE error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL DEFAULT 'error', + message TEXT NOT NULL, + stack TEXT, + route TEXT, + method TEXT, + user_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE file_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('class', 'user')), + target_id INTEGER NOT NULL, + UNIQUE (file_id, type, target_id) + ); + +CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL UNIQUE, + mimetype TEXT, + size INTEGER, + subject_slug TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + uploaded_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , folder_id INTEGER); + +CREATE TABLE flashcard_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, + front TEXT NOT NULL DEFAULT '', + back TEXT NOT NULL DEFAULT '', + order_idx INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE flashcard_decks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#9B5DE5', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE flashcard_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + card_id INTEGER NOT NULL REFERENCES flashcard_cards(id) ON DELETE CASCADE, + ease_factor REAL NOT NULL DEFAULT 2.5, + interval_days INTEGER NOT NULL DEFAULT 1, + repetitions INTEGER NOT NULL DEFAULT 0, + due_at TEXT NOT NULL DEFAULT (datetime('now')), + last_reviewed TEXT, + UNIQUE(user_id, card_id) + ); + +CREATE TABLE folder_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('class', 'user')), + target_id INTEGER NOT NULL, + UNIQUE (folder_id, type, target_id) + ); + +CREATE TABLE folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE geometry_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES geometry_tasks(id) ON DELETE CASCADE, + student_id INTEGER NOT NULL REFERENCES users(id), + state_json TEXT NOT NULL DEFAULT '{}', + score REAL DEFAULT NULL, + feedback TEXT DEFAULT '', + submitted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(task_id, student_id) + ); + +CREATE TABLE geometry_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + teacher_id INTEGER NOT NULL REFERENCES users(id), + class_id INTEGER REFERENCES classes(id) ON DELETE SET NULL, + title TEXT NOT NULL DEFAULT 'Π‘Π΅Π· названия', + description TEXT DEFAULT '', + state_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE "lesson_blocks" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'text' + CHECK (type IN ('heading','text','formula','image','quiz','sim', + 'table','code','divider','callout','video','flashcard', + 'matching','fill-blank','ordering', + 'accordion','timeline','diagram','geogebra','audio','columns','alert')), + order_index INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + +CREATE TABLE lesson_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES lesson_comments(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE lesson_notes ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + text TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, lesson_id) + ); + +CREATE TABLE lesson_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + completed INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, lesson_id) + ); + +CREATE TABLE lesson_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'general', + subject_slug TEXT, + blocks TEXT NOT NULL DEFAULT '[]', + is_public INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , section_id INTEGER REFERENCES course_sections(id) ON DELETE SET NULL, read_time INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE "live_answers" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + live_session_id INTEGER NOT NULL REFERENCES live_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + question_id INTEGER REFERENCES questions(id) ON DELETE SET NULL, + option_id INTEGER REFERENCES options(id), + answer_text TEXT, + is_correct INTEGER, + answered_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(live_session_id, user_id, question_id) + ); + +CREATE TABLE live_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + teacher_id INTEGER NOT NULL REFERENCES users(id), + question_id INTEGER REFERENCES questions(id), + status TEXT NOT NULL DEFAULT 'waiting', + show_results INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT + ); + +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + link TEXT, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + text TEXT NOT NULL, + is_correct INTEGER NOT NULL DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0 + , match_pair TEXT); + +CREATE TABLE parent_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + label TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT + ); + +CREATE TABLE parent_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_link_id INTEGER NOT NULL REFERENCES parent_links(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_id INTEGER NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL, + text TEXT NOT NULL, + difficulty INTEGER NOT NULL DEFAULT 1 CHECK (difficulty BETWEEN 1 AND 3), + year INTEGER, + explanation TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , type TEXT NOT NULL DEFAULT 'single', correct_text TEXT, image TEXT, source_type TEXT NOT NULL DEFAULT 'Π±Π°Π·ΠΎΠ²Ρ‹ΠΉ'); + +CREATE TABLE rb_food_web ( + predator_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + prey_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + strength REAL NOT NULL DEFAULT 0.5, + PRIMARY KEY (predator_id, prey_id) + ); + +CREATE TABLE rb_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name_ru TEXT NOT NULL, + name_lat TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT '🌿', + color TEXT NOT NULL DEFAULT '#16a34a' + ); + +CREATE TABLE rb_habitats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'forest', + description TEXT NOT NULL DEFAULT '', + sound_file TEXT NOT NULL DEFAULT '' + ); + +CREATE TABLE rb_population_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + count_estimate INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '' + ); + +CREATE TABLE rb_quests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + species_ids TEXT NOT NULL DEFAULT '[]', + xp_reward INTEGER NOT NULL DEFAULT 150, + badge_slug TEXT NOT NULL DEFAULT '' + ); + +CREATE TABLE rb_sightings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + region_code TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + photo_url TEXT NOT NULL DEFAULT '', + confirmed_by_teacher INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE rb_species ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES rb_groups(id), + habitat_id INTEGER REFERENCES rb_habitats(id), + name_ru TEXT NOT NULL, + name_be TEXT NOT NULL DEFAULT '', + name_lat TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'VU' + CHECK (category IN ('CR','EN','VU','NT','LC')), + by_category TEXT NOT NULL DEFAULT 'III', + description TEXT NOT NULL DEFAULT '', + interesting_fact TEXT NOT NULL DEFAULT '', + threats TEXT NOT NULL DEFAULT '[]', + conservation TEXT NOT NULL DEFAULT '', + where_to_see TEXT NOT NULL DEFAULT '', + photo_url TEXT NOT NULL DEFAULT '', + model_type TEXT NOT NULL DEFAULT 'silhouette' + CHECK (model_type IN ('procedural','silhouette','none')), + population_trend TEXT NOT NULL DEFAULT '[]', + biomass_kg REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , season_active TEXT NOT NULL DEFAULT '', name_be_short TEXT NOT NULL DEFAULT ''); + +CREATE TABLE rb_species_regions ( + species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + region_code TEXT NOT NULL + CHECK (region_code IN ('brest','vitebsk','gomel','grodno','minsk','mogilev')), + PRIMARY KEY (species_id, region_code) + ); + +CREATE TABLE rb_user_collection ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE, + unlock_method TEXT NOT NULL DEFAULT 'explore', + notes TEXT NOT NULL DEFAULT '', + unlocked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, species_id) + ); + +CREATE TABLE rb_user_quests ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + quest_id INTEGER NOT NULL REFERENCES rb_quests(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','completed')), + progress TEXT NOT NULL DEFAULT '{}', + completed_at TEXT, + PRIMARY KEY (user_id, quest_id) + ); + +CREATE TABLE "role_permissions" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL CHECK (role IN ('teacher', 'student', 'free_student')), + permission TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + UNIQUE (role, permission) + ); + +CREATE TABLE session_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id), + order_index INTEGER NOT NULL + ); + +CREATE TABLE shop_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'frame' + CHECK (type IN ('frame','theme','title','effect')), + category TEXT NOT NULL DEFAULT 'cosmetic', + price INTEGER NOT NULL DEFAULT 100, + data TEXT NOT NULL DEFAULT '{}', + icon TEXT NOT NULL DEFAULT 'star', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE subjects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + icon TEXT + , default_mode TEXT NOT NULL DEFAULT 'exam', default_count INTEGER NOT NULL DEFAULT 25, default_test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL); + +CREATE TABLE submission_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER NOT NULL, + class_id INTEGER, + assignment_id INTEGER, + student_id INTEGER NOT NULL, + student_name TEXT, + original_name TEXT, + status TEXT, + grade INTEGER, + teacher_note TEXT, + submitted_at TEXT, + action TEXT NOT NULL DEFAULT 'deleted', + deleted_by INTEGER NOT NULL, + deleted_by_role TEXT, + deleted_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE "submissions" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + assignment_id INTEGER REFERENCES assignments(id) ON DELETE SET NULL, + student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL UNIQUE, + mimetype TEXT, + size INTEGER, + message TEXT, + status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','reviewed','revision','resubmitted','accepted')), + teacher_note TEXT, + grade INTEGER, + reviewed_at TEXT, + submitted_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE test_questions ( + test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + order_index INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (test_id, question_id) + ); + +CREATE TABLE test_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject_id INTEGER REFERENCES subjects(id) ON DELETE SET NULL, + mode TEXT NOT NULL DEFAULT 'exam' + CHECK (mode IN ('exam', 'practice', 'topic', 'random')), + total INTEGER NOT NULL, + score INTEGER, + status TEXT NOT NULL DEFAULT 'in_progress' + CHECK (status IN ('in_progress', 'completed', 'abandoned')), + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + +CREATE TABLE tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + subject_slug TEXT NOT NULL, + description TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + , show_answers INTEGER NOT NULL DEFAULT 1, time_limit INTEGER); + +CREATE TABLE topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_id INTEGER NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0 + ); + +CREATE TABLE user_achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + achievement_id INTEGER NOT NULL REFERENCES achievements(id) ON DELETE CASCADE, + unlocked_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, achievement_id) + ); + +CREATE TABLE user_answers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id), + chosen_option_id INTEGER REFERENCES options(id), + is_correct INTEGER, + time_spent_sec INTEGER, + answered_at TEXT NOT NULL DEFAULT (datetime('now')), answer_text TEXT, + UNIQUE (session_id, question_id) + ); + +CREATE TABLE user_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, permission) + ); + +CREATE TABLE user_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE TABLE user_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES shop_items(id) ON DELETE CASCADE, + purchased_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, item_id) + ); + +CREATE TABLE "users" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'student' + CHECK (role IN ('student','teacher','admin','free_student')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT, + xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 1, + streak_current INTEGER NOT NULL DEFAULT 0, + streak_best INTEGER NOT NULL DEFAULT 0, + streak_date TEXT, + goal_tier TEXT DEFAULT 'medium', + avatar_frame TEXT DEFAULT 'default', + coins INTEGER NOT NULL DEFAULT 0, + active_title TEXT DEFAULT NULL, + active_theme TEXT DEFAULT NULL, + active_effect TEXT DEFAULT NULL, + token_version INTEGER NOT NULL DEFAULT 0, + lab_experiments INTEGER NOT NULL DEFAULT 0, + lab_reactions INTEGER NOT NULL DEFAULT 0, + is_banned INTEGER NOT NULL DEFAULT 0 + , pet_name TEXT, pet_color TEXT DEFAULT 'purple', pet_last_petted TEXT, pet_petting_streak INT DEFAULT 0, pet_last_star TEXT, pet_bg TEXT DEFAULT 'default', pet_bg_owned TEXT DEFAULT '[]', pet_last_fed TEXT, avatar_url TEXT DEFAULT NULL); + +CREATE TABLE xp_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + +CREATE INDEX idx_announcements_class ON announcements(class_id); + +CREATE INDEX idx_answers_session ON user_answers(session_id); + +CREATE INDEX idx_answers_sq ON user_answers(session_id, question_id); + +CREATE INDEX idx_assign_sess_assign ON assignment_sessions(assignment_id); + +CREATE INDEX idx_assign_sess_user ON assignment_sessions(user_id); + +CREATE INDEX idx_assignments_class ON assignments(class_id); + +CREATE INDEX idx_assignments_deadline ON assignments(deadline); + +CREATE INDEX idx_assignments_user ON assignments(user_id); + +CREATE INDEX idx_audit_log_admin ON admin_audit_log(admin_id); + +CREATE INDEX idx_audit_log_date ON admin_audit_log(created_at DESC); + +CREATE INDEX idx_avatar_req_status ON avatar_requests(status); + +CREATE INDEX idx_avatar_req_user ON avatar_requests(user_id); + +CREATE INDEX idx_bio_mol_cat ON bio_molecules(category); + +CREATE INDEX idx_bio_mol_formula ON bio_molecules(formula); + +CREATE INDEX idx_bio_user_chal ON bio_user_challenges(user_id); + +CREATE INDEX idx_bio_user_mol ON bio_user_molecules(user_id); + +CREATE INDEX idx_blocks_lesson ON lesson_blocks(lesson_id); + +CREATE INDEX idx_bookmarks_user ON bookmarks(user_id); + +CREATE INDEX idx_cc_class ON class_courses(class_id); + +CREATE INDEX idx_cc_course ON class_courses(course_id); + +CREATE INDEX idx_challenges_user_week ON challenges(user_id, week); + +CREATE INDEX idx_class_members_class ON class_members(class_id); + +CREATE INDEX idx_class_members_user ON class_members(user_id); + +CREATE INDEX idx_courses_creator ON courses(created_by); + +CREATE INDEX idx_courses_subject ON courses(subject_slug); + +CREATE INDEX idx_cr_chat ON classroom_chat(session_id); + +CREATE INDEX idx_cr_reactions ON classroom_chat_reactions(chat_id); + +CREATE INDEX idx_cr_sessions_class ON classroom_sessions(class_id, status); + +CREATE INDEX idx_cr_strokes ON classroom_strokes(session_id, page_num, seq); + +CREATE INDEX idx_ctpl_creator ON course_templates(created_by); + +CREATE INDEX idx_ctpl_public ON course_templates(is_public); + +CREATE INDEX idx_daily_goals_user ON daily_goals(user_id, date); + +CREATE INDEX idx_error_log_date ON error_log(created_at DESC); + +CREATE INDEX idx_file_access_file ON file_access(file_id); + +CREATE INDEX idx_files_subject ON files(subject_slug); + +CREATE INDEX idx_files_uploader ON files(uploaded_by); + +CREATE INDEX idx_folder_access_folder ON folder_access(folder_id); + +CREATE INDEX idx_geo_subs_student ON geometry_submissions(student_id); + +CREATE INDEX idx_geo_subs_task ON geometry_submissions(task_id); + +CREATE INDEX idx_geo_tasks_teacher ON geometry_tasks(teacher_id); + +CREATE INDEX idx_lcomments_lesson ON lesson_comments(lesson_id); + +CREATE INDEX idx_lcomments_parent ON lesson_comments(parent_id); + +CREATE INDEX idx_lcomments_user ON lesson_comments(user_id); + +CREATE INDEX idx_lessons_course ON lessons(course_id); + +CREATE INDEX idx_live_answers_question ON live_answers(live_session_id, question_id); + +CREATE INDEX idx_live_answers_session ON live_answers(live_session_id); + +CREATE INDEX idx_live_sessions_class ON live_sessions(class_id, status); + +CREATE INDEX idx_lprogress_lesson ON lesson_progress(lesson_id); + +CREATE INDEX idx_lprogress_user ON lesson_progress(user_id); + +CREATE INDEX idx_ltpl_creator ON lesson_templates(created_by); + +CREATE INDEX idx_ltpl_public ON lesson_templates(is_public); + +CREATE INDEX idx_notes_lesson ON lesson_notes(lesson_id); + +CREATE INDEX idx_notes_user ON lesson_notes(user_id); + +CREATE INDEX idx_notifications_user ON notifications(user_id, is_read); + +CREATE INDEX idx_parent_links_student ON parent_links(student_id); + +CREATE INDEX idx_parent_links_token ON parent_links(token); + +CREATE INDEX idx_parent_notif_link ON parent_notifications(parent_link_id, is_read); + +CREATE INDEX idx_parent_notif_link_date ON parent_notifications(parent_link_id, created_at DESC); + +CREATE INDEX idx_purchases_user ON user_purchases(user_id); + +CREATE INDEX idx_questions_source ON questions(source_type); + +CREATE INDEX idx_questions_subject ON questions(subject_id); + +CREATE INDEX idx_questions_topic ON questions(topic_id); + +CREATE INDEX idx_rb_collection_user ON rb_user_collection(user_id); + +CREATE INDEX idx_rb_popdata_species ON rb_population_data(species_id); + +CREATE INDEX idx_rb_sightings_user ON rb_sightings(user_id); + +CREATE INDEX idx_rb_species_category ON rb_species(category); + +CREATE INDEX idx_rb_species_group ON rb_species(group_id); + +CREATE INDEX idx_sections_course ON course_sections(course_id); + +CREATE INDEX idx_sessions_user ON test_sessions(user_id); + +CREATE INDEX idx_sessions_user_status ON test_sessions(user_id, status); + +CREATE INDEX idx_sessions_user_status_date ON test_sessions(user_id, status, started_at DESC); + +CREATE INDEX idx_sq_session ON session_questions(session_id); + +CREATE INDEX idx_submissions_assignment ON submissions(assignment_id); + +CREATE INDEX idx_submissions_class ON submissions(class_id); + +CREATE INDEX idx_submissions_student ON submissions(student_id); + +CREATE INDEX idx_test_sessions_status ON test_sessions(status); + +CREATE INDEX idx_test_sessions_user_status ON test_sessions(user_id, status); + +CREATE UNIQUE INDEX idx_topics_uniq ON topics(subject_id, name); + +CREATE INDEX idx_tpl_creator ON assignment_templates(created_by); + +CREATE INDEX idx_user_achievements_user ON user_achievements(user_id); + +CREATE INDEX idx_user_permissions_user ON user_permissions(user_id); + +CREATE INDEX idx_xp_log_date ON xp_log(created_at); + +CREATE INDEX idx_xp_log_user ON xp_log(user_id); + +CREATE INDEX idx_xp_log_user_created ON xp_log(user_id, created_at); diff --git a/backend/src/db/migrations/001_init.sql b/backend/src/db/migrations/001_init.sql deleted file mode 100644 index 482d09e..0000000 --- a/backend/src/db/migrations/001_init.sql +++ /dev/null @@ -1,102 +0,0 @@ --- ============================================= --- 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); diff --git a/backend/src/db/migrations/002_constraints.sql b/backend/src/db/migrations/002_constraints.sql deleted file mode 100644 index cb69e10..0000000 --- a/backend/src/db/migrations/002_constraints.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ индСкс для upsert ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ² -CREATE UNIQUE INDEX IF NOT EXISTS idx_answers_session_question - ON user_answers (session_id, question_id); diff --git a/backend/src/db/migrations/README.md b/backend/src/db/migrations/README.md new file mode 100644 index 0000000..70c1159 --- /dev/null +++ b/backend/src/db/migrations/README.md @@ -0,0 +1,63 @@ +# Versioned migrations + +Each schema change is a separate `.sql` file, applied in alphabetical order. +Applied files are tracked in the `_migrations` table. + +## Applying migrations + +```sh +npm run migrate # apply pending migrations (safe to re-run) +npm run migrate:bootstrap # mark 000_baseline.sql as applied on existing DB (run ONCE per env) +npm run migrate:legacy # run legacy migrate.js (kept for reference, do not use) +``` + +## Naming convention + +``` +NNN_short_description.sql +``` + +Examples: `001_add_user_avatar.sql`, `002_drop_unused_columns.sql` + +To find the next number: +```sh +ls backend/src/db/migrations/*.sql | sort -r | head -1 +``` + +## Rules + +1. **Never edit** a migration file after it has been committed and deployed. +2. To revert: write a new migration that undoes the change. +3. Each file must be valid SQLite SQL (not PostgreSQL β€” no SERIAL, no EXTENSION). +4. Use `IF NOT EXISTS` / `IF EXISTS` where possible for safety. +5. Test on a copy of the prod DB before deploying. + +## Deploy order (first time on a new environment) + +```sh +npm run migrate:legacy # initialize full schema (existing init script) +npm run seed:permissions # seed default permissions and achievements +npm run migrate:bootstrap # mark 000_baseline.sql as applied +npm run migrate # apply any newer migrations (should say "nothing to apply") +npm start +``` + +## Adding a new migration + +```sh +# 1. Create the file +echo "ALTER TABLE users ADD COLUMN avatar_url TEXT;" > backend/src/db/migrations/001_add_avatar_url.sql + +# 2. Apply and verify +npm run migrate + +# 3. Commit +git add backend/src/db/migrations/001_add_avatar_url.sql +git commit -m "db: add avatar_url column to users" +``` + +## Files + +| File | Description | +|------|-------------| +| `000_baseline.sql` | Snapshot of full schema as of 2026-05-06. Never runs on existing DBs. |