Files
zeropost-engine/src/routes/zeroAdmin.js
T
Aleksei Pavlov 4ffadc6baa feat(zero): /config endpoints + dynamic scheduler hours
- GET/PATCH /api/admin/zero/config — read/write all Zero settings via app_settings
  - scheduler reads GENERATE_HOUR / APPROVE_HOUR dynamically (no restart needed)
  - generateDraft uses PUBLISH_HOUR for scheduled_at (was hardcoded 13)
  - requireAdmin softened — works both with and without users.is_admin column
    (prod has no is_admin; auth is provided by x-internal-secret + web cookie)
2026-06-19 11:16:58 +03:00

269 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;