/** * 일정 라우트 * GET: 공개, POST/PUT/DELETE: 인증 필요 */ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js'; import { errorResponse, scheduleSearchQuery, scheduleSearchResponse, idParam, } from '../../schemas/index.js'; export default async function schedulesRoutes(fastify) { const { db, meilisearch, redis } = fastify; // 추천 검색어 라우트 등록 fastify.register(suggestionsRoutes, { prefix: '/suggestions' }); /** * GET /api/schedules/categories * 카테고리 목록 조회 */ fastify.get('/categories', { schema: { tags: ['schedules'], summary: '카테고리 목록 조회', description: '일정 카테고리 목록을 조회합니다.', response: { 200: { type: 'array', items: { type: 'object', additionalProperties: true } }, }, }, }, async (request, reply) => { try { const [categories] = await db.query( 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' ); return categories; } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '카테고리 목록 조회 실패' }); } }); /** * GET /api/schedules * 검색 모드: search 파라미터가 있으면 Meilisearch 검색 * 월별 조회 모드: year, month 파라미터로 월별 조회 */ fastify.get('/', { schema: { tags: ['schedules'], summary: '일정 조회 (검색 또는 월별)', description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회', querystring: scheduleSearchQuery, response: { 200: { type: 'object', additionalProperties: true }, }, }, }, async (request, reply) => { try { const { search, year, month, startDate, offset = 0, limit = 100 } = request.query; // 검색 모드 if (search && search.trim()) { return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit)); } // 다가오는 일정 조회 (startDate부터) if (startDate) { return await getUpcomingSchedules(db, startDate, parseInt(limit)); } // 월별 조회 모드 if (!year || !month) { return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' }); } return await getMonthlySchedules(db, parseInt(year), parseInt(month)); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '일정 조회 실패' }); } }); /** * POST /api/schedules/sync-search * Meilisearch 전체 동기화 (관리자 전용) */ fastify.post('/sync-search', { schema: { tags: ['schedules'], summary: 'Meilisearch 전체 동기화', description: 'DB의 모든 일정을 Meilisearch에 동기화합니다.', security: [{ bearerAuth: [] }], response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, synced: { type: 'integer', description: '동기화된 일정 수' }, }, }, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { try { const count = await syncAllSchedules(meilisearch, db); return { success: true, synced: count }; } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '동기화 실패' }); } }); /** * GET /api/schedules/:id * 일정 상세 조회 (카테고리별 다른 형식 반환) */ fastify.get('/:id', { schema: { tags: ['schedules'], summary: '일정 상세 조회', description: '일정 ID로 상세 정보를 조회합니다. 카테고리에 따라 추가 정보(YouTube/X)가 포함됩니다.', params: idParam, response: { 200: { type: 'object', additionalProperties: true }, }, }, }, async (request, reply) => { try { const { id } = request.params; const [schedules] = await db.query(` SELECT s.*, c.name as category_name, c.color as category_color, sy.channel_name as youtube_channel, sy.video_id as youtube_video_id, sy.video_type as youtube_video_type, sx.post_id as x_post_id, sx.content as x_content, sx.image_urls as x_image_urls FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_x sx ON s.id = sx.schedule_id WHERE s.id = ? `, [id]); if (schedules.length === 0) { return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); } const s = schedules[0]; // 멤버 정보 조회 const [members] = await db.query(` SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ? ORDER BY m.id `, [id]); // datetime 생성 (date + time) const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0]; const timeStr = s.time ? s.time.slice(0, 5) : null; const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr; // 공통 필드 const result = { id: s.id, title: s.title, datetime, category: { id: s.category_id, name: s.category_name, color: s.category_color, }, members, createdAt: s.created_at, updatedAt: s.updated_at, }; // 카테고리별 추가 필드 if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { // YouTube result.videoId = s.youtube_video_id; result.videoType = s.youtube_video_type; result.channelName = s.youtube_channel; result.videoUrl = s.youtube_video_type === 'shorts' ? `https://www.youtube.com/shorts/${s.youtube_video_id}` : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { // X (Twitter) const username = config.x.defaultUsername; result.postId = s.x_post_id; result.content = s.x_content || null; result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; // 프로필 정보 (Redis 캐시 → DB) const profile = await fastify.xBot.getProfile(username); if (profile) { result.profile = { username: profile.username, displayName: profile.displayName, avatarUrl: profile.avatarUrl, }; } } return result; } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '일정 상세 조회 실패' }); } }); /** * DELETE /api/schedules/:id * 일정 삭제 (인증 필요) */ fastify.delete('/:id', { schema: { tags: ['schedules'], summary: '일정 삭제', description: '일정과 관련 데이터(YouTube/X 정보, 멤버, 이미지)를 삭제합니다.', security: [{ bearerAuth: [] }], params: idParam, response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, }, }, 404: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { try { const { id } = request.params; // 일정 존재 확인 const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); if (existing.length === 0) { return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); } // 관련 테이블 삭제 (외래 키) await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]); // 메인 테이블 삭제 await db.query('DELETE FROM schedules WHERE id = ?', [id]); // Meilisearch에서도 삭제 try { const { deleteSchedule } = await import('../../services/meilisearch/index.js'); await deleteSchedule(meilisearch, id); } catch (meiliErr) { fastify.log.error(`Meilisearch 삭제 오류: ${meiliErr.message}`); } return { success: true }; } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '일정 삭제 실패' }); } }); } /** * 검색 처리 */ async function handleSearch(fastify, query, offset, limit) { const { db, meilisearch, redis } = fastify; // 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습) if (offset === 0) { // 비동기로 저장 (응답 지연 방지) saveSearchQueryAsync(fastify, query); } // Meilisearch 검색 const results = await searchSchedules(meilisearch, db, query, { limit: 1000 }); // 페이징 적용 const paginatedHits = results.hits.slice(offset, offset + limit); return { schedules: paginatedHits, total: results.total, offset, limit, hasMore: offset + paginatedHits.length < results.total, }; } /** * 검색어 비동기 저장 */ async function saveSearchQueryAsync(fastify, query) { try { // suggestions 서비스의 saveSearchQuery 사용 const { SuggestionService } = await import('../../services/suggestions/index.js'); const service = new SuggestionService(fastify.db, fastify.redis); await service.saveSearchQuery(query); } catch (err) { fastify.log.error(`[Search] 검색어 저장 실패: ${err.message}`); } }