feat: 일정 API 추가

- GET /api/schedules?year=&month= 월별 일정 조회
- 날짜별 그룹화 + 카테고리 목록/개수 포함
- 유튜브 카테고리의 경우 source_name(채널명) 반환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-17 16:50:16 +09:00
parent 8b65f61e47
commit a5ed04e9af
3 changed files with 155 additions and 4 deletions

View file

@ -61,10 +61,10 @@ export async function buildApp(opts = {}) {
], ],
tags: [ tags: [
{ name: 'auth', description: '인증 API' }, { name: 'auth', description: '인증 API' },
{ name: 'members', description: '멤버 관리 API' }, { name: 'members', description: '멤버 API' },
{ name: 'albums', description: '앨범 관리 API' }, { name: 'albums', description: '앨범 API' },
{ name: 'schedules', description: '일정 API' },
{ name: 'stats', description: '통계 API' }, { name: 'stats', description: '통계 API' },
{ name: 'public', description: '공개 API' },
], ],
components: { components: {
securitySchemes: { securitySchemes: {

View file

@ -1,13 +1,14 @@
import authRoutes from './auth.js'; import authRoutes from './auth.js';
import membersRoutes from './members/index.js'; import membersRoutes from './members/index.js';
import albumsRoutes from './albums/index.js'; import albumsRoutes from './albums/index.js';
import schedulesRoutes from './schedules/index.js';
import statsRoutes from './stats/index.js'; import statsRoutes from './stats/index.js';
/** /**
* 라우트 통합 * 라우트 통합
* /api/* * /api/*
*/ */
export default async function routes(fastify, opts) { export default async function routes(fastify) {
// 인증 라우트 // 인증 라우트
fastify.register(authRoutes, { prefix: '/auth' }); fastify.register(authRoutes, { prefix: '/auth' });
@ -17,6 +18,9 @@ export default async function routes(fastify, opts) {
// 앨범 라우트 // 앨범 라우트
fastify.register(albumsRoutes, { prefix: '/albums' }); fastify.register(albumsRoutes, { prefix: '/albums' });
// 일정 라우트
fastify.register(schedulesRoutes, { prefix: '/schedules' });
// 통계 라우트 // 통계 라우트
fastify.register(statsRoutes, { prefix: '/stats' }); fastify.register(statsRoutes, { prefix: '/stats' });
} }

View file

@ -0,0 +1,147 @@
/**
* 일정 라우트
* GET: 공개, POST/PUT/DELETE: 인증 필요
*/
export default async function schedulesRoutes(fastify) {
const { db } = fastify;
/**
* GET /api/schedules
* 월별 일정 목록 조회
* @query year - 년도 (필수)
* @query month - (필수)
*/
fastify.get('/', {
schema: {
tags: ['schedules'],
summary: '월별 일정 목록 조회',
querystring: {
type: 'object',
required: ['year', 'month'],
properties: {
year: { type: 'integer', description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
},
},
},
}, async (request, reply) => {
const { year, month } = request.query;
if (!year || !month) {
return reply.code(400).send({ error: 'year와 month는 필수입니다.' });
}
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
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 source_name
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
WHERE s.date BETWEEN ? AND ?
ORDER BY s.date ASC, s.time ASC
`, [startDate, endDate]);
// 날짜별로 그룹화
const grouped = {};
for (const s of schedules) {
const dateKey = s.date.toISOString().split('T')[0];
if (!grouped[dateKey]) {
grouped[dateKey] = {
categories: [],
schedules: [],
};
}
// 일정 추가
const schedule = {
id: s.id,
title: s.title,
time: s.time,
category: {
id: s.category_id,
name: s.category_name,
color: s.category_color,
},
};
if (s.source_name) {
schedule.source_name = s.source_name;
}
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,
});
}
}
return grouped;
});
/**
* 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 source_name
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
WHERE s.id = ?
`, [id]);
if (schedules.length === 0) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
}
const s = schedules[0];
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,
},
created_at: s.created_at,
updated_at: s.updated_at,
};
if (s.source_name) {
result.source_name = s.source_name;
}
return result;
});
}