fromis_9/backend/src/routes/schedules/index.js

426 lines
12 KiB
JavaScript
Raw Normal View History

/**
* 일정 라우트
* GET: 공개, POST/PUT/DELETE: 인증 필요
*/
import suggestionsRoutes from './suggestions.js';
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/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: '카테고리 목록 조회',
},
}, async (request, reply) => {
const [categories] = await db.query(
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
);
return categories;
});
/**
* GET /api/schedules
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
* 월별 조회 모드: year, month 파라미터로 월별 조회
*/
fastify.get('/', {
schema: {
tags: ['schedules'],
summary: '일정 조회 (검색 또는 월별)',
querystring: {
type: 'object',
properties: {
search: { type: 'string', description: '검색어' },
year: { type: 'integer', description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
offset: { type: 'integer', default: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, description: '결과 개수' },
},
},
},
}, async (request, reply) => {
const { search, year, month, offset = 0, limit = 100 } = request.query;
// 검색 모드
if (search && search.trim()) {
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
}
// 월별 조회 모드
if (!year || !month) {
return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' });
}
return await handleMonthlySchedules(db, parseInt(year), parseInt(month));
});
/**
* POST /api/schedules/sync-search
* Meilisearch 전체 동기화 (관리자 전용)
*/
fastify.post('/sync-search', {
schema: {
tags: ['schedules'],
summary: 'Meilisearch 전체 동기화',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const count = await syncAllSchedules(meilisearch, db);
return { success: true, synced: count };
});
/**
* GET /api/schedules/:id
* 일정 상세 조회
*/
fastify.get('/:id', {
schema: {
tags: ['schedules'],
summary: '일정 상세 조회',
},
}, async (request, reply) => {
const { id } = request.params;
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
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 reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
}
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]);
const result = {
id: s.id,
title: s.title,
date: s.date,
time: s.time,
category: {
id: s.category_id,
name: s.category_name,
color: s.category_color,
},
members: members,
youtube: s.youtube_video_id ? {
videoId: s.youtube_video_id,
videoType: s.youtube_video_type,
channelName: s.youtube_channel,
} : null,
x: s.x_post_id ? {
postId: s.x_post_id,
} : null,
created_at: s.created_at,
updated_at: s.updated_at,
};
// source 정보 추가 (YouTube: 2, X: 3)
if (s.category_id === 2 && 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}`;
result.source = {
name: s.youtube_channel || 'YouTube',
url: videoUrl,
};
} else if (s.category_id === 3 && s.x_post_id) {
result.source = {
name: '',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
};
}
return result;
});
/**
* DELETE /api/schedules/:id
* 일정 삭제 (인증 필요)
*/
fastify.delete('/:id', {
schema: {
tags: ['schedules'],
summary: '일정 삭제',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
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 (err) {
fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`);
}
return { success: true };
});
}
/**
* 검색 처리
*/
async function handleSearch(fastify, query, offset, limit) {
const { db, meilisearch, redis } = fastify;
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
if (offset === 0) {
// 비동기로 저장 (응답 지연 방지)
saveSearchQueryAsync(fastify, query);
}
// Meilisearch 검색
const results = await searchSchedules(meilisearch, db, query, { limit: 1000 });
// 페이징 적용
const paginatedHits = results.hits.slice(offset, offset + limit);
return {
schedules: paginatedHits,
total: results.total,
offset,
limit,
hasMore: offset + paginatedHits.length < results.total,
};
}
/**
* 검색어 비동기 저장
*/
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) {
console.error('[Search] 검색어 저장 실패:', err.message);
}
}
/**
* 월별 일정 조회 (생일 포함)
*/
async function handleMonthlySchedules(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 정보 추가 (YouTube: 2, X: 3)
if (s.category_id === 2 && 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 === 3 && s.x_post_id) {
schedule.source = {
name: '',
url: `https://x.com/realfromis_9/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: [],
};
}
// 생일 카테고리 (id: 8)
const BIRTHDAY_CATEGORY = {
id: 8,
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 === 8);
if (existingBirthdayCategory) {
existingBirthdayCategory.count++;
} else {
grouped[dateKey].categories.push({
...BIRTHDAY_CATEGORY,
count: 1,
});
}
}
return grouped;
}