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: 9 additions & 0 deletions docs/analytics/ga4-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@

운영자가 만드는 UTM 값에는 이름, 이메일, 전화번호, 검색어 등 개인을 식별하거나 사용자가 입력한 값을 넣지 않는다. 허용 키를 추가하려면 실제 분석 목적과 개인정보 처리방침의 수집 범위를 먼저 검토한다.

## 초대 링크 캠페인 보존

- 복사·기본 공유 시 외부 초대 링크에 `utm_source=member_invite`, `utm_medium=referral`, `utm_campaign=invite`를 붙인다. 플랫폼이나 초대한 개인을 구분하는 값이 아니다.
- 링크에 기존 `utm_*`가 하나라도 있으면 빈 값·불완전한 캠페인도 그대로 두고 기본 캠페인을 채우거나 섞지 않는다.
- 초대 진입 URL의 허용된 UTM만 초대 코드와 함께 같은 탭의 pending invite 문맥으로 보존한다. 로그인·약관 동의·오류 재시도와 인증 후 초대 복귀에 전달하고, 인증 완료의 복귀 주소를 만든 뒤 소비한다. 다른 초대가 들어오면 이전 문맥을 교체하며 인증된 초대 진입에서도 남은 문맥을 정리한다.
- 이 저장은 초대 인증 이동의 URL 문맥이며 이벤트 큐가 아니다. 동의 전 이벤트를 저장하거나 소급 전송하지 않는다. 일반 로그인·다른 내부 탐색에는 기본 캠페인을 만들지 않는다.
- OAuth state와 인증 토큰에 캠페인을 싣지 않는다. 원래 referrer 복원, 브라우저 간 연결, 초대 코드·초대자 ID의 분석 전송은 하지 않는다. 분석 URL 정규화와 User-ID 설정·해제는 기존 정책을 유지한다.
- 외부 브라우저가 같은 탭 저장소를 공유한다고 가정하지 않는다. 이 코드만으로 GA 세션 귀속이나 신규 사용자 집계가 소급 변경되는 것은 보장하지 않는다. 인증 도메인 referral 제외 등 운영 설정은 별도 관리한다.

## 배포 환경 변수

프로덕션에는 운영 데이터 스트림의 측정 ID만 설정한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,15 @@ it("does not record a rejected copy or cancelled native share", async () => {
}
expect(commands).not.toHaveBeenCalled();
});
it.each(["복사", "공유"])("provides the same tagged external URL to %s", async label => {
const writeText = vi.fn().mockResolvedValue(undefined);
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText }, share });
await mount();
const button = renderer.root.findAllByType("button").find(b => b.children.includes(label))!;
await act(async () => { await button.props.onClick(); });
const url = "https://example.test/join/private-access-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite";
if (label === "복사") expect(writeText).toHaveBeenCalledWith(url);
else expect(share).toHaveBeenCalledWith({ title: "우때 여행 초대", url });
expect(JSON.stringify(commands.mock.calls)).not.toContain("private-access-code");
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { buildSharedInviteUrl } from "@/lib/invite-campaign";

import { CopyCheckIcon, CopyIcon, RefreshIcon, ShareIcon } from "@/assets/icons";
import { Spinner } from "@/components/ui/spinner";

Expand Down Expand Up @@ -113,7 +115,7 @@
const displayedCode =
issuedCode === undefined ? detailInviteCode || null : issuedCode;
const inviteUrl = canShareInvite && displayedCode
? `${typeof window !== "undefined" ? window.location.origin : ""}/join/${displayedCode}`
? buildSharedInviteUrl(`${typeof window !== "undefined" ? window.location.origin : ""}/join/${encodeURIComponent(displayedCode)}`)

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaGlcwYCCbXmea9iAyGy&open=AaGlcwYCCbXmea9iAyGy&pullRequest=255
: null;

async function handleCopy() {
Expand Down
13 changes: 7 additions & 6 deletions src/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
loginErrorQueryForExchangeFailure,
saveGoogleAgreementFlowSession,
} from "@/lib/google-oauth";
import { pendingInviteAuthPath } from "@/lib/session-user-flow";
import { finishGoogleLogin } from "@/lib/google-login-client";

function AuthCallbackContent() {
Expand All @@ -37,15 +38,15 @@ function AuthCallbackContent() {
!pending ||
returnedState !== pending.state
) {
router.replace("/login?error=OAuthCallback");
router.replace(pendingInviteAuthPath("/login?error=OAuthCallback"));
return;
}

exchangeGoogleCode(code, pending.agreementsAccepted)
.then(async (result) => {
if (result.ok && result.status === "AUTHENTICATED") {
const destination = await finishGoogleLogin(queryClient);
router.replace(destination ?? "/login?error=OAuthCallback");
router.replace(destination ?? pendingInviteAuthPath("/login?error=OAuthCallback"));
return;
}

Expand All @@ -55,7 +56,7 @@ function AuthCallbackContent() {
signupToken: result.signupToken,
expiresAt: Date.now() + result.expiresInSeconds * 1000,
});
router.replace("/login/agreements");
router.replace(pendingInviteAuthPath("/login/agreements"));
return;
}

Expand All @@ -65,15 +66,15 @@ function AuthCallbackContent() {
reacceptanceToken: result.reacceptanceToken,
expiresAt: Date.now() + result.expiresInSeconds * 1000,
});
router.replace("/login/agreements");
router.replace(pendingInviteAuthPath("/login/agreements"));
return;
}

const errorQuery = loginErrorQueryForExchangeFailure(result.errorCode);
router.replace(`/login?error=${errorQuery}`);
router.replace(pendingInviteAuthPath(`/login?error=${errorQuery}`));
})
.catch(() => {
router.replace("/login?error=OAuthCallback");
router.replace(pendingInviteAuthPath("/login?error=OAuthCallback"));
});
}, [searchParams, router, queryClient]);

Expand Down
14 changes: 7 additions & 7 deletions src/app/home/_components/RoomCardMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ it("opens the selected room's invite link without regenerating it and restores f
expect(state.detailId).toHaveBeenCalledWith("selected-room");
expect(state.regenerate).not.toHaveBeenCalled();
await click("복사");
expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/existing-code`);
expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
expect(button("복사됨")).toBeDefined();
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
await flush();
Expand All @@ -87,7 +87,7 @@ it("issues a link once when the room detail confirms no existing code", async ()
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`);
expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/first-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});

it.each(["loading", "error"])("does not issue links while detail is %s", async (mode) => {
Expand Down Expand Up @@ -115,7 +115,7 @@ it.each(["loading", "error"])("does not issue links while detail is %s", async (
await render();
expect(button("다시 시도")).toBeUndefined();
await click("복사");
expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code`);
expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
expect(state.regenerate).not.toHaveBeenCalled();
});

Expand All @@ -133,11 +133,11 @@ it("regenerates only on request and keeps the existing link when regeneration fa
expect(state.regenerate).toHaveBeenCalledTimes(1);
await act(async () => state.regenerate.mock.calls[0][1].onError());
await click("복사");
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`);
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
await click("초대 링크 재발급");
await act(async () => state.regenerate.mock.calls[1][1].onSuccess({ inviteCode: "new-code" }));
await click("복사");
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code`);
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});

it("blocks copying and sharing the old link while regeneration is pending", async () => {
Expand All @@ -152,7 +152,7 @@ it("blocks copying and sharing the old link while regeneration is pending", asyn
await act(async () => state.regenerate.mock.calls[0][1].onSuccess({ inviteCode: "new-code" }));
await render();
await click("복사");
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code`);
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});

it("preserves host deletion and member leave permissions", async () => {
Expand All @@ -173,7 +173,7 @@ it("lets a member copy and share the existing link without issuing or regenerati
await render("MEMBER");
await click("제주 여행 더보기"); await click("초대하기");
await click("복사");
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`);
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
await click("공유");
expect(state.copy).toHaveBeenCalledTimes(2);
expect(button("초대 링크 재발급")).toBeDefined();
Expand Down
112 changes: 112 additions & 0 deletions src/app/invite-auth-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { useEffect, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import JoinPage from "./join/[inviteCode]/page";
import AuthCallbackPage from "./auth/callback/page";
import LoginAgreementsPage from "./login/agreements/page";
import { saveOAuthPendingSession, readGoogleAgreementFlowSession, buildGoogleAuthorizationUrl } from "@/lib/google-oauth";
import { pendingInviteAuthPath, setPendingInviteCode, consumePendingInviteCode } from "@/lib/session-user-flow";

const ports = vi.hoisted(() => ({
router: { replace: vi.fn() },
code: "test-invite",
exchange: vi.fn(), signup: vi.fn(), reaccept: vi.fn(), join: vi.fn(),
readUser: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ports.router,
useSearchParams: () => new URLSearchParams(window.location.search),
useParams: () => ({ inviteCode: ports.code }),
}));
vi.mock("@/lib/api/auth", () => ({
exchangeGoogleCode: ports.exchange,
completeGoogleSignup: ports.signup,
completeGoogleReaccept: ports.reaccept,
}));
vi.mock("@/lib/session-user", () => ({ fetchSessionUserWithRetry: ports.readUser }));
vi.mock("@/hooks/useRooms", () => ({ useJoinRoom: () => ({ mutate: ports.join }) }));
vi.mock("@/components/BrandLogo", () => ({ BrandLogo: () => null }));
vi.mock("@/components/agreements/AgreementConsentSection", () => ({
AgreementConsentSection: ({ onStateChange }: { onStateChange: (state: { isLoading: boolean; isError: boolean; canProceed: boolean }) => void }) => {
useEffect(() => onStateChange({ isLoading: false, isError: false, canProceed: true }), [onStateChange]);
return null;
},
}));
let client: QueryClient;
let renderer: ReactTestRenderer | undefined;
async function mount(node: ReactNode, path: string) {
if (renderer) await act(async () => renderer?.unmount());
window.history.replaceState(null, "", path);
await act(async () => { renderer = create(<QueryClientProvider client={client}>{node}</QueryClientProvider>); });
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
client = new QueryClient();
sessionStorage.clear();
ports.code = "test-invite";
ports.readUser.mockResolvedValue({ id: 42, email: "fixture@example.test", nickname: "fixture", profileImageUrl: null, provider: "GOOGLE", tutorialCompleted: false });
ports.signup.mockResolvedValue({ ok: true });
ports.reaccept.mockResolvedValue({ ok: true });
});
afterEach(async () => {
await act(async () => renderer?.unmount());
renderer = undefined;
client.clear();
vi.unstubAllGlobals(); vi.clearAllMocks();
});
const campaign = "utm_source=member_invite&utm_medium=referral&utm_campaign=invite";
it("carries only incoming campaign parameters from the invite page to login", async () => {
await mount(<JoinPage />, `/join/test-invite?${campaign}&code=secret&state=secret`);
expect(ports.router.replace).toHaveBeenCalledWith(`/login?${campaign}`);
expect(pendingInviteAuthPath("/login/agreements")).toBe(`/login/agreements?${campaign}`);
});
it.each(["signup", "reaccept"] as const)("carries the original partial campaign through callback, %s and invite return", async kind => {
setPendingInviteCode("test-invite", "?utm_campaign=existing");
saveOAuthPendingSession({ state: "expected-state" });
ports.exchange.mockResolvedValue(kind === "signup"
? { ok: true, status: "SIGNUP_REQUIRED", signupToken: "test-signup", expiresInSeconds: 600 }
: { ok: true, status: "REACCEPTANCE_REQUIRED", reacceptanceToken: "test-reaccept", expiresInSeconds: 600 });
await mount(<AuthCallbackPage />, "/auth/callback?code=test-code&state=expected-state");
expect(ports.exchange).toHaveBeenCalledExactlyOnceWith("test-code", undefined);
expect(ports.router.replace).toHaveBeenLastCalledWith("/login/agreements?utm_campaign=existing");
expect(readGoogleAgreementFlowSession()?.kind).toBe(kind);
await mount(<LoginAgreementsPage />, "/login/agreements?utm_campaign=existing");
const button = renderer!.root.findAllByType("button").find(b => b.children.includes(kind === "signup" ? "동의하고 가입하기" : "동의하고 계속하기"))!;
expect(button.props.disabled).toBe(false);
await act(async () => { button.props.onClick(); });
expect(ports.router.replace).toHaveBeenLastCalledWith("/join/test-invite?utm_campaign=existing");
expect(kind === "signup" ? ports.signup : ports.reaccept).toHaveBeenCalledExactlyOnceWith(kind === "signup" ? "test-signup" : "test-reaccept");
expect(consumePendingInviteCode()).toBeNull();
expect(pendingInviteAuthPath("/login")).toBe("/login");
});
it("keeps state validation before any code exchange and preserves retry context", async () => {
setPendingInviteCode("test-invite", `?${campaign}`);
saveOAuthPendingSession({ state: "expected-state" });
await mount(<AuthCallbackPage />, "/auth/callback?code=test-code&state=wrong-state");
expect(ports.exchange).not.toHaveBeenCalled();
expect(ports.router.replace).toHaveBeenCalledWith(`/login?error=OAuthCallback&${campaign}`);
expect(sessionStorage.getItem("oauth_pending")).toBeNull();
expect(buildGoogleAuthorizationUrl()).not.toContain("utm_");
});
it("preserves retry context after exchange rejection", async () => {
setPendingInviteCode("test-invite", "?utm_source=partner");
saveOAuthPendingSession({ state: "expected-state" });
ports.exchange.mockRejectedValue(new Error("test network failure"));
await mount(<AuthCallbackPage />, "/auth/callback?code=test-code&state=expected-state");
expect(ports.router.replace).toHaveBeenLastCalledWith("/login?error=OAuthCallback&utm_source=partner");
});
it("removes stale pending context when an authenticated user opens another invitation", async () => {
setPendingInviteCode("previous-invite", `?${campaign}`);
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 200 }));
await mount(<JoinPage />, "/join/test-invite");
expect(ports.join).toHaveBeenCalledWith("test-invite", expect.any(Object));
expect(consumePendingInviteCode()).toBeNull();
expect(pendingInviteAuthPath("/login")).toBe("/login");
});
it("keeps ordinary unauthenticated invitation navigation untagged", async () => {
await mount(<JoinPage />, "/join/test-invite");
expect(ports.router.replace).toHaveBeenCalledWith("/login");
});
8 changes: 6 additions & 2 deletions src/app/join/[inviteCode]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
import { BrandLogo } from "@/components/BrandLogo";

import { checkClientAuthenticated, setPendingInviteCode } from "@/lib/auth";
import { consumePendingInviteCode, pendingInviteAuthPath } from "@/lib/session-user-flow";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { useJoinRoom } from "@/hooks/useRooms";
import { getRoomDetail } from "@/lib/api/rooms";
Expand Down Expand Up @@ -36,18 +37,21 @@ export default function JoinPage() {
});
}

const entrySearch = window.location.search;
let cancelled = false;

void (async () => {
const authenticated = await checkClientAuthenticated();
if (cancelled) return;

if (!authenticated) {
setPendingInviteCode(inviteCode);
router.replace("/login");
setPendingInviteCode(inviteCode, entrySearch);
router.replace(pendingInviteAuthPath("/login"));
return;
}

// An authenticated re-entry completes the detour (or replaces an older invite).
consumePendingInviteCode();
join(inviteCode, {
onSuccess: async (data) => {
if (data.httpStatus === 200) {
Expand Down
7 changes: 4 additions & 3 deletions src/app/login/agreements/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
GOOGLE_REACCEPTANCE_TOKEN_NOT_FOUND_ERROR_CODE,
GOOGLE_SIGNUP_TOKEN_NOT_FOUND_ERROR_CODE,
} from "@/lib/api/errors";
import { pendingInviteAuthPath } from "@/lib/session-user-flow";
import { finishGoogleLogin } from "@/lib/google-login-client";
import {
clearGoogleAgreementFlowSession,
Expand Down Expand Up @@ -50,7 +51,7 @@ export default function LoginAgreementsPage() {
useEffect(() => {
const storedFlow = readGoogleAgreementFlowSession();
if (!storedFlow) {
router.replace("/login");
router.replace(pendingInviteAuthPath("/login"));
return;
}

Expand All @@ -69,7 +70,7 @@ export default function LoginAgreementsPage() {

const returnToLogin = () => {
clearGoogleAgreementFlowSession();
router.replace("/login");
router.replace(pendingInviteAuthPath("/login"));
};

const handleSubmit = async () => {
Expand Down Expand Up @@ -118,7 +119,7 @@ export default function LoginAgreementsPage() {
}

const destination = await finishGoogleLogin(queryClient);
router.replace(destination ?? "/login?error=OAuthCallback");
router.replace(destination ?? pendingInviteAuthPath("/login?error=OAuthCallback"));
} catch {
setErrorMessage(
flow.kind === "reaccept"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/analytics/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const dynamicPagePaths: ReadonlyArray<readonly [RegExp, string]> = [
[/^\/bookmark\/[^/]+$/, "/bookmark/[folderId]"],
];

const analyticsCampaignParameterNames = [
export const analyticsCampaignParameterNames = [
"utm_id",
"utm_source",
"utm_medium",
Expand Down
Loading
Loading