Skip to content

feat(webapp): self-serve private Slack support channel - #4593

Open
isshaddad wants to merge 27 commits into
mainfrom
connect-slack-button-settings
Open

feat(webapp): self-serve private Slack support channel#4593
isshaddad wants to merge 27 commits into
mainfrom
connect-slack-button-settings

Conversation

@isshaddad

Copy link
Copy Markdown
Collaborator

What

A private Slack support channel for paid organizations, plus a super-admin page to link the channels that already exist.

  • /orgs/:org/settings/support — Owners on an entitled plan can create a private Slack Connect channel and get invited to it. Everyone else sees an upgrade option.
  • /admin/slack-channels — discovers cus-* Slack Connect channels the bot is in, proposes an organization for each, and writes the link once a human approves.

Provisioning runs on the common worker and is retry-safe: the channel id is persisted before the invite is attempted, so a retry never creates a second channel.

Dormant until the plan entitlement ships

The page gates on a supportChannel plan entitlement and is fail-closed — until that entitlement is granted, every organization sees the upgrade option and nothing provisions. Safe to merge; the feature is switched on separately (TRI-12095 for the entitlement, TRI-12049 for the Slack app scopes).

Notes

  • Two migrations add OrganizationSupportChannel.
  • SLACK_BOT_TOKEN is optional — with no token the worker job is a no-op.
  • Provisioning requires manage:billing, enforced on the action and mirrored as a disabled button.

TRI-11240

D-K-P and others added 25 commits June 26, 2026 16:40
Adds provisionOrganizationSupportChannel, which creates a private Slack
channel for an organization, invites the org owner by email, and
records the outcome on OrganizationSupportChannel. Idempotent when a
channel already exists (INVITED or LINKED), and records FAILED with
the error message when the owner email is missing or Slack calls
throw.
…on worker

Wires the support-channel provisioning orchestrator into the background
job system so callers can enqueue it by organization id, keeping Slack
API calls off the request path.
Adds the loader and action for the organization Support settings page, gated so only paid plans can connect a Slack support channel.
Shows the Slack support channel connection state on the organization
settings page: an upgrade prompt for free orgs, a connect button for
paid orgs without a channel yet, and status for invited, linked, and
in-progress channels.
Adds discovery helpers for finding existing customer support Slack
channels: isCustomerSupportChannel identifies cus- prefixed Connect
channels, and pickExternalTeamId resolves the customer's workspace id
from a channel's connected teams. Extends SupportSlackClientLive with
ownTeamId, listCustomerChannels, and getTeamDomains so a later step can
propose links between organizations and existing channels.
Proposes which organization a discovered cus- Slack channel likely belongs to, scoring on channel/org name similarity and email domain match, so an admin can review and approve links instead of us guessing silently.
Adds linkSupportChannel, which records an admin-approved match between
an organization and a Slack support channel. Handles idempotent
re-linking, and refuses to overwrite an org's existing link or steal a
channel already linked to another org unless explicitly reassigning.
Adds a super-admin page at /admin/slack-channels that lists existing
customer Slack Connect channels, proposes an organization match for
each using name and email-domain heuristics, and lets an admin approve
or reassign the link with one click.
…urface enqueue failures

Persist the Slack channel id right after creation instead of only on
final invite success, so a redis-worker retry reuses the existing
channel instead of hitting Slack's name_taken error and orphaning it.
Also let enqueue failures in the settings action surface as an error
instead of silently stranding the row at PROVISIONING with no way to
retry.
…-upgrade

Adds an ARCHIVED status to OrganizationSupportChannel, archive/unarchive
methods on the Slack client, and unlinkSupportChannel to disconnect a
support channel. Re-provisioning an archived channel unarchives and
reuses it instead of creating a new one, avoiding a Slack name_taken
error on the cus-<slug> channel name.
…page

Admins can now see which orgs kept a linked Slack support channel after
downgrading off a paid plan, and unlink a channel directly from the admin
page instead of going through a script.
Collapse the release note back to a single entry, add a confirmation to the admin unlink action, and clarify the best-effort team-domain lookup.
…settings layout

Switch the org support-channel gate from generic paid-plan status to a
data-driven Pro/Enterprise entitlement (v3Subscription.plan.limits.supportChannel),
and remove the temporary loader override that had been forcing the paid view
locally. Rename isPaying to hasSupportAccess throughout the route to reflect
that it is an entitlement check, not a payment check.
… main's

