diff --git a/docs/analytics/ga4-operations.md b/docs/analytics/ga4-operations.md index 32dd0c0c..316e365c 100644 --- a/docs/analytics/ga4-operations.md +++ b/docs/analytics/ga4-operations.md @@ -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만 설정한다. 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 109a470d..898bc265 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx @@ -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"); +}); diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index c8a91c7f..985e150b 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -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"; @@ -113,7 +115,7 @@ export function AddMemberPanel({ 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)}`) : null; async function handleCopy() { diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx index 4ae95d5a..42c3374d 100644 --- a/src/app/auth/callback/page.tsx +++ b/src/app/auth/callback/page.tsx @@ -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() { @@ -37,7 +38,7 @@ function AuthCallbackContent() { !pending || returnedState !== pending.state ) { - router.replace("/login?error=OAuthCallback"); + router.replace(pendingInviteAuthPath("/login?error=OAuthCallback")); return; } @@ -45,7 +46,7 @@ function AuthCallbackContent() { .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; } @@ -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; } @@ -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]); diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx index 0b278a1e..bd1e469c 100644 --- a/src/app/home/_components/RoomCardMenu.test.tsx +++ b/src/app/home/_components/RoomCardMenu.test.tsx @@ -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(); @@ -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) => { @@ -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(); }); @@ -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 () => { @@ -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 () => { @@ -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(); diff --git a/src/app/invite-auth-flow.test.tsx b/src/app/invite-auth-flow.test.tsx new file mode 100644 index 00000000..f0eeec5f --- /dev/null +++ b/src/app/invite-auth-flow.test.tsx @@ -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({node}); }); +} +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(, `/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(, "/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(, "/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(, "/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(, "/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(, "/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(, "/join/test-invite"); + expect(ports.router.replace).toHaveBeenCalledWith("/login"); +}); diff --git a/src/app/join/[inviteCode]/page.tsx b/src/app/join/[inviteCode]/page.tsx index 69615cc8..02c88ee3 100644 --- a/src/app/join/[inviteCode]/page.tsx +++ b/src/app/join/[inviteCode]/page.tsx @@ -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"; @@ -36,6 +37,7 @@ export default function JoinPage() { }); } + const entrySearch = window.location.search; let cancelled = false; void (async () => { @@ -43,11 +45,13 @@ export default function JoinPage() { 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) { diff --git a/src/app/login/agreements/page.tsx b/src/app/login/agreements/page.tsx index 1f68261e..8aa44c87 100644 --- a/src/app/login/agreements/page.tsx +++ b/src/app/login/agreements/page.tsx @@ -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, @@ -50,7 +51,7 @@ export default function LoginAgreementsPage() { useEffect(() => { const storedFlow = readGoogleAgreementFlowSession(); if (!storedFlow) { - router.replace("/login"); + router.replace(pendingInviteAuthPath("/login")); return; } @@ -69,7 +70,7 @@ export default function LoginAgreementsPage() { const returnToLogin = () => { clearGoogleAgreementFlowSession(); - router.replace("/login"); + router.replace(pendingInviteAuthPath("/login")); }; const handleSubmit = async () => { @@ -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" diff --git a/src/lib/analytics/context.ts b/src/lib/analytics/context.ts index 881c19d0..8bf67658 100644 --- a/src/lib/analytics/context.ts +++ b/src/lib/analytics/context.ts @@ -34,7 +34,7 @@ const dynamicPagePaths: ReadonlyArray = [ [/^\/bookmark\/[^/]+$/, "/bookmark/[folderId]"], ]; -const analyticsCampaignParameterNames = [ +export const analyticsCampaignParameterNames = [ "utm_id", "utm_source", "utm_medium", diff --git a/src/lib/google-login-client.test.ts b/src/lib/google-login-client.test.ts new file mode 100644 index 00000000..77e015cc --- /dev/null +++ b/src/lib/google-login-client.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { finishGoogleLogin } from "./google-login-client"; +import { setPendingInviteCode, consumePendingInviteCode } from "./session-user-flow"; +import { saveGoogleAgreementFlowSession } from "./google-oauth"; +import { analyticsConsentStore } from "./analytics/consent-store"; +import { initializeGoogleAnalytics, revokeGoogleAnalyticsConsent } from "./analytics/client"; +import { tearDownClientSession } from "./client-storage"; + +vi.mock("./analytics/runtime", () => ({ analyticsRuntime: { enabled: true } })); +vi.mock("./analytics/amplitude-runtime", () => ({ amplitudeRuntime: { enabled: false } })); +const session = vi.hoisted(() => ({ read: vi.fn() })); +vi.mock("./session-user", () => ({ fetchSessionUserWithRetry: session.read })); +let client: QueryClient; +function commands() { + return (window.dataLayer ?? []).map(c => Array.from(c as ArrayLike)); +} +beforeEach(() => { + client = new QueryClient(); + sessionStorage.clear(); + window.history.replaceState(null, "", "/auth/callback?code=test-code&state=test-state"); + window.dataLayer = []; + session.read.mockResolvedValue({ id: 42, email: "fixture@example.test", nickname: "fixture", profileImageUrl: null, provider: "GOOGLE", tutorialCompleted: false }); + analyticsConsentStore.set("granted"); + initializeGoogleAnalytics("G-TEST", false); + window.dataLayer = []; +}); +afterEach(() => { client.clear(); vi.clearAllMocks(); }); +it.each(["login", "signup", "reaccept"] as const)("returns the same invite campaign after %s and sets identity before its event", async kind => { + setPendingInviteCode("test-invite", "?utm_campaign=existing&code=secret"); + if (kind === "signup") saveGoogleAgreementFlowSession({ kind, signupToken: "test-signup", expiresAt: Date.now() + 10000 }); + if (kind === "reaccept") saveGoogleAgreementFlowSession({ kind, reacceptanceToken: "test-reaccept", expiresAt: Date.now() + 10000 }); + expect(await finishGoogleLogin(client)).toBe("/join/test-invite?utm_campaign=existing"); + expect(consumePendingInviteCode()).toBeNull(); + expect(commands()).toEqual([ + ["set", { user_id: "42" }], + ["event", kind === "signup" ? "sign_up" : "login", { + method: "google", entry_point: "invite", page_path: "/auth/callback", page_location: "http://localhost:3000/auth/callback", + }], + ]); + tearDownClientSession({ queryClient: client }); + expect(commands().at(-1)).toEqual(["set", { user_id: null }]); +}); +it("does not replay a denied-consent login after later consent", async () => { + revokeGoogleAnalyticsConsent(); + analyticsConsentStore.set("denied"); + window.dataLayer = []; + setPendingInviteCode("test-invite", "?utm_source=partner"); + expect(await finishGoogleLogin(client)).toBe("/join/test-invite?utm_source=partner"); + expect(commands()).toEqual([]); + analyticsConsentStore.set("granted"); + initializeGoogleAnalytics("G-TEST", false); + expect(commands().some(([name]) => name === "event")).toBe(false); +}); +it("keeps the pending invitation when session confirmation fails", async () => { + session.read.mockResolvedValue(null); + setPendingInviteCode("test-invite", "?utm_source=partner"); + expect(await finishGoogleLogin(client)).toBeNull(); + expect(consumePendingInviteCode()).toBe("test-invite"); + expect(commands().some(([name]) => name === "event")).toBe(false); +}); +it("does not invent an invite campaign for ordinary login", async () => { + expect(await finishGoogleLogin(client)).toBe("/home"); + expect(commands()[1]).toEqual(["event", "login", { + method: "google", entry_point: "direct", page_path: "/auth/callback", page_location: "http://localhost:3000/auth/callback", + }]); +}); diff --git a/src/lib/google-login-client.ts b/src/lib/google-login-client.ts index 1e004272..aa949e1a 100644 --- a/src/lib/google-login-client.ts +++ b/src/lib/google-login-client.ts @@ -15,6 +15,7 @@ import { clearGoogleAgreementFlowSession, readGoogleAgreementFlowSession, } from "@/lib/google-oauth"; +import { pendingInviteReturnPath } from "@/lib/session-user-flow"; import { setSessionUserCache } from "@/lib/session-user-cache"; import { useSessionStore } from "@/stores/session-store"; @@ -32,6 +33,7 @@ export async function finishGoogleLogin( setSessionUserCache(queryClient, me); useSessionStore.getState().setSessionReady(true); const agreementFlow = readGoogleAgreementFlowSession(); + const inviteDestination = pendingInviteReturnPath(); const pendingInviteCode = consumePendingInviteCode(); clearGoogleAgreementFlowSession(); setAnalyticsUserId(me.id); @@ -45,5 +47,5 @@ export async function finishGoogleLogin( }, ); - return pendingInviteCode ? `/join/${pendingInviteCode}` : "/home"; + return inviteDestination ?? "/home"; } diff --git a/src/lib/invite-campaign.test.ts b/src/lib/invite-campaign.test.ts new file mode 100644 index 00000000..8283bbe6 --- /dev/null +++ b/src/lib/invite-campaign.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildSharedInviteUrl, inviteCampaignSearch, withInviteCampaign } from "./invite-campaign"; + +describe("invite campaign URLs", () => { + it("tags an outgoing invitation with the approved campaign", () => { + expect(buildSharedInviteUrl("https://example.test/join/test-invite")) + .toBe("https://example.test/join/test-invite?utm_source=member_invite&utm_medium=referral&utm_campaign=invite"); + }); + it.each([ + "utm_source=partner&utm_medium=email&utm_campaign=launch&utm_content=a", + "utm_campaign=launch", "utm_source=", "utm_custom=keep", + ])("does not mix defaults into an existing campaign: %s", search => { + const url = `https://example.test/join/test-invite?${search}#section`; + expect(buildSharedInviteUrl(url)).toBe(url); + }); + it("carries only allowed campaign keys, including empty and repeated values", () => { + expect(inviteCampaignSearch("?utm_source=partner&utm_content=a&utm_content=b&utm_campaign=&code=secret&state=secret&inviteCode=secret&redirect=https://other.test#hash")) + .toBe("utm_source=partner&utm_content=a&utm_content=b&utm_campaign="); + }); + it("preserves destination campaign as one set, rather than merging fields", () => { + expect(withInviteCampaign("/login?utm_campaign=other&error=OAuthCallback", "utm_source=member_invite")) + .toBe("/login?utm_campaign=other&error=OAuthCallback"); + }); + it("carries context without inventing a default on internal navigation", () => { + expect(withInviteCampaign("/login?error=OAuthCallback", "utm_source=partner")) + .toBe("/login?error=OAuthCallback&utm_source=partner"); + expect(withInviteCampaign("/login", "")).toBe("/login"); + }); +}); diff --git a/src/lib/invite-campaign.ts b/src/lib/invite-campaign.ts new file mode 100644 index 00000000..0f16ed4d --- /dev/null +++ b/src/lib/invite-campaign.ts @@ -0,0 +1,37 @@ +import { analyticsCampaignParameterNames } from "@/lib/analytics/context"; + +const allowedCampaignNames = new Set(analyticsCampaignParameterNames); +const defaultInviteCampaign = "utm_source=member_invite&utm_medium=referral&utm_campaign=invite"; + +function hasCampaign(search: URLSearchParams): boolean { + return [...search.keys()].some(name => name.startsWith("utm_")); +} + +/** Navigation context only: never retain OAuth parameters, arbitrary queries or fragments. */ +export function inviteCampaignSearch(search: string): string { + const source = new URLSearchParams(search.split("#", 1)[0]); + const campaign = new URLSearchParams(); + for (const [name, value] of source) { + if (allowedCampaignNames.has(name)) campaign.append(name, value); + } + return campaign.toString(); +} + +/** Preserve an existing campaign as a whole, including partial/empty sets. */ +export function withInviteCampaign(path: string, campaign: string): string { + const hashIndex = path.indexOf("#"); + const beforeHash = hashIndex === -1 ? path : path.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : path.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + const search = new URLSearchParams(queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1)); + if (hasCampaign(search)) return path; + const allowedCampaign = inviteCampaignSearch(campaign); + if (!allowedCampaign) return path; + const separator = queryIndex === -1 ? "?" : beforeHash.endsWith("?") ? "" : "&"; + return `${beforeHash}${separator}${allowedCampaign}${fragment}`; +} + +/** Only outgoing copy/share links create the default invitation campaign. */ +export function buildSharedInviteUrl(url: string): string { + return withInviteCampaign(url, defaultInviteCampaign); +} diff --git a/src/lib/session-user-flow.test.ts b/src/lib/session-user-flow.test.ts new file mode 100644 index 00000000..524fc2ff --- /dev/null +++ b/src/lib/session-user-flow.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { beforeEach, expect, it } from "vitest"; +import { consumePendingInviteCode, pendingInviteAuthPath, setPendingInviteCode } from "./session-user-flow"; + +beforeEach(() => sessionStorage.clear()); +it("preserves an invite campaign across login and agreements until consumed", () => { + setPendingInviteCode("test-invite", "?utm_source=partner&utm_campaign=launch&code=secret"); + expect(pendingInviteAuthPath("/login")).toBe("/login?utm_source=partner&utm_campaign=launch"); + expect(pendingInviteAuthPath("/login/agreements")).toBe("/login/agreements?utm_source=partner&utm_campaign=launch"); + expect(consumePendingInviteCode()).toBe("test-invite"); + expect(pendingInviteAuthPath("/login")).toBe("/login"); + expect(consumePendingInviteCode()).toBeNull(); +}); +it("replaces the entire old campaign when a different invitation arrives", () => { + setPendingInviteCode("first", "?utm_source=partner&utm_medium=email"); + setPendingInviteCode("second", "?utm_campaign=partial"); + expect(pendingInviteAuthPath("/login")).toBe("/login?utm_campaign=partial"); + setPendingInviteCode("third"); + expect(pendingInviteAuthPath("/login")).toBe("/login"); + expect(consumePendingInviteCode()).toBe("third"); +}); +it("does not add a default campaign to a legacy or general login", () => { + expect(pendingInviteAuthPath("/login?error=OAuthCallback")).toBe("/login?error=OAuthCallback"); + sessionStorage.setItem("pendingInviteCode", "legacy"); + expect(pendingInviteAuthPath("/login")).toBe("/login"); +}); diff --git a/src/lib/session-user-flow.ts b/src/lib/session-user-flow.ts index 6ba236b2..a51ca773 100644 --- a/src/lib/session-user-flow.ts +++ b/src/lib/session-user-flow.ts @@ -1,3 +1,5 @@ +import { inviteCampaignSearch, withInviteCampaign } from "@/lib/invite-campaign"; + /** OAuth·초대 등 인증 흐름 부가 유틸 */ export async function checkClientAuthenticated(): Promise { @@ -10,16 +12,37 @@ export async function checkClientAuthenticated(): Promise { } const PENDING_INVITE_KEY = "pendingInviteCode"; +const PENDING_INVITE_CAMPAIGN_KEY = "pendingInviteCampaign"; -export function setPendingInviteCode(code: string): void { +export function setPendingInviteCode(code: string, search = ""): void { if (typeof window === "undefined") return; + // Replace, never merge: a new invite must not inherit the previous campaign. + sessionStorage.removeItem(PENDING_INVITE_CAMPAIGN_KEY); sessionStorage.setItem(PENDING_INVITE_KEY, code); + const campaign = inviteCampaignSearch(search); + if (campaign) sessionStorage.setItem(PENDING_INVITE_CAMPAIGN_KEY, campaign); } export function consumePendingInviteCode(): string | null { if (typeof window === "undefined") return null; const value = sessionStorage.getItem(PENDING_INVITE_KEY); - if (value) sessionStorage.removeItem(PENDING_INVITE_KEY); + sessionStorage.removeItem(PENDING_INVITE_KEY); + sessionStorage.removeItem(PENDING_INVITE_CAMPAIGN_KEY); return value; } +function pendingInviteCampaign(): string { + if (typeof window === "undefined" || !sessionStorage.getItem(PENDING_INVITE_KEY)) return ""; + return sessionStorage.getItem(PENDING_INVITE_CAMPAIGN_KEY) ?? ""; +} + +/** Only the invite authentication detour carries this context; ordinary login creates none. */ +export function pendingInviteAuthPath(path: "/login" | "/login/agreements" | `/login?error=${string}`): string { + return withInviteCampaign(path, pendingInviteCampaign()); +} + +export function pendingInviteReturnPath(): string | null { + if (typeof window === "undefined") return null; + const code = sessionStorage.getItem(PENDING_INVITE_KEY); + return code ? withInviteCampaign(`/join/${encodeURIComponent(code)}`, pendingInviteCampaign()) : null; +}