diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index 3fd0817..65f19e0 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -8,6 +8,7 @@ import Inko from 'inko'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { createLogger } from '../../utils/logger.js'; +import { buildDatetime } from '../schedule.js'; const inko = new Inko(); const logger = createLogger('Meilisearch'); @@ -122,28 +123,16 @@ export async function searchSchedules(meilisearch, db, query, options = {}) { /** * 검색 결과 응답 형식 변환 + * schedule.js의 공통 포맷과 동일한 구조 반환 + * (Meilisearch 인덱스 필드명이 다르므로 별도 매핑 필요) */ function formatScheduleResponse(hit) { - // date + time 합치기 - let datetime = null; - if (hit.date) { - const dateStr = hit.date instanceof Date - ? hit.date.toISOString().split('T')[0] - : String(hit.date).split('T')[0]; - - if (hit.time) { - datetime = `${dateStr}T${hit.time}`; - } else { - datetime = dateStr; - } - } - // member_names를 배열로 변환 const members = hit.member_names ? hit.member_names.split(',').map(name => name.trim()).filter(Boolean) : []; - // source 객체 구성 (X는 name 비움) + // source 객체 구성 (Meilisearch에는 URL 없음) let source = null; if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) { source = { name: hit.source_name, url: null }; @@ -154,7 +143,7 @@ function formatScheduleResponse(hit) { return { id: hit.id, title: hit.title, - datetime, + datetime: buildDatetime(hit.date, hit.time), category: { id: hit.category_id, name: hit.category_name, diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 1457fb9..b05cb9d 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -5,6 +5,123 @@ 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, + datetime: buildDatetime(rawSchedule.date, rawSchedule.time), + category: { + id: rawSchedule.category_id, + name: rawSchedule.category_name, + color: rawSchedule.category_color, + }, + source: buildSource(rawSchedule), + members, + }; +} + +/** + * 멤버 맵 조회 (일정 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 [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]); + + const memberMap = {}; + for (const sm of scheduleMembers) { + if (!memberMap[sm.schedule_id]) { + memberMap[sm.schedule_id] = []; + } + memberMap[sm.schedule_id].push(sm.name); + } + + 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 - 데이터베이스 연결 @@ -25,6 +142,8 @@ export async function getCategories(db, redis = null) { return fetchCategories(); } +// ==================== 일정 상세 ==================== + /** * 일정 상세 조회 * @param {object} db - 데이터베이스 연결 @@ -66,16 +185,11 @@ export async function getScheduleDetail(db, id, getXProfile = null) { 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, + datetime: buildDatetime(s.date, s.time), category: { id: s.category_id, name: s.category_name, @@ -88,7 +202,6 @@ export async function getScheduleDetail(db, id, getXProfile = null) { // 카테고리별 추가 필드 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; @@ -96,14 +209,12 @@ export async function getScheduleDetail(db, id, getXProfile = null) { ? `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) { @@ -119,62 +230,52 @@ export async function getScheduleDetail(db, id, getXProfile = null) { 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 +`; + /** * 월별 일정 조회 (생일 포함) - * 검색 API와 동일한 형식으로 반환 * @param {object} db - 데이터베이스 연결 * @param {number} year - 연도 * @param {number} month - 월 - * @returns {object} { schedules: [] } 형식의 일정 배열 + * @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]; - // 일정 조회 (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 [rawSchedules] = await db.query( + `${SCHEDULE_LIST_SQL} WHERE s.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC`, + [startDate, endDate] + ); - // 일정 멤버 조회 - const scheduleIds = schedules.map(s => s.id); - let memberMap = {}; + // 멤버 맵 조회 + const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id)); - 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]); + // 일정 포맷팅 + const schedules = formatSchedules(rawSchedules, memberMap); - for (const sm of scheduleMembers) { - if (!memberMap[sm.schedule_id]) { - memberMap[sm.schedule_id] = []; - } - memberMap[sm.schedule_id].push(sm.name); - } - } - - // 생일 조회 + // 생일 조회 및 추가 const [birthdays] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, i.thumb_url as image_url @@ -183,68 +284,16 @@ export async function getMonthlySchedules(db, year, month) { WHERE m.is_former = 0 AND MONTH(m.birth_date) = ? `, [month]); - // 결과 배열 - const result = []; - - // 일정 추가 - for (const s of schedules) { - const dateStr = s.date instanceof Date - ? s.date.toISOString().split('T')[0] - : s.date; - - // datetime 생성 - const datetime = s.time ? `${dateStr}T${s.time}` : dateStr; - - // 멤버 이름 배열 (문자열 배열) - const members = memberMap[s.id] || []; - - const schedule = { - id: s.id, - title: s.title, - datetime, - category: { - id: s.category_id, - name: s.category_name, - color: s.category_color, - }, - source: null, - 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}`, - }; - } - - result.push(schedule); - } - - // 생일 일정 추가 for (const member of birthdays) { const birthDate = new Date(member.birth_date); - const birthYear = birthDate.getFullYear(); + if (year < birthDate.getFullYear()) continue; - // 조회 연도가 생년보다 이전이면 스킵 - if (year < birthYear) continue; + const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate()); - const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate()); - const dateKey = birthdayThisYear.toISOString().split('T')[0]; - - const birthdaySchedule = { + schedules.push({ id: `birthday-${member.id}`, title: `HAPPY ${member.name_en} DAY`, - datetime: dateKey, + datetime: birthdayDate.toISOString().split('T')[0], category: { id: CATEGORY_IDS.BIRTHDAY, name: '생일', @@ -254,115 +303,34 @@ export async function getMonthlySchedules(db, year, month) { members: [member.name], is_birthday: true, member_image: member.image_url, - }; - - result.push(birthdaySchedule); + }); } - // 날짜 + 시간 순으로 정렬 - result.sort((a, b) => a.datetime.localeCompare(b.datetime)); + // 날짜순 정렬 + schedules.sort((a, b) => a.datetime.localeCompare(b.datetime)); - return { schedules: result }; + return { schedules }; } /** - * 다가오는 일정 조회 (startDate부터 limit개) - * 검색 API와 동일한 형식으로 반환 + * 다가오는 일정 조회 * @param {object} db - 데이터베이스 연결 * @param {string} startDate - 시작 날짜 * @param {number} limit - 조회 개수 - * @returns {object} { schedules: [] } 형식의 일정 배열 + * @returns {object} { schedules: [] } */ export async function getUpcomingSchedules(db, startDate, limit) { - // 일정 조회 (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 >= ? - ORDER BY s.date ASC, s.time ASC - LIMIT ? - `, [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 scheduleIds = schedules.map(s => s.id); - let memberMap = {}; + // 멤버 맵 조회 + const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id)); - 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]); + // 일정 포맷팅 + const schedules = formatSchedules(rawSchedules, memberMap); - for (const sm of scheduleMembers) { - if (!memberMap[sm.schedule_id]) { - memberMap[sm.schedule_id] = []; - } - memberMap[sm.schedule_id].push(sm.name); - } - } - - // 결과 배열 - const result = []; - - for (const s of schedules) { - const dateStr = s.date instanceof Date - ? s.date.toISOString().split('T')[0] - : s.date; - - // datetime 생성 - const datetime = s.time ? `${dateStr}T${s.time}` : dateStr; - - // 멤버 이름 배열 (문자열 배열) - const members = memberMap[s.id] || []; - - const schedule = { - id: s.id, - title: s.title, - datetime, - category: { - id: s.category_id, - name: s.category_name, - color: s.category_color, - }, - source: null, - 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}`, - }; - } - - result.push(schedule); - } - - return { schedules: result }; + return { schedules }; }