feat: SMTP, maintenance mode, blog topic bank UI

8. SMTP: emailService.js (nodemailer), templates (welcome/payment/low_credits)
   /api/admin/email/test — тест отправки
   app_settings category=smtp (HOST/PORT/USER/PASS/FROM/ENABLED)
9. Maintenance mode: middleware в index.js, MAINTENANCE_MODE в engine settings
   При true → 503 для всех запросов кроме /uploads и /api/settings
10. Blog topic bank:
   DB: blog_topics(category,topic,is_used,source,priority)
   40 тем мигрированы из хардкода (source=hardcoded)
   autogen.js: getNextTopic берёт из DB, fallback на TOPIC_BANK
   admin API: GET/POST /blog-topics, DELETE /:id, POST /generate (AI +10)
This commit is contained in:
Ник (Claude)
2026-06-13 11:45:23 +03:00
parent 9b40f2cd7a
commit c40ef90ad1
6 changed files with 282 additions and 21 deletions
+124
View File
@@ -490,3 +490,127 @@ router.delete('/autogen/queue/:id', async (req, res) => {
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── EMAIL ────────────────────────────────────────────────────
// POST /api/admin/email/test — тестовая отправка
router.post('/email/test', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'to обязателен' });
try {
const email = require('../services/emailService');
const result = await email.send({
to,
subject: '✅ ZeroPost SMTP тест',
html: '<p>Если ты видишь это письмо — SMTP настроен правильно!</p>',
text: 'Если ты видишь это письмо — SMTP настроен правильно!',
});
if (result.skipped) return res.json({ ok: false, message: 'SMTP отключён или не настроен' });
if (result.error) return res.status(500).json({ error: result.error });
res.json({ ok: true, messageId: result.messageId });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── BLOG TOPIC BANK ──────────────────────────────────────────
// GET /api/admin/blog-topics — список тем по категории
router.get('/blog-topics', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, includeUsed = 'false', limit = 100 } = req.query;
try {
const where = category ? 'WHERE bt.category=$1' : '';
const args = category ? [category] : [];
const { rows } = await query(`
SELECT bt.*,
EXISTS(SELECT 1 FROM articles a WHERE a.source_topic=bt.topic) as is_published
FROM blog_topics bt
${where}
${includeUsed !== 'true' ? (where ? 'AND' : 'WHERE') + ' bt.is_used=false' : ''}
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT ${parseInt(limit)}
`, args);
// Статистика по категориям
const { rows: stats } = await query(`
SELECT category,
count(*)::int as total,
count(*) FILTER (WHERE is_used=false)::int as unused
FROM blog_topics GROUP BY category
`);
res.json({ topics: rows, stats });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/blog-topics — добавить тему
router.post('/blog-topics', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, topic, tags = [], priority = 5 } = req.body;
if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' });
try {
const { rows: [row] } = await query(
`INSERT INTO blog_topics (category, topic, tags, priority, source)
VALUES ($1,$2,$3,$4,'manual') ON CONFLICT DO NOTHING RETURNING *`,
[category, topic.trim(), tags, priority]
);
res.json(row || { error: 'Такая тема уже есть' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/admin/blog-topics/:id
router.delete('/blog-topics/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
await query('DELETE FROM blog_topics WHERE id=$1', [req.params.id]);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/blog-topics/generate — AI генерация новых тем для категории
router.post('/blog-topics/generate', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, count = 10 } = req.body;
if (!category) return res.status(400).json({ error: 'category обязателен' });
try {
res.json({ ok: true, message: `Генерирую ${count} тем для ${category}...` });
// Берём уже существующие темы для дедупликации
const { rows: existing } = await query('SELECT topic FROM blog_topics WHERE category=$1', [category]);
const existingTopics = existing.map(r => r.topic).join('\n');
const ai = require('../services/ai');
const config = require('../config');
const CATEGORY_NAMES = {
'ai-tools': 'AI инструменты для работы и бизнеса',
'ai-dev': 'AI разработка и программирование',
'automation': 'Автоматизация процессов',
'cybersec': 'Кибербезопасность',
};
const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${CATEGORY_NAMES[category] || category}".
Темы должны быть: конкретными, практическими, интересными читателям.
Формат: точные заголовки статей, не категории.
Ответь ТОЛЬКО JSON-массивом строк без markdown.`;
const userMsg = `Придумай ${count} уникальных тем.${existingTopics ? `\n\nИзбегай повторений:\n${existingTopics.slice(0,800)}` : ''}`;
const result = await ai.chat(
config.ai.models.topics || 'claude-haiku-4-5-20251001',
system, userMsg, 0.9, 600
);
const topics = JSON.parse(result.replace(/```json|```/g, '').trim());
let added = 0;
for (const topic of topics.slice(0, count)) {
if (!topic?.trim()) continue;
const { rows: [row] } = await query(
`INSERT INTO blog_topics (category, topic, source)
VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING RETURNING id`,
[category, topic.trim()]
);
if (row) added++;
}
console.log(`[BlogTopics] AI generated ${added} topics for ${category}`);
} catch (err) { console.error(`[BlogTopics] generate error: ${err.message}`); }
});