/** * routes/zeroAdmin.js — админские роуты для управления заметками Зеро. * Монтируется на /api/admin/zero * * Auth: x-user-id header → users.is_admin = true (та же конвенция что в routes/admin.js) */ const express = require('express'); const router = express.Router(); const { query } = require('../config/db'); const zeroNotes = require('../services/zeroNotes'); const zPrompt = require('../services/zeroPrompt'); function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } async function requireAdmin(req, res) { // x-internal-secret уже проверен глобальным middleware (см. index.js). // Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим; // если колонки нет (минимальный prod), доверяем секрету. const adminId = uid(req); if (!adminId) return 'system'; // нет header — доверяем секрету try { const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); if (u && u.is_admin === false) { res.status(403).json({ error: 'Forbidden' }); return null; } return adminId; } catch (err) { // колонки is_admin нет — это нормально для prod конфига return adminId; } } // ─────────────────────────────────────────────────────────────────────────── // СПИСКИ // ─────────────────────────────────────────────────────────────────────────── // GET /api/admin/zero/notes?status=draft&channel_id=1&limit=50 router.get('/notes', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const status = req.query.status || null; const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null; const limit = Math.min(parseInt(req.query.limit) || 50, 200); const params = [limit]; const where = []; if (status) { params.push(status); where.push(`status = $${params.length}`); } if (channelId) { params.push(channelId); where.push(`channel_id = $${params.length}`); } const sqlWhere = where.length ? `WHERE ${where.join(' AND ')}` : ''; const { rows } = await query( `SELECT id, channel_id, content, theme, theme_bucket, theme_hash, pose, image_url, status, scheduled_at, approved_at, approved_by, published_at, channel_message_id, model, tokens_in, tokens_out, attempts, error, created_at, updated_at FROM zero_notes ${sqlWhere} ORDER BY CASE status WHEN 'draft' THEN 1 WHEN 'approved' THEN 2 WHEN 'scheduled' THEN 3 WHEN 'published' THEN 4 WHEN 'failed' THEN 5 WHEN 'skipped' THEN 6 ELSE 7 END, scheduled_at ASC NULLS LAST, created_at DESC LIMIT $1`, params ); res.json({ ok: true, items: rows, count: rows.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/admin/zero/notes/:id — детали одной заметки (любой статус) router.get('/notes/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const n = await zeroNotes.getById(parseInt(req.params.id)); if (!n) return res.status(404).json({ error: 'not found' }); res.json({ ok: true, note: n }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/admin/zero/buckets — список ведёр (для UI: dropdown "Сгенерить с ведром X") router.get('/buckets', async (req, res) => { if (!await requireAdmin(req, res)) return; res.json({ ok: true, buckets: zPrompt.THEME_BUCKETS.map(b => ({ key: b.key, label: b.label, examples: b.examples })), }); }); // ─────────────────────────────────────────────────────────────────────────── // ГЕНЕРАЦИЯ (КНОПКА) // ─────────────────────────────────────────────────────────────────────────── // POST /api/admin/zero/generate // body: { channel_id: int (обязательно), force_bucket?: string, allow_today_dup?: bool } router.post('/generate', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const channelId = parseInt(req.body?.channel_id); const forceBucket = req.body?.force_bucket || null; const allowDup = !!req.body?.allow_today_dup; if (!Number.isFinite(channelId)) { return res.status(400).json({ error: 'channel_id required' }); } // Если уже есть draft сегодня — по умолчанию запрещаем (защита от случайных кликов), // но админ может передать allow_today_dup=true чтобы всё-таки сгенерить if (!allowDup && await zeroNotes.hasDraftToday(channelId)) { return res.status(409).json({ error: 'draft уже создан сегодня — передай allow_today_dup=true, чтобы пересоздать', }); } const draft = await zeroNotes.generateDraft(channelId, { forceBucket, allowDup }); if (!draft) { return res.status(409).json({ error: 'не удалось создать draft (возможно дубль)' }); } res.json({ ok: true, note: draft }); } catch (err) { console.error('[POST /api/admin/zero/generate]', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/admin/zero/notes/:id/regenerate // body: { force_bucket?: string } — стирает старый draft и создаёт новый router.post('/notes/:id/regenerate', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const id = parseInt(req.params.id); const old = await zeroNotes.getById(id); if (!old) return res.status(404).json({ error: 'not found' }); if (!['draft', 'failed'].includes(old.status)) { return res.status(400).json({ error: `нельзя перегенерить заметку в статусе ${old.status}` }); } // Удаляем старый и генерим новый await query('DELETE FROM zero_notes WHERE id=$1', [id]); const fresh = await zeroNotes.generateDraft(old.channel_id, { forceBucket: req.body?.force_bucket || null, }); res.json({ ok: true, note: fresh, replaced: id }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ─────────────────────────────────────────────────────────────────────────── // WORKFLOW // ─────────────────────────────────────────────────────────────────────────── // PATCH /api/admin/zero/notes/:id — редактирование router.patch('/notes/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const id = parseInt(req.params.id); const updated = await zeroNotes.editContent(id, { content: req.body?.content, theme: req.body?.theme, pose: req.body?.pose, imageUrl: req.body?.image_url, scheduledAt: req.body?.scheduled_at ? new Date(req.body.scheduled_at) : undefined, }); if (!updated) return res.status(404).json({ error: 'not found' }); res.json({ ok: true, note: updated }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/zero/notes/:id/approve — ручное одобрение router.post('/notes/:id/approve', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const adminId = uid(req); const { rows: [u] } = await query('SELECT email FROM users WHERE id=$1', [adminId]); const updated = await zeroNotes.approveManual(parseInt(req.params.id), u?.email || `admin#${adminId}`); if (!updated) return res.status(404).json({ error: 'not found or wrong status' }); res.json({ ok: true, note: updated }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/zero/notes/:id/skip — пропустить (не публиковать) router.post('/notes/:id/skip', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const updated = await zeroNotes.skipNote(parseInt(req.params.id), req.body?.reason || 'skipped by admin'); if (!updated) return res.status(404).json({ error: 'not found or wrong status' }); res.json({ ok: true, note: updated }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/zero/auto-approve — ручной триггер auto-approve (для тестов) router.post('/auto-approve', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const rows = await zeroNotes.autoApproveOldDrafts(); res.json({ ok: true, approved: rows.length, ids: rows.map(r => r.id) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ─────────────────────────────────────────────────────────────────────────── // CONFIG (app_settings) // ─────────────────────────────────────────────────────────────────────────── const settings = require('../services/settings'); const CONFIG_KEYS = [ { key: 'ZERO_NOTES_CHANNEL_IDS', default: '', description: 'csv int id каналов, для которых работают заметки Зеро' }, { key: 'ZERO_NOTES_MODEL', default: '', description: 'модель для генерации (пусто = AI_MODEL_POST)' }, { key: 'ZERO_NOTES_GENERATE_HOUR', default: '13', description: 'час генерации в МСК (0-23)' }, { key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' }, { key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' }, { key: 'ZERO_SITE_URL_BASE', default: '', description: 'для inline-кнопки "Открыть на сайте"' }, ]; router.get('/config', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const out = {}; for (const { key, default: def } of CONFIG_KEYS) { out[key] = await settings.get(key, def); } out._enabled = !!(out.ZERO_NOTES_CHANNEL_IDS && String(out.ZERO_NOTES_CHANNEL_IDS).trim()); out._keys_meta = CONFIG_KEYS; res.json({ ok: true, config: out }); } catch (err) { res.status(500).json({ error: err.message }); } }); router.patch('/config', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const body = req.body || {}; const allowed = new Set(CONFIG_KEYS.map(k => k.key)); const updated = {}; for (const [k, v] of Object.entries(body)) { if (!allowed.has(k)) continue; const value = v == null ? null : String(v); await query( `INSERT INTO app_settings (key, value, category, description, updated_at) VALUES ($1, $2, 'zero_notes', $3, NOW()) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`, [k, value, CONFIG_KEYS.find(c => c.key === k)?.description || null] ); updated[k] = value; } settings.invalidate(); res.json({ ok: true, updated }); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router;