The two migrations were authored in July and now sit behind a month of
migrations that are already applied, so they would apply out of order.
Re-dated to keep their relative order.
Line-wrapping only; oxfmt --check was failing on this file.
The route used a raw loader/action, so provisioning was reachable by any
org member. Both now go through the dashboard route builders, with the
role check on the action and a disabled button mirroring it in the UI.

The plan gate still runs first, so unentitled orgs see the upsell
whatever their role.
The owner lookup had no orderBy, so which admin received the Slack Connect
invite varied between runs and a retry could email someone else. Orders by
createdAt, matching the admin page's lookup.
…page

The dropdown defaulted to the first organization in the list when no match
was proposed, so a single click on Approve could link the wrong org. Adds a
placeholder default, disables the buttons until one is chosen, and rejects
the sentinel server-side.
The entitlement gate changed behaviour users never saw, since the feature
has not shipped. The remaining note covers it.
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b8271bd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ff2a8560-0a96-4f2a-9318-89250c324a7a

📥 Commits

Reviewing files that changed from the base of the PR and between 949177a and b8271bd.

📒 Files selected for processing (8)
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
  • apps/webapp/app/services/supportChannelFlag.server.ts
  • apps/webapp/app/services/supportChannelFlag.ts
  • apps/webapp/app/v3/featureFlags.ts
  • apps/webapp/test/supportChannelFlag.test.ts
  • apps/webapp/test/supportChannelSettings.e2e.full.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx

Walkthrough

Added database models and migrations for organization support-channel lifecycle states. Added feature-flag resolution and settings navigation. Added Slack discovery, provisioning, linking, unlinking, reuse, and organization matching services. Added background provisioning with retries. Added plan-aware organization settings and super-admin channel management. Added unit, integration, model, path, and end-to-end tests.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the feature and implementation, but it omits the required issue, checklist, Testing, Changelog, and Screenshots sections. Use the repository template and add the required sections, including a closing issue reference, checklist status, test steps, changelog entry, and screenshots or an explicit omission.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: self-serve private Slack support channels in the web app.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch connect-slack-button-settings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines +103 to +114
try {
await enqueueProvisionSupportChannel({ organizationId });
} catch (error) {
logger.error("Failed to enqueue support channel provisioning", { organizationId, error });
return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 });
}

await prisma.organizationSupportChannel.upsert({
where: { organizationId },
create: { organizationId, status: "PROVISIONING" },
update: { status: "PROVISIONING" },
});

@devin-ai-integration devin-ai-integration Bot Aug 12, 2026

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.

🟡 Newly created support channel can get stuck showing "setting up" even though it is ready

The background provisioning job is started (enqueueProvisionSupportChannel at apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:125) before the request records that setup is in progress, so a channel that finishes quickly gets its "ready" state overwritten and the page keeps saying it is still being set up.

Impact: A customer can be left looking at a permanent "Setting up your channel" message for a channel that is already live, and a further attempt sends them a second Slack invite.

Write ordering between the action and the worker job

The action enqueues the job first (apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:124-129) and only afterwards upserts status: "PROVISIONING" (...:131-135). If the worker picks the job up and provisionOrganizationSupportChannel completes (writing status: "INVITED", apps/webapp/app/services/supportSlackChannel.server.ts:377-386) before that upsert runs, the upsert clobbers INVITED back to PROVISIONING (the update only touches status, so the channel id/inviteUrl remain). The page then renders the PROVISIONING branch forever (...:203-206), and because the action's guard only short-circuits on INVITED/LINKED (...:120-122), a subsequent POST re-enqueues provisioning, which re-invites the owner by email.

Swapping the order (persist PROVISIONING first, then enqueue) removes the window.

Open in Devin Review

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

Comment on lines +333 to +341
"supportChannel.provision": async ({ payload }) => {
const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN);
if (!slackClient) return;
await provisionOrganizationSupportChannel({
organizationId: payload.organizationId,
prisma,
slackClient,
});
},

@devin-ai-integration devin-ai-integration Bot Aug 12, 2026

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.

🟡 Support channel setup hangs forever with no error when Slack is not configured

The background setup job quietly does nothing when the Slack credentials are missing (if (!slackClient) return; at apps/webapp/app/v3/commonWorker.server.ts:335), leaving the request permanently showing that setup is in progress.

