diff --git a/app/src/hooks/useBrowserstackGroupId.ts b/app/src/hooks/useBrowserstackGroupId.ts new file mode 100644 index 0000000000..6db5d2d222 --- /dev/null +++ b/app/src/hooks/useBrowserstackGroupId.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import { getUserBrowserstackGroupId } from "store/features/billing/selectors"; + +declare global { + interface Window { + currentlyActiveBrowserstackGroupId: string | null | undefined; + } +} + +/** + * Exposes the signed-in user's BrowserStack group id on `window`, mirroring + * the existing `window.currentlyActiveWorkspace*` global pattern. The BS EDS + * integration (which ships via the requestly-cloud `webapp` analytics overlay, + * not this repo's `modules/analytics`) reads this global and stamps it as a + * top-level `data.group_id` field on every EDS event — flowing the same way as + * `browserstack_user_id`. This hook keeps the value in sync from the billing + * store; it is the interceptor-side source, analogous to how the auth handler + * exposes `browserstackId`. + * + * Group is a property of the USER (not the workspace) — sourced from the user's + * BS-linked billing team (`browserstackGroupId`). Feeds BrowserStack Usage + * Reports group attribution. See RQ-4675 and the track design doc. + */ +export const useBrowserstackGroupId = () => { + const browserstackGroupId = useSelector(getUserBrowserstackGroupId); + + useEffect(() => { + window.currentlyActiveBrowserstackGroupId = browserstackGroupId ?? null; + }, [browserstackGroupId]); +}; diff --git a/app/src/layouts/AppLayout/index.tsx b/app/src/layouts/AppLayout/index.tsx index 6235ac5bb5..5dc62efac0 100644 --- a/app/src/layouts/AppLayout/index.tsx +++ b/app/src/layouts/AppLayout/index.tsx @@ -23,6 +23,7 @@ import AutomationNotAllowedNotice from "components/misc/notices/AutomationNotAll import { useIsExtensionEnabled } from "hooks"; import { LazyMotion, domMax } from "framer-motion"; import { useBillingTeamsListener } from "backend/billing/hooks/useBillingTeamsListener"; +import { useBrowserstackGroupId } from "hooks/useBrowserstackGroupId"; import ThemeProvider from "lib/design-system-v2/helpers/ThemeProvider"; import { InitImplicitWidgetConfigHandler } from "components/features/rules/TestThisRule"; import APP_CONSTANTS from "config/constants"; @@ -45,6 +46,7 @@ const App: React.FC = () => { useGeoLocation(); useIsExtensionEnabled(); useBillingTeamsListener(); + useBrowserstackGroupId(); useAppLanguageObserver(); // useInitializeNewUserSessionRecordingConfig(); diff --git a/app/src/store/features/billing/selectors.test.ts b/app/src/store/features/billing/selectors.test.ts new file mode 100644 index 0000000000..e3f3cd6fd0 --- /dev/null +++ b/app/src/store/features/billing/selectors.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { BillingTeamDetails, BillingTeamRoles } from "features/settings/components/BillingTeam/types"; +import { pickUserBrowserstackGroupId } from "./selectors"; + +const UID = "user-123"; + +const team = (overrides: Partial): BillingTeamDetails => + ({ + id: "team-1", + name: "Team", + description: "", + owner: "owner", + subscriptionDetails: {}, + members: {}, + seats: 5, + ...overrides, + } as BillingTeamDetails); + +describe("pickUserBrowserstackGroupId", () => { + it("returns the browserstackGroupId of the team the user is a member of", () => { + const teams = [ + team({ + id: "browserstack-777", + browserstackGroupId: "777", + members: { [UID]: { role: BillingTeamRoles.Admin, joiningDate: 0 } }, + }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("777"); + }); + + it("prefers the team the user is a MEMBER of over a domain-matched team", () => { + const teams = [ + // domain-matched team the listener also pulls in — user is NOT a member + team({ id: "browserstack-111", browserstackGroupId: "111", members: {} }), + // the user's actual team + team({ + id: "browserstack-222", + browserstackGroupId: "222", + members: { [UID]: { role: BillingTeamRoles.Member, joiningDate: 0 } }, + }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("222"); + }); + + it("falls back to any team carrying a browserstackGroupId when the user is not a member of one", () => { + const teams = [ + team({ id: "browserstack-333", browserstackGroupId: "333", members: {} }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("333"); + }); + + it("ignores non-BrowserStack billing teams (no browserstackGroupId)", () => { + const teams = [ + team({ id: "stripe-team", members: { [UID]: { role: BillingTeamRoles.Manager, joiningDate: 0 } } }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBeNull(); + }); + + it("returns null when there are no billing teams", () => { + expect(pickUserBrowserstackGroupId([], UID)).toBeNull(); + expect(pickUserBrowserstackGroupId(undefined, UID)).toBeNull(); + }); + + it("returns null when there is no signed-in uid", () => { + const teams = [team({ browserstackGroupId: "999", members: {} })]; + expect(pickUserBrowserstackGroupId(teams, undefined)).toBeNull(); + }); +}); diff --git a/app/src/store/features/billing/selectors.ts b/app/src/store/features/billing/selectors.ts index 4c5a0ddf54..e9a1daf632 100644 --- a/app/src/store/features/billing/selectors.ts +++ b/app/src/store/features/billing/selectors.ts @@ -1,5 +1,6 @@ import { BillingTeamDetails } from "features/settings/components/BillingTeam/types"; import { ReducerKeys } from "store/constants"; +import { getUserAuthDetails } from "store/slices/global/user/selectors"; import { RootState } from "store/types"; export const getAvailableBillingTeams = (state: RootState): BillingTeamDetails[] => { @@ -32,3 +33,45 @@ export const getBillingTeamMemberById = (billingId: string, memberId: string) => export const getIsBillingTeamsLoading = (state: RootState): boolean => { return state[ReducerKeys.BILLING].isBillingTeamsLoading; }; + +/** + * Pure resolver for the signed-in user's BrowserStack group id from their + * billing teams. A BrowserStack-synced user belongs to exactly one + * `browserstack-` billing team (requestly-cloud userSync guarantees + * this — see the design doc), so we prefer a team the user is an actual + * MEMBER of (over domain-matched teams the billing listener also pulls in), + * falling back to any team carrying a `browserstackGroupId`. Returns `null` + * when the user has no BS-linked billing team (email/Firebase-only or + * unlinked). Extracted from the selector so it can be unit-tested without a + * full RootState. See RQ-4675. + */ +export const pickUserBrowserstackGroupId = ( + billingTeams: BillingTeamDetails[] | undefined, + uid: string | undefined +): string | null => { + if (!uid || !billingTeams?.length) { + return null; + } + + const memberTeam = billingTeams.find( + (team) => Boolean(team.browserstackGroupId) && Boolean(team.members) && uid in team.members + ); + if (memberTeam?.browserstackGroupId) { + return memberTeam.browserstackGroupId; + } + + const anyTeamWithGroup = billingTeams.find((team) => Boolean(team.browserstackGroupId)); + return anyTeamWithGroup?.browserstackGroupId ?? null; +}; + +/** + * The signed-in user's BrowserStack group id (from their BS-linked billing + * team), or `null`. Feeds BrowserStack Usage Reports group attribution + * (RQ-4675) via `useBrowserstackGroupId` → the analytics enrichment choke-point. + */ +export const getUserBrowserstackGroupId = (state: RootState): string | null => { + return pickUserBrowserstackGroupId( + getAvailableBillingTeams(state), + getUserAuthDetails(state)?.details?.profile?.uid + ); +};