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)
This commit is contained in:
Aleksei Pavlov
2026-06-19 11:16:58 +03:00
parent 29788a8f9d
commit 4ffadc6baa
3 changed files with 81 additions and 14 deletions
+63 -4
View File
@@ -14,11 +14,19 @@ 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) { res.status(401).json({ error: 'x-user-id required' }); return null; }
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; }
return adminId;
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;
}
}
// ───────────────────────────────────────────────────────────────────────────
@@ -206,4 +214,55 @@ router.post('/auto-approve', async (req, res) => {
}
});
// ───────────────────────────────────────────────────────────────────────────
// 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;