109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
// Авто-публикация статей в каналы.
|
||
//
|
||
// Логика:
|
||
// 1. При сохранении статьи со status='published' — engine вызывает scheduleForArticle(articleId)
|
||
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть)
|
||
// 3. Для каждого канала ищем ближайший подходящий момент:
|
||
// - если delay_min > 0 → now + delay_min
|
||
// - иначе — ближайший publish_slot канала в будущем
|
||
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW)
|
||
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent)
|
||
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у
|
||
|
||
const { query } = require('../config/db');
|
||
|
||
/**
|
||
* Подобрать ближайший момент публикации для канала.
|
||
* @returns Date
|
||
*/
|
||
async function pickScheduleTime(channel) {
|
||
const now = new Date();
|
||
if (channel.auto_publish_delay_min > 0) {
|
||
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
|
||
}
|
||
// Ищем publish_slots
|
||
const { rows: slots } = await query(
|
||
`SELECT slot_hour, slot_minute FROM publish_slots
|
||
WHERE channel_id=$1 AND enabled=true
|
||
ORDER BY slot_hour, slot_minute`,
|
||
[channel.id]
|
||
);
|
||
if (slots.length === 0) {
|
||
return now; // публикуем сразу
|
||
}
|
||
|
||
// Получаем уже занятые слоты (pending)
|
||
const { rows: taken } = await query(
|
||
`SELECT scheduled_at FROM scheduled_posts WHERE channel_id=$1 AND status='pending'`,
|
||
[channel.id]
|
||
);
|
||
const takenTimes = new Set(taken.map(r => new Date(r.scheduled_at).getTime()));
|
||
|
||
// Ищем ближайший незанятый слот на ближайшие 7 дней
|
||
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
|
||
const base = new Date(now);
|
||
base.setDate(base.getDate() + dayOffset);
|
||
for (const s of slots) {
|
||
const t = new Date(base);
|
||
t.setHours(s.slot_hour, s.slot_minute, 0, 0);
|
||
if (t > now && !takenTimes.has(t.getTime())) {
|
||
return t;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback — завтра первый слот
|
||
const tomorrow = new Date(now);
|
||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
tomorrow.setHours(slots[0].slot_hour, slots[0].slot_minute, 0, 0);
|
||
return tomorrow;
|
||
}
|
||
|
||
/**
|
||
* Поставить статью на авто-публикацию во все подходящие каналы.
|
||
* Идемпотентно: дубли в pending/sent не создаются.
|
||
* @returns массив созданных scheduled_posts
|
||
*/
|
||
async function scheduleForArticle(articleId) {
|
||
const { rows: arts } = await query(
|
||
`SELECT id, slug, title, category, status FROM articles WHERE id=$1`,
|
||
[articleId]
|
||
);
|
||
if (!arts.length || arts[0].status !== 'published') return [];
|
||
const article = arts[0];
|
||
|
||
const { rows: channels } = await query(
|
||
`SELECT * FROM channels
|
||
WHERE is_system=true
|
||
AND is_active=true
|
||
AND auto_publish_enabled=true
|
||
AND (cardinality(auto_publish_categories) = 0
|
||
OR $1 = ANY(auto_publish_categories))`,
|
||
[article.category]
|
||
);
|
||
|
||
const created = [];
|
||
for (const ch of channels) {
|
||
// Дедуп
|
||
const { rows: existing } = await query(
|
||
`SELECT id FROM scheduled_posts
|
||
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent')
|
||
LIMIT 1`,
|
||
[ch.id, article.id]
|
||
);
|
||
if (existing.length) continue;
|
||
|
||
const scheduledAt = await pickScheduleTime(ch);
|
||
const { rows: inserted } = await query(
|
||
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
|
||
VALUES ($1,$2,$3,'pending') RETURNING *`,
|
||
[ch.id, article.id, scheduledAt]
|
||
);
|
||
created.push(inserted[0]);
|
||
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()}`);
|
||
}
|
||
return created;
|
||
}
|
||
|
||
module.exports = { scheduleForArticle, pickScheduleTime };
|