/** * GET /api/calendar * Возвращает объединённый список событий за период: * - user_posts (черновики / запланированные / опубликованные пользователем) * - scheduled_posts (авто-публикации статей блога) * * Query params: * from — ISO date начала (включительно), default = начало текущего месяца * to — ISO date конца (включительно), default = конец следующего месяца * channel_id — фильтр по каналу (опционально) * user_id — берётся из заголовка x-user-id */ const express = require('express'); const router = express.Router(); const { query } = require('../config/db'); const getUserId = (req) => parseInt(req.headers['x-user-id']) || null; router.get('/', async (req, res) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); // Диапазон дат const now = new Date(); const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1); const defaultTo = new Date(now.getFullYear(), now.getMonth() + 2, 0, 23, 59, 59); const from = req.query.from ? new Date(req.query.from) : defaultFrom; const to = req.query.to ? new Date(req.query.to) : defaultTo; const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null; const events = []; // ── user_posts ──────────────────────────────────────────────────────────── // Берём посты юзера у которых scheduled_at или published_at попадает в диапазон const upParams = [userId, from, to]; let upWhere = `up.user_id = $1 AND ( (up.scheduled_at IS NOT NULL AND up.scheduled_at BETWEEN $2 AND $3) OR (up.published_at IS NOT NULL AND up.published_at BETWEEN $2 AND $3) OR (up.scheduled_at IS NULL AND up.published_at IS NULL AND up.status='draft' AND up.created_at BETWEEN $2 AND $3) )`; if (channelId) { upParams.push(channelId); upWhere += ` AND up.channel_id = $${upParams.length}`; } const { rows: userPosts } = await query( `SELECT up.id, up.channel_id, up.status, up.scheduled_at, up.published_at, up.created_at, left(up.content, 120) AS content_preview, up.topic, up.image_url, c.name AS channel_name, c.platform FROM user_posts up JOIN channels c ON c.id = up.channel_id WHERE ${upWhere} ORDER BY COALESCE(up.scheduled_at, up.published_at, up.created_at) ASC LIMIT 500`, upParams ); for (const p of userPosts) { events.push({ id: `up_${p.id}`, source: 'user_post', source_id: p.id, channel_id: p.channel_id, channel_name: p.channel_name, platform: p.platform, status: p.status, // draft | scheduled | published | failed date: p.scheduled_at || p.published_at || p.created_at, scheduled_at: p.scheduled_at, published_at: p.published_at, title: p.topic || null, preview: p.content_preview, image_url: p.image_url, editable: true, // можно перетаскивать / редактировать }); } // ── scheduled_posts (авто-публикации статей блога) ──────────────────────── // Отдаём только если нет фильтра по каналу, или канал совпадает const spParams = [from, to]; let spWhere = `sp.scheduled_at BETWEEN $1 AND $2`; if (channelId) { spParams.push(channelId); spWhere += ` AND sp.channel_id = $${spParams.length}`; } else { // Только каналы этого юзера (или системные) spParams.push(userId); spWhere += ` AND (c.user_id = $${spParams.length} OR c.is_system = true)`; } const { rows: schedPosts } = await query( `SELECT sp.id, sp.channel_id, sp.status, sp.scheduled_at, sp.published_at, sp.error, a.title AS article_title, a.slug AS article_slug, a.category AS article_category, a.cover_url, c.name AS channel_name, c.platform FROM scheduled_posts sp JOIN channels c ON c.id = sp.channel_id LEFT JOIN articles a ON a.id = sp.article_id WHERE ${spWhere} ORDER BY sp.scheduled_at ASC LIMIT 500`, spParams ); for (const p of schedPosts) { // Маппинг статусов: pending → scheduled, sent → published const status = p.status === 'sent' ? 'published' : p.status === 'pending' ? 'scheduled' : p.status; // failed events.push({ id: `sp_${p.id}`, source: 'scheduled_post', source_id: p.id, channel_id: p.channel_id, channel_name: p.channel_name, platform: p.platform, status, date: p.published_at || p.scheduled_at, scheduled_at: p.scheduled_at, published_at: p.published_at, title: p.article_title || null, preview: p.article_title || null, image_url: p.cover_url || null, article_slug: p.article_slug || null, article_category: p.article_category || null, error: p.error || null, editable: p.status === 'pending', // pending можно отменить/перенести }); } // Сортируем общий список по дате events.sort((a, b) => new Date(a.date) - new Date(b.date)); res.json({ from: from.toISOString(), to: to.toISOString(), count: events.length, events, }); } catch (err) { console.error('[Calendar] Error:', err); res.status(500).json({ error: err.message }); } }); module.exports = router;