From 008323fa749722b133bf9dcdbd07abafc9d8f7d6 Mon Sep 17 00:00:00 2001 From: "Nik (Claude)" Date: Mon, 8 Jun 2026 10:16:49 +0300 Subject: [PATCH] feat: /api/calendar endpoint (user_posts + scheduled_posts) --- index.js | 2 + src/routes/calendar.js | 160 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/routes/calendar.js diff --git a/index.js b/index.js index 92e5e92..11232c6 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ const settingsRoutes = require('./src/routes/settings'); const photoSearchRoutes = require('./src/routes/photo-search'); const scheduledPostsRoutes = require('./src/routes/scheduledPosts'); const channelStatsRoutes = require('./src/routes/channelStats'); +const calendarRoutes = require('./src/routes/calendar'); // Start queue worker require('./src/workers/generation'); @@ -56,6 +57,7 @@ app.use('/api/settings', settingsRoutes); app.use('/api/photo-search', photoSearchRoutes); app.use('/api/scheduled-posts', scheduledPostsRoutes); app.use('/api/channel-stats', channelStatsRoutes); +app.use('/api/calendar', calendarRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/routes/calendar.js b/src/routes/calendar.js new file mode 100644 index 0000000..5e44ebc --- /dev/null +++ b/src/routes/calendar.js @@ -0,0 +1,160 @@ +/** + * 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;