/** * 일정 라우트 * GET: 공개, POST/PUT/DELETE: 인증 필요 */ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import { CATEGORY_IDS } from '../../config/index.js'; import { getCategories, getScheduleDetail, 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 { return await getCategories(db, redis); } 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 result = await getScheduleDetail( db, request.params.id, (username) => fastify.xBot.getProfile(username) ); if (!result) { return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); } 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 } = fastify; // 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습) if (offset === 0) { // 비동기로 저장 (응답 지연 방지) saveSearchQueryAsync(fastify, query); } // Meilisearch 검색 (페이징 포함) const results = await searchSchedules(meilisearch, db, query, { offset, limit }); return { schedules: results.hits, total: results.total, offset: results.offset, limit: results.limit, hasMore: results.hasMore, }; } /** * 검색어 비동기 저장 */ 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}`); } }