feat(webapp): self-serve private Slack support channel - #4593
Conversation
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.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughAdded 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)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
| 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" }, | ||
| }); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| "supportChannel.provision": async ({ payload }) => { | ||
| const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN); | ||
| if (!slackClient) return; | ||
| await provisionOrganizationSupportChannel({ | ||
| organizationId: payload.organizationId, | ||
| prisma, | ||
| slackClient, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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"; | ||
|
|
There was a problem hiding this comment.
🟡 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).
Was this helpful? React with 👍 or 👎 to provide feedback.
| } catch (error) { | ||
| await setStatus(prisma, organizationId, "FAILED", { | ||
| slackChannelId: channelId, | ||
| slackChannelName: channelName, | ||
| lastError: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| return { status: "failed" }; | ||
| } |
There was a problem hiding this comment.
🔍 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Observability mapAs of 20/100 over 427 measured of 443 entry points (base 20, no change) What this PR changed
FIX FIRST
AUDIT 3 of 50 sensitive mutations record an actor. 47 without one. What the score is made ofThe 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. |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
apps/webapp/app/routes/admin.slack-channels.tsx (2)
58-81: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider parallelizing the per-channel and per-organization lookups.
The loader awaits
client.getTeamDomainsonce per distinct external team, andgetCurrentPlanonce 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 winAdd an accessible name to the organization select.
The
<select>has no associated label, so screen readers announce it without a purpose. Add anaria-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 winAdd coverage for the Slack "already in that state" handling.
isSlackErrorCodedecides whetherarchiveChannelandunarchiveChannelswallowalready_archivedandnot_archived.FakeSupportSlackClientnever throws for those paths, so the predicate is untested. The predicate reads a nesteddata.errorfield, which is easy to break during refactoring.Add a small unit test that passes a Slack-shaped error object (
{ data: { error: "already_archived" } }) throughSupportSlackClientLive.archiveChannelbehavior, or export and test the predicate directly.apps/webapp/test/supportSlackChannelModel.test.ts (1)
28-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert 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 theorganizationIdandslackChannelIdunique indexes. Assert the Prisma codeP2002so 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
📒 Files selected for processing (15)
.server-changes/slack-support-channel.mdapps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsxapps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsxapps/webapp/app/routes/admin.slack-channels.tsxapps/webapp/app/routes/admin.tsxapps/webapp/app/services/supportSlackChannel.server.tsapps/webapp/app/utils/pathBuilder.tsapps/webapp/app/v3/commonWorker.server.tsapps/webapp/test/pathBuilder.supportPath.test.tsapps/webapp/test/supportChannelSettings.e2e.full.test.tsapps/webapp/test/supportSlackChannel.test.tsapps/webapp/test/supportSlackChannelModel.test.tsinternal-packages/database/prisma/migrations/20260812150000_add_organization_support_channel/migration.sqlinternal-packages/database/prisma/migrations/20260812150100_add_organization_support_channel_archived_status/migration.sqlinternal-packages/database/prisma/schema.prisma
| 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" }, | ||
| }); |
There was a problem hiding this comment.
🗄️ 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.
| 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 }); | |
| } |
| ) : 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} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The invite link is unreachable, and the channel name is printed twice.
Two problems in this branch:
- Line 169 prefers
slackChannelIdoverinviteUrl.provisionOrganizationSupportChannelalways persistsslackChannelIdbefore it records the invite, so statusINVITEDalways 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 throughslack.com/app_redirect. ShowinviteUrlwhile the status isINVITED, and show the deep link once the status isLINKED. - 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.
| ) : 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} |
| 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 ?? [], |
There was a problem hiding this comment.
🗄️ 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:
- 1: https://docs.slack.dev/reference/methods/conversations.inviteshared.md
- 2: https://github.com/slackapi/node-slack-sdk/blob/main/packages/web-api/src/types/response/ConversationsInviteSharedResponse.ts
- 3: https://docs.slack.dev/reference/methods/users.conversations.md
- 4: https://docs.slack.dev/reference/methods/users.conversations
- 5: https://docs.slack.dev/apis/slack-connect/
- 6: https://docs.slack.dev/reference/objects/conversation-object
- 7: https://docs.slack.dev/reference/methods/conversations.info.md
🏁 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 -200Repository: 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 -240Repository: 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 -240Repository: 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:
- 1: https://docs.slack.dev/reference/methods/conversations.inviteshared.md
- 2: https://docs.slack.dev/reference/scopes/conversations.connect.write.md
🌐 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:
- 1: https://docs.slack.dev/reference/objects/conversation-object
- 2: https://github.com/slack-go/slack/blob/master/conversation.go
- 3: https://docs.slack.dev/reference/methods/conversations.info.md
- 4: https://docs.slack.dev/reference/methods/users.conversations
- 5: https://github.com/slackapi/node-slack-sdk/blob/main/packages/web-api/src/types/response/UsersConversationsResponse.ts
🏁 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 -240Repository: 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.
| "supportChannel.provision": { | ||
| schema: OrganizationSupportChannelSchema, | ||
| visibilityTimeoutMs: 30_000, | ||
| retry: { | ||
| maxAttempts: 3, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
🩺 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.tsRepository: 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 500Repository: 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")
PYRepository: 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.
| "supportChannel.provision": async ({ payload }) => { | ||
| const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN); | ||
| if (!slackClient) return; | ||
| await provisionOrganizationSupportChannel({ | ||
| organizationId: payload.organizationId, | ||
| prisma, | ||
| slackClient, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
🎯 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.
| } catch (error) { | ||
| await setStatus(prisma, organizationId, "FAILED", { | ||
| slackChannelId: channelId, | ||
| slackChannelName: channelName, | ||
| lastError: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| return { status: "failed" }; | ||
| } |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
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— discoverscus-*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
supportChannelplan 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
OrganizationSupportChannel.SLACK_BOT_TOKENis optional — with no token the worker job is a no-op.manage:billing, enforced on the action and mirrored as a disabled button.TRI-11240