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:
+61
-2
@@ -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; }
|
||||
if (!adminId) return 'system'; // нет header — доверяем секрету
|
||||
try {
|
||||
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; }
|
||||
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;
|
||||
|
||||
@@ -68,6 +68,10 @@ async function getModel() {
|
||||
return await settings.get('ZERO_NOTES_MODEL', process.env.AI_MODEL_POST || 'claude-haiku-4-5-20251001');
|
||||
}
|
||||
|
||||
async function getPublishHour() {
|
||||
return parseInt(await settings.get('ZERO_NOTES_PUBLISH_HOUR', '13'), 10);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// ВЫБОРКИ
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
@@ -177,8 +181,9 @@ async function generateDraft(channelId, opts = {}) {
|
||||
// 7. theme_hash для дедупа
|
||||
const themeHash = zPrompt.normalizeTheme(prompt.themeHint);
|
||||
|
||||
// 8. scheduled_at = ближайшее 13:00 МСК (если генерим в 13:00 сегодня → ставим на завтра)
|
||||
const scheduledAt = nextPublishSlot({ publishHourMsk: 13 });
|
||||
// 8. scheduled_at = ближайший слот публикации в МСК (час берётся из настроек)
|
||||
const publishHour = await getPublishHour();
|
||||
const scheduledAt = nextPublishSlot({ publishHourMsk: publishHour });
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
@@ -320,6 +325,7 @@ module.exports = {
|
||||
// настройки
|
||||
getActiveChannelIds,
|
||||
getModel,
|
||||
getPublishHour,
|
||||
// утилиты времени
|
||||
nowMsk,
|
||||
nextPublishSlot,
|
||||
|
||||
@@ -19,15 +19,15 @@ const TICK_MS = 60_000;
|
||||
// Отметка последнего успешного тика по slot'у: { generate: '2026-06-19T13:00', approve: '2026-06-19T07:00' }
|
||||
const lastRun = {};
|
||||
|
||||
const GENERATE_HOUR_MSK = 13;
|
||||
const APPROVE_HOUR_MSK = 7;
|
||||
async function generateHourMsk() { return parseInt(await settings.get('ZERO_NOTES_GENERATE_HOUR', '13'), 10); }
|
||||
async function approveHourMsk() { return parseInt(await settings.get('ZERO_NOTES_APPROVE_HOUR', '7'), 10); }
|
||||
|
||||
function slotKey(ymd, hour) {
|
||||
return `${ymd}T${String(hour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
async function runGeneration(ymd) {
|
||||
const key = slotKey(ymd, GENERATE_HOUR_MSK);
|
||||
const key = slotKey(ymd, await generateHourMsk());
|
||||
if (lastRun.generate === key) return;
|
||||
lastRun.generate = key;
|
||||
|
||||
@@ -50,7 +50,7 @@ async function runGeneration(ymd) {
|
||||
}
|
||||
|
||||
async function runAutoApprove(ymd) {
|
||||
const key = slotKey(ymd, APPROVE_HOUR_MSK);
|
||||
const key = slotKey(ymd, await approveHourMsk());
|
||||
if (lastRun.approve === key) return;
|
||||
lastRun.approve = key;
|
||||
|
||||
@@ -65,8 +65,9 @@ async function runAutoApprove(ymd) {
|
||||
async function tick() {
|
||||
const { hour, ymd } = zeroNotes.nowMsk();
|
||||
try {
|
||||
if (hour === GENERATE_HOUR_MSK) await runGeneration(ymd);
|
||||
if (hour === APPROVE_HOUR_MSK) await runAutoApprove(ymd);
|
||||
const [genHour, appHour] = [await generateHourMsk(), await approveHourMsk()];
|
||||
if (hour === genHour) await runGeneration(ymd);
|
||||
if (hour === appHour) await runAutoApprove(ymd);
|
||||
// публикация approved-заметок в TG (каждую минуту)
|
||||
const published = await zeroRunner.publishReady({ limit: 3 });
|
||||
if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`);
|
||||
@@ -85,8 +86,9 @@ function start() {
|
||||
intervalRef = setInterval(tick, TICK_MS);
|
||||
// первый тик через 30 сек после старта (даём engine стабильно подняться)
|
||||
setTimeout(tick, 30_000);
|
||||
console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS / 1000}s, ` +
|
||||
`generate=${GENERATE_HOUR_MSK}:00 MSK, auto-approve=${APPROVE_HOUR_MSK}:00 MSK`);
|
||||
Promise.all([generateHourMsk(), approveHourMsk()]).then(([gh, ah]) =>
|
||||
console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS/1000}s, generate=${gh}:00 MSK, auto-approve=${ah}:00 MSK (dynamic)`)
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
|
||||
Reference in New Issue
Block a user