diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 96773dc..3fcdafc 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -4,6 +4,8 @@ import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { travelSessionApi } from "@/api/travelSessionApi"; +import { useMomentLogListQuery } from "@/api/momentLogQueries"; +import { recapApi } from "@/api/recapApi"; import { recapQueryKeys } from "@/api/recapQueries"; import { useNearbyPlacesQuery } from "@/api/tourQueries"; import { AppText } from "@/components/AppText"; @@ -22,17 +24,12 @@ import { useTravelRouteTracking, } from "@/hooks/useTravelRouteTracking"; import { useAuthStore } from "@/store/authStore"; -import { useMomentLogStore } from "@/store/momentLogStore"; import { usePlayerStore } from "@/store/playerStore"; import { queryClient } from "@/providers/queryClient"; import { useTravelSessionStore } from "@/store/travelSessionStore"; -import { useTravelLogSyncStore } from "@/store/travelLogSyncStore"; import { useUserProfileStore } from "@/store/userProfileStore"; import type { TravelMode } from "@/types/domain"; import { requestForegroundLocationWithStatus } from "@/utils/location"; -import { createSessionRecapId } from "@/utils/recapMappers"; -import { flushPendingMomentActions } from "@/utils/momentLogSync"; -import { flushPendingTravelLogFinalizations } from "@/utils/travelLogSync"; const NEARBY_TOUR_RADIUS_METERS = 2000; @@ -47,7 +44,6 @@ export default function MapHomeScreen() { const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); const [isEndingTravel, setIsEndingTravel] = useState(false); const [mapMessage, setMapMessage] = useState(); - const momentLogs = useMomentLogStore((state) => state.logs); const { clearLocation, currentLocation, @@ -73,6 +69,13 @@ export default function MapHomeScreen() { location: currentLocation, radiusMeters: NEARBY_TOUR_RADIUS_METERS, }); + const sessionMomentsQuery = useMomentLogListQuery( + { + limit: 100, + sessionId: session.status === "active" ? session.id : undefined, + }, + { enabled: status === "authenticated" && session.status === "active" }, + ); const nearestTourPlace = currentLocation ? nearbyPlacesQuery.data?.find( (place) => place.source === "tour-api" && Boolean(place.location), @@ -96,9 +99,7 @@ export default function MapHomeScreen() { : nearbyPlacesQuery.isError ? "error" : "empty"; - const sessionMomentCount = momentLogs.filter( - (log) => log.sessionId === session.id, - ).length; + const sessionMomentCount = sessionMomentsQuery.data?.length ?? 0; useEffect( function synchronizeRecommendationMode() { @@ -244,23 +245,18 @@ export default function MapHomeScreen() { travelMode: nextMode, }); - startSession({ - id: serverSession?.id, - routePoints: serverSession?.routePoints ?? initialRoutePoints, - startedAt: serverSession?.startedAt ?? startedAt, - }); - } catch { - const startLocation = currentLocation ?? activeCurrentPlace?.location; - const startedAt = new Date().toISOString(); + if (!serverSession?.id) { + throw new Error("travel_session_create_failed"); + } startSession({ - routePoints: startLocation - ? [createRoutePoint(startLocation, new Date(startedAt))] - : undefined, - startedAt, + id: serverSession.id, + routePoints: serverSession.routePoints ?? initialRoutePoints, + startedAt: serverSession.startedAt ?? startedAt, }); + } catch { setMapMessage( - "서버 여행 세션 연결에 실패해서 로컬 여행모드로 먼저 시작했어요.", + "여행모드를 시작하지 못했어요. 네트워크를 확인한 뒤 다시 시도해주세요.", ); } finally { setIsStartingTravel(false); @@ -274,59 +270,63 @@ export default function MapHomeScreen() { const endingSession = session; const endedAt = new Date().toISOString(); - const localRecapId = createSessionRecapId(endingSession.id); setIsEndingTravel(true); setMapMessage(undefined); try { - await flushPendingMomentActions(); - - const latestSessionLogs = useMomentLogStore - .getState() - .logs.filter((log) => log.sessionId === endingSession.id); + const latestSessionLogs = + (await sessionMomentsQuery.refetch()).data ?? []; + + const endedServerSession = await travelSessionApi.endTravelSession( + endingSession.id, + { + endedAt, + location: currentLocation ?? activeCurrentPlace?.location, + routePoints: endingSession.routePoints, + }, + ); - endSession(); - setIsEndConfirmVisible(false); + if (!endedServerSession) { + throw new Error("travel_session_end_failed"); + } if (latestSessionLogs.length === 0) { + endSession(); setSessionRecapId(undefined); + setIsEndConfirmVisible(false); setMapMessage( "여행을 종료했어요. 남긴 리캡이 없어 로그는 만들지 않았어요.", ); return; } - useTravelLogSyncStore.getState().queueFinalization({ - endedAt, - location: currentLocation ?? activeCurrentPlace?.location, - routePoints: endingSession.routePoints, - sessionId: endingSession.id, - templateId: "album", - title: `${latestSessionLogs[0]?.placeName ?? "여행"} 로그`, - }); - const syncResult = await flushPendingTravelLogFinalizations(); - const recapId = - syncResult.createdRecapIds[endingSession.id] ?? localRecapId; - - setSessionRecapId(recapId); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + const recap = await recapApi.createRecap( + { + momentLogIds: latestSessionLogs.map((log) => log.id), + routePoints: endingSession.routePoints, + sessionId: endingSession.id, + templateId: "album", + title: `${latestSessionLogs[0]?.placeName ?? "여행"} 로그`, + visibility: "private", + }, + `travel-log:${endingSession.id}`, + ); - if (recapId === localRecapId) { - setMapMessage( - "서버 동기화가 끝나면 여행 로그가 자동으로 완성돼요. 지금은 기기 기록을 보여드릴게요.", - ); + if (!recap) { + throw new Error("travel_log_create_failed"); } - router.push(`/recap-share/${recapId}`); - } catch { endSession(); - setSessionRecapId(localRecapId); + setSessionRecapId(recap.id); + setIsEndConfirmVisible(false); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + router.push(`/recap-share/${recap.id}`); + } catch { setIsEndConfirmVisible(false); setMapMessage( - "서버 로그 생성에 실패해 기기에 저장된 여행 로그를 먼저 보여드려요.", + "여행 로그를 서버에 저장하지 못했어요. 네트워크를 확인한 뒤 다시 종료해주세요.", ); - router.push(`/recap-share/${localRecapId}`); } finally { setIsEndingTravel(false); } diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 348d31a..726175c 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -10,7 +10,6 @@ import { PageHeader } from "@/components/PageHeader"; import { Screen } from "@/components/Screen"; import { useAuthStore } from "@/store/authStore"; import { useUserProfileStore } from "@/store/userProfileStore"; -import { migrateLocalDataToAccount } from "@/utils/localDataMigration"; type AuthMode = "login" | "register"; @@ -99,7 +98,6 @@ export default function LoginScreen() { } finishLogin(session); - void migrateLocalDataToAccount(); router.replace(getNextRoute(didCompleteOnboarding)); } catch (error) { setStatus("unauthenticated"); diff --git a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md index 077e638..1eaea34 100644 --- a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md +++ b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md @@ -1,6 +1,6 @@ # Soundlog 로그인/소셜 로그인 구현 계획 -> **Superseded:** 이 문서의 소셜 로그인 설계는 과거 검토안이다. 현재 MVP 인증 기준은 이메일/비밀번호이며, 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md` 및 `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다. +> **Superseded:** 이 문서의 소셜 로그인과 로컬 데이터 이관 설계는 과거 검토안이다. 현재 MVP 인증 기준은 Soundlog 자체 이메일과 비밀번호이며 사용자 기록은 로그인 상태에서 서버에 직접 저장한다. 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md`, `docs/implementation/2026-08-11-server-first-recap-write-plan.md`, `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다. ## 1. 목표 @@ -10,7 +10,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현 - 소셜 로그인 제공자를 쉽게 추가할 수 있는 인증 레이어 구축 - 앱 시작 시 세션 복구, 로그인 필요 여부, 온보딩 완료 여부를 안정적으로 분기 -- 기존 로컬 취향/여행 로그/좋아요 데이터를 로그인 후 서버 계정으로 이관할 수 있는 구조 마련 +- 로그인 이후 생성하는 기록을 서버 계정에 즉시 저장하는 구조 마련 - 웹, iOS, Android에서 모두 테스트 가능한 mock auth 플로우 제공 ## 2. 추천 UX 정책 @@ -23,7 +23,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현 - 여행 기록과 Recap은 계정에 보존되어야 사용자가 데이터 유실을 덜 걱정한다. - 공동 Recap, Live Sound Map, 음악 취향 매칭은 계정 식별과 신고/차단 정책이 필요하다. -- 기존 기기에 남아 있는 로컬 기록은 로그인 후 서버 동기화를 시도한다. +- 로그인 전에 사용자 기록을 만들지 않으므로 별도 이관 단계를 두지 않는다. 권장 제한: @@ -73,7 +73,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현 - `app/(tabs)/my.tsx` - 상단에 계정 카드 추가 - - 로그인 상태: 이름/이메일/제공자/동기화 상태/로그아웃 + - 로그인 상태: 이름/이메일/제공자/로그아웃 - 로그아웃 상태: 로그인 유도 CTA ## 4. 상태 관리 설계 @@ -189,10 +189,6 @@ refresh token으로 access token을 갱신한다. 온보딩/취향 정보를 서버에 저장한다. -### `POST /v1/me/migrate-local-data` - -로그인 전 로컬로 만든 로그, 좋아요, Recap 초안을 로그인 계정으로 이관한다. - ## 6. 소셜 로그인 제공자 전략 ### MVP 1순위 @@ -344,22 +340,10 @@ access token 만료 시 여러 query/mutation이 동시에 refresh를 호출할 - web MVP: mock auth 또는 memory session - web production: 백엔드 httpOnly secure cookie 기반 세션 검토 -### 10.4 로그인 전 로컬 데이터 이관 - -로그인 전 로컬에 남은 데이터가 로그인 후 중복 생성될 수 있다. - -보강안: - -- 로컬 로그/좋아요/Recap에는 `localId`, `createdAt`, `syncedAt`을 둔다. -- migration endpoint는 idempotency key를 받는다. -- migration 성공 후 즉시 삭제하지 않고 `syncedAt` 표시로 남긴다. - ## 11. 구현 전 확인 질문 아래 항목은 제품 정책에 영향을 주므로 구현 전에 결정이 필요하다. 1. MVP에서는 로그인을 필수로 막고, 온보딩 소개와 약관만 로그아웃 상태에서 접근할 수 있게 합니다. 2. 1차 소셜 로그인 제공자는 무엇으로 갈까요? 권장안은 Apple, Google, Kakao입니다. -3. 로그인 전 만든 여행 로그/좋아요/Recap은 로그인 후 자동 이관할까요, 사용자 확인 후 이관할까요? -4. 로그아웃 시 로컬 여행 기록은 유지할까요, 모두 삭제할까요? -5. 실제 소셜 OAuth는 이번 작업에서 붙일까요, 아니면 mock auth 골격과 API 계약까지만 먼저 갈까요? +3. 실제 소셜 OAuth는 이번 작업에서 붙일까요, 아니면 mock auth 골격과 API 계약까지만 먼저 갈까요? diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index bee4deb..e49f59d 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -52,7 +52,7 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 | 네비게이션 | React Navigation | 탭, 스택, 모달 흐름 구성에 적합 | | 서버 상태 | TanStack Query | 관광 API, 추천 API, Recap API 캐싱 및 실패 처리 | | 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 선택 곡 상태 관리 | -| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태, 최근 여행 세션, 임시 로그 저장 | +| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태와 진행 중 GPS 경로 복구용 센서 버퍼 저장 | | 카메라 | expo-camera 또는 react-native-vision-camera | MVP는 expo-camera, 고성능 촬영/프레임 처리는 vision-camera 검토 | | 위치 | expo-location 또는 react-native-geolocation-service | 백그라운드 위치 필요 여부에 따라 선택 | | 이미지 처리 | expo-image, expo-image-manipulator | Recap 썸네일, 이미지 압축, 캐싱 | @@ -341,7 +341,6 @@ type MomentLog = { mode?: TravelMode; moods: MoodTag[]; memo?: string; - syncStatus: "local" | "syncing" | "synced" | "failed"; }; ``` @@ -517,9 +516,9 @@ Soundlog는 이미지와 위치, 리스트가 많기 때문에 모바일 성능 프론트 대응은 다음과 같다. - 마지막 추천 플레이리스트를 로컬 캐싱한다. -- 리캡 저장 요청은 네트워크 실패 시 로컬 큐에 저장한다. -- 네트워크 복구 후 자동 동기화한다. -- Recap 생성 요청 실패 시 재시도 가능하게 한다. +- 리캡 저장 요청은 서버 성공 응답을 받은 뒤 완료 처리한다. +- 저장 요청이 실패하면 작성 화면을 유지하고 명시적으로 재시도할 수 있게 한다. +- 여행 시작과 종료도 서버 요청이 실패하면 로컬 성공 상태로 바꾸지 않는다. - 오프라인 상태에서는 “최근 추천 기반으로 계속 듣기”를 제공한다. --- diff --git a/docs/implementation/2026-08-11-server-first-recap-write-plan.md b/docs/implementation/2026-08-11-server-first-recap-write-plan.md new file mode 100644 index 0000000..c75d5a0 --- /dev/null +++ b/docs/implementation/2026-08-11-server-first-recap-write-plan.md @@ -0,0 +1,96 @@ +# 리캡 서버 우선 저장 전환 계획 + +## 목표 + +Soundlog의 리캡과 여행 로그는 서버 저장이 완료된 데이터만 사용자 기록으로 취급한다. 로그인 전 로컬 기록을 계정으로 옮기는 기능과 로컬 재시도 큐는 제거한다. 네트워크 요청이 실패하면 성공한 것처럼 화면을 떠나지 않고 현재 화면에서 원인과 재시도 방법을 안내한다. + +## 기능 계약 + +### 사용자 목표 + +사용자는 저장 버튼을 누른 결과가 서버 계정에 실제로 보관됐는지 즉시 알 수 있어야 한다. 다른 기기에서 볼 수 없는 기기 전용 기록이나 나중에 자동으로 올라갈 것처럼 보이는 기록을 만들지 않는다. + +### 진입과 종료 + +- 카메라 검토 화면에서 저장을 누르면 `POST /v1/recap-captures` 요청을 즉시 보낸다. +- 서버가 리캡을 반환한 뒤에만 지도나 음악추천 화면으로 돌아간다. +- 여행모드는 서버 여행 세션 생성이 성공한 뒤에만 시작한다. +- 여행 종료는 서버 세션 종료와 여행 로그 생성이 성공한 뒤에만 완료 화면으로 이동한다. +- 수정과 삭제는 서버 응답이 성공한 뒤 화면의 서버 캐시를 갱신한다. + +### 상태 소유권 + +서버가 리캡과 여행 로그의 유일한 원본이다. React Query는 서버 응답을 화면에 전달하는 캐시로만 사용한다. Zustand와 AsyncStorage에는 리캡 목록과 저장 대기 작업을 보관하지 않는다. + +여행 중 수집하는 GPS 경로는 연속 센서 데이터이므로 예외로 둔다. 현재 여행 세션과 경로점은 앱 재실행 복구와 요청 묶음을 위해 기기에 잠시 보관할 수 있다. 이 데이터는 사용자에게 완성된 리캡이나 로그로 표시하지 않는다. + +### API 기대사항 + +- `POST /v1/recap-captures`는 사진과 장소와 음악과 무드를 서버에 저장한다. +- 여행모드 밖에서 만든 독립 리캡은 같은 요청에서 공유용 리캡 식별자까지 생성하고 `recapId`로 반환한다. +- 같은 `Idempotency-Key` 요청은 중복 리캡을 만들지 않는다. +- `GET /v1/recap-captures`와 `GET /v1/recaps`가 화면의 기록 원본이다. +- 로그인 필수 정책과 충돌하는 `POST /v1/me/migrate-local-data`는 제거한다. +- 서버 데이터베이스에서 항상 `synced`인 `syncStatus` 필드는 제거한다. 이전 앱 배포 호환을 위해 서버 응답 상수는 한시적으로 유지하고 신규 앱에서는 사용하지 않는다. + +### 실패와 오프라인 상태 + +- 저장 실패 시 카메라 검토 화면을 유지하고 다시 저장할 수 있게 한다. +- 여행 시작 실패 시 여행모드를 시작하지 않는다. +- 여행 종료 실패 시 현재 여행 세션을 유지하고 다시 종료할 수 있게 한다. +- 수정과 삭제 실패 시 기존 서버 데이터를 그대로 보여주고 오류를 안내한다. +- 오프라인 자동 저장과 백그라운드 재시도는 제공하지 않는다. +- 저장하지 않은 촬영 결과는 앱을 종료하면 사라질 수 있다. 이를 서버 저장 완료 기록으로 표시하지 않는다. + +### 권한과 분석 이벤트 + +카메라와 위치와 사진 보관함 권한 정책은 유지한다. `moment_log_saved` 이벤트는 서버 저장 성공 후에만 발생시킨다. 저장 실패는 화면 오류로 안내하고 서버 기록 성공 이벤트로 집계하지 않는다. + +## 변경 범위 + +### 프론트엔드 + +1. 마이 화면의 `로컬 기록 동기화` 버튼과 로그인 후 자동 이관 호출을 제거한다. +2. 인증 API와 타입과 목 서버에서 로컬 데이터 이관 계약을 제거한다. +3. 리캡 저장을 API 직접 호출로 바꾸고 성공 전 화면 이동을 막는다. +4. 로컬 리캡 저장소와 생성 및 수정 및 삭제 큐와 자동 동기화 작업을 제거한다. +5. 지도와 로그 목록과 로그 상세에서 로컬 기록 대체 표시와 동기화 상태 문구를 제거한다. +6. 여행 시작과 종료에서 로컬 세션과 로컬 로그 대체 흐름을 제거한다. +7. 서버 쿼리 무효화와 재조회로 저장 결과를 반영한다. + +### 서버 + +1. 독립 리캡 생성 시 `POST /v1/recap-captures`가 서버 공유 식별자까지 반환한다. +2. 멱등성 키 재요청이 캡처와 독립 리캡을 중복 생성하지 않는지 테스트한다. +3. 로컬 데이터 이관 라우트와 검증기와 서비스와 문서를 제거한다. +4. `MomentLog.syncStatus` 데이터베이스 필드를 제거한다. 응답 상수는 이전 앱 배포 호환 기간에만 유지한다. +5. OpenAPI 문서와 서버 도메인 계약을 서버 우선 저장 정책으로 갱신한다. + +## 검증 + +### 프론트엔드 + +- TypeScript 검사를 통과한다. +- 저장 코드에 `queueCreate`와 `pendingActions`와 `syncStatus`가 남지 않는다. +- 앱 전체에 사용자용 `로컬 기록 동기화`와 `동기화 대기` 문구가 남지 않는다. +- 서버 계약 검사와 Expo 진단을 실행한다. + +### 서버 + +- 타입 검사와 빌드와 API 테스트를 통과한다. +- OpenAPI와 Express 라우트 동기화 검사를 통과한다. +- 독립 리캡 생성 응답에 `recapId`가 있고 같은 멱등성 키로 재요청해도 캡처와 리캡 개수가 늘지 않는다. +- `/v1/me/migrate-local-data`가 더 이상 공개 라우트나 OpenAPI에 존재하지 않는다. +- Prisma 마이그레이션이 `MomentLog.syncStatus`를 제거한다. + +## 위험과 대응 + +사진 업로드 뒤 독립 리캡 집계 생성이 실패할 수 있다. 서버는 캡처 생성과 집계 생성에 연결된 멱등성 키를 사용한다. 사용자가 같은 화면에서 다시 저장하면 기존 캡처를 재사용하고 누락된 집계 생성을 다시 시도한다. + +여행 종료 중 세션 종료는 성공하고 로그 생성만 실패할 수 있다. 종료 버튼 재시도 시 서버의 세션 종료 요청과 로그 생성 요청이 멱등하게 처리되도록 기존 계약을 유지한다. 프론트엔드는 로그 식별자를 받기 전에는 로컬 완료 상태로 바꾸지 않는다. + +기존 앱 버전이 `syncStatus`를 읽더라도 이 필드는 화면 분기에만 사용됐고 서버 저장 데이터는 항상 `synced`였다. 신규 앱은 필드를 사용하지 않는다. 신규 앱만 `createStandaloneRecap: true`를 보내 서버의 독립 리캡 확정을 요청한다. 이전 앱은 이 값을 보내지 않으므로 새 서버에서도 기존 후속 요청을 사용한다. 신규 앱이 이전 서버를 호출해 응답에 `recapId`가 없으면 같은 멱등성 키로 후속 요청을 한 번 수행한다. 따라서 앱과 서버의 배포 순서가 달라도 독립 리캡이 빠지거나 중복되지 않는다. + +## 계획 자체 검토 + +이 계획을 기능 계약과 데이터 무결성 기준으로 다시 검토했다. 단순히 버튼만 제거하면 로그인 시 자동 이관과 저장 큐가 남는 문제가 있어 관련 계약 전체를 제거 범위에 포함했다. 모든 로컬 저장을 제거하면 GPS 경로 복구가 불가능해지므로 완성된 사용자 기록과 센서 버퍼를 구분했다. 저장 실패를 자동 성공처럼 처리하지 않으며 현재 화면에서 명시적으로 재시도하는 정책으로 통일했다. diff --git a/docs/product/RECAP_LOG_DOMAIN_MODEL.md b/docs/product/RECAP_LOG_DOMAIN_MODEL.md index 380e6c0..ce4f1ba 100644 --- a/docs/product/RECAP_LOG_DOMAIN_MODEL.md +++ b/docs/product/RECAP_LOG_DOMAIN_MODEL.md @@ -346,25 +346,25 @@ sessionId가 있고 + 해당 세션의 리캡이 1개 이상인 경우 - 로그 상세에서 개별 리캡의 표현 템플릿을 다시 고르지 않는다. - 세션이 다른 리캡을 임의로 옮기거나 합치는 기능은 별도 기획 없이는 제공하지 않는다. -## 10. 오프라인과 동기화 +## 10. 서버 우선 저장과 실패 처리 -리캡은 네트워크가 없어도 로컬에 먼저 저장한다. +리캡과 로그는 서버 저장이 끝난 데이터만 사용자 기록으로 취급한다. ```text -local/pending -> synced - -> failed -> retry -> synced +저장 요청 -> 서버 성공 -> 기록 완료 + -> 서버 실패 -> 현재 화면 유지 -> 사용자가 재시도 ``` -동기화 규칙: +저장 규칙: -1. 로컬 리캡 ID를 idempotency key로 사용해 중복 생성을 막는다. -2. 여행모드 리캡의 `sessionId`를 재시도 과정에서도 잃지 않는다. -3. 표현 템플릿과 공개 범위도 재시도 payload에 보존한다. -4. 여행 종료 시 pending 리캡을 먼저 동기화하고 로그 생성을 시도한다. -5. 일부 리캡 동기화가 실패하면 로컬 로그를 우선 보여주며 구성원을 버리지 않는다. -6. 앱 재실행 후에도 활성 여행 세션과 GPS 경로를 복구한다. -7. 여행 종료 로그 생성 요청도 별도 영속 큐에 저장하고, 리캡 업로드가 모두 끝난 뒤 자동 재시도한다. -8. 오프라인 로컬 세션은 서버가 소유 리캡을 확인한 뒤 종료 세션으로 복구하므로 로컬 `sessionId`를 바꾸거나 버리지 않는다. +1. 카메라 저장은 `POST /v1/recap-captures` 응답이 성공한 뒤 완료한다. +2. 같은 저장 화면의 재시도는 같은 idempotency key를 사용해 중복 생성을 막는다. +3. 여행모드는 서버 여행 세션이 생성된 뒤에만 시작한다. +4. 여행 종료는 서버 세션 종료와 로그 생성이 끝난 뒤에만 완료한다. +5. 서버 저장에 실패한 리캡과 로그를 기기 기록으로 대신 보여주지 않는다. +6. 앱 재실행 복구가 필요한 활성 여행 세션과 GPS 경로는 센서 버퍼로 기기에 보관할 수 있다. +7. GPS 경로 버퍼는 완성된 리캡이나 로그가 아니며 사용자 기록 목록에 표시하지 않는다. +8. 백그라운드 저장 큐와 자동 업로드는 제공하지 않는다. ## 11. 제품 용어와 레거시 코드 매핑 @@ -400,8 +400,8 @@ type Recap = { track?: Track; moodTags: MoodTag[]; note?: string; - templateId: "album" | "lp" | "film" | "map"; - visibility: "private" | "public"; + templateId: 'album' | 'lp' | 'film' | 'map'; + visibility: 'private' | 'public'; }; type TravelLog = { @@ -413,7 +413,7 @@ type TravelLog = { startedAt: string; endedAt: string; title?: string; - visibility: "private" | "public"; + visibility: 'private' | 'public'; }; ``` @@ -474,12 +474,12 @@ type TravelLog = { - [ ] private 리캡의 위치와 존재가 공개 응답에 섞이지 않는가? - [ ] public 전환 전에 위치 존재 여부를 검증하는가? -### 동기화 +### 서버 저장 -- [ ] 오프라인 재시도에서 `sessionId`, `templateId`, 공개 범위를 보존하는가? -- [ ] 여행 종료 시 리캡과 경로를 잃지 않는가? -- [ ] 중복 요청이 중복 리캡/로그를 만들지 않는가? -- [ ] 앱 재실행 후 남은 여행 종료 로그 생성 큐가 자동 재시도되는가? +- [ ] 서버 성공 응답 전에 저장 완료 화면으로 이동하지 않는가? +- [ ] 저장 실패 시 현재 화면에서 오류와 재시도 방법을 안내하는가? +- [ ] 여행 종료 시 서버의 리캡과 경로만으로 로그를 만드는가? +- [ ] 같은 요청의 재시도가 중복 리캡과 로그를 만들지 않는가? ## 15. 대표 수용 시나리오 @@ -529,18 +529,16 @@ type TravelLog = { 다른 작업 세션은 아래 파일에서 현재 구현을 확인한다. -| 책임 | 프론트엔드 기준 파일 | -| ------------------------------------ | --------------------------------------------------------------- | -| 카메라 촬영과 Recap 로컬 저장 | `src/components/moment-capture/MomentCaptureScreen.tsx` | -| 촬영 후 템플릿/장소/공개 범위 설정 | `src/components/moment-capture/MomentReviewPanel.tsx` | -| Recap 오프라인 큐와 동기화 | `src/store/momentLogStore.ts`, `src/utils/momentLogSync.ts` | -| 여행 종료 Log 생성 영속 큐 | `src/store/travelLogSyncStore.ts`, `src/utils/travelLogSync.ts` | -| 여행 세션과 경로 로컬 영속화 | `src/store/travelSessionStore.ts` | -| foreground GPS 경로 수집 | `src/hooks/useTravelRouteTracking.ts` | -| `sessionId` 기반 Log 그룹 생성 | `src/utils/recapMappers.ts` | -| 여행 Log만 보여주는 격자 목록 | `src/components/recap/RecapListScreen.tsx` | -| Log 상세과 독립 Recap 상세 분기 | `src/components/recap-share/RecapShareScreen.tsx` | -| 현재 Log Recap 핀과 세션 경로 렌더링 | `src/components/recap-share/RecapRouteMap.tsx` | +| 책임 | 프론트엔드 기준 파일 | +| ------------------------------------ | -------------------------------------------------------- | +| 카메라 촬영과 Recap 서버 저장 | `src/components/moment-capture/MomentCaptureScreen.tsx` | +| 촬영 후 템플릿/장소/공개 범위 설정 | `src/components/moment-capture/MomentReviewPanel.tsx` | +| 여행 세션과 경로 로컬 영속화 | `src/store/travelSessionStore.ts` | +| foreground GPS 경로 수집 | `src/hooks/useTravelRouteTracking.ts` | +| 서버 리캡과 로그 조회 | `src/api/momentLogQueries.ts`, `src/api/recapQueries.ts` | +| 여행 Log만 보여주는 격자 목록 | `src/components/recap/RecapListScreen.tsx` | +| Log 상세과 독립 Recap 상세 분기 | `src/components/recap-share/RecapShareScreen.tsx` | +| 현재 Log Recap 핀과 세션 경로 렌더링 | `src/components/recap-share/RecapRouteMap.tsx` | 서버 기준점: diff --git a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md index 5a68ecd..542ccb1 100644 --- a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md +++ b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md @@ -297,7 +297,7 @@ CTA 정책: - 여행모드 ON이면 현재 `sessionId`를 가진 Recap으로 저장하고 활성 Log에 포함한다. - 여행모드 OFF이면 `sessionId`가 없는 독립 Recap으로 저장하며 Log를 만들지 않는다. -- 업로드 실패 시 로컬 임시 저장 후 재시도할 수 있어야 한다. +- 업로드 실패 시 현재 작성 화면을 유지하고 사용자가 다시 저장할 수 있어야 한다. - 사진이 없어도 음악/장소/무드 기반 Recap을 허용한다. ### 6.5 로그 탭 @@ -340,7 +340,7 @@ CTA 정책: - `전체공개`: 다른사람 보기와 지도 공개 영역에 표시 - `비공개`: 내것만 보기에서만 표시 -- 서버에 저장되지 않은 로컬 로그는 상세 진입만 가능하고 공개 범위 변경은 비활성화 +- 서버 조회에 실패하면 로그를 대체 표시하지 않고 오류와 재시도 방법을 안내 빈 상태: @@ -654,7 +654,7 @@ MVP 이후 흐름: | 지도 로딩 실패 | 기본 지도 영역 유지, 핀만 실패 상태 | | 추천 API 실패 | 이전 추천 또는 샘플 추천 fallback | | 외부 링크 실패 | YouTube 검색 링크 fallback | -| 사진 업로드 실패 | 로컬 임시 저장 후 재시도 | +| 사진 업로드 실패 | 작성 화면 유지 후 명시적 재시도 | | Recap 저장 실패 | 편집 초안 유지 | | 공개 Recap 위치 없음 | 공개 전환 차단 | | 로그인 만료 | 재로그인 유도, 작성 중 데이터 임시 보존 | @@ -698,7 +698,7 @@ MVP 이후 흐름: - 여러 템플릿 - 공개 Recap 신고/숨김 - 수동 장소 선택 -- 로컬 임시 저장/재시도 +- 작성 중 초안 복구 정책 검토 - 추천 실패 fallback ### P2: 커뮤니티 확장 diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md index 2d56552..4d0a5b9 100644 --- a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md @@ -20,16 +20,16 @@ Soundlog는 사용자가 여행 중에 음악을 직접 재생하는 앱이 아 이 문서는 다음 목표를 충족하도록 작성한다. -| 요청/요구사항 | 문서 반영 위치 | 완료 기준 | -| --- | --- | --- | -| 기획을 상세하게 다시 작성 | 3~13장 | 제품 정의, 타겟, 핵심 루프, IA, 화면, API, 이벤트, MVP 범위, 데모 시나리오가 한 흐름으로 연결된다. | -| 와이어프레임을 잡고 옆에 기능/화면 명세를 작성 | 8장 | 각 화면이 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 정리된다. | -| 기록·Recap 중심 피벗 | 3~6장, 8장, 11장 | 추천이 기록과 Recap으로 이어지고, Recap이 조연이 아니라 핵심 결과물이 된다. | -| 재생 외부화 | 4.4장, P-05, 10장 | 앱은 곡 메타데이터와 링크를 제공하고, 실제 감상은 Spotify/YouTube Music/YouTube로 넘긴다. | -| 브랜딩 문구 반영 | 1장, P-01 | "지금 이 장소의 음악을 찾아주고, 여행이 끝나면 너만의 사운드트랙 앨범으로 남겨주는 서비스"가 제품 정의와 첫 화면에 반영된다. | -| 강점과 약점까지 검토 | 3.2장, 3.3장, 14장 | 저작권/정책 리스크 감소, 곡 단위 출력 유지, Recap 주연화와 함께 데모 심심함/피드백 부족 리스크를 보완한다. | -| 리뷰를 거듭하며 루프 | 14장 | 제품 방향, UX/화면, 구현/검증 관점의 다회 자체 리뷰와 반영 결과를 남긴다. | -| 낯선 여행자와 음악 공유/동행 매칭 | 6장, 7장, P-12~P-14 | 여행 모드 사용자가 익명으로 음악 취향을 공개하고, 취향이 맞는 사람끼리 상호 동의 기반 동행 매칭을 시도할 수 있다. | +| 요청/요구사항 | 문서 반영 위치 | 완료 기준 | +| ---------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 기획을 상세하게 다시 작성 | 3~13장 | 제품 정의, 타겟, 핵심 루프, IA, 화면, API, 이벤트, MVP 범위, 데모 시나리오가 한 흐름으로 연결된다. | +| 와이어프레임을 잡고 옆에 기능/화면 명세를 작성 | 8장 | 각 화면이 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 정리된다. | +| 기록·Recap 중심 피벗 | 3~6장, 8장, 11장 | 추천이 기록과 Recap으로 이어지고, Recap이 조연이 아니라 핵심 결과물이 된다. | +| 재생 외부화 | 4.4장, P-05, 10장 | 앱은 곡 메타데이터와 링크를 제공하고, 실제 감상은 Spotify/YouTube Music/YouTube로 넘긴다. | +| 브랜딩 문구 반영 | 1장, P-01 | "지금 이 장소의 음악을 찾아주고, 여행이 끝나면 너만의 사운드트랙 앨범으로 남겨주는 서비스"가 제품 정의와 첫 화면에 반영된다. | +| 강점과 약점까지 검토 | 3.2장, 3.3장, 14장 | 저작권/정책 리스크 감소, 곡 단위 출력 유지, Recap 주연화와 함께 데모 심심함/피드백 부족 리스크를 보완한다. | +| 리뷰를 거듭하며 루프 | 14장 | 제품 방향, UX/화면, 구현/검증 관점의 다회 자체 리뷰와 반영 결과를 남긴다. | +| 낯선 여행자와 음악 공유/동행 매칭 | 6장, 7장, P-12~P-14 | 여행 모드 사용자가 익명으로 음악 취향을 공개하고, 취향이 맞는 사람끼리 상호 동의 기반 동행 매칭을 시도할 수 있다. | --- @@ -39,35 +39,35 @@ Soundlog는 사용자가 여행 중에 음악을 직접 재생하는 앱이 아 초기 아이디어에는 Spotify 계정 연동, 재생 시작/정지, 플레이리스트 제어 같은 기능이 포함될 수 있었다. 하지만 이 방향은 MVP의 성공 가능성을 낮추는 리스크가 있다. -| 이슈 | 설명 | 제품 영향 | -| --- | --- | --- | -| 외부 플랫폼 의존 | Spotify 조작 기능은 사용자 계정, 구독 상태, 활성 기기, 플랫폼 정책에 영향을 받는다. | 테스트 가능한 사용자가 줄고 핵심 플로우가 불안정해진다. | -| 프리미엄 제약 | 실제 재생 제어 기능은 Spotify Premium 사용자에게만 유효한 경우가 많다. | Soundlog 가치가 일부 사용자에게만 전달된다. | -| 저작권/정책 리스크 | 앱 안에서 음원을 직접 제공하거나 재생 제어를 핵심으로 삼으면 심사와 정책 검토 범위가 커진다. | 배포 전 검수 난도가 올라간다. | -| ROI 문제 | "Spotify 사용자가 Spotify를 조작하는 앱"으로 보이면 시장이 좁아진다. | 여행 기록 앱으로서의 차별점이 약해진다. | +| 이슈 | 설명 | 제품 영향 | +| ------------------ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| 외부 플랫폼 의존 | Spotify 조작 기능은 사용자 계정, 구독 상태, 활성 기기, 플랫폼 정책에 영향을 받는다. | 테스트 가능한 사용자가 줄고 핵심 플로우가 불안정해진다. | +| 프리미엄 제약 | 실제 재생 제어 기능은 Spotify Premium 사용자에게만 유효한 경우가 많다. | Soundlog 가치가 일부 사용자에게만 전달된다. | +| 저작권/정책 리스크 | 앱 안에서 음원을 직접 제공하거나 재생 제어를 핵심으로 삼으면 심사와 정책 검토 범위가 커진다. | 배포 전 검수 난도가 올라간다. | +| ROI 문제 | "Spotify 사용자가 Spotify를 조작하는 앱"으로 보이면 시장이 좁아진다. | 여행 기록 앱으로서의 차별점이 약해진다. | ### 3.2 기록과 Recap 중심 기획의 장점 Soundlog가 곡의 실제 재생을 외부 앱으로 넘기고, 앱 내부에서는 추천, 선택, 기록, Recap에 집중하면 다음 장점이 생긴다. -| 장점 | 설명 | -| --- | --- | -| 더 넓은 사용자 대상 | Spotify Premium이 없어도 추천, 저장, 기록, Recap을 사용할 수 있다. | -| 정책 리스크 감소 | 음원 파일을 제공하지 않고 곡 메타데이터와 외부 링크만 다룬다. | -| 제품 정체성 강화 | 음악 앱이 아니라 "여행을 음악으로 기록하는 앱"이 된다. | -| 데모 안정성 향상 | 외부 재생 상태와 무관하게 홈, 로그, Recap 플로우를 보여줄 수 있다. | -| 데이터 학습 가능 | 링크 열기, 좋아요, 저장, 기록, 공유 이벤트로 추천 품질을 개선할 수 있다. | +| 장점 | 설명 | +| ------------------- | ------------------------------------------------------------------------ | +| 더 넓은 사용자 대상 | Spotify Premium이 없어도 추천, 저장, 기록, Recap을 사용할 수 있다. | +| 정책 리스크 감소 | 음원 파일을 제공하지 않고 곡 메타데이터와 외부 링크만 다룬다. | +| 제품 정체성 강화 | 음악 앱이 아니라 "여행을 음악으로 기록하는 앱"이 된다. | +| 데모 안정성 향상 | 외부 재생 상태와 무관하게 홈, 로그, Recap 플로우를 보여줄 수 있다. | +| 데이터 학습 가능 | 링크 열기, 좋아요, 저장, 기록, 공유 이벤트로 추천 품질을 개선할 수 있다. | ### 3.3 약점과 보완 전략 기록·Recap 중심 피벗은 안전하지만, 데모와 추천 고도화 측면에서 약점이 있다. MVP에서는 이 약점을 숨기지 않고 제품 구조 안에서 보완한다. -| 약점 | 사용자에게 보이는 위험 | MVP 보완 | -| --- | --- | --- | +| 약점 | 사용자에게 보이는 위험 | MVP 보완 | +| ------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | 앱 안에서 음악이 직접 나오지 않는다. | 데모가 음악 앱처럼 즉각적으로 느껴지지 않을 수 있다. | 온보딩과 홈에서 "외부 앱에서 듣고 Soundlog에는 기록한다"는 콘셉트를 먼저 설명하고, Recap 미리보기 이미지를 첫 화면의 감정적 보상으로 보여준다. | -| 재생/스킵 피드백을 직접 잡기 어렵다. | 추천 품질을 개선할 신호가 부족해질 수 있다. | `external_music_opened`, `track_liked`, `track_saved`, `moment_log_saved`, `next_recommendation_clicked`를 대체 피드백으로 사용한다. | -| 외부 앱 링크가 실패할 수 있다. | 사용자가 음악 감상으로 이어지지 못하고 막힐 수 있다. | Spotify/YouTube Music 딥링크 실패 시 YouTube 또는 웹 검색 링크로 fallback한다. | -| Recap이 약하면 제품 가치가 흐려진다. | "추천만 하고 끝나는 앱"으로 느껴질 수 있다. | MVP 성공 기준을 "로그 1개 이상으로 공유 가능한 이미지형 Recap 생성"으로 둔다. | +| 재생/스킵 피드백을 직접 잡기 어렵다. | 추천 품질을 개선할 신호가 부족해질 수 있다. | `external_music_opened`, `track_liked`, `track_saved`, `moment_log_saved`, `next_recommendation_clicked`를 대체 피드백으로 사용한다. | +| 외부 앱 링크가 실패할 수 있다. | 사용자가 음악 감상으로 이어지지 못하고 막힐 수 있다. | Spotify/YouTube Music 딥링크 실패 시 YouTube 또는 웹 검색 링크로 fallback한다. | +| Recap이 약하면 제품 가치가 흐려진다. | "추천만 하고 끝나는 앱"으로 느껴질 수 있다. | MVP 성공 기준을 "로그 1개 이상으로 공유 가능한 이미지형 Recap 생성"으로 둔다. | --- @@ -79,24 +79,24 @@ Soundlog가 곡의 실제 재생을 외부 앱으로 넘기고, 앱 내부에서 ### 4.2 핵심 가치 -| 가치 | 설명 | -| --- | --- | -| 장소에 맞는 발견 | 사용자는 직접 검색하지 않아도 지금 장소와 무드에 맞는 곡 목록을 받는다. | -| 부담 없는 감상 연결 | 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 앱 링크로 이어진다. | -| 순간의 맥락 저장 | 사진, 장소, 시간, 선택 곡, 무드가 하나의 MomentLog로 저장된다. | -| 여행 후 콘텐츠화 | 여행이 끝나면 선택한 곡과 기록이 사운드트랙 앨범, 필름, 공유 이미지로 재구성된다. | -| 함께 만드는 회고 | 같이 여행 간 사람끼리 사진과 곡을 모아 하나의 공동 Recap을 만든다. | -| 취향 기반 연결 | 낯선 사람의 프로필보다 공개한 음악, 무드, 여행 상태를 먼저 보고 취향이 맞는 여행자를 발견한다. | +| 가치 | 설명 | +| ------------------- | ---------------------------------------------------------------------------------------------- | +| 장소에 맞는 발견 | 사용자는 직접 검색하지 않아도 지금 장소와 무드에 맞는 곡 목록을 받는다. | +| 부담 없는 감상 연결 | 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 앱 링크로 이어진다. | +| 순간의 맥락 저장 | 사진, 장소, 시간, 선택 곡, 무드가 하나의 MomentLog로 저장된다. | +| 여행 후 콘텐츠화 | 여행이 끝나면 선택한 곡과 기록이 사운드트랙 앨범, 필름, 공유 이미지로 재구성된다. | +| 함께 만드는 회고 | 같이 여행 간 사람끼리 사진과 곡을 모아 하나의 공동 Recap을 만든다. | +| 취향 기반 연결 | 낯선 사람의 프로필보다 공개한 음악, 무드, 여행 상태를 먼저 보고 취향이 맞는 여행자를 발견한다. | ### 4.3 제품 포지셔닝 Soundlog는 다음 셋의 중간에 위치한다. -| 축 | 기존 서비스 | Soundlog의 위치 | -| --- | --- | --- | -| 음악 추천 | 스트리밍 앱의 청취 이력 기반 추천 | 장소, 여행 모드, 무드 기반 추천 | -| 여행 기록 | 사진/지도 중심 기록 | 사진, 장소, 음악, 시간의 결합 기록 | -| 공유 콘텐츠 | 수동 편집형 스토리/릴스 | 여행 로그 기반 자동 Recap | +| 축 | 기존 서비스 | Soundlog의 위치 | +| ----------- | --------------------------------- | ---------------------------------- | +| 음악 추천 | 스트리밍 앱의 청취 이력 기반 추천 | 장소, 여행 모드, 무드 기반 추천 | +| 여행 기록 | 사진/지도 중심 기록 | 사진, 장소, 음악, 시간의 결합 기록 | +| 공유 콘텐츠 | 수동 편집형 스토리/릴스 | 여행 로그 기반 자동 Recap | ### 4.4 MVP에서 하지 않는 것 @@ -110,13 +110,13 @@ Soundlog는 다음 셋의 중간에 위치한다. ## 5. 타겟 사용자와 사용 맥락 -| 타겟 | 상황 | 페인포인트 | Soundlog 해결 | -| --- | --- | --- | --- | -| 여행 기록형 사용자 | 여행 중 사진과 분위기를 잘 남기고 싶다. | 사진만 보면 그때의 음악과 감정이 사라진다. | 사진, 장소, 음악, 무드를 함께 저장한다. | -| 선곡이 귀찮은 사용자 | 카페, 바다, 야경, 드라이브 중 어울리는 음악을 듣고 싶다. | 음악 앱에서 직접 검색하는 시간이 여행 흐름을 끊는다. | 장소/무드 기반 곡 목록과 외부 링크를 제공한다. | -| 공유 콘텐츠형 사용자 | 여행이 끝난 뒤 감성적인 결과물을 만들고 싶다. | 직접 편집하기 번거롭고 일관된 콘셉트를 만들기 어렵다. | 자동 Recap 템플릿으로 사운드트랙 앨범을 만든다. | -| 동행 여행자 | 친구와 여행 중 각자의 취향을 남기고 싶다. | 한 명의 플레이리스트만 남고 개인의 기억은 흩어진다. | 각자가 마음에 든 곡과 사진을 올리고 공동 Recap으로 합친다. | -| 혼자 여행자/소규모 여행자 | 낯선 곳에서 취향이 맞는 사람과 가볍게 연결되고 싶다. | 일반 동행 매칭은 프로필 중심이라 어색하고 안전 우려가 크다. | 음악 취향, 무드, 여행 상태를 먼저 공유하고 상호 동의 기반으로 동행을 제안한다. | +| 타겟 | 상황 | 페인포인트 | Soundlog 해결 | +| ------------------------- | -------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ | +| 여행 기록형 사용자 | 여행 중 사진과 분위기를 잘 남기고 싶다. | 사진만 보면 그때의 음악과 감정이 사라진다. | 사진, 장소, 음악, 무드를 함께 저장한다. | +| 선곡이 귀찮은 사용자 | 카페, 바다, 야경, 드라이브 중 어울리는 음악을 듣고 싶다. | 음악 앱에서 직접 검색하는 시간이 여행 흐름을 끊는다. | 장소/무드 기반 곡 목록과 외부 링크를 제공한다. | +| 공유 콘텐츠형 사용자 | 여행이 끝난 뒤 감성적인 결과물을 만들고 싶다. | 직접 편집하기 번거롭고 일관된 콘셉트를 만들기 어렵다. | 자동 Recap 템플릿으로 사운드트랙 앨범을 만든다. | +| 동행 여행자 | 친구와 여행 중 각자의 취향을 남기고 싶다. | 한 명의 플레이리스트만 남고 개인의 기억은 흩어진다. | 각자가 마음에 든 곡과 사진을 올리고 공동 Recap으로 합친다. | +| 혼자 여행자/소규모 여행자 | 낯선 곳에서 취향이 맞는 사람과 가볍게 연결되고 싶다. | 일반 동행 매칭은 프로필 중심이라 어색하고 안전 우려가 크다. | 음악 취향, 무드, 여행 상태를 먼저 공유하고 상호 동의 기반으로 동행을 제안한다. | --- @@ -171,45 +171,45 @@ Soundlog는 다음 셋의 중간에 위치한다. Recap은 Soundlog의 최종 보상이므로 기능적으로만 맞는 화면이 아니라 저장하고 공유하고 싶은 결과물이어야 한다. -| 템플릿 | 목적 | 구성 | -| --- | --- | --- | -| 앨범 커버형 | 대표 여행 이미지를 가장 강하게 보여준다. | 대표 사진, 여행명, 대표 장소, 대표 곡, 무드 팔레트 | -| LP 슬리브형 | 여행을 하나의 레코드처럼 보이게 한다. | LP 그래픽, 트랙 리스트, 대표 곡, 동행자 이름 | -| 필름 타임라인형 | 여행의 흐름을 시간순으로 보여준다. | MomentLog 컷, 시간, 장소, 곡, 짧은 메모 | -| 지도 엽서형 | 이동 경로와 장소별 음악을 한 장에 담는다. | 지도 경로, 장소 핀, 대표 곡, 지역명 | +| 템플릿 | 목적 | 구성 | +| --------------- | ----------------------------------------- | -------------------------------------------------- | +| 앨범 커버형 | 대표 여행 이미지를 가장 강하게 보여준다. | 대표 사진, 여행명, 대표 장소, 대표 곡, 무드 팔레트 | +| LP 슬리브형 | 여행을 하나의 레코드처럼 보이게 한다. | LP 그래픽, 트랙 리스트, 대표 곡, 동행자 이름 | +| 필름 타임라인형 | 여행의 흐름을 시간순으로 보여준다. | MomentLog 컷, 시간, 장소, 곡, 짧은 메모 | +| 지도 엽서형 | 이동 경로와 장소별 음악을 한 장에 담는다. | 지도 경로, 장소 핀, 대표 곡, 지역명 | ### 커뮤니티 확장 원칙 커뮤니티는 공개 피드보다 여행 맥락이 선명한 관계형 기능부터 시작한다. -| 기능 | 설명 | 개인정보 원칙 | -| --- | --- | --- | -| 공동 Recap | 같은 여행방에 참여한 동행자가 사진, 곡, 메모를 올리고 최종 Recap 후보를 함께 고른다. | 초대 코드/링크 기반 참여, 방장 공개 범위 확정 | -| Live Sound Map | 여행 모드 ON 사용자의 현재 위치와 Soundlog에서 선택했거나 외부 링크로 연 곡을 지도 위에 표시한다. | 여행 모드 중에만 위치 표시, 동행자/익명 공개/비공개 선택 | -| 현재 음악 표시 | 실제 외부 플랫폼 재생 상태를 제어하지 않고 Soundlog의 현재 선택 곡 상태를 보여준다. | 사용자가 숨김 처리 가능 | -| Nearby Sound Match | 주변 익명 여행자의 공개 곡, 무드, 여행 상태를 보고 음악 취향이 맞는 사람을 발견한다. | 실명/정확 좌표/연락처 비공개, 공개 핀 TTL 적용 | -| Travel Mate Match | 음악 취향과 여행 목적이 맞는 사람끼리 동행 요청을 주고받는다. | 상호 수락 전 자유 채팅 제한, 신고/차단/반복 요청 제한 | +| 기능 | 설명 | 개인정보 원칙 | +| ------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| 공동 Recap | 같은 여행방에 참여한 동행자가 사진, 곡, 메모를 올리고 최종 Recap 후보를 함께 고른다. | 초대 코드/링크 기반 참여, 방장 공개 범위 확정 | +| Live Sound Map | 여행 모드 ON 사용자의 현재 위치와 Soundlog에서 선택했거나 외부 링크로 연 곡을 지도 위에 표시한다. | 여행 모드 중에만 위치 표시, 동행자/익명 공개/비공개 선택 | +| 현재 음악 표시 | 실제 외부 플랫폼 재생 상태를 제어하지 않고 Soundlog의 현재 선택 곡 상태를 보여준다. | 사용자가 숨김 처리 가능 | +| Nearby Sound Match | 주변 익명 여행자의 공개 곡, 무드, 여행 상태를 보고 음악 취향이 맞는 사람을 발견한다. | 실명/정확 좌표/연락처 비공개, 공개 핀 TTL 적용 | +| Travel Mate Match | 음악 취향과 여행 목적이 맞는 사람끼리 동행 요청을 주고받는다. | 상호 수락 전 자유 채팅 제한, 신고/차단/반복 요청 제한 | --- ## 7. 정보 구조 -| 영역 | 화면 ID | 화면명 | 목적 | -| --- | --- | --- | --- | -| 시작 | P-01 | 온보딩 | 서비스 개념과 외부 재생 방식을 명확히 전달 | -| 시작 | P-02 | 취향/권한 설정 | 위치, 여행 상태, 음악 무드 추천 준비 | -| 여행 | P-03 | 홈 / 지금 장소 | 현재 장소 기반 추천 허브 | -| 여행 | P-04 | 추천 사운드트랙 상세 | 곡 목록, 추천 이유, 피드백 제공 | -| 여행 | P-05 | 외부 음악 링크 패널 | 선택 곡을 외부 앱에서 열기 | -| 기록 | P-06 | 순간 기록 | 사진, 장소, 곡, 무드 저장 | -| 기록 | P-07 | 여행 사운드트랙 로그 | 여행별 기록 타임라인 확인 | -| 회고 | P-08 | Recap 생성 / 리스트 | 여행별 Recap 생성 상태 확인 | -| 회고 | P-09 | Recap 상세 / 공유 | 사운드트랙 앨범 결과물 저장/공유 | -| 관리 | P-10 | 보관함 / 마이 | 저장 곡, 권한, 취향 관리 | -| 커뮤니티 | P-11 | 공동 Recap 만들기 | 동행자 기록을 모아 하나의 Recap을 함께 편집 | -| 커뮤니티 | P-12 | Live Sound Map | 여행 모드 사용자의 현재 위치와 음악을 지도에 표시 | -| 커뮤니티 | P-13 | Nearby Sound Match | 주변 익명 여행자와 음악 취향을 먼저 공유 | -| 커뮤니티 | P-14 | Travel Mate Match | 취향이 맞는 사람에게 동행 요청을 보내고 상호 수락 | +| 영역 | 화면 ID | 화면명 | 목적 | +| -------- | ------- | -------------------- | ------------------------------------------------- | +| 시작 | P-01 | 온보딩 | 서비스 개념과 외부 재생 방식을 명확히 전달 | +| 시작 | P-02 | 취향/권한 설정 | 위치, 여행 상태, 음악 무드 추천 준비 | +| 여행 | P-03 | 홈 / 지금 장소 | 현재 장소 기반 추천 허브 | +| 여행 | P-04 | 추천 사운드트랙 상세 | 곡 목록, 추천 이유, 피드백 제공 | +| 여행 | P-05 | 외부 음악 링크 패널 | 선택 곡을 외부 앱에서 열기 | +| 기록 | P-06 | 순간 기록 | 사진, 장소, 곡, 무드 저장 | +| 기록 | P-07 | 여행 사운드트랙 로그 | 여행별 기록 타임라인 확인 | +| 회고 | P-08 | Recap 생성 / 리스트 | 여행별 Recap 생성 상태 확인 | +| 회고 | P-09 | Recap 상세 / 공유 | 사운드트랙 앨범 결과물 저장/공유 | +| 관리 | P-10 | 보관함 / 마이 | 저장 곡, 권한, 취향 관리 | +| 커뮤니티 | P-11 | 공동 Recap 만들기 | 동행자 기록을 모아 하나의 Recap을 함께 편집 | +| 커뮤니티 | P-12 | Live Sound Map | 여행 모드 사용자의 현재 위치와 음악을 지도에 표시 | +| 커뮤니티 | P-13 | Nearby Sound Match | 주변 익명 여행자와 음악 취향을 먼저 공유 | +| 커뮤니티 | P-14 | Travel Mate Match | 취향이 맞는 사람에게 동행 요청을 보내고 상호 수락 | --- @@ -217,87 +217,87 @@ Recap은 Soundlog의 최종 보상이므로 기능적으로만 맞는 화면이 ### P-01. 온보딩 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ Soundlog │
│ 지금 장소의 음악을 │
│ 여행 앨범으로 │
│ │
│ [예시 Recap 이미지]│
│ │
│ 로그인하고 시작하기│
│ 계정 만들기 │
└────────────────────┘
| **사용자 목표:** 서비스가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱이라는 점을 이해하고 계정 기반 저장 구조를 이해한다.

**진입:** 첫 실행, 로그아웃 상태.

**나가기:** 로그인/가입 후 취향·권한 설정.

**핵심 문구:** "음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 계정에 남겨요."

**상태:** 첫 실행 여부, 로그인 여부, 온보딩 완료 여부.

**예외:** 네트워크가 없어도 샘플 Recap 이미지는 볼 수 있지만 홈/기록/Recap 기능은 로그인 후 사용할 수 있다. | +| 와이어프레임 | 기능 및 화면 명세 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ Soundlog │
│ 지금 장소의 음악을 │
│ 여행 앨범으로 │
│ │
│ [예시 Recap 이미지]│
│ │
│ 로그인하고 시작하기│
│ 계정 만들기 │
└────────────────────┘
| **사용자 목표:** 서비스가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱이라는 점을 이해하고 계정 기반 저장 구조를 이해한다.

**진입:** 첫 실행, 로그아웃 상태.

**나가기:** 로그인/가입 후 취향·권한 설정.

**핵심 문구:** "음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 계정에 남겨요."

**상태:** 첫 실행 여부, 로그인 여부, 온보딩 완료 여부.

**예외:** 네트워크가 없어도 샘플 Recap 이미지는 볼 수 있지만 홈/기록/Recap 기능은 로그인 후 사용할 수 있다. | ### P-02. 취향/권한 설정 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 어떤 여행 중인가요?│
│ [바다] [드라이브] │
│ [산책] [카페] [야경]│
│ │
│ 어떤 분위기인가요? │
│ [잔잔한] [신나는] │
│ [시원한] [설레는] │
│ [감성적인] │
│ │
│ 위치 추천 켜기 │
│ 나중에 하기 │
└────────────────────┘
| **사용자 목표:** 추천에 필요한 최소 입력을 빠르게 설정한다.

**필수 입력:** 없음. 스킵 가능해야 한다.

**기본 상태:** 여행 상태는 `산책`, 무드는 `잔잔한`을 기본값으로 둘 수 있다.

**권한 정책:** 위치 권한은 홈에서 "현재 위치로 추천받기"를 누를 때 요청해도 된다. 초기 강제 요청은 피한다.

**로컬 저장:** 마지막 선택 상태/무드는 로컬에 저장한다.

**이벤트:** `onboarding_completed`, `travel_state_selected`, `mood_selected`, `location_permission_requested`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 어떤 여행 중인가요?│
│ [바다] [드라이브] │
│ [산책] [카페] [야경]│
│ │
│ 어떤 분위기인가요? │
│ [잔잔한] [신나는] │
│ [시원한] [설레는] │
│ [감성적인] │
│ │
│ 위치 추천 켜기 │
│ 나중에 하기 │
└────────────────────┘
| **사용자 목표:** 추천에 필요한 최소 입력을 빠르게 설정한다.

**필수 입력:** 없음. 스킵 가능해야 한다.

**기본 상태:** 여행 상태는 `산책`, 무드는 `잔잔한`을 기본값으로 둘 수 있다.

**권한 정책:** 위치 권한은 홈에서 "현재 위치로 추천받기"를 누를 때 요청해도 된다. 초기 강제 요청은 피한다.

**로컬 저장:** 마지막 선택 상태/무드는 로컬에 저장한다.

**이벤트:** `onboarding_completed`, `travel_state_selected`, `mood_selected`, `location_permission_requested`. | ### P-03. 홈 / 지금 장소 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 현재 위치 │
│ 성수동 카페거리 │
│ [장소 바꾸기] │
│ │
│ 상태 [카페 v] │
│ 무드 [잔잔한 v] │
│ │
│ 오늘의 사운드트랙 │
│ ┌────────────────┐ │
│ │ 커버 / 추천 이유│ │
│ │ 12곡 · 카페 무드│ │
│ └────────────────┘ │
│ │
│ [곡 보기] [기록] │
└────────────────────┘
| **사용자 목표:** 지금 장소에 어울리는 사운드트랙을 바로 확인한다.

**핵심 CTA:** 곡 보기, 순간 기록.

**API:** `POST /v1/playlists/contextual`에 `location`, `travelMode`, `moodTags` 또는 수동 장소 정보를 보낸다.

**상태:** 위치 조회 중, 권한 거부, 수동 장소, 추천 로딩, 추천 실패, 추천 없음.

**오프라인:** 마지막 추천을 표시하고 "최근 추천" 배지를 붙인다.

**피드백:** 상태/무드 변경은 즉시 추천 재요청 또는 적용 버튼 방식 중 하나로 통일한다. MVP는 적용 버튼보다 즉시 재요청이 더 간단하다. | +| 와이어프레임 | 기능 및 화면 명세 | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 현재 위치 │
│ 성수동 카페거리 │
│ [장소 바꾸기] │
│ │
│ 상태 [카페 v] │
│ 무드 [잔잔한 v] │
│ │
│ 오늘의 사운드트랙 │
│ ┌────────────────┐ │
│ │ 커버 / 추천 이유│ │
│ │ 12곡 · 카페 무드│ │
│ └────────────────┘ │
│ │
│ [곡 보기] [기록] │
└────────────────────┘
| **사용자 목표:** 지금 장소에 어울리는 사운드트랙을 바로 확인한다.

**핵심 CTA:** 곡 보기, 순간 기록.

**API:** `POST /v1/playlists/contextual`에 `location`, `travelMode`, `moodTags` 또는 수동 장소 정보를 보낸다.

**상태:** 위치 조회 중, 권한 거부, 수동 장소, 추천 로딩, 추천 실패, 추천 없음.

**오프라인:** 마지막 추천을 표시하고 "최근 추천" 배지를 붙인다.

**피드백:** 상태/무드 변경은 즉시 추천 재요청 또는 적용 버튼 방식 중 하나로 통일한다. MVP는 적용 버튼보다 즉시 재요청이 더 간단하다. | ### P-04. 추천 사운드트랙 상세 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 성수동 카페 사운드 │
│ 조용한 오후에 맞는 │
│ 잔잔한 인디/재즈 │
│ │
│ [더 잔잔하게] │
│ [더 신나게] │
│ │
│ 01 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 02 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 03 곡명 - 아티스트 │
│ ♡ 저장 열기 │
└────────────────────┘
| **사용자 목표:** 추천 이유를 이해하고 마음에 드는 곡을 고른다.

**핵심 CTA:** 열기, 좋아요, 저장, 무드 조정.

**앱 내부 역할:** 곡을 재생하는 것이 아니라 선택한 곡을 현재 여행 맥락에 연결한다.

**API:** 추천 상세 조회, 좋아요/저장, 추천 피드백 이벤트 전송.

**데이터:** track id, title, artist, album image, external links, reason tags.

**실패:** 링크가 없으면 곡명/아티스트 기반 검색 URL을 생성한다.

**이벤트:** `track_selected`, `track_liked`, `track_saved`, `mood_adjusted`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 성수동 카페 사운드 │
│ 조용한 오후에 맞는 │
│ 잔잔한 인디/재즈 │
│ │
│ [더 잔잔하게] │
│ [더 신나게] │
│ │
│ 01 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 02 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 03 곡명 - 아티스트 │
│ ♡ 저장 열기 │
└────────────────────┘
| **사용자 목표:** 추천 이유를 이해하고 마음에 드는 곡을 고른다.

**핵심 CTA:** 열기, 좋아요, 저장, 무드 조정.

**앱 내부 역할:** 곡을 재생하는 것이 아니라 선택한 곡을 현재 여행 맥락에 연결한다.

**API:** 추천 상세 조회, 좋아요/저장, 추천 피드백 이벤트 전송.

**데이터:** track id, title, artist, album image, external links, reason tags.

**실패:** 링크가 없으면 곡명/아티스트 기반 검색 URL을 생성한다.

**이벤트:** `track_selected`, `track_liked`, `track_saved`, `mood_adjusted`. | ### P-05. 외부 음악 링크 패널 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 선택한 곡 │
│ 곡명 │
│ 아티스트 │
│ 성수동 카페거리 │
│ │
│ 어디에서 들을까요? │
│ [Spotify에서 열기] │
│ [YouTube Music] │
│ [YouTube 검색] │
│ │
│ [이 곡으로 기록] │
└────────────────────┘
| **사용자 목표:** 선택한 곡을 익숙한 외부 서비스에서 듣거나, 이 곡을 여행 기록에 붙인다.

**중요 원칙:** 외부 앱이 없어도 Soundlog 기록은 가능해야 한다.

**딥링크 우선순위:** 앱 딥링크가 가능하면 앱으로 열고, 실패하면 웹 검색으로 fallback한다.

**API:** 링크 열기 이벤트만 서버에 보낸다. 재생 상태는 추적하지 않는다.

**상태:** 외부 앱 열기 성공/실패, 웹 fallback, 링크 없음.

**이벤트:** `external_music_opened`, `external_music_open_failed`, `track_attached_to_moment`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 선택한 곡 │
│ 곡명 │
│ 아티스트 │
│ 성수동 카페거리 │
│ │
│ 어디에서 들을까요? │
│ [Spotify에서 열기] │
│ [YouTube Music] │
│ [YouTube 검색] │
│ │
│ [이 곡으로 기록] │
└────────────────────┘
| **사용자 목표:** 선택한 곡을 익숙한 외부 서비스에서 듣거나, 이 곡을 여행 기록에 붙인다.

**중요 원칙:** 외부 앱이 없어도 Soundlog 기록은 가능해야 한다.

**딥링크 우선순위:** 앱 딥링크가 가능하면 앱으로 열고, 실패하면 웹 검색으로 fallback한다.

**API:** 링크 열기 이벤트만 서버에 보낸다. 재생 상태는 추적하지 않는다.

**상태:** 외부 앱 열기 성공/실패, 웹 fallback, 링크 없음.

**이벤트:** `external_music_opened`, `external_music_open_failed`, `track_attached_to_moment`. | ### P-06. 순간 기록 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ [카메라 프리뷰] │
│ │
│ 촬영 버튼 │
└────────────────────┘

┌────────────────────┐
│ 이 순간 저장하기 │
│ 사진 썸네일 │
│ 장소 성수동 카페거리│
│ 곡명 - 아티스트 │
│ 무드 잔잔한 │
│ 메모 추가 │
│ [저장] │
└────────────────────┘
| **사용자 목표:** 여행 중 인상적인 순간을 사진, 장소, 곡, 무드로 저장한다.

**권한:** 카메라 권한은 이 화면 진입 시 요청한다.

**저장 정책:** 사진이 없어도 저장 가능, 위치가 없어도 수동 장소로 저장 가능, 곡이 없어도 "음악 없음"으로 저장 가능.

**API:** `POST /v1/moment-logs`.

**로컬:** 네트워크 실패 시 로컬 큐에 저장하고 재시도한다.

**실패:** 업로드 실패를 숨기지 않고 `동기화 대기` 상태로 표시한다.

**이벤트:** `moment_capture_started`, `moment_log_saved`, `moment_log_sync_failed`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ [카메라 프리뷰] │
│ │
│ 촬영 버튼 │
└────────────────────┘

┌────────────────────┐
│ 이 순간 저장하기 │
│ 사진 썸네일 │
│ 장소 성수동 카페거리│
│ 곡명 - 아티스트 │
│ 무드 잔잔한 │
│ 메모 추가 │
│ [저장] │
└────────────────────┘
| **사용자 목표:** 여행 중 인상적인 순간을 사진, 장소, 곡, 무드로 저장한다.

**권한:** 카메라 권한은 이 화면 진입 시 요청한다.

**저장 정책:** 사진이 없어도 저장 가능, 위치가 없어도 수동 장소로 저장 가능, 곡이 없어도 "음악 없음"으로 저장 가능.

**API:** `POST /v1/recap-captures`.

**실패:** 서버 저장에 실패하면 현재 편집 화면을 유지하고 사용자가 다시 저장할 수 있게 한다.

**이벤트:** `moment_capture_started`, `moment_log_saved`. | ### P-07. 여행 사운드트랙 로그 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 이번 여행 로그 │
│ 7개 순간 · 12곡 │
│ │
│ 14:20 성수동 │
│ 사진 / 곡 / 무드 │
│ │
│ 16:05 서울숲 │
│ 사진 / 곡 / 무드 │
│ │
│ [Recap 만들기] │
└────────────────────┘
| **사용자 목표:** 여행 중 저장한 순간과 곡이 누적되는 것을 확인한다.

**핵심 CTA:** Recap 만들기, 로그 수정, 로그 삭제.

**데이터:** MomentLog 목록, 여행 세션 상태, 동기화 상태.

**빈 상태:** "아직 저장한 순간이 없어요. 지금 장소의 곡을 하나 골라볼까요?"로 홈 연결.

**오프라인:** 로컬 로그를 우선 표시하고 동기화 상태를 배지로 표시한다.

**이벤트:** `travel_log_viewed`, `moment_log_edited`, `recap_create_clicked`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 이번 여행 로그 │
│ 7개 순간 · 12곡 │
│ │
│ 14:20 성수동 │
│ 사진 / 곡 / 무드 │
│ │
│ 16:05 서울숲 │
│ 사진 / 곡 / 무드 │
│ │
│ [Recap 만들기] │
└────────────────────┘
| **사용자 목표:** 여행 중 저장한 순간과 곡이 누적되는 것을 확인한다.

**핵심 CTA:** Recap 만들기, 로그 수정, 로그 삭제.

**데이터:** 서버 MomentLog 목록과 여행 세션 상태.

**빈 상태:** "아직 저장한 순간이 없어요. 지금 장소의 곡을 하나 골라볼까요?"로 홈 연결.

**실패:** 서버 조회에 실패하면 대체 기록을 표시하지 않고 재시도 방법을 안내한다.

**이벤트:** `travel_log_viewed`, `moment_log_edited`, `recap_create_clicked`. | ### P-08. Recap 생성 / 리스트 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ Recap │
│ │
│ 이번 여행 │
│ 생성 중... │
│ │
│ 6월 강릉 여행 │
│ 대표 사진 / 대표 곡│
│ │
│ 5월 서울 산책 │
│ 대표 사진 / 대표 곡│
└────────────────────┘
| **사용자 목표:** 여행별 사운드트랙 앨범 생성 상태를 확인한다.

**API:** `POST /v1/recaps`, `GET /v1/recaps`.

**상태:** 생성 전, 생성 중, 완료, 실패, 재시도 가능.

**최소 생성 조건:** 로그 1개 이상이면 이미지형 Recap 생성 가능. 사진이 없으면 장소 이미지 또는 기본 템플릿 사용.

**실패:** 실패한 Recap은 숨기지 말고 재시도 버튼을 제공한다.

**이벤트:** `recap_requested`, `recap_generation_failed`, `recap_opened`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ Recap │
│ │
│ 이번 여행 │
│ 생성 중... │
│ │
│ 6월 강릉 여행 │
│ 대표 사진 / 대표 곡│
│ │
│ 5월 서울 산책 │
│ 대표 사진 / 대표 곡│
└────────────────────┘
| **사용자 목표:** 여행별 사운드트랙 앨범 생성 상태를 확인한다.

**API:** `POST /v1/recaps`, `GET /v1/recaps`.

**상태:** 생성 전, 생성 중, 완료, 실패, 재시도 가능.

**최소 생성 조건:** 로그 1개 이상이면 이미지형 Recap 생성 가능. 사진이 없으면 장소 이미지 또는 기본 템플릿 사용.

**실패:** 실패한 Recap은 숨기지 말고 재시도 버튼을 제공한다.

**이벤트:** `recap_requested`, `recap_generation_failed`, `recap_opened`. | ### P-09. Recap 상세 / 공유 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 강릉 바다 사운드 │
│ ┌────────────────┐ │
│ │ 앨범 커버형 │ │
│ │ 사진 + 대표 곡 │ │
│ └────────────────┘ │
│ [앨범] [LP] [필름] │
│ │
│ 대표 장소 주문진 │
│ 대표 곡 곡명 │
│ │
│ [이미지 저장] │
│ [공유하기] │
└────────────────────┘
| **사용자 목표:** 여행 결과물을 감상하고 저장/공유한다.

**MVP 우선순위:** 이미지형 Recap부터 구현한다. 영상형 Recap은 후순위다.

**비율:** 앨범 커버 1:1, 스토리 공유 9:16, 필름형 세로 스크롤.

**권한:** 사진 저장 권한은 저장 시점에 요청한다. 공유는 OS share sheet를 우선 사용한다.

**실패:** 캡처 실패, 저장 실패, 공유 실패를 각각 분리 안내한다.

**이벤트:** `recap_exported`, `recap_shared`, `recap_template_changed`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 강릉 바다 사운드 │
│ ┌────────────────┐ │
│ │ 앨범 커버형 │ │
│ │ 사진 + 대표 곡 │ │
│ └────────────────┘ │
│ [앨범] [LP] [필름] │
│ │
│ 대표 장소 주문진 │
│ 대표 곡 곡명 │
│ │
│ [이미지 저장] │
│ [공유하기] │
└────────────────────┘
| **사용자 목표:** 여행 결과물을 감상하고 저장/공유한다.

**MVP 우선순위:** 이미지형 Recap부터 구현한다. 영상형 Recap은 후순위다.

**비율:** 앨범 커버 1:1, 스토리 공유 9:16, 필름형 세로 스크롤.

**권한:** 사진 저장 권한은 저장 시점에 요청한다. 공유는 OS share sheet를 우선 사용한다.

**실패:** 캡처 실패, 저장 실패, 공유 실패를 각각 분리 안내한다.

**이벤트:** `recap_exported`, `recap_shared`, `recap_template_changed`. | ### P-10. 보관함 / 마이 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 보관함 │
│ [좋아요] [저장곡] │
│ 곡명 - 아티스트 │
│ 곡명 - 아티스트 │
│ │
│ 마이 │
│ 취향 수정 │
│ 위치 권한 │
│ 카메라 권한 │
│ 데이터 관리 │
└────────────────────┘
| **사용자 목표:** 마음에 든 음악과 앱 권한/취향을 관리한다.

**보관함:** 좋아요한 곡, 저장한 곡, 저장한 추천 목록을 분리한다.

**마이:** 계정, 권한, 취향, 데이터 삭제, 로그아웃을 제공한다.

**오프라인:** 최근 저장 목록은 로컬 캐시로 표시한다.

**권한:** 위치/카메라/사진 권한 상태를 확인하고 OS 설정으로 이동할 수 있어야 한다.

**이벤트:** `library_viewed`, `preference_updated`, `permission_settings_opened`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +|
┌────────────────────┐
│ 보관함 │
│ [좋아요] [저장곡] │
│ 곡명 - 아티스트 │
│ 곡명 - 아티스트 │
│ │
│ 마이 │
│ 취향 수정 │
│ 위치 권한 │
│ 카메라 권한 │
│ 데이터 관리 │
└────────────────────┘
| **사용자 목표:** 마음에 든 음악과 앱 권한/취향을 관리한다.

**보관함:** 좋아요한 곡, 저장한 곡, 저장한 추천 목록을 분리한다.

**마이:** 계정, 권한, 취향, 데이터 삭제, 로그아웃을 제공한다.

**오프라인:** 최근 저장 목록은 로컬 캐시로 표시한다.

**권한:** 위치/카메라/사진 권한 상태를 확인하고 OS 설정으로 이동할 수 있어야 한다.

**이벤트:** `library_viewed`, `preference_updated`, `permission_settings_opened`. | ### P-11. 공동 Recap 만들기 -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 강릉 여행방 │
│ 4명이 함께 만드는 │
│ 사운드트랙 앨범 │
│ │
│ 18개 순간 · 24곡 │
│ │
│ 주문진 바다 컷 │
│ 수경 추가 · 곡명 A │
│ [채택] [댓글] │
│ │
│ [공동 Recap 생성] │
└────────────────────┘
| **사용자 목표:** 같이 여행 간 사람이 남긴 사진과 곡을 하나의 Recap으로 합친다.

**초대:** 초대 코드 또는 링크로 여행방에 참여한다.

**권한:** 방장만 최종 Recap 생성과 공개 범위를 확정한다.

**기여:** 사진, 장소, 곡, 메모를 후보로 올리고 채택/댓글로 최종 구성에 반영한다.

**이벤트:** `trip_room_joined`, `shared_moment_added`, `collab_recap_created`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 강릉 여행방 │
│ 4명이 함께 만드는 │
│ 사운드트랙 앨범 │
│ │
│ 18개 순간 · 24곡 │
│ │
│ 주문진 바다 컷 │
│ 수경 추가 · 곡명 A │
│ [채택] [댓글] │
│ │
│ [공동 Recap 생성] │
└────────────────────┘
| **사용자 목표:** 같이 여행 간 사람이 남긴 사진과 곡을 하나의 Recap으로 합친다.

**초대:** 초대 코드 또는 링크로 여행방에 참여한다.

**권한:** 방장만 최종 Recap 생성과 공개 범위를 확정한다.

**기여:** 사진, 장소, 곡, 메모를 후보로 올리고 채택/댓글로 최종 구성에 반영한다.

**이벤트:** `trip_room_joined`, `shared_moment_added`, `collab_recap_created`. | ### P-12. Live Sound Map -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ Live Sound Map │
│ 여행 모드 ON │
│ 주변 익명 공개 │
│ │
│ [지도] │
│ 나 · 곡명 A │
│ 친구 · 곡명 B │
│ 82% · 근처 여행자 │
│ │
│ 현재 음악 │
│ 곡명 A - 아티스트 │
│ 주변 취향 매칭 보기│
│ [동행자만] [익명] │
└────────────────────┘
| **사용자 목표:** 여행 중 내가 어디서 어떤 음악을 듣고 있는지 동행자 또는 주변 익명 여행자와 공유한다.

**현재 음악 기준:** 외부 앱 재생 상태가 아니라 Soundlog에서 선택했거나 외부 링크로 연 곡을 현재 음악으로 표시한다.

**위치 정책:** 여행 모드 ON일 때만 표시하고, 공개 범위는 동행자/주변 익명/비공개로 제어한다. 주변 익명 공개는 정확 좌표가 아니라 대략 위치만 사용한다.

**상태:** 위치 권한 거부, 여행 모드 OFF, 현재 곡 없음, 네트워크 실패, 동행자 없음, 주변 공개 사용자 없음 상태를 분리한다.

**이벤트:** `travel_mode_enabled`, `live_track_shared`, `sound_map_viewed`, `nearby_sound_opened`. | +| 와이어프레임 | 기능 및 화면 명세 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ Live Sound Map │
│ 여행 모드 ON │
│ 주변 익명 공개 │
│ │
│ [지도] │
│ 나 · 곡명 A │
│ 친구 · 곡명 B │
│ 82% · 근처 여행자 │
│ │
│ 현재 음악 │
│ 곡명 A - 아티스트 │
│ 주변 취향 매칭 보기│
│ [동행자만] [익명] │
└────────────────────┘
| **사용자 목표:** 여행 중 내가 어디서 어떤 음악을 듣고 있는지 동행자 또는 주변 익명 여행자와 공유한다.

**현재 음악 기준:** 외부 앱 재생 상태가 아니라 Soundlog에서 선택했거나 외부 링크로 연 곡을 현재 음악으로 표시한다.

**위치 정책:** 여행 모드 ON일 때만 표시하고, 공개 범위는 동행자/주변 익명/비공개로 제어한다. 주변 익명 공개는 정확 좌표가 아니라 대략 위치만 사용한다.

**상태:** 위치 권한 거부, 여행 모드 OFF, 현재 곡 없음, 네트워크 실패, 동행자 없음, 주변 공개 사용자 없음 상태를 분리한다.

**이벤트:** `travel_mode_enabled`, `live_track_shared`, `sound_map_viewed`, `nearby_sound_opened`. | ### P-13. Nearby Sound Match -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 주변 사운드 취향 │
│ 대략 위치만 공개 │
│ │
│ [산책] [카페] [야경]│
│ │
│ 82% 비 오는 골목 │
│ 잔잔한 · 인디 │
│ 곡명 C - 아티스트 │
│ │
│ 76% 야경 수집가 │
│ 설레는 · 시티팝 │
│ │
│ [동행 후보 보기] │
└────────────────────┘
| **사용자 목표:** 낯선 사람의 실명/프로필보다 음악 취향과 여행 맥락을 먼저 보고 연결 가능성을 판단한다.

**진입:** P-12 Live Sound Map의 주변 취향 매칭 CTA, 홈의 여행 모드 배너.

**매칭 기준:** 공개 곡, 좋아요/저장 태그, 현재 무드, 여행 상태, 대략 거리, 최근 공개 시간.

**개인정보:** 실명, 정확 좌표, 연락처, 전체 청취 이력은 공개하지 않는다. 공개 핀은 기본 2시간 TTL 후 자동 숨김 처리한다.

**상태:** 주변 공개 사용자 없음, 내 공개 상태 OFF, 차단한 사용자 숨김, 네트워크 실패, 매칭 계산 실패.

**이벤트:** `nearby_sound_opened`, `music_match_viewed`, `music_match_profile_opened`. | +| 와이어프레임 | 기능 및 화면 명세 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 주변 사운드 취향 │
│ 대략 위치만 공개 │
│ │
│ [산책] [카페] [야경]│
│ │
│ 82% 비 오는 골목 │
│ 잔잔한 · 인디 │
│ 곡명 C - 아티스트 │
│ │
│ 76% 야경 수집가 │
│ 설레는 · 시티팝 │
│ │
│ [동행 후보 보기] │
└────────────────────┘
| **사용자 목표:** 낯선 사람의 실명/프로필보다 음악 취향과 여행 맥락을 먼저 보고 연결 가능성을 판단한다.

**진입:** P-12 Live Sound Map의 주변 취향 매칭 CTA, 홈의 여행 모드 배너.

**매칭 기준:** 공개 곡, 좋아요/저장 태그, 현재 무드, 여행 상태, 대략 거리, 최근 공개 시간.

**개인정보:** 실명, 정확 좌표, 연락처, 전체 청취 이력은 공개하지 않는다. 공개 핀은 기본 2시간 TTL 후 자동 숨김 처리한다.

**상태:** 주변 공개 사용자 없음, 내 공개 상태 OFF, 차단한 사용자 숨김, 네트워크 실패, 매칭 계산 실패.

**이벤트:** `nearby_sound_opened`, `music_match_viewed`, `music_match_profile_opened`. | ### P-14. Travel Mate Match -| 와이어프레임 | 기능 및 화면 명세 | -| --- | --- | -|
┌────────────────────┐
│ 동행 매칭 요청 │
│ 취향 82% 일치 │
│ 정확한 위치 비공개 │
│ │
│ 비 오는 골목 │
│ 잔잔한 인디 │
│ 카페 산책 선호 │
│ │
│ 공개 사운드 │
│ 곡명 C - 아티스트 │
│ │
│ 안전장치 │
│ 위치 선택 공개 │
│ 첫 메시지 제한 │
│ 차단/신고 가능 │
│ │
│ [취향으로 인사] │
│ [차단/신고] │
└────────────────────┘
| **사용자 목표:** 음악 취향과 여행 목적이 맞는 사람에게 부담 낮은 동행 요청을 보낸다.

**상호 동의:** 요청 발송과 상대 수락이 모두 있어야 대화가 열리고, 만남 제안은 안전 안내를 거친다.

**첫 메시지:** 자유 입력 전에 "이 곡 좋아해서 눌렀어요", "근처 카페 산책 코스 같이 볼까요?" 같은 템플릿을 우선 사용한다.

**안전장치:** 정확 위치 숨김, 연락처 비공개, 신고/차단, 반복 요청 제한, 매칭 취소, 만남 전 주의 안내를 제공한다.

**상태:** 요청 대기, 수락, 거절, 만료, 차단, 신고 완료, 상대 여행 모드 OFF.

**이벤트:** `travel_mate_requested`, `travel_mate_accepted`, `travel_mate_declined`, `community_user_blocked`, `community_user_reported`. | +| 와이어프레임 | 기능 및 화면 명세 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
┌────────────────────┐
│ 동행 매칭 요청 │
│ 취향 82% 일치 │
│ 정확한 위치 비공개 │
│ │
│ 비 오는 골목 │
│ 잔잔한 인디 │
│ 카페 산책 선호 │
│ │
│ 공개 사운드 │
│ 곡명 C - 아티스트 │
│ │
│ 안전장치 │
│ 위치 선택 공개 │
│ 첫 메시지 제한 │
│ 차단/신고 가능 │
│ │
│ [취향으로 인사] │
│ [차단/신고] │
└────────────────────┘
| **사용자 목표:** 음악 취향과 여행 목적이 맞는 사람에게 부담 낮은 동행 요청을 보낸다.

**상호 동의:** 요청 발송과 상대 수락이 모두 있어야 대화가 열리고, 만남 제안은 안전 안내를 거친다.

**첫 메시지:** 자유 입력 전에 "이 곡 좋아해서 눌렀어요", "근처 카페 산책 코스 같이 볼까요?" 같은 템플릿을 우선 사용한다.

**안전장치:** 정확 위치 숨김, 연락처 비공개, 신고/차단, 반복 요청 제한, 매칭 취소, 만남 전 주의 안내를 제공한다.

**상태:** 요청 대기, 수락, 거절, 만료, 차단, 신고 완료, 상대 여행 모드 OFF.

**이벤트:** `travel_mate_requested`, `travel_mate_accepted`, `travel_mate_declined`, `community_user_blocked`, `community_user_reported`. | --- @@ -321,28 +321,28 @@ Content-Type: application/json } ``` -| 필드 | 설명 | -| --- | --- | -| `location.lat` | 위도 | -| `location.lng` | 경도 | -| `travelMode` | 여행 상태 API 값: `ocean`, `drive`, `walk`, `cafe`, `night` | -| `moodTags` | 무드 API 값: `calm`, `active`, `fresh`, `emotional`, `local` | -| `preferredGenres` | 사용자가 선호하는 장르 힌트 | -| `excludeTrackIds` | 다음 추천에서 제외할 곡 ID 목록 | +| 필드 | 설명 | +| ----------------- | ------------------------------------------------------------ | +| `location.lat` | 위도 | +| `location.lng` | 경도 | +| `travelMode` | 여행 상태 API 값: `ocean`, `drive`, `walk`, `cafe`, `night` | +| `moodTags` | 무드 API 값: `calm`, `active`, `fresh`, `emotional`, `local` | +| `preferredGenres` | 사용자가 선호하는 장르 힌트 | +| `excludeTrackIds` | 다음 추천에서 제외할 곡 ID 목록 | UI 라벨과 API 값은 다음처럼 매핑한다. -| UI 라벨 | API 값 | -| --- | --- | -| 바다 | `ocean` | -| 드라이브 | `drive` | -| 산책 | `walk` | -| 카페 | `cafe` | -| 야경 | `night` | -| 잔잔한 | `calm` | -| 신나는 | `active` | -| 시원한 | `fresh` | -| 설레는 | `local` | +| UI 라벨 | API 값 | +| -------- | ----------- | +| 바다 | `ocean` | +| 드라이브 | `drive` | +| 산책 | `walk` | +| 카페 | `cafe` | +| 야경 | `night` | +| 잔잔한 | `calm` | +| 신나는 | `active` | +| 시원한 | `fresh` | +| 설레는 | `local` | | 감성적인 | `emotional` | ### 9.2 추천 응답 @@ -429,36 +429,36 @@ POST /v1/community/blocks POST /v1/community/reports ``` -| API | 목적 | -| --- | --- | -| `POST /v1/travel-sessions` | 서버 기준 여행 모드를 시작한다. Live Sound Map 공개의 전제 조건이다. | -| `PATCH /v1/travel-sessions/:sessionId` | 여행 모드를 종료하거나 상태를 변경한다. | -| `POST /v1/travel-rooms` | 같이 여행 간 사람끼리 쓸 여행방을 만든다. | -| `GET /v1/travel-rooms/:roomId` | 공동 여행방의 멤버, 후보 순간, 댓글, Recap 상태를 조회한다. | -| `POST /v1/travel-rooms/:roomId/join` | 초대 코드로 공동 여행방에 참여한다. 이미 참여한 사용자는 방 정보를 다시 받는다. | -| `POST /v1/travel-rooms/:roomId/moments` | 동행자가 공동 Recap 후보 순간을 추가한다. | -| `PATCH /v1/travel-rooms/:roomId/moments/:momentId` | 방장이 후보 순간을 `candidate`, `accepted`, `rejected` 상태로 변경한다. | -| `POST /v1/travel-rooms/:roomId/moments/:momentId/comments` | 방장/참여자가 후보 순간에 댓글을 남긴다. | -| `POST /v1/travel-rooms/:roomId/recaps` | 방장이 공동 Recap을 생성한다. 승인된 후보가 있으면 승인된 순간을 우선 사용한다. | -| `GET /v1/sound-map` | 여행 모드 ON 사용자의 현재 위치와 선택 곡 핀을 조회한다. | -| `POST /v1/sound-map/current-track` | active 여행 세션이 있는 사용자의 현재 위치와 Soundlog 현재 선택 곡을 지도에 표시한다. | -| `GET /v1/sound-map/nearby` | 주변 익명 공개 음악 핀을 조회한다. 정확 좌표 대신 대략 위치만 응답한다. | -| `GET /v1/music-matches` | 주변 여행자와의 음악 취향 일치율과 공개 프로필 카드를 조회한다. | -| `POST /v1/travel-mate-requests` | 취향이 맞는 사용자에게 동행 매칭 요청을 보낸다. | -| `PATCH /v1/travel-mate-requests/:requestId` | 매칭 요청을 수락, 거절, 만료, 취소 상태로 변경한다. | -| `POST /v1/community/blocks` | 사용자 차단을 기록하고 이후 지도/매칭에서 숨긴다. | -| `POST /v1/community/reports` | 부적절한 공개 핀, 프로필, 매칭 요청을 신고한다. | +| API | 목적 | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `POST /v1/travel-sessions` | 서버 기준 여행 모드를 시작한다. Live Sound Map 공개의 전제 조건이다. | +| `PATCH /v1/travel-sessions/:sessionId` | 여행 모드를 종료하거나 상태를 변경한다. | +| `POST /v1/travel-rooms` | 같이 여행 간 사람끼리 쓸 여행방을 만든다. | +| `GET /v1/travel-rooms/:roomId` | 공동 여행방의 멤버, 후보 순간, 댓글, Recap 상태를 조회한다. | +| `POST /v1/travel-rooms/:roomId/join` | 초대 코드로 공동 여행방에 참여한다. 이미 참여한 사용자는 방 정보를 다시 받는다. | +| `POST /v1/travel-rooms/:roomId/moments` | 동행자가 공동 Recap 후보 순간을 추가한다. | +| `PATCH /v1/travel-rooms/:roomId/moments/:momentId` | 방장이 후보 순간을 `candidate`, `accepted`, `rejected` 상태로 변경한다. | +| `POST /v1/travel-rooms/:roomId/moments/:momentId/comments` | 방장/참여자가 후보 순간에 댓글을 남긴다. | +| `POST /v1/travel-rooms/:roomId/recaps` | 방장이 공동 Recap을 생성한다. 승인된 후보가 있으면 승인된 순간을 우선 사용한다. | +| `GET /v1/sound-map` | 여행 모드 ON 사용자의 현재 위치와 선택 곡 핀을 조회한다. | +| `POST /v1/sound-map/current-track` | active 여행 세션이 있는 사용자의 현재 위치와 Soundlog 현재 선택 곡을 지도에 표시한다. | +| `GET /v1/sound-map/nearby` | 주변 익명 공개 음악 핀을 조회한다. 정확 좌표 대신 대략 위치만 응답한다. | +| `GET /v1/music-matches` | 주변 여행자와의 음악 취향 일치율과 공개 프로필 카드를 조회한다. | +| `POST /v1/travel-mate-requests` | 취향이 맞는 사용자에게 동행 매칭 요청을 보낸다. | +| `PATCH /v1/travel-mate-requests/:requestId` | 매칭 요청을 수락, 거절, 만료, 취소 상태로 변경한다. | +| `POST /v1/community/blocks` | 사용자 차단을 기록하고 이후 지도/매칭에서 숨긴다. | +| `POST /v1/community/reports` | 부적절한 공개 핀, 프로필, 매칭 요청을 신고한다. | 공동 여행/지도 API의 서버 정책은 다음과 같다. -| 정책 | 서버 동작 | -| --- | --- | -| 방 접근 권한 | 공동 여행방 조회, 후보 추가, 댓글 작성은 방장 또는 참여자만 가능하다. | -| 초대 코드 | 신규 참여자는 `POST /v1/travel-rooms/:roomId/join` 요청에 유효한 초대 코드를 보내야 한다. | -| 공동 Recap 권한 | 후보 상태 변경과 공동 Recap 생성은 방장만 가능하다. | -| Live Sound Map 공개 | `POST /v1/sound-map/current-track`은 서버에 active 여행 세션이 있는 사용자만 성공한다. | -| 위치 없는 조회 | `GET /v1/sound-map/nearby`, `GET /v1/music-matches`는 좌표가 없으면 낯선 사용자 데이터를 노출하지 않고 빈 목록을 반환한다. | -| 동행 요청 상태 | `accept` 액션은 `accepted`, `decline` 액션은 `declined`, `cancel` 액션은 `cancelled`, `expire` 액션은 `expired`로 변경한다. | +| 정책 | 서버 동작 | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| 방 접근 권한 | 공동 여행방 조회, 후보 추가, 댓글 작성은 방장 또는 참여자만 가능하다. | +| 초대 코드 | 신규 참여자는 `POST /v1/travel-rooms/:roomId/join` 요청에 유효한 초대 코드를 보내야 한다. | +| 공동 Recap 권한 | 후보 상태 변경과 공동 Recap 생성은 방장만 가능하다. | +| Live Sound Map 공개 | `POST /v1/sound-map/current-track`은 서버에 active 여행 세션이 있는 사용자만 성공한다. | +| 위치 없는 조회 | `GET /v1/sound-map/nearby`, `GET /v1/music-matches`는 좌표가 없으면 낯선 사용자 데이터를 노출하지 않고 빈 목록을 반환한다. | +| 동행 요청 상태 | `accept` 액션은 `accepted`, `decline` 액션은 `declined`, `cancel` 액션은 `cancelled`, `expire` 액션은 `expired`로 변경한다. | --- @@ -466,23 +466,23 @@ POST /v1/community/reports 직접 재생 제어가 없으므로 Soundlog의 추천 피드백은 다음 이벤트로 쌓는다. -| 이벤트 | 의미 | 추천 반영 | -| --- | --- | --- | -| `track_selected` | 사용자가 곡 상세나 링크 패널을 열었다. | 약한 긍정 신호 | -| `external_music_opened` | 외부 음악 앱/웹으로 이동했다. | 강한 긍정 신호 | -| `track_liked` | 사용자가 좋아요를 눌렀다. | 강한 긍정 신호 | -| `track_saved` | 사용자가 곡을 저장했다. | 강한 긍정 신호 | -| `moment_log_saved` | 곡이 실제 여행 기록에 붙었다. | 매우 강한 긍정 신호 | -| `mood_adjusted` | 사용자가 추천 방향을 바꿨다. | 현재 추천의 부분 부적합 신호 | -| `next_recommendation_clicked` | 사용자가 다음 추천을 원했다. | 약한 부정 신호 | -| `recap_shared` | 결과물을 공유했다. | 여행/Recap 만족 신호 | -| `shared_moment_added` | 동행자가 공동 여행방에 순간을 추가했다. | 공동 Recap 후보 신호 | -| `live_track_shared` | 사용자가 여행 모드 지도에 현재 곡을 표시했다. | 장소-음악 커뮤니티 신호 | -| `nearby_sound_opened` | 사용자가 주변 익명 음악 지도를 열었다. | 공개 지도 관심 신호 | -| `music_match_viewed` | 사용자가 취향 매칭 후보를 봤다. | 취향 기반 커뮤니티 신호 | -| `travel_mate_requested` | 사용자가 동행 매칭 요청을 보냈다. | 매칭 전환 신호 | -| `travel_mate_accepted` | 상대가 동행 매칭 요청을 수락했다. | 강한 커뮤니티 성공 신호 | -| `community_user_blocked` | 사용자가 상대를 차단했다. | 안전/품질 관리 신호 | +| 이벤트 | 의미 | 추천 반영 | +| ----------------------------- | --------------------------------------------- | ---------------------------- | +| `track_selected` | 사용자가 곡 상세나 링크 패널을 열었다. | 약한 긍정 신호 | +| `external_music_opened` | 외부 음악 앱/웹으로 이동했다. | 강한 긍정 신호 | +| `track_liked` | 사용자가 좋아요를 눌렀다. | 강한 긍정 신호 | +| `track_saved` | 사용자가 곡을 저장했다. | 강한 긍정 신호 | +| `moment_log_saved` | 곡이 실제 여행 기록에 붙었다. | 매우 강한 긍정 신호 | +| `mood_adjusted` | 사용자가 추천 방향을 바꿨다. | 현재 추천의 부분 부적합 신호 | +| `next_recommendation_clicked` | 사용자가 다음 추천을 원했다. | 약한 부정 신호 | +| `recap_shared` | 결과물을 공유했다. | 여행/Recap 만족 신호 | +| `shared_moment_added` | 동행자가 공동 여행방에 순간을 추가했다. | 공동 Recap 후보 신호 | +| `live_track_shared` | 사용자가 여행 모드 지도에 현재 곡을 표시했다. | 장소-음악 커뮤니티 신호 | +| `nearby_sound_opened` | 사용자가 주변 익명 음악 지도를 열었다. | 공개 지도 관심 신호 | +| `music_match_viewed` | 사용자가 취향 매칭 후보를 봤다. | 취향 기반 커뮤니티 신호 | +| `travel_mate_requested` | 사용자가 동행 매칭 요청을 보냈다. | 매칭 전환 신호 | +| `travel_mate_accepted` | 상대가 동행 매칭 요청을 수락했다. | 강한 커뮤니티 성공 신호 | +| `community_user_blocked` | 사용자가 상대를 차단했다. | 안전/품질 관리 신호 | --- @@ -503,7 +503,7 @@ POST /v1/community/reports - 여행 모드 ON 상태에서 현재 위치와 선택 곡을 지도 위에 표시할 수 있다. - 베타 플래그에서 주변 익명 음악 공개와 취향 기반 매칭 후보를 확인할 수 있다. - 매칭 기능에는 정확 위치 숨김, 상호 동의, 신고/차단, 반복 요청 제한을 포함한다. -- 오프라인 또는 API 실패 시 최근 추천과 로컬 로그를 유지한다. +- 오프라인 또는 API 실패 시 최근 추천 캐시를 유지하고 기록 저장은 성공으로 처리하지 않는다. ### 11.2 후순위 @@ -517,15 +517,15 @@ POST /v1/community/reports ### 11.3 MVP 성공 기준 -| 기준 | 성공 조건 | -| --- | --- | -| 첫 경험 | 신규 사용자가 1분 안에 현재 장소 추천을 볼 수 있다. | -| 추천 이해 | 사용자가 왜 이 곡이 추천됐는지 짧게 이해할 수 있다. | -| 외부 감상 연결 | 외부 음악 링크가 실패해도 검색 fallback을 제공한다. | -| 기록 완성 | 사용자가 사진, 장소, 곡, 무드가 묶인 MomentLog를 저장할 수 있다. | -| Recap 완성 | 로그 1개 이상으로 공유 가능한 이미지형 Recap을 만들 수 있다. | -| 공동 회고 | 동행자 2명 이상이 같은 여행방에 기록을 추가하고 공동 Recap을 생성할 수 있다. | -| 지도 공유 | 여행 모드 ON 사용자의 현재 위치와 선택 곡이 지도에 표시되고 공개 범위를 바꿀 수 있다. | +| 기준 | 성공 조건 | +| -------------- | ---------------------------------------------------------------------------------------- | +| 첫 경험 | 신규 사용자가 1분 안에 현재 장소 추천을 볼 수 있다. | +| 추천 이해 | 사용자가 왜 이 곡이 추천됐는지 짧게 이해할 수 있다. | +| 외부 감상 연결 | 외부 음악 링크가 실패해도 검색 fallback을 제공한다. | +| 기록 완성 | 사용자가 사진, 장소, 곡, 무드가 묶인 MomentLog를 저장할 수 있다. | +| Recap 완성 | 로그 1개 이상으로 공유 가능한 이미지형 Recap을 만들 수 있다. | +| 공동 회고 | 동행자 2명 이상이 같은 여행방에 기록을 추가하고 공동 Recap을 생성할 수 있다. | +| 지도 공유 | 여행 모드 ON 사용자의 현재 위치와 선택 곡이 지도에 표시되고 공개 범위를 바꿀 수 있다. | | 취향 매칭 베타 | 주변 익명 공개 사용자를 음악 취향 기준으로 발견하고, 상호 동의 기반 요청을 보낼 수 있다. | --- @@ -537,7 +537,7 @@ POST /v1/community/reports - API 접근은 `src/api` facade 뒤에 둔다. - 추천, 로그, Recap은 TanStack Query로 서버 상태를 관리한다. - 현재 여행 세션, 선택 상태/무드, 선택 곡은 클라이언트 상태로 관리한다. -- 로컬 로그 재시도 큐를 둬서 순간 기록 손실을 막는다. +- 기록 저장은 서버 응답을 기다리고 실패하면 편집 화면과 입력값을 유지한다. - 외부 음악 앱 링크는 앱 딥링크 실패 시 웹 검색으로 fallback한다. ### 12.2 백엔드 @@ -553,7 +553,7 @@ POST /v1/community/reports - 위치는 MVP에서 foreground 기준으로만 사용한다. - 카메라는 순간 기록 진입 시점에 요청한다. - 사진 저장 권한은 Recap 이미지 저장 시점에 요청한다. -- 로그아웃 상태에서는 앱 주요 기능을 사용하지 못하게 하고, 로그인 후 로컬에 남아 있던 과거 기록이 있으면 서버 동기화를 시도한다. +- 로그아웃 상태에서는 앱 주요 기능을 사용하지 못하게 하고 로그인 후 생성한 기록은 서버에 직접 저장한다. --- @@ -577,7 +577,7 @@ POST /v1/community/reports 1. 위치 권한을 거부하고 수동 장소 선택으로 추천을 받는다. 2. 외부 음악 앱 링크가 없을 때 YouTube 검색 fallback을 연다. -3. 네트워크 실패 상태에서 순간 기록을 저장하고 로컬 동기화 대기 상태를 확인한다. +3. 네트워크 실패 상태에서 순간 기록 저장 오류와 편집 화면 유지 여부를 확인한다. 4. Recap 생성 실패 후 재시도 버튼을 확인한다. --- @@ -594,42 +594,42 @@ POST /v1/community/reports ### 14.2 1차 리뷰 - 제품 방향 -| 중요도 | 발견 사항 | 반영 내용 | -| --- | --- | --- | -| P0 | "음악 앱"으로 보이면 직접 재생 기능 부재가 결함처럼 느껴질 수 있다. | 온보딩과 제품 정의에 "외부 앱에서 듣고 Soundlog에는 기록한다"는 문구를 명시했다. | -| P0 | Spotify Premium이 없으면 핵심 플로우가 막히면 안 된다. | Spotify 로그인/재생 제어를 후순위로 내리고, YouTube Music/YouTube 검색 fallback을 MVP에 포함했다. | -| P1 | 피벗 이유가 기능 목록만으로 보이면 설득력이 약하다. | 직접 스트리밍 중심 기획의 문제와 기록·Recap 중심 기획의 장점을 분리해 설명했다. | +| 중요도 | 발견 사항 | 반영 내용 | +| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| P0 | "음악 앱"으로 보이면 직접 재생 기능 부재가 결함처럼 느껴질 수 있다. | 온보딩과 제품 정의에 "외부 앱에서 듣고 Soundlog에는 기록한다"는 문구를 명시했다. | +| P0 | Spotify Premium이 없으면 핵심 플로우가 막히면 안 된다. | Spotify 로그인/재생 제어를 후순위로 내리고, YouTube Music/YouTube 검색 fallback을 MVP에 포함했다. | +| P1 | 피벗 이유가 기능 목록만으로 보이면 설득력이 약하다. | 직접 스트리밍 중심 기획의 문제와 기록·Recap 중심 기획의 장점을 분리해 설명했다. | ### 14.3 2차 리뷰 - UX와 화면 명세 -| 중요도 | 발견 사항 | 반영 내용 | -| --- | --- | --- | -| P0 | 와이어프레임만 있으면 실제 구현자가 상태/권한/API를 놓칠 수 있다. | 모든 화면을 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 작성하고, 주요 상태와 이벤트를 함께 적었다. | -| P1 | 사진, 위치, 곡 중 하나가 없으면 순간 기록이 깨질 수 있다. | 일부 데이터가 없어도 MomentLog 저장 가능하도록 저장 정책을 명시했다. | -| P1 | 외부 링크 실패 시 사용자가 막힐 수 있다. | 앱 딥링크 실패 시 웹 검색 fallback과 실패 이벤트를 추가했다. | -| P2 | Recap이 화면 목록 뒤쪽에만 있으면 조연처럼 보일 수 있다. | 핵심 루프와 MVP 성공 기준에 Recap 생성을 포함하고, P-08/P-09를 별도 핵심 화면으로 분리했다. | +| 중요도 | 발견 사항 | 반영 내용 | +| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| P0 | 와이어프레임만 있으면 실제 구현자가 상태/권한/API를 놓칠 수 있다. | 모든 화면을 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 작성하고, 주요 상태와 이벤트를 함께 적었다. | +| P1 | 사진, 위치, 곡 중 하나가 없으면 순간 기록이 깨질 수 있다. | 일부 데이터가 없어도 MomentLog 저장 가능하도록 저장 정책을 명시했다. | +| P1 | 외부 링크 실패 시 사용자가 막힐 수 있다. | 앱 딥링크 실패 시 웹 검색 fallback과 실패 이벤트를 추가했다. | +| P2 | Recap이 화면 목록 뒤쪽에만 있으면 조연처럼 보일 수 있다. | 핵심 루프와 MVP 성공 기준에 Recap 생성을 포함하고, P-08/P-09를 별도 핵심 화면으로 분리했다. | ### 14.4 3차 리뷰 - 추천 학습과 구현 가능성 -| 중요도 | 발견 사항 | 반영 내용 | -| --- | --- | --- | -| P1 | 직접 스킵/재생 이벤트가 없어 추천 학습 데이터가 부족할 수 있다. | 링크 열기, 좋아요, 저장, 기록, 공유, 다음 추천 이벤트를 추천 학습 신호로 정의했다. | -| P2 | Recap이 영상형부터 시작하면 구현 비용이 크다. | MVP는 이미지형 Recap 우선, 영상형은 후순위로 분리했다. | -| P2 | 네트워크가 불안정한 여행지에서 로그 손실이 생길 수 있다. | 로컬 로그 큐와 동기화 대기 상태를 구현 원칙에 포함했다. | -| P1 | 낯선 사람 매칭은 위치/안전 리스크가 커서 바로 공개하면 신뢰를 잃을 수 있다. | 정확 위치를 숨기고, 익명 공개 TTL, 상호 동의, 신고/차단, 반복 요청 제한을 기본 정책으로 추가했다. | +| 중요도 | 발견 사항 | 반영 내용 | +| ------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| P1 | 직접 스킵/재생 이벤트가 없어 추천 학습 데이터가 부족할 수 있다. | 링크 열기, 좋아요, 저장, 기록, 공유, 다음 추천 이벤트를 추천 학습 신호로 정의했다. | +| P2 | Recap이 영상형부터 시작하면 구현 비용이 크다. | MVP는 이미지형 Recap 우선, 영상형은 후순위로 분리했다. | +| P2 | 네트워크가 불안정한 여행지에서 로그 손실이 생길 수 있다. | 서버 저장 실패 시 편집 화면과 입력값을 유지하고 명시적 재시도를 제공한다. | +| P1 | 낯선 사람 매칭은 위치/안전 리스크가 커서 바로 공개하면 신뢰를 잃을 수 있다. | 정확 위치를 숨기고, 익명 공개 TTL, 상호 동의, 신고/차단, 반복 요청 제한을 기본 정책으로 추가했다. | ### 14.5 남은 후속 검토 아래 항목은 MVP 기획을 막지는 않지만, 구현 단계에서 다시 검토해야 한다. -| 항목 | 이유 | 판단 | -| --- | --- | --- | -| Spotify OAuth | 플레이리스트 export나 계정 기반 저장에는 유용하지만 MVP 핵심은 아니다. | Phase 2 | -| 전국 단위 공개 피드 | 지역 전체 공개 피드는 운영/신고/노출 품질 관리 리스크가 커진다. | Phase 2 | -| 동행자 투표 기반 곡 선정 | 공동 Recap과 Live Sound Map 이후에 붙이면 여행방의 상호작용을 더 키울 수 있다. | Phase 2 | -| 자유 채팅 기반 동행 | 음악 취향 검증 없이 바로 채팅을 열면 스팸과 안전 문제가 커진다. | 매칭 베타 검증 후 | -| 영상형 Recap | 데모 효과는 크지만 구현 비용과 안정성 리스크가 크다. | 이미지형 이후 | -| 백그라운드 위치 | 여행 경로 자동 기록에 유용하지만 권한/배터리/심사 부담이 있다. | foreground 검증 후 | +| 항목 | 이유 | 판단 | +| ------------------------ | ------------------------------------------------------------------------------ | ------------------ | +| Spotify OAuth | 플레이리스트 export나 계정 기반 저장에는 유용하지만 MVP 핵심은 아니다. | Phase 2 | +| 전국 단위 공개 피드 | 지역 전체 공개 피드는 운영/신고/노출 품질 관리 리스크가 커진다. | Phase 2 | +| 동행자 투표 기반 곡 선정 | 공동 Recap과 Live Sound Map 이후에 붙이면 여행방의 상호작용을 더 키울 수 있다. | Phase 2 | +| 자유 채팅 기반 동행 | 음악 취향 검증 없이 바로 채팅을 열면 스팸과 안전 문제가 커진다. | 매칭 베타 검증 후 | +| 영상형 Recap | 데모 효과는 크지만 구현 비용과 안정성 리스크가 크다. | 이미지형 이후 | +| 백그라운드 위치 | 여행 경로 자동 기록에 유용하지만 권한/배터리/심사 부담이 있다. | foreground 검증 후 | --- diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 706d986..c40903c 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -1,12 +1,5 @@ -import { createIdempotencyKey, requestApi } from '@/api/client'; -import { - AuthMe, - AuthSession, - LoginRequest, - LocalDataMigrationPayload, - LocalDataMigrationResult, - RegisterRequest, -} from '@/types/auth'; +import { requestApi } from '@/api/client'; +import { AuthMe, AuthSession, LoginRequest, RegisterRequest } from '@/types/auth'; export const authApi = { deleteAccount: async () => { @@ -33,13 +26,6 @@ export const authApi = { method: 'POST', }); }, - migrateLocalData: async (payload: LocalDataMigrationPayload) => { - return requestApi('/v1/me/migrate-local-data', { - body: payload, - idempotencyKey: payload.idempotencyKey ?? createIdempotencyKey('migration'), - method: 'POST', - }); - }, refresh: async (refreshToken?: string) => { return requestApi('/v1/auth/refresh', { auth: false, diff --git a/src/api/authQueries.ts b/src/api/authQueries.ts index 9ab58a3..edf5cb1 100644 --- a/src/api/authQueries.ts +++ b/src/api/authQueries.ts @@ -1,11 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { authApi } from '@/api/authApi'; -import { - LocalDataMigrationPayload, - LoginRequest, - RegisterRequest, -} from '@/types/auth'; +import { LoginRequest, RegisterRequest } from '@/types/auth'; export function useLoginMutation() { return useMutation({ @@ -30,10 +26,3 @@ export function useLogoutMutation() { mutationFn: (refreshToken?: string) => authApi.logout(refreshToken), }); } - -export function useLocalDataMigrationMutation() { - return useMutation({ - mutationFn: (payload: LocalDataMigrationPayload) => - authApi.migrateLocalData(payload), - }); -} diff --git a/src/api/momentLogApi.ts b/src/api/momentLogApi.ts index 7a608b1..84c780f 100644 --- a/src/api/momentLogApi.ts +++ b/src/api/momentLogApi.ts @@ -18,6 +18,7 @@ import { sanitizeTrack } from '@/utils/trackSanitizer'; const RECAP_CAPTURE_API_PATH = '/v1/recap-captures'; export type CreateMomentLogInput = { + createStandaloneRecap?: boolean; createdAt: string; idempotencyKey?: string; location?: GeoPoint; @@ -94,6 +95,10 @@ function toCreateParameters(input: CreateMomentLogInput) { visibility: input.recapVisibility, }; + if (input.createStandaloneRecap) { + parameters.createStandaloneRecap = 'true'; + } + Object.entries(optionalParameters).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { parameters[key] = String(value); @@ -233,14 +238,10 @@ export const momentLogApi = { return Promise.resolve(undefined); } - return uploadApiFile( - `${RECAP_CAPTURE_API_PATH}/${momentLogId}/photo`, - photoUri, - { - httpMethod: 'PUT', - mimeType: getPhotoContentType(photoUri), - }, - ).then(sanitizeMomentLog); + return uploadApiFile(`${RECAP_CAPTURE_API_PATH}/${momentLogId}/photo`, photoUri, { + httpMethod: 'PUT', + mimeType: getPhotoContentType(photoUri), + }).then(sanitizeMomentLog); }, deleteMomentLogPhoto: (momentLogId: string) => { if (!shouldAttemptAuthenticatedApi()) { diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index d56c3ee..769fc66 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -20,13 +20,12 @@ import { playlistCurationById } from '@/mocks/playlistMocks'; import { useAuthStore } from '@/store/authStore'; import { useHomeFilterStore } from '@/store/homeFilterStore'; import { useLibraryStore } from '@/store/libraryStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { AuthProvider, AuthSession } from '@/types/auth'; -import { GeoPoint, MomentLog, PlaceContext, Track, TravelMode } from '@/types/domain'; +import { GeoPoint, PlaceContext, Track, TravelMode } from '@/types/domain'; const BUTTON_SIZE = 58; const samplePlaylist = playlistCurationById['busan-ocean']; @@ -223,7 +222,6 @@ function DevTestManagerContent() { const startSession = useTravelSessionStore((state) => state.startSession); const { completeOnboarding, resetOnboarding, updateProfile } = useUserProfileStore(); const { finishLogin, logoutLocal, status: authStatus, user: authUser } = useAuthStore(); - const { logs, addLog, removeLog } = useMomentLogStore(); const { likedTracks, savedTracks, removeLikedTrack, removeSavedTrack, toggleLike, toggleSave } = useLibraryStore(); const { clearTrack, currentTrack, setTrack } = usePlayerStore(); @@ -322,61 +320,6 @@ function DevTestManagerContent() { setLocationStatus(status); invalidateRuntimeQueries(); }; - const addSampleMomentLogs = () => { - const baseTime = Date.now(); - const sampleLogs: MomentLog[] = [ - { - createdAt: new Date(baseTime - 1000 * 60 * 24).toISOString(), - id: `dev-moment-busan-${baseTime}`, - location: placePresets[0].location, - moodTags: ['fresh', 'local'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', - placeCategory: '해변', - placeId: placePresets[0].place.id, - placeName: '광안리 해수욕장', - sessionId: 'dev-session-busan', - source: 'camera', - syncStatus: 'local', - track: getSampleTrack(0), - travelMode: 'walk', - }, - { - createdAt: new Date(baseTime - 1000 * 60 * 12).toISOString(), - id: `dev-moment-cafe-${baseTime}`, - location: placePresets[0].location, - moodTags: ['calm', 'emotional'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - placeCategory: '카페거리', - placeId: 'dev-busan-cafe', - placeName: '민락동 카페거리', - sessionId: 'dev-session-busan', - source: 'camera', - syncStatus: 'local', - track: getSampleTrack(2), - travelMode: 'cafe', - }, - { - createdAt: new Date(baseTime - 1000 * 60 * 4).toISOString(), - id: `dev-moment-seoul-${baseTime}`, - location: placePresets[1].location, - moodTags: ['emotional'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - placeCategory: '야경', - placeId: placePresets[1].place.id, - placeName: '남산서울타워', - sessionId: 'dev-session-seoul', - source: 'camera', - syncStatus: 'local', - track: getSampleTrack(5), - travelMode: 'night', - }, - ]; - - sampleLogs.forEach(addLog); - }; - const clearMomentLogs = () => { - logs.forEach((log) => removeLog(log.id)); - }; const seedLibrary = () => { const libraryState = useLibraryStore.getState(); @@ -413,7 +356,12 @@ function DevTestManagerContent() { - setIsOpen(false)} transparent visible={isOpen}> + setIsOpen(false)} + transparent + visible={isOpen} + > setIsOpen(false)} /> - - Test Manager - + Test Manager - 페이지 이동, 조건문, 로컬 데이터를 빠르게 검수해요. + 페이지 이동과 조건문을 빠르게 검수해요. @@ -476,7 +426,10 @@ function DevTestManagerContent() { navigate('/camera')} /> - + : null} - + @@ -536,7 +492,7 @@ function DevTestManagerContent() { startSession({ id: `dev-session-${Date.now()}` })} /> - - - diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 39c29f9..7b30071 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -1,9 +1,14 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { router, useLocalSearchParams } from "expo-router"; +import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { syncRecommendationEvent } from "@/api/recommendationEventApi"; +import { momentLogApi } from "@/api/momentLogApi"; +import { momentLogQueryKeys } from "@/api/momentLogQueries"; +import { recapApi } from "@/api/recapApi"; +import { recapQueryKeys } from "@/api/recapQueries"; import { AppText } from "@/components/AppText"; import { PageHeader } from "@/components/PageHeader"; import { CameraCaptureView } from "@/components/moment-capture/CameraCaptureView"; @@ -16,10 +21,6 @@ import { Screen } from "@/components/Screen"; import { SectionTitle } from "@/components/SectionTitle"; import { SettingsRow } from "@/components/SettingsRow"; import { useHomeFilterStore } from "@/store/homeFilterStore"; -import { - useMomentLogStore, - type MomentLogCreateQueuePayload, -} from "@/store/momentLogStore"; import { usePlayerStore } from "@/store/playerStore"; import { useRecommendationEventStore } from "@/store/recommendationEventStore"; import { useTravelSessionStore } from "@/store/travelSessionStore"; @@ -31,7 +32,6 @@ import { } from "@/types/domain"; import { getForegroundLocationWithTimeout } from "@/utils/location"; import { getMoodTagsFromFilter } from "@/utils/moodTags"; -import { persistMomentPhoto } from "@/utils/momentFiles"; import { pickMomentPhotoFromLibrary } from "@/utils/momentPhotoPicker"; import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; import { getDistanceMeters } from "@/utils/recapTravelSummary"; @@ -61,8 +61,10 @@ export function MomentCaptureScreen() { }>(); const cameraRef = useRef(null); const reviewPanelRef = useRef(null); + const queryClient = useQueryClient(); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [capturedAt, setCapturedAt] = useState(); + const [saveIdempotencyKey, setSaveIdempotencyKey] = useState(); const [capturedPhotoUri, setCapturedPhotoUri] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isReviewing, setIsReviewing] = useState(false); @@ -79,8 +81,6 @@ export function MomentCaptureScreen() { const [isSaving, setIsSaving] = useState(false); const [locationStatus, setLocationStatus] = useState("idle"); - const addLog = useMomentLogStore((state) => state.addLog); - const queueCreate = useMomentLogStore((state) => state.queueCreate); const addRecommendationEvent = useRecommendationEventStore( (state) => state.addEvent, ); @@ -118,16 +118,12 @@ export function MomentCaptureScreen() { // immediately, before any await, so the second call is rejected in the // same tick as the first one claims the lock. const isSavingRef = useRef(false); - // The moment/idempotency id for the in-flight (or retry-pending) save - // attempt. Generated once per review session and reused across retries so - // a failed save followed by the user tapping "저장" again reuses the same - // idempotencyKey instead of minting a new one — see queueCreate / - // syncCreateAction, which key the server request on this id. - const saveIdRef = useRef(undefined); const prepareReview = (photoUri?: string) => { - saveIdRef.current = undefined; - setCapturedAt(new Date().toISOString()); + const nextCapturedAt = new Date().toISOString(); + + setCapturedAt(nextCapturedAt); + setSaveIdempotencyKey(`recap-capture:${nextCapturedAt}`); setCapturedPhotoUri(photoUri); setReviewPlaceName(""); setReviewTemplate("film"); @@ -257,8 +253,6 @@ export function MomentCaptureScreen() { setErrorMessage(undefined); try { - const id = saveIdRef.current ?? `moment-${Date.now()}`; - saveIdRef.current = id; const locationSnapshot: GeoPoint | undefined = currentLocation; const activeSessionId = session.status === "active" ? session.id : undefined; @@ -273,9 +267,6 @@ export function MomentCaptureScreen() { photoSourceUri = capturedCanvasUri ?? capturedPhotoUri; } - const photoUri = photoSourceUri - ? await persistMomentPhoto(photoSourceUri, id) - : undefined; const createdAt = capturedAt ?? new Date().toISOString(); const placeName = reviewPlaceName.trim() || undefined; const trackSnapshot = shouldSaveMusic ? currentTrack : undefined; @@ -286,28 +277,14 @@ export function MomentCaptureScreen() { placeName, travelMode: activeTravelMode, }); - const localLog: MomentLog = { - createdAt, - id, - location: locationSnapshot, - moodTags: reviewMoodTags, - placeCategory: capturePlace?.category, - placeId: capturePlace?.id, - photoUri, - placeName, - recapVisibility, - sessionId: activeSessionId, - source: "camera", - syncStatus: "pending", - templateId: reviewTemplate, - track: trackSnapshot, - travelMode: activeTravelMode, - }; - const createPayload: MomentLogCreateQueuePayload = { + const idempotencyKey = saveIdempotencyKey ?? `recap-capture:${createdAt}`; + const savedLog = await momentLogApi.createMomentLog({ + createStandaloneRecap: !activeSessionId, createdAt, + idempotencyKey, location: locationSnapshot, moodTags: reviewMoodTags, - photoUri, + photoUri: photoSourceUri, placeCategory: capturePlace?.category, placeId: capturePlace?.id, placeName, @@ -316,20 +293,42 @@ export function MomentCaptureScreen() { templateId: reviewTemplate, track: trackSnapshot, travelMode: activeTravelMode, - }; + }); + + if (!savedLog) { + throw new Error("recap_save_failed"); + } + + if (!activeSessionId && !savedLog.recapId) { + const fallbackRecap = await recapApi.createRecap( + { + momentLogIds: [savedLog.id], + templateId: reviewTemplate, + visibility: recapVisibility, + }, + `standalone-recap:${idempotencyKey}`, + ); + + if (!fallbackRecap) { + throw new Error("standalone_recap_save_failed"); + } + } - addLog(localLog); - queueCreate(id, createPayload); syncRecommendationEvent( addRecommendationEvent({ context: recommendationContext, playlistId, trackId: trackSnapshot?.id, type: "moment_log_saved", - value: localLog.syncStatus, + value: "server", }), ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }), + queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), + ]); + router.replace(resolveReturnPath(returnTo) as never); } catch { setErrorMessage("이 리캡을 저장하지 못했어요. 다시 시도해주세요."); @@ -355,6 +354,7 @@ export function MomentCaptureScreen() { onChangeVisibility={setRecapVisibility} onRetake={() => { setCapturedAt(undefined); + setSaveIdempotencyKey(undefined); setCapturedPhotoUri(undefined); setIsReviewing(false); setErrorMessage(undefined); diff --git a/src/components/my/AuthAccountCard.tsx b/src/components/my/AuthAccountCard.tsx index 15949e2..34211c3 100644 --- a/src/components/my/AuthAccountCard.tsx +++ b/src/components/my/AuthAccountCard.tsx @@ -8,7 +8,6 @@ import { MySettingsRow } from '@/components/my/MySettingsRow'; import { SectionTitle } from '@/components/SectionTitle'; import { useAuthStore } from '@/store/authStore'; import { clearAccountSession } from '@/utils/accountSession'; -import { migrateLocalDataToAccount } from '@/utils/localDataMigration'; function getAccountInitial(displayName?: string, email?: string) { const source = displayName?.trim() || email?.trim() || 'S'; @@ -17,8 +16,7 @@ function getAccountInitial(displayName?: string, email?: string) { } export function AuthAccountCard() { - const [isMigratingLocalData, setIsMigratingLocalData] = useState(false); - const [migrationMessage, setMigrationMessage] = useState(); + const [accountMessage, setAccountMessage] = useState(); const deleteAccountMutation = useDeleteAccountMutation(); const logoutMutation = useLogoutMutation(); const { refreshToken, status, user } = useAuthStore(); @@ -32,29 +30,6 @@ export function AuthAccountCard() { } }; - const handleMigrate = async () => { - setMigrationMessage(undefined); - setIsMigratingLocalData(true); - - try { - const result = await migrateLocalDataToAccount(); - const failedCount = - result.momentLogFailedCount + result.libraryFailedCount; - - setMigrationMessage( - failedCount > 0 - ? `리캡 ${result.momentLogSyncedCount}/${result.summary.momentLogCount}개, 보관함 ${result.librarySyncedCount}/${result.summary.libraryTrackCount}개를 동기화했어요. 실패한 항목은 다시 시도할 수 있어요.` - : `리캡 ${result.summary.momentLogCount}개, 보관함 ${result.summary.libraryTrackCount}개를 서버 동기화에 반영했어요.`, - ); - } catch { - setMigrationMessage( - '동기화 요청에 실패했어요. 네트워크 상태를 확인해주세요.', - ); - } finally { - setIsMigratingLocalData(false); - } - }; - const handleLoginPress = () => { router.push('/auth/login' as never); }; @@ -67,7 +42,7 @@ export function AuthAccountCard() { { style: 'cancel', text: '취소' }, { onPress: () => { - setMigrationMessage(undefined); + setAccountMessage(undefined); void deleteAccountMutation .mutateAsync() .then(() => { @@ -75,7 +50,7 @@ export function AuthAccountCard() { router.replace('/auth/login' as never); }) .catch(() => { - setMigrationMessage( + setAccountMessage( '계정을 삭제하지 못했어요. 네트워크 상태를 확인한 뒤 다시 시도해주세요.', ); }); @@ -96,33 +71,19 @@ export function AuthAccountCard() { - - {accountInitial} - + {accountInitial} - + {user.displayName} {user.email ?? 'Soundlog 계정'} - - 로그인됨 - + 로그인됨 - void handleMigrate()} - rightText={isMigratingLocalData ? '동기화 중' : undefined} - /> - {migrationMessage ? ( + {accountMessage ? ( - {migrationMessage} + {accountMessage} ) : null} diff --git a/src/components/recap-share/RecapShareScreen.tsx b/src/components/recap-share/RecapShareScreen.tsx index 285e8fc..58289ab 100644 --- a/src/components/recap-share/RecapShareScreen.tsx +++ b/src/components/recap-share/RecapShareScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryClient } from "@tanstack/react-query"; @@ -31,8 +31,6 @@ import { Screen } from "@/components/Screen"; import { SectionTitle } from "@/components/SectionTitle"; import { getTabBarHeight } from "@/constants/layout"; import { useRecapShareActions } from "@/hooks/useRecapShareActions"; -import { useMomentLogStore } from "@/store/momentLogStore"; -import { useTravelSessionStore } from "@/store/travelSessionStore"; import type { RecapItem, RecapShare, @@ -40,12 +38,6 @@ import type { RecapVisibility, } from "@/types/domain"; import { formatRecapRecordedAt } from "@/utils/dateFormat"; -import { - createMomentLogGroups, - extractSessionIdFromRecapId, - momentLogGroupToRecapShare, - momentLogToRecapShare, -} from "@/utils/recapMappers"; import { getRecapSoundLogs } from "@/utils/recapTravelSummary"; type RecapShareScreenProps = { @@ -75,68 +67,27 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { const [visibility, setVisibility] = useState("private"); const [thumbnailMessage, setThumbnailMessage] = useState(); const [visibilityMessage, setVisibilityMessage] = useState(); - const momentLogs = useMomentLogStore((state) => state.logs); - const session = useTravelSessionStore((state) => state.session); - const sessionId = extractSessionIdFromRecapId(recapId); - const localMomentGroup = useMemo( - () => - sessionId - ? createMomentLogGroups(momentLogs).find( - (group) => group.sessionId === sessionId, - ) - : undefined, - [momentLogs, sessionId], - ); - const localMomentLog = sessionId - ? undefined - : momentLogs.find((item) => item.id === recapId); - const localRecap = localMomentGroup - ? momentLogGroupToRecapShare( - localMomentGroup, - localMomentGroup.sessionId === session.id - ? { - endedAt: session.endedAt, - routePoints: session.routePoints, - startedAt: session.startedAt, - } - : {}, - ) - : localMomentLog - ? momentLogToRecapShare(localMomentLog) - : undefined; - const isLocalRecap = Boolean(localRecap); const { - data: remoteRecap, + data: recap, isError, isLoading, refetch, - } = useRecapShareQuery(recapId, { enabled: Boolean(recapId) && !localRecap }); - const recap = localRecap ?? remoteRecap; + } = useRecapShareQuery(recapId, { enabled: Boolean(recapId) }); const isTravelLog = isTravelLogRecap(recap); const soundLogs = recap ? getRecapSoundLogs(recap) : []; const shareMoment = soundLogs[soundLogs.length - 1]; - const itemLabel = isTravelLog ? "로그" : "리캡"; - const canManageVisibility = Boolean(isLocalRecap || recap?.isMine); - const canSelectThumbnail = Boolean( - !isLocalRecap && isTravelLog && recap?.isMine, - ); + const canManageVisibility = Boolean(recap?.isMine); + const canSelectThumbnail = Boolean(isTravelLog && recap?.isMine); const shareActions = useRecapShareActions({ capture: () => captureFrameRef.current?.capture() ?? Promise.resolve(undefined), - recapId: isLocalRecap ? undefined : recap?.id, + recapId: recap?.id, }); const handleChangeVisibility = async (nextVisibility: RecapVisibility) => { if (!recap || !canManageVisibility || isUpdatingVisibility) { return; } - if (isLocalRecap) { - setVisibilityMessage( - `서버에 저장된 ${itemLabel}만 공개 범위를 바꿀 수 있어요.`, - ); - return; - } - setIsUpdatingVisibility(true); setVisibilityMessage(undefined); @@ -254,7 +205,11 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { > router.back()} /> + router.back()} + /> } title={createRecapTitle(recap)} /> @@ -313,7 +268,7 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ) : null} - {isLoading && !localRecap ? ( + {isLoading ? ( ) : isError ? ( refetch()} /> @@ -321,21 +276,14 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ) : ( <> - {isLocalRecap ? ( - - - 서버 동기화 전 로컬 {itemLabel}이에요. 리캡 동기화 후 서버에 - 저장할 수 있어요. - - - ) : null} - void handleSelectThumbnail(moment)} + onSelectThumbnail={(moment) => + void handleSelectThumbnail(moment) + } recap={recap} /> {thumbnailMessage ? ( diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx index 93bd6dc..8c0b3c0 100644 --- a/src/components/recap/RecapListScreen.tsx +++ b/src/components/recap/RecapListScreen.tsx @@ -26,13 +26,7 @@ import { RecapEmptyState } from "@/components/recap/RecapEmptyState"; import { Screen } from "@/components/Screen"; import { getTabBarHeight } from "@/constants/layout"; import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; -import { useMomentLogStore } from "@/store/momentLogStore"; -import type { MomentLog, RecapItem, RecapVisibility } from "@/types/domain"; -import { - createMomentLogGroups, - momentLogGroupToRecapItem, -} from "@/utils/recapMappers"; -import { flushPendingMomentActions } from "@/utils/momentLogSync"; +import type { RecapItem, RecapVisibility } from "@/types/domain"; type LogFeedTabId = "others" | "mine"; @@ -41,8 +35,6 @@ type LogGridEntry = { item: RecapItem; owner: "mine" | "other"; shareId: string; - source: "local" | "server"; - syncStatus?: MomentLog["syncStatus"]; }; const logFeedTabs: Array<{ @@ -83,34 +75,6 @@ function getMomentCountLabel(item: RecapItem) { return "1개"; } -function getGroupSyncStatus(logs: MomentLog[]): MomentLog["syncStatus"] { - if (logs.some((log) => log.syncStatus === "failed")) { - return "failed"; - } - - if (logs.some((log) => log.syncStatus === "pending")) { - return "pending"; - } - - if (logs.some((log) => log.syncStatus === "local")) { - return "local"; - } - - return "synced"; -} - -function getLocalSyncLabel(syncStatus?: MomentLog["syncStatus"]) { - if (syncStatus === "failed") { - return "재시도"; - } - - if (syncStatus === "pending") { - return "동기화 중"; - } - - return "기기 저장"; -} - type LogGridCardProps = { entry: LogGridEntry; itemSize: number; @@ -140,7 +104,6 @@ function LogGridCard({ const visibility = entry.item.visibility ?? "private"; const nextVisibility: RecapVisibility = visibility === "public" ? "private" : "public"; - const canChangeVisibility = entry.source === "server"; return ( onChangeVisibility(entry, nextVisibility, event)} - style={{ opacity: canChangeVisibility ? 1 : 0.62 }} > - {isUpdating - ? "변경중" - : canChangeVisibility - ? getVisibilityLabel(visibility) - : getLocalSyncLabel(entry.syncStatus)} + {isUpdating ? "변경중" : getVisibilityLabel(visibility)} ) : null} @@ -341,7 +295,7 @@ function LogFeedPage({ itemSize={itemSize} isMine={entry.owner === "mine"} isUpdating={updatingRecapId === entry.item.id} - key={`${tabId}-${entry.source}-${entry.item.id}`} + key={`${tabId}-${entry.item.id}`} onChangeVisibility={onChangeVisibility} onPress={() => onOpenEntry(entry)} /> @@ -364,7 +318,6 @@ export function RecapListScreen() { const params = useLocalSearchParams<{ view?: string | string[] }>(); const initialView = Array.isArray(params.view) ? params.view[0] : params.view; const queryClient = useQueryClient(); - const momentLogs = useMomentLogStore((state) => state.logs); const [selectedTab, setSelectedTab] = useState( initialView === "mine" || initialView === "all" ? "mine" : "others", ); @@ -390,40 +343,9 @@ export function RecapListScreen() { item, owner: "mine" as const, shareId: item.id, - source: "server", })), [mineRecapsQuery.data], ); - const serverSessionIds = useMemo( - () => - new Set( - serverMineEntries.map(({ item }) => item.sessionId).filter(Boolean), - ), - [serverMineEntries], - ); - const serverRecapIds = useMemo( - () => new Set(serverMineEntries.map(({ item }) => item.id)), - [serverMineEntries], - ); - const localEntries: LogGridEntry[] = useMemo( - () => - createMomentLogGroups(momentLogs) - .filter((group) => Boolean(group.sessionId)) - .map((group) => ({ - imageUrl: group.logs[0]?.photoUri, - item: momentLogGroupToRecapItem(group), - owner: "mine" as const, - shareId: group.id, - source: "local" as const, - syncStatus: getGroupSyncStatus(group.logs), - })) - .filter( - ({ item }) => - !serverRecapIds.has(item.id) && - (!item.sessionId || !serverSessionIds.has(item.sessionId)), - ), - [momentLogs, serverRecapIds, serverSessionIds], - ); const otherEntries = useMemo( () => sortEntriesByCreatedAt( @@ -434,14 +356,13 @@ export function RecapListScreen() { item, owner: "other" as const, shareId: item.id, - source: "server" as const, })), ), [otherRecapsQuery.data], ); const myEntries = useMemo( - () => sortEntriesByCreatedAt([...localEntries, ...serverMineEntries]), - [localEntries, serverMineEntries], + () => sortEntriesByCreatedAt(serverMineEntries), + [serverMineEntries], ); const hasAnyLog = otherEntries.length > 0 || myEntries.length > 0; const handleOpenEntry = useCallback((entry: LogGridEntry) => { @@ -506,34 +427,7 @@ export function RecapListScreen() { return; } - if (entry.owner !== "mine" || entry.source === "local") { - if (entry.source !== "local") { - return; - } - - setUpdatingRecapId(entry.item.id); - setActionMessage("기기에 저장된 로그를 서버와 다시 동기화하고 있어요."); - - try { - const result = await flushPendingMomentActions(); - - if (result.failureCount > 0) { - setActionMessage( - "동기화하지 못했어요. 네트워크를 확인한 뒤 다시 눌러주세요.", - ); - return; - } - - setActionMessage( - "서버 동기화를 마쳤어요. 로그 목록을 새로 불러올게요.", - ); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), - queryClient.invalidateQueries({ queryKey: ["moment-logs"] }), - ]); - } finally { - setUpdatingRecapId(undefined); - } + if (entry.owner !== "mine") { return; } @@ -597,9 +491,7 @@ export function RecapListScreen() { > {tab.label} @@ -648,7 +540,9 @@ export function RecapListScreen() { onMomentumScrollEnd={handlePagerScrollEnd} onScroll={Animated.event( [{ nativeEvent: { contentOffset: { x: scrollX } } }], - { useNativeDriver: true }, + { + useNativeDriver: true, + }, )} pagingEnabled ref={pagerRef} diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx deleted file mode 100644 index b610a2f..0000000 --- a/src/components/travel/MomentCard.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { ActivityIndicator, Pressable, View } from 'react-native'; - -import { AppText } from '@/components/AppText'; -import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; -import type { MomentLog } from '@/types/domain'; - -import { formatKoreanDateTime } from './travelFormat'; -import { moodLabelByValue } from './travelData'; - -type MomentCardProps = { - item: MomentLog; - onPress?: () => void; - onRetry?: (item: MomentLog) => void; -}; - -export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { - const photoSource = useAuthenticatedImageSource(item.photoUri); - const moodLabel = item.moodTags[0] ? moodLabelByValue[item.moodTags[0]] : '무드 기록'; - const isRetrying = item.syncStatus === 'pending'; - const showRetry = item.syncStatus === 'failed' && onRetry; - const syncLabel = - item.syncStatus === 'failed' - ? '업로드 실패' - : item.syncStatus === 'pending' - ? '동기화 중' - : undefined; - - return ( - - - {item.photoUri ? ( - - ) : ( - - - - )} - - - - - - - 리캡 - - {syncLabel ? ( - - - {syncLabel} - - - ) : null} - - {moodLabel} - - - - - - - {item.placeName ?? '위치 없음'} - - - - - - - {item.track ? `${item.track.title} - ${item.track.artist}` : '음악 없음'} - - - - {item.note ? ( - - - - {item.note} - - - ) : null} - - - - - {formatKoreanDateTime(item.createdAt)} - - {isRetrying ? : null} - {showRetry ? ( - { - event.stopPropagation(); - onRetry(item); - }} - > - - 재시도 - - - ) : null} - - - - ); -} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx deleted file mode 100644 index 4ffabe7..0000000 --- a/src/components/travel/TravelScreen.tsx +++ /dev/null @@ -1,1410 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { router, useLocalSearchParams } from 'expo-router'; -import { Image } from 'expo-image'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Pressable, ScrollView, TextInput, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { ApiError } from '@/api/client'; -import { momentLogApi } from '@/api/momentLogApi'; -import { - momentLogQueryKeys, - useMomentLogListQuery, -} from '@/api/momentLogQueries'; -import { recapApi } from '@/api/recapApi'; -import { recapQueryKeys } from '@/api/recapQueries'; -import { travelSessionApi } from '@/api/travelSessionApi'; -import { MiniPlayer } from '@/components/MiniPlayer'; -import { RecapListCard } from '@/components/recap/RecapListCard'; -import { Screen } from '@/components/Screen'; -import { AppText } from '@/components/AppText'; -import { getHomeContentBottomPadding } from '@/constants/layout'; -import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; -import { - createRoutePoint, - useTravelRouteTracking, -} from '@/hooks/useTravelRouteTracking'; -import { useAuthStore } from '@/store/authStore'; -import { - useMomentLogStore, - type MomentLogCreateQueuePayload, - type MomentLogEditQueuePayload, - type MomentLogPendingAction, -} from '@/store/momentLogStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; -import type { MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; -import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; - -import { CommunityRecapCard } from './CommunityRecapCard'; -import { EndTravelConfirmModal } from './EndTravelConfirmModal'; -import { MomentCard } from './MomentCard'; -import { RecapMapSection } from './recap-map'; -import { TravelModeBottomSheet } from './TravelModeBottomSheet'; -import { TravelStatusCard } from './TravelStatusCard'; -import { formatKoreanDateTime } from './travelFormat'; -import { moodLabelByValue } from './travelData'; -import { pickMomentReplacementPhoto } from '@/utils/momentPhotoPicker'; -import { - createMomentLogGroups, - createSessionRecapId, - momentLogGroupToRecapItem, -} from '@/utils/recapMappers'; - -function getUniqueTrackCount(logs: MomentLog[]) { - return new Set(logs.map((log) => log.track?.id).filter(Boolean)).size; -} - -type MomentLogEditDraft = { - moodTags: MoodTag[]; - note?: string; - placeName?: string; - removePhoto?: boolean; - replacePhotoUri?: string; - track?: Track; -}; - -const moodOptions = Object.entries(moodLabelByValue) as Array< - [MoodTag, string] ->; -const LIVE_SOUND_MAP_FOCUS = 'sound-map'; -const LIVE_SOUND_MAP_SCROLL_OFFSET = 16; - -function momentLogPatchFromPayload( - moment: MomentLog, - payload: MomentLogEditQueuePayload, -): Partial { - return { - moodTags: payload.moodTags, - note: payload.note ?? undefined, - photoUri: payload.removePhoto - ? undefined - : (payload.replacePhotoUri ?? moment.photoUri), - placeName: payload.placeName ?? undefined, - track: payload.track, - }; -} - -function momentLogCreatePayloadFromLog( - log: MomentLog, -): MomentLogCreateQueuePayload { - return { - createdAt: log.createdAt, - location: log.location, - moodTags: log.moodTags, - note: log.note, - photoUri: log.photoUri, - placeCategory: log.placeCategory, - placeId: log.placeId, - placeName: log.placeName, - recapVisibility: log.recapVisibility, - sessionId: log.sessionId, - templateId: log.templateId, - track: log.track, - travelMode: log.travelMode, - }; -} - -type TravelLogSummaryCardProps = { - currentTrack?: Track; - logs: MomentLog[]; - momentCount: number; - onCreateRecap: () => void; - onDeleteMoment: (moment: MomentLog) => Promise | void; - onEditMoment: ( - moment: MomentLog, - draft: MomentLogEditDraft, - ) => Promise | void; - onOpenMoment: (moment: MomentLog) => void; - pendingMomentActionId?: string; - sessionStatus: 'active' | 'ended' | 'idle'; - trackCount: number; -}; - -function formatMomentTime(value: string) { - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return '시간 없음'; - } - - return new Intl.DateTimeFormat('ko-KR', { - hour: '2-digit', - hour12: false, - minute: '2-digit', - }).format(date); -} - -function getMomentMoodLabel(log: MomentLog) { - return ( - log.moodTags - .map((tag) => moodLabelByValue[tag]) - .filter(Boolean) - .join(', ') || '무드 없음' - ); -} - -// Extracted so `useAuthenticatedImageSource` can be called safely: it must run in a -// component's own render body, not directly inside the `logs.slice(0, 3).map()` below (a -// hook call site would otherwise vary per render as the log list's length changes). -function EditPhotoPreview({ photoUri }: { photoUri?: string }) { - const photoSource = useAuthenticatedImageSource(photoUri); - - if (!photoUri) { - return null; - } - - return ( - - - - ); -} - -function TravelLogSummaryCard({ - currentTrack, - logs, - momentCount, - onCreateRecap, - onDeleteMoment, - onEditMoment, - onOpenMoment, - pendingMomentActionId, - sessionStatus, - trackCount, -}: TravelLogSummaryCardProps) { - const [editingMomentId, setEditingMomentId] = useState(); - const [editMoodDraft, setEditMoodDraft] = useState([]); - const [editNoteDraft, setEditNoteDraft] = useState(''); - const [editPhotoMessage, setEditPhotoMessage] = useState(); - const [editPhotoUriDraft, setEditPhotoUriDraft] = useState(); - const [editPlaceDraft, setEditPlaceDraft] = useState(''); - const [editTrackDraft, setEditTrackDraft] = useState(); - const title = sessionStatus === 'idle' ? '최근 여행 로그' : '이번 여행 로그'; - const buttonLabel = - momentCount === 0 - ? '첫 리캡 남기기' - : sessionStatus === 'active' - ? '로그 만들기' - : '로그 보기'; - const resetEditDraft = () => { - setEditingMomentId(undefined); - setEditMoodDraft([]); - setEditNoteDraft(''); - setEditPhotoMessage(undefined); - setEditPhotoUriDraft(undefined); - setEditPlaceDraft(''); - setEditTrackDraft(undefined); - }; - const handlePickPhoto = async (momentLogId: string) => { - setEditPhotoMessage(undefined); - - const result = await pickMomentReplacementPhoto(momentLogId); - - if (result.status === 'selected') { - setEditPhotoUriDraft(result.uri); - setEditPhotoMessage('새 사진을 선택했어요. 저장하면 리캡에 반영돼요.'); - return; - } - - if (result.status === 'permission-denied') { - setEditPhotoMessage( - '사진을 교체하려면 사진 보관함 접근 권한이 필요해요.', - ); - return; - } - - if (result.status === 'unavailable') { - setEditPhotoMessage( - '사진을 불러오지 못했어요. 잠시 후 다시 시도해주세요.', - ); - } - }; - - return ( - - - - - {title} - - - 리캡 {momentCount}개 · {trackCount}곡 - - - - - Soundtrack - - - - - - {logs.length === 0 ? ( - - - 아직 저장한 리캡이 없어요. 지금 장소의 곡을 하나 고르고 사진이나 - 메모로 남겨보세요. - - - ) : ( - logs.slice(0, 3).map((log) => { - const isEditing = editingMomentId === log.id; - const isActionPending = pendingMomentActionId === log.id; - const trackLabel = log.track - ? `${log.track.title} - ${log.track.artist}` - : '음악 없음'; - const metaLabel = `${log.photoUri ? '사진' : '사진 없음'} / ${trackLabel} / ${getMomentMoodLabel(log)}`; - const actionLabelPrefix = log.placeName ?? '저장한 리캡'; - const draftTrackLabel = editTrackDraft - ? `${editTrackDraft.title} - ${editTrackDraft.artist}` - : '음악 없음'; - - return ( - - onOpenMoment(log)} - > - - - {formatMomentTime(log.createdAt)}{' '} - {log.placeName ?? '위치 없음'} - - - {metaLabel} - {log.note ? ` · ${log.note}` : ''} - - - - - {log.syncStatus === 'failed' - ? '동기화 필요' - : log.syncStatus === 'pending' - ? '동기화 중' - : '저장됨'} - - - - - {isEditing ? ( - - - - - {moodOptions.map(([tag, label]) => { - const isSelected = editMoodDraft.includes(tag); - - return ( - { - setEditMoodDraft((current) => - current.includes(tag) - ? current.filter((item) => item !== tag) - : [...current, tag], - ); - }} - > - - {label} - - - ); - })} - - - - 사진 - - - - {editPhotoUriDraft ? '사진 연결됨' : '사진 없음'} - - {editPhotoMessage ? ( - - {editPhotoMessage} - - ) : null} - - void handlePickPhoto(log.id)} - > - - {editPhotoUriDraft ? '사진 교체' : '사진 추가'} - - - {editPhotoUriDraft ? ( - { - setEditPhotoUriDraft(undefined); - setEditPhotoMessage( - '저장하면 리캡 사진이 제거돼요.', - ); - }} - > - - 사진 제거 - - - ) : null} - - - - - 연결된 곡 - - - {draftTrackLabel} - - {currentTrack ? ( - setEditTrackDraft(currentTrack)} - > - - 현재 곡으로 교체 - - - ) : ( - - 현재 선택된 곡이 없어요. - - )} - - - { - void Promise.resolve( - onEditMoment(log, { - moodTags: editMoodDraft, - note: editNoteDraft.trim() || undefined, - placeName: editPlaceDraft.trim() || undefined, - removePhoto: Boolean( - log.photoUri && !editPhotoUriDraft, - ), - replacePhotoUri: - editPhotoUriDraft && - editPhotoUriDraft !== log.photoUri - ? editPhotoUriDraft - : undefined, - track: editTrackDraft, - }), - ).then(() => { - resetEditDraft(); - }); - }} - > - - {isActionPending ? '저장 중' : '저장'} - - - - - 취소 - - - - - ) : ( - - { - setEditingMomentId(log.id); - setEditMoodDraft(log.moodTags); - setEditNoteDraft(log.note ?? ''); - setEditPhotoMessage(undefined); - setEditPhotoUriDraft(log.photoUri); - setEditPlaceDraft(log.placeName ?? ''); - setEditTrackDraft(log.track); - }} - > - - 수정 - - - void onDeleteMoment(log)} - > - - {isActionPending ? '삭제 중' : '삭제'} - - - - )} - - ); - }) - )} - - - - - {buttonLabel} - - - - ); -} - -export function TravelScreen() { - const insets = useSafeAreaInsets(); - useTravelRouteTracking(); - const queryClient = useQueryClient(); - const params = useLocalSearchParams<{ - focus?: string | string[]; - focusAt?: string | string[]; - }>(); - const focusTarget = Array.isArray(params.focus) - ? params.focus[0] - : params.focus; - const focusAt = Array.isArray(params.focusAt) - ? params.focusAt[0] - : params.focusAt; - const scrollRef = useRef(null); - const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); - const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); - const [isStartingTravel, setIsStartingTravel] = useState(false); - const [isCreatingRecap, setIsCreatingRecap] = useState(false); - const [pendingMomentActionId, setPendingMomentActionId] = useState(); - const [isSyncingMomentUploads, setIsSyncingMomentUploads] = useState(false); - const [isSyncingPendingActions, setIsSyncingPendingActions] = useState(false); - const [recapMessage, setRecapMessage] = useState(); - const [soundMapSectionY, setSoundMapSectionY] = useState(); - const [clockTick, setClockTick] = useState(0); - const authStatus = useAuthStore((state) => state.status); - const { - currentLocation, - currentPlace, - selectedMode, - session, - endSession, - resetSession, - setMode, - setSessionRecapId, - startSession, - } = useTravelSessionStore(); - const { currentTrack } = usePlayerStore(); - const { - logs: momentLogs, - mergeServerLogs, - pendingActions, - queueCreate, - queueDelete, - queueEdit, - removePendingAction, - removeLog, - resolveLocalLog, - updateLog, - } = useMomentLogStore(); - const pendingCreateActions = useMemo( - () => pendingActions.filter((action) => action.type === 'create'), - [pendingActions], - ); - const pendingChangeActions = useMemo( - () => pendingActions.filter((action) => action.type !== 'create'), - [pendingActions], - ); - const pendingActionCount = pendingChangeActions.length; - const stalePendingCreateMomentIds = useMemo(() => { - const staleThresholdMs = 60 * 1000; - const now = Date.now(); - - return new Set( - pendingCreateActions - .filter( - (action) => - now - new Date(action.queuedAt).getTime() > staleThresholdMs, - ) - .map((action) => action.momentLogId), - ); - }, [clockTick, pendingCreateActions]); - const momentLogListParams = useMemo(() => ({ limit: 50 }), []); - const { - data: serverMomentLogs, - isError: isMomentLogSyncError, - isFetching: isMomentLogSyncing, - refetch: refetchMomentLogs, - } = useMomentLogListQuery(momentLogListParams, { - enabled: authStatus === 'authenticated', - }); - const sessionLogs = useMemo( - () => momentLogs.filter((log) => log.sessionId === session.id), - [momentLogs, session.id], - ); - const moments = useMemo( - () => (session.status === 'idle' ? momentLogs : sessionLogs).slice(0, 3), - [momentLogs, session.status, sessionLogs], - ); - const momentCount = session.status === 'idle' ? 0 : sessionLogs.length; - const trackCount = useMemo( - () => getUniqueTrackCount(sessionLogs), - [sessionLogs], - ); - const travelLogMoments = useMemo( - () => (session.status === 'idle' ? momentLogs : sessionLogs), - [momentLogs, session.status, sessionLogs], - ); - const travelLogMomentCount = - session.status === 'idle' ? momentLogs.length : sessionLogs.length; - const travelLogTrackCount = useMemo( - () => getUniqueTrackCount(travelLogMoments), - [travelLogMoments], - ); - const unsyncedMomentUploads = useMemo( - () => - travelLogMoments.filter( - (log) => - log.syncStatus === 'failed' || - log.syncStatus === 'local' || - (log.syncStatus === 'pending' && - stalePendingCreateMomentIds.has(log.id)), - ), - [stalePendingCreateMomentIds, travelLogMoments], - ); - const pendingMomentUploadCount = useMemo( - () => - travelLogMoments.filter( - (log) => - log.syncStatus === 'pending' && - !stalePendingCreateMomentIds.has(log.id), - ).length, - [stalePendingCreateMomentIds, travelLogMoments], - ); - const localRecaps = useMemo( - () => - createMomentLogGroups(momentLogs) - .slice(0, 3) - .map((group) => ({ - imageUrl: group.logs[0]?.photoUri, - item: momentLogGroupToRecapItem(group), - shareId: group.id, - })), - [momentLogs], - ); - - useEffect(() => { - if (session.status !== 'active') { - return; - } - - const intervalId = setInterval(() => { - setClockTick((tick) => tick + 1); - }, 1000); - - return () => clearInterval(intervalId); - }, [session.status]); - - useEffect(() => { - if (authStatus !== 'authenticated' || !serverMomentLogs) { - return; - } - - mergeServerLogs(serverMomentLogs); - }, [authStatus, mergeServerLogs, serverMomentLogs]); - - const openModeSheet = () => { - if (session.status === 'ended') { - resetSession(); - } - - setIsModeSheetVisible(true); - }; - const handleSelectMode = (mode: TravelMode) => { - setMode(mode); - }; - const handleStartTravel = async () => { - if (isStartingTravel) { - return; - } - - const nextMode = selectedMode ?? 'cafe'; - - if (!selectedMode) { - setMode(nextMode); - } - - setIsStartingTravel(true); - - try { - const startLocation = currentLocation ?? currentPlace?.location; - const startedAt = new Date().toISOString(); - const initialRoutePoints = startLocation - ? [createRoutePoint(startLocation, new Date(startedAt))] - : undefined; - const serverSession = await travelSessionApi.createTravelSession({ - location: startLocation, - routePoints: initialRoutePoints, - startedAt, - travelMode: nextMode, - }); - - startSession({ - id: serverSession?.id, - routePoints: serverSession?.routePoints ?? initialRoutePoints, - startedAt: serverSession?.startedAt ?? startedAt, - }); - } catch { - const startLocation = currentLocation ?? currentPlace?.location; - const startedAt = new Date().toISOString(); - - startSession({ - routePoints: startLocation - ? [createRoutePoint(startLocation, new Date(startedAt))] - : undefined, - startedAt, - }); - setRecapMessage( - '서버 여행 세션 연결에 실패해서 로컬 세션으로 먼저 시작했어요.', - ); - } finally { - setIsStartingTravel(false); - setIsModeSheetVisible(false); - } - }; - const handleSubmitTravelMode = async () => { - if (session.status === 'active') { - if (session.id && selectedMode) { - travelSessionApi.updateTravelMode(session.id, selectedMode).catch(() => undefined); - } - - setIsModeSheetVisible(false); - setRecapMessage( - '현재 여행 상태를 수정했어요. 다음 추천과 리캡에 반영돼요.', - ); - return; - } - - await handleStartTravel(); - }; - const retryMomentLog = async (log: MomentLog) => { - if ( - log.syncStatus === 'pending' && - !stalePendingCreateMomentIds.has(log.id) - ) { - return false; - } - - const queuedCreateAction = pendingCreateActions.find( - (action) => action.momentLogId === log.id, - ); - const createPayload = - queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); - - queueCreate(log.id, createPayload); - updateLog(log.id, { syncStatus: 'pending' }); - - try { - const serverLog = await momentLogApi.createMomentLog({ - ...createPayload, - idempotencyKey: log.id, - }); - - if (!serverLog) { - updateLog(log.id, { syncStatus: 'local' }); - return false; - } - - resolveLocalLog(log.id, serverLog); - await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - return true; - } catch { - queueCreate(log.id, createPayload); - updateLog(log.id, { syncStatus: 'failed' }); - return false; - } - }; - const handleRetryUnsyncedMomentUploads = async () => { - if (isSyncingMomentUploads || unsyncedMomentUploads.length === 0) { - return; - } - - setIsSyncingMomentUploads(true); - - let failureCount = 0; - - try { - for (const log of [...unsyncedMomentUploads]) { - const didSync = await retryMomentLog(log); - - if (!didSync) { - failureCount += 1; - } - } - - await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - setRecapMessage( - failureCount === 0 - ? '대기 중인 리캡 업로드를 모두 동기화했어요.' - : `리캡 ${failureCount}개는 아직 서버에 올리지 못했어요.`, - ); - } finally { - setIsSyncingMomentUploads(false); - } - }; - const handleConfirmEnd = async () => { - if (isCreatingRecap) { - return; - } - - const endedSessionId = session.id; - const logsForSession = sessionLogs; - const localRecapId = createSessionRecapId(endedSessionId); - const endedAt = new Date().toISOString(); - - setRecapMessage(undefined); - endSession(); - setIsEndConfirmVisible(false); - - if (logsForSession.length === 0) { - setSessionRecapId(undefined); - setRecapMessage('저장한 리캡이 없어 빈 로그 화면으로 이동할 수 있어요.'); - return; - } - - setIsCreatingRecap(true); - - try { - useTravelLogSyncStore.getState().queueFinalization({ - endedAt, - location: currentLocation ?? currentPlace?.location, - routePoints: session.routePoints, - sessionId: endedSessionId, - templateId: 'album', - title: `${logsForSession[0]?.placeName ?? '여행'} 로그`, - }); - const syncResult = await flushPendingTravelLogFinalizations(); - const recapId = syncResult.createdRecapIds[endedSessionId] ?? localRecapId; - - setSessionRecapId(recapId); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - - if (recapId === localRecapId) { - setRecapMessage( - '서버 동기화가 끝나면 여행 로그가 자동으로 완성돼요. 지금은 기기 기록을 보여드릴게요.', - ); - } - - router.push(`/recap-share/${recapId}`); - } catch { - setSessionRecapId(localRecapId); - setRecapMessage( - '서버 로그 생성이 실패해서 로컬 로그로 먼저 보여드릴게요.', - ); - router.push(`/recap-share/${localRecapId}`); - } finally { - setIsCreatingRecap(false); - } - }; - const openCurrentRecap = () => { - const recapId = session.recapId ?? createSessionRecapId(session.id); - - router.push(`/recap-share/${recapId}`); - }; - const handleCreateTravelLogRecap = () => { - if (travelLogMomentCount === 0) { - router.push('/camera'); - return; - } - - if (session.status === 'active') { - setIsEndConfirmVisible(true); - return; - } - - if (session.status === 'ended') { - openCurrentRecap(); - return; - } - - const latestRecap = localRecaps[0]; - - if (latestRecap) { - router.push(`/recap-share/${latestRecap.shareId}`); - } - }; - const handleDeleteMoment = async (moment: MomentLog) => { - if (pendingMomentActionId) { - return; - } - - setPendingMomentActionId(moment.id); - - try { - let deletedOnServer = false; - - if (moment.syncStatus === 'synced') { - deletedOnServer = Boolean( - await momentLogApi.deleteMomentLog(moment.id), - ); - - if (!deletedOnServer) { - throw new Error( - 'Recap capture delete was not accepted by the server.', - ); - } - } - - removeLog(moment.id); - await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - setRecapMessage( - deletedOnServer - ? '서버와 여행 로그에서 리캡을 삭제했어요.' - : '로컬 여행 로그에서 리캡을 삭제했어요.', - ); - } catch { - if (moment.syncStatus === 'synced') { - queueDelete(moment); - setRecapMessage( - '서버 리캡 삭제에 실패해서 동기화 대기열에 저장했어요.', - ); - } else { - setRecapMessage('리캡 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); - } - } finally { - setPendingMomentActionId(undefined); - } - }; - const handleEditMoment = async ( - moment: MomentLog, - draft: MomentLogEditDraft, - ) => { - if (pendingMomentActionId) { - return; - } - - const editPayload: MomentLogEditQueuePayload = { - moodTags: draft.moodTags, - note: draft.note?.trim() || null, - placeName: draft.placeName?.trim() || null, - removePhoto: draft.removePhoto, - replacePhotoUri: draft.removePhoto ? undefined : draft.replacePhotoUri, - track: draft.track, - }; - const nextPatch = momentLogPatchFromPayload(moment, editPayload); - - setPendingMomentActionId(moment.id); - - try { - if (moment.syncStatus === 'synced') { - if (draft.removePhoto) { - await momentLogApi.deleteMomentLogPhoto(moment.id); - } else if (draft.replacePhotoUri) { - await momentLogApi.updateMomentLogPhoto( - moment.id, - draft.replacePhotoUri, - ); - } - - const serverLog = await momentLogApi.updateMomentLog(moment.id, { - moodTags: editPayload.moodTags, - note: editPayload.note, - placeName: editPayload.placeName, - track: editPayload.track, - }); - - if (serverLog) { - updateLog(moment.id, serverLog); - } else { - throw new Error('Recap capture edit was not accepted by the server.'); - } - } else { - updateLog(moment.id, nextPatch); - queueCreate( - moment.id, - momentLogCreatePayloadFromLog({ ...moment, ...nextPatch }), - ); - } - - await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - setRecapMessage('리캡 정보를 수정했어요.'); - } catch { - if (moment.syncStatus === 'synced') { - updateLog(moment.id, nextPatch); - queueEdit(moment.id, editPayload); - setRecapMessage( - '서버 리캡 수정에 실패해서 동기화 대기열에 저장했어요.', - ); - } else { - setRecapMessage('리캡 수정에 실패했어요. 잠시 후 다시 시도해주세요.'); - } - } finally { - setPendingMomentActionId(undefined); - } - }; - const syncPendingMomentAction = async (action: MomentLogPendingAction) => { - if (action.type === 'create') { - const localMoment = momentLogs.find( - (moment) => moment.id === action.momentLogId, - ); - - if (!localMoment) { - removePendingAction(action.id); - return; - } - - updateLog(action.momentLogId, { syncStatus: 'pending' }); - - const serverLog = await momentLogApi.createMomentLog({ - ...action.payload, - idempotencyKey: action.momentLogId, - }); - - if (!serverLog) { - updateLog(action.momentLogId, { syncStatus: 'local' }); - throw new Error( - 'Queued recap capture create was not accepted by the server.', - ); - } - - resolveLocalLog(action.momentLogId, serverLog); - return; - } - - if (action.type === 'delete') { - try { - const accepted = await momentLogApi.deleteMomentLog(action.momentLogId); - - if (!accepted) { - throw new Error( - 'Queued recap capture delete was not accepted by the server.', - ); - } - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - removePendingAction(action.id); - return; - } - - throw error; - } - - removePendingAction(action.id); - return; - } - - const localMoment = momentLogs.find( - (moment) => moment.id === action.momentLogId, - ); - - if (!localMoment) { - removePendingAction(action.id); - return; - } - - if (action.payload.removePhoto) { - const photoDeletedLog = await momentLogApi.deleteMomentLogPhoto( - action.momentLogId, - ); - - if (!photoDeletedLog) { - throw new Error( - 'Queued recap capture photo delete was not accepted by the server.', - ); - } - } else if (action.payload.replacePhotoUri) { - const photoUpdatedLog = await momentLogApi.updateMomentLogPhoto( - action.momentLogId, - action.payload.replacePhotoUri, - ); - - if (!photoUpdatedLog) { - throw new Error( - 'Queued recap capture photo update was not accepted by the server.', - ); - } - } - - const serverLog = await momentLogApi.updateMomentLog(action.momentLogId, { - moodTags: action.payload.moodTags, - note: action.payload.note, - placeName: action.payload.placeName, - track: action.payload.track, - }); - - if (!serverLog) { - throw new Error( - 'Queued recap capture edit was not accepted by the server.', - ); - } - - updateLog(action.momentLogId, serverLog); - removePendingAction(action.id); - }; - const handleRetryPendingMomentActions = async () => { - if (isSyncingPendingActions || pendingActions.length === 0) { - return; - } - - setIsSyncingPendingActions(true); - - let failureCount = 0; - - try { - for (const action of [...pendingChangeActions]) { - try { - await syncPendingMomentAction(action); - } catch { - failureCount += 1; - } - } - - await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - setRecapMessage( - failureCount === 0 - ? '대기 중인 리캡 변경을 모두 동기화했어요.' - : `리캡 변경 ${failureCount}개는 아직 동기화하지 못했어요.`, - ); - } finally { - setIsSyncingPendingActions(false); - } - }; - const handleCommunityRecapCreated = async (recap: { id: string }) => { - setSessionRecapId(recap.id); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); - router.push(`/recap-share/${recap.id}`); - }; - - useEffect(() => { - if ( - focusTarget !== LIVE_SOUND_MAP_FOCUS || - soundMapSectionY === undefined - ) { - return; - } - - const timeoutId = setTimeout(() => { - scrollRef.current?.scrollTo({ - animated: true, - y: Math.max(0, soundMapSectionY - LIVE_SOUND_MAP_SCROLL_OFFSET), - }); - }, 80); - - return () => clearTimeout(timeoutId); - }, [focusAt, focusTarget, soundMapSectionY]); - - return ( - - - - - 장소에 남기는 사운드 로그 - - - 여행모드 - - - - setIsEndConfirmVisible(true)} - onEditTravelState={openModeSheet} - onOpenRecap={openCurrentRecap} - onSaveMoment={() => router.push('/camera')} - onStartTravel={openModeSheet} - selectedMode={selectedMode} - startedAt={session.startedAt} - status={session.status} - trackCount={trackCount} - /> - - {recapMessage ? ( - - - {recapMessage} - - - ) : null} - - {authStatus === 'authenticated' && isMomentLogSyncError ? ( - - - 서버 여행 로그를 불러오지 못했어요. 로컬에 저장된 리캡을 먼저 - 보여드릴게요. - - void refetchMomentLogs()} - > - - 다시 동기화 - - - - ) : authStatus === 'authenticated' && isMomentLogSyncing ? ( - - - 서버 여행 로그를 동기화하고 있어요. - - - ) : null} - - {unsyncedMomentUploads.length > 0 || pendingMomentUploadCount > 0 ? ( - - - {pendingMomentUploadCount > 0 - ? `리캡 ${pendingMomentUploadCount}개를 서버에 올리는 중이에요.` - : `서버에 아직 올라가지 않은 리캡 ${unsyncedMomentUploads.length}개가 있어요.`} - - {unsyncedMomentUploads.length > 0 ? ( - authStatus === 'authenticated' ? ( - void handleRetryUnsyncedMomentUploads()} - > - - {isSyncingMomentUploads ? '업로드 중' : '지금 업로드'} - - - ) : ( - - 로그인하면 로컬에 남긴 리캡을 서버에 동기화할 수 있어요. - - ) - ) : null} - - ) : null} - - {pendingActionCount > 0 ? ( - - - 서버에 아직 반영되지 않은 리캡 변경 {pendingActionCount}개가 - 있어요. - - void handleRetryPendingMomentActions()} - > - - {isSyncingPendingActions ? '동기화 중' : '지금 동기화'} - - - - ) : null} - - router.push(`/recap-share/${moment.id}`)} - pendingMomentActionId={pendingMomentActionId} - sessionStatus={session.status} - trackCount={travelLogTrackCount} - /> - - setSoundMapSectionY(event.nativeEvent.layout.y)} - > - router.push('/camera')} - onOpenRecap={(recapId) => router.push(`/recap-share/${recapId}`)} - onStartTravel={openModeSheet} - sessionStatus={session.status} - /> - - - void handleCommunityRecapCreated(recap)} - sessionId={session.id} - sessionStatus={session.status} - trackCount={trackCount} - /> - - - - - - 최근 리캡 - - - 여행 중 직접 저장한 리캡 - - - router.push('/library')} - > - - 더보기 - - - - - - {moments.length === 0 ? ( - - - 아직 저장한 리캡이 없어요. 여행 중 카메라 버튼으로 첫 리캡을 - 남겨보세요. - - - ) : ( - moments.map((moment) => ( - router.push(`/recap-share/${moment.id}`)} - onRetry={(item) => void retryMomentLog(item)} - /> - )) - )} - - - - - - - - 여행 로그 - - - 여행별 리캡과 음악 요약 - - - - - - {localRecaps.length === 0 ? ( - - - 여행이 끝나면 저장한 리캡들이 하나의 로그로 묶여요. - - - ) : ( - localRecaps.map(({ imageUrl, item, shareId }) => ( - router.push(`/recap-share/${shareId}`)} - /> - )) - )} - - - - - {currentTrack ? : null} - - setIsModeSheetVisible(false)} - onSelectMode={handleSelectMode} - onStart={() => void handleSubmitTravelMode()} - selectedMode={selectedMode} - submitLabel={ - session.status === 'active' ? '여행 상태 저장' : '여행 시작' - } - visible={isModeSheetVisible} - /> - setIsEndConfirmVisible(false)} - onConfirm={() => void handleConfirmEnd()} - visible={isEndConfirmVisible} - /> - - ); -} diff --git a/src/components/travel/recap-map/RecapMapSection.tsx b/src/components/travel/recap-map/RecapMapSection.tsx index 6abf0bd..846abba 100644 --- a/src/components/travel/recap-map/RecapMapSection.tsx +++ b/src/components/travel/recap-map/RecapMapSection.tsx @@ -5,18 +5,8 @@ import { Pressable, View } from 'react-native'; import { recapApi } from '@/api/recapApi'; import { AppText } from '@/components/AppText'; import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import type { - GeoPoint, - MomentLog, - PlaceContext, - RecapMapMarker, - RecapMapScope, -} from '@/types/domain'; -import { - clusterRecapMarkers, - type RecapMapClusteringViewport, -} from '@/utils/recapMapClustering'; +import type { GeoPoint, PlaceContext, RecapMapMarker, RecapMapScope } from '@/types/domain'; +import { clusterRecapMarkers, type RecapMapClusteringViewport } from '@/utils/recapMapClustering'; import { SoundMapView } from '../live-sound-map/SoundMapView'; import { createSoundMapCenter } from '../live-sound-map/soundMapData'; @@ -33,13 +23,7 @@ type RecapMapPinGroup = { markers: RecapMapMarker[]; pin: SoundMapPin; }; -type TourPlaceStatus = - | 'disabled' - | 'empty' - | 'error' - | 'loading' - | 'ready' - | 'unavailable'; +type TourPlaceStatus = 'disabled' | 'empty' | 'error' | 'loading' | 'ready' | 'unavailable'; type RecapMapSectionProps = { currentLocation?: GeoPoint; @@ -96,10 +80,7 @@ function createPlacePin(place: PlaceContext): SoundMapPin | undefined { }; } -function getTourPlaceLabel( - currentPlace: PlaceContext | undefined, - status: TourPlaceStatus, -) { +function getTourPlaceLabel(currentPlace: PlaceContext | undefined, status: TourPlaceStatus) { if (currentPlace?.title) { return currentPlace.title; } @@ -123,27 +104,6 @@ function getTourPlaceLabel( return '현재 위치를 확인할 수 없어요'; } -function toMarkerFromLocalMoment(log: MomentLog): RecapMapMarker | undefined { - if (!log.location) { - return undefined; - } - - return { - artistName: log.track?.artist ?? '음악 없음', - createdAt: log.createdAt, - id: `local-marker-${log.id}`, - imageUrl: log.photoUri, - location: log.location, - ownerAlias: '나', - placeName: log.placeName ?? '위치 없음', - recapId: log.id, - templateId: 'album', - title: log.note?.trim() || log.placeName || '내 리캡', - trackTitle: log.track?.title ?? '저장된 리캡', - visibility: 'private', - }; -} - function toMapPin(marker: RecapMapMarker): SoundMapPin { const isMine = marker.ownerAlias === '나' || marker.visibility === 'private'; @@ -159,9 +119,7 @@ function toMapPin(marker: RecapMapMarker): SoundMapPin { } function getClusterPlaceSummary(markers: RecapMapMarker[]) { - const placeNames = Array.from( - new Set(markers.map((marker) => marker.placeName).filter(Boolean)), - ); + const placeNames = Array.from(new Set(markers.map((marker) => marker.placeName).filter(Boolean))); const visiblePlaceNames = placeNames.slice(0, 2).join(' · '); const hiddenPlaceCount = Math.max(placeNames.length - 2, 0); @@ -188,15 +146,13 @@ function toRecapMapPinGroups( return { markers: cluster.markers, pin: { - artistName: - filter === 'mine' ? '내 여행 지도' : '주변 공개 사운드 지도', + artistName: filter === 'mine' ? '내 여행 지도' : '주변 공개 사운드 지도', id: cluster.id, kind: 'cluster', label: `${cluster.markers.length}`, location: cluster.location, subtitle: getClusterPlaceSummary(cluster.markers), - trackTitle: - filter === 'mine' ? '이 지역의 내 리캡' : '이 지역의 공개 리캡', + trackTitle: filter === 'mine' ? '이 지역의 내 리캡' : '이 지역의 공개 리캡', }, }; }); @@ -266,7 +222,6 @@ export function RecapMapSection({ const [serverMarkers, setServerMarkers] = useState([]); const mapViewRef = useRef(null); const authStatus = useAuthStore((state) => state.status); - const momentLogs = useMomentLogStore((state) => state.logs); const currentLocationCenter = useMemo( () => createSoundMapCenter(currentLocation, currentPlace), [currentLocation, currentPlace], @@ -275,32 +230,7 @@ export function RecapMapSection({ const center = filter === 'place' ? placeCenter : currentLocationCenter; const placeName = getTourPlaceLabel(currentPlace, tourPlaceStatus); const scope = getScope(filter); - const localMineMarkers = useMemo( - () => - momentLogs - .map(toMarkerFromLocalMoment) - .filter((marker): marker is RecapMapMarker => Boolean(marker)), - [momentLogs], - ); - const pendingLocalMineMarkers = useMemo( - () => - momentLogs - .filter((log) => log.syncStatus !== 'synced') - .map(toMarkerFromLocalMoment) - .filter((marker): marker is RecapMapMarker => Boolean(marker)), - [momentLogs], - ); - const visibleMarkers = useMemo(() => { - if (filter !== 'mine') { - return serverMarkers; - } - - if (serverMarkers.length === 0) { - return localMineMarkers; - } - - return [...pendingLocalMineMarkers, ...serverMarkers]; - }, [filter, localMineMarkers, pendingLocalMineMarkers, serverMarkers]); + const visibleMarkers = serverMarkers; const placePin = useMemo( () => (currentPlace ? createPlacePin(currentPlace) : undefined), [currentPlace], @@ -323,10 +253,7 @@ export function RecapMapSection({ return toRecapMapPinGroups(visibleMarkers, filter, clusteringViewport); }, [clusteringViewport, filter, placePin, visibleMarkers]); - const mapPins = useMemo( - () => mapPinGroups.map((group) => group.pin), - [mapPinGroups], - ); + const mapPins = useMemo(() => mapPinGroups.map((group) => group.pin), [mapPinGroups]); const selectedPinGroup = useMemo( () => mapPinGroups.find((group) => group.pin.id === selectedPinId), [mapPinGroups, selectedPinId], @@ -353,15 +280,10 @@ export function RecapMapSection({ : filter === 'public' ? '현재 위치 주변 공개 리캡' : placeName; - const selectedFilter = filterOptions.find( - (option) => option.value === filter, - ); - const statusLabel = - filter === 'public' ? 'PUBLIC' : filter === 'mine' ? 'MINE' : 'PLACE'; - const markerQueryLat = - scope === 'public' ? currentLocationCenter.lat : undefined; - const markerQueryLng = - scope === 'public' ? currentLocationCenter.lng : undefined; + const selectedFilter = filterOptions.find((option) => option.value === filter); + const statusLabel = filter === 'public' ? 'PUBLIC' : filter === 'mine' ? 'MINE' : 'PLACE'; + const markerQueryLat = scope === 'public' ? currentLocationCenter.lat : undefined; + const markerQueryLng = scope === 'public' ? currentLocationCenter.lng : undefined; const mapPinStatus = isLoadingMarkers || (filter === 'place' && tourPlaceStatus === 'loading') ? 'SYNC' @@ -371,16 +293,11 @@ export function RecapMapSection({ const handleRegionChangeComplete = useCallback((region: SoundMapRegion) => { setMapRegion(region); }, []); - const handleViewportLayoutChange = useCallback( - (size: SoundMapViewportSize) => { - setMapViewportSize((currentSize) => - currentSize.height === size.height && currentSize.width === size.width - ? currentSize - : size, - ); - }, - [], - ); + const handleViewportLayoutChange = useCallback((size: SoundMapViewportSize) => { + setMapViewportSize((currentSize) => + currentSize.height === size.height && currentSize.width === size.width ? currentSize : size, + ); + }, []); useEffect( function fetchRecapMarkersForScope() { @@ -392,9 +309,7 @@ export function RecapMapSection({ if (authStatus !== 'authenticated') { setServerMarkers([]); - setMapMessage( - '로그인하면 주변 공개 리캡과 내 리캡을 지도에서 볼 수 있어요.', - ); + setMapMessage('로그인하면 주변 공개 리캡과 내 리캡을 지도에서 볼 수 있어요.'); return; } @@ -423,7 +338,7 @@ export function RecapMapSection({ setServerMarkers([]); setMapMessage( scope === 'mine' - ? '서버 내 리캡을 불러오지 못해 로컬 리캡을 먼저 보여드려요.' + ? '내 리캡을 불러오지 못했어요. 잠시 후 다시 확인해주세요.' : '주변 공개 리캡을 불러오지 못했어요.', ); } @@ -443,10 +358,7 @@ export function RecapMapSection({ useEffect( function clearUnavailablePinSelection() { - if ( - selectedPinId && - !mapPinGroups.some((group) => group.pin.id === selectedPinId) - ) { + if (selectedPinId && !mapPinGroups.some((group) => group.pin.id === selectedPinId)) { setSelectedPinId(undefined); } }, @@ -455,9 +367,7 @@ export function RecapMapSection({ const isPageVariant = variant === 'page'; const renderFilterChips = () => ( - + {filterOptions.map((option) => { const selected = filter === option.value; @@ -494,9 +404,7 @@ export function RecapMapSection({ {isPageVariant ? ( setSelectedPinId(pin.id) - } + onPinPress={filter === 'place' ? undefined : (pin) => setSelectedPinId(pin.id)} pins={mapPins} ref={mapViewRef} selectedPinId={selectedPinId} @@ -582,10 +488,7 @@ export function RecapMapSection({ ) : null} - + {showTravelCta ? ( - {sessionStatus === 'active' - ? '기록 남기기' - : '여행모드 시작'} + {sessionStatus === 'active' ? '기록 남기기' : '여행모드 시작'} {sessionStatus === 'active' @@ -611,9 +512,7 @@ export function RecapMapSection({ @@ -673,9 +572,7 @@ export function RecapMapSection({ - - {mapPinStatus} - + {mapPinStatus} @@ -705,9 +602,7 @@ export function RecapMapSection({ {mapMessage ? ( - - {mapMessage} - + {mapMessage} ) : null} @@ -716,9 +611,7 @@ export function RecapMapSection({ @@ -765,18 +658,11 @@ export function RecapMapSection({ > - + {marker.title} - - {marker.placeName} · {marker.trackTitle} -{' '} - {marker.artistName} + + {marker.placeName} · {marker.trackTitle} - {marker.artistName} @@ -790,9 +676,7 @@ export function RecapMapSection({ ) : ( - - {getEmptyCopy(filter)} - + {getEmptyCopy(filter)} )} diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index 579547a..da7515f 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -66,7 +66,6 @@ export const sampleMoments: MomentLog[] = [ photoUri: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', placeName: '광안리 해변', source: 'camera', - syncStatus: 'synced', track: { artist: 'NewJeans', fallbackColor: '#7DD3FC', @@ -82,7 +81,6 @@ export const sampleMoments: MomentLog[] = [ photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', placeName: '성수 카페거리', source: 'camera', - syncStatus: 'synced', track: { artist: 'IU', fallbackColor: '#FBBF24', @@ -98,7 +96,6 @@ export const sampleMoments: MomentLog[] = [ photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', placeName: '남산 산책로', source: 'camera', - syncStatus: 'synced', track: { artist: '10CM', fallbackColor: '#C084FC', diff --git a/src/mock-server/README.md b/src/mock-server/README.md index 0eb1055..ad8a45e 100644 --- a/src/mock-server/README.md +++ b/src/mock-server/README.md @@ -13,7 +13,7 @@ - `playlistHandlers.ts`: 플레이리스트 상세 - `recapHandlers.ts`: Recap 리스트, Recap 공유 - `tourHandlers.ts`: TourAPI 실패 또는 미설정 시 주변 관광지 fallback -- `authHandlers.ts`: 로그인, 토큰 갱신, 로그아웃, 로컬 데이터 이관 mock +- `authHandlers.ts`: 로그인, 토큰 갱신, 로그아웃 mock ## 레거시 실패 상태 테스트 @@ -49,7 +49,6 @@ EXPO_PUBLIC_MOCK_API_DELAY_MS=1500 npm run web - `auth.refresh` - `auth.logout` - `auth.me` -- `auth.migrateLocalData` - `playlist.detail` - `recap.list` - `recap.share` diff --git a/src/mock-server/authHandlers.ts b/src/mock-server/authHandlers.ts index 2b5c078..f38d5fc 100644 --- a/src/mock-server/authHandlers.ts +++ b/src/mock-server/authHandlers.ts @@ -1,12 +1,5 @@ import { mockServerDelay } from '@/mock-server/delay'; -import { - AuthMe, - AuthSession, - LoginRequest, - LocalDataMigrationPayload, - LocalDataMigrationResult, - RegisterRequest, -} from '@/types/auth'; +import { AuthMe, AuthSession, LoginRequest, RegisterRequest } from '@/types/auth'; let refreshTokenSeed = 1; let activeSession: AuthSession | undefined; @@ -124,17 +117,4 @@ export const authMockHandlers = { user: activeSession.user, }); }, - - async migrateLocalData( - payload: LocalDataMigrationPayload, - ): Promise { - return mockServerDelay('auth.migrateLocalData', { - accepted: true, - migrated: { - libraryTrackCount: payload.libraryTrackCount, - momentLogCount: payload.momentLogCount, - recapDraftCount: payload.recapDraftCount, - }, - }); - }, }; diff --git a/src/mock-server/types.ts b/src/mock-server/types.ts index f396007..462cd96 100644 --- a/src/mock-server/types.ts +++ b/src/mock-server/types.ts @@ -1,11 +1,4 @@ -import { - AuthMe, - AuthSession, - LoginRequest, - LocalDataMigrationPayload, - LocalDataMigrationResult, - RegisterRequest, -} from '@/types/auth'; +import { AuthMe, AuthSession, LoginRequest, RegisterRequest } from '@/types/auth'; import { FeaturedPlaylist, GeoPoint, @@ -53,16 +46,11 @@ export type MockServer = { getMe: () => Promise; login: (request: LoginRequest) => Promise; logout: () => Promise<{ accepted: boolean }>; - migrateLocalData: ( - payload: LocalDataMigrationPayload, - ) => Promise; refresh: (refreshToken?: string) => Promise; register: (request: RegisterRequest) => Promise; }; home: { - getFeaturedPlaylists: ( - params?: FeaturedPlaylistMockParams, - ) => Promise; + getFeaturedPlaylists: (params?: FeaturedPlaylistMockParams) => Promise; getMoodRecommendations: ( params?: MoodRecommendationMockParams, ) => Promise; @@ -87,8 +75,6 @@ export type MockServer = { getRecapShare: (id?: string) => Promise; }; tour: { - getNearbyPlaces: ( - params: NearbyPlacesMockParams, - ) => Promise; + getNearbyPlaces: (params: NearbyPlacesMockParams) => Promise; }; }; diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 9b13ea7..c90a75f 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -3,18 +3,17 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import { queryClient } from '@/providers/queryClient'; -import { MomentLogSyncWorker } from '@/providers/MomentLogSyncWorker'; import { useAuthStore } from '@/store/authStore'; -const DevTestManager = __DEV__ && Platform.OS !== 'web' - ? require('@/components/dev/DevTestManager').DevTestManager - : undefined; + +const DevTestManager = + __DEV__ && Platform.OS !== 'web' + ? require('@/components/dev/DevTestManager').DevTestManager + : undefined; function AuthScopedQueryCache({ children }: PropsWithChildren) { const scopedQueryClient = useQueryClient(); const authScope = useAuthStore((state) => - state.isHydrated - ? `${state.status}:${state.user?.id ?? 'anonymous'}` - : undefined, + state.isHydrated ? `${state.status}:${state.user?.id ?? 'anonymous'}` : undefined, ); const previousAuthScope = useRef(undefined); @@ -38,7 +37,6 @@ export function AppProviders({ children }: PropsWithChildren) { {children} - {DevTestManager ? : null} diff --git a/src/providers/MomentLogSyncWorker.tsx b/src/providers/MomentLogSyncWorker.tsx deleted file mode 100644 index 01b18f2..0000000 --- a/src/providers/MomentLogSyncWorker.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect } from 'react'; -import { AppState } from 'react-native'; - -import { momentLogQueryKeys } from '@/api/momentLogQueries'; -import { recapQueryKeys } from '@/api/recapQueries'; -import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; -import { flushPendingMomentActions } from '@/utils/momentLogSync'; -import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; - -const RETRY_INTERVAL_MS = 30_000; - -// Note: account-ownership reconciliation (quarantining/restoring drafts on -// login/logout) is NOT handled here. A React `useEffect` runs after commit, -// which left a one-frame window where a freshly-rendered screen could read -// the previous account's `logs` before reconciliation caught up. Instead, -// `src/store/momentLogStore.ts` subscribes to `useAuthStore` directly at -// module scope, so reconciliation runs synchronously inside the same -// `set()` call that logs a user in/out — before React has a chance to -// render anything against the stale state. See `reconcileOwnership` and -// the `useAuthStore.subscribe(...)` call there. - -export function MomentLogSyncWorker() { - const queryClient = useQueryClient(); - const authStatus = useAuthStore((state) => state.status); - const pendingActionCount = useMomentLogStore((state) => state.pendingActions.length); - const pendingFinalizationCount = useTravelLogSyncStore( - (state) => state.pendingFinalizations.length, - ); - - const flush = useCallback(async () => { - if ( - authStatus !== 'authenticated' || - (pendingActionCount === 0 && pendingFinalizationCount === 0) - ) { - return; - } - - const momentResult = await flushPendingMomentActions(); - const logResult = await flushPendingTravelLogFinalizations(); - - if (momentResult.successCount > 0 || logResult.successCount > 0) { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }), - queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), - ]); - } - }, [authStatus, pendingActionCount, pendingFinalizationCount, queryClient]); - - useEffect(() => { - void flush(); - }, [flush]); - - useEffect(() => { - if ( - authStatus !== 'authenticated' || - (pendingActionCount === 0 && pendingFinalizationCount === 0) - ) { - return; - } - - const intervalId = setInterval(() => { - void flush(); - }, RETRY_INTERVAL_MS); - const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') { - void flush(); - } - }); - - return () => { - clearInterval(intervalId); - subscription.remove(); - }; - }, [authStatus, flush, pendingActionCount, pendingFinalizationCount]); - - return null; -} diff --git a/src/store/devToolsStore.ts b/src/store/devToolsStore.ts index 4782d97..72c3b72 100644 --- a/src/store/devToolsStore.ts +++ b/src/store/devToolsStore.ts @@ -6,7 +6,6 @@ export const mockEndpointIds = [ 'auth.refresh', 'auth.logout', 'auth.me', - 'auth.migrateLocalData', 'home.featuredPlaylists', 'home.moodRecommendations', 'home.recentMusicLogs', diff --git a/src/store/momentLogStore.ts b/src/store/momentLogStore.ts deleted file mode 100644 index cef7f58..0000000 --- a/src/store/momentLogStore.ts +++ /dev/null @@ -1,465 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { useAuthStore } from '@/store/authStore'; -import { - GeoPoint, - MomentLog, - MoodTag, - MusicLogItem, - RecapTemplateId, - RecapVisibility, - Track, - TravelMode, -} from '@/types/domain'; - -export type MomentLogCreateQueuePayload = { - createdAt: string; - location?: GeoPoint; - moodTags: MoodTag[]; - note?: string; - photoUri?: string; - placeCategory?: string; - placeId?: string; - placeName?: string; - recapVisibility?: RecapVisibility; - sessionId?: string; - templateId?: RecapTemplateId; - track?: Track; - travelMode?: TravelMode; -}; - -export type MomentLogEditQueuePayload = { - moodTags: MoodTag[]; - note: string | null; - placeName: string | null; - removePhoto?: boolean; - replacePhotoUri?: string; - track?: Track; -}; - -export type MomentLogPendingAction = - | { - id: string; - momentLogId: string; - // Account that owned the session when this action was queued. Used to - // gate sync so a preserved offline draft never uploads under a - // different account after re-login. `undefined` means the owner is - // unknown (legacy data persisted before this field existed) and the - // action must stay quarantined rather than be treated as a match. - ownerUserId?: string; - payload: MomentLogCreateQueuePayload; - queuedAt: string; - type: 'create'; - } - | { - id: string; - momentLogId: string; - ownerUserId?: string; - payload: MomentLogEditQueuePayload; - queuedAt: string; - type: 'edit'; - } - | { - id: string; - momentLogId: string; - ownerUserId?: string; - queuedAt: string; - type: 'delete'; - }; - -// Internal-only shape: every MomentLog kept in `logs` / `quarantinedLogs` -// also carries the account id that owned it when it entered local state. -// Kept as an intersection (rather than editing the shared MomentLog type in -// types/domain.ts) so every other consumer of MomentLog is unaffected — a -// plain MomentLog is still assignable wherever this type is required. -type OwnedMomentLog = MomentLog & { ownerUserId?: string }; - -type MomentLogState = { - logs: MomentLog[]; - pendingActions: MomentLogPendingAction[]; - // Drafts / pending actions preserved (never auto-deleted) but hidden from - // every screen because they belong to an account other than whoever is - // currently logged in — or to an unknown owner (data persisted before - // ownership tracking existed). Restored into `logs`/`pendingActions` by - // `reconcileOwnership` only once the matching account re-authenticates. - quarantinedLogs: MomentLog[]; - quarantinedPendingActions: MomentLogPendingAction[]; - addLog: (log: MomentLog) => void; - getRecentLogs: (limit?: number) => MomentLog[]; - mergeServerLogs: (logs: MomentLog[]) => void; - queueCreate: ( - momentLogId: string, - payload: MomentLogCreateQueuePayload, - ) => void; - queueEdit: (momentLogId: string, payload: MomentLogEditQueuePayload) => void; - queueDelete: (log: MomentLog) => void; - // Re-partitions logs/pendingActions vs. their quarantined counterparts - // based on which account currently owns the session. Call this whenever - // the authenticated account changes (login, logout, refresh failure) — - // see MomentLogSyncWorker.tsx. - reconcileOwnership: (currentUserId?: string) => void; - removePendingAction: (id: string) => void; - removeLog: (id: string) => void; - resolveLocalLog: (localMomentLogId: string, serverLog: MomentLog) => void; - updateLog: (id: string, patch: Partial) => void; -}; - -export function momentLogToMusicLogItem(log: MomentLog): MusicLogItem { - return { - artistName: log.track?.artist ?? '음악 없음', - createdAt: log.createdAt, - id: log.id, - imageUrl: log.photoUri, - placeName: log.placeName ?? '위치 없음', - recapShareId: log.id, - trackTitle: log.track?.title ?? '저장된 순간', - }; -} - -function sortByNewest(logs: MomentLog[]) { - return [...logs].sort((first, second) => { - const firstTime = new Date(first.createdAt).getTime(); - const secondTime = new Date(second.createdAt).getTime(); - - return secondTime - firstTime; - }); -} - -function getPendingActionId( - type: MomentLogPendingAction['type'], - momentLogId: string, -) { - return `${type}:${momentLogId}`; -} - -function dedupePendingActions(actions: MomentLogPendingAction[]) { - return Array.from( - new Map(actions.map((action) => [action.id, action])).values(), - ); -} - -function remapPendingAction( - action: MomentLogPendingAction, - nextMomentLogId: string, -): MomentLogPendingAction { - return { - ...action, - id: getPendingActionId(action.type, nextMomentLogId), - momentLogId: nextMomentLogId, - } as MomentLogPendingAction; -} - -export const useMomentLogStore = create()( - persist( - (set, get) => ({ - logs: [], - pendingActions: [], - quarantinedLogs: [], - quarantinedPendingActions: [], - addLog: (log) => - set((state) => { - const owned: OwnedMomentLog = { - ...log, - ownerUserId: useAuthStore.getState().user?.id, - }; - - return { - logs: [owned, ...state.logs.filter((item) => item.id !== log.id)], - }; - }), - getRecentLogs: (limit = 10) => get().logs.slice(0, limit), - mergeServerLogs: (serverLogs) => - set((state) => { - const ownerUserId = useAuthStore.getState().user?.id; - const pendingDeleteIds = new Set( - state.pendingActions - .filter((action) => action.type === 'delete') - .map((action) => action.momentLogId), - ); - const pendingEditIds = new Set( - state.pendingActions - .filter((action) => action.type === 'edit') - .map((action) => action.momentLogId), - ); - const localLogsById = new Map(state.logs.map((log) => [log.id, log])); - const visibleServerLogs: OwnedMomentLog[] = serverLogs - .filter((log) => !pendingDeleteIds.has(log.id)) - .map((log) => - pendingEditIds.has(log.id) - ? (localLogsById.get(log.id) ?? log) - : { ...log, ownerUserId }, - ); - const serverLogIds = new Set(visibleServerLogs.map((log) => log.id)); - const localOnlyLogs = state.logs.filter( - (log) => !serverLogIds.has(log.id) && !pendingDeleteIds.has(log.id), - ); - - return { - logs: sortByNewest([...visibleServerLogs, ...localOnlyLogs]), - }; - }), - queueCreate: (momentLogId, payload) => - set((state) => { - const createActionId = getPendingActionId('create', momentLogId); - const hasPendingDelete = state.pendingActions.some( - (action) => - action.type === 'delete' && action.momentLogId === momentLogId, - ); - - if (hasPendingDelete) { - return state; - } - - return { - pendingActions: [ - ...state.pendingActions.filter( - (action) => action.id !== createActionId, - ), - { - id: createActionId, - momentLogId, - ownerUserId: useAuthStore.getState().user?.id, - payload, - queuedAt: new Date().toISOString(), - type: 'create', - }, - ], - }; - }), - queueEdit: (momentLogId, payload) => - set((state) => { - const editActionId = getPendingActionId('edit', momentLogId); - const hasPendingDelete = state.pendingActions.some( - (action) => - action.type === 'delete' && action.momentLogId === momentLogId, - ); - - if (hasPendingDelete) { - return state; - } - - return { - pendingActions: [ - ...state.pendingActions.filter( - (action) => action.id !== editActionId, - ), - { - id: editActionId, - momentLogId, - ownerUserId: useAuthStore.getState().user?.id, - payload, - queuedAt: new Date().toISOString(), - type: 'edit', - }, - ], - }; - }), - queueDelete: (log) => - set((state) => { - const deleteActionId = getPendingActionId('delete', log.id); - - return { - logs: state.logs.filter((item) => item.id !== log.id), - pendingActions: [ - ...state.pendingActions.filter( - (action) => action.momentLogId !== log.id, - ), - { - id: deleteActionId, - momentLogId: log.id, - ownerUserId: useAuthStore.getState().user?.id, - queuedAt: new Date().toISOString(), - type: 'delete', - }, - ], - }; - }), - reconcileOwnership: (currentUserId) => - set((state) => { - const allLogs = [ - ...state.logs, - ...state.quarantinedLogs, - ] as OwnedMomentLog[]; - const allActions = [ - ...state.pendingActions, - ...state.quarantinedPendingActions, - ]; - const visibleLogs: OwnedMomentLog[] = []; - const hiddenLogs: OwnedMomentLog[] = []; - const seenLogIds = new Set(); - - for (const log of allLogs) { - if (seenLogIds.has(log.id)) { - continue; - } - seenLogIds.add(log.id); - - if (currentUserId && log.ownerUserId === currentUserId) { - visibleLogs.push(log); - } else { - hiddenLogs.push(log); - } - } - - const visibleActions: MomentLogPendingAction[] = []; - const hiddenActions: MomentLogPendingAction[] = []; - const seenActionIds = new Set(); - - for (const action of allActions) { - if (seenActionIds.has(action.id)) { - continue; - } - seenActionIds.add(action.id); - - if (currentUserId && action.ownerUserId === currentUserId) { - visibleActions.push(action); - } else { - hiddenActions.push(action); - } - } - - return { - logs: sortByNewest(visibleLogs), - pendingActions: visibleActions, - quarantinedLogs: hiddenLogs, - quarantinedPendingActions: hiddenActions, - }; - }), - removePendingAction: (id) => - set((state) => ({ - pendingActions: state.pendingActions.filter( - (action) => action.id !== id, - ), - })), - removeLog: (id) => - set((state) => ({ - logs: state.logs.filter((item) => item.id !== id), - pendingActions: state.pendingActions.filter( - (action) => action.momentLogId !== id, - ), - quarantinedLogs: state.quarantinedLogs.filter( - (item) => item.id !== id, - ), - quarantinedPendingActions: state.quarantinedPendingActions.filter( - (action) => action.momentLogId !== id, - ), - })), - resolveLocalLog: (localMomentLogId, serverLog) => - set((state) => { - const hasLocalLog = state.logs.some( - (item) => item.id === localMomentLogId, - ); - - if (!hasLocalLog) { - return state; - } - - const ownedServerLog: OwnedMomentLog = { - ...serverLog, - ownerUserId: useAuthStore.getState().user?.id, - }; - const remappedActions = state.pendingActions - .filter( - (action) => - !( - action.type === 'create' && - action.momentLogId === localMomentLogId - ), - ) - .map((action) => - action.momentLogId === localMomentLogId - ? remapPendingAction(action, serverLog.id) - : action, - ); - - return { - logs: sortByNewest([ - ownedServerLog, - ...state.logs.filter( - (item) => - item.id !== localMomentLogId && item.id !== serverLog.id, - ), - ]), - pendingActions: dedupePendingActions(remappedActions), - }; - }), - updateLog: (id, patch) => - set((state) => ({ - logs: state.logs.map((item) => - item.id === id ? { ...item, ...patch } : item, - ), - })), - }), - { - // v1 introduced `ownerUserId` on MomentLogPendingAction. - // v2 introduces `quarantinedLogs` / `quarantinedPendingActions`. Older - // persisted state simply lacks both fields — that's the "unknown - // owner" state sync/display gating treats as quarantined, so no data - // transform is required beyond defaulting the new arrays to empty. - migrate: (persistedState) => { - const state = (persistedState ?? {}) as Partial; - - return { - ...state, - logs: state.logs ?? [], - pendingActions: state.pendingActions ?? [], - quarantinedLogs: state.quarantinedLogs ?? [], - quarantinedPendingActions: state.quarantinedPendingActions ?? [], - } as MomentLogState; - }, - name: 'soundlog-moment-logs', - // Fires once this store's own persisted logs/pendingActions have - // loaded. Needed in addition to the useAuthStore.subscribe below: - // on cold start, auth can finish hydrating (and fire its - // subscription) before THIS store's async storage read resolves, in - // which case reconciliation at that point runs against an empty - // in-memory state. This closes that gap by re-running reconciliation - // once real persisted data is in place. - onRehydrateStorage: () => () => { - reconcileMomentLogOwnershipWithCurrentAuth(); - }, - storage: createJSONStorage(() => AsyncStorage), - version: 2, - }, - ), -); - -// --- Cross-account display/sync isolation ----------------------------- -// -// `reconcileOwnership` must run synchronously the instant the authenticated -// account changes (login, logout, refresh failure) — BEFORE React renders -// any screen that reads `logs`/`pendingActions`. A `useEffect` in a -// provider component is too late: effects run after commit, so a screen -// can paint one frame of the previous account's data first. Subscribing to -// useAuthStore directly, here at module scope, means our listener runs -// synchronously inside the very `set()` call that logs the new account in, -// before React's own re-render of any subscribed screen is committed. -let lastReconciledOwnerUserId: string | undefined; -let hasReconciledSinceHydration = false; - -function reconcileMomentLogOwnershipWithCurrentAuth() { - const authState = useAuthStore.getState(); - - // Guard against cold start: before auth has hydrated, `user` is - // transiently undefined even for an already-logged-in device. Treating - // that as "logged out" would needlessly quarantine everything. - if (!authState.isHydrated) { - return; - } - - const ownerUserId = authState.user?.id; - - if (hasReconciledSinceHydration && ownerUserId === lastReconciledOwnerUserId) { - return; - } - - hasReconciledSinceHydration = true; - lastReconciledOwnerUserId = ownerUserId; - useMomentLogStore.getState().reconcileOwnership(ownerUserId); -} - -useAuthStore.subscribe(() => { - reconcileMomentLogOwnershipWithCurrentAuth(); -}); diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index bc281ff..bd2257d 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -13,7 +13,6 @@ export type RecommendationEventType = | 'track_save' | 'track_unsave' | 'moment_log_saved' - | 'moment_log_sync_failed' | 'playlist_open' | 'mood_adjusted' | 'mood_filter_change' diff --git a/src/store/travelLogSyncStore.ts b/src/store/travelLogSyncStore.ts deleted file mode 100644 index bdbf217..0000000 --- a/src/store/travelLogSyncStore.ts +++ /dev/null @@ -1,71 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { useAuthStore } from '@/store/authStore'; -import type { GeoPoint, RecapTemplateId, RoutePoint } from '@/types/domain'; - -export type PendingTravelLogFinalization = { - endedAt: string; - id: string; - location?: GeoPoint; - // Account that owned the session when finalization was queued. `undefined` - // means unknown owner (legacy data) and must stay quarantined by sync - // gating rather than be treated as belonging to whoever is logged in now. - ownerUserId?: string; - queuedAt: string; - routePoints: RoutePoint[]; - sessionId: string; - templateId: RecapTemplateId; - title: string; -}; - -type TravelLogSyncState = { - pendingFinalizations: PendingTravelLogFinalization[]; - queueFinalization: ( - input: Omit, - ) => void; - removeFinalization: (id: string) => void; -}; - -function getFinalizationId(sessionId: string) { - return `travel-log:${sessionId}`; -} - -export const useTravelLogSyncStore = create()( - persist( - (set) => ({ - pendingFinalizations: [], - queueFinalization: (input) => - set((state) => { - const id = getFinalizationId(input.sessionId); - - return { - pendingFinalizations: [ - ...state.pendingFinalizations.filter((item) => item.id !== id), - { - ...input, - id, - ownerUserId: useAuthStore.getState().user?.id, - queuedAt: new Date().toISOString(), - }, - ], - }; - }), - removeFinalization: (id) => - set((state) => ({ - pendingFinalizations: state.pendingFinalizations.filter( - (item) => item.id !== id, - ), - })), - }), - { - // v1 introduces `ownerUserId`; pre-v1 entries simply lack it, which is - // the intended "unknown owner" quarantined state (see momentLogStore). - migrate: (persistedState) => persistedState as TravelLogSyncState, - name: 'soundlog-travel-log-finalizations', - storage: createJSONStorage(() => AsyncStorage), - version: 1, - }, - ), -); diff --git a/src/store/travelSessionStore.ts b/src/store/travelSessionStore.ts index 2b8eaa5..0e2f7f9 100644 --- a/src/store/travelSessionStore.ts +++ b/src/store/travelSessionStore.ts @@ -17,8 +17,8 @@ type TravelSession = { endedAt?: string; id: string; // Account that owned the device when this session was started. Used to - // gate display/sync the same way momentLogStore gates moment logs — see - // `reconcileOwnership` below. `undefined` means unknown owner (data + // keep an active travel sensor buffer isolated by account. `undefined` + // means unknown owner (data // persisted before this field existed) and is treated like a mismatch: // quarantined, never auto-deleted, never shown to whoever is logged in. ownerUserId?: string; @@ -57,11 +57,13 @@ type TravelSessionState = { setMode: (mode: TravelMode) => void; setRecommendationMode: (mode: MusicRecommendationMode) => void; setSessionRecapId: (recapId?: string) => void; - startSession: (session?: Partial>) => void; + startSession: ( + session: Pick & Partial>, + ) => void; }; const idleSession: TravelSession = { - id: 'local-session', + id: 'idle', routePoints: [], status: 'idle', }; @@ -176,10 +178,10 @@ export const useTravelSessionStore = create()( startSession: (session) => set({ session: { - id: session?.id ?? `session-${Date.now()}`, + id: session.id, ownerUserId: useAuthStore.getState().user?.id, - routePoints: session?.routePoints ?? [], - startedAt: session?.startedAt ?? new Date().toISOString(), + routePoints: session.routePoints ?? [], + startedAt: session.startedAt ?? new Date().toISOString(), status: 'active', }, }), @@ -203,8 +205,7 @@ export const useTravelSessionStore = create()( name: 'soundlog-travel-session', // Fires once this store's own persisted session/quarantinedSessions // have loaded. Needed alongside the useAuthStore.subscribe below for - // the same cold-start reason as momentLogStore: auth can finish - // hydrating (and fire its subscription) before this store's own + // Auth can finish hydrating and fire its subscription before this store's own // async storage read resolves, so reconciliation must also re-run // once real persisted data is in place. onRehydrateStorage: () => () => { @@ -230,11 +231,9 @@ export const useTravelSessionStore = create()( ), ); -// --- Cross-account display isolation (mirrors momentLogStore.ts) ------ +// --- Cross-account display isolation ------ // -// Registered independently here (rather than piggy-backing on -// momentLogStore's subscription) to keep the two stores decoupled, but it -// rides the exact same mechanism: useAuthStore notifies subscribers +// useAuthStore notifies subscribers // synchronously inside the very `set()` call that logs an account in/out, // so this reconciliation always finishes before React renders any screen // that reads `session` — no one-frame exposure of a stale account's diff --git a/src/test/__tests__/accountSession.test.ts b/src/test/__tests__/accountSession.test.ts index 48c51f0..2e773b1 100644 --- a/src/test/__tests__/accountSession.test.ts +++ b/src/test/__tests__/accountSession.test.ts @@ -4,9 +4,7 @@ import { requestApi } from '@/api/client'; import { queryClient } from '@/providers/queryClient'; import { useAuthStore } from '@/store/authStore'; import { useLibraryStore } from '@/store/libraryStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; import { useRecommendationCacheStore } from '@/store/recommendationCacheStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { clearAccountSession, clearAuthSession } from '@/utils/accountSession'; @@ -29,55 +27,11 @@ function seedAuthenticatedState() { }); } -function seedUnsyncedDraftState() { - useMomentLogStore.setState({ - logs: [ - { - createdAt: new Date().toISOString(), - id: 'moment-1', - moodTags: [], - // Mirrors real addLog/queueCreate behavior: the log and its pending - // action are stamped with the same ownerUserId at creation time. - ownerUserId: 'user-a', - source: 'camera', - syncStatus: 'pending', - } as never, - ], - pendingActions: [ - { - id: 'create:moment-1', - momentLogId: 'moment-1', - ownerUserId: 'user-a', - payload: { createdAt: new Date().toISOString(), moodTags: [] }, - queuedAt: new Date().toISOString(), - type: 'create', - }, - ], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); - - useTravelLogSyncStore.setState({ - pendingFinalizations: [ - { - endedAt: new Date().toISOString(), - id: 'travel-log:session-1', - ownerUserId: 'user-a', - queuedAt: new Date().toISOString(), - routePoints: [], - sessionId: 'session-1', - templateId: 'film', - title: 'Test trip', - }, - ], - }); - +function seedActiveTravelSession() { useTravelSessionStore.setState({ quarantinedSessions: [], session: { id: 'session-1', - // Mirrors real startSession behavior: stamped with the account that - // was authenticated when the session began. ownerUserId: 'user-a', routePoints: [{ lat: 1, lng: 1, recordedAt: new Date().toISOString() }], startedAt: new Date().toISOString(), @@ -119,88 +73,49 @@ beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); seedAuthenticatedState(); - seedUnsyncedDraftState(); + seedActiveTravelSession(); seedServerDerivedCaches(); }); -describe('clearAuthSession (P0-3: refresh-failure path)', () => { - it('resets only auth state and preserves unsynced drafts / active session', () => { +describe('clearAuthSession', () => { + it('clears authentication and quarantines the active travel sensor buffer', () => { clearAuthSession(); expect(useAuthStore.getState().status).toBe('unauthenticated'); expect(useAuthStore.getState().accessToken).toBeUndefined(); expect(useAuthStore.getState().refreshToken).toBeUndefined(); expect(useAuthStore.getState().user).toBeUndefined(); - - // Preserved, but no longer "current account" -- logoutLocal() flips the - // authenticated user to undefined, which synchronously reconciles the - // draft out of the visible arrays (see momentLogStore.ts's - // useAuthStore.subscribe). Nothing is deleted: it lands in quarantine - // and comes straight back once the SAME account re-authenticates. - expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); - expect(useMomentLogStore.getState().logs).toHaveLength(0); - expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( - 1, - ); - expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(1); - // Preserved: pending travel Log finalization. - expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( - 1, - ); - // Preserved travel session: also immediately quarantined (invisible) - // rather than deleted, same as the moment log draft above. expect(useTravelSessionStore.getState().session.status).toBe('idle'); - expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength( - 1, - ); + expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength(1); expect( useTravelSessionStore.getState().quarantinedSessions[0].routePoints, ).toHaveLength(1); }); - it('wipes server-derived caches (library, profile, recommendation cache, query cache)', () => { + it('wipes server-derived caches', () => { clearAuthSession(); expect(useLibraryStore.getState().likedTracks).toHaveLength(0); expect(useLibraryStore.getState().seededPlaylistIds).toHaveLength(0); - expect(useUserProfileStore.getState().profile.completedOnboarding).toBe( - false, - ); - expect( - useRecommendationCacheStore.getState().featuredPlaylists, - ).toEqual({}); + expect(useUserProfileStore.getState().profile.completedOnboarding).toBe(false); + expect(useRecommendationCacheStore.getState().featuredPlaylists).toEqual({}); expect(queryClient.getQueryData(['probe-query'])).toBeUndefined(); }); }); -describe('clearAccountSession (explicit logout / account deletion)', () => { - it('wipes auth state and all local drafts', () => { +describe('clearAccountSession', () => { + it('clears authentication and the active travel sensor buffer', () => { clearAccountSession(); expect(useAuthStore.getState().status).toBe('unauthenticated'); - expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); - expect(useMomentLogStore.getState().logs).toHaveLength(0); - // Explicit account deletion/logout wipes quarantine too -- unlike the - // refresh-failure path, this is user-initiated and intentionally final. - expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( - 0, - ); - expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(0); - expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( - 0, - ); expect(useTravelSessionStore.getState().session.status).toBe('idle'); - expect(useTravelSessionStore.getState().session.routePoints).toHaveLength( - 0, - ); - expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength( - 0, - ); + expect(useTravelSessionStore.getState().session.routePoints).toHaveLength(0); + expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength(0); }); }); describe('client.ts token refresh failure', () => { - it('preserves pendingActions and drops only auth state on a failed refresh', async () => { + it('clears authentication and preserves the active travel sensor buffer', async () => { process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL = 'https://api.test.local'; const fetchMock = vi.fn(async (input: unknown) => { @@ -223,16 +138,8 @@ describe('client.ts token refresh failure', () => { status: 401, }); - // Auth state cleared (forces re-login)... expect(useAuthStore.getState().status).toBe('unauthenticated'); - // ...and the draft is immediately out of the visible arrays (no - // account is "current" to own it right now)... - expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); - expect(useMomentLogStore.getState().logs).toHaveLength(0); - // ...but it survives, quarantined, ready to come back for its owner. - expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( - 1, - ); - expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(1); + expect(useTravelSessionStore.getState().session.status).toBe('idle'); + expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength(1); }); }); diff --git a/src/test/__tests__/momentLogQuarantine.test.ts b/src/test/__tests__/momentLogQuarantine.test.ts deleted file mode 100644 index 688e2d0..0000000 --- a/src/test/__tests__/momentLogQuarantine.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { flushPendingMomentActions } from '@/utils/momentLogSync'; - -// Same mocks as momentLogSync.test.ts — createMomentLog / createRecap should -// only ever be reached once a draft has been un-quarantined for its owner. -vi.mock('@/api/momentLogApi', () => ({ - momentLogApi: { - createMomentLog: vi.fn(async () => ({ - createdAt: new Date().toISOString(), - id: 'server-moment-1', - moodTags: [], - source: 'camera', - syncStatus: 'synced', - })), - }, -})); - -vi.mock('@/api/recapApi', () => ({ - recapApi: { - createRecap: vi.fn(async () => ({ id: 'recap-1' })), - }, -})); - -const OWNER_A = 'user-a'; -const OWNER_B = 'user-b'; - -function seedOwnedDraft() { - useMomentLogStore.setState({ - logs: [ - { - createdAt: new Date().toISOString(), - id: 'moment-1', - moodTags: [], - // `ownerUserId` isn't part of the public MomentLog type (see - // momentLogStore's internal OwnedMomentLog intersection), but the - // store stamps it onto every persisted entry — mirror that shape - // here rather than going through `addLog`, to keep this test - // focused on `reconcileOwnership` alone. - ownerUserId: OWNER_A, - source: 'camera', - syncStatus: 'pending', - } as never, - ], - pendingActions: [ - { - id: 'create:moment-1', - momentLogId: 'moment-1', - ownerUserId: OWNER_A, - payload: { createdAt: new Date().toISOString(), moodTags: [] }, - queuedAt: new Date().toISOString(), - type: 'create', - }, - ], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); -} - -function seedAuthenticatedAs(userId: string) { - useAuthStore.setState({ - accessToken: 'access-token', - refreshToken: 'refresh-token', - status: 'authenticated', - user: { displayName: 'Test', id: userId, provider: 'email' }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - useMomentLogStore.setState({ - logs: [], - pendingActions: [], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); -}); - -describe('reconcileOwnership (display-level account isolation)', () => { - it('quarantines a draft out of `logs`/`pendingActions` when a different account logs in, without deleting it', () => { - seedOwnedDraft(); - - useMomentLogStore.getState().reconcileOwnership(OWNER_B); - - const state = useMomentLogStore.getState(); - - // Hidden from every screen that reads `logs`. - expect(state.logs).toHaveLength(0); - expect(state.pendingActions).toHaveLength(0); - // Not deleted — preserved in the quarantine arrays. - expect(state.quarantinedLogs).toHaveLength(1); - expect(state.quarantinedLogs[0].id).toBe('moment-1'); - expect(state.quarantinedPendingActions).toHaveLength(1); - }); - - it('quarantines unknown-owner (legacy/guest) data the same way', () => { - useMomentLogStore.setState({ - logs: [ - { - createdAt: new Date().toISOString(), - id: 'legacy-moment', - moodTags: [], - source: 'camera', - syncStatus: 'local', - }, - ], - pendingActions: [], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); - - useMomentLogStore.getState().reconcileOwnership(OWNER_A); - - const state = useMomentLogStore.getState(); - - expect(state.logs).toHaveLength(0); - expect(state.quarantinedLogs).toHaveLength(1); - expect(state.quarantinedLogs[0].id).toBe('legacy-moment'); - }); - - it('restores a quarantined draft to `logs`/`pendingActions` when its owner logs back in, and resumes sync', async () => { - seedOwnedDraft(); - useMomentLogStore.getState().reconcileOwnership(OWNER_B); // quarantine under a different account - expect(useMomentLogStore.getState().logs).toHaveLength(0); - - // The original owner logs back in. - useMomentLogStore.getState().reconcileOwnership(OWNER_A); - - const restored = useMomentLogStore.getState(); - - expect(restored.logs).toHaveLength(1); - expect(restored.pendingActions).toHaveLength(1); - expect(restored.quarantinedLogs).toHaveLength(0); - expect(restored.quarantinedPendingActions).toHaveLength(0); - - // Sync resumes now that the action is visible and owner-matched again. - seedAuthenticatedAs(OWNER_A); - - const { momentLogApi } = await import('@/api/momentLogApi'); - const result = await flushPendingMomentActions(); - - expect(momentLogApi.createMomentLog).toHaveBeenCalledTimes(1); - expect(result.successCount).toBe(1); - }); -}); - -describe('reconciliation timing (no stale-account render frame)', () => { - it('quarantines the previous account draft synchronously inside the same account-switch call, before any await/microtask', () => { - // Owner A is logged in with a visible draft (mirrors normal app usage: - // the log and its pending action already sit in the visible arrays). - seedOwnedDraft(); - useAuthStore.setState({ - accessToken: 'access-a', - isHydrated: true, - refreshToken: 'refresh-a', - status: 'authenticated', - user: { displayName: 'A', id: OWNER_A, provider: 'email' }, - }); - expect(useMomentLogStore.getState().logs).toHaveLength(1); - - // Account B logs in — e.g. the exact `set()` finishLogin performs. - // This is the single synchronous statement a screen's render would - // race against; there is no `await`, `setTimeout`, or `Promise.resolve()` - // between it and the assertions below. - useAuthStore.setState({ - accessToken: 'access-b', - refreshToken: 'refresh-b', - status: 'authenticated', - user: { displayName: 'B', id: OWNER_B, provider: 'email' }, - }); - - // If reconciliation depended on a React effect (which only runs after - // commit), this would still show account A's draft here. Because - // momentLogStore subscribes to useAuthStore directly, reconciliation - // already ran synchronously inside the `setState` call above — so by - // the time ANY screen's render reads `logs`, it's already account B's - // (empty) view. - const state = useMomentLogStore.getState(); - - expect(state.logs).toHaveLength(0); - expect(state.pendingActions).toHaveLength(0); - expect(state.quarantinedLogs).toHaveLength(1); - expect(state.quarantinedLogs[0].id).toBe('moment-1'); - expect(state.quarantinedPendingActions).toHaveLength(1); - }); -}); diff --git a/src/test/__tests__/momentLogSync.test.ts b/src/test/__tests__/momentLogSync.test.ts deleted file mode 100644 index c706dd5..0000000 --- a/src/test/__tests__/momentLogSync.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { flushPendingMomentActions } from '@/utils/momentLogSync'; - -vi.mock('@/api/momentLogApi', () => ({ - momentLogApi: { - createMomentLog: vi.fn(async () => ({ - createdAt: new Date().toISOString(), - id: 'server-moment-1', - moodTags: [], - source: 'camera', - syncStatus: 'synced', - })), - }, -})); - -vi.mock('@/api/recapApi', () => ({ - recapApi: { - createRecap: vi.fn(async () => ({ id: 'recap-1' })), - }, -})); - -const OWNER_A = 'user-a'; -const OWNER_B = 'user-b'; - -function seedPendingCreateAction(ownerUserId: string | undefined) { - useMomentLogStore.setState({ - logs: [ - { - createdAt: new Date().toISOString(), - id: 'moment-1', - moodTags: [], - // Real callers (addLog + queueCreate) stamp the log and its - // pending action with the same ownerUserId in the same beat — mirror - // that here so the now-synchronous auth-driven reconciliation (see - // momentLogStore.ts's useAuthStore.subscribe) treats them consistently. - ownerUserId, - source: 'camera', - syncStatus: 'pending', - } as never, - ], - pendingActions: [ - { - id: 'create:moment-1', - momentLogId: 'moment-1', - ownerUserId, - payload: { createdAt: new Date().toISOString(), moodTags: [] }, - queuedAt: new Date().toISOString(), - type: 'create', - }, - ], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); -} - -function seedAuthenticatedAs(userId: string) { - useAuthStore.setState({ - accessToken: 'access-token', - refreshToken: 'refresh-token', - status: 'authenticated', - user: { displayName: 'Test', id: userId, provider: 'email' }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - useMomentLogStore.setState({ - logs: [], - pendingActions: [], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); - useAuthStore.setState({ - accessToken: undefined, - refreshToken: undefined, - status: 'unauthenticated', - user: undefined, - }); -}); - -describe('flushPendingMomentActions account gating (P0-3)', () => { - it('resumes sync when the re-logged-in account matches the draft owner', async () => { - seedPendingCreateAction(OWNER_A); - seedAuthenticatedAs(OWNER_A); - - const { momentLogApi } = await import('@/api/momentLogApi'); - const result = await flushPendingMomentActions(); - - expect(result.successCount).toBe(1); - expect(momentLogApi.createMomentLog).toHaveBeenCalledTimes(1); - }); - - it('quarantines the draft when a different account is logged in (no upload)', async () => { - seedPendingCreateAction(OWNER_A); - seedAuthenticatedAs(OWNER_B); - - const { momentLogApi } = await import('@/api/momentLogApi'); - const result = await flushPendingMomentActions(); - - expect(momentLogApi.createMomentLog).not.toHaveBeenCalled(); - expect(result.successCount).toBe(0); - expect(result.failureCount).toBe(0); - // The account-change subscription already moved it out of the visible - // array before flush ever ran... - expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); - // ...but it's queued, untouched, in quarantine for whenever its owner - // logs back in. - expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( - 1, - ); - }); - - it('quarantines legacy actions with an unknown owner (undefined ownerUserId)', async () => { - seedPendingCreateAction(undefined); - seedAuthenticatedAs(OWNER_A); - - const { momentLogApi } = await import('@/api/momentLogApi'); - const result = await flushPendingMomentActions(); - - expect(momentLogApi.createMomentLog).not.toHaveBeenCalled(); - expect(result.successCount).toBe(0); - expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); - expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( - 1, - ); - }); -}); diff --git a/src/test/__tests__/travelLogSync.test.ts b/src/test/__tests__/travelLogSync.test.ts deleted file mode 100644 index 4e1d24b..0000000 --- a/src/test/__tests__/travelLogSync.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; -import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; - -vi.mock('@/api/travelSessionApi', () => ({ - travelSessionApi: { - endTravelSession: vi.fn(async () => undefined), - }, -})); - -vi.mock('@/api/recapApi', () => ({ - recapApi: { - createRecap: vi.fn(async () => ({ id: 'recap-1' })), - }, -})); - -const OWNER_A = 'user-a'; -const CURRENT_SESSION = 'session-current'; -const OTHER_SESSION = 'session-other'; - -function seedFinalization() { - useTravelLogSyncStore.setState({ - pendingFinalizations: [ - { - endedAt: new Date().toISOString(), - id: `travel-log:${CURRENT_SESSION}`, - ownerUserId: OWNER_A, - queuedAt: new Date().toISOString(), - routePoints: [], - sessionId: CURRENT_SESSION, - templateId: 'film', - title: 'Trip', - }, - ], - }); -} - -function seedSyncedSessionLog() { - useMomentLogStore.setState((state) => ({ - logs: [ - ...state.logs, - { - createdAt: new Date().toISOString(), - id: 'log-current-session', - moodTags: [], - sessionId: CURRENT_SESSION, - source: 'camera', - syncStatus: 'synced', - }, - ], - })); -} - -function seedAuthenticatedAs(userId: string) { - useAuthStore.setState({ - accessToken: 'access-token', - refreshToken: 'refresh-token', - status: 'authenticated', - user: { displayName: 'Test', id: userId, provider: 'email' }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - useMomentLogStore.setState({ logs: [], pendingActions: [] }); - useTravelLogSyncStore.setState({ pendingFinalizations: [] }); - seedAuthenticatedAs(OWNER_A); - seedFinalization(); - seedSyncedSessionLog(); -}); - -describe('flushPendingTravelLogFinalizations session-scoped gate (P1-3)', () => { - it('confirms the Log when the only pending action belongs to a different session', async () => { - useMomentLogStore.setState((state) => ({ - logs: [ - ...state.logs, - { - createdAt: new Date().toISOString(), - id: 'log-other-session', - moodTags: [], - sessionId: OTHER_SESSION, - source: 'camera', - syncStatus: 'pending', - }, - ], - pendingActions: [ - { - id: 'create:log-other-session', - momentLogId: 'log-other-session', - ownerUserId: OWNER_A, - payload: { createdAt: new Date().toISOString(), moodTags: [] }, - queuedAt: new Date().toISOString(), - type: 'create', - }, - ], - })); - - const { recapApi } = await import('@/api/recapApi'); - const result = await flushPendingTravelLogFinalizations(); - - expect(result.successCount).toBe(1); - expect(result.deferredCount).toBe(0); - expect(recapApi.createRecap).toHaveBeenCalledTimes(1); - expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( - 0, - ); - }); - - it('defers the Log while a pending action for its own session is unresolved', async () => { - // The log itself already reports `synced` (e.g. its create succeeded), - // but a follow-up edit for the same session is still queued. This is - // exactly the case the old global `pendingActions.length > 0` check and - // the syncStatus-based session check could each miss on their own. - useMomentLogStore.setState((state) => ({ - logs: state.logs, - pendingActions: [ - { - id: 'edit:log-current-session', - momentLogId: 'log-current-session', - ownerUserId: OWNER_A, - payload: { moodTags: [], note: null, placeName: null }, - queuedAt: new Date().toISOString(), - type: 'edit', - }, - ], - })); - - const { recapApi } = await import('@/api/recapApi'); - const result = await flushPendingTravelLogFinalizations(); - - expect(result.deferredCount).toBe(1); - expect(result.successCount).toBe(0); - expect(recapApi.createRecap).not.toHaveBeenCalled(); - expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( - 1, - ); - }); -}); diff --git a/src/test/mocks/reactNativeMock.ts b/src/test/mocks/reactNativeMock.ts index 7f27ebd..8063315 100644 --- a/src/test/mocks/reactNativeMock.ts +++ b/src/test/mocks/reactNativeMock.ts @@ -1,9 +1,7 @@ // Minimal stand-in for the `react-native` package under vitest (see // vitest.config.ts alias). Only `Platform.OS` is needed by // src/store/authStorage.ts to choose the AsyncStorage branch over -// expo-secure-store, and AppState is referenced by -// src/providers/MomentLogSyncWorker.tsx (not exercised by these unit -// tests, but stubbed for safety if it's ever imported transitively). +// expo-secure-store. AppState is also stubbed for hooks imported by tests. export const Platform = { OS: 'web' as const, }; diff --git a/src/types/auth.ts b/src/types/auth.ts index 798b6dc..422eb48 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -2,10 +2,7 @@ import { UserProfile } from '@/store/userProfileStore'; export type AuthProvider = 'email'; -export type AuthStatus = - | 'authenticated' - | 'checking' - | 'unauthenticated'; +export type AuthStatus = 'authenticated' | 'checking' | 'unauthenticated'; export type AuthUser = { id: string; @@ -37,19 +34,3 @@ export type AuthMe = { profile?: UserProfile; user: AuthUser; }; - -export type LocalDataMigrationPayload = { - idempotencyKey: string; - libraryTrackCount: number; - momentLogCount: number; - recapDraftCount: number; -}; - -export type LocalDataMigrationResult = { - accepted: boolean; - migrated: { - libraryTrackCount: number; - momentLogCount: number; - recapDraftCount: number; - }; -}; diff --git a/src/types/domain.ts b/src/types/domain.ts index dafd608..c1a83ad 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -22,13 +22,7 @@ export type PlaceContext = { title: string; }; -export type TravelMode = - | 'walk' - | 'drive' - | 'cafe' - | 'ocean' - | 'festival' - | 'night'; +export type TravelMode = 'walk' | 'drive' | 'cafe' | 'ocean' | 'festival' | 'night'; export type MusicRecommendationMode = 'everyday' | 'travel'; @@ -36,11 +30,7 @@ export type MoodTag = 'calm' | 'fresh' | 'emotional' | 'active' | 'local'; export type MusicPlatformId = 'none' | 'spotify' | 'youtubeMusic' | 'youtube'; -export type ExternalMusicPlatformId = - | 'melon' - | 'spotify' - | 'youtube' - | 'youtubeMusic'; +export type ExternalMusicPlatformId = 'melon' | 'spotify' | 'youtube' | 'youtubeMusic'; export type PlaylistRecommendationSource = | 'ml-recommendation' @@ -143,8 +133,6 @@ export type MomentLog = { travelMode?: TravelMode; moodTags: MoodTag[]; source: 'camera'; - syncError?: string; - syncStatus: 'failed' | 'local' | 'pending' | 'synced'; templateId?: RecapTemplateId; }; @@ -301,9 +289,7 @@ export type MusicMatch = { matchScore: number; safety: { exactLocationHidden: boolean; - firstMessageTemplates: Array< - 'cafe_together' | 'liked_track' | 'walk_together' - >; + firstMessageTemplates: Array<'cafe_together' | 'liked_track' | 'walk_together'>; contactHiddenUntilAccepted: boolean; }; }; diff --git a/src/utils/accountSession.ts b/src/utils/accountSession.ts index 538273f..e97728c 100644 --- a/src/utils/accountSession.ts +++ b/src/utils/accountSession.ts @@ -2,12 +2,10 @@ import { queryClient } from '@/providers/queryClient'; import { useAuthStore } from '@/store/authStore'; import { useHomeFilterStore } from '@/store/homeFilterStore'; import { useLibraryStore } from '@/store/libraryStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationCacheStore } from '@/store/recommendationCacheStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { useTravelRoomStore } from '@/store/travelRoomStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; @@ -20,15 +18,9 @@ import { useUserProfileStore } from '@/store/userProfileStore'; * previous account's data render on screen after someone else logs in. * * Used on the silent token-refresh-failure path so an expired session - * prompts re-login WITHOUT discarding unsynced local data: pending moment - * log actions, in-progress recap drafts, the active travel session and its - * route points, and pending Log finalizations. Those are preserved (see - * momentLogStore's `quarantinedLogs` / `quarantinedPendingActions` and - * travelSessionStore's `quarantinedSessions`) and are gated back into - * visibility + sync (see momentLogSync.ts / travelLogSync.ts, and each - * store's own synchronous `useAuthStore.subscribe(...)` -> `reconcileOwnership` - * call) only once the SAME account re-authenticates; a different account - * never sees them, not even for a single render frame. + * prompts re-login without discarding the active travel session and its + * route points. The travel session is preserved in quarantine and restored + * only when the same account signs in again. * * Use `clearAccountSession` instead for explicit logout / account deletion, * where wiping all local data — including those preserved drafts — is the @@ -55,13 +47,6 @@ export function clearAuthSession() { export function clearAccountSession() { clearAuthSession(); - useMomentLogStore.setState({ - logs: [], - pendingActions: [], - quarantinedLogs: [], - quarantinedPendingActions: [], - }); - useTravelLogSyncStore.setState({ pendingFinalizations: [] }); useTravelSessionStore.setState({ currentLocation: undefined, currentPlace: undefined, @@ -71,7 +56,7 @@ export function clearAccountSession() { recommendationMode: 'everyday', selectedMode: undefined, session: { - id: 'local-session', + id: 'idle', routePoints: [], status: 'idle', }, diff --git a/src/utils/localDataMigration.ts b/src/utils/localDataMigration.ts deleted file mode 100644 index 190a8b2..0000000 --- a/src/utils/localDataMigration.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { authApi } from '@/api/authApi'; -import { createIdempotencyKey } from '@/api/client'; -import { libraryApi } from '@/api/libraryApi'; -import { momentLogApi } from '@/api/momentLogApi'; -import { meApi } from '@/api/meApi'; -import { useLibraryStore } from '@/store/libraryStore'; -import { - useMomentLogStore, - type MomentLogCreateQueuePayload, - type MomentLogPendingAction, -} from '@/store/momentLogStore'; -import { useUserProfileStore } from '@/store/userProfileStore'; -import type { MomentLog } from '@/types/domain'; - -export type LocalDataMigrationSummary = { - libraryTrackCount: number; - momentLogCount: number; - recapDraftCount: number; -}; - -export type LocalDataMigrationSyncResult = { - libraryFailedCount: number; - librarySyncedCount: number; - migrationAccepted: boolean; - momentLogFailedCount: number; - momentLogSyncedCount: number; - summary: LocalDataMigrationSummary; -}; - -function momentLogCreatePayloadFromLog( - log: MomentLog, -): MomentLogCreateQueuePayload { - return { - createdAt: log.createdAt, - location: log.location, - moodTags: log.moodTags, - note: log.note, - photoUri: log.photoUri, - placeCategory: log.placeCategory, - placeId: log.placeId, - placeName: log.placeName, - recapVisibility: 'private', - sessionId: log.sessionId, - templateId: log.templateId, - track: log.track, - travelMode: log.travelMode, - }; -} - -function isCreatePendingAction( - action: MomentLogPendingAction, -): action is Extract { - return action.type === 'create'; -} - -export function getLocalDataMigrationSummary(): LocalDataMigrationSummary { - const { likedTracks, savedTracks } = useLibraryStore.getState(); - const { logs } = useMomentLogStore.getState(); - - return { - libraryTrackCount: likedTracks.length + savedTracks.length, - momentLogCount: logs.length, - recapDraftCount: logs.length > 0 ? 1 : 0, - }; -} - -async function syncCompletedLocalProfile() { - const { profile } = useUserProfileStore.getState(); - - if (!profile.completedOnboarding) { - return; - } - - await meApi.updateProfile(profile); -} - -async function syncLocalMomentLogs() { - const { logs, pendingActions, queueCreate, resolveLocalLog, updateLog } = - useMomentLogStore.getState(); - let syncedCount = 0; - let failedCount = 0; - - for (const log of logs) { - if (log.syncStatus === 'synced') { - continue; - } - - const queuedCreateAction = pendingActions - .filter(isCreatePendingAction) - .find((action) => action.momentLogId === log.id); - const payload = - queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); - - queueCreate(log.id, payload); - updateLog(log.id, { syncStatus: 'pending' }); - - try { - const serverLog = await momentLogApi.createMomentLog({ - ...payload, - idempotencyKey: log.id, - }); - - if (!serverLog) { - updateLog(log.id, { syncStatus: 'local' }); - failedCount += 1; - continue; - } - - resolveLocalLog(log.id, serverLog); - syncedCount += 1; - } catch { - queueCreate(log.id, payload); - updateLog(log.id, { syncStatus: 'failed' }); - failedCount += 1; - } - } - - return { failedCount, syncedCount }; -} - -async function syncLocalLibrary() { - const { likedTracks, savedTracks } = useLibraryStore.getState(); - let syncedCount = 0; - let failedCount = 0; - - const records = [ - ...likedTracks.map((record) => ({ action: 'like' as const, record })), - ...savedTracks.map((record) => ({ action: 'save' as const, record })), - ]; - - for (const { action, record } of records) { - try { - const result = await libraryApi.updateTrackState(record.track.id, { - action, - playlistId: record.playlistId, - }); - - if (!result) { - failedCount += 1; - continue; - } - - syncedCount += 1; - } catch { - failedCount += 1; - } - } - - return { failedCount, syncedCount }; -} - -export async function migrateLocalDataToAccount(): Promise { - const summary = getLocalDataMigrationSummary(); - let migrationAccepted = false; - - try { - const migration = await authApi.migrateLocalData({ - ...summary, - idempotencyKey: createIdempotencyKey('migration'), - }); - - migrationAccepted = migration.accepted; - } catch { - migrationAccepted = false; - } - - await syncCompletedLocalProfile().catch(() => undefined); - - const [momentLogResult, libraryResult] = await Promise.all([ - syncLocalMomentLogs(), - syncLocalLibrary(), - ]); - - return { - libraryFailedCount: libraryResult.failedCount, - librarySyncedCount: libraryResult.syncedCount, - migrationAccepted, - momentLogFailedCount: momentLogResult.failedCount, - momentLogSyncedCount: momentLogResult.syncedCount, - summary, - }; -} diff --git a/src/utils/momentFiles.ts b/src/utils/momentFiles.ts deleted file mode 100644 index fe7966b..0000000 --- a/src/utils/momentFiles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Directory, File, Paths } from 'expo-file-system'; - -const MOMENT_LOGS_DIRECTORY = 'moment-logs'; - -function getMomentDirectory() { - const directory = new Directory(Paths.document, MOMENT_LOGS_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - - return directory; -} - -function getExtension(uri: string) { - const match = uri.match(/\.([a-zA-Z0-9]+)(?:\?|#|$)/); - return match?.[1] ? `.${match[1]}` : '.jpg'; -} - -export async function persistMomentPhoto(sourceUri: string, logId: string) { - const directory = getMomentDirectory(); - const source = new File(sourceUri); - const destination = new File(directory, `${logId}${getExtension(sourceUri)}`); - - await source.copy(destination, { overwrite: true }); - - return destination.uri; -} diff --git a/src/utils/momentLogSync.ts b/src/utils/momentLogSync.ts deleted file mode 100644 index d2df5d3..0000000 --- a/src/utils/momentLogSync.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { ApiError, shouldAttemptAuthenticatedApi } from "@/api/client"; -import { momentLogApi } from "@/api/momentLogApi"; -import { recapApi } from "@/api/recapApi"; -import { useAuthStore } from "@/store/authStore"; -import { - useMomentLogStore, - type MomentLogPendingAction, -} from "@/store/momentLogStore"; - -// A pending action only syncs when it was queued by the account that is -// currently logged in. `undefined` ownerUserId covers both legacy data -// (persisted before this field existed) and drafts queued while signed out -// — both stay quarantined rather than uploading under whichever account -// happens to log in next. -function belongsToCurrentAccount(action: MomentLogPendingAction) { - const currentUserId = useAuthStore.getState().user?.id; - - return Boolean(action.ownerUserId) && action.ownerUserId === currentUserId; -} - -export type MomentLogSyncResult = { - failureCount: number; - successCount: number; -}; - -let activeFlushPromise: Promise | undefined; - -async function syncCreateAction( - action: Extract, -) { - const store = useMomentLogStore.getState(); - const localMoment = store.logs.find( - (moment) => moment.id === action.momentLogId, - ); - - if (!localMoment) { - store.removePendingAction(action.id); - return; - } - - store.updateLog(action.momentLogId, { - syncError: undefined, - syncStatus: "pending", - }); - - const serverLog = await momentLogApi.createMomentLog({ - ...action.payload, - idempotencyKey: action.momentLogId, - }); - - if (!serverLog) { - throw new Error("Moment create was not accepted by the server."); - } - - let recapId: string | undefined; - - if (!action.payload.sessionId) { - const recap = await recapApi.createRecap( - { - momentLogIds: [serverLog.id], - templateId: action.payload.templateId ?? "film", - visibility: action.payload.recapVisibility ?? "private", - }, - `standalone-recap:${action.momentLogId}`, - ); - - if (!recap) { - throw new Error( - "Standalone recap create was not accepted by the server.", - ); - } - - recapId = recap.id; - } - - useMomentLogStore.getState().resolveLocalLog(action.momentLogId, { - ...serverLog, - recapId, - recapVisibility: action.payload.recapVisibility, - templateId: action.payload.templateId, - }); -} - -async function syncDeleteAction( - action: Extract, -) { - try { - const accepted = await momentLogApi.deleteMomentLog(action.momentLogId); - - if (!accepted) { - throw new Error("Moment delete was not accepted by the server."); - } - } catch (error) { - if (!(error instanceof ApiError && error.status === 404)) { - throw error; - } - } - - useMomentLogStore.getState().removePendingAction(action.id); -} - -async function syncEditAction( - action: Extract, -) { - const store = useMomentLogStore.getState(); - const localMoment = store.logs.find( - (moment) => moment.id === action.momentLogId, - ); - - if (!localMoment) { - store.removePendingAction(action.id); - return; - } - - if (action.payload.removePhoto) { - const updatedLog = await momentLogApi.deleteMomentLogPhoto( - action.momentLogId, - ); - - if (!updatedLog) { - throw new Error("Moment photo delete was not accepted by the server."); - } - } else if (action.payload.replacePhotoUri) { - const updatedLog = await momentLogApi.updateMomentLogPhoto( - action.momentLogId, - action.payload.replacePhotoUri, - ); - - if (!updatedLog) { - throw new Error("Moment photo update was not accepted by the server."); - } - } - - const serverLog = await momentLogApi.updateMomentLog(action.momentLogId, { - moodTags: action.payload.moodTags, - note: action.payload.note, - placeName: action.payload.placeName, - track: action.payload.track, - }); - - if (!serverLog) { - throw new Error("Moment edit was not accepted by the server."); - } - - const nextStore = useMomentLogStore.getState(); - nextStore.updateLog(action.momentLogId, serverLog); - nextStore.removePendingAction(action.id); -} - -async function syncAction(action: MomentLogPendingAction) { - if (action.type === "create") { - await syncCreateAction(action); - return; - } - - if (action.type === "delete") { - await syncDeleteAction(action); - return; - } - - await syncEditAction(action); -} - -async function performFlush(): Promise { - if (!shouldAttemptAuthenticatedApi()) { - return { failureCount: 0, successCount: 0 }; - } - - const actions = [...useMomentLogStore.getState().pendingActions].filter( - belongsToCurrentAccount, - ); - let failureCount = 0; - let successCount = 0; - - for (const action of actions) { - try { - await syncAction(action); - successCount += 1; - } catch (error) { - failureCount += 1; - - if (action.type === "create") { - useMomentLogStore.getState().updateLog(action.momentLogId, { - syncError: - error instanceof Error - ? error.message - : "리캡을 서버와 동기화하지 못했어요.", - syncStatus: "failed", - }); - } - } - } - - return { failureCount, successCount }; -} - -export function flushPendingMomentActions() { - activeFlushPromise ??= performFlush().finally(() => { - activeFlushPromise = undefined; - }); - - return activeFlushPromise; -} diff --git a/src/utils/momentPhotoPicker.ts b/src/utils/momentPhotoPicker.ts index c4fcb6d..56ed84e 100644 --- a/src/utils/momentPhotoPicker.ts +++ b/src/utils/momentPhotoPicker.ts @@ -1,5 +1,3 @@ -import { persistMomentPhoto } from '@/utils/momentFiles'; - export type PickMomentPhotoResult = | { status: 'selected'; @@ -37,37 +35,3 @@ export async function pickMomentPhotoFromLibrary(): Promise { - try { - const ImagePicker = await import('expo-image-picker'); - const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(false); - - if (!permission.granted) { - return { status: 'permission-denied' }; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - allowsEditing: false, - allowsMultipleSelection: false, - mediaTypes: ['images'], - quality: 0.92, - }); - - if (result.canceled || !result.assets[0]?.uri) { - return { status: 'cancelled' }; - } - - const persistedUri = await persistMomentPhoto( - result.assets[0].uri, - `${momentLogId}-replacement-${Date.now()}`, - ); - - return { - status: 'selected', - uri: persistedUri, - }; - } catch { - return { status: 'unavailable' }; - } -} diff --git a/src/utils/recapMappers.ts b/src/utils/recapMappers.ts deleted file mode 100644 index 1caf11f..0000000 --- a/src/utils/recapMappers.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { - MomentLog, - RecapItem, - RecapShare, - RecapShareMoment, - RoutePoint, -} from "@/types/domain"; -import { createRecapTravelSummary } from "@/utils/recapTravelSummary"; - -const FALLBACK_ARTIST = "Soundlog"; -const FALLBACK_PLACE = "위치 없음"; -const FALLBACK_TITLE = "저장된 리캡"; -export const SESSION_RECAP_ID_PREFIX = "session-recap__"; - -export type MomentLogGroup = { - id: string; - logs: MomentLog[]; - sessionId?: string; -}; - -function getNewestLog(logs: MomentLog[]) { - return [...logs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - )[0]; -} - -function getOldestFirstLogs(logs: MomentLog[]) { - return [...logs].sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); -} - -function momentLogToRecapShareMoment(log: MomentLog): RecapShareMoment { - return { - artistName: log.track?.artist ?? FALLBACK_ARTIST, - id: log.id, - imageUrl: log.photoUri, - location: log.location, - placeName: log.placeName ?? FALLBACK_PLACE, - recordedAt: log.createdAt, - templateId: log.templateId, - track: log.track, - trackTitle: log.track?.title ?? FALLBACK_TITLE, - visibility: log.recapVisibility, - }; -} - -export function createSessionRecapId(sessionId: string) { - return `${SESSION_RECAP_ID_PREFIX}${sessionId}`; -} - -export function extractSessionIdFromRecapId(id?: string) { - if (!id?.startsWith(SESSION_RECAP_ID_PREFIX)) { - return undefined; - } - - return id.slice(SESSION_RECAP_ID_PREFIX.length); -} - -export function createMomentLogGroups(logs: MomentLog[]): MomentLogGroup[] { - const groupMap = new Map(); - - logs.forEach((log) => { - const groupKey = log.sessionId - ? `session:${log.sessionId}` - : `log:${log.id}`; - const existingGroup = groupMap.get(groupKey); - - if (existingGroup) { - existingGroup.logs.push(log); - return; - } - - groupMap.set(groupKey, { - id: log.sessionId - ? createSessionRecapId(log.sessionId) - : (log.recapId ?? log.id), - logs: [log], - sessionId: log.sessionId, - }); - }); - - return Array.from(groupMap.values()) - .map((group) => ({ - ...group, - logs: [...group.logs].sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ), - })) - .sort((a, b) => { - const aRepresentative = getNewestLog(a.logs); - const bRepresentative = getNewestLog(b.logs); - - return ( - new Date(bRepresentative?.createdAt ?? 0).getTime() - - new Date(aRepresentative?.createdAt ?? 0).getTime() - ); - }); -} - -export function momentLogGroupToRecapItem(group: MomentLogGroup): RecapItem { - const representativeLog = getNewestLog(group.logs); - - if (!representativeLog) { - return { - createdAt: new Date().toISOString(), - id: group.id, - momentCount: 0, - placeName: FALLBACK_PLACE, - representativeTrack: { - artist: FALLBACK_ARTIST, - fallbackColor: "#2B176C", - id: `${group.id}-fallback-track`, - title: FALLBACK_TITLE, - }, - sessionId: group.sessionId, - title: "여행 로그", - visibility: "private", - }; - } - - const baseItem = momentLogToRecapItem(representativeLog); - const momentCount = group.logs.length; - const placeName = baseItem.placeName; - - return { - ...baseItem, - id: group.id, - momentCount, - sessionId: group.sessionId, - title: group.sessionId ? `${placeName} 여행 로그` : baseItem.title, - visibility: representativeLog.recapVisibility ?? "private", - }; -} - -export function momentLogGroupToRecapShare( - group: MomentLogGroup, - timing: { - endedAt?: string; - routePoints?: RoutePoint[]; - startedAt?: string; - } = {}, -): RecapShare | undefined { - const representativeLog = getNewestLog(group.logs); - - if (!representativeLog) { - return undefined; - } - - const moments = getOldestFirstLogs(group.logs).map( - momentLogToRecapShareMoment, - ); - - return { - ...momentLogToRecapShare(representativeLog), - id: group.id, - moments, - routePoints: timing.routePoints, - sessionId: group.sessionId, - travelSummary: createRecapTravelSummary({ - endedAt: timing.endedAt, - fallbackPlaceName: representativeLog.placeName ?? FALLBACK_PLACE, - moments, - routePoints: timing.routePoints, - startedAt: timing.startedAt, - }), - }; -} - -export function momentLogToRecapItem(log: MomentLog): RecapItem { - return { - createdAt: log.createdAt, - id: log.id, - placeName: log.placeName ?? FALLBACK_PLACE, - representativeTrack: log.track ?? { - artist: FALLBACK_ARTIST, - fallbackColor: "#2B176C", - id: `${log.id}-fallback-track`, - title: FALLBACK_TITLE, - }, - title: log.track?.title ?? FALLBACK_TITLE, - visibility: log.recapVisibility ?? "private", - }; -} - -export function momentLogToRecapShare(log: MomentLog): RecapShare { - const moments = [momentLogToRecapShareMoment(log)]; - - return { - artistName: log.track?.artist ?? FALLBACK_ARTIST, - backgroundImageUrl: log.photoUri, - discImageUrl: log.photoUri, - id: log.id, - isMine: true, - moments, - placeName: log.placeName ?? FALLBACK_PLACE, - recordedAt: log.createdAt, - templateId: log.templateId ?? "album", - trackTitle: log.track?.title ?? FALLBACK_TITLE, - travelSummary: createRecapTravelSummary({ - fallbackPlaceName: log.placeName ?? FALLBACK_PLACE, - moments, - }), - visibility: log.recapVisibility ?? "private", - }; -} diff --git a/src/utils/travelLogSync.ts b/src/utils/travelLogSync.ts deleted file mode 100644 index 1deb1a3..0000000 --- a/src/utils/travelLogSync.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { ApiError, shouldAttemptAuthenticatedApi } from '@/api/client'; -import { recapApi } from '@/api/recapApi'; -import { travelSessionApi } from '@/api/travelSessionApi'; -import { useAuthStore } from '@/store/authStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { - useTravelLogSyncStore, - type PendingTravelLogFinalization, -} from '@/store/travelLogSyncStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; - -// Mirrors momentLogSync's account gate: only finalize a Log finalization -// queued by the account that is currently logged in. Unknown owner (legacy -// data, or queued while signed out) stays quarantined. -function belongsToCurrentAccount(finalization: PendingTravelLogFinalization) { - const currentUserId = useAuthStore.getState().user?.id; - - return ( - Boolean(finalization.ownerUserId) && - finalization.ownerUserId === currentUserId - ); -} - -export type TravelLogSyncResult = { - createdRecapIds: Record; - deferredCount: number; - failureCount: number; - successCount: number; -}; - -let activeFlushPromise: Promise | undefined; - -async function performFlush(): Promise { - const result: TravelLogSyncResult = { - createdRecapIds: {}, - deferredCount: 0, - failureCount: 0, - successCount: 0, - }; - - if (!shouldAttemptAuthenticatedApi()) { - return result; - } - - const pendingFinalizations = [ - ...useTravelLogSyncStore.getState().pendingFinalizations, - ].filter(belongsToCurrentAccount); - - for (const finalization of pendingFinalizations) { - const momentState = useMomentLogStore.getState(); - - // Only pending actions that belong to THIS session may block its Log - // confirmation. A pending action has no sessionId of its own, so we - // resolve it via the moment log it targets. Actions whose log is no - // longer present (e.g. an already-queued delete removed it from - // `logs`) can't affect this session's finalized content, since the - // sessionLogs computation below also no longer includes it. - const sessionPendingActionCount = momentState.pendingActions.filter( - (action) => - momentState.logs.find((log) => log.id === action.momentLogId) - ?.sessionId === finalization.sessionId, - ).length; - - if (sessionPendingActionCount > 0) { - result.deferredCount += 1; - continue; - } - - const sessionLogs = momentState.logs.filter( - (log) => log.sessionId === finalization.sessionId, - ); - - if (sessionLogs.length === 0) { - useTravelLogSyncStore.getState().removeFinalization(finalization.id); - result.successCount += 1; - continue; - } - - if (sessionLogs.some((log) => log.syncStatus !== 'synced')) { - result.deferredCount += 1; - continue; - } - - try { - try { - await travelSessionApi.endTravelSession(finalization.sessionId, { - endedAt: finalization.endedAt, - location: finalization.location, - routePoints: finalization.routePoints, - }); - } catch (error) { - if (!(error instanceof ApiError && error.status === 404)) { - throw error; - } - } - - const representativeTrackId = sessionLogs.find((log) => log.track?.id) - ?.track?.id; - const recap = await recapApi.createRecap( - { - momentLogIds: sessionLogs.map((log) => log.id), - representativeTrackId, - routePoints: finalization.routePoints, - sessionId: finalization.sessionId, - templateId: finalization.templateId, - title: finalization.title, - visibility: 'private', - }, - finalization.id, - ); - - if (!recap) { - throw new Error('Travel Log create was not accepted by the server.'); - } - - result.createdRecapIds[finalization.sessionId] = recap.id; - result.successCount += 1; - useTravelLogSyncStore.getState().removeFinalization(finalization.id); - - const travelSessionState = useTravelSessionStore.getState(); - - if (travelSessionState.session.id === finalization.sessionId) { - travelSessionState.setSessionRecapId(recap.id); - } - } catch { - result.failureCount += 1; - } - } - - return result; -} - -export function flushPendingTravelLogFinalizations() { - activeFlushPromise ??= performFlush().finally(() => { - activeFlushPromise = undefined; - }); - - return activeFlushPromise; -}