import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { formatDate, formatTime } from '../../utils/date.js'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { errorResponse, xPostInfoQuery, xScheduleCreate, } from '../../schemas/index.js'; const X_CATEGORY_ID = CATEGORY_IDS.X; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; const DEFAULT_USERNAME = config.x.defaultUsername; /** * X(Twitter) 관련 관리자 라우트 */ export default async function xRoutes(fastify) { const { db, meilisearch } = fastify; /** * GET /api/admin/x/post-info * X 게시글 정보 조회 */ fastify.get('/post-info', { schema: { tags: ['admin/x'], summary: 'X 게시글 정보 조회', description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.', security: [{ bearerAuth: [] }], querystring: xPostInfoQuery, response: { 200: { type: 'object', properties: { postId: { type: 'string' }, username: { type: 'string' }, text: { type: 'string' }, title: { type: 'string' }, imageUrls: { type: 'array', items: { type: 'string' } }, date: { type: 'string' }, time: { type: 'string' }, postUrl: { type: 'string' }, profile: { type: 'object', properties: { username: { type: 'string' }, displayName: { type: 'string' }, avatarUrl: { type: 'string' }, }, }, }, }, 400: errorResponse, 500: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { postId, username = DEFAULT_USERNAME } = request.query; // 게시글 ID 유효성 검사 if (!/^\d+$/.test(postId)) { return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' }); } try { const tweet = await fetchSingleTweet(NITTER_URL, username, postId); return { postId: tweet.id, username, text: tweet.text, title: extractTitle(tweet.text), imageUrls: tweet.imageUrls, date: tweet.time ? formatDate(tweet.time) : null, time: tweet.time ? formatTime(tweet.time) : null, postUrl: tweet.url, profile: tweet.profile, }; } catch (err) { fastify.log.error(`X 게시글 조회 오류: ${err.message}`); return reply.code(500).send({ error: err.message }); } }); /** * POST /api/admin/x/schedule * X 일정 저장 */ fastify.post('/schedule', { schema: { tags: ['admin/x'], summary: 'X 일정 저장', description: 'X(Twitter) 게시글을 일정으로 등록합니다.', security: [{ bearerAuth: [] }], body: xScheduleCreate, response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, scheduleId: { type: 'integer' }, }, }, 409: errorResponse, 500: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { postId, title, content, imageUrls, date, time } = request.body; try { // 중복 체크 const [existing] = await db.query( 'SELECT id FROM schedule_x WHERE post_id = ?', [postId] ); if (existing.length > 0) { return reply.code(409).send({ error: '이미 등록된 게시글입니다.' }); } // schedules 테이블에 저장 const [result] = await db.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', [X_CATEGORY_ID, title, date, time || null] ); const scheduleId = result.insertId; // schedule_x 테이블에 저장 await db.query( 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', [scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null] ); // Meilisearch 동기화 const [categoryRows] = await db.query( 'SELECT name, color FROM schedule_categories WHERE id = ?', [X_CATEGORY_ID] ); const category = categoryRows[0] || {}; await addOrUpdateSchedule(meilisearch, { id: scheduleId, title, date, time: time || '', category_id: X_CATEGORY_ID, category_name: category.name || '', category_color: category.color || '', source_name: '', }); return { success: true, scheduleId }; } catch (err) { fastify.log.error(`X 일정 저장 오류: ${err.message}`); return reply.code(500).send({ error: err.message }); } }); }