Impact: A customer who clicks "Connect to Slack" on an install without Slack credentials sees "Setting up your channel" forever, with no error and no way to retry.

Why the state is unrecoverable

The action writes status: "PROVISIONING" before/after enqueueing (apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:131-135). The worker job returns early without touching the row when env.SLACK_BOT_TOKEN is unset. The page's PROVISIONING branch (...:203-206) renders informational text with no retry control, and the row never transitions to FAILED, so nothing ever surfaces the misconfiguration. Recording FAILED with a lastError (via the existing status writer in apps/webapp/app/services/supportSlackChannel.server.ts:254-271) would let the page show the retry button.

Open in Devin Review

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

Comment on lines +120 to +126
export default function Page() {
const { supportChannel, hasSupportAccess, canManage } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
const navigation = useNavigation();
const isSubmitting = navigation.state !== "idle";

@devin-ai-integration devin-ai-integration Bot Aug 12, 2026

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.

🟡 Failed attempts to create a Slack support channel show no feedback to the user

When the request to set up the channel is rejected or errors, the page returns an error message that is never displayed anywhere (json({ error: ... }) at apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:100), so the button appears to do nothing.
Impact: A user clicking Connect during a failure sees the page silently reload with no explanation of what went wrong.

Action data is never consumed by the component

The action returns error JSON in three places (...:95, ...:100, ...:107), all non-redirect responses. The component (...:120-126) only reads useTypedLoaderData and useNavigation; there is no useActionData usage, so the error text is discarded and the UI re-renders unchanged (the row is also not created in the 403/400 paths, so the form simply reappears).

Open in Devin Review

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

Comment on lines +388 to +395
} catch (error) {
await setStatus(prisma, organizationId, "FAILED", {
slackChannelId: channelId,
slackChannelName: channelName,
lastError: error instanceof Error ? error.message : String(error),
});
return { status: "failed" };
}

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.

🔍 Provisioning returns "failed" instead of throwing, so worker retries never fire

provisionOrganizationSupportChannel swallows all Slack errors and returns { status: "failed" }; the worker handler ignores the return value, so the maxAttempts: 3 retry policy configured for supportChannel.provision is effectively dead — a transient Slack 5xx/rate-limit will never be retried automatically and the org lands on FAILED until a human clicks Connect again. Worth confirming this is intended (the FAILED UI does offer a retry button), otherwise re-throwing for transient errors would make the configured retries meaningful.

Open in Devin Review

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

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Observability map

As of b8271bd.

20/100 over 427 measured of 443 entry points (base 20, no change)

What this PR changed

route base head now failing
/admin/slack-channels new 0 request-context
/_app/orgs/:organizationSlug/settings/support new 50 error-classification

FIX FIRST

  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context
  • /_app/orgs/:organizationSlug/settings/team (sensitive) - error-classification, auth-scope, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 23 of 427 entry points name a tenant on a failure path. 325 appear only here, 39 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  179 applicable, 101 pass,   0 sole, global without it 12
  auth-boundary          62 applicable,  57 pass,   0 sole, global without it 17
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 20
  request-context       427 applicable,  23 pass, 226 sole, global without it 64
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🧹 Nitpick comments (4)
apps/webapp/app/routes/admin.slack-channels.tsx (2)

58-81: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider parallelizing the per-channel and per-organization lookups.

The loader awaits client.getTeamDomains once per distinct external team, and getCurrentPlan once per linked organization, all sequentially. Page load time grows linearly with the number of channels and linked organizations. The route is super-admin only, so this is not urgent, but the fix is contained.

Collect the distinct external team IDs first, then resolve them with Promise.all. Apply the same pattern to the plan lookups.

Also applies to: 118-143


323-335: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an accessible name to the organization select.

The <select> has no associated label, so screen readers announce it without a purpose. Add an aria-label.

♿ Proposed fix
           <select
             name="organizationId"
+            aria-label={`Organization for ${channel.channelName}`}
             value={organizationId}
             onChange={(event) => setOrganizationId(event.target.value)}
apps/webapp/test/supportSlackChannel.test.ts (1)

84-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the Slack "already in that state" handling.

isSlackErrorCode decides whether archiveChannel and unarchiveChannel swallow already_archived and not_archived. FakeSupportSlackClient never throws for those paths, so the predicate is untested. The predicate reads a nested data.error field, which is easy to break during refactoring.

