/** * 스케줄 서비스 * 스케줄 관련 비즈니스 로직 */ import config, { CATEGORY_IDS } from '../config/index.js'; import { getOrSet, cacheKeys, TTL } from '../utils/cache.js'; /** * 카테고리 목록 조회 (캐시 적용) * @param {object} db - 데이터베이스 연결 * @param {object} redis - Redis 클라이언트 (선택적) * @returns {array} 카테고리 목록 */ export async function getCategories(db, redis = null) { const fetchCategories = async () => { const [categories] = await db.query( 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' ); return categories; }; if (redis) { return getOrSet(redis, cacheKeys.categories, fetchCategories, TTL.VERY_LONG); } return fetchCategories(); } /** * 일정 상세 조회 * @param {object} db - 데이터베이스 연결 * @param {number} id - 일정 ID * @param {Function} getXProfile - X 프로필 조회 함수 (선택적) * @returns {object|null} 일정 상세 또는 null */ export async function getScheduleDetail(db, id, getXProfile = null) { 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 null; } 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}`; // 프로필 정보 (선택적) if (getXProfile) { const profile = await getXProfile(username); if (profile) { result.profile = { username: profile.username, displayName: profile.displayName, avatarUrl: profile.avatarUrl, }; } } } return result; } /** * 월별 일정 조회 (생일 포함) * @param {object} db - 데이터베이스 연결 * @param {number} year - 연도 * @param {number} month - 월 * @returns {object} 날짜별로 그룹화된 일정 */ export async function getMonthlySchedules(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 정보 추가 if (s.category_id === CATEGORY_IDS.YOUTUBE && 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 === CATEGORY_IDS.X && s.x_post_id) { schedule.source = { name: '', url: `https://x.com/${config.x.defaultUsername}/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: [], }; } // 생일 카테고리 const BIRTHDAY_CATEGORY = { id: CATEGORY_IDS.BIRTHDAY, 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 === CATEGORY_IDS.BIRTHDAY); if (existingBirthdayCategory) { existingBirthdayCategory.count++; } else { grouped[dateKey].categories.push({ ...BIRTHDAY_CATEGORY, count: 1, }); } } return grouped; } /** * 다가오는 일정 조회 (startDate부터 limit개) * @param {object} db - 데이터베이스 연결 * @param {string} startDate - 시작 날짜 * @param {number} limit - 조회 개수 * @returns {array} 일정 목록 */ export async function getUpcomingSchedules(db, startDate, limit) { 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 FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id WHERE s.date >= ? ORDER BY s.date ASC, s.time ASC LIMIT ? `, [startDate, limit]); // 멤버 정보 조회 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 }); } } // 결과 포맷팅 return schedules.map(s => { const scheduleMembers = memberMap[s.id] || []; const members = scheduleMembers.length >= 5 ? [{ name: '프로미스나인' }] : scheduleMembers; return { id: s.id, title: s.title, date: s.date, time: s.time, category_id: s.category_id, category_name: s.category_name, category_color: s.category_color, members, }; }); }