-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): self-serve private Slack support channel #4593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
isshaddad
wants to merge
28
commits into
main
Choose a base branch
from
connect-slack-button-settings
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,467
−3
Open
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
0f2a006
feat(webapp): add support Slack client + channel-name helper
D-K-P df5637e
Merge origin/main into connect-slack-button-settings
D-K-P 1b24d9d
feat(database): add OrganizationSupportChannel model
D-K-P fcbc5da
feat(webapp): add support-channel provisioning orchestrator
D-K-P c23ad8e
feat(webapp): enqueue Slack support-channel provisioning via the comm…
D-K-P 7d81d7a
feat(webapp): add support-channel settings nav item
D-K-P ece5820
feat(webapp): add Slack support-channel settings route with paid gate
D-K-P 6242c0c
feat(webapp): add support-channel settings page UI
D-K-P 0a688d3
feat(webapp): add Slack Connect channel discovery client
D-K-P 94a48f7
feat(webapp): add support-channel org match proposer
D-K-P e07db7c
feat(webapp): add support-channel link writer
D-K-P f0e1c6f
feat(webapp): add admin Slack support-channel linking page
D-K-P 05aae7d
chore(webapp): add Slack support channel release note
D-K-P 16fd42c
fix(webapp): make Slack support-channel provisioning retry-safe and s…
D-K-P 6afe7e2
feat(webapp): support-channel unlink (archive) and re-provision on re…
D-K-P 4abbd10
feat(webapp): flag downgraded orgs and add unlink in the admin Slack …
D-K-P bcc55d6
chore(webapp): tidy Slack support-channel release note and admin unlink
D-K-P 626f87d
feat(webapp): gate Slack support channel on plan entitlement and fix …
D-K-P d9802d4
Merge origin/main into connect-slack-button-settings
isshaddad a6644b0
chore(database): re-date the support-channel migrations to sort after…
isshaddad cb475d9
chore(webapp): format the support-channel model test
isshaddad 37f1064
fix(webapp): gate the Slack support channel behind manage:billing
isshaddad ba1f9eb
fix(webapp): invite the longest-standing admin to the support channel
isshaddad d4539c0
fix(webapp): require an explicit org pick in the admin Slack linking …
isshaddad 949177a
chore(webapp): drop the redundant support-channel release note
isshaddad 00bc0a6
feat(webapp): put the Slack support channel behind a feature flag
isshaddad b8271bd
fix(webapp): no-op the connect action when a channel already exists
isshaddad 6be17a3
fix(webapp): address support-channel review findings
isshaddad File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| Owners of paid organizations can set up a private Slack support channel from Organization settings. Free plans see an upgrade option instead. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
245 changes: 245 additions & 0 deletions
245
apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| import { json, redirect } from "@remix-run/node"; | ||
| import { Form, useActionData, useNavigation } from "@remix-run/react"; | ||
| import { typedjson, useTypedLoaderData } from "remix-typedjson"; | ||
| import { z } from "zod"; | ||
| import { | ||
| MainHorizontallyCenteredContainer, | ||
| PageBody, | ||
| PageContainer, | ||
| } from "~/components/layout/AppLayout"; | ||
| import { Button, LinkButton } from "~/components/primitives/Buttons"; | ||
| import { Header2 } from "~/components/primitives/Headers"; | ||
| import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; | ||
| import { Paragraph } from "~/components/primitives/Paragraph"; | ||
| import { prisma } from "~/db.server"; | ||
| import { useOrganization } from "~/hooks/useOrganizations"; | ||
| import { useShowSelfServe } from "~/hooks/useShowSelfServe"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { getCurrentPlan } from "~/services/platform.v3.server"; | ||
| import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server"; | ||
| import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; | ||
| import { getUserId } from "~/services/session.server"; | ||
| import { | ||
| enqueueProvisionSupportChannel, | ||
| hasPrivateSlackSupport, | ||
| } from "~/services/supportSlackChannel.server"; | ||
| import { | ||
| OrganizationParamsSchema, | ||
| organizationSupportPath, | ||
| v3BillingPath, | ||
| } from "~/utils/pathBuilder"; | ||
|
|
||
| async function resolveOrg(slug: string, userId: string) { | ||
| // Scoped to membership: ability.can is not a tenant floor (the cloud RBAC | ||
| // plugin returns a permissive ability for a non-member), so without the | ||
| // members filter a non-member reaches the handler for any org slug. | ||
| return prisma.organization.findFirst({ | ||
| where: { slug, members: { some: { userId } }, deletedAt: null }, | ||
| select: { id: true }, | ||
| }); | ||
| } | ||
|
|
||
| async function orgScope(params: { organizationSlug: string }, request: Request) { | ||
| const userId = await getUserId(request); | ||
| if (!userId) return {}; | ||
| const org = await resolveOrg(params.organizationSlug, userId); | ||
| return org ? { organizationId: org.id } : {}; | ||
| } | ||
|
|
||
| export const loader = dashboardLoader( | ||
| { | ||
| params: OrganizationParamsSchema, | ||
| context: orgScope, | ||
| // Plan-gated before role-gated: unentitled orgs render the upsell whatever | ||
| // their role, so manage:billing is enforced on the action (and mirrored as | ||
| // a disabled button here) rather than on the whole route. | ||
| }, | ||
| async ({ context, ability }) => { | ||
| const organizationId = context.organizationId; | ||
| if (!organizationId) { | ||
| throw new Response("Not Found", { status: 404 }); | ||
| } | ||
|
|
||
| // Flag off means the feature does not exist yet, so 404 rather than render | ||
| // an upsell for something nobody can buy. | ||
| if (!(await isSupportChannelEnabled(organizationId))) { | ||
| throw new Response("Not Found", { status: 404 }); | ||
| } | ||
|
|
||
| const supportChannel = await prisma.organizationSupportChannel.findFirst({ | ||
| where: { organizationId }, | ||
| }); | ||
|
|
||
| const plan = await getCurrentPlan(organizationId); | ||
|
|
||
| return typedjson({ | ||
| supportChannel, | ||
| hasSupportAccess: hasPrivateSlackSupport(plan), | ||
| canManage: ability.can("manage", { type: "billing" }), | ||
| }); | ||
| } | ||
| ); | ||
|
|
||
| const ActionSchema = z.object({ | ||
| intent: z.literal("connect"), | ||
| }); | ||
|
|
||
| export const action = dashboardAction( | ||
| { | ||
| params: OrganizationParamsSchema, | ||
| context: orgScope, | ||
| authorization: { action: "manage", resource: { type: "billing" } }, | ||
| }, | ||
| async ({ request, params, context }) => { | ||
| const organizationId = context.organizationId; | ||
| if (!organizationId) { | ||
| throw new Response("Not Found", { status: 404 }); | ||
| } | ||
|
|
||
| if (!(await isSupportChannelEnabled(organizationId))) { | ||
| throw new Response("Not Found", { status: 404 }); | ||
| } | ||
|
|
||
| const formData = await request.formData(); | ||
| const result = ActionSchema.safeParse({ intent: formData.get("intent") }); | ||
| if (!result.success) { | ||
| return json({ error: "Invalid action" }, { status: 400 }); | ||
| } | ||
|
|
||
| const plan = await getCurrentPlan(organizationId); | ||
| if (!hasPrivateSlackSupport(plan)) { | ||
| return json({ error: "Upgrade required" }, { status: 403 }); | ||
| } | ||
|
|
||
| // A live channel already covers this org. Without this an out-of-band POST | ||
| // would flip the row back to PROVISIONING and re-send the Slack invite. | ||
| const existing = await prisma.organizationSupportChannel.findFirst({ | ||
| where: { organizationId }, | ||
| select: { status: true }, | ||
| }); | ||
| if (existing?.status === "INVITED" || existing?.status === "LINKED") { | ||
| return redirect(organizationSupportPath({ slug: params.organizationSlug })); | ||
| } | ||
|
|
||
| // Persist before enqueueing. The worker can finish between the two, and if | ||
| // the write came second it would clobber INVITED back to PROVISIONING — | ||
| // leaving the page stuck, with the job already deduped so nothing retries. | ||
| await prisma.organizationSupportChannel.upsert({ | ||
| where: { organizationId }, | ||
| create: { organizationId, status: "PROVISIONING" }, | ||
| update: { status: "PROVISIONING", lastError: null }, | ||
| }); | ||
|
|
||
| try { | ||
| await enqueueProvisionSupportChannel({ organizationId }); | ||
| } catch (error) { | ||
| logger.error("Failed to enqueue support channel provisioning", { organizationId, error }); | ||
| await prisma.organizationSupportChannel.update({ | ||
| where: { organizationId }, | ||
| data: { status: "FAILED", lastError: "Failed to enqueue provisioning" }, | ||
| }); | ||
| return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 }); | ||
| } | ||
|
|
||
| return redirect(organizationSupportPath({ slug: params.organizationSlug })); | ||
| } | ||
| ); | ||
|
|
||
| export default function Page() { | ||
| const { supportChannel, hasSupportAccess, canManage } = useTypedLoaderData<typeof loader>(); | ||
| const actionData = useActionData<{ error?: string }>(); | ||
| const organization = useOrganization(); | ||
| const showSelfServe = useShowSelfServe(); | ||
| const navigation = useNavigation(); | ||
| const isSubmitting = navigation.state !== "idle"; | ||
|
|
||
|
isshaddad marked this conversation as resolved.
|
||
| return ( | ||
| <PageContainer> | ||
| <NavBar> | ||
| <PageTitle title="Slack support channel" /> | ||
| </NavBar> | ||
| <PageBody> | ||
| <MainHorizontallyCenteredContainer> | ||
| <Header2 spacing>Private Slack support channel</Header2> | ||
| <Paragraph spacing> | ||
| Get a private Slack channel shared with the Trigger.dev team for direct support. | ||
| </Paragraph> | ||
|
|
||
| {!hasSupportAccess ? ( | ||
| <div className="flex flex-col gap-3"> | ||
| <Paragraph variant="small" className="text-text-dimmed"> | ||
| A private Slack support channel is available on Pro and Enterprise plans. | ||
| </Paragraph> | ||
| {showSelfServe ? ( | ||
| <LinkButton variant="primary/medium" to={v3BillingPath(organization)}> | ||
| Upgrade to unlock | ||
| </LinkButton> | ||
| ) : ( | ||
| <LinkButton variant="secondary/medium" to={v3BillingPath(organization)}> | ||
| Contact us | ||
| </LinkButton> | ||
| )} | ||
| </div> | ||
| ) : supportChannel?.status === "INVITED" || supportChannel?.status === "LINKED" ? ( | ||
| <div className="flex flex-col gap-3"> | ||
| <Paragraph variant="small"> | ||
| Your private Slack support channel | ||
| {supportChannel.slackChannelName ? ` #${supportChannel.slackChannelName}` : ""} is | ||
| ready. | ||
| {supportChannel.status === "INVITED" && supportChannel.invitedEmail | ||
| ? ` We've sent a Slack Connect invite to ${supportChannel.invitedEmail}.` | ||
| : ""} | ||
| </Paragraph> | ||
| {/* While INVITED the owner has not joined yet, so the deep link | ||
| would 404 for them — offer the Slack Connect invite instead. | ||
| The channel id is always set by then, so ordering matters. */} | ||
| {supportChannel.status === "INVITED" && supportChannel.inviteUrl ? ( | ||
| <LinkButton variant="primary/medium" to={supportChannel.inviteUrl}> | ||
| Join the channel | ||
| </LinkButton> | ||
| ) : supportChannel.slackChannelId ? ( | ||
| <LinkButton | ||
| variant="primary/medium" | ||
| to={`https://slack.com/app_redirect?channel=${supportChannel.slackChannelId}`} | ||
| > | ||
| Open in Slack | ||
| </LinkButton> | ||
| ) : null} | ||
|
isshaddad marked this conversation as resolved.
|
||
| </div> | ||
| ) : supportChannel?.status === "PROVISIONING" ? ( | ||
| <Paragraph variant="small" className="text-text-dimmed"> | ||
| Setting up your channel. Check your email shortly for the Slack Connect invite. | ||
| </Paragraph> | ||
| ) : ( | ||
| <Form method="post" className="flex flex-col gap-3"> | ||
| {actionData?.error ? ( | ||
| <Paragraph variant="small" className="text-error"> | ||
| {actionData.error} | ||
| </Paragraph> | ||
| ) : null} | ||
| {supportChannel?.status === "FAILED" ? ( | ||
| <Paragraph variant="small" className="text-error"> | ||
| Something went wrong setting up your channel. Try again, or contact us. | ||
| </Paragraph> | ||
| ) : null} | ||
| <Button | ||
| type="submit" | ||
| name="intent" | ||
| value="connect" | ||
| variant="primary/medium" | ||
| disabled={isSubmitting || !canManage} | ||
| tooltip={ | ||
| canManage | ||
| ? undefined | ||
| : "You don't have permission to connect a Slack support channel" | ||
| } | ||
| > | ||
| Connect to Slack | ||
| </Button> | ||
| </Form> | ||
| )} | ||
| </MainHorizontallyCenteredContainer> | ||
| </PageBody> | ||
| </PageContainer> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Rebuilding a support channel after it was taken away never works
The organization's support channel record is reset to "setting up" (
upsertatapps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx:127-131) before the background job runs, so the job no longer knows the channel was previously shut down and every attempt to bring it back fails.Impact: A customer whose Slack support channel was unlinked can click Connect forever and only ever gets an error; the channel is never restored.
ARCHIVED status is overwritten before the worker reads it, making the unarchive branch unreachable
unlinkSupportChannel(apps/webapp/app/services/supportSlackChannel.server.ts:440-463) archives the Slack channel and leaves the row at statusARCHIVEDwithslackChannelIdretained. The support page then renders the "Connect to Slack" form for that state.When the user submits, the action unconditionally writes
status: "PROVISIONING"before enqueuing (...settings.support.tsx:127-131). By the time the worker callsprovisionOrganizationSupportChannel,existing.statusisPROVISIONING, so the dedicated re-upgrade branch atapps/webapp/app/services/supportSlackChannel.server.ts:323(which callsunarchiveChannelfirst) is skipped. Execution falls through to the "reuse the persisted channel" path atapps/webapp/app/services/supportSlackChannel.server.ts:363-405, which callsinviteSharedByEmaildirectly on a channel that is still archived in Slack — exactly the failure mode the comment atapps/webapp/app/services/supportSlackChannel.server.ts:347-350warns about. The row lands onFAILED, and every retry repeats the same path.The only enqueue site for
supportChannel.provisionis this action (enqueueProvisionSupportChannel,apps/webapp/app/services/supportSlackChannel.server.ts:648), so in practice the unarchive branch is dead code and the archived → reconnect flow is permanently broken.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.