Add a small unit test that passes a Slack-shaped error object ({ data: { error: "already_archived" } }) through SupportSlackClientLive.archiveChannel behavior, or export and test the predicate directly.

apps/webapp/test/supportSlackChannelModel.test.ts (1)

28-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the unique-constraint error code instead of any error.

rejects.toThrow() passes for any failure, including an unrelated validation or foreign-key error. These two tests exist to prove the organizationId and slackChannelId unique indexes. Assert the Prisma code P2002 so the tests fail if the constraint is dropped but some other error appears.

💚 Proposed assertion tightening
   await expect(
     prisma.organizationSupportChannel.create({
       data: { organizationId: org.id, status: "PENDING" },
     })
-  ).rejects.toThrow();
+  ).rejects.toMatchObject({ code: "P2002" });
   await expect(
     prisma.organizationSupportChannel.create({
       data: { organizationId: b.id, status: "LINKED", slackChannelId: "C9" },
     })
-  ).rejects.toThrow();
+  ).rejects.toMatchObject({ code: "P2002" });

Also applies to: 41-45


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 865e35aa-a31e-4895-98fe-427325a44911

📥 Commits

Reviewing files that changed from the base of the PR and between 622fa79 and 949177a.

📒 Files selected for processing (15)
  • .server-changes/slack-support-channel.md
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx
  • apps/webapp/app/routes/admin.slack-channels.tsx
  • apps/webapp/app/routes/admin.tsx
  • apps/webapp/app/services/supportSlackChannel.server.ts
  • apps/webapp/app/utils/pathBuilder.ts
  • apps/webapp/app/v3/commonWorker.server.ts
  • apps/webapp/test/pathBuilder.supportPath.test.ts
  • apps/webapp/test/supportChannelSettings.e2e.full.test.ts
  • apps/webapp/test/supportSlackChannel.test.ts
  • apps/webapp/test/supportSlackChannelModel.test.ts
  • internal-packages/database/prisma/migrations/20260812150000_add_organization_support_channel/migration.sql
  • internal-packages/database/prisma/migrations/20260812150100_add_organization_support_channel_archived_status/migration.sql
  • internal-packages/database/prisma/schema.prisma

Comment on lines +103 to +114
try {
await enqueueProvisionSupportChannel({ organizationId });
} catch (error) {
logger.error("Failed to enqueue support channel provisioning", { organizationId, error });
return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 });
}

await prisma.organizationSupportChannel.upsert({
where: { organizationId },
create: { organizationId, status: "PROVISIONING" },
update: { status: "PROVISIONING" },
});

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persist PROVISIONING before you enqueue the job.

The action enqueues the provisioning job at Line 104 and writes the PROVISIONING row at Line 110. The common worker can start and finish provisionOrganizationSupportChannel between those two statements. The worker writes INVITED (or FAILED) with slackChannelId and inviteUrl; Line 110 then overwrites status back to PROVISIONING while keeping the Slack fields.

The result is a permanently stuck page. The UI renders the "Setting up your channel" branch at Line 182, the job has already completed, and enqueueProvisionSupportChannel dedupes on support-channel:<organizationId>, so no retry occurs.

Write the row first, then enqueue. If the enqueue fails, reset the status so the user can retry.

🐛 Proposed fix for the ordering race
-    try {
-      await enqueueProvisionSupportChannel({ organizationId });
-    } catch (error) {
-      logger.error("Failed to enqueue support channel provisioning", { organizationId, error });
-      return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 });
-    }
-
-    await prisma.organizationSupportChannel.upsert({
-      where: { organizationId },
-      create: { organizationId, status: "PROVISIONING" },
-      update: { status: "PROVISIONING" },
-    });
+    await prisma.organizationSupportChannel.upsert({
+      where: { organizationId },
+      create: { organizationId, status: "PROVISIONING" },
+      update: { status: "PROVISIONING", lastError: null },
+    });
+
+    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 });
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await enqueueProvisionSupportChannel({ organizationId });
} catch (error) {
logger.error("Failed to enqueue support channel provisioning", { organizationId, error });
return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 });
}
await prisma.organizationSupportChannel.upsert({
where: { organizationId },
create: { organizationId, status: "PROVISIONING" },
update: { status: "PROVISIONING" },
});
await prisma.organizationSupportChannel.upsert({
where: { organizationId },
create: { organizationId, status: "PROVISIONING" },
update: { status: "PROVISIONING", lastError: null },
});
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 });
}

