Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/analytics/amplitude-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`별 이탈을 분석한다.

Expand All @@ -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가 보이는 것만으로 그룹 퍼널이 설정된 것은 아니다.

그룹 분석은 생성·조회·일정·북마크·채팅·가계부·준비물 전반에 사용할 수 있다. 검색·장소 상세·튜토리얼·페이지 조회는 확정된 방 문맥에서만 그룹을 가진다. 준비물은 개인 기능이며 이를 공동 편집 활성의 근거로 삼지 않는다. 협업 지표에는 적절한 이벤트 집합과 서로 다른 참여자 조건을 별도로 정의한다.

Expand All @@ -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`의 두 역할 모두를 포함한다. 이 문서는 소스 계약이며 배포나 실제 수신을 확인했다는 의미가 아니다.
17 changes: 14 additions & 3 deletions docs/analytics/amplitude-tracking-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 그룹 보고 기능의 계약/활성화와 퍼널 구성은 별도로 필요하다. 준비물은 여행방 공동 작업이 아닌 개인 사용이다.

## 여행방 단위 그룹 분석

Expand All @@ -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`만으로 기존 멤버 재진입과 신규 합류를 엄밀히 구분하거나 서버 기준으로 중복 제거할 수 없다. 정확한 신규 참여 전환율에는 백엔드의 멤버십 확정 이벤트가 추가로 필요하다.

Expand All @@ -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 모두 복사/공유 성공 후 기록하며 기존 의미를 유지한다.
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AddMemberPanel roomId="room-42" inviteCode="private-access-code" memberCount={2} role="HOST" isRoomDetailLoading={false} isRoomDetailError={false} />); });
async function mount(role: "HOST" | "MEMBER" = "HOST") {
await act(async () => { renderer = create(<AddMemberPanel roomId="room-42" inviteCode="private-access-code" memberCount={2} role={role} isRoomDetailLoading={false} isRoomDetailError={false} />); });
}
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<void>(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");
Expand Down
21 changes: 12 additions & 9 deletions src/app/(main)/member-settings/_components/AddMemberPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { toast } from "sonner";

import { useRegenerateInviteCode } from "@/hooks/useRooms";
import { isHostRole, isRoomMemberRole } from "@/lib/rooms";
import { isRoomMemberRole } from "@/lib/rooms";
import {
bucketMemberCount,
toAnalyticsRoomRole,
Expand All @@ -19,8 +19,6 @@
inviteCode: string | null | undefined;
memberCount?: number;
role?: string;
/** 방장 여부를 이미 계산한 화면은 넘긴다 — 없으면 `role`로 판단 */
isHost?: boolean;
isRoomDetailLoading: boolean;
isRoomDetailError: boolean;
/** 안내 문구 — 모달처럼 바깥에서 본문으로 보여줄 때는 false */
Expand All @@ -47,20 +45,19 @@
return <span className="truncate text-body-s-regular mobile:text-body-xs-regular text-text-subtle">{message}</span>;
}

export function AddMemberPanel({

Check failure on line 48 in src/app/(main)/member-settings/_components/AddMemberPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaElfXMkn3atgy46GKSq&open=AaElfXMkn3atgy46GKSq&pullRequest=227
roomId,
inviteCode,
memberCount,
role,
isHost,
isRoomDetailLoading,
isRoomDetailError,
showDescription = true,
}: Readonly<Props>) {
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<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -91,11 +88,14 @@

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("초대 링크를 발급하지 못했어요.");
},
});
Expand Down Expand Up @@ -166,13 +166,16 @@
}

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("초대 링크를 재발급하지 못했어요.");
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export function RoomMembersSection() {
inviteCode={roomDetail?.inviteCode}
memberCount={roomDetail?.memberCount}
role={roomDetail?.role}
isHost={isHost}
isRoomDetailLoading={isDetailLoading}
isRoomDetailError={isDetailError}
/>
Expand Down
Loading
Loading