/** * 일정 라우트 * GET: 공개, POST/PUT/DELETE: 인증 필요 */ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/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: '카테고리 목록 조회', }, }, async (request, reply) => { const [categories] = await db.query( 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' ); return categories; }); /** * GET /api/schedules * 검색 모드: search 파라미터가 있으면 Meilisearch 검색 * 월별 조회 모드: year, month 파라미터로 월별 조회 */ fastify.get('/', { schema: { tags: ['schedules'], summary: '일정 조회 (검색 또는 월별)', querystring: { type: 'object', properties: { search: { type: 'string', description: '검색어' }, year: { type: 'integer', description: '년도' }, month: { type: 'integer', minimum: 1, maximum: 12, description: '월' }, offset: { type: 'integer', default: 0, description: '페이지 오프셋' }, limit: { type: 'integer', default: 100, description: '결과 개수' }, }, }, }, }, async (request, reply) => { const { search, year, month, offset = 0, limit = 100 } = request.query; // 검색 모드 if (search && search.trim()) { return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit)); } // 월별 조회 모드 if (!year || !month) { return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' }); } return await handleMonthlySchedules(db, parseInt(year), parseInt(month)); }); /** * POST /api/schedules/sync-search * Meilisearch 전체 동기화 (관리자 전용) */ fastify.post('/sync-search', { schema: { tags: ['schedules'], summary: 'Meilisearch 전체 동기화', security: [{ bearerAuth: [] }], }, preHandler: [fastify.authenticate], }, async (request, reply) => { const count = await syncAllSchedules(meilisearch, db); return { success: true, synced: count }; }); /** * GET /api/schedules/:id * 일정 상세 조회 */ fastify.get('/:id', { schema: { tags: ['schedules'], summary: '일정 상세 조회', }, }, async (request, reply) => { 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 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]); const result = { id: s.id, title: s.title, date: s.date, time: s.time, category: { id: s.category_id, name: s.category_name, color: s.category_color, }, members: members, youtube: s.youtube_video_id ? { videoId: s.youtube_video_id, videoType: s.youtube_video_type, channelName: s.youtube_channel, } : null, x: s.x_post_id ? { postId: s.x_post_id, } : null, created_at: s.created_at, updated_at: s.updated_at, }; // source 정보 추가 (YouTube: 2, X: 3) if (s.category_id === 2 && s.youtube_video_id) { const 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}`; result.source = { name: s.youtube_channel || 'YouTube', url: videoUrl, }; } else if (s.category_id === 3 && s.x_post_id) { result.source = { name: '', url: `https://x.com/realfromis_9/status/${s.x_post_id}`, }; } return result; }); /** * DELETE /api/schedules/:id * 일정 삭제 (인증 필요) */ fastify.delete('/:id', { schema: { tags: ['schedules'], summary: '일정 삭제', security: [{ bearerAuth: [] }], }, preHandler: [fastify.authenticate], }, async (request, reply) => { 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 (err) { fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`); } return { success: true }; }); } /** * 검색 처리 */ 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) { console.error('[Search] 검색어 저장 실패:', err.message); } } /** * 월별 일정 조회 (생일 포함) */ async function handleMonthlySchedules(db, year, month) { const startDate = `${year}-${String(month).padStart(2, '0')}-01`; const endDate = new Date(year, month, 0).toISOString().split('T')[0]; // 일정 조회 (YouTube, X 소스 정보 포함) const [schedules] = await db.query(` SELECT s.id, s.title, s.date, s.time, s.category_id, 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 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.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC `, [startDate, endDate]); // 일정 멤버 조회 const scheduleIds = schedules.map(s => s.id); let memberMap = {}; if (scheduleIds.length > 0) { const [scheduleMembers] = await db.query(` SELECT sm.schedule_id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id IN (?) ORDER BY m.id `, [scheduleIds]); // 일정별 멤버 그룹화 for (const sm of scheduleMembers) { if (!memberMap[sm.schedule_id]) { memberMap[sm.schedule_id] = []; } memberMap[sm.schedule_id].push({ name: sm.name }); } } // 생일 조회 const [birthdays] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, i.thumb_url as image_url FROM members m LEFT JOIN images i ON m.image_id = i.id WHERE m.is_former = 0 AND MONTH(m.birth_date) = ? `, [month]); // 날짜별로 그룹화 const grouped = {}; // 일정 추가 for (const s of schedules) { const dateKey = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date; if (!grouped[dateKey]) { grouped[dateKey] = { categories: [], schedules: [], }; } // 멤버 정보 (5명 이상이면 프로미스나인) const scheduleMembers = memberMap[s.id] || []; const members = scheduleMembers.length >= 5 ? [{ name: '프로미스나인' }] : scheduleMembers; const schedule = { id: s.id, title: s.title, time: s.time, category: { id: s.category_id, name: s.category_name, color: s.category_color, }, members, }; // source 정보 추가 (YouTube: 2, X: 3) if (s.category_id === 2 && s.youtube_video_id) { const 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}`; schedule.source = { name: s.youtube_channel || 'YouTube', url: videoUrl, }; } else if (s.category_id === 3 && s.x_post_id) { schedule.source = { name: '', url: `https://x.com/realfromis_9/status/${s.x_post_id}`, }; } grouped[dateKey].schedules.push(schedule); // 카테고리 카운트 const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id); if (existingCategory) { existingCategory.count++; } else { grouped[dateKey].categories.push({ id: s.category_id, name: s.category_name, color: s.category_color, count: 1, }); } } // 생일 일정 추가 for (const member of birthdays) { const birthDate = new Date(member.birth_date); const birthYear = birthDate.getFullYear(); // 조회 연도가 생년보다 이전이면 스킵 if (year < birthYear) continue; const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate()); const dateKey = birthdayThisYear.toISOString().split('T')[0]; if (!grouped[dateKey]) { grouped[dateKey] = { categories: [], schedules: [], }; } // 생일 카테고리 (id: 8) const BIRTHDAY_CATEGORY = { id: 8, name: '생일', color: '#f472b6', }; const birthdaySchedule = { id: `birthday-${member.id}`, title: `HAPPY ${member.name_en} DAY`, time: null, category: BIRTHDAY_CATEGORY, is_birthday: true, member_name: member.name, member_image: member.image_url, }; grouped[dateKey].schedules.push(birthdaySchedule); // 생일 카테고리 카운트 const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === 8); if (existingBirthdayCategory) { existingBirthdayCategory.count++; } else { grouped[dateKey].categories.push({ ...BIRTHDAY_CATEGORY, count: 1, }); } } return grouped; }