diff --git a/docs/analytics/amplitude-operations.md b/docs/analytics/amplitude-operations.md index 60e67aa2..45fce9b5 100644 --- a/docs/analytics/amplitude-operations.md +++ b/docs/analytics/amplitude-operations.md @@ -79,7 +79,7 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용 - 생성자 활성화 퍼널: `create_plan` → `add_to_itinerary` (신규 가입자는 별도 코호트로 구분) - 참여자 활성화 퍼널: `invite_view` → `join_group` → `view_plan`; 이후 일정 편집과 조회 위주 사용을 구분 -- 공유: 생성자의 `create_plan → share`와 참여자의 `invite_view → join_group`을 분리. 서로 다른 사람의 행동을 기본 사용자 퍼널로 연결하지 않는다. 여행방 기능 이벤트에는 기존 방 ID를 이벤트별 `groups.room_id`로 전달한다. 그룹 보고 기능의 사용 가능 여부를 확인한 뒤 방 기준 퍼널로 분석한다(정확한 신규 합류 판정은 별도 백엔드 이벤트 필요). +- 공유: 공유자(HOST 또는 MEMBER)의 `share`와 참여자의 `invite_view → join_group`을 분리. 생성자의 `create_plan → share`는 별도 세그먼트로 본다. 서로 다른 사람의 행동을 기본 사용자 퍼널로 연결하지 않는다. 여행방 기능 이벤트에는 기존 방 ID를 이벤트별 `groups.room_id`로 전달한다. 그룹 보고 기능의 사용 가능 여부를 확인한 뒤 방 기준 퍼널로 분석한다(정확한 신규 합류 판정은 별도 백엔드 이벤트 필요). - 탐색 퍼널: `view_search_results` → `view_place` → `add_to_bookmark` 또는 `add_to_itinerary` - 온보딩 퍼널: `tutorial_begin` → `tutorial_complete`; `tutorial_skip`은 `skip_step`별 이탈을 분석한다. @@ -97,7 +97,7 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용 - 재사용 분석은 단순 페이지 재방문과 핵심 행동 재사용을 구분하고 여행 준비 기간/다음 여행을 고려한다. - 운영 Tracking Plan·대시보드·GA4 주요 이벤트 지정은 원격 설정이며 코드 변경만으로 완료되지 않는다. 가계부·준비물 이벤트에는 공통 정규화 URL과 방 ID만 사용하고 금액·메모·이름·항목 식별자는 추가하지 않는다. -방 단위 퍼널은 그룹 타입 `room_id`를 선택하고 `share` 단계에 `role=host`, `join_group` 단계에 `role=member`를 적용한다. Accounts 기능 활성화/계약은 이번 프론트 코드 변경과 별개다. 이벤트 속성에 방 ID가 보이는 것만으로 그룹 퍼널이 설정된 것은 아니다. +방 단위 퍼널은 그룹 타입 `room_id`를 선택하고 `share` 단계는 HOST와 MEMBER 전체를 포함하고, 역할별 비교가 필요하면 `role=host`와 `role=member` 세그먼트로 나눈다. `join_group` 단계에는 `role=member`를 적용한다. Accounts 기능 활성화/계약은 이번 프론트 코드 변경과 별개다. 이벤트 속성에 방 ID가 보이는 것만으로 그룹 퍼널이 설정된 것은 아니다. 그룹 분석은 생성·조회·일정·북마크·채팅·가계부·준비물 전반에 사용할 수 있다. 검색·장소 상세·튜토리얼·페이지 조회는 확정된 방 문맥에서만 그룹을 가진다. 준비물은 개인 기능이며 이를 공동 편집 활성의 근거로 삼지 않는다. 협업 지표에는 적절한 이벤트 집합과 서로 다른 참여자 조건을 별도로 정의한다. @@ -110,3 +110,8 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용 - SDK 초기화 전 대기 중이던 이전 사용자의 Amplitude 명령은 폐기한다. 초기화 완료 후 식별자를 재설정한 다음 로그아웃 이후 명령을 처리한다. 초기화 중에는 SDK 자동 수집을 일시 정지하고, 동의가 유지된 경우에만 준비 후 재개한다. - SDK가 시작되지 않았다면 해당 프로젝트의 저장된 식별 쿠키만 제거한다. 로그아웃 때문에 SDK를 초기화하거나 동의를 허용하지 않는다. 초기화 실패 후 재시도에도 식별 경계를 유지한다. - 이 처리는 로그아웃 이후 이벤트의 귀속을 분리하며, 이미 수집된 과거 이벤트를 삭제하는 기능은 아니다. + + +## 멤버 권한 관련 코드 계측 + +`invite_code_issued`(명시적 발급·재발급 성공)와 `room_info_updated`(방 정보 저장 성공)는 Amplitude 전용이다. 자동 초대 코드 fallback, 실패·취소, 후속 재조회는 집계하지 않는다. 두 이벤트의 `room_id`는 요청 대상 방이고 `role`은 `host`/`member`이며, 코드·링크·제목 등 원문을 속성에 넣지 않는다. 전체 공유 분석은 기존 `share`의 두 역할 모두를 포함한다. 이 문서는 소스 계약이며 배포나 실제 수신을 확인했다는 의미가 아니다. diff --git a/docs/analytics/amplitude-tracking-plan.md b/docs/analytics/amplitude-tracking-plan.md index 8a510cbd..00529350 100644 --- a/docs/analytics/amplitude-tracking-plan.md +++ b/docs/analytics/amplitude-tracking-plan.md @@ -38,6 +38,8 @@ | `reorder_itinerary` | 드래그 앤 드롭 일정 재정렬이 반영됐을 때 | `item_count_bucket`, `method=drag_drop`, `room_id` (Amplitude만) | 없음 | Amplitude | | `view_search_results` | 새로운 검색 결과 세대가 화면에 반영됐을 때 | `result_count_bucket`, `search_mode` | `room_id` (확정된 방만, Amplitude) | Amplitude | | `share` | 초대 링크 복사 또는 네이티브 공유가 성공했을 때 | `method`, `room_id` (Amplitude만) | `member_count_bucket`, `role` | 공통 | +| `invite_code_issued` | 본인의 명시적 초대 코드 발급·재발급 POST 성공 | `room_id`, `role` | 없음 | Amplitude | +| `room_info_updated` | 본인의 방 정보 PATCH 성공 | `room_id`, `role` | 없음 | Amplitude | | `chat_message_sent` | 텍스트·AI·장소 채팅 전송 액션이 실행됐을 때 | `message_type`, `room_id` (Amplitude만) | 없음 | Amplitude | | `tutorial_begin` | 사이드바 튜토리얼이 처음 시작됐을 때 | `tutorial_version=sidebar_v1` | `room_id` (확정된 방만, Amplitude) | Amplitude | | `tutorial_complete` | 사이드바 튜토리얼의 마지막 단계를 완료했을 때 | `tutorial_version=sidebar_v1` | `room_id` (확정된 방만, Amplitude) | Amplitude | @@ -102,9 +104,9 @@ ## 후속 후보와 분석 단위 -이번 구현은 위 이벤트로 제한한다. 가계부 필터, 준비물 이름·메모·삭제·카테고리 관리, 일정 시간·날짜·이동수단 변경, 일차 관리, 북마크 이동/삭제, 여행·멤버 관리의 세부 이벤트는 아직 추가하지 않았다. 분석 질문이 생기면 Amplitude 전용 후보로 검토한다. 원문 속성은 추가하지 않는다. +이번 구현은 위 이벤트로 제한한다. 가계부 필터, 준비물 이름·메모·삭제·카테고리 관리, 일정 시간·날짜·이동수단 변경, 일차 관리, 북마크 이동/삭제, 이번 명시적 초대 코드 발급·재발급과 방 정보 저장을 제외한 여행·멤버 관리의 세부 이벤트는 아직 추가하지 않았다. 분석 질문이 생기면 Amplitude 전용 후보로 검토한다. 원문 속성은 추가하지 않는다. -개인 활성화는 생성자 `create_plan → add_to_itinerary`, 초대 참여자 `invite_view → join_group → view_plan`처럼 분리한다. 방장의 `share`와 다른 사람의 `join_group`을 기본 사용자 퍼널로 연결하지 않는다. 여행방에 속한 기능 이벤트에 이벤트별 여행방 그룹 계측을 적용했다. Amplitude 그룹 보고 기능의 계약/활성화와 퍼널 구성은 별도로 필요하다. 준비물은 여행방 공동 작업이 아닌 개인 사용이다. +개인 활성화는 생성자 `create_plan → add_to_itinerary`, 초대 참여자 `invite_view → join_group → view_plan`처럼 분리한다. 공유자(HOST 또는 MEMBER)의 `share`와 다른 사람의 `join_group`을 기본 사용자 퍼널로 연결하지 않는다. 여행방에 속한 기능 이벤트에 이벤트별 여행방 그룹 계측을 적용했다. Amplitude 그룹 보고 기능의 계약/활성화와 퍼널 구성은 별도로 필요하다. 준비물은 여행방 공동 작업이 아닌 개인 사용이다. ## 여행방 단위 그룹 분석 @@ -113,7 +115,7 @@ - `join_group`: 즉시 참여와 승인 대기 완료 모두 서버 응답의 `id`를 사용한다. 추가 방 정보 조회가 실패해도 응답의 방 ID는 유지한다. - `invite_view`: 로그인 전에는 방 ID를 알 수 없어 기존 상태를 유지한다. 초대 코드를 ID로 대체하지 않는다. - GA4로 보내거나 GA 대기열에 넣기 전에 `room_id`를 제거한다. 양 도구의 사용자 ID는 계속 실제 사용자 ID다. URL 정규화 정책도 유지한다. -- Amplitude Accounts 그룹 분석 사용 가능 여부를 확인하고 그룹 타입 `room_id`로 퍼널을 만든다. 1단계는 `share` + `role=host`, 2단계는 `join_group` + `role=member`다. 관찰 기간(예: 7일)은 보고서에 명시한다. +- Amplitude Accounts 그룹 분석 사용 가능 여부를 확인하고 그룹 타입 `room_id`로 퍼널을 만든다. 전체 공유 퍼널의 1단계는 `share`이며 HOST와 MEMBER를 모두 포함한다. 역할별 비교는 `role=host`와 `role=member` 세그먼트로 나누고, 2단계는 `join_group` + `role=member`로 본다. 관찰 기간(예: 7일)은 보고서에 명시한다. - 이 퍼널은 같은 방에서 공유 후 참여 완료가 관측됐다는 뜻이다. 특정 링크/공유자가 실제 합류를 유발했다는 증명은 아니며 `share`의 복사 성공도 전달 완료가 아니다. - 현재 참여 API 응답에는 신규 멤버십 생성 여부/고유 참여 발생 ID가 없다. 클라이언트의 `join_group`만으로 기존 멤버 재진입과 신규 합류를 엄밀히 구분하거나 서버 기준으로 중복 제거할 수 없다. 정확한 신규 참여 전환율에는 백엔드의 멤버십 확정 이벤트가 추가로 필요하다. @@ -127,3 +129,12 @@ - 이벤트 수집 목적지는 그대로다. 그룹 확대는 Amplitude의 방 단위 분석 문맥만 추가하며 GA4로 방 ID를 전달하지 않는다. 페이지 조회는 늦은 사용자/방 문맥 보충만으로 중복하지 않는다. 사용자 조회 오류/익명 상태의 기존 페이지뷰는 그룹 없이 유지하고, 실제로 화면의 대상 방이 바뀐 경우를 별도 조회로 센다. `/packing/{roomId}`도 다른 동적 경로와 같이 정규화하여 GA URL에 원본 방 ID가 남지 않게 한다. + + +## 멤버 초대 발급과 방 정보 수정 + +- 기존 초대 코드 발급·재발급 UI는 활성 HOST와 MEMBER가 사용한다. 조회·공유는 기존 코드를 유지하며, 명시적 POST 성공은 새 코드 발급/기존 코드 교체를 뜻한다. `invite_code_issued`는 두 경우를 포괄하며 최초 발급인지 재발급인지 추측하지 않는다. 코드 누락 시 자동 fallback은 이 이벤트로 세지 않는다. +- `room_info_updated`는 서버 PATCH 성공 직후, 캐시 갱신·후속 일정 재조회 전에 한 번 기록한다. 요청 실패·취소·연속 클릭으로 차단된 요청·재조회는 성공 이벤트가 아니다. +- 두 이벤트는 Amplitude 전용이며 기존 `room_id`와 이벤트별 `groups.room_id`를 사용한다. GA4로 보내지 않는다. `role`은 `host` 또는 `member`: 방 정보 수정은 서버 성공 응답의 실제 역할, 발급은 요청 시작 시 서버 조회 캐시에 있는 역할을 사용한다. 발급 응답에는 역할이 없으므로 서버 처리 순간의 역할을 재검증했다고 해석하지 않는다. +- 요청 시작과 성공 시 모두 분석 동의·로그인 문맥이 유효해야 한다. 대기 중 계정/선택 방 변경, 세션 종료, 알려진 멤버십 상실, 관련 캐시 제거, 동의 철회가 발생하면 원래 문맥으로 돌아와도 해당 요청의 이벤트를 보내지 않는다. 홈 카드처럼 다른 방을 대상으로 한 행동도 요청 인자의 방 ID를 사용한다. +- 초대 코드/링크, 여행 제목·여행지·날짜 원문은 보내지 않는다. 새 초대자 링크 조회 이벤트는 추가하지 않는다. 기존 `share`는 HOST와 MEMBER 모두 복사/공유 성공 후 기록하며 기존 의미를 유지한다. diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx index 7b5adf67..109a470d 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx @@ -12,20 +12,23 @@ beforeEach(() => { vi.stubGlobal("window", { location: { origin: "https://example.test", pathname: "/member-settings" } }); }); afterEach(async () => { await act(async () => renderer?.unmount()); vi.unstubAllGlobals(); vi.clearAllMocks(); }); -async function mount() { - await act(async () => { renderer = create(); }); +async function mount(role: "HOST" | "MEMBER" = "HOST") { + await act(async () => { renderer = create(); }); } -it.each(["copy_link", "native_share"])("records the existing room only after %s succeeds", async (method) => { +it.each([ + ["copy_link", "HOST"], ["native_share", "HOST"], + ["copy_link", "MEMBER"], ["native_share", "MEMBER"], +] as const)("records the existing room only after %s succeeds for %s", async (method, role) => { let resolve!: () => void; const pending = new Promise(done => { resolve = done; }); vi.stubGlobal("navigator", { clipboard: { writeText: () => pending }, share: () => pending }); - await mount(); + await mount(role); const button = renderer.root.findAllByType("button").find(b => b.children.includes(method === "copy_link" ? "복사" : "공유"))!; await act(async () => { void button.props.onClick(); }); expect(commands).not.toHaveBeenCalled(); await act(async () => { resolve(); await pending; }); expect(commands).toHaveBeenCalledExactlyOnceWith("event", "share", { - room_id: "room-42", role: "host", member_count_bucket: "2", method, + room_id: "room-42", role: role.toLowerCase(), member_count_bucket: "2", method, page_path: "/member-settings", page_location: "https://example.test/member-settings", }); expect(JSON.stringify(commands.mock.calls)).not.toContain("private-access-code"); diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index 0b25c19c..04993a86 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useRegenerateInviteCode } from "@/hooks/useRooms"; -import { isHostRole, isRoomMemberRole } from "@/lib/rooms"; +import { isRoomMemberRole } from "@/lib/rooms"; import { bucketMemberCount, toAnalyticsRoomRole, @@ -19,8 +19,6 @@ type Props = { inviteCode: string | null | undefined; memberCount?: number; role?: string; - /** 방장 여부를 이미 계산한 화면은 넘긴다 — 없으면 `role`로 판단 */ - isHost?: boolean; isRoomDetailLoading: boolean; isRoomDetailError: boolean; /** 안내 문구 — 모달처럼 바깥에서 본문으로 보여줄 때는 false */ @@ -52,15 +50,14 @@ export function AddMemberPanel({ inviteCode, memberCount, role, - isHost, isRoomDetailLoading, isRoomDetailError, showDescription = true, }: Readonly) { const roomIdTrim = roomId.trim(); - // 초대 코드 발급·재발급 API는 방장 전용 — 참여자는 서버가 내려준 코드가 있을 때만 링크를 보여준다 const canShareInvite = isRoomMemberRole(role) && !isRoomDetailError; - const canIssue = canShareInvite && (isHost ?? isHostRole(role)); + const canIssue = canShareInvite && !isRoomDetailLoading; + const issueInFlight = useRef(false); const detailInviteCode = normalizeInviteCode(inviteCode); const [copied, setCopied] = useState(false); const copyTimerRef = useRef | null>(null); @@ -91,11 +88,14 @@ export function AddMemberPanel({ autoIssueAttemptedRoomRef.current = roomIdTrim; - regenerate(roomIdTrim, { + issueInFlight.current = true; + regenerate({ roomId: roomIdTrim, trigger: "automatic" }, { onSuccess: ({ inviteCode: newCode }) => { + issueInFlight.current = false; setIssuedCode(newCode); }, onError: () => { + issueInFlight.current = false; toast.error("초대 링크를 발급하지 못했어요."); }, }); @@ -166,13 +166,16 @@ export function AddMemberPanel({ } function handleRegenerate() { - if (!roomIdTrim.length || !canIssue || isRegenerating) return; + if (!roomIdTrim.length || !canIssue || isRegenerating || issueInFlight.current) return; setCopied(false); - regenerate(roomIdTrim, { + issueInFlight.current = true; + regenerate({ roomId: roomIdTrim, trigger: "explicit" }, { onSuccess: ({ inviteCode: newCode }) => { + issueInFlight.current = false; setIssuedCode(newCode); }, onError: () => { + issueInFlight.current = false; toast.error("초대 링크를 재발급하지 못했어요."); }, }); diff --git a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx index e9fd3da4..36c2bac3 100644 --- a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx +++ b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx @@ -37,7 +37,6 @@ export function RoomMembersSection() { inviteCode={roomDetail?.inviteCode} memberCount={roomDetail?.memberCount} role={roomDetail?.role} - isHost={isHost} isRoomDetailLoading={isDetailLoading} isRoomDetailError={isDetailError} /> diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx index 3232de7e..ade0e36e 100644 --- a/src/app/home/_components/RoomCardMenu.test.tsx +++ b/src/app/home/_components/RoomCardMenu.test.tsx @@ -80,21 +80,19 @@ it("issues a link once when the room detail confirms no existing code", async () state.detail!.inviteCode = null; await openInvite(); expect(state.regenerate).toHaveBeenCalledTimes(1); - expect(state.regenerate.mock.calls[0][0]).toBe("selected-room"); + expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "automatic" }); await act(async () => state.regenerate.mock.calls[0][1].onSuccess({ inviteCode: "first-code" })); await click("복사"); expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/first-code`); }); -it.each(["loading", "error", "member"])("does not issue links while detail is %s", async (mode) => { +it.each(["loading", "error"])("does not issue links while detail is %s", async (mode) => { state.detail!.inviteCode = null; state.loading = mode === "loading"; state.error = mode === "error"; - if (mode === "member") state.detail!.role = "MEMBER"; await openInvite(); expect(state.regenerate).not.toHaveBeenCalled(); - if (mode === "member") expect(button("복사").disabled).toBe(true); - else expect(button("복사")).toBeUndefined(); + expect(button("복사")).toBeUndefined(); if (mode === "error") { await click("다시 시도"); expect(state.refetch).toHaveBeenCalledOnce(); @@ -158,7 +156,7 @@ it("lets a member copy and share the existing link without issuing or regenerati expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`); await click("공유"); expect(state.copy).toHaveBeenCalledTimes(2); - expect(button("초대 링크 재발급")).toBeUndefined(); + expect(button("초대 링크 재발급")).toBeDefined(); expect(state.regenerate).not.toHaveBeenCalled(); }); @@ -169,10 +167,24 @@ it("does not expose an invite from a nonmember detail response", async () => { expect(state.regenerate).not.toHaveBeenCalled(); }); -it.each(["PENDING", undefined])("hides even a cached invite link for invalid role %s", async (role) => { +it.each(["PENDING", "LEFT", undefined])("hides even a cached invite link for invalid role %s", async (role) => { await act(async () => root.render()); expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull(); expect(button("복사").disabled).toBe(true); expect(button("초대 링크 재발급")).toBeUndefined(); expect(state.regenerate).not.toHaveBeenCalled(); }); + +it("lets MEMBER use the existing reissue button once for rapid clicks", async () => { + state.detail!.role = "MEMBER"; + await render("MEMBER"); await click("제주 여행 더보기"); await click("초대하기"); + await act(async () => { button("초대 링크 재발급").click(); button("초대 링크 재발급").click(); }); + expect(state.regenerate).toHaveBeenCalledTimes(1); + expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "explicit" }); +}); +it("allows MEMBER automatic fallback without presenting it as an explicit issuance", async () => { + state.detail!.role = "MEMBER"; state.detail!.inviteCode = null; + await render("MEMBER"); await click("제주 여행 더보기"); await click("초대하기"); + expect(state.regenerate).toHaveBeenCalledTimes(1); + expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "automatic" }); +}); diff --git a/src/components/layout/HeaderMemberPreview.tsx b/src/components/layout/HeaderMemberPreview.tsx index ae95d28e..6ee2033d 100644 --- a/src/components/layout/HeaderMemberPreview.tsx +++ b/src/components/layout/HeaderMemberPreview.tsx @@ -174,7 +174,6 @@ export function HeaderMemberPreview() { inviteCode={roomDetail?.inviteCode} memberCount={roomDetail?.memberCount} role={roomDetail?.role} - isHost={isHost} isRoomDetailLoading={isDetailLoading} isRoomDetailError={isDetailError} showDescription={false} diff --git a/src/components/rooms/RoomTripEditForm.test.tsx b/src/components/rooms/RoomTripEditForm.test.tsx index 527791f2..55e427d7 100644 --- a/src/components/rooms/RoomTripEditForm.test.tsx +++ b/src/components/rooms/RoomTripEditForm.test.tsx @@ -69,3 +69,21 @@ it("preserves drafts and original date floor on same-room refresh; cancel and ro expect(state.update).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); + +it("does not submit on cancel and submits once for rapid apply clicks", async () => { + state.update.mockClear(); + const container = document.createElement("div"); + const root = createRoot(container); + const room = { id: "r", title: "old", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" }; + await act(async () => root.render()); + await act(async () => state.fields!.onTitleChange("cancelled")); + await act(async () => container.querySelectorAll("button")[0].click()); + expect(state.update).not.toHaveBeenCalled(); + await act(async () => state.fields!.onTitleChange("new")); + await act(async () => { + container.querySelectorAll("button")[1].click(); + container.querySelectorAll("button")[1].click(); + }); + expect(state.update).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); +}); diff --git a/src/components/rooms/RoomTripEditForm.tsx b/src/components/rooms/RoomTripEditForm.tsx index 68c138a8..2d82f4c5 100644 --- a/src/components/rooms/RoomTripEditForm.tsx +++ b/src/components/rooms/RoomTripEditForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -85,7 +85,10 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop !isPending && isDirty; + const submitInFlight = useRef(false); const submitUpdate = useCallback(() => { + if (submitInFlight.current || readOnly) return; + submitInFlight.current = true; updateRoom( { roomId: room.id, @@ -99,6 +102,7 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop previousEndDate: saved.endDate, }, { + onSettled: () => { submitInFlight.current = false; }, onSuccess: () => { setShrinkConfirmOpen(false); toast.success("여행 정보가 수정되었어요"); @@ -116,6 +120,7 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop startDate, title, updateRoom, + readOnly, onSaved, ]); diff --git a/src/hooks/useRooms.analytics.test.tsx b/src/hooks/useRooms.analytics.test.tsx new file mode 100644 index 00000000..52075034 --- /dev/null +++ b/src/hooks/useRooms.analytics.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { useEffect } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useRegenerateInviteCode, useUpdateRoom } from "./useRooms"; +import { analyticsConsentStore } from "@/lib/analytics/consent-store"; +import { initializeAmplitude } from "@/lib/analytics/amplitude"; +import { initializeGoogleAnalytics } from "@/lib/analytics/client"; +import { roomDetailQueryKey, roomMembersQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; +import { useSessionStore } from "@/stores/session-store"; + +const calls = vi.hoisted(() => ({ + issue: vi.fn(), update: vi.fn(), hydrate: vi.fn(), + track: vi.fn(), initAll: vi.fn(async () => {}), setUserId: vi.fn(), setOptOut: vi.fn(), reset: vi.fn(), +})); +vi.mock("@/lib/api/rooms", async (original) => ({ + ...await original(), regenerateInviteCode: calls.issue, updateRoom: calls.update, +})); +vi.mock("@/lib/rooms", async (original) => ({ + ...await original(), hydrateRoomSchedulesFromServer: calls.hydrate, +})); +vi.mock("@amplitude/unified", () => ({ + track: calls.track, initAll: calls.initAll, setUserId: calls.setUserId, setOptOut: calls.setOptOut, reset: calls.reset, +})); +vi.mock("@/lib/analytics/amplitude-runtime", () => ({ amplitudeRuntime: { enabled: true, apiKey: "test", sessionReplaySampleRate: 0 } })); +vi.mock("@/lib/analytics/runtime", () => ({ analyticsRuntime: { enabled: true } })); + +let client: QueryClient; +let renderer: ReactTestRenderer; +let issue: ReturnType; +let update: ReturnType; +const detail = { id: "target-room", role: "MEMBER", inviteCode: "private-code", title: "private-title", destinations: ["private-place"], memberCount: 2, startDate: "2026-10-10", endDate: "2026-10-12", createdAt: "2026-10-10" }; +const variables = { roomId: detail.id, data: { title: "changed-private-title" } }; +const issueArgs = (trigger: "explicit" | "automatic" = "explicit") => ({ roomId: detail.id, trigger }); +function Harness() { + const issuance = useRegenerateInviteCode(); + const patch = useUpdateRoom(); + useEffect(() => { issue = issuance; update = patch; }, [issuance, patch]); + return null; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const events = () => calls.track.mock.calls.map(([value]) => value); +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + client.setQueryData(sessionUserQueryKey, { id: 17 }); + client.setQueryData(roomDetailQueryKey(detail.id), detail); + useSessionStore.setState({ sessionReady: true, currentRoomId: detail.id }); + analyticsConsentStore.set("granted"); + initializeAmplitude(); initializeGoogleAnalytics("G-TEST", false); + await Promise.resolve(); await Promise.resolve(); + calls.issue.mockResolvedValue({ inviteCode: "new-private-code" }); + calls.update.mockResolvedValue({ ...detail, title: "changed-private-title" }); + calls.hydrate.mockResolvedValue([]); + await act(async () => { renderer = create(); }); +}); +afterEach(async () => { + await act(async () => renderer.unmount()); + client.clear(); vi.unstubAllGlobals(); +}); + +it.each(["HOST", "MEMBER"])("records explicit %s issuance only after the API succeeds, with room group and no secret fields", async role => { + client.setQueryData(roomDetailQueryKey(detail.id), { ...detail, role }); + const pending = deferred<{ inviteCode: string }>(); calls.issue.mockReturnValue(pending.promise); + let result!: Promise; + await act(async () => { result = issue.mutateAsync(issueArgs()); }); + expect(events()).toEqual([]); + await act(async () => { pending.resolve({ inviteCode: "new-private-code" }); await result; }); + expect(events()).toEqual([{ event_type: "invite_code_issued", event_properties: { room_id: detail.id, role: role.toLowerCase(), page_path: "/", page_location: window.location.origin + "/" }, groups: { room_id: detail.id } }]); + expect(JSON.stringify(events())).not.toMatch(/private-code|private-title|private-place/); + expect((window.dataLayer ?? []).map(x => Array.from(x as ArrayLike)).filter(x => x[0] === "event")).not.toContainEqual(expect.arrayContaining(["invite_code_issued"])); +}); +it("does not count automatic fallback or failed issuance", async () => { + await act(async () => { await issue.mutateAsync(issueArgs("automatic")); }); + calls.issue.mockRejectedValue(new Error("forbidden")); + await act(async () => { await expect(issue.mutateAsync(issueArgs())).rejects.toThrow("forbidden"); }); + expect(events()).toEqual([]); +}); +it("uses the actual PATCH response role and counts success before a failing follow-up refresh", async () => { + client.setQueryData(roomDetailQueryKey(detail.id), { ...detail, role: "HOST" }); + calls.hydrate.mockRejectedValue(new Error("refresh failed")); + await act(async () => { await expect(update.mutateAsync({ ...variables, data: { endDate: "2026-10-13" } })).rejects.toThrow("refresh failed"); }); + expect(events()).toEqual([{ event_type: "room_info_updated", event_properties: { room_id: detail.id, role: "member", page_path: "/", page_location: window.location.origin + "/" }, groups: { room_id: detail.id } }]); +}); +it("does not record PATCH failures", async () => { + calls.update.mockRejectedValue(new Error("invalid dates")); + await act(async () => { await expect(update.mutateAsync(variables)).rejects.toThrow("invalid dates"); }); + expect(events()).toEqual([]); +}); +it.each(["issue", "update"])("does not backfill %s started without consent", async kind => { + analyticsConsentStore.set("denied"); + const pending = deferred(); + (kind === "issue" ? calls.issue : calls.update).mockReturnValue(pending.promise); + let result!: Promise; + await act(async () => { result = kind === "issue" ? issue.mutateAsync(issueArgs()) : update.mutateAsync(variables); }); + analyticsConsentStore.set("granted"); + await act(async () => { pending.resolve(detail); await result; }); + expect(events()).toEqual([]); +}); +it.each(["account", "logout", "room", "permission", "consent"])("drops pending success permanently after %s roundtrip", async change => { + const pending = deferred(); calls.update.mockReturnValue(pending.promise); + let result!: Promise; + await act(async () => { result = update.mutateAsync(variables); }); + if (change === "account") { client.setQueryData(sessionUserQueryKey, { id: 99 }); client.setQueryData(sessionUserQueryKey, { id: 17 }); } + if (change === "logout") { useSessionStore.setState({ sessionReady: false }); useSessionStore.setState({ sessionReady: true }); } + if (change === "room") { useSessionStore.setState({ currentRoomId: "other" }); useSessionStore.setState({ currentRoomId: detail.id }); } + if (change === "permission") { client.removeQueries({ queryKey: roomDetailQueryKey(detail.id) }); client.setQueryData(roomDetailQueryKey(detail.id), detail); } + if (change === "consent") { analyticsConsentStore.set("denied"); analyticsConsentStore.set("granted"); } + await act(async () => { pending.resolve(detail); await result; }); + expect(events()).toEqual([]); +}); +it("uses the requested home-card room instead of injecting the selected room", async () => { + useSessionStore.setState({ currentRoomId: "another-selected-room" }); + await act(async () => { await issue.mutateAsync(issueArgs()); }); + expect(events()[0]).toMatchObject({ event_type: "invite_code_issued", groups: { room_id: detail.id } }); +}); + +it.each(["PENDING", "LEFT", undefined])("does not attribute issuance to invalid role %s", async role => { + client.setQueryData(roomDetailQueryKey(detail.id), { ...detail, role }); + await act(async () => { await issue.mutateAsync(issueArgs()); }); + expect(events()).toEqual([]); +}); +it("does not attribute to a logged-out session even if a stale request succeeds", async () => { + useSessionStore.setState({ sessionReady: false }); + await act(async () => { await update.mutateAsync(variables); }); + expect(events()).toEqual([]); +}); +it("drops issuance when a known membership becomes LEFT while the request is pending", async () => { + const pending = deferred<{ inviteCode: string }>(); calls.issue.mockReturnValue(pending.promise); + let result!: Promise; + await act(async () => { result = issue.mutateAsync(issueArgs()); }); + client.setQueryData(roomMembersQueryKey(detail.id), { members: [{ userId: 17, role: "MEMBER", status: "LEFT" }] }); + await act(async () => { pending.resolve({ inviteCode: "new-private-code" }); await result; }); + expect(events()).toEqual([]); +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index eae9520d..f6668bc8 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -1,3 +1,5 @@ +import { AnalyticsEvents } from "@/lib/analytics/track"; +import { trackRoomMutation } from "@/lib/analytics/room-mutation"; import { beginExpenseRoomAdmission } from "@/lib/expenses/expense-recovery"; import { joinStatusForWaitingUi } from "@/lib/join-room-workflow"; import { invalidateExpenses, expenseKeys } from "@/lib/expenses/expense-queries"; @@ -510,7 +512,9 @@ export function useUpdateRoom() { data: RoomUpdateRequest; previousStartDate?: string | null; previousEndDate?: string | null; - }) => updateRoom(roomId, data), + }) => trackRoomMutation(queryClient, roomId, AnalyticsEvents.roomInfoUpdated, + () => updateRoom(roomId, data), result => result.role), + retry: false, onSuccess: async ( updated, { roomId, data, previousStartDate, previousEndDate }, @@ -575,8 +579,12 @@ export function useRegenerateInviteCode() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (roomId: string) => regenerateInviteCode(roomId), - onSuccess: (data, roomId) => { + mutationFn: ({ roomId, trigger }: { roomId: string; trigger: "explicit" | "automatic" }) => + trigger === "automatic" ? regenerateInviteCode(roomId) : + trackRoomMutation(queryClient, roomId, AnalyticsEvents.inviteCodeIssued, + () => regenerateInviteCode(roomId)), + retry: false, + onSuccess: (data, { roomId }) => { queryClient.setQueryData( roomDetailQueryKey(roomId), (prev) => (prev ? { ...prev, inviteCode: data.inviteCode } : prev), diff --git a/src/lib/analytics/event-destinations.ts b/src/lib/analytics/event-destinations.ts index c28e51a7..f1e8b75e 100644 --- a/src/lib/analytics/event-destinations.ts +++ b/src/lib/analytics/event-destinations.ts @@ -12,6 +12,8 @@ const eventDestinations = { join_group: "both", view_plan: "both", share: "both", + invite_code_issued: "amplitude", + room_info_updated: "amplitude", add_to_bookmark: "both", add_to_itinerary: "both", expense_created: "both", diff --git a/src/lib/analytics/event-routing.test.ts b/src/lib/analytics/event-routing.test.ts index 2bee3237..4da2bf55 100644 --- a/src/lib/analytics/event-routing.test.ts +++ b/src/lib/analytics/event-routing.test.ts @@ -17,6 +17,7 @@ afterEach(() => { vi.resetModules(); vi.resetAllMocks(); vi.unstubAllGlobals(); it.each([ ["sign_up", "both"], ["login", "both"], ["create_plan", "both"], ["invite_view", "both"], ["join_group", "both"], ["view_plan", "both"], + ["invite_code_issued", "amplitude"], ["room_info_updated", "amplitude"], ["share", "both"], ["add_to_bookmark", "both"], ["add_to_itinerary", "both"], ["expense_created", "both"], ["packing_item_added", "both"], ["page_view", "both"], ["view_search_results", "amplitude"], ["view_place", "amplitude"], diff --git a/src/lib/analytics/room-events.ts b/src/lib/analytics/room-events.ts index a2d0ffe7..3c544848 100644 --- a/src/lib/analytics/room-events.ts +++ b/src/lib/analytics/room-events.ts @@ -11,6 +11,8 @@ const roomScopes = { cta_click: "none", section_view: "none", share: "required", + invite_code_issued: "required", + room_info_updated: "required", join_group: "required", create_plan: "required", view_plan: "required", diff --git a/src/lib/analytics/room-mutation.ts b/src/lib/analytics/room-mutation.ts new file mode 100644 index 00000000..1c551718 --- /dev/null +++ b/src/lib/analytics/room-mutation.ts @@ -0,0 +1,64 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { RoomDetail, RoomListResponse, RoomMemberListResponse } from "@/lib/api/rooms/types"; +import { roomDetailQueryKey, roomMembersQueryKey, ROOMS_QUERY_KEY, sessionUserQueryKey } from "@/lib/query-keys"; +import { readSessionUserId } from "@/lib/session-user-cache"; +import { useSessionStore } from "@/stores/session-store"; +import { analyticsConsentStore } from "./consent-store"; +import { toAnalyticsRoomRole } from "./context"; +import { AnalyticsEvents, trackAnalyticsEvent } from "./track"; + +type RoomMutationEvent = typeof AnalyticsEvents.inviteCodeIssued | typeof AnalyticsEvents.roomInfoUpdated; + +function roomRole(client: QueryClient, roomId: string) { + const detail = client.getQueryData(roomDetailQueryKey(roomId)); + const summary = client.getQueryData(ROOMS_QUERY_KEY)?.rooms.find(room => room.id === roomId); + return toAnalyticsRoomRole(detail?.role ?? summary?.role); +} + +/** Only these room writes use this scope; never inject the selected room into events. */ +export async function trackRoomMutation( + client: QueryClient, + roomId: string, + event: RoomMutationEvent, + action: () => Promise, + responseRole?: (result: Result) => string, +): Promise { + const actor = readSessionUserId(client); + const selectedRoom = useSessionStore.getState().currentRoomId; + const role = roomRole(client, roomId); + let active = actor !== undefined && Boolean(role) && + useSessionStore.getState().sessionReady && analyticsConsentStore.isGranted(); + const checkScope = () => { + const session = useSessionStore.getState(); + const member = client.getQueryData(roomMembersQueryKey(roomId)) + ?.members.find(member => member.userId === actor); + if (!session.sessionReady || session.currentRoomId !== selectedRoom || + readSessionUserId(client) !== actor || !analyticsConsentStore.isGranted() || + !roomRole(client, roomId) || (member && (member.status !== "ACTIVE" || !toAnalyticsRoomRole(member.role)))) { + active = false; + } + }; + checkScope(); + const watchedKeys = [sessionUserQueryKey, roomDetailQueryKey(roomId), roomMembersQueryKey(roomId), ROOMS_QUERY_KEY] + .map(key => JSON.stringify(key)); + const unsubscribeCache = client.getQueryCache().subscribe(change => { + if (!watchedKeys.includes(JSON.stringify(change.query.queryKey))) return; + if (change.type === "removed") active = false; + checkScope(); + }); + const unsubscribeSession = useSessionStore.subscribe(checkScope); + const unsubscribeConsent = analyticsConsentStore.subscribe(checkScope); + try { + const result = await action(); + checkScope(); + const actualRole = responseRole ? toAnalyticsRoomRole(responseRole(result)) : role; + if (active && actualRole) { + trackAnalyticsEvent(event, { room_id: roomId, role: actualRole }); + } + return result; + } finally { + unsubscribeCache(); + unsubscribeSession(); + unsubscribeConsent(); + } +} diff --git a/src/lib/analytics/track.ts b/src/lib/analytics/track.ts index e569ad45..fe05ae69 100644 --- a/src/lib/analytics/track.ts +++ b/src/lib/analytics/track.ts @@ -47,6 +47,8 @@ export const AnalyticsEvents = { reorderItinerary: "reorder_itinerary", search: "view_search_results", sharePlan: "share", + inviteCodeIssued: "invite_code_issued", + roomInfoUpdated: "room_info_updated", chatMessageSent: "chat_message_sent", tutorialBegin: "tutorial_begin", tutorialComplete: "tutorial_complete", @@ -84,6 +86,8 @@ export type LandingSectionId = | "final_cta"; export type AnalyticsEventParamsMap = { + [AnalyticsEvents.inviteCodeIssued]: { room_id: string; role: AnalyticsRoomRole }; + [AnalyticsEvents.roomInfoUpdated]: { room_id: string; role: AnalyticsRoomRole }; [AnalyticsEvents.expenseCreated]: { room_id: string }; [AnalyticsEvents.expenseUpdated]: { room_id: string }; [AnalyticsEvents.expenseDeleted]: { room_id: string };