const db = require('../db/db'); const { awardXP, checkRedBookAchievements } = require('./gamificationController'); const { stripTags } = require('../utils/sanitize'); /* ── helpers ─────────────────────────────────────────────────────────── */ const stmts = { groups: db.prepare('SELECT * FROM rb_groups ORDER BY name_ru'), habitats: db.prepare('SELECT * FROM rb_habitats ORDER BY name'), speciesById: db.prepare(` SELECT s.*, g.name_ru as group_name, g.icon as group_icon, g.color as group_color, h.name as habitat_name, h.type as habitat_type FROM rb_species s JOIN rb_groups g ON g.id = s.group_id LEFT JOIN rb_habitats h ON h.id = s.habitat_id WHERE s.id = ? `), regionsBySpecies: db.prepare('SELECT region_code FROM rb_species_regions WHERE species_id = ?'), popdata: db.prepare('SELECT year, count_estimate, source FROM rb_population_data WHERE species_id = ? ORDER BY year'), foodWebPrey: db.prepare(` SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category, g.icon as group_icon, g.color as group_color FROM rb_food_web fw JOIN rb_species s ON s.id = fw.prey_id JOIN rb_groups g ON g.id = s.group_id WHERE fw.predator_id = ? `), foodWebPredators: db.prepare(` SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category, g.icon as group_icon, g.color as group_color FROM rb_food_web fw JOIN rb_species s ON s.id = fw.predator_id JOIN rb_groups g ON g.id = s.group_id WHERE fw.prey_id = ? `), collection: db.prepare('SELECT species_id, unlock_method, notes, unlocked_at FROM rb_user_collection WHERE user_id = ?'), hasCollect: db.prepare('SELECT 1 FROM rb_user_collection WHERE user_id = ? AND species_id = ?'), addCollect: db.prepare('INSERT OR IGNORE INTO rb_user_collection (user_id, species_id, unlock_method) VALUES (?,?,?)'), quests: db.prepare('SELECT * FROM rb_quests ORDER BY id'), userQuests: db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ?'), startQuest: db.prepare('INSERT OR IGNORE INTO rb_user_quests (user_id, quest_id) VALUES (?,?)'), questById: db.prepare('SELECT * FROM rb_quests WHERE id = ?'), userQuestRow:db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ? AND quest_id = ?'), completeQ: db.prepare("UPDATE rb_user_quests SET status='completed', completed_at=datetime('now') WHERE user_id=? AND quest_id=?"), sightings: db.prepare(` SELECT rs.*, u.name as user_name, s.name_ru as species_name FROM rb_sightings rs JOIN users u ON u.id = rs.user_id JOIN rb_species s ON s.id = rs.species_id ORDER BY rs.created_at DESC LIMIT 50 `), addSighting: db.prepare('INSERT INTO rb_sightings (user_id, species_id, region_code, description) VALUES (?,?,?,?)'), mapData: db.prepare(` SELECT r.region_code, COUNT(r.species_id) as total, SUM(CASE WHEN s.category='CR' THEN 1 ELSE 0 END) as cr, SUM(CASE WHEN s.category='EN' THEN 1 ELSE 0 END) as en, SUM(CASE WHEN s.category='VU' THEN 1 ELSE 0 END) as vu FROM rb_species_regions r JOIN rb_species s ON s.id = r.species_id GROUP BY r.region_code `), }; function buildSpeciesList(filter = {}) { let sql = ` SELECT s.id, s.name_ru, s.name_be, s.name_lat, s.category, s.by_category, s.photo_url, s.model_type, s.biomass_kg, s.interesting_fact, g.name_ru as group_name, g.icon as group_icon, g.color as group_color, h.name as habitat_name, h.type as habitat_type FROM rb_species s JOIN rb_groups g ON g.id = s.group_id LEFT JOIN rb_habitats h ON h.id = s.habitat_id `; const cond = [], params = []; if (filter.group_id) { cond.push('s.group_id = ?'); params.push(filter.group_id); } if (filter.category) { cond.push('s.category = ?'); params.push(filter.category); } if (filter.habitat_id) { cond.push('s.habitat_id = ?'); params.push(filter.habitat_id); } if (filter.region) { sql += ' JOIN rb_species_regions rr ON rr.species_id = s.id'; cond.push('rr.region_code = ?'); params.push(filter.region); } if (filter.q) { cond.push('(s.name_ru LIKE ? OR s.name_lat LIKE ? OR s.name_be LIKE ?)'); const like = `%${filter.q}%`; params.push(like, like, like); } if (cond.length) sql += ' WHERE ' + cond.join(' AND '); sql += ' ORDER BY s.category, s.name_ru'; const limit = Math.min(parseInt(filter.limit) || 50, 100); const offset = parseInt(filter.offset) || 0; sql += ' LIMIT ? OFFSET ?'; params.push(limit, offset); return db.prepare(sql).all(...params); } /* ── GET /api/red-book/groups ───────────────────────────────────────── */ exports.getGroups = (req, res) => { const groups = stmts.groups.all(); // attach count const counts = db.prepare(` SELECT group_id, COUNT(*) as n, SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr, SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en FROM rb_species GROUP BY group_id `).all(); const countMap = {}; counts.forEach(c => countMap[c.group_id] = { n: c.n, cr: c.cr, en: c.en }); groups.forEach(g => Object.assign(g, countMap[g.id] || { n: 0, cr: 0, en: 0 })); res.json(groups); }; /* ── GET /api/red-book/habitats ─────────────────────────────────────── */ exports.getHabitats = (req, res) => { res.json(stmts.habitats.all()); }; /* ── GET /api/red-book/species ──────────────────────────────────────── */ exports.getSpecies = (req, res) => { const list = buildSpeciesList(req.query); // total for pagination const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n; // user collection ids let collected = new Set(); if (req.user) { stmts.collection.all(req.user.id).forEach(r => collected.add(r.species_id)); } list.forEach(s => { try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; } s.collected = collected.has(s.id); }); res.json({ total, species: list }); }; /* ── GET /api/red-book/species/:id ─────────────────────────────────── */ exports.getSpeciesById = (req, res) => { const s = stmts.speciesById.get(req.params.id); if (!s) return res.status(404).json({ error: 'Вид не найден' }); s.regions = stmts.regionsBySpecies.all(s.id).map(r => r.region_code); s.population_data = stmts.popdata.all(s.id); s.prey = stmts.foodWebPrey.all(s.id); s.predators = stmts.foodWebPredators.all(s.id); try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; } try { s.population_trend = JSON.parse(s.population_trend || '[]'); } catch { s.population_trend = []; } if (req.user) { s.collected = !!stmts.hasCollect.get(req.user.id, s.id); } res.json(s); }; /* ── GET /api/red-book/map-data ─────────────────────────────────────── */ exports.getMapData = (req, res) => { res.json(stmts.mapData.all()); }; /* ── GET /api/red-book/food-web ─────────────────────────────────────── */ exports.getFoodWeb = (req, res) => { const ids = (req.query.species_ids || '').split(',').map(Number).filter(Boolean); if (!ids.length) { // return full web const nodes = db.prepare(` SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color, g.id as group_id, h.id as habitat_id, h.type as habitat_type, h.name as habitat_name FROM rb_species s JOIN rb_groups g ON g.id = s.group_id LEFT JOIN rb_habitats h ON h.id = s.habitat_id `).all(); const links = db.prepare('SELECT predator_id as source, prey_id as target, strength FROM rb_food_web').all(); return res.json({ nodes, links }); } const ph = ids.map(() => '?').join(','); const nodes = db.prepare(` SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color FROM rb_species s JOIN rb_groups g ON g.id = s.group_id WHERE s.id IN (${ph}) `).all(...ids); const links = db.prepare(` SELECT predator_id as source, prey_id as target, strength FROM rb_food_web WHERE predator_id IN (${ph}) OR prey_id IN (${ph}) `).all(...ids, ...ids); res.json({ nodes, links }); }; /* ── GET /api/red-book/biome/:habitatId ─────────────────────────────── */ exports.getBiomeSpecies = (req, res) => { const list = db.prepare(` SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, s.model_type, s.biomass_kg, g.icon, g.color FROM rb_species s JOIN rb_groups g ON g.id = s.group_id WHERE s.habitat_id = ? ORDER BY s.category, s.name_ru `).all(req.params.habitatId); res.json(list); }; /* ── POST /api/red-book/species/:id/collect ─────────────────────────── */ exports.collectSpecies = (req, res) => { const userId = req.user.id; const speciesId = parseInt(req.params.id); const sp = stmts.speciesById.get(speciesId); if (!sp) return res.status(404).json({ error: 'Вид не найден' }); const already = stmts.hasCollect.get(userId, speciesId); if (already) return res.json({ already: true, message: 'Вид уже в коллекции' }); stmts.addCollect.run(userId, speciesId, req.body?.method || 'explore'); // Award XP (via gamification service — also updates level in DB) const xpMap = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 }; const xp = xpMap[sp.category] || 20; try { awardXP(userId, xp, `Открыт вид: ${sp.name_ru}`); } catch {} // Auto-complete any active quests that required this species const completedQuests = []; try { const activeQuests = db.prepare(` SELECT q.*, uq.quest_id FROM rb_user_quests uq JOIN rb_quests q ON q.id = uq.quest_id WHERE uq.user_id = ? AND uq.status = 'active' `).all(userId); // Get all collected species ids for this user const collectedIds = new Set( db.prepare('SELECT species_id FROM rb_user_collection WHERE user_id = ?').all(userId).map(r => r.species_id) ); for (const quest of activeQuests) { const requiredIds = JSON.parse(quest.species_ids || '[]'); if (requiredIds.every(id => collectedIds.has(id))) { stmts.completeQ.run(userId, quest.id); // Award quest XP via gamification service try { awardXP(userId, quest.xp_reward, `Квест выполнен: ${quest.title}`); } catch {} completedQuests.push({ id: quest.id, title: quest.title, xp_reward: quest.xp_reward }); } } } catch {} // Check Red Book achievements (non-blocking) try { checkRedBookAchievements(userId); } catch {} res.json({ collected: true, xp_earned: xp, species: { id: sp.id, name_ru: sp.name_ru, category: sp.category }, completed_quests: completedQuests }); }; /* ── GET /api/red-book/collection ───────────────────────────────────── */ exports.getCollection = (req, res) => { const rows = stmts.collection.all(req.user.id); const ids = rows.map(r => r.species_id); if (!ids.length) return res.json({ total: 0, species: [] }); const ph = ids.map(() => '?').join(','); const species = db.prepare(` SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, g.icon, g.color FROM rb_species s JOIN rb_groups g ON g.id = s.group_id WHERE s.id IN (${ph}) `).all(...ids); const meta = {}; rows.forEach(r => meta[r.species_id] = { method: r.unlock_method, notes: r.notes, at: r.unlocked_at }); species.forEach(s => Object.assign(s, meta[s.id] || {})); res.json({ total: species.length, species }); }; /* ── GET /api/red-book/quests ───────────────────────────────────────── */ exports.getQuests = (req, res) => { const quests = stmts.quests.all(); quests.forEach(q => q.species_ids = JSON.parse(q.species_ids || '[]')); if (!req.user) return res.json(quests); const userQ = {}; stmts.userQuests.all(req.user.id).forEach(uq => userQ[uq.quest_id] = uq); quests.forEach(q => { const uq = userQ[q.id]; q.user_status = uq?.status || 'locked'; q.user_progress = uq ? JSON.parse(uq.progress || '{}') : {}; }); res.json(quests); }; /* ── POST /api/red-book/quests/:id/start ────────────────────────────── */ exports.startQuest = (req, res) => { const q = stmts.questById.get(req.params.id); if (!q) return res.status(404).json({ error: 'Квест не найден' }); stmts.startQuest.run(req.user.id, q.id); res.json({ started: true }); }; /* ── GET /api/red-book/sightings ────────────────────────────────────── */ exports.getSightings = (req, res) => { const speciesId = req.query.species_id ? parseInt(req.query.species_id) : null; let sql = ` SELECT rs.id, rs.species_id, rs.region_code, rs.description, rs.confirmed_by_teacher, rs.created_at, u.name as user_name, s.name_ru as species_name, g.icon as species_icon FROM rb_sightings rs JOIN users u ON u.id = rs.user_id JOIN rb_species s ON s.id = rs.species_id JOIN rb_groups g ON g.id = s.group_id `; const params = []; if (speciesId) { sql += ' WHERE rs.species_id = ?'; params.push(speciesId); } sql += ' ORDER BY rs.created_at DESC LIMIT 50'; res.json(db.prepare(sql).all(...params)); }; /* ── POST /api/red-book/sightings ───────────────────────────────────── */ exports.addSighting = (req, res) => { const { species_id, region_code, description } = req.body || {}; if (!species_id) return res.status(400).json({ error: 'species_id обязателен' }); const safeDesc = stripTags((description || '').slice(0, 2000)); const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', safeDesc).lastInsertRowid; try { checkRedBookAchievements(req.user.id); } catch {} res.json({ id }); }; /* ── GET /api/red-book/stats ────────────────────────────────────────── */ exports.getStats = (req, res) => { const totals = db.prepare(` SELECT COUNT(*) as total, SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr, SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en, SUM(CASE WHEN category='VU' THEN 1 ELSE 0 END) as vu, SUM(CASE WHEN category='NT' THEN 1 ELSE 0 END) as nt, SUM(CASE WHEN category='LC' THEN 1 ELSE 0 END) as lc FROM rb_species `).get(); let collected = 0; if (req.user) { collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(req.user.id).n; } res.json({ ...totals, collected }); }; /* ── GET /api/red-book/daily ────────────────────────────────────────── */ exports.getDaily = (req, res) => { // deterministic by day of year const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0)) / 86400000); const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n; const offset = dayOfYear % total; const daily = db.prepare(` SELECT s.id, s.name_ru, s.name_lat, s.category, s.interesting_fact, s.description, s.photo_url, g.icon, g.color FROM rb_species s JOIN rb_groups g ON g.id = s.group_id ORDER BY s.id LIMIT 1 OFFSET ? `).get(offset); res.json(daily); };