/** * 스케줄 서비스 * 스케줄 관련 비즈니스 로직 */ import config, { CATEGORY_IDS } from '../config/index.js'; import { getOrSet, cacheKeys, TTL } from '../utils/cache.js'; // ==================== 공통 포맷팅 함수 ==================== /** * 날짜 문자열 정규화 * @param {Date|string} date - 날짜 * @returns {string} YYYY-MM-DD 형식 */ export function normalizeDate(date) { if (!date) return ''; if (date instanceof Date) { return date.toISOString().split('T')[0]; } return String(date).split('T')[0]; } /** * datetime 생성 (date + time) * @param {Date|string} date - 날짜 * @param {string} time - 시간 (HH:mm:ss) * @returns {string} YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD */ export function buildDatetime(date, time) { const dateStr = normalizeDate(date); return time ? `${dateStr}T${time}` : dateStr; } /** * source 객체 생성 * @param {object} schedule - 일정 원본 데이터 * @returns {object|null} { name, url } 또는 null */ export function buildSource(schedule) { const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule; if (category_id === CATEGORY_IDS.YOUTUBE && youtube_video_id) { const url = youtube_video_type === 'shorts' ? `https://www.youtube.com/shorts/${youtube_video_id}` : `https://www.youtube.com/watch?v=${youtube_video_id}`; return { name: youtube_channel || 'YouTube', url, }; } if (category_id === CATEGORY_IDS.X && x_post_id) { return { name: '', url: `https://x.com/${config.x.defaultUsername}/status/${x_post_id}`, }; } return null; } /** * 단일 일정 포맷팅 (공통) * @param {object} rawSchedule - DB에서 조회한 원본 일정 * @param {string[]} members - 멤버 이름 배열 * @returns {object} 포맷된 일정 객체 */ export function formatSchedule(rawSchedule, members = []) { return { id: rawSchedule.id, title: rawSchedule.title, date: normalizeDate(rawSchedule.date), time: rawSchedule.time || null, category: { id: rawSchedule.category_id, name: rawSchedule.category_name, color: rawSchedule.category_color, }, source: buildSource(rawSchedule), members, }; } /** * 현재 활동 멤버 수 조회 * @param {object} db - 데이터베이스 연결 * @returns {number} 현재 활동 멤버 수 */ async function getActiveMemberCount(db) { const [[{ count }]] = await db.query( 'SELECT COUNT(*) as count FROM members WHERE is_former = 0' ); return count; } /** * 멤버 맵 조회 (일정 ID → 멤버 이름 배열) * 전체 멤버인 경우 "프로미스나인"으로 대체 * @param {object} db - 데이터베이스 연결 * @param {number[]} scheduleIds - 일정 ID 배열 * @returns {object} { scheduleId: [memberName, ...] } */ export async function buildMemberMap(db, scheduleIds) { if (!scheduleIds || scheduleIds.length === 0) { return {}; } // 현재 활동 멤버 수 조회 const activeMemberCount = await getActiveMemberCount(db); 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 (?) AND m.is_former = 0 ORDER BY m.id `, [scheduleIds]); const memberMap = {}; for (const sm of scheduleMembers) { if (!memberMap[sm.schedule_id]) { memberMap[sm.schedule_id] = []; } memberMap[sm.schedule_id].push(sm.name); } // 전체 멤버인 경우 "프로미스나인"으로 대체 for (const scheduleId of Object.keys(memberMap)) { if (memberMap[scheduleId].length === activeMemberCount) { memberMap[scheduleId] = ['프로미스나인']; } } return memberMap; } /** * 일정 목록 포맷팅 (공통) * @param {object[]} rawSchedules - DB에서 조회한 원본 일정 배열 * @param {object} memberMap - 멤버 맵 * @returns {object[]} 포맷된 일정 배열 */ export function formatSchedules(rawSchedules, memberMap) { return rawSchedules.map(s => formatSchedule(s, memberMap[s.id] || [])); } // ==================== 카테고리 ==================== /** * 카테고리 목록 조회 (캐시 적용) * @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 activeMemberCount = await getActiveMemberCount(db); // 멤버 정보 조회 (탈퇴 멤버 제외) 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 = ? AND m.is_former = 0 ORDER BY m.id `, [id]); // 전체 멤버인 경우 "프로미스나인"으로 대체 const formattedMembers = members.length === activeMemberCount ? [{ id: 0, name: '프로미스나인' }] : members; // 공통 필드 const result = { id: s.id, title: s.title, date: normalizeDate(s.date), time: s.time || null, category: { id: s.category_id, name: s.category_name, color: s.category_color, }, members: formattedMembers, createdAt: s.created_at, updatedAt: s.updated_at, }; // 카테고리별 추가 필드 if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { 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) { 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; } // ==================== 일정 목록 조회 ==================== /** 일정 목록 조회용 공통 SQL */ const SCHEDULE_LIST_SQL = ` 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 `; /** * 월별 일정 조회 (생일 포함) * @param {object} db - 데이터베이스 연결 * @param {number} year - 연도 * @param {number} month - 월 * @returns {object} { schedules: [] } */ 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]; // 일정 조회 const [rawSchedules] = await db.query( `${SCHEDULE_LIST_SQL} WHERE s.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC`, [startDate, endDate] ); // 멤버 맵 조회 const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id)); // 일정 포맷팅 const schedules = formatSchedules(rawSchedules, memberMap); // 생일 조회 및 추가 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]); for (const member of birthdays) { const birthDate = new Date(member.birth_date); if (year < birthDate.getFullYear()) continue; const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate()); schedules.push({ id: `birthday-${member.id}`, title: `HAPPY ${member.name_en} DAY`, date: birthdayDate.toISOString().split('T')[0], time: null, category: { id: CATEGORY_IDS.BIRTHDAY, name: '생일', color: '#f472b6', }, source: null, members: [member.name], is_birthday: true, member_image: member.image_url, }); } // 날짜순 정렬 schedules.sort((a, b) => a.date.localeCompare(b.date)); return { schedules }; } /** * 다가오는 일정 조회 * @param {object} db - 데이터베이스 연결 * @param {string} startDate - 시작 날짜 * @param {number} limit - 조회 개수 * @returns {object} { schedules: [] } */ export async function getUpcomingSchedules(db, startDate, limit) { // 일정 조회 const [rawSchedules] = await db.query( `${SCHEDULE_LIST_SQL} WHERE s.date >= ? ORDER BY s.date ASC, s.time ASC LIMIT ?`, [startDate, limit] ); // 멤버 맵 조회 const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id)); // 일정 포맷팅 const schedules = formatSchedules(rawSchedules, memberMap); return { schedules }; }