Comment on lines +154 to +180
) : 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>
{supportChannel.status === "LINKED" ? (
<Paragraph variant="small">
Your support channel is #{supportChannel.slackChannelName}
</Paragraph>
) : null}
{supportChannel.slackChannelId ? (
<LinkButton
variant="primary/medium"
to={`https://slack.com/app_redirect?channel=${supportChannel.slackChannelId}`}
>
Open in Slack
</LinkButton>
) : supportChannel.inviteUrl ? (
<LinkButton variant="primary/medium" to={supportChannel.inviteUrl}>
Join the channel
</LinkButton>
) : null}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The invite link is unreachable, and the channel name is printed twice.

Two problems in this branch:

  1. Line 169 prefers slackChannelId over inviteUrl. provisionOrganizationSupportChannel always persists slackChannelId before it records the invite, so status INVITED always has a channel id. The "Join the channel" button at Line 177 therefore never renders. An owner who has not yet accepted the Slack Connect invite cannot open the channel through slack.com/app_redirect. Show inviteUrl while the status is INVITED, and show the deep link once the status is LINKED.
  2. Lines 156-167 print the channel name twice for LINKED: once inside the "is ready" sentence and again in the extra paragraph.
🐛 Proposed fix
-              {supportChannel.status === "LINKED" ? (
-                <Paragraph variant="small">
-                  Your support channel is #{supportChannel.slackChannelName}
-                </Paragraph>
-              ) : null}
-              {supportChannel.slackChannelId ? (
+              {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>
-              ) : supportChannel.inviteUrl ? (
-                <LinkButton variant="primary/medium" to={supportChannel.inviteUrl}>
-                  Join the channel
-                </LinkButton>
               ) : null}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) : 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>
{supportChannel.status === "LINKED" ? (
<Paragraph variant="small">
Your support channel is #{supportChannel.slackChannelName}
</Paragraph>
) : null}
{supportChannel.slackChannelId ? (
<LinkButton
variant="primary/medium"
to={`https://slack.com/app_redirect?channel=${supportChannel.slackChannelId}`}
>
Open in Slack
</LinkButton>
) : supportChannel.inviteUrl ? (
<LinkButton variant="primary/medium" to={supportChannel.inviteUrl}>
Join the channel
</LinkButton>
) : null}
) : 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>
{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 on lines +132 to +149
const res = await this.client.users.conversations({
types: "private_channel",
exclude_archived: true,
limit: 200,
cursor,
});

for (const c of res.channels ?? []) {
if (!isCustomerSupportChannel({ name: c.name, is_ext_shared: c.is_ext_shared })) {
continue;
}
if (!c.id || !c.name) {
continue;
}
channels.push({
channelId: c.id,
channelName: c.name,
connectedTeamIds: (c as { connected_team_ids?: string[] }).connected_team_ids ?? [],

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Slack @slack/web-api conversations.inviteShared response invite_id url and users.conversations connected_team_ids typing

💡 Result:

The Slack conversations.inviteShared API method and the users.conversations API method handle information about shared channels and team connections as follows: conversations.inviteShared Response Fields When successfully calling conversations.inviteShared, the API returns an invite_id [1][2]. If you provide an email address in your request, the response typically includes a url field—a unique invitation link for the recipient to join the Slack Connect channel [1]. However, please note that when external_limited is set to true (which is the default behavior), both the url and conf_code fields are omitted from the success response [1]. users.conversations and Connected Team IDs The users.conversations method returns a list of conversation objects [3][4]. While these objects contain various metadata, the users.conversations method specifically does not include the connected_team_ids field [5]. To retrieve information about the workspaces connected to a shared channel (such as connected_team_ids), you must use the conversations.info method, which provides the full conversation object, including shared_team_ids, connected_team_ids, and other relevant Slack Connect status fields [6][5][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate file ---'
sed -n '110,210p' apps/webapp/app/services/supportSlackChannel.server.ts
printf '%s\n' '--- Slack package declarations and versions ---'
rg -n --hidden --glob 'package.json' --glob 'pnpm-lock.yaml' --glob 'yarn.lock' --glob 'package-lock.json' '"`@slack/web-api`"|`@slack/web-api`@|web-api' .
printf '%s\n' '--- related API usage ---'
rg -n 'inviteShared|users\.conversations|connected_team_ids|is_ext_shared|invite_id' apps packages --glob '*.{ts,tsx,js,jsx,json}' 2>/dev/null | head -200

Repository: triggerdotdev/trigger.dev

Length of output: 5800


🏁 Script executed:

#!/bin/bash
set -e
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL https://registry.npmjs.org/@slack%2fweb-api/7.16.0 \
  -o "$tmpdir/metadata.json"
tarball="$(node -e 'const p=require(process.argv[1]); console.log(p.dist.tarball)' "$tmpdir/metadata.json")"
curl -fsSL "$tarball" -o "$tmpdir/package.tgz"
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"

printf '%s\n' '--- package metadata ---'
node -e 'const p=require(process.argv[1]); console.log(JSON.stringify({version:p.version, dist:p.dist}, null, 2))' "$tmpdir/metadata.json"

printf '%s\n' '--- inviteShared response declarations ---'
rg -n -C 8 'interface ConversationsInviteSharedResponse|invite_id|url' "$tmpdir/package" \
  --glob '*ConversationsInviteSharedResponse*' --glob '*.d.ts'

printf '%s\n' '--- users.conversations response and conversation declarations ---'
rg -n -C 8 'UsersConversationsResponse|connected_team_ids|is_ext_shared' "$tmpdir/package" \
  --glob '*.d.ts' | head -240

Repository: triggerdotdev/trigger.dev

Length of output: 50382


🌐 Web query:

site:docs.slack.dev/reference/methods/conversations.inviteShared conversations.connect:write invite_id url external_limited

💡 Result:

There is no publicly documented API method named conversations.connect:write, nor are there official references to the specific parameters invite_id, url, or external_limited in the context of a Slack or similar messaging API [1]. It is possible that these terms refer to a private, custom, or deprecated API, or they may be incorrectly formatted. You may want to verify the source or documentation associated with the specific system or platform you are investigating [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL https://registry.npmjs.org/@slack%2fweb-api/7.16.0 -o "$tmpdir/metadata.json"
tarball="$(node -e 'const p=require(process.argv[1]); console.log(p.dist.tarball)' "$tmpdir/metadata.json")"
curl -fsSL "$tarball" -o "$tmpdir/package.tgz"
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"

printf '%s\n' '--- inviteShared response declarations ---'
rg -n -C 8 'interface ConversationsInviteSharedResponse|invite_id|url' "$tmpdir/package" \
  --glob '*ConversationsInviteSharedResponse*' --glob '*.d.ts'

printf '%s\n' '--- users.conversations response and conversation declarations ---'
rg -n -C 8 'UsersConversationsResponse|connected_team_ids|is_ext_shared' "$tmpdir/package" \
  --glob '*.d.ts' | head -240

Repository: triggerdotdev/trigger.dev

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -e
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL https://registry.npmjs.org/@slack%2fweb-api/7.16.0 -o "$tmpdir/metadata.json"
tarball="$(node -e 'const p=require(process.argv[1]); console.log(p.dist.tarball)' "$tmpdir/metadata.json")"
curl -fsSL "$tarball" -o "$tmpdir/package.tgz"
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"

printf '%s\n' '--- matching declaration files ---'
find "$tmpdir/package/dist/types/response" -type f \
  \( -iname '*InviteShared*' -o -iname '*UsersConversations*' \) -print

printf '%s\n' '--- inviteShared declaration ---'
find "$tmpdir/package/dist/types/response" -type f -iname '*InviteShared*' -exec sh -c 'for f do echo "FILE: $f"; cat "$f"; done' sh {} +

printf '%s\n' '--- users.conversations declarations ---'
find "$tmpdir/package/dist/types/response" -type f -iname '*UsersConversations*' -exec sh -c 'for f do echo "FILE: $f"; cat "$f"; done' sh {} +

printf '%s\n' '--- exact connected-team fields in response declarations ---'
rg -n 'connected_team_ids|shared_team_ids|is_ext_shared' "$tmpdir/package/dist/types/response" \
  --glob '*Conversations*.d.ts' --glob '*Users*.d.ts'

Repository: triggerdotdev/trigger.dev

Length of output: 13249


🌐 Web query:

Slack API conversations.inviteShared required OAuth scope conversations.connect:write

💡 Result:

Yes, the Slack API method conversations.inviteShared requires the conversations.connect:write OAuth scope [1][2]. This scope grants your app the necessary permissions to create Slack Connect invitations for channels that it has been added to [2].

Citations:


🌐 Web query:

Slack API users.conversations connected_team_ids response field @slack/web-api 7.16.0

💡 Result:

The connected_team_ids field is an optional array of strings that contains the IDs of external workspaces connected to a shared channel [1]. While this field is part of the conversation object structure [1][2], it is important to note how it relates to specific Slack API methods: 1. conversations.info: This method returns full conversation objects, which include the connected_team_ids field when applicable [1][3]. 2. users.conversations: This method returns a list of "limited" conversation objects [4]. Per the Slack developer documentation, these objects are intentionally limited in scope [4]. Consequently, connected_team_ids is generally not included in the response from users.conversations [4]. Regarding the @slack/web-api library version 7.16.0, the library's TypeScript definitions include connected_team_ids within the Channel interface (which represents the conversation object) [5]. However, the availability of this data in the response depends on the API endpoint being called [4]. If your application requires connected_team_ids, you should use conversations.info for the specific channel ID rather than relying on the list returned by users.conversations [4][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL https://registry.npmjs.org/@slack%2fweb-api/7.16.0 -o "$tmpdir/metadata.json"
tarball="$(node -e 'const p=require(process.argv[1]); console.log(p.dist.tarball)' "$tmpdir/metadata.json")"
curl -fsSL "$tarball" -o "$tmpdir/package.tgz"
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"

find "$tmpdir/package/dist/types/response" -type f \
  \( -iname '*InviteShared*' -o -iname '*UsersConversations*' \) -print

find "$tmpdir/package/dist/types/response" -type f -iname '*InviteShared*' \
  -exec sh -c 'for f do echo "FILE: $f"; cat "$f"; done' sh {} +

find "$tmpdir/package/dist/types/response" -type f -iname '*UsersConversations*' \
  -exec sh -c 'for f do echo "FILE: $f"; cat "$f"; done' sh {} +

rg -n 'connected_team_ids|shared_team_ids|is_ext_shared' \
  "$tmpdir/package/dist/types/response" \
  --glob '*Conversations*.d.ts' --glob '*Users*.d.ts'

Repository: triggerdotdev/trigger.dev

Length of output: 13079


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Slack OAuth scopes in repository configuration ---'
rg -n --hidden --glob '!pnpm-lock.yaml' --glob '!node_modules' \
  'conversations\.connect:write|oauth_config|botScopes|scopes|SLACK' \
  apps packages .github 2>/dev/null | head -240

Repository: triggerdotdev/trigger.dev

Length of output: 26831


Fetch connected team IDs with conversations.info.

users.conversations includes is_ext_shared, but its Channel type and limited response do not include connected_team_ids. The cast at Line 149 cannot provide this data. Fetch each matching channel with conversations.info and read connected_team_ids from that response. Provision the bot token with conversations.connect:write for conversations.inviteShared; its invite_id and optional url fields are correctly typed.

Comment on lines +198 to +204
"supportChannel.provision": {
schema: OrganizationSupportChannelSchema,
visibilityTimeoutMs: 30_000,
retry: {
maxAttempts: 3,
},
},

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how Redis Worker classifies completed versus failed handler attempts.
ast-grep outline packages/redis-worker/src/worker.ts --items all
rg -n -C 5 'maxAttempts|retry|attempt|catch|throw|handler' packages/redis-worker/src

# Trace the support-channel result contract and its worker caller.
rg -n -C 8 'provisionOrganizationSupportChannel|status: "failed"|supportChannel\.provision' \
  apps/webapp/app/services/supportSlackChannel.server.ts \
  apps/webapp/app/v3/commonWorker.server.ts

Repository: triggerdotdev/trigger.dev

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- common worker registration and handler ---'
sed -n '180,215p;315,350p' apps/webapp/app/v3/commonWorker.server.ts

printf '%s\n' '--- provisioning service result paths ---'
rg -n -C 10 'provisionOrganizationSupportChannel|status: "failed"|status: "success"|return \{' \
  apps/webapp/app/services/supportSlackChannel.server.ts

printf '%s\n' '--- Redis worker implementation and retry configuration ---'
fd -t f -i 'worker' packages/redis-worker/src
rg -n -C 8 --glob '*.ts' 'class .*Worker|onMessage|handler|retry|FixedDelayRetry|ctx\.fail|failMessage|completeMessage' \
  packages/redis-worker/src | head -n 500

Repository: triggerdotdev/trigger.dev

Length of output: 49734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- retry calculation and non-batch failure path ---'
sed -n '70,108p;892,1025p' packages/redis-worker/src/worker.ts

printf '%s\n' '--- support-channel enqueue callers and entitlement checks ---'
rg -n -C 12 'supportChannel\.provision|OrganizationSupportChannel|support channel|entitlement|billing' \
  apps/webapp/app/v3 apps/webapp/app/services | head -n 500

printf '%s\n' '--- structural verifier ---'
python3 - <<'PY'
from pathlib import Path

worker = Path("packages/redis-worker/src/worker.ts").read_text()
service = Path("apps/webapp/app/services/supportSlackChannel.server.ts").read_text()
common = Path("apps/webapp/app/v3/commonWorker.server.ts").read_text()

checks = {
    "handler_awaits_service_without_result_check":
        'await provisionOrganizationSupportChannel({' in common
        and 'const result = await provisionOrganizationSupportChannel' not in common,
    "service_returns_failed_instead_of_throwing":
        '): Promise<{ status: "invited" | "exists" | "failed"; channelId?: string }>' in service
        and service.count('return { status: "failed" }') >= 5,
    "worker_requeues_only_after_handler_rejection":
        'handler({ id, payload: item' in worker
        and 'Attempt requeue logic.' in worker,
    "max_attempts_three_is_configured":
        '"supportChannel.provision": {' in common
        and 'maxAttempts: 3' in common,
}

for name, passed in checks.items():
    print(f"{name}={passed}")
if not all(checks.values()):
    raise SystemExit("structural verification failed")
PY

Repository: triggerdotdev/trigger.dev

Length of output: 48105


Propagate retryable provisioning failures.

provisionOrganizationSupportChannel returns { status: "failed" } for Slack and owner-resolution failures. The handler ignores this result, so it resolves successfully and the Redis Worker acknowledges the job instead of using maxAttempts: 3. Return a retryable failure signal or throw a retryable error after persisting FAILED. Classify permanent failures, such as a missing owner, so they are not retried.

Comment on lines +333 to +341
"supportChannel.provision": async ({ payload }) => {
const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN);
if (!slackClient) return;
await provisionOrganizationSupportChannel({
organizationId: payload.organizationId,
prisma,
slackClient,
});
},

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recheck the entitlement before provisioning.

Line 336 provisions a queued organization without checking the current supportChannel entitlement. The organization can lose eligibility after the route action enqueues the job. Then the worker can create or invite users to a paid Slack channel for an unentitled organization.

Recheck the entitlement in provisionOrganizationSupportChannel immediately before Slack operations. Set a retryable user-visible state when access is no longer enabled. This violates the PR objective that access is fail-closed until the entitlement is enabled.

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.
An out-of-band POST would flip a LINKED row back to PROVISIONING and
re-send the Slack invite. Redirects instead, and gates both the loader and
the action on the feature flag.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +322 to +329
} catch (error) {
await setStatus(prisma, organizationId, "FAILED", {
slackChannelId: channelId,
slackChannelName: channelName,
lastError: error instanceof Error ? error.message : String(error),
});
return { status: "failed" };
}

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.

🟡 A support channel that was previously shut down can never be reopened after one failed attempt

When reopening a previously shut-down channel fails, the record is left in a state (setStatus(... "FAILED") at apps/webapp/app/services/supportSlackChannel.server.ts:323-327) that makes every later attempt skip the reopening step, so the customer can never get access again.

Impact: An organization that re-subscribes keeps hitting "Something went wrong setting up your channel" with no way to recover without manual intervention.

State machine detail

provisionOrganizationSupportChannel only calls slackClient.unarchiveChannel when the persisted row has status === "ARCHIVED" (apps/webapp/app/services/supportSlackChannel.server.ts:299-309). If unarchiveChannel (or the subsequent invite) throws, the catch sets the row to FAILED while keeping slackChannelId (...:322-329). On the next attempt the ARCHIVED branch no longer matches, so control falls to the "reuse persisted channel" path (...:335-373) which invites into a channel that is still archived in Slack — failing again, permanently.

A fix would be to keep the row ARCHIVED on failure (or track "needs unarchive" separately) so the unarchive step is retried.

Open in Devin Review

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants