From e0ab3ce0f85364d6e0ef8ffe5d26dffe86eb6268 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 20:10:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20getUpcomingSchedules=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=98=95=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getUpcomingSchedules가 getMonthlySchedules와 동일한 날짜별 그룹화 형식 반환 - routes/schedules 응답 스키마에 oneOf 추가 (객체/배열 둘 다 허용) - docs/architecture.md, migration.md 업데이트 Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/schedules/index.js | 7 +- backend/src/services/schedule.js | 76 ++++++- docs/architecture.md | 173 +++++++++++++--- docs/migration.md | 279 ++++++++++++++++++++++++++ 4 files changed, 501 insertions(+), 34 deletions(-) diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 8f51ebf..cfa5487 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -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) => { diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 6463d0e..40f8e66 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -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; } diff --git a/docs/architecture.md b/docs/architecture.md index ab596f3..f216854 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/migration.md b/docs/migration.md index 2a84e3b..7a6d089 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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 ( + + + + {/* Admin routes */} + } /> + } /> + + {/* Public routes with PC Layout */} + }> + } /> + } /> + } /> + } /> + ... + + + + + + + }> + } /> + } /> + ... + + + + + ); +} +``` + +## 전체 마이그레이션 체크리스트 + +### 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 교체