Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0f2a006
feat(webapp): add support Slack client + channel-name helper
D-K-P Jun 26, 2026
df5637e
Merge origin/main into connect-slack-button-settings
D-K-P Jul 13, 2026
1b24d9d
feat(database): add OrganizationSupportChannel model
D-K-P Jul 13, 2026
fcbc5da
feat(webapp): add support-channel provisioning orchestrator
D-K-P Jul 13, 2026
c23ad8e
feat(webapp): enqueue Slack support-channel provisioning via the comm…
D-K-P Jul 13, 2026
7d81d7a
feat(webapp): add support-channel settings nav item
D-K-P Jul 13, 2026
ece5820
feat(webapp): add Slack support-channel settings route with paid gate
D-K-P Jul 13, 2026
6242c0c
feat(webapp): add support-channel settings page UI
D-K-P Jul 13, 2026
0a688d3
feat(webapp): add Slack Connect channel discovery client
D-K-P Jul 13, 2026
94a48f7
feat(webapp): add support-channel org match proposer
D-K-P Jul 13, 2026
e07db7c
feat(webapp): add support-channel link writer
D-K-P Jul 13, 2026
f0e1c6f
feat(webapp): add admin Slack support-channel linking page
D-K-P Jul 13, 2026
05aae7d
chore(webapp): add Slack support channel release note
D-K-P Jul 13, 2026
16fd42c
fix(webapp): make Slack support-channel provisioning retry-safe and s…
D-K-P Jul 13, 2026
6afe7e2
feat(webapp): support-channel unlink (archive) and re-provision on re…
D-K-P Jul 14, 2026
4abbd10
feat(webapp): flag downgraded orgs and add unlink in the admin Slack …
D-K-P Jul 14, 2026
bcc55d6
chore(webapp): tidy Slack support-channel release note and admin unlink
D-K-P Jul 14, 2026
626f87d
feat(webapp): gate Slack support channel on plan entitlement and fix …
D-K-P Jul 14, 2026
d9802d4
Merge origin/main into connect-slack-button-settings
isshaddad Aug 12, 2026
a6644b0
chore(database): re-date the support-channel migrations to sort after…
isshaddad Aug 12, 2026
cb475d9
chore(webapp): format the support-channel model test
isshaddad Aug 12, 2026
37f1064
fix(webapp): gate the Slack support channel behind manage:billing
isshaddad Aug 12, 2026
ba1f9eb
fix(webapp): invite the longest-standing admin to the support channel
isshaddad Aug 12, 2026
d4539c0
fix(webapp): require an explicit org pick in the admin Slack linking …
isshaddad Aug 12, 2026
949177a
chore(webapp): drop the redundant support-channel release note
isshaddad Aug 12, 2026
00bc0a6
feat(webapp): put the Slack support channel behind a feature flag
isshaddad Aug 12, 2026
b8271bd
fix(webapp): no-op the connect action when a channel already exists
isshaddad Aug 12, 2026
6be17a3
fix(webapp): address support-channel review findings
isshaddad Aug 13, 2026
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
6 changes: 6 additions & 0 deletions .server-changes/slack-support-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Owners of paid organizations can set up a private Slack support channel from Organization settings. Free plans see an upgrade option instead.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationSsoPath,
organizationSupportPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
Expand Down Expand Up @@ -49,11 +50,13 @@ export function OrganizationSettingsSideMenu({
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
supportChannelEnabled,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
supportChannelEnabled: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
Expand Down Expand Up @@ -135,6 +138,17 @@ export function OrganizationSettingsSideMenu({
to={organizationTeamPath(organization)}
data-action="team"
/>
{isManagedCloud && supportChannelEnabled && (
<SideMenuItem
name="Support"
icon={SlackIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
iconClassName="size-4 ml-0.5"
to={organizationSupportPath(organization)}
data-action="support"
/>
)}
{featureFlags.hasPrivateConnections && (
<SideMenuItem
name="Private Connections"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { logger } from "~/services/logger.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { getUserId } from "~/services/session.server";
import {
enqueueProvisionSupportChannel,
hasPrivateSlackSupport,
} from "~/services/supportSlackChannel.server";
import {
OrganizationParamsSchema,
organizationSupportPath,
v3BillingPath,
} from "~/utils/pathBuilder";

async function resolveOrg(slug: string, userId: string) {
// Scoped to membership: ability.can is not a tenant floor (the cloud RBAC
// plugin returns a permissive ability for a non-member), so without the
// members filter a non-member reaches the handler for any org slug.
return prisma.organization.findFirst({
where: { slug, members: { some: { userId } }, deletedAt: null },
select: { id: true },
});
}

async function orgScope(params: { organizationSlug: string }, request: Request) {
const userId = await getUserId(request);
if (!userId) return {};
const org = await resolveOrg(params.organizationSlug, userId);
return org ? { organizationId: org.id } : {};
}

export const loader = dashboardLoader(
{
params: OrganizationParamsSchema,
context: orgScope,
// Plan-gated before role-gated: unentitled orgs render the upsell whatever
// their role, so manage:billing is enforced on the action (and mirrored as
// a disabled button here) rather than on the whole route.
},
async ({ context, ability }) => {
const organizationId = context.organizationId;
if (!organizationId) {
throw new Response("Not Found", { status: 404 });
}

// Flag off means the feature does not exist yet, so 404 rather than render
// an upsell for something nobody can buy.
if (!(await isSupportChannelEnabled(organizationId))) {
throw new Response("Not Found", { status: 404 });
}

const supportChannel = await prisma.organizationSupportChannel.findFirst({
where: { organizationId },
});

const plan = await getCurrentPlan(organizationId);

return typedjson({
supportChannel,
hasSupportAccess: hasPrivateSlackSupport(plan),
canManage: ability.can("manage", { type: "billing" }),
});
}
);

const ActionSchema = z.object({
intent: z.literal("connect"),
});

export const action = dashboardAction(
{
params: OrganizationParamsSchema,
context: orgScope,
authorization: { action: "manage", resource: { type: "billing" } },
},
async ({ request, params, context }) => {
const organizationId = context.organizationId;
if (!organizationId) {
throw new Response("Not Found", { status: 404 });
}

if (!(await isSupportChannelEnabled(organizationId))) {
throw new Response("Not Found", { status: 404 });
}

const formData = await request.formData();
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
if (!result.success) {
return json({ error: "Invalid action" }, { status: 400 });
}

const plan = await getCurrentPlan(organizationId);
if (!hasPrivateSlackSupport(plan)) {
return json({ error: "Upgrade required" }, { status: 403 });
}

// A live channel already covers this org. Without this an out-of-band POST
// would flip the row back to PROVISIONING and re-send the Slack invite.
const existing = await prisma.organizationSupportChannel.findFirst({
where: { organizationId },
select: { status: true },
});
if (existing?.status === "INVITED" || existing?.status === "LINKED") {
return redirect(organizationSupportPath({ slug: params.organizationSlug }));
}

// Persist before enqueueing. The worker can finish between the two, and if
// the write came second it would clobber INVITED back to PROVISIONING —
// leaving the page stuck, with the job already deduped so nothing retries.
await prisma.organizationSupportChannel.upsert({
where: { organizationId },
create: { organizationId, status: "PROVISIONING" },
update: { status: "PROVISIONING", lastError: null },
});
Comment on lines +124 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Rebuilding a support channel after it was taken away never works

The organization's support channel record is reset to "setting up" (upsert at apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:127-131) before the background job runs, so the job no longer knows the channel was previously shut down and every attempt to bring it back fails.
Impact: A customer whose Slack support channel was unlinked can click Connect forever and only ever gets an error; the channel is never restored.

ARCHIVED status is overwritten before the worker reads it, making the unarchive branch unreachable

unlinkSupportChannel (apps/webapp/app/services/supportSlackChannel.server.ts:440-463) archives the Slack channel and leaves the row at status ARCHIVED with slackChannelId retained. The support page then renders the "Connect to Slack" form for that state.

When the user submits, the action unconditionally writes status: "PROVISIONING" before enqueuing (...settings.support.tsx:127-131). By the time the worker calls provisionOrganizationSupportChannel, existing.status is PROVISIONING, so the dedicated re-upgrade branch at apps/webapp/app/services/supportSlackChannel.server.ts:323 (which calls unarchiveChannel first) is skipped. Execution falls through to the "reuse the persisted channel" path at apps/webapp/app/services/supportSlackChannel.server.ts:363-405, which calls inviteSharedByEmail directly on a channel that is still archived in Slack — exactly the failure mode the comment at apps/webapp/app/services/supportSlackChannel.server.ts:347-350 warns about. The row lands on FAILED, and every retry repeats the same path.

The only enqueue site for supportChannel.provision is this action (enqueueProvisionSupportChannel, apps/webapp/app/services/supportSlackChannel.server.ts:648), so in practice the unarchive branch is dead code and the archived → reconnect flow is permanently broken.

Prompt for agents
In apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx the connect action always upserts the OrganizationSupportChannel row to status PROVISIONING before enqueuing the worker job. When the row is currently ARCHIVED (set by unlinkSupportChannel after an admin unlink, which archives the Slack channel but keeps slackChannelId), this overwrite destroys the only signal provisionOrganizationSupportChannel uses to take its unarchive-and-reuse branch (apps/webapp/app/services/supportSlackChannel.server.ts around line 323). The worker then takes the plain reuse path and invites into a channel that is still archived in Slack, which fails on every attempt — the exact scenario the comment in that branch describes.

Possible approaches: leave the row at ARCHIVED (only clearing lastError) when re-connecting so the worker can detect it, or make the worker decide based on whether the channel is archived in Slack rather than on the row's status (e.g. always unarchive when a persisted slackChannelId exists, treating the Slack 'not_archived' error as a no-op — unarchiveChannel already handles that). Also note the error path in the same action that writes status FAILED would similarly lose the ARCHIVED signal, so it needs the same treatment.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


try {
await enqueueProvisionSupportChannel({ organizationId });
} catch (error) {
logger.error("Failed to enqueue support channel provisioning", { organizationId, error });
await prisma.organizationSupportChannel.update({
where: { organizationId },
data: { status: "FAILED", lastError: "Failed to enqueue provisioning" },
});
return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 });
}

return redirect(organizationSupportPath({ slug: params.organizationSlug }));
}
);

export default function Page() {
const { supportChannel, hasSupportAccess, canManage } = useTypedLoaderData<typeof loader>();
const actionData = useActionData<{ error?: string }>();
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
const navigation = useNavigation();
const isSubmitting = navigation.state !== "idle";

Comment thread
isshaddad marked this conversation as resolved.
return (
<PageContainer>
<NavBar>
<PageTitle title="Slack support channel" />
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer>
<Header2 spacing>Private Slack support channel</Header2>
<Paragraph spacing>
Get a private Slack channel shared with the Trigger.dev team for direct support.
</Paragraph>

{!hasSupportAccess ? (
<div className="flex flex-col gap-3">
<Paragraph variant="small" className="text-text-dimmed">
A private Slack support channel is available on Pro and Enterprise plans.
</Paragraph>
{showSelfServe ? (
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade to unlock
</LinkButton>
) : (
<LinkButton variant="secondary/medium" to={v3BillingPath(organization)}>
Contact us
</LinkButton>
)}
</div>
) : supportChannel?.status === "INVITED" || supportChannel?.status === "LINKED" ? (
<div className="flex flex-col gap-3">
<Paragraph variant="small">
Your private Slack support channel
{supportChannel.slackChannelName ? ` #${supportChannel.slackChannelName}` : ""} is
ready.
{supportChannel.status === "INVITED" && supportChannel.invitedEmail
? ` We've sent a Slack Connect invite to ${supportChannel.invitedEmail}.`
: ""}
</Paragraph>
{/* While INVITED the owner has not joined yet, so the deep link
would 404 for them — offer the Slack Connect invite instead.
The channel id is always set by then, so ordering matters. */}
{supportChannel.status === "INVITED" && supportChannel.inviteUrl ? (
<LinkButton variant="primary/medium" to={supportChannel.inviteUrl}>
Join the channel
</LinkButton>
) : supportChannel.slackChannelId ? (
<LinkButton
variant="primary/medium"
to={`https://slack.com/app_redirect?channel=${supportChannel.slackChannelId}`}
>
Open in Slack
</LinkButton>
) : null}
Comment thread
isshaddad marked this conversation as resolved.
</div>
) : supportChannel?.status === "PROVISIONING" ? (
<Paragraph variant="small" className="text-text-dimmed">
Setting up your channel. Check your email shortly for the Slack Connect invite.
</Paragraph>
) : (
<Form method="post" className="flex flex-col gap-3">
{actionData?.error ? (
<Paragraph variant="small" className="text-error">
{actionData.error}
</Paragraph>
) : null}
{supportChannel?.status === "FAILED" ? (
<Paragraph variant="small" className="text-error">
Something went wrong setting up your channel. Try again, or contact us.
</Paragraph>
) : null}
<Button
type="submit"
name="intent"
value="connect"
variant="primary/medium"
disabled={isSubmitting || !canManage}
tooltip={
canManage
? undefined
: "You don't have permission to connect a Slack support channel"
}
>
Connect to Slack
</Button>
</Form>
)}
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,28 @@ import {
type BuildInfo,
OrganizationSettingsSideMenu,
} from "~/components/navigation/OrganizationSettingsSideMenu";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
import { getUserId } from "~/services/session.server";
import { ssoController } from "~/services/sso.server";
import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server";

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

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([
const userId = await getUserId(request);
const organization = userId
? await prisma.organization.findFirst({
where: { slug: params.organizationSlug ?? "", members: { some: { userId } } },
select: { id: true },
})
: null;

const [isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled] = await Promise.all([
rbac.isUsingPlugin(),
ssoController.isUsingPlugin(),
organization ? isSupportChannelEnabled(organization.id) : Promise.resolve(false),
]);
return typedjson({
buildInfo: {
Expand All @@ -30,18 +42,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
} satisfies BuildInfo,
isUsingPlugin,
isSsoUsingPlugin,
supportChannelEnabled,
});
};

function SettingsChrome({
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
supportChannelEnabled,
children,
}: {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
supportChannelEnabled: boolean;
children: ReactNode;
}) {
const organization = useOrganization();
Expand All @@ -54,6 +69,7 @@ function SettingsChrome({
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
isSsoUsingPlugin={isSsoUsingPlugin}
supportChannelEnabled={supportChannelEnabled}
/>
<MainBody>{children}</MainBody>
</div>
Expand All @@ -62,13 +78,15 @@ function SettingsChrome({
}

export default function Page() {
const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData<typeof loader>();
const { buildInfo, isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled } =
useTypedLoaderData<typeof loader>();

return (
<SettingsChrome
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
isSsoUsingPlugin={isSsoUsingPlugin}
supportChannelEnabled={supportChannelEnabled}
>
<Outlet />
</SettingsChrome>
Expand All @@ -81,7 +99,12 @@ export default function Page() {
// available via useRouteLoaderData.
export function ErrorBoundary() {
const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as
| { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean }
| {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
supportChannelEnabled: boolean;
}
| undefined;

if (!data) {
Expand All @@ -93,6 +116,7 @@ export function ErrorBoundary() {
buildInfo={data.buildInfo}
isUsingPlugin={data.isUsingPlugin}
isSsoUsingPlugin={data.isSsoUsingPlugin}
supportChannelEnabled={data.supportChannelEnabled}
>
<RouteErrorDisplay />
</SettingsChrome>
Expand Down
Loading
Loading