4ffadc6baa
- 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)
269 lines
12 KiB
JavaScript
269 lines
12 KiB
JavaScript
/**
|
||
* 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;
|