/** * 일정 라우트 * 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 * 검색 모드: 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 source_name 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 WHERE s.id = ? `, [id]); if (schedules.length === 0) { return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); } const s = schedules[0]; 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, }, created_at: s.created_at, updated_at: s.updated_at, }; if (s.source_name) { result.source_name = s.source_name; } return result; }); } /** * 검색 처리 */ 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]; // 일정 조회 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 source_name 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 WHERE s.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC `, [startDate, endDate]); // 생일 조회 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: [], }; } const schedule = { id: s.id, title: s.title, time: s.time, category: { id: s.category_id, name: s.category_name, color: s.category_color, }, }; if (s.source_name) { schedule.source_name = s.source_name; } 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 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; }