Skip to content

Commit e7e4ade

Browse files
dydals3440hijjoyclaudeMatthew Kimhijjoy
authored
v1.0.4 서버 추가 배포 (#424)
* feat(mobile): 태블릿 지원 true 설정 * feat(mobile): 소셜 로그인 버튼 UI 통일 및 Apple HIG 준수 (#356) - 모든 소셜 로그인 버튼을 동일한 SocialLoginButton(풀 버튼) 형태로 통일 - Apple 버튼 다크모드 대응: 전용 색상 토큰(apple-button/apple-button-dark) 추가 - Apple 로고 SVG fill을 currentColor로 변경하여 colorClassName 지원 - 미사용 SocialLoginIconButton 컴포넌트 및 "또는" 구분선 제거 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): Firebase 자동 screen_view 중복 수집 비활성화 * fix(mobile): 디자인 스펙에 맞게 oklch 색상 값 교정 (#359) * fix(mobile): 디자인 스펙에 맞게 oklch 색상 값 교정 - gray 5~10 L값 교정 (반올림으로 인한 어둡게 렌더링 수정) - gray-10 보라빛 틴트 제거 (C=0.005 H=285 → 0) - main/secondary/error 브랜드 색상 스펙 hex 정밀 매칭 - dark white 따뜻한 틴트 제거 (H=29 → 무채색) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): 다크 모드 background/surface 색상 틴트 제거 - --background: 따뜻한 틴트(C=0.0056, H=29) 제거 → 무채색 - --surface: 보라빛 틴트(C=0.005, H=285) 제거 → 무채색 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 주간 목표 달성 배지 기능 추가 (#360) * feat(mobile): 주간 달성 배지 기능 추가 - 주간 완료율 기반 배지 시스템 (perfect/almost/completed) - 상장 스타일 요약 카드 및 배지 목록 UI - Achievement 모델, 서비스, 매퍼, 쿼리, 뷰모델 구현 - DI 컨테이너 등록 및 마이페이지 내비게이션 연결 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(mobile): ViewModel 패턴 아키텍처 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): 긴 텍스트 잘림 방지를 위한 레이아웃 수정 - 리포트 헤더 날짜 범위 flex-wrap 적용 - 프리미엄 배너, 제안 목록 텍스트 정렬 및 줄바꿈 전략 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(mobile): 달성 배지 UI 가이드 문서 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): 달성 배지 연도를 현재 연도 자동 계산으로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): weekly-badge 관련 로깅 추가 * refactor(mobile): 배지 이미지를 webp 포맷으로 변환 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(mobile): ViewModel 예외 케이스 가이드 추가 단순 변환은 select에서 인라인 처리 가능하다는 예외 케이스 문서화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 주간 배지 상세 페이지 추가 - detail API 연결 (service + query key + query options) - achievements/[year]/[week] 상장 카드 상세 화면 - 리스트 아이템 탭 → 상세 네비게이션 + ListRow 리팩토링 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): CertificateBorder 공용 컴포넌트 분리 AchievementSummaryCard에서 CertificateBorder를 별도 파일로 추출하여 상세 페이지와 리스트 요약 카드에서 공유 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: 김용민 <dydals3440@gmail.com> * refactor(api): scheduler 내부 정리 및 WeeklyAchievement catch-up 추가 (#365) * refactor(api): scheduler 내부 정리 및 WeeklyAchievement catch-up 추가 - ITimezoneStrategy 인터페이스 추출, 모든 strategy에 implements 적용 - handleHourlySweep → handleMinuteSweep 메서드명 정정 - pushEnabled 유저 없는 타임존의 WeeklyAchievement catch-up 로직 추가 - wrapAllTimezones 캐시 메서드 및 allTimezones 캐시 키 추가 - subtractMonths 날짜 유틸리티 추가 - jobs barrel export 추가 * refactor(api): WeeklyAchievement catch-up 중복 dayjs 계산 제거 및 테스트 데이터 보정 * feat(mobile): 반복 설정 UX 개선 및 시스템 글꼴 스케일 대응 (#363) * feat(mobile): 반복 설정 날짜 선택 UX 개선 - 반복 기간 설정 라벨 변경 및 날짜 범위 표시 개선 - 선택된 반복 요일 요약 텍스트 추가 - 반복 모드에서 오늘 날짜 배경 하이라이트 비활성화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): 피드 프로필 이름 최대 너비 제한 및 InfoIcon 텍스트 전환 - 긴 이름(최대 20자)이 레이아웃을 깨지 않도록 max-w + numberOfLines 적용 - InfoIcon SVG를 ⓘ 텍스트로 변경하여 폰트 스케일 대응 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 시스템 글꼴 크기 비례 스케일링 유틸 함수 추가 fontScaledSize(size, ratio) — PixelRatio.getFontScale() 기반으로 아이콘/버튼 크기를 시스템 글꼴에 비례하여 스케일링한다. ratio 파라미터로 반영 비율 조절 가능 (기본 0.5). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 아이콘/버튼 시스템 글꼴 스케일 적용 및 바텀시트 레이아웃 개선 - TodoFormContent: chips flex-wrap 레이아웃으로 변경, 버튼/아이콘 fontScaledSize 적용 - PickerHeader: CloseIcon fontScaledSize 적용 - TodoList: 카테고리 PlusIcon fontScaledSize 적용 - ProfileCard: CopyIcon fontScaledSize 적용 - KeyboardBottomSheet: 하단 패딩 여유 추가 - testing-guide: 유틸 함수 테스트 가이드 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(mobile): fontScaledSize JSDoc 기본값 수정 (0.3 → 0.5) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): fontScaledSize 호출을 인라인으로 변경 - PickerHeader의 HEADER_ICON_SIZE 상수 제거 - TodoFormContent의 scaledButtonSize/scaledIconSize/scaledMicIconSize 변수 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): 테스트 설명에서 Given/When/Then 장황한 형식 제거 - testing-guide.md: it 설명은 간결하게 작성하도록 가이드 수정 - scale.test.ts: 7개 테스트 설명을 간결한 한국어로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(docs): 약관 업데이트 * feat(mobile): 24시간제 시간 형식 설정 및 앱 전체 적용 (#369) * chore(mobile): 알림 설정 설명 문구를 ~해요 체로 통일 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 24시간제 설정을 앱 전체 시간 표시에 반영 - 알림 설정 화면에 24시간제 토글 추가 - 할일 목록, 친구 할일, AI 리포트 시간 표시에 timeFormat 반영 - iOS DateTimePicker locale 동적 전환 (en_GB/ko) - formatTime, formatHour, formatReminderTime에 timeFormat 파라미터 추가 - formatTimeDisplay 유틸 함수 및 테스트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): TodoTimePickerContent에 Suspense boundary 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): formatTimeDisplay 기본값을 TWELVE_HOUR로 통일 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 주간 배지 공유 기능 추가 ⭐️ (#367) * feat(mobile): 주간 배지 공유 기능 추가 - useShareView 훅으로 ViewShot 캡처 + expo-sharing 공유 로직 분리 - 배지 상세/요약 화면에 공유 버튼 추가 - badge_share_attempted 애널리틱스 이벤트 추가 - ShareIcon 아이콘 추가 및 fontScaledSize 적용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): 공유 캡처 실패 시 에러 토스트 추가 - isAvailableAsync 체크를 캡처 전으로 이동 - 캡처 실패 시 에러 토스트 표시 - trackEvent를 shareCapture 전으로 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(mobile): 배지 목록 빈 상태 UI를 상장 스타일로 개선 - 기본 Result 컴포넌트 대신 CertificateBorder 활용한 empty state - 기록 영역에 Result 컴포넌트로 안내 표시 - badge_empty 이미지 에셋 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mobile): 콕 찌르기 쿨다운 에러 메시지 수정 - 오늘 할 일 기반이므로 시간 안내 대신 상태 안내로 변경 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(api): AI 제안에 카테고리 추천 추가 및 분석 주기를 매일로 변경 (#371) * feat(api): AI 제안에 카테고리 추천 추가 및 분석 주기를 매일로 변경 (#370) * feat(mobile): 카테고리 id를 기준으로 바로 선택할 수 있게 UX 변경 * refactor(api): Gemini 코드 리뷰 반영 — jobId 타임존 버그 수정 * feat(api): 투두 파싱 프롬프트에 요일 약어 및 월+요일 복합 규칙 추가 - DayAbbr 규칙: 한글 요일 약어 매핑(월=MON, 화=TUE, ...) 및 조합 패턴(월수금→MON+WED+FRI) - Compound 확장: N월+요일약어→isRecurring:true, startDate=N월1일, endDate=N월말일 - "4월 월수금 출근" 같은 월+요일 복합 표현의 endDate가 해당 월 말일로 정확히 설정됨 * feat(mobile): v1.0.1 update * feat(mobile): 캘린더 뷰 모드 선택 값 MMKV 저장 (#376) * feat(mobile): 캘린더 뷰 모드 선택 값 MMKV 저장 closes #375 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): MMKV persist 함수 네이밍 통일 - useState setter는 관례적 네이밍(setX) 유지 - MMKV 저장 포함 함수는 persistX로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): 캘린더 뷰 모드 읽기에서 타입 단언 제거 - VALID_VIEW_MODES 배열 + as CalendarViewMode 대신 직접 문자열 비교로 변경 - TypeScript 자동 narrowing 활용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(mobile): MMKV 저장소에 DIP 적용 및 preference 순수 함수 분리 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: 김용민 <dydals3440@gmail.com> * fix(mobile): Android 마이페이지 하단 컨텐츠 탭 바 가림 수정 (#378) * fix(mobile): Android 마이페이지 하단 컨텐츠 탭 바 가림 수정 (#377) * refactor(mobile): 탭 바 오버레이 패딩 상수 추출 (#377) 하드코딩된 paddingBottom: 120을 LAYOUT.tabBarOverlayPadding 상수로 분리 * feat(mobile): analytics 이벤트 강화 및 친구 요청 lifecycle 추적 (#379) * feat(mobile): analytics 이벤트 강화 및 친구 요청 lifecycle 추적 추가 - todo_created 이벤트에 source, is_recurring, has_scheduled_time, is_all_day, visibility 속성 추가 - 반복 할 일 생성(createRecurringTodo) 추적 추가 - category_created에 color 속성, category_updated 이벤트 신규 추가 - AI 파싱 source를 react-hook-form 상태로 관리 (props 없이) - friend_request_accepted, friend_request_rejected 이벤트 추가 - onSuccess 내 trackEvent 호출 순서 통일 (invalidate → toast → trackEvent) * fix(mobile): onSuccess 내 trackEvent 호출 순서 통일 (Gemini review) 카테고리 생성/수정 mutation에서 trackEvent를 invalidateQueries, toast 뒤로 이동하여 전체 코드베이스의 onSuccess 패턴(invalidate → toast → trackEvent)과 일관성 유지 * feat(mobile): 문의하기 기능 추가 (#381) * feat(mobile): 문의하기 기능 추가 (#380) * refactor(mobile): CONTENT_PLACEHOLDER를 멀티라인 템플릿 리터럴로 변환 (#380) * refactor(mobile): 마이페이지/프로필 설정 UI 리팩토링 및 SettingNavigation 공통 컴포넌트 추출 (#383) * refactor(mobile): 마이페이지/프로필 설정 UI 리팩토링 및 SettingNavigation 공통 컴포넌트 추출 (#382) * refactor(mobile): SettingNavigation Compound Component 패턴 전환 (#382) * fix(api): 카카오 프로필 이미지 미표시 수정 (#385) * fix(api): 카카오 프로필 이미지 미표시 수정 (#384) * test(api): 카카오 토큰 검증 시 secure_resource 파라미터 검증 추가 (#384) * refactor(api): secure_resource 파라미터를 URL 상수에 직접 포함 (#384) * feat(api,mobile): 리마인드 콕 찌르기 기능 추가 (#386) (#387) * feat(api,mobile): 리마인드 콕 찌르기 기능 추가 (#386) * refactor(api): countTodayTodos 쿼리 가독성 개선 (#386) * fix(mobile): 다크모드에서 Apple 아이콘 미표시 수정 (#389) * fix(mobile): 다크모드에서 Apple 아이콘 미표시 수정 (#388) * refactor(mobile): ThemedAppleIcon 제거, dark: modifier로 통일 (#388) * chore(mobile): v1.0.2 버전 업데이트 * feat(mobile): 할일 액션 메뉴에서 카테고리 변경 기능 추가 (#392) * feat(mobile): 할일 액션 메뉴에서 카테고리 변경 기능 추가 (#391) * fix(mobile): 카테고리 바텀시트 stale state 및 방어 코드 개선 (#391) - CategorySelectBottomSheet: {isOpen && (...)} 패턴으로 재오픈 시 선택 상태 초기화 - SuggestionCategoryBottomSheet: ?? 0 제거, early return null로 방어 코드 개선 * fix(mobile): 문의하기 화면 키보드 UX 개선 (#394) * fix(mobile): 문의하기 화면 키보드 UX 개선 (#393) * refactor(mobile): scrollTo 매직 넘버를 실제 콘텐츠 높이로 대체 onContentSizeChange로 콘텐츠 높이를 추적하여 scrollTo의 하드코딩된 9999 값을 제거하고 정확한 스크롤 위치를 사용 * feat(api,mobile): 친구 목록 드래그 앤 드롭 순서 변경 기능 추가 (#395) * feat(api,mobile): 친구 목록 드래그 앤 드롭 순서 변경 기능 추가 Follow 모델에 sortOrder 필드를 추가하고, 친구 목록에서 꾹 눌러 드래그로 순서를 변경할 수 있는 기능을 구현한다. API: - Prisma 스키마에 sortOrder 필드 및 인덱스 추가 - PATCH /follows/friends/:followId/reorder 엔드포인트 추가 - 상대 위치 기반 reorder + shift 알고리즘 구현 - 친구 수락 시 sortOrder 자동 할당 (maxSortOrder + 1) - 기존 데이터 하위 호환 SQL 마이그레이션 Mobile: - DraggableFlatList 기반 드래그 앤 드롭 UI - 낙관적 업데이트 훅 (useDraggableFriendReorderList) - reorderFriend 서비스 메서드 및 mutation options * fix: 코드 리뷰 반영 및 친구 목록 레이아웃 수정 - reorder 시 followRequestNotFound → friendReorderTargetNotFound로 변경하여 순서 변경 맥락에 맞는 에러 메시지 사용 - DraggableFlatList에 containerStyle flex:1 추가하여 전체 높이 채우도록 수정 * refactor: 코드 스타일 개선 및 analytics 이벤트 추가 - 단일 라인 if/return을 멀티 라인 브레이스로 변환 (가독성) - API 로그 메시지 영문 → 한국어 전환 (로깅 가이드 준수) - friend_reordered analytics 이벤트 추가 * chore(mobile): v1.0.3 버전 업데이트 * refactor(api): Gemini 코드 리뷰 반영 - 일관성 개선 및 문서 최신화 (#399) * refactor(api): Gemini 코드 리뷰 반영 - 일관성 개선 및 문서 최신화 (#398) * refactor(mobile): notification 모델 아키텍처 준수 리팩토링 - 서버 DTO 스키마를 model에서 제거, @aido/validators에서 import - Policy에서 비-Policy 함수(isUnread, getExternalUrl) 제거 - 순수 함수 getCategoryLabel, getInternalRoute export - *Result → *Response 네이밍 통일 (validators 기준) * fix(api): ai 모듈 import 경로를 상대 경로로 수정 Gemini 코드 리뷰 반영 - @/modules/ 절대 경로를 상대 경로로 변경하여 api-conventions.md 규칙 준수 * feat(api): 일일 완료 현황에 카테고리 색상 필드 추가 (#404) * feat(api): 일일 완료 현황에 카테고리 색상 필드 추가 (#403) * refactor(api): Gemini 코드 리뷰 반영 - colorMap 루프 간결화 * feat(api): AI 제안 패턴 유형 확장 - 순차/발전 패턴 감지 추가 (#406) * feat(api): AI 리포트 프롬프트 주간/월간 분리 및 코칭 품질 개선 (#408) * feat(api): AI 리포트 프롬프트 주간/월간 분리 및 코칭 품질 개선 (#407) * refactor(api): Gemini 코드 리뷰 반영 - buildReportPrompt 분기 구조 개선 type 기준으로 먼저 분기한 뒤 hasActivity를 확인하는 구조로 변경하여 중복 조건 제거 및 가독성 향상. * feat(api): 푸시알림 문구 랜덤 variants 시스템 + 온보딩/마일스톤/윈백 확장 (#410) * feat(api): 푸시알림 문구 랜덤 variants 시스템 + 온보딩/마일스톤/윈백 확장 (#409) * refactor(api): Gemini 코드 리뷰 반영 - 일관성 개선 및 가드 강화 (#409) * feat(mobile): 캘린더 날짜별 할일 완료 상태 표시 (#401) * feat(mobile): RepeatIcon 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 캘린더 날짜별 할일 완료 상태 표시 - 전부 완료: 날짜 아래 파란 물고기 아이콘 - 미완료 할일 있음: 날짜 아래 작은 회색 점 - 할일 없음: 날짜만 표시 - 기존 +N 완료 개수 텍스트 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 할일 바텀시트 칩/반복 분리 리팩토링 - 칩 영역 가로 스크롤 + 반복칩 추가 - 반복 설정 전용 피커 뷰 분리 (TodoRepeatPickerContent) - 날짜 피커에서 반복 관련 코드 제거 - AiParseButton 컴포넌트로 마이크 로직 캡슐화 - 칩 스타일 아웃라인으로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mobile): 키보드 열린 상태에서 칩 터치 안 되는 문제 수정 ScrollView의 keyboardShouldPersistTaps 기본값이 "never"라 키보드가 열려있을 때 칩 탭이 키보드 닫기에 소비되어 onPress가 실행되지 않던 문제 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 반복 설정 확인 버튼 유효성 검증 추가 - PickerHeader에 isConfirmDisabled prop 추가 - 요일 미선택/종료일 미선택/범위 내 요일 없음 시 확인 버튼 비활성화 - 자동 종료일(+7일) 기본값 제거, 명시적 선택 필요 - hasSelectedDayInRange 유틸 추가 및 테스트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(mobile): hasSelectedDayInRange O(n) 루프를 O(1) 수학 연산으로 개선 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): AI 기능 안내 툴팁 추가 - ⓘ 아이콘 탭 시 Popover로 AI 음성 파싱 사용법 안내 - 툴팁 로직을 AiFeatureTooltip 컴포넌트로 분리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 캘린더 날짜별 카테고리 색상 인디케이터 표시 - 클라이언트 모델/매퍼에 categoryColors 필드 추가 - 카테고리 1개: 단색 점, 2개+: 색상 바 (최대 3색) - 인디케이터 높이를 물고기 아이콘과 동일하게 정렬 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tooling): VSCode Biome 저장 시 자동 포맷/린트 설정 추가 - editor.formatOnSave, organizeImports 추가 - language-specific 블록 통합 - deprecated biome.lspBin → biome.lsp.bin 변경 * fix(mobile): Android BottomTabs 경계선 제거 (#414) * fix(mobile): Android 탭바 콘텐츠 경계 선 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mobile): 캘린더 카테고리 인디케이터 border-radius 조정 * refactor(mobile): 탭바 하단 패딩을 useBottomTabBarHeight로 동적 처리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hijjoy <hijjoy@hijjoyui-MacBookPro.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 카테고리 관리를 마이 탭으로 이동 및 섹션 구조 UX 개편 (#415) * feat(mobile): 카테고리 관리를 마이 탭으로 이동 및 섹션 구조 개편 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mobile): 플러스버튼 색상 수정 * feat(mobile): 마이탭 섹션 구조 UX 개선 및 UI 미세 조정 - 마이페이지 섹션을 활동/구독/설정/지원으로 재구성 - 카테고리 관리를 활동 섹션으로 이동, 구독을 설정보다 상위 배치 - PlusIcon Flex 래퍼 제거, SettingNavigation 항목 shade 조정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mobile): iOS NativeTabs에서 useBottomTabBarHeight crash 수정 (#418) * chore(mobile): v1.0.4 변경 * fix(api): 리마인더 알림에 수정 전 투두 제목이 표시되는 버그 수정 (#421) * fix(api): 리마인더 알림에 수정 전 투두 제목이 표시되는 버그 수정 (#420) * test(api): 리마인더 제목 변경 테스트에 Math.random mock 적용 (#420) * refactor(api): ReminderJobData에서 미사용 todoTitle 필드 제거 (#420) * feat(api,validators): 관리자 알림에 action 필드 추가 (#423) * feat(api,validators): 관리자 알림에 action 필드 추가 (#422) * feat(api): action URL을 metadata.externalUrl로 전달하여 알림 목록 탭 시 외부 링크 지원 - AdminService broadcast/targeted에서 action.url이 있으면 metadata.externalUrl 자동 포함 - describe에 누락된 NONE 액션 타입 설명 추가 (Gemini review 반영) - action + metadata 관련 단위 테스트 3건 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hyewon <hijjoy@naver.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Matthew Kim <matthew@Matthewui-MacBookPro.local> Co-authored-by: hijjoy <hijjoy@hijjoyui-MacBookPro.local>
1 parent 390b77b commit e7e4ade

10 files changed

Lines changed: 204 additions & 49 deletions

File tree

apps/api/src/modules/admin/admin.service.spec.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
*
99
* @see https://docs.nestjs.com/recipes/suites
1010
*/
11-
import { BROADCAST_TARGET_FILTER } from "@aido/validators";
11+
import {
12+
BROADCAST_TARGET_FILTER,
13+
NOTIFICATION_ACTION_TYPE,
14+
} from "@aido/validators";
1215
import type { Mocked } from "@suites/doubles.jest";
1316
import { TestBed } from "@suites/unit";
1417
import { UserBuilder } from "@test/builders";
@@ -199,6 +202,88 @@ describe("AdminService", () => {
199202
);
200203
});
201204

205+
it("action에 URL이 있으면 metadata.externalUrl을 포함해야 한다", async () => {
206+
// Given
207+
const dto = {
208+
title: "v1.0.4 업데이트",
209+
body: "새로운 기능이 추가되었어요!",
210+
targetFilter: BROADCAST_TARGET_FILTER.ALL,
211+
action: {
212+
type: NOTIFICATION_ACTION_TYPE.BROWSER,
213+
url: "https://www.aido.kr/ko/patch-notes",
214+
},
215+
};
216+
217+
const userIds: UserIdOnly[] = [{ id: "user-1" }, { id: "user-2" }];
218+
database.user.findMany.mockResolvedValue(userIds as never);
219+
notificationService.createAndSendBatch.mockResolvedValue({
220+
count: 2,
221+
});
222+
223+
// When
224+
await service.broadcastNotification(dto);
225+
226+
// Then
227+
expect(notificationService.createAndSendBatch).toHaveBeenCalledWith([
228+
{
229+
userId: "user-1",
230+
type: "ADMIN_BROADCAST",
231+
title: "v1.0.4 업데이트",
232+
body: "새로운 기능이 추가되었어요!",
233+
action: {
234+
type: NOTIFICATION_ACTION_TYPE.BROWSER,
235+
url: "https://www.aido.kr/ko/patch-notes",
236+
},
237+
metadata: { externalUrl: "https://www.aido.kr/ko/patch-notes" },
238+
},
239+
{
240+
userId: "user-2",
241+
type: "ADMIN_BROADCAST",
242+
title: "v1.0.4 업데이트",
243+
body: "새로운 기능이 추가되었어요!",
244+
action: {
245+
type: NOTIFICATION_ACTION_TYPE.BROWSER,
246+
url: "https://www.aido.kr/ko/patch-notes",
247+
},
248+
metadata: { externalUrl: "https://www.aido.kr/ko/patch-notes" },
249+
},
250+
]);
251+
});
252+
253+
it("action에 URL이 없으면 metadata를 포함하지 않아야 한다", async () => {
254+
// Given
255+
const dto = {
256+
title: "공지사항",
257+
body: "안내 메시지",
258+
targetFilter: BROADCAST_TARGET_FILTER.ALL,
259+
action: {
260+
type: NOTIFICATION_ACTION_TYPE.NONE,
261+
},
262+
};
263+
264+
const userIds: UserIdOnly[] = [{ id: "user-1" }];
265+
database.user.findMany.mockResolvedValue(userIds as never);
266+
notificationService.createAndSendBatch.mockResolvedValue({
267+
count: 1,
268+
});
269+
270+
// When
271+
await service.broadcastNotification(dto);
272+
273+
// Then
274+
expect(notificationService.createAndSendBatch).toHaveBeenCalledWith([
275+
{
276+
userId: "user-1",
277+
type: "ADMIN_BROADCAST",
278+
title: "공지사항",
279+
body: "안내 메시지",
280+
action: {
281+
type: NOTIFICATION_ACTION_TYPE.NONE,
282+
},
283+
},
284+
]);
285+
});
286+
202287
it("구독자에게만 알림을 발송해야 한다", async () => {
203288
// Given
204289
const dto = {
@@ -329,6 +414,43 @@ describe("AdminService", () => {
329414
]);
330415
});
331416

417+
it("action에 URL이 있으면 metadata.externalUrl을 포함해야 한다", async () => {
418+
// Given
419+
const dto = {
420+
title: "개인 알림",
421+
body: "확인해주세요",
422+
userIds: ["user-1"],
423+
action: {
424+
type: NOTIFICATION_ACTION_TYPE.BROWSER,
425+
url: "https://www.aido.kr/ko/patch-notes",
426+
},
427+
};
428+
429+
const userIds: UserIdOnly[] = [{ id: "user-1" }];
430+
database.user.findMany.mockResolvedValue(userIds as never);
431+
notificationService.createAndSendBatch.mockResolvedValue({
432+
count: 1,
433+
});
434+
435+
// When
436+
await service.sendTargetedNotification(dto);
437+
438+
// Then
439+
expect(notificationService.createAndSendBatch).toHaveBeenCalledWith([
440+
{
441+
userId: "user-1",
442+
type: "ADMIN_TARGETED",
443+
title: "개인 알림",
444+
body: "확인해주세요",
445+
action: {
446+
type: NOTIFICATION_ACTION_TYPE.BROWSER,
447+
url: "https://www.aido.kr/ko/patch-notes",
448+
},
449+
metadata: { externalUrl: "https://www.aido.kr/ko/patch-notes" },
450+
},
451+
]);
452+
});
453+
332454
it("모든 사용자가 존재하지 않으면 에러를 던져야 한다", async () => {
333455
// Given
334456
const dto = {

apps/api/src/modules/admin/admin.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ export class AdminService {
3333
async broadcastNotification(
3434
dto: BroadcastNotificationDto,
3535
): Promise<BroadcastResult> {
36-
const { title, body, targetFilter } = dto;
36+
const { title, body, targetFilter, action } = dto;
3737
const whereClause = this.#buildTargetWhere(targetFilter);
38+
const metadata = action?.url ? { externalUrl: action.url } : undefined;
3839

3940
let totalTargets = 0;
4041
let successCount = 0;
@@ -59,6 +60,8 @@ export class AdminService {
5960
type: "ADMIN_BROADCAST" as const,
6061
title,
6162
body,
63+
action,
64+
metadata,
6265
}));
6366

6467
const result =
@@ -88,7 +91,8 @@ export class AdminService {
8891
async sendTargetedNotification(
8992
dto: TargetedNotificationDto,
9093
): Promise<BroadcastResult> {
91-
const { title, body, userIds } = dto;
94+
const { title, body, userIds, action } = dto;
95+
const metadata = action?.url ? { externalUrl: action.url } : undefined;
9296

9397
// 존재하는 사용자만 필터링
9498
const existingUsers = await this.database.user.findMany({
@@ -115,6 +119,8 @@ export class AdminService {
115119
type: "ADMIN_TARGETED" as const,
116120
title,
117121
body,
122+
action,
123+
metadata,
118124
}));
119125

120126
// 배치로 알림 생성 및 발송

apps/api/src/modules/scheduler/reminder/adapters/bullmq-reminder-scheduler.adapter.spec.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
// =============================================================================
1818

1919
const USER_ID = "user-1";
20-
const TODO_TITLE = "Test Todo";
2120
const SIXTY_MIN_MS = 60 * 60 * 1000;
2221

2322
// =============================================================================
@@ -55,7 +54,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
5554
const scheduledTime = new Date(Date.now() + 2 * SIXTY_MIN_MS);
5655

5756
// When
58-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
57+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
5958
await flushPromises();
6059

6160
// Then — 각 단계별 잡 등록
@@ -67,7 +66,6 @@ describe("BullMQReminderSchedulerAdapter", () => {
6766
expect.objectContaining({
6867
todoId: 1,
6968
userId: USER_ID,
70-
todoTitle: TODO_TITLE,
7169
stageLabel: "60min",
7270
}),
7371
expect.objectContaining({
@@ -89,7 +87,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
8987
const scheduledTime = new Date(Date.now() + 30 * 60 * 1000);
9088

9189
// When
92-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
90+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
9391
await flushPromises();
9492

9593
// Then — 10분 단계만 등록 (getJob 호출 제외)
@@ -106,7 +104,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
106104
const scheduledTime = new Date(Date.now() + 5 * 60 * 1000);
107105

108106
// When
109-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
107+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
110108
await flushPromises();
111109

112110
// Then — 즉시 발송 잡
@@ -129,7 +127,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
129127
const scheduledTime = new Date(Date.now() - 60 * 1000);
130128

131129
// When
132-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
130+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
133131
await flushPromises();
134132

135133
// Then — 잡 등록 없음 (getJob은 기존 잡 제거 시도로 호출될 수 있음)
@@ -144,7 +142,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
144142
const scheduledTime = new Date(Date.now() + 2 * SIXTY_MIN_MS);
145143

146144
// When
147-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
145+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
148146
await flushPromises();
149147

150148
// Then — 기존 잡 제거 호출됨
@@ -159,7 +157,7 @@ describe("BullMQReminderSchedulerAdapter", () => {
159157
const scheduledTime = new Date(now + 2 * SIXTY_MIN_MS);
160158

161159
// When
162-
adapter.scheduleReminder(1, scheduledTime, USER_ID, TODO_TITLE);
160+
adapter.scheduleReminder(1, scheduledTime, USER_ID);
163161
await flushPromises();
164162

165163
// Then — 60분 단계의 delay 확인

apps/api/src/modules/scheduler/reminder/adapters/bullmq-reminder-scheduler.adapter.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const TODO_REMINDER_QUEUE = "todo-reminder";
1313
export interface ReminderJobData {
1414
todoId: number;
1515
userId: string;
16-
todoTitle: string;
1716
stageLabel: string;
1817
}
1918

@@ -41,21 +40,14 @@ export class BullMQReminderSchedulerAdapter implements IReminderScheduler {
4140
* 모든 단계가 이미 지났지만 scheduledTime이 미래인 경우 즉시 발송 잡을 등록합니다.
4241
* 같은 todoId로 재호출 시 기존 잡을 제거하고 새로 등록합니다.
4342
*/
44-
scheduleReminder(
45-
todoId: number,
46-
scheduledTime: Date,
47-
userId: string,
48-
todoTitle: string,
49-
): void {
43+
scheduleReminder(todoId: number, scheduledTime: Date, userId: string): void {
5044
// 비동기 작업을 fire-and-forget으로 실행
51-
this.#scheduleAsync(todoId, scheduledTime, userId, todoTitle).catch(
52-
(error) => {
53-
this.#logger.error(
54-
`Failed to schedule reminder: todoId=${todoId}, ${error}`,
55-
error instanceof Error ? error.stack : undefined,
56-
);
57-
},
58-
);
45+
this.#scheduleAsync(todoId, scheduledTime, userId).catch((error) => {
46+
this.#logger.error(
47+
`Failed to schedule reminder: todoId=${todoId}, ${error}`,
48+
error instanceof Error ? error.stack : undefined,
49+
);
50+
});
5951
}
6052

6153
/**
@@ -74,7 +66,6 @@ export class BullMQReminderSchedulerAdapter implements IReminderScheduler {
7466
todoId: number,
7567
scheduledTime: Date,
7668
userId: string,
77-
todoTitle: string,
7869
): Promise<void> {
7970
// 기존 잡 제거 (재스케줄링 지원)
8071
await this.#cancelAsync(todoId);
@@ -100,7 +91,7 @@ export class BullMQReminderSchedulerAdapter implements IReminderScheduler {
10091
hasScheduledJob = true;
10192
await this.queue.add(
10293
"send-reminder",
103-
{ todoId, userId, todoTitle, stageLabel: stage.label },
94+
{ todoId, userId, stageLabel: stage.label },
10495
{
10596
jobId: `reminder_${todoId}_${stage.label}`,
10697
delay,
@@ -122,7 +113,6 @@ export class BullMQReminderSchedulerAdapter implements IReminderScheduler {
122113
{
123114
todoId,
124115
userId,
125-
todoTitle,
126116
stageLabel: REMINDER_IMMEDIATE_LABEL,
127117
},
128118
{

apps/api/src/modules/scheduler/reminder/interfaces/reminder-scheduler.interface.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,8 @@ export interface IReminderScheduler {
1616
* @param todoId 대상 투두 ID
1717
* @param scheduledTime 투두 마감 시각
1818
* @param userId 사용자 ID
19-
* @param todoTitle 투두 제목 (알림 메시지용)
2019
*/
21-
scheduleReminder(
22-
todoId: number,
23-
scheduledTime: Date,
24-
userId: string,
25-
todoTitle: string,
26-
): void;
20+
scheduleReminder(todoId: number, scheduledTime: Date, userId: string): void;
2721

2822
/**
2923
* 리마인더 타이머 취소

0 commit comments

Comments
 (0)