Skip to content

Commit 00bc0a6

Browse files
committed
feat(webapp): put the Slack support channel behind a feature flag
The feature needs a plan entitlement and Slack app scopes that ship separately, so it must stay dark until both are live. Off by default, with a per-organization override so one org can be switched on first. When off the route 404s and the nav item is hidden, rather than showing an upsell for something that cannot be bought yet.
1 parent 949177a commit 00bc0a6

6 files changed

Lines changed: 132 additions & 4 deletions

File tree

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ export function OrganizationSettingsSideMenu({
5050
buildInfo,
5151
isUsingPlugin,
5252
isSsoUsingPlugin,
53+
supportChannelEnabled,
5354
}: {
5455
organization: MatchedOrganization;
5556
buildInfo: BuildInfo;
5657
isUsingPlugin: boolean;
5758
isSsoUsingPlugin: boolean;
59+
supportChannelEnabled: boolean;
5860
}) {
5961
const { isManagedCloud } = useFeatures();
6062
const featureFlags = useFeatureFlags();
@@ -136,7 +138,7 @@ export function OrganizationSettingsSideMenu({
136138
to={organizationTeamPath(organization)}
137139
data-action="team"
138140
/>
139-
{isManagedCloud && (
141+
{isManagedCloud && supportChannelEnabled && (
140142
<SideMenuItem
141143
name="Support"
142144
icon={SlackIcon}

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,28 @@ import {
99
type BuildInfo,
1010
OrganizationSettingsSideMenu,
1111
} from "~/components/navigation/OrganizationSettingsSideMenu";
12+
import { prisma } from "~/db.server";
1213
import { useOrganization } from "~/hooks/useOrganizations";
1314
import { rbac } from "~/services/rbac.server";
15+
import { getUserId } from "~/services/session.server";
1416
import { ssoController } from "~/services/sso.server";
17+
import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server";
1518

1619
const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings";
1720

1821
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
19-
const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([
22+
const userId = await getUserId(request);
23+
const organization = userId
24+
? await prisma.organization.findFirst({
25+
where: { slug: params.organizationSlug ?? "", members: { some: { userId } } },
26+
select: { id: true },
27+
})
28+
: null;
29+
30+
const [isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled] = await Promise.all([
2031
rbac.isUsingPlugin(),
2132
ssoController.isUsingPlugin(),
33+
organization ? isSupportChannelEnabled(organization.id) : Promise.resolve(false),
2234
]);
2335
return typedjson({
2436
buildInfo: {
@@ -30,18 +42,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
3042
} satisfies BuildInfo,
3143
isUsingPlugin,
3244
isSsoUsingPlugin,
45+
supportChannelEnabled,
3346
});
3447
};
3548

3649
function SettingsChrome({
3750
buildInfo,
3851
isUsingPlugin,
3952
isSsoUsingPlugin,
53+
supportChannelEnabled,
4054
children,
4155
}: {
4256
buildInfo: BuildInfo;
4357
isUsingPlugin: boolean;
4458
isSsoUsingPlugin: boolean;
59+
supportChannelEnabled: boolean;
4560
children: ReactNode;
4661
}) {
4762
const organization = useOrganization();
@@ -54,6 +69,7 @@ function SettingsChrome({
5469
buildInfo={buildInfo}
5570
isUsingPlugin={isUsingPlugin}
5671
isSsoUsingPlugin={isSsoUsingPlugin}
72+
supportChannelEnabled={supportChannelEnabled}
5773
/>
5874
<MainBody>{children}</MainBody>
5975
</div>
@@ -62,13 +78,15 @@ function SettingsChrome({
6278
}
6379

6480
export default function Page() {
65-
const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData<typeof loader>();
81+
const { buildInfo, isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled } =
82+
useTypedLoaderData<typeof loader>();
6683

6784
return (
6885
<SettingsChrome
6986
buildInfo={buildInfo}
7087
isUsingPlugin={isUsingPlugin}
7188
isSsoUsingPlugin={isSsoUsingPlugin}
89+
supportChannelEnabled={supportChannelEnabled}
7290
>
7391
<Outlet />
7492
</SettingsChrome>
@@ -81,7 +99,12 @@ export default function Page() {
8199
// available via useRouteLoaderData.
82100
export function ErrorBoundary() {
83101
const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as
84-
| { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean }
102+
| {
103+
buildInfo: BuildInfo;
104+
isUsingPlugin: boolean;
105+
isSsoUsingPlugin: boolean;
106+
supportChannelEnabled: boolean;
107+
}
85108
| undefined;
86109

87110
if (!data) {
@@ -93,6 +116,7 @@ export function ErrorBoundary() {
93116
buildInfo={data.buildInfo}
94117
isUsingPlugin={data.isUsingPlugin}
95118
isSsoUsingPlugin={data.isSsoUsingPlugin}
119+
supportChannelEnabled={data.supportChannelEnabled}
96120
>
97121
<RouteErrorDisplay />
98122
</SettingsChrome>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type PrismaClient } from "@trigger.dev/database";
2+
import { prisma } from "~/db.server";
3+
import { resolveSupportChannelEnabled } from "~/services/supportChannelFlag";
4+
import { FEATURE_FLAG } from "~/v3/featureFlags";
5+
6+
type SupportChannelFlagPrismaClient = Pick<PrismaClient, "featureFlag" | "organization">;
7+
8+
export async function isSupportChannelEnabled(
9+
organizationId: string,
10+
prismaClient: SupportChannelFlagPrismaClient = prisma
11+
): Promise<boolean> {
12+
const [organization, globalFlags] = await Promise.all([
13+
prismaClient.organization.findFirst({
14+
where: { id: organizationId },
15+
select: { featureFlags: true },
16+
}),
17+
prismaClient.featureFlag.findMany({
18+
where: { key: { in: [FEATURE_FLAG.supportChannelEnabled] } },
19+
select: { key: true, value: true },
20+
}),
21+
]);
22+
23+
if (!organization) {
24+
return false;
25+
}
26+
27+
return resolveSupportChannelEnabled(
28+
Object.fromEntries(globalFlags.map((featureFlag) => [featureFlag.key, featureFlag.value])),
29+
(organization.featureFlags as Record<string, unknown> | null) ?? undefined
30+
);
31+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags";
2+
3+
/**
4+
* Resolves whether the private Slack support channel is switched on for an org.
5+
*
6+
* A per-organization value wins over the global one in both directions, so a
7+
* single org can be enabled ahead of a global rollout, or excluded during one.
8+
* Absent everywhere means off — the feature depends on a plan entitlement and
9+
* Slack app scopes that ship separately, so defaulting on would surface a
10+
* button that cannot work.
11+
*/
12+
export function resolveSupportChannelEnabled(
13+
globalFlags: Partial<FeatureFlagCatalog> | Record<string, unknown> | undefined,
14+
organizationFlags: Record<string, unknown> | undefined
15+
): boolean {
16+
const organizationOverride = organizationFlags?.[FEATURE_FLAG.supportChannelEnabled];
17+
if (organizationOverride === true || organizationOverride === false) {
18+
return organizationOverride;
19+
}
20+
21+
return globalFlags?.[FEATURE_FLAG.supportChannelEnabled] === true;
22+
}

apps/webapp/app/v3/featureFlags.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export const FEATURE_FLAG = {
3434
// System-wide kill switch for additional (scoped) environment API-key lookup.
3535
// Defaults off; enable during rollout once the new lookup path is trusted.
3636
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
37+
// Gates the private Slack support channel. Off by default: the feature only
38+
// works once the plan entitlement and the Slack app scopes are both live, and
39+
// those ship independently of this code. Per-organization override supported,
40+
// so a single org can be switched on first.
41+
supportChannelEnabled: "supportChannelEnabled",
3742
} as const;
3843

3944
export const FeatureFlagCatalog = {
@@ -98,6 +103,7 @@ export const FeatureFlagCatalog = {
98103
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
99104
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
100105
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
106+
[FEATURE_FLAG.supportChannelEnabled]: z.boolean(),
101107
};
102108

103109
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveSupportChannelEnabled } from "~/services/supportChannelFlag";
3+
4+
describe("resolveSupportChannelEnabled", () => {
5+
it("is off when nothing is set", () => {
6+
expect(resolveSupportChannelEnabled(undefined, undefined)).toBe(false);
7+
expect(resolveSupportChannelEnabled({}, {})).toBe(false);
8+
});
9+
10+
it("follows the global flag when the org has no override", () => {
11+
expect(resolveSupportChannelEnabled({ supportChannelEnabled: true }, {})).toBe(true);
12+
expect(resolveSupportChannelEnabled({ supportChannelEnabled: false }, {})).toBe(false);
13+
});
14+
15+
it("lets an org opt in ahead of a global rollout", () => {
16+
expect(resolveSupportChannelEnabled({}, { supportChannelEnabled: true })).toBe(true);
17+
});
18+
19+
it("lets an org be excluded from a global rollout", () => {
20+
expect(
21+
resolveSupportChannelEnabled(
22+
{ supportChannelEnabled: true },
23+
{ supportChannelEnabled: false }
24+
)
25+
).toBe(false);
26+
});
27+
28+
it("ignores a non-boolean org override and falls back to the global flag", () => {
29+
expect(
30+
resolveSupportChannelEnabled(
31+
{ supportChannelEnabled: true },
32+
{ supportChannelEnabled: "yes" }
33+
)
34+
).toBe(true);
35+
expect(
36+
resolveSupportChannelEnabled({ supportChannelEnabled: false }, { supportChannelEnabled: 1 })
37+
).toBe(false);
38+
});
39+
40+
it("treats a truthy-but-not-true global value as off", () => {
41+
expect(resolveSupportChannelEnabled({ supportChannelEnabled: "true" }, {})).toBe(false);
42+
});
43+
});

0 commit comments

Comments
 (0)