import express from "express"; import pool from "../lib/db.js"; import { searchSchedules } from "../services/meilisearch.js"; import { saveSearchQuery, getSuggestions } from "../services/suggestions.js"; import { getXProfile } from "../services/x-bot.js"; const router = express.Router(); // 검색어 추천 API (Bi-gram 기반) router.get("/suggestions", async (req, res) => { try { const { q, limit } = req.query; if (!q || q.trim().length === 0) { return res.json({ suggestions: [] }); } const suggestions = await getSuggestions(q, parseInt(limit) || 10); res.json({ suggestions }); } catch (error) { console.error("추천 검색어 오류:", error); res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." }); } }); // 공개 일정 목록 조회 (검색 포함) router.get("/", async (req, res) => { try { const { search, startDate, endDate, limit, year, month } = req.query; // 검색어가 있으면 Meilisearch 사용 if (search && search.trim()) { const offset = parseInt(req.query.offset) || 0; const pageLimit = parseInt(req.query.limit) || 100; // 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습) if (offset === 0) { saveSearchQuery(search.trim()).catch((err) => console.error("검색어 저장 실패:", err.message) ); } // Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징) const results = await searchSchedules(search.trim(), { limit: 1000, // 내부적으로 1000개까지 검색 }); // 페이징 적용 const paginatedHits = results.hits.slice(offset, offset + pageLimit); return res.json({ schedules: paginatedHits, total: results.total, offset: offset, limit: pageLimit, hasMore: offset + paginatedHits.length < results.total, }); } // 날짜 필터 및 제한 조건 구성 let whereClause = "WHERE 1=1"; const params = []; // 년/월 필터링 (월별 데이터 로딩용) if (year && month) { whereClause += " AND YEAR(s.date) = ? AND MONTH(s.date) = ?"; params.push(parseInt(year), parseInt(month)); } else if (year) { whereClause += " AND YEAR(s.date) = ?"; params.push(parseInt(year)); } if (startDate) { whereClause += " AND s.date >= ?"; params.push(startDate); } if (endDate) { whereClause += " AND s.date <= ?"; params.push(endDate); } // limit 파라미터 처리 const limitClause = limit ? `LIMIT ${parseInt(limit)}` : ""; // 검색어 없으면 DB에서 전체 조회 const [schedules] = await pool.query( ` SELECT s.id, s.title, s.description, s.date, s.time, s.category_id, s.source_url, s.source_name, s.location_name, c.name as category_name, c.color as category_color, GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id ${whereClause} GROUP BY s.id ORDER BY s.date ASC, s.time ASC ${limitClause} `, params ); // 년/월 필터가 있으면 해당 월의 현재 멤버 생일을 가상 일정으로 추가 if (year && month) { const [birthdays] = await pool.query( `SELECT id, name, name_en, birth_date, image_url FROM members WHERE is_former = 0 AND MONTH(birth_date) = ?`, [parseInt(month)] ); const birthdaySchedules = birthdays.map((member) => { const birthDate = new Date(member.birth_date); const birthdayThisYear = new Date( parseInt(year), birthDate.getMonth(), birthDate.getDate() ); return { id: `birthday-${member.id}`, title: `HAPPY ${member.name_en} DAY`, description: null, date: birthdayThisYear, time: null, category_id: 8, source_url: null, source_name: null, location_name: null, category_name: "생일", category_color: "#f472b6", member_names: member.name, is_birthday: true, member_image: member.image_url, }; }); // 일정과 생일을 합쳐서 날짜순 정렬 const allSchedules = [...schedules, ...birthdaySchedules].sort( (a, b) => new Date(a.date) - new Date(b.date) ); return res.json(allSchedules); } res.json(schedules); } catch (error) { console.error("일정 목록 조회 오류:", error); res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." }); } }); // 카테고리 목록 조회 router.get("/categories", async (req, res) => { try { const [categories] = await pool.query(` SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC `); res.json(categories); } catch (error) { console.error("카테고리 조회 오류:", error); res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); } }); // 개별 일정 조회 router.get("/:id", async (req, res) => { try { const { id } = req.params; const [schedules] = await pool.query( ` SELECT s.*, c.name as category_name, c.color as category_color FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id WHERE s.id = ? `, [id] ); if (schedules.length === 0) { return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); } const schedule = schedules[0]; // 이미지 조회 const [images] = await pool.query( `SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`, [id] ); schedule.images = images.map((img) => img.image_url); // 멤버 조회 const [members] = await pool.query( `SELECT m.id, m.name FROM members m JOIN schedule_members sm ON m.id = sm.member_id WHERE sm.schedule_id = ? ORDER BY m.id`, [id] ); schedule.members = members; // 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회 if (schedule.category_id === 6) { const [relatedSchedules] = await pool.query( ` SELECT id, date, time FROM schedules WHERE title = ? AND category_id = 6 ORDER BY date ASC, time ASC `, [schedule.title] ); schedule.related_dates = relatedSchedules; } res.json(schedule); } catch (error) { console.error("일정 조회 오류:", error); res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); } }); // Meilisearch 동기화 API router.post("/sync-search", async (req, res) => { try { const { syncAllSchedules } = await import("../services/meilisearch.js"); // DB에서 모든 일정 조회 const [schedules] = await pool.query(` SELECT s.id, s.title, s.description, s.date, s.time, s.category_id, s.source_url, s.source_name, c.name as category_name, c.color as category_color, GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id GROUP BY s.id `); const count = await syncAllSchedules(schedules); res.json({ success: true, synced: count }); } catch (error) { console.error("Meilisearch 동기화 오류:", error); res.status(500).json({ error: "동기화 중 오류가 발생했습니다." }); } }); // X 프로필 정보 조회 router.get("/x-profile/:username", async (req, res) => { try { const { username } = req.params; const profile = await getXProfile(username); if (!profile) { return res.status(404).json({ error: "프로필을 찾을 수 없습니다." }); } res.json(profile); } catch (error) { console.error("X 프로필 조회 오류:", error); res.status(500).json({ error: "프로필 조회 중 오류가 발생했습니다." }); } }); export default router;