Skip to content
Draft
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
31 changes: 31 additions & 0 deletions app/src/hooks/useBrowserstackGroupId.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
2 changes: 2 additions & 0 deletions app/src/layouts/AppLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -45,6 +46,7 @@ const App: React.FC = () => {
useGeoLocation();
useIsExtensionEnabled();
useBillingTeamsListener();
useBrowserstackGroupId();
useAppLanguageObserver();
// useInitializeNewUserSessionRecordingConfig();

Expand Down
69 changes: 69 additions & 0 deletions app/src/store/features/billing/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
43 changes: 43 additions & 0 deletions app/src/store/features/billing/selectors.ts
Original file line number Diff line number Diff line change
@@ -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[] => {
Expand Down Expand Up @@ -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-<groupId>` 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
);
};