fix(backend): getUpcomingSchedules 응답 형식 통일

- getUpcomingSchedules가 getMonthlySchedules와 동일한 날짜별 그룹화 형식 반환
- routes/schedules 응답 스키마에 oneOf 추가 (객체/배열 둘 다 허용)
- docs/architecture.md, migration.md 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 20:10:26 +09:00
parent bd8e87f636
commit e0ab3ce0f8
4 changed files with 501 additions and 34 deletions

View file

@ -58,7 +58,12 @@ export default async function schedulesRoutes(fastify) {
description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회',
querystring: scheduleSearchQuery,
response: {
200: { type: 'object', additionalProperties: true },
200: {
oneOf: [
{ type: 'object', additionalProperties: true },
{ type: 'array', items: { type: 'object', additionalProperties: true } },
],
},
},
},
}, async (request, reply) => {

View file

@ -302,12 +302,14 @@ export async function getMonthlySchedules(db, year, month) {
/**
* 다가오는 일정 조회 (startDate부터 limit개)
* getMonthlySchedules와 동일한 형식으로 반환
* @param {object} db - 데이터베이스 연결
* @param {string} startDate - 시작 날짜
* @param {number} limit - 조회 개수
* @returns {array} 일정 목록
* @returns {object} 날짜별로 그룹화된 일정
*/
export async function getUpcomingSchedules(db, startDate, limit) {
// 일정 조회 (YouTube, X 소스 정보 포함)
const [schedules] = await db.query(`
SELECT
s.id,
@ -316,9 +318,15 @@ export async function getUpcomingSchedules(db, startDate, limit) {
s.time,
s.category_id,
c.name as category_name,
c.color as category_color
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 ?
@ -345,22 +353,70 @@ export async function getUpcomingSchedules(db, startDate, limit) {
}
}
// 결과 포맷팅
return schedules.map(s => {
// 날짜별로 그룹화 (getMonthlySchedules와 동일한 형식)
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;
return {
const schedule = {
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,
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,
});
}
}
return grouped;
}

View file

@ -45,36 +45,163 @@ fromis_9/
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
├── frontend/ # React 프론트엔드
├── frontend/ # React 프론트엔드 (레거시, frontend-temp로 대체 예정)
│ ├── src/
│ │ ├── api/ # API 클라이언트
│ │ │ ├── index.js # fetchApi 유틸
│ │ ├── api/
│ │ │ ├── public/ # 공개 API
│ │ │ │ ├── albums.js
│ │ │ │ ├── members.js
│ │ │ │ └── schedules.js
│ │ │ └── admin/ # 어드민 API
│ │ │ ├── albums.js
│ │ │ ├── auth.js
│ │ │ ├── bots.js
│ │ │ ├── categories.js
│ │ │ ├── members.js
│ │ │ ├── schedules.js
│ │ │ ├── stats.js
│ │ │ └── suggestions.js
│ │ ├── components/ # 공통 컴포넌트
│ │ │ └── common/
│ │ │ ├── Lightbox.jsx # 이미지 라이트박스 (PC)
│ │ │ └── LightboxIndicator.jsx
│ │ ├── pages/
│ │ │ ├── pc/ # PC 페이지
│ │ │ └── mobile/ # 모바일 페이지
│ │ ├── stores/ # Zustand 스토어
│ │ ├── utils/
│ │ │ └── date.js # dayjs 기반 날짜 유틸리티
│ │ └── App.jsx
│ │ └── ...
│ └── package.json
├── frontend-temp/ # React 프론트엔드 (신규, Strangler Fig 마이그레이션)
│ ├── src/
│ │ ├── api/ # API 클라이언트 (공유)
│ │ │ ├── index.js
│ │ │ ├── client.js # fetchApi, fetchAuthApi
│ │ │ ├── albums.js
│ │ │ ├── members.js
│ │ │ ├── schedules.js
│ │ │ ├── auth.js
│ │ │ └── admin/ # 관리자 API
│ │ │ ├── albums.js
│ │ │ ├── members.js
│ │ │ ├── schedules.js
│ │ │ ├── categories.js
│ │ │ ├── stats.js
│ │ │ ├── bots.js
│ │ │ └── suggestions.js
│ │ │
│ │ ├── hooks/ # 커스텀 훅 (공유)
│ │ │ ├── index.js
│ │ │ ├── useAlbumData.js
│ │ │ ├── useMemberData.js
│ │ │ ├── useScheduleData.js
│ │ │ ├── useScheduleSearch.js
│ │ │ ├── useCalendar.js
│ │ │ ├── useToast.js
│ │ │ └── useAdminAuth.js
│ │ │
│ │ ├── stores/ # Zustand 스토어 (공유)
│ │ │ ├── index.js
│ │ │ ├── useScheduleStore.js
│ │ │ └── useAuthStore.js
│ │ │
│ │ ├── utils/ # 유틸리티 (공유)
│ │ │ ├── index.js
│ │ │ ├── date.js
│ │ │ └── format.js
│ │ │
│ │ ├── constants/
│ │ │ └── index.js
│ │ │
│ │ ├── components/
│ │ │ ├── index.js
│ │ │ ├── common/ # 디바이스 무관 공통 컴포넌트
│ │ │ │ ├── Loading.jsx
│ │ │ │ ├── ErrorBoundary.jsx
│ │ │ │ ├── Toast.jsx
│ │ │ │ ├── Lightbox.jsx
│ │ │ │ ├── LightboxIndicator.jsx
│ │ │ │ ├── Tooltip.jsx
│ │ │ │ └── ScrollToTop.jsx
│ │ │ ├── pc/ # PC 레이아웃 컴포넌트
│ │ │ │ ├── Layout.jsx
│ │ │ │ ├── Header.jsx
│ │ │ │ └── Footer.jsx
│ │ │ ├── mobile/ # Mobile 레이아웃 컴포넌트
│ │ │ │ ├── Layout.jsx
│ │ │ │ └── MobileNav.jsx
│ │ │ └── admin/ # 관리자 컴포넌트
│ │ │ ├── AdminLayout.jsx
│ │ │ ├── AdminHeader.jsx
│ │ │ ├── ConfirmDialog.jsx
│ │ │ ├── CustomDatePicker.jsx
│ │ │ ├── CustomTimePicker.jsx
│ │ │ └── NumberPicker.jsx
│ │ │
│ │ ├── pages/
│ │ │ ├── index.js
│ │ │ │
│ │ │ ├── home/
│ │ │ │ ├── index.js # export { PCHome, MobileHome }
│ │ │ │ ├── pc/
│ │ │ │ │ └── Home.jsx
│ │ │ │ └── mobile/
│ │ │ │ └── Home.jsx
│ │ │ │
│ │ │ ├── members/
│ │ │ │ ├── index.js
│ │ │ │ ├── pc/
│ │ │ │ │ └── Members.jsx
│ │ │ │ └── mobile/
│ │ │ │ └── Members.jsx
│ │ │ │
│ │ │ ├── album/
│ │ │ │ ├── index.js
│ │ │ │ ├── pc/
│ │ │ │ │ ├── Album.jsx
│ │ │ │ │ ├── AlbumDetail.jsx
│ │ │ │ │ ├── AlbumGallery.jsx
│ │ │ │ │ └── TrackDetail.jsx
│ │ │ │ └── mobile/
│ │ │ │ ├── Album.jsx
│ │ │ │ ├── AlbumDetail.jsx
│ │ │ │ ├── AlbumGallery.jsx
│ │ │ │ └── TrackDetail.jsx
│ │ │ │
│ │ │ ├── schedule/
│ │ │ │ ├── index.js
│ │ │ │ ├── sections/ # 일정 상세 섹션 (PC 전용)
│ │ │ │ │ ├── DefaultSection.jsx
│ │ │ │ │ ├── XSection.jsx
│ │ │ │ │ └── YoutubeSection.jsx
│ │ │ │ ├── pc/
│ │ │ │ │ ├── Schedule.jsx
│ │ │ │ │ ├── ScheduleDetail.jsx
│ │ │ │ │ └── Birthday.jsx
│ │ │ │ └── mobile/
│ │ │ │ ├── Schedule.jsx
│ │ │ │ └── ScheduleDetail.jsx
│ │ │ │
│ │ │ ├── common/
│ │ │ │ ├── pc/
│ │ │ │ │ └── NotFound.jsx
│ │ │ │ └── mobile/
│ │ │ │ └── NotFound.jsx
│ │ │ │
│ │ │ └── admin/ # 관리자 페이지 (PC 전용)
│ │ │ ├── index.js
│ │ │ ├── Login.jsx
│ │ │ ├── Dashboard.jsx
│ │ │ ├── members/
│ │ │ │ ├── List.jsx
│ │ │ │ └── Edit.jsx
│ │ │ ├── albums/
│ │ │ │ ├── List.jsx
│ │ │ │ ├── Form.jsx
│ │ │ │ └── Photos.jsx
│ │ │ ├── schedules/
│ │ │ │ ├── List.jsx
│ │ │ │ ├── Form.jsx
│ │ │ │ ├── YouTubeForm.jsx
│ │ │ │ ├── XForm.jsx
│ │ │ │ └── YouTubeEditForm.jsx
│ │ │ ├── categories/
│ │ │ │ └── List.jsx
│ │ │ ├── bots/
│ │ │ │ └── Manager.jsx
│ │ │ └── dict/
│ │ │ └── Manager.jsx
│ │ │
│ │ ├── App.jsx # BrowserView/MobileView 라우팅
│ │ └── main.jsx
│ │
│ ├── vite.config.js
│ ├── Dockerfile # 프론트엔드 컨테이너
│ ├── tailwind.config.js
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml

View file

@ -144,3 +144,282 @@
- DB 스키마 변경 사항:
- `tracks``album_tracks` (이름 변경)
- `venues``concert_venues` (이름 변경)
---
# 프론트엔드 마이그레이션 (Strangler Fig)
## 개요
`frontend/` (레거시) → `frontend-temp/` (신규)로 점진적 마이그레이션
## 설계 원칙
1. **react-device-detect 사용**
- App.jsx에서 `BrowserView`/`MobileView`로 PC/Mobile 완전 분리
- User Agent 기반 디바이스 감지
2. **기능별 폴더 + pc/mobile 하위 폴더**
- 각 페이지 폴더 내에 `pc/`, `mobile/` 서브폴더
- 비즈니스 로직(api, hooks, stores)은 최상위에서 공유
3. **React Query 사용**
- `useQuery`로 데이터 패칭 및 캐싱
4. **관리자 페이지는 PC 전용**
- mobile 폴더 없이 단일 구조
## App.jsx 라우팅 구조
```jsx
import { BrowserView, MobileView } from 'react-device-detect';
function App() {
return (
<BrowserRouter>
<BrowserView>
<Routes>
{/* Admin routes */}
<Route path="/admin" element={<AdminLogin />} />
<Route path="/admin/*" element={<AdminRoutes />} />
{/* Public routes with PC Layout */}
<Route element={<PCLayout />}>
<Route path="/" element={<PCHome />} />
<Route path="/members" element={<PCMembers />} />
<Route path="/album" element={<PCAlbum />} />
<Route path="/album/:name" element={<PCAlbumDetail />} />
...
</Route>
</Routes>
</BrowserView>
<MobileView>
<Routes>
<Route element={<MobileLayout />}>
<Route path="/" element={<MobileHome />} />
<Route path="/members" element={<MobileMembers />} />
...
</Route>
</Routes>
</MobileView>
</BrowserRouter>
);
}
```
## 전체 마이그레이션 체크리스트
### API 계층
#### 공개 API
- [x] client.js (fetchApi, fetchAuthApi)
- [x] albums.js
- [x] members.js
- [x] schedules.js
- [x] auth.js
#### 관리자 API (`api/admin/`)
- [ ] albums.js
- [ ] members.js
- [ ] schedules.js
- [ ] categories.js
- [ ] stats.js
- [ ] bots.js
- [ ] suggestions.js
### 훅 (hooks/)
- [x] useAlbumData.js
- [x] useMemberData.js
- [x] useScheduleData.js
- [x] useScheduleSearch.js
- [x] useCalendar.js
- [x] useAdminAuth.js
- [ ] useToast.js
### 스토어 (stores/)
- [x] useScheduleStore.js
- [x] useAuthStore.js
### 유틸리티 (utils/)
- [x] date.js
- [x] format.js
### 공통 컴포넌트 (components/common/)
- [x] Loading.jsx
- [x] ErrorBoundary.jsx
- [x] Toast.jsx
- [x] Lightbox.jsx
- [ ] LightboxIndicator.jsx
- [ ] Tooltip.jsx
- [ ] ScrollToTop.jsx
### PC 레이아웃 컴포넌트 (components/pc/)
- [ ] Layout.jsx (Outlet 사용)
- [ ] Header.jsx
- [ ] Footer.jsx
### Mobile 레이아웃 컴포넌트 (components/mobile/)
- [ ] Layout.jsx (Outlet 사용)
- [ ] MobileNav.jsx
### 관리자 컴포넌트 (components/admin/)
- [ ] AdminLayout.jsx
- [ ] AdminHeader.jsx
- [ ] ConfirmDialog.jsx
- [ ] CustomDatePicker.jsx
- [ ] CustomTimePicker.jsx
- [ ] NumberPicker.jsx
### 페이지 - Home (pages/home/)
- [ ] pc/Home.jsx
- [ ] mobile/Home.jsx
### 페이지 - Members (pages/members/)
- [ ] pc/Members.jsx
- [ ] mobile/Members.jsx
### 페이지 - Album (pages/album/)
- [ ] pc/Album.jsx
- [ ] pc/AlbumDetail.jsx
- [ ] pc/AlbumGallery.jsx
- [ ] pc/TrackDetail.jsx
- [ ] mobile/Album.jsx
- [ ] mobile/AlbumDetail.jsx
- [ ] mobile/AlbumGallery.jsx
- [ ] mobile/TrackDetail.jsx
### 페이지 - Schedule (pages/schedule/)
- [ ] sections/DefaultSection.jsx
- [ ] sections/XSection.jsx
- [ ] sections/YoutubeSection.jsx
- [ ] pc/Schedule.jsx
- [ ] pc/ScheduleDetail.jsx
- [ ] pc/Birthday.jsx
- [ ] mobile/Schedule.jsx
- [ ] mobile/ScheduleDetail.jsx
### 페이지 - Common (pages/common/)
- [ ] pc/NotFound.jsx
- [ ] mobile/NotFound.jsx
### 페이지 - Admin (pages/admin/) - PC 전용
- [ ] Login.jsx
- [ ] Dashboard.jsx
- [ ] members/List.jsx
- [ ] members/Edit.jsx
- [ ] albums/List.jsx
- [ ] albums/Form.jsx
- [ ] albums/Photos.jsx
- [ ] schedules/List.jsx
- [ ] schedules/Form.jsx
- [ ] schedules/YouTubeForm.jsx
- [ ] schedules/XForm.jsx
- [ ] schedules/YouTubeEditForm.jsx
- [ ] categories/List.jsx
- [ ] bots/Manager.jsx
- [ ] dict/Manager.jsx
### 기타
- [ ] App.jsx (BrowserView/MobileView 라우팅)
- [ ] main.jsx
### CSS 파일
- [x] index.css
- [ ] mobile.css (모바일 전용 스타일, 달력 등)
- [ ] pc.css (PC 전용 스타일)
### 기타 파일
- [ ] data/dummy.js (개발용 더미 데이터)
- [ ] .env (VITE_KAKAO_JS_KEY - 콘서트 장소 검색용, 미구현)
- [ ] public/favicon.ico
## 사용 라이브러리 (package.json)
| 라이브러리 | 용도 | 사용 위치 |
|-----------|------|----------|
| react-device-detect | PC/Mobile 분기 | App.jsx |
| @tanstack/react-query | 데이터 패칭 | 모든 페이지 |
| @tanstack/react-virtual | 가상화 리스트 | 관리자 일정 목록 |
| react-calendar | 캘린더 | 일정 페이지 |
| react-colorful | 색상 선택 | 카테고리 관리 |
| react-photo-album | 앨범 갤러리 | 앨범 갤러리 |
| react-infinite-scroll-component | 무한 스크롤 | 일정 검색 |
| react-intersection-observer | 뷰포트 감지 | 애니메이션 |
| react-ios-time-picker | 시간 선택 | 일정 폼 |
| react-linkify | URL 자동 링크 | 일정 상세 |
| react-window | 가상화 리스트 | 긴 목록 |
| swiper | 슬라이더 | 앨범 상세 |
| canvas-confetti | 축하 효과 | 생일 페이지 |
| framer-motion | 애니메이션 | 전체 |
| dayjs | 날짜 처리 | 전체 |
| zustand | 상태 관리 | 전체 |
## 특수 패턴 및 주의사항
### React Query 고급 사용
- `useInfiniteQuery` - 일정 검색 무한 스크롤 (mobile/Schedule)
- `useQuery` - 일반 데이터 패칭
### Swiper 사용 시
```jsx
import { Swiper, SwiperSlide } from 'swiper/react';
import { Virtual } from 'swiper/modules';
import 'swiper/css';
```
- 사용 위치: mobile/Members, mobile/AlbumDetail, mobile/AlbumGallery
### 가상화 리스트
- `useVirtualizer` from `@tanstack/react-virtual`
- 사용 위치: mobile/Schedule, admin/AdminSchedule
### 교차 관찰자
- `useInView` from `react-intersection-observer`
- 사용 위치: mobile/Schedule (무한 스크롤 트리거)
### 축하 효과
- `canvas-confetti` - 생일 페이지 폭죽 효과
- 사용 위치: pc/Birthday, mobile/Schedule (생일 일정)
### 색상 선택기
- `HexColorPicker` from `react-colorful`
- 사용 위치: admin/AdminScheduleCategory
### 앨범 갤러리
- `RowsPhotoAlbum` from `react-photo-album`
- 사용 위치: pc/AlbumGallery, mobile/AlbumGallery
### Kakao API (미구현)
- 콘서트 장소 검색: `/api/admin/kakao/places`
- 환경변수: `VITE_KAKAO_JS_KEY`
- 사용 예정: 콘서트 일정 추가 시 장소 검색
## 마이그레이션 진행 상황
### 완료된 작업 (재검토 필요)
- [x] Phase 1: 프로젝트 셋업
- [x] Phase 2: 유틸리티 및 상수
- [x] Phase 3: Zustand 스토어
- [x] Phase 4: API 계층 (공개 API만)
- [x] Phase 5: 커스텀 훅 (일부)
- [x] Phase 6: 공통 컴포넌트 (일부)
### 구조 리팩토링 필요
- [ ] 기존 코드를 새 폴더 구조로 재배치
- [ ] App.jsx를 BrowserView/MobileView 구조로 변경
- [ ] 레이아웃 컴포넌트 분리 (components/pc, components/mobile)
### 미완료 작업
- [ ] 관리자 API 전체
- [ ] 관리자 컴포넌트 전체
- [ ] 관리자 페이지 전체
- [ ] 누락된 공통 컴포넌트 (LightboxIndicator, Tooltip, ScrollToTop)
- [ ] 누락된 페이지 (AlbumDetail, AlbumGallery, TrackDetail, ScheduleDetail, Birthday)
- [ ] 누락된 훅 (useToast)
- [ ] PC/Mobile 페이지 분리
### 최종 검증
- [ ] 모든 라우트 동작 확인
- [ ] PC/Mobile 전환 테스트
- [ ] 관리자 기능 테스트
- [ ] frontend-temp → frontend 교체