feat: /api/calendar endpoint (user_posts + scheduled_posts)

This commit is contained in:
Nik (Claude)
2026-06-08 10:16:49 +03:00
parent a370b8f7d8
commit 008323fa74
2 changed files with 162 additions and 0 deletions
+160
View File
@@ -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;