diff --git a/.server-changes/slack-support-channel.md b/.server-changes/slack-support-channel.md new file mode 100644 index 0000000000..cc747839f7 --- /dev/null +++ b/.server-changes/slack-support-channel.md @@ -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. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 8790e47942..677a268929 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -18,6 +18,7 @@ import { organizationSettingsPath, organizationSlackIntegrationPath, organizationSsoPath, + organizationSupportPath, organizationTeamPath, organizationVercelIntegrationPath, rootPath, @@ -49,11 +50,13 @@ export function OrganizationSettingsSideMenu({ buildInfo, isUsingPlugin, isSsoUsingPlugin, + supportChannelEnabled, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; + supportChannelEnabled: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -135,6 +138,17 @@ export function OrganizationSettingsSideMenu({ to={organizationTeamPath(organization)} data-action="team" /> + {isManagedCloud && supportChannelEnabled && ( + + )} {featureFlags.hasPrivateConnections && ( { + 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(); + const actionData = useActionData<{ error?: string }>(); + const organization = useOrganization(); + const showSelfServe = useShowSelfServe(); + const navigation = useNavigation(); + const isSubmitting = navigation.state !== "idle"; + + return ( + + + + + + + Private Slack support channel + + Get a private Slack channel shared with the Trigger.dev team for direct support. + + + {!hasSupportAccess ? ( +
+ + A private Slack support channel is available on Pro and Enterprise plans. + + {showSelfServe ? ( + + Upgrade to unlock + + ) : ( + + Contact us + + )} +
+ ) : supportChannel?.status === "INVITED" || supportChannel?.status === "LINKED" ? ( +
+ + 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}.` + : ""} + + {/* 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 ? ( + + Join the channel + + ) : supportChannel.slackChannelId ? ( + + Open in Slack + + ) : null} +
+ ) : supportChannel?.status === "PROVISIONING" ? ( + + Setting up your channel. Check your email shortly for the Slack Connect invite. + + ) : ( +
+ {actionData?.error ? ( + + {actionData.error} + + ) : null} + {supportChannel?.status === "FAILED" ? ( + + Something went wrong setting up your channel. Try again, or contact us. + + ) : null} + +
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index 77f471713c..6003b6e8b2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -9,16 +9,28 @@ import { type BuildInfo, OrganizationSettingsSideMenu, } from "~/components/navigation/OrganizationSettingsSideMenu"; +import { prisma } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; +import { getUserId } from "~/services/session.server"; import { ssoController } from "~/services/sso.server"; +import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server"; const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([ + const userId = await getUserId(request); + const organization = userId + ? await prisma.organization.findFirst({ + where: { slug: params.organizationSlug ?? "", members: { some: { userId } } }, + select: { id: true }, + }) + : null; + + const [isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled] = await Promise.all([ rbac.isUsingPlugin(), ssoController.isUsingPlugin(), + organization ? isSupportChannelEnabled(organization.id) : Promise.resolve(false), ]); return typedjson({ buildInfo: { @@ -30,6 +42,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } satisfies BuildInfo, isUsingPlugin, isSsoUsingPlugin, + supportChannelEnabled, }); }; @@ -37,11 +50,13 @@ function SettingsChrome({ buildInfo, isUsingPlugin, isSsoUsingPlugin, + supportChannelEnabled, children, }: { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; + supportChannelEnabled: boolean; children: ReactNode; }) { const organization = useOrganization(); @@ -54,6 +69,7 @@ function SettingsChrome({ buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} isSsoUsingPlugin={isSsoUsingPlugin} + supportChannelEnabled={supportChannelEnabled} /> {children} @@ -62,13 +78,15 @@ function SettingsChrome({ } export default function Page() { - const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData(); + const { buildInfo, isUsingPlugin, isSsoUsingPlugin, supportChannelEnabled } = + useTypedLoaderData(); return ( @@ -81,7 +99,12 @@ export default function Page() { // available via useRouteLoaderData. export function ErrorBoundary() { const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as - | { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean } + | { + buildInfo: BuildInfo; + isUsingPlugin: boolean; + isSsoUsingPlugin: boolean; + supportChannelEnabled: boolean; + } | undefined; if (!data) { @@ -93,6 +116,7 @@ export function ErrorBoundary() { buildInfo={data.buildInfo} isUsingPlugin={data.isUsingPlugin} isSsoUsingPlugin={data.isSsoUsingPlugin} + supportChannelEnabled={data.supportChannelEnabled} > diff --git a/apps/webapp/app/routes/admin.slack-channels.tsx b/apps/webapp/app/routes/admin.slack-channels.tsx new file mode 100644 index 0000000000..5dbedd61cc --- /dev/null +++ b/apps/webapp/app/routes/admin.slack-channels.tsx @@ -0,0 +1,404 @@ +import { useFetcher } from "@remix-run/react"; +import { useState } from "react"; +import type { OrganizationSupportChannelStatus } from "@trigger.dev/database"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { Button } from "~/components/primitives/Buttons"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { getCurrentPlan } from "~/services/platform.v3.server"; +import { + createSupportSlackClient, + createSupportSlackDiscoveryClient, + isDowngradedLink, + isPaidPlan, + linkSupportChannel, + pickExternalTeamId, + proposeOrgMatches, + unlinkSupportChannel, + type ChannelCandidate, + type MatchProposal, + type OrgCandidate, +} from "~/services/supportSlackChannel.server"; + +// No-proposal rows default to this rather than the first org in the list, so +// approving always needs a deliberate pick. Rejected server-side too. +const UNSET_ORGANIZATION_ID = "__unset__"; + +type LinkedChannelInfo = { + organizationId: string; + title: string; + status: OrganizationSupportChannelStatus; + downgraded: boolean; +}; + +export const loader = dashboardLoader({ authorization: { requireSuper: true } }, async () => { + const client = createSupportSlackDiscoveryClient(env.SLACK_BOT_TOKEN); + if (!client) { + return typedjson({ + notConfigured: true as const, + channels: [], + proposals: [], + orgs: [], + linkedChannelInfoByChannelId: {} as Record, + }); + } + + const ownTeamId = await client.ownTeamId(); + const rawChannels = await client.listCustomerChannels(); + + const teamDomainCache = new Map(); + const channels: ChannelCandidate[] = []; + for (const rawChannel of rawChannels) { + const externalTeamId = pickExternalTeamId(rawChannel.connectedTeamIds, ownTeamId); + let domains: { domain?: string; emailDomain?: string } = {}; + if (externalTeamId) { + const cached = teamDomainCache.get(externalTeamId); + if (cached) { + domains = cached; + } else { + domains = await client.getTeamDomains(externalTeamId); + teamDomainCache.set(externalTeamId, domains); + } + } + channels.push({ + channelId: rawChannel.channelId, + channelName: rawChannel.channelName, + externalTeamDomain: domains.domain, + externalTeamEmailDomain: domains.emailDomain, + }); + } + + const organizations = await prisma.organization.findMany({ + where: { deletedAt: null }, + select: { + id: true, + slug: true, + title: true, + supportChannel: { + select: { slackChannelId: true, slackChannelName: true, status: true }, + }, + members: { + where: { role: "ADMIN" }, + take: 1, + orderBy: { createdAt: "asc" }, + select: { user: { select: { email: true } } }, + }, + }, + }); + + const orgs: OrgCandidate[] = organizations.map((organization) => { + const ownerEmail = organization.members[0]?.user.email; + const ownerEmailDomain = ownerEmail?.split("@")[1]; + return { + organizationId: organization.id, + slug: organization.slug, + title: organization.title, + ownerEmailDomain, + alreadyLinked: Boolean(organization.supportChannel?.slackChannelId), + }; + }); + + // Maps a Slack channel id to its linked org + status, so the table can show + // per-channel link status and a downgraded flag. Built separately from + // `OrgCandidate` since that type only carries a boolean for matching. + // Plan lookups are cached per org since the same org can only appear once + // here, but this keeps the pattern safe if that ever changes. + const planCache = new Map(); + async function isOrgPaying(organizationId: string): Promise { + const cached = planCache.get(organizationId); + if (cached !== undefined) { + return cached; + } + const plan = await getCurrentPlan(organizationId); + const paying = isPaidPlan(plan); + planCache.set(organizationId, paying); + return paying; + } + + const linkedChannelInfoByChannelId: Record = {}; + for (const organization of organizations) { + const supportChannel = organization.supportChannel; + if (!supportChannel?.slackChannelId) { + continue; + } + const isPaying = await isOrgPaying(organization.id); + linkedChannelInfoByChannelId[supportChannel.slackChannelId] = { + organizationId: organization.id, + title: organization.title, + status: supportChannel.status, + downgraded: isDowngradedLink({ hasChannel: true, isPaying }), + }; + } + + const proposals = proposeOrgMatches(channels, orgs); + + return typedjson({ + notConfigured: false as const, + channels, + proposals, + orgs, + linkedChannelInfoByChannelId, + }); +}); + +const LinkActionBody = z.object({ + _action: z.enum(["link", "reassign"]), + channelId: z.string(), + channelName: z.string(), + organizationId: z.string().refine((value) => value !== UNSET_ORGANIZATION_ID, { + message: "Select an organization", + }), +}); + +const UnlinkActionBody = z.object({ + _action: z.literal("unlink"), + organizationId: z.string(), +}); + +const ActionBody = z.union([LinkActionBody, UnlinkActionBody]); + +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const formData = await request.formData(); + const parsed = ActionBody.safeParse(Object.fromEntries(formData)); + if (!parsed.success) { + return typedjson({ error: "Invalid form submission" }, { status: 400 }); + } + + if (parsed.data._action === "unlink") { + const { organizationId } = parsed.data; + const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN); + if (!slackClient) { + return typedjson({ error: "Slack is not configured" }, { status: 400 }); + } + + const result = await unlinkSupportChannel({ organizationId, prisma, slackClient }); + if (result.status === "not_found") { + return typedjson( + { error: "No linked channel found for this organization" }, + { status: 404 } + ); + } + + return typedjson({ success: true as const }); + } + + const { _action, channelId, channelName, organizationId } = parsed.data; + + const result = await linkSupportChannel({ + organizationId, + prisma, + channel: { channelId, channelName }, + reassign: _action === "reassign", + }); + + if (result.status === "conflict") { + return typedjson({ error: result.reason }, { status: 409 }); + } + + return typedjson({ success: true as const }); + } +); + +type LoaderChannel = ChannelCandidate; +type LoaderOrg = OrgCandidate & { organizationId: string }; + +export default function AdminSlackChannelsRoute() { + const { notConfigured, channels, proposals, orgs, linkedChannelInfoByChannelId } = + useTypedLoaderData(); + + if (notConfigured) { + return ( +
+ + Slack is not configured (missing SLACK_BOT_TOKEN). Support channel discovery is + unavailable. + +
+ ); + } + + const proposalByChannelId = new Map( + proposals.map((proposal) => [proposal.channelId, proposal]) + ); + + return ( +
+
+ + {channels.length} customer Slack Connect channel{channels.length === 1 ? "" : "s"} found. + + + + + + Channel + Status + Proposed org + Confidence + + + + + {channels.length === 0 ? ( + + No customer Slack Connect channels found + + ) : ( + channels.map((channel) => ( + + )) + )} + +
+
+
+ ); +} + +function ChannelRow({ + channel, + proposal, + orgs, + linkedInfo, +}: { + channel: LoaderChannel; + proposal: MatchProposal | undefined; + orgs: LoaderOrg[]; + linkedInfo: LinkedChannelInfo | undefined; +}) { + const fetcher = useFetcher<{ error?: string; success?: boolean }>(); + const unlinkFetcher = useFetcher<{ error?: string; success?: boolean }>(); + const [organizationId, setOrganizationId] = useState( + proposal?.organizationId ?? UNSET_ORGANIZATION_ID + ); + const isBusy = fetcher.state !== "idle"; + const isUnlinking = unlinkFetcher.state !== "idle"; + const hasNoOrgPicked = organizationId === UNSET_ORGANIZATION_ID; + + return ( + + + {channel.channelName} + + + {linkedInfo ? ( +
+ + Linked: {linkedInfo.title} ({linkedInfo.status}) + + {linkedInfo.downgraded && ( + + Downgraded + + )} +
+ ) : ( + Unlinked + )} +
+ + + + + + + + + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + + {proposal ? ( + + {proposal.confidence} ({proposal.reasons.join(", ")}) + + ) : ( + + )} + + + {linkedInfo && ( + { + if ( + !window.confirm( + "Archive this Slack support channel and unlink it from the organization? The customer will lose access." + ) + ) { + e.preventDefault(); + } + }} + > + + + + )} + {unlinkFetcher.data?.error && ( + + {unlinkFetcher.data.error} + + )} + +
+ ); +} diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index 2cd7cbfd1e..5c0a592161 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -42,6 +42,10 @@ export default function Page() { label: "Notifications", to: "/admin/notifications", }, + { + label: "Slack Channels", + to: "/admin/slack-channels", + }, { label: "Back office", to: "/admin/back-office", diff --git a/apps/webapp/app/services/supportChannelFlag.server.ts b/apps/webapp/app/services/supportChannelFlag.server.ts new file mode 100644 index 0000000000..3a966918c1 --- /dev/null +++ b/apps/webapp/app/services/supportChannelFlag.server.ts @@ -0,0 +1,31 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { resolveSupportChannelEnabled } from "~/services/supportChannelFlag"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; + +type SupportChannelFlagPrismaClient = Pick; + +export async function isSupportChannelEnabled( + organizationId: string, + prismaClient: SupportChannelFlagPrismaClient = prisma +): Promise { + const [organization, globalFlags] = await Promise.all([ + prismaClient.organization.findFirst({ + where: { id: organizationId }, + select: { featureFlags: true }, + }), + prismaClient.featureFlag.findMany({ + where: { key: { in: [FEATURE_FLAG.supportChannelEnabled] } }, + select: { key: true, value: true }, + }), + ]); + + if (!organization) { + return false; + } + + return resolveSupportChannelEnabled( + Object.fromEntries(globalFlags.map((featureFlag) => [featureFlag.key, featureFlag.value])), + (organization.featureFlags as Record | null) ?? undefined + ); +} diff --git a/apps/webapp/app/services/supportChannelFlag.ts b/apps/webapp/app/services/supportChannelFlag.ts new file mode 100644 index 0000000000..3db33ff51e --- /dev/null +++ b/apps/webapp/app/services/supportChannelFlag.ts @@ -0,0 +1,22 @@ +import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags"; + +/** + * Resolves whether the private Slack support channel is switched on for an org. + * + * A per-organization value wins over the global one in both directions, so a + * single org can be enabled ahead of a global rollout, or excluded during one. + * Absent everywhere means off — the feature depends on a plan entitlement and + * Slack app scopes that ship separately, so defaulting on would surface a + * button that cannot work. + */ +export function resolveSupportChannelEnabled( + globalFlags: Partial | Record | undefined, + organizationFlags: Record | undefined +): boolean { + const organizationOverride = organizationFlags?.[FEATURE_FLAG.supportChannelEnabled]; + if (organizationOverride === true || organizationOverride === false) { + return organizationOverride; + } + + return globalFlags?.[FEATURE_FLAG.supportChannelEnabled] === true; +} diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts new file mode 100644 index 0000000000..8e584154c3 --- /dev/null +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -0,0 +1,656 @@ +import { WebClient } from "@slack/web-api"; +import { z } from "zod"; +import { type PrismaClientOrTransaction } from "~/db.server"; + +export const OrganizationSupportChannelSchema = z.object({ + organizationId: z.string(), +}); +export type OrganizationSupportChannelPayload = z.infer; + +export interface SupportSlackClient { + createPrivateChannel(name: string): Promise<{ channelId: string; channelName: string }>; + inviteSharedByEmail( + channelId: string, + email: string + ): Promise<{ inviteId: string; url?: string }>; + archiveChannel(channelId: string): Promise; + unarchiveChannel(channelId: string): Promise; +} + +// Slack surfaces "already in that state" as a platform error rather than success. +// Both archive and unarchive treat their respective already-there error as a no-op success. +function isSlackErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "data" in error && + typeof (error as { data?: unknown }).data === "object" && + (error as { data?: { error?: unknown } }).data !== null && + (error as { data?: { error?: unknown } }).data?.error === code + ); +} + +/** + * `retryable` tells the worker whether to throw (and burn a retry attempt) or + * accept the job. Slack errors are transient; a missing owner or org is not. + */ +export type ProvisionResult = + | { status: "invited" | "exists"; channelId?: string } + | { status: "failed"; retryable: boolean; channelId?: string }; + +export interface SupportSlackDiscoveryClient { + ownTeamId(): Promise; + listCustomerChannels(): Promise< + Array<{ channelId: string; channelName: string; connectedTeamIds: string[] }> + >; + getTeamDomains(teamId: string): Promise<{ domain?: string; emailDomain?: string }>; +} + +// A channel is treated as a customer support channel only when it follows the +// `cus-` naming convention AND is actually a Slack Connect (externally shared) channel. +export function isCustomerSupportChannel({ + name, + is_ext_shared, +}: { + name?: string; + is_ext_shared?: boolean; +}): boolean { + return name?.startsWith("cus-") === true && is_ext_shared === true; +} + +// Slack Connect channels list the connected workspaces' team ids, including our own. +// This picks the first id that isn't ours, i.e. the customer's workspace. +export function pickExternalTeamId( + connectedTeamIds: string[] | undefined, + ownTeamId: string +): string | undefined { + return connectedTeamIds?.find((teamId) => teamId !== ownTeamId); +} + +export function hasPrivateSlackSupport( + plan: + | { + v3Subscription?: { + plan?: { limits?: { supportChannel?: boolean; [key: string]: unknown } }; + }; + } + | null + | undefined +): boolean { + return plan?.v3Subscription?.plan?.limits?.supportChannel === true; +} + +export function isPaidPlan( + plan: { v3Subscription?: { isPaying?: boolean } } | null | undefined +): boolean { + return plan?.v3Subscription?.isPaying === true; +} + +// An org is "downgraded" when it still has a support channel linked but is no +// longer on a paying plan, e.g. it downgraded after the channel was created. +export function isDowngradedLink({ + hasChannel, + isPaying, +}: { + hasChannel: boolean; + isPaying: boolean; +}): boolean { + return hasChannel && !isPaying; +} + +// Slack channel names: lowercase, only [a-z0-9-], <= 80 chars. +export function supportChannelName(orgSlug: string): string { + const cleaned = orgSlug + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return `cus-${cleaned}`.slice(0, 80); +} + +export class SupportSlackClientLive implements SupportSlackClient, SupportSlackDiscoveryClient { + private readonly client: WebClient; + private cachedOwnTeamId: string | undefined; + + constructor(token: string) { + this.client = new WebClient(token); + } + + async ownTeamId(): Promise { + if (this.cachedOwnTeamId) { + return this.cachedOwnTeamId; + } + const res = await this.client.auth.test(); + const teamId = res.team_id; + if (!teamId) { + throw new Error("auth.test returned no team_id"); + } + this.cachedOwnTeamId = teamId; + return teamId; + } + + async listCustomerChannels(): Promise< + Array<{ channelId: string; channelName: string; connectedTeamIds: string[] }> + > { + const channels: Array<{ channelId: string; channelName: string; connectedTeamIds: string[] }> = + []; + let cursor: string | undefined; + + do { + 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, + // users.conversations does not return connected_team_ids — only + // conversations.info does. Fetched per channel below. + connectedTeamIds: await this.connectedTeamIds(c.id), + }); + } + + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + + return channels; + } + + private async connectedTeamIds(channelId: string): Promise { + try { + const res = await this.client.conversations.info({ channel: channelId }); + return ( + (res.channel as { connected_team_ids?: string[] } | undefined)?.connected_team_ids ?? [] + ); + } catch { + // Best-effort: the team ids only feed the domain matching hint, so a + // failure here degrades the proposal rather than breaking discovery. + return []; + } + } + + async getTeamDomains(teamId: string): Promise<{ domain?: string; emailDomain?: string }> { + try { + const res = await this.client.team.info({ team: teamId }); + return { domain: res.team?.domain, emailDomain: res.team?.email_domain }; + } catch { + // Best-effort enrichment only: cross-org team.info can fail, and the domain is + // just a matching hint. Fall back to no domains rather than failing discovery. + return {}; + } + } + + async createPrivateChannel(name: string) { + const res = await this.client.conversations.create({ name, is_private: true }); + const channelId = res.channel?.id; + const channelName = res.channel?.name; + if (!channelId || !channelName) { + throw new Error("conversations.create returned no channel id/name"); + } + return { channelId, channelName }; + } + + async inviteSharedByEmail(channelId: string, email: string) { + // external_limited: false → Slack returns a clickable join `url` we surface in-app. + const res = await this.client.conversations.inviteShared({ + channel: channelId, + emails: [email], + external_limited: false, + }); + if (!res.invite_id) { + throw new Error("conversations.inviteShared returned no invite_id"); + } + return { inviteId: res.invite_id, url: res.url }; + } + + async archiveChannel(channelId: string): Promise { + try { + await this.client.conversations.archive({ channel: channelId }); + } catch (error) { + if (isSlackErrorCode(error, "already_archived")) { + return; + } + throw error; + } + } + + async unarchiveChannel(channelId: string): Promise { + try { + await this.client.conversations.unarchive({ channel: channelId }); + } catch (error) { + if (isSlackErrorCode(error, "not_archived")) { + return; + } + throw error; + } + } +} + +/** + * Creates a SupportSlackClient from an optional bot token. + * Pass `env.SLACK_BOT_TOKEN` from the call site (env.server is not imported + * here to keep this module testable — env.server transitively pulls in + * packages that are only built in production). + */ +export function createSupportSlackClient(token: string | undefined): SupportSlackClient | null { + if (!token) return null; + return new SupportSlackClientLive(token); +} + +/** + * Creates a SupportSlackDiscoveryClient from an optional bot token. + * Pass `env.SLACK_BOT_TOKEN` from the call site (see createSupportSlackClient above). + */ +export function createSupportSlackDiscoveryClient( + token: string | undefined +): SupportSlackDiscoveryClient | null { + if (!token) return null; + return new SupportSlackClientLive(token); +} + +async function getOrganizationOwnerEmail( + prisma: PrismaClientOrTransaction, + organizationId: string +): Promise { + // Longest-standing ADMIN member is treated as the org owner. Ordering is + // load-bearing: without it the invite recipient varies between runs, so a + // retry can email a different person than the first attempt. Matches the + // admin page's owner lookup. + const adminMember = await prisma.orgMember.findFirst({ + where: { organizationId, role: "ADMIN" }, + orderBy: { createdAt: "asc" }, + include: { user: { select: { email: true } } }, + }); + return adminMember?.user.email ?? null; +} + +async function setStatus( + prisma: PrismaClientOrTransaction, + organizationId: string, + status: "PENDING" | "PROVISIONING" | "INVITED" | "FAILED" | "ARCHIVED", + data: { + slackChannelId?: string | null; + slackChannelName?: string | null; + inviteUrl?: string | null; + invitedEmail?: string | null; + lastError?: string | null; + } = {} +) { + await prisma.organizationSupportChannel.upsert({ + where: { organizationId }, + create: { organizationId, status, ...data }, + update: { status, ...data }, + }); +} + +export async function provisionOrganizationSupportChannel({ + organizationId, + prisma, + slackClient, +}: { + organizationId: string; + prisma: PrismaClientOrTransaction; + slackClient: SupportSlackClient; +}): Promise { + const existing = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId }, + }); + if (existing?.slackChannelId && (existing.status === "INVITED" || existing.status === "LINKED")) { + return { status: "exists", channelId: existing.slackChannelId }; + } + + const ownerEmail = await getOrganizationOwnerEmail(prisma, organizationId); + if (!ownerEmail) { + await setStatus(prisma, organizationId, "FAILED", { + lastError: "No organization owner email found", + }); + // Permanent: no amount of retrying invents an owner. + return { status: "failed", retryable: false }; + } + + // A re-upgrade after an unlink (archive) reuses the existing channel: recreating the + // same `cus-` name would fail with Slack `name_taken`, so unarchive it instead. + if (existing?.status === "ARCHIVED" && existing.slackChannelId) { + const channelId = existing.slackChannelId; + const channelName = existing.slackChannelName ?? undefined; + await setStatus(prisma, organizationId, "PROVISIONING", { + slackChannelId: channelId, + slackChannelName: channelName, + invitedEmail: ownerEmail, + }); + + try { + await slackClient.unarchiveChannel(channelId); + const { url } = await slackClient.inviteSharedByEmail(channelId, ownerEmail); + await prisma.organizationSupportChannel.update({ + where: { organizationId }, + data: { + status: "INVITED", + slackChannelId: channelId, + slackChannelName: channelName, + inviteUrl: url ?? null, + lastError: null, + }, + }); + return { status: "invited", channelId }; + } catch (error) { + // Stay ARCHIVED rather than dropping to FAILED: the channel is still + // archived in Slack, and only this branch unarchives it. Marking it FAILED + // would send the next attempt down the reuse path, which invites into an + // archived channel and fails forever. + await setStatus(prisma, organizationId, "ARCHIVED", { + slackChannelId: channelId, + slackChannelName: channelName, + lastError: error instanceof Error ? error.message : String(error), + }); + return { status: "failed", retryable: true }; + } + } + + // A previous attempt may have already created the Slack channel but died (or failed) + // before recording the invite. Reuse the persisted channel instead of re-creating it, + // since Slack rejects a second `conversations.create` for the same name (name_taken). + let channelId = existing?.slackChannelId ?? undefined; + let channelName = existing?.slackChannelName ?? undefined; + + if (!channelId) { + const org = await prisma.organization.findFirst({ + where: { id: organizationId }, + select: { slug: true }, + }); + if (!org) { + await setStatus(prisma, organizationId, "FAILED", { lastError: "Organization not found" }); + // Permanent: the org is gone. + return { status: "failed", retryable: false }; + } + + await setStatus(prisma, organizationId, "PROVISIONING", { invitedEmail: ownerEmail }); + + try { + const created = await slackClient.createPrivateChannel(supportChannelName(org.slug)); + channelId = created.channelId; + channelName = created.channelName; + // Persist immediately so a retry never re-creates the channel, even if the + // invite step below fails or the process dies before it runs. + await setStatus(prisma, organizationId, "PROVISIONING", { + slackChannelId: channelId, + slackChannelName: channelName, + invitedEmail: ownerEmail, + }); + } catch (error) { + await setStatus(prisma, organizationId, "FAILED", { + lastError: error instanceof Error ? error.message : String(error), + }); + return { status: "failed", retryable: true }; + } + } else { + await setStatus(prisma, organizationId, "PROVISIONING", { + slackChannelId: channelId, + slackChannelName: channelName, + invitedEmail: ownerEmail, + }); + } + + try { + const { url } = await slackClient.inviteSharedByEmail(channelId, ownerEmail); + await prisma.organizationSupportChannel.update({ + where: { organizationId }, + data: { + status: "INVITED", + slackChannelId: channelId, + slackChannelName: channelName, + inviteUrl: url ?? null, + lastError: null, + }, + }); + return { status: "invited", channelId }; + } catch (error) { + await setStatus(prisma, organizationId, "FAILED", { + slackChannelId: channelId, + slackChannelName: channelName, + lastError: error instanceof Error ? error.message : String(error), + }); + return { status: "failed", retryable: true }; + } +} + +/** + * Marks a provisioning attempt failed without going near Slack. Used when the + * worker refuses to provision at all, so the row never sits at PROVISIONING + * with nothing coming to move it. + */ +export async function failSupportChannelProvisioning( + prisma: PrismaClientOrTransaction, + organizationId: string, + lastError: string +): Promise { + await setStatus(prisma, organizationId, "FAILED", { lastError }); +} + +export async function unlinkSupportChannel({ + organizationId, + prisma, + slackClient, +}: { + organizationId: string; + prisma: PrismaClientOrTransaction; + slackClient: SupportSlackClient; +}): Promise<{ status: "archived" } | { status: "not_found" }> { + const existing = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId }, + }); + if (!existing?.slackChannelId) { + return { status: "not_found" }; + } + + await slackClient.archiveChannel(existing.slackChannelId); + // Keep slackChannelId/slackChannelName for history; a later re-provision reuses them. + await setStatus(prisma, organizationId, "ARCHIVED", { + slackChannelId: existing.slackChannelId, + slackChannelName: existing.slackChannelName, + }); + return { status: "archived" }; +} + +export async function linkSupportChannel({ + organizationId, + prisma, + channel, + reassign = false, +}: { + organizationId: string; + prisma: PrismaClientOrTransaction; + channel: { channelId: string; channelName: string }; + reassign?: boolean; +}): Promise<{ status: "linked" } | { status: "conflict"; reason: string }> { + const channelOwner = await prisma.organizationSupportChannel.findFirst({ + where: { slackChannelId: channel.channelId }, + }); + if (channelOwner && channelOwner.organizationId !== organizationId) { + return { + status: "conflict", + reason: `Channel ${channel.channelId} is already linked to another organization`, + }; + } + + const existing = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId }, + }); + + if (existing?.slackChannelId && existing.slackChannelId !== channel.channelId && !reassign) { + return { + status: "conflict", + reason: `Organization is already linked to a different channel (${existing.slackChannelId})`, + }; + } + + try { + await prisma.organizationSupportChannel.upsert({ + where: { organizationId }, + create: { + organizationId, + status: "LINKED", + slackChannelId: channel.channelId, + slackChannelName: channel.channelName, + }, + update: { + status: "LINKED", + slackChannelId: channel.channelId, + slackChannelName: channel.channelName, + }, + }); + return { status: "linked" }; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return { + status: "conflict", + reason: `Channel ${channel.channelId} is already linked to another organization`, + }; + } + throw error; + } +} + +export type ChannelCandidate = { + channelId: string; + channelName: string; + externalTeamDomain?: string; + externalTeamEmailDomain?: string; +}; + +export type OrgCandidate = { + organizationId: string; + slug: string; + title: string; + ownerEmailDomain?: string; + alreadyLinked: boolean; +}; + +export type MatchProposal = { + channelId: string; + organizationId: string; + confidence: "high" | "medium" | "low"; + reasons: string[]; +}; + +function normalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function channelKey(channelName: string): string { + return normalize(channelName.replace(/^cus-/, "")); +} + +function orgSlugKey(slug: string): string { + return normalize(slug.replace(/-[a-z0-9]{4}$/, "")); +} + +function scoreOrgAgainstChannel( + channel: ChannelCandidate, + candidate: OrgCandidate +): { score: number; reasons: string[] } { + const reasons: string[] = []; + let score = 0; + + const chKey = channelKey(channel.channelName); + const slugKey = orgSlugKey(candidate.slug); + const titleKey = normalize(candidate.title); + + const nameExact = chKey.length > 0 && (chKey === slugKey || chKey === titleKey); + const nameContains = + !nameExact && + chKey.length > 0 && + ((slugKey.length > 0 && (chKey.includes(slugKey) || slugKey.includes(chKey))) || + (titleKey.length > 0 && (chKey.includes(titleKey) || titleKey.includes(chKey)))); + + if (nameExact) { + score += 2; + reasons.push("name"); + } else if (nameContains) { + score += 1; + reasons.push("name"); + } + + if (candidate.ownerEmailDomain) { + const domain = candidate.ownerEmailDomain.toLowerCase(); + const channelDomains = [channel.externalTeamDomain, channel.externalTeamEmailDomain] + .filter((d): d is string => Boolean(d)) + .map((d) => d.toLowerCase()); + if (channelDomains.includes(domain)) { + score += 2; + reasons.push("domain"); + } + } + + return { score, reasons }; +} + +function confidenceForScore(score: number): "high" | "medium" | "low" { + if (score >= 4) return "high"; + if (score >= 2) return "medium"; + return "low"; +} + +export function proposeOrgMatches( + channels: ChannelCandidate[], + orgs: OrgCandidate[] +): MatchProposal[] { + const eligibleOrgs = orgs.filter((o) => !o.alreadyLinked); + const proposals: MatchProposal[] = []; + + for (const channel of channels) { + let best: { org: OrgCandidate; score: number; reasons: string[] } | undefined; + let tie = false; + + for (const candidate of eligibleOrgs) { + const { score, reasons } = scoreOrgAgainstChannel(channel, candidate); + if (score <= 0) continue; + + if (!best || score > best.score) { + best = { org: candidate, score, reasons }; + tie = false; + } else if (score === best.score) { + tie = true; + } + } + + if (!best) continue; + + const confidence = tie ? "low" : confidenceForScore(best.score); + const reasons = tie ? [...best.reasons, "ambiguous"] : best.reasons; + + proposals.push({ + channelId: channel.channelId, + organizationId: best.org.organizationId, + confidence, + reasons, + }); + } + + return proposals; +} + +export async function enqueueProvisionSupportChannel(payload: OrganizationSupportChannelPayload) { + // Lazy import to avoid a circular dependency with commonWorker (which imports this module's schema). + const { commonWorker } = await import("~/v3/commonWorker.server"); + await commonWorker.enqueue({ + id: `support-channel:${payload.organizationId}`, + job: "supportChannel.provision", + payload, + }); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index d50d9e10f3..053da6e618 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -170,6 +170,10 @@ export function organizationSettingsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings`; } +export function organizationSupportPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/support`; +} + export function organizationIntegrationsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings/integrations`; } diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 2203b9d744..e372820d74 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -2,6 +2,7 @@ import { Logger } from "@trigger.dev/core/logger"; import { CronSchema, Worker as RedisWorker } from "@trigger.dev/redis-worker"; import { DeliverEmailSchema } from "emails"; import { z } from "zod"; +import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server"; import { sendEmail } from "~/services/email.server"; @@ -26,6 +27,15 @@ import { MembershipDevEnvironmentsSchema, provisionDevEnvironmentsForMembership, } from "~/services/memberDevEnvironments.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; +import { isSupportChannelEnabled } from "~/services/supportChannelFlag.server"; +import { + createSupportSlackClient, + failSupportChannelProvisioning, + hasPrivateSlackSupport, + OrganizationSupportChannelSchema, + provisionOrganizationSupportChannel, +} from "~/services/supportSlackChannel.server"; import { singleton } from "~/utils/singleton"; import { DeliverAlertService } from "./services/alerts/deliverAlert.server"; import { PerformDeploymentAlertsService } from "./services/alerts/performDeploymentAlerts.server"; @@ -189,6 +199,13 @@ function initializeWorker() { maxAttempts: 5, }, }, + "supportChannel.provision": { + schema: OrganizationSupportChannelSchema, + visibilityTimeoutMs: 30_000, + retry: { + maxAttempts: 3, + }, + }, }, concurrency: { workers: env.COMMON_WORKER_CONCURRENCY_WORKERS, @@ -317,6 +334,51 @@ function initializeWorker() { }); } }, + "supportChannel.provision": async ({ payload }) => { + const { organizationId } = payload; + + // Entitlement is rechecked here, not just at enqueue time: an org can + // lose it (downgrade, flag off) while the job sits in the queue, and + // provisioning a paid Slack channel for an unentitled org is exactly + // what the fail-closed gate is meant to prevent. + const [flagEnabled, plan] = await Promise.all([ + isSupportChannelEnabled(organizationId), + getCurrentPlan(organizationId), + ]); + if (!flagEnabled || !hasPrivateSlackSupport(plan)) { + await failSupportChannelProvisioning( + prisma, + organizationId, + "Organization is no longer entitled to a support channel" + ); + return; + } + + const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN); + if (!slackClient) { + // Without this the row sits at PROVISIONING forever and the page + // shows "Setting up your channel" with no error and no retry. + await failSupportChannelProvisioning( + prisma, + organizationId, + "Slack is not configured (missing SLACK_BOT_TOKEN)" + ); + return; + } + + const result = await provisionOrganizationSupportChannel({ + organizationId, + prisma, + slackClient, + }); + + // The status row is already written; throwing is what makes the + // configured maxAttempts actually retry. Permanent failures are + // accepted so they don't burn attempts. + if (result.status === "failed" && result.retryable) { + throw new Error(`Support channel provisioning failed for ${organizationId}`); + } + }, }, }); diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index d7ab026b12..9466163429 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -34,6 +34,11 @@ export const FEATURE_FLAG = { // System-wide kill switch for additional (scoped) environment API-key lookup. // Defaults off; enable during rollout once the new lookup path is trusted. additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled", + // Gates the private Slack support channel. Off by default: the feature only + // works once the plan entitlement and the Slack app scopes are both live, and + // those ship independently of this code. Per-organization override supported, + // so a single org can be switched on first. + supportChannelEnabled: "supportChannelEnabled", } as const; export const FeatureFlagCatalog = { @@ -98,6 +103,7 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(), [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(), [FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(), + [FEATURE_FLAG.supportChannelEnabled]: z.boolean(), }; export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; diff --git a/apps/webapp/test/pathBuilder.supportPath.test.ts b/apps/webapp/test/pathBuilder.supportPath.test.ts new file mode 100644 index 0000000000..138acfaa2f --- /dev/null +++ b/apps/webapp/test/pathBuilder.supportPath.test.ts @@ -0,0 +1,6 @@ +import { organizationSupportPath } from "~/utils/pathBuilder"; +import { expect, it } from "vitest"; + +it("builds the org support settings path", () => { + expect(organizationSupportPath({ slug: "acme-1234" })).toBe("/orgs/acme-1234/settings/support"); +}); diff --git a/apps/webapp/test/supportChannelFlag.test.ts b/apps/webapp/test/supportChannelFlag.test.ts new file mode 100644 index 0000000000..6828134267 --- /dev/null +++ b/apps/webapp/test/supportChannelFlag.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { resolveSupportChannelEnabled } from "~/services/supportChannelFlag"; + +describe("resolveSupportChannelEnabled", () => { + it("is off when nothing is set", () => { + expect(resolveSupportChannelEnabled(undefined, undefined)).toBe(false); + expect(resolveSupportChannelEnabled({}, {})).toBe(false); + }); + + it("follows the global flag when the org has no override", () => { + expect(resolveSupportChannelEnabled({ supportChannelEnabled: true }, {})).toBe(true); + expect(resolveSupportChannelEnabled({ supportChannelEnabled: false }, {})).toBe(false); + }); + + it("lets an org opt in ahead of a global rollout", () => { + expect(resolveSupportChannelEnabled({}, { supportChannelEnabled: true })).toBe(true); + }); + + it("lets an org be excluded from a global rollout", () => { + expect( + resolveSupportChannelEnabled( + { supportChannelEnabled: true }, + { supportChannelEnabled: false } + ) + ).toBe(false); + }); + + it("ignores a non-boolean org override and falls back to the global flag", () => { + expect( + resolveSupportChannelEnabled( + { supportChannelEnabled: true }, + { supportChannelEnabled: "yes" } + ) + ).toBe(true); + expect( + resolveSupportChannelEnabled({ supportChannelEnabled: false }, { supportChannelEnabled: 1 }) + ).toBe(false); + }); + + it("treats a truthy-but-not-true global value as off", () => { + expect(resolveSupportChannelEnabled({ supportChannelEnabled: "true" }, {})).toBe(false); + }); +}); diff --git a/apps/webapp/test/supportChannelSettings.e2e.full.test.ts b/apps/webapp/test/supportChannelSettings.e2e.full.test.ts new file mode 100644 index 0000000000..34065cfaac --- /dev/null +++ b/apps/webapp/test/supportChannelSettings.e2e.full.test.ts @@ -0,0 +1,134 @@ +// Slack support-channel settings page — free-org upsell path. See +// auth-dashboard.e2e.full.test.ts for the seedTestSession harness this +// borrows. +// +// In the e2e environment billing is unconfigured, so every seeded org is +// non-paying — this only exercises the FREE upsell branch of the page. The +// paid branches (INVITED/LINKED/PROVISIONING/connect) aren't covered here; +// see supportSlackChannel.test.ts for the service-level unit/pg coverage of +// those states. + +import { randomBytes } from "node:crypto"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestSession } from "./helpers/seedTestSession"; + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)) + .toString("hex") + .slice(0, len); +} + +// seedTestUser doesn't expose confirmedBasicDetails, and the dashboard shell +// (_app/route.tsx) redirects to /confirm-basic-details until that's true — +// so this seeds the user directly to reach the settings page. +// +// The org/project setup mirrors what OrganizationsPresenter requires to +// resolve a "best project" for the org loader shared by every settings +// page: isActivated: true (managed-cloud orgs start deactivated and get +// redirected through select-plan otherwise) and a version: "V3" project +// (the presenter only lists V3 projects). +async function seedConfirmedOrgWithAdmin(prisma: PrismaClient) { + const suffix = randomHex(8); + const user = await prisma.user.create({ + data: { + email: `e2e-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: false, + confirmedBasicDetails: true, + }, + }); + const organization = await prisma.organization.create({ + data: { + title: `Free Org ${suffix}`, + slug: `free-org-${suffix}`, + isActivated: true, + // Per-org opt-in: the feature flag is off globally, so without this the + // route 404s and the upsell branch below is never reached. + featureFlags: { supportChannelEnabled: true }, + }, + }); + await prisma.orgMember.create({ + data: { organizationId: organization.id, userId: user.id, role: "ADMIN" }, + }); + const project = await prisma.project.create({ + data: { + name: `free-project-${suffix}`, + slug: `free-proj-${suffix}`, + externalRef: `proj_${suffix}`, + organizationId: organization.id, + version: "V3", + engine: "V2", + }, + }); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + apiKey: `tr_dev_${randomHex(24)}`, + pkApiKey: `pk_dev_${randomHex(24)}`, + shortcode: suffix.slice(0, 4), + projectId: project.id, + organizationId: organization.id, + orgMemberId: ( + await prisma.orgMember.findFirstOrThrow({ + where: { organizationId: organization.id, userId: user.id }, + }) + ).id, + }, + }); + + return { user, organization }; +} + +describe("Support channel settings page", () => { + it("GET /orgs/:slug/settings/support shows the upgrade CTA for a free org", async () => { + const server = getTestServer(); + const { user, organization } = await seedConfirmedOrgWithAdmin(server.prisma); + const cookie = await seedTestSession({ userId: user.id }); + + const res = await server.webapp.fetch(`/orgs/${organization.slug}/settings/support`, { + headers: { Cookie: cookie }, + }); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("Upgrade to unlock"); + }); + + it("404s when the feature flag is off", async () => { + const server = getTestServer(); + const { user, organization } = await seedConfirmedOrgWithAdmin(server.prisma); + await server.prisma.organization.update({ + where: { id: organization.id }, + data: { featureFlags: { supportChannelEnabled: false } }, + }); + const cookie = await seedTestSession({ userId: user.id }); + + const res = await server.webapp.fetch(`/orgs/${organization.slug}/settings/support`, { + headers: { Cookie: cookie }, + }); + + expect(res.status).toBe(404); + }); + + it("POST intent=connect is rejected for a free org", async () => { + const server = getTestServer(); + const { user, organization } = await seedConfirmedOrgWithAdmin(server.prisma); + const cookie = await seedTestSession({ userId: user.id }); + + const body = new URLSearchParams({ intent: "connect" }); + const res = await server.webapp.fetch(`/orgs/${organization.slug}/settings/support`, { + method: "POST", + headers: { + Cookie: cookie, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + redirect: "manual", + }); + + expect(res.status).toBe(403); + }); +}); diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts new file mode 100644 index 0000000000..673786d046 --- /dev/null +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -0,0 +1,692 @@ +import { describe, it, expect } from "vitest"; +import { postgresTest } from "@internal/testcontainers"; +import { + hasPrivateSlackSupport, + isDowngradedLink, + isPaidPlan, + isCustomerSupportChannel, + linkSupportChannel, + pickExternalTeamId, + proposeOrgMatches, + provisionOrganizationSupportChannel, + supportChannelName, + unlinkSupportChannel, + type ChannelCandidate, + type OrgCandidate, + type SupportSlackClient, +} from "~/services/supportSlackChannel.server"; +import type { PrismaClientOrTransaction } from "~/db.server"; + +describe("hasPrivateSlackSupport", () => { + it("gates on the supportChannel plan limit", () => { + expect(hasPrivateSlackSupport(undefined)).toBe(false); + expect(hasPrivateSlackSupport({})).toBe(false); + expect( + hasPrivateSlackSupport({ v3Subscription: { plan: { limits: { supportChannel: false } } } }) + ).toBe(false); + expect( + hasPrivateSlackSupport({ v3Subscription: { plan: { limits: { supportChannel: true } } } }) + ).toBe(true); + }); +}); + +describe("isPaidPlan", () => { + it("gates on isPaying", () => { + expect(isPaidPlan(undefined)).toBe(false); + expect(isPaidPlan({})).toBe(false); + expect(isPaidPlan({ v3Subscription: { isPaying: false } })).toBe(false); + expect(isPaidPlan({ v3Subscription: { isPaying: true } })).toBe(true); + }); +}); + +describe("isDowngradedLink", () => { + it("flags a linked channel on a non-paying org as downgraded", () => { + expect(isDowngradedLink({ hasChannel: true, isPaying: false })).toBe(true); + }); + it("does not flag a linked channel on a paying org", () => { + expect(isDowngradedLink({ hasChannel: true, isPaying: true })).toBe(false); + }); + it("does not flag an org with no channel regardless of plan", () => { + expect(isDowngradedLink({ hasChannel: false, isPaying: false })).toBe(false); + expect(isDowngradedLink({ hasChannel: false, isPaying: true })).toBe(false); + }); +}); + +describe("supportChannelName", () => { + it("prefixes cus- and lowercases", () => { + expect(supportChannelName("Acme-Corp")).toBe("cus-acme-corp"); + }); + it("replaces invalid characters and collapses dashes", () => { + expect(supportChannelName("acme.co/team!")).toBe("cus-acme-co-team"); + }); + it("caps total length at 80 characters", () => { + expect(supportChannelName("a".repeat(100)).length).toBe(80); + }); +}); + +describe("isCustomerSupportChannel", () => { + it("identifies customer support channels", () => { + expect(isCustomerSupportChannel({ name: "cus-acme", is_ext_shared: true })).toBe(true); + expect(isCustomerSupportChannel({ name: "cus-acme", is_ext_shared: false })).toBe(false); + expect(isCustomerSupportChannel({ name: "general", is_ext_shared: true })).toBe(false); + expect(isCustomerSupportChannel({})).toBe(false); + }); +}); + +describe("pickExternalTeamId", () => { + it("picks the external team id", () => { + expect(pickExternalTeamId(["T_OWN", "T_EXT"], "T_OWN")).toBe("T_EXT"); + expect(pickExternalTeamId(["T_OWN"], "T_OWN")).toBeUndefined(); + expect(pickExternalTeamId(undefined, "T_OWN")).toBeUndefined(); + }); +}); + +class FakeSupportSlackClient implements SupportSlackClient { + public created: string[] = []; + public invited: Array<{ channelId: string; email: string }> = []; + public archived: string[] = []; + public unarchived: string[] = []; + constructor(private opts: { failInvite?: boolean; failUnarchive?: boolean } = {}) {} + setFailInvite(failInvite: boolean) { + this.opts = { ...this.opts, failInvite }; + } + setFailUnarchive(failUnarchive: boolean) { + this.opts = { ...this.opts, failUnarchive }; + } + async createPrivateChannel(name: string) { + this.created.push(name); + return { channelId: "C123", channelName: name }; + } + async inviteSharedByEmail(channelId: string, email: string) { + this.invited.push({ channelId, email }); + if (this.opts.failInvite) throw new Error("no_external_invite_permission"); + return { inviteId: "I123", url: "https://join.slack.com/share/abc" }; + } + async archiveChannel(channelId: string) { + this.archived.push(channelId); + } + async unarchiveChannel(channelId: string) { + this.unarchived.push(channelId); + if (this.opts.failUnarchive) throw new Error("channel_not_found"); + } +} + +async function seedOrg( + prisma: PrismaClientOrTransaction, + { withAdmin = true, slug = "acme" }: { withAdmin?: boolean; slug?: string } = {} +) { + const email = slug === "acme" ? "owner@acme.com" : `owner-${slug}@acme.com`; + const user = await prisma.user.create({ + data: { email, name: "Owner", authenticationMethod: "MAGIC_LINK" }, + }); + const org = await prisma.organization.create({ data: { title: "Acme", slug } }); + if (withAdmin) { + await prisma.orgMember.create({ + data: { organizationId: org.id, userId: user.id, role: "ADMIN" }, + }); + } + return { user, org }; +} + +describe("provisionOrganizationSupportChannel", () => { + postgresTest( + "provisions a channel and invites the owner", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + const client = new FakeSupportSlackClient(); + + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result.status).toBe("invited"); + expect(client.created).toEqual(["cus-acme"]); + expect(client.invited).toEqual([{ channelId: "C123", email: "owner@acme.com" }]); + + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("INVITED"); + expect(row?.slackChannelId).toBe("C123"); + expect(row?.inviteUrl).toBe("https://join.slack.com/share/abc"); + }, + 15000 + ); + + postgresTest( + "invites the longest-standing admin when an org has several", + async ({ prisma }) => { + const org = await prisma.organization.create({ data: { title: "Acme", slug: "acme" } }); + + // Insertion order is deliberately the opposite of createdAt order: the + // newer admin goes in first, so an unordered findFirst returns it. Seeding + // them in the natural order would let the test pass without the orderBy. + const newer = await prisma.user.create({ + data: { email: "newer@acme.com", name: "Newer", authenticationMethod: "MAGIC_LINK" }, + }); + await prisma.orgMember.create({ + data: { + organizationId: org.id, + userId: newer.id, + role: "ADMIN", + createdAt: new Date("2026-02-01T00:00:00Z"), + }, + }); + + const founder = await prisma.user.create({ + data: { email: "owner@acme.com", name: "Owner", authenticationMethod: "MAGIC_LINK" }, + }); + await prisma.orgMember.create({ + data: { + organizationId: org.id, + userId: founder.id, + role: "ADMIN", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + }); + + const client = new FakeSupportSlackClient(); + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result.status).toBe("invited"); + expect(client.invited).toEqual([{ channelId: "C123", email: "owner@acme.com" }]); + }, + 15000 + ); + + postgresTest( + "a failed unarchive stays ARCHIVED so the next attempt retries it", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "ARCHIVED", + slackChannelId: "C123", + slackChannelName: "cus-acme", + }, + }); + + const client = new FakeSupportSlackClient({ failUnarchive: true }); + const failed = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(failed).toEqual({ status: "failed", retryable: true }); + // Dropping to FAILED here would send the retry down the reuse path, which + // invites into a channel that is still archived — broken forever. + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("ARCHIVED"); + + client.setFailUnarchive(false); + const retried = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(retried.status).toBe("invited"); + expect(client.unarchived).toEqual(["C123", "C123"]); + }, + 15000 + ); + + postgresTest( + "a missing owner is a permanent failure, Slack errors are retryable", + async ({ prisma }) => { + const { org: noOwner } = await seedOrg(prisma, { withAdmin: false, slug: "noowner" }); + const permanent = await provisionOrganizationSupportChannel({ + organizationId: noOwner.id, + prisma, + slackClient: new FakeSupportSlackClient(), + }); + expect(permanent).toEqual({ status: "failed", retryable: false }); + + const { org } = await seedOrg(prisma, { slug: "transient" }); + const transient = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: new FakeSupportSlackClient({ failInvite: true }), + }); + expect(transient).toEqual({ status: "failed", retryable: true }); + }, + 15000 + ); + + postgresTest("is idempotent — existing channel makes no Slack calls", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { organizationId: org.id, status: "INVITED", slackChannelId: "C999" }, + }); + const client = new FakeSupportSlackClient(); + + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result).toEqual({ status: "exists", channelId: "C999" }); + expect(client.created).toEqual([]); + expect(client.invited).toEqual([]); + }); + + postgresTest("fails when the org has no owner email", async ({ prisma }) => { + const { org } = await seedOrg(prisma, { withAdmin: false }); + const client = new FakeSupportSlackClient(); + + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result.status).toBe("failed"); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("FAILED"); + expect(row?.lastError).toContain("owner"); + }); + + postgresTest("records FAILED when the Slack invite throws", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + const client = new FakeSupportSlackClient({ failInvite: true }); + + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result.status).toBe("failed"); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("FAILED"); + expect(row?.lastError).toContain("no_external_invite_permission"); + }); + + postgresTest( + "retrying after a failed invite reuses the persisted channel instead of recreating it", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + const client = new FakeSupportSlackClient({ failInvite: true }); + + const firstResult = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(firstResult.status).toBe("failed"); + const rowAfterFailure = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(rowAfterFailure?.status).toBe("FAILED"); + expect(rowAfterFailure?.slackChannelId).toBe("C123"); + expect(rowAfterFailure?.slackChannelName).toBe("cus-acme"); + expect(client.created).toEqual(["cus-acme"]); + + // Simulate a redis-worker retry: same organization, invite now succeeds. + client.setFailInvite(false); + const secondResult = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(secondResult).toEqual({ status: "invited", channelId: "C123" }); + // createPrivateChannel must not be called again across both runs. + expect(client.created).toEqual(["cus-acme"]); + expect(client.invited).toEqual([ + { channelId: "C123", email: "owner@acme.com" }, + { channelId: "C123", email: "owner@acme.com" }, + ]); + + const rowAfterRetry = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(rowAfterRetry?.status).toBe("INVITED"); + expect(rowAfterRetry?.slackChannelId).toBe("C123"); + } + ); + + postgresTest("LINKED row is treated as exists (no Slack calls)", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { organizationId: org.id, status: "LINKED", slackChannelId: "C777" }, + }); + const client = new FakeSupportSlackClient(); + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + expect(result).toEqual({ status: "exists", channelId: "C777" }); + expect(client.created).toEqual([]); + }); + + postgresTest( + "ARCHIVED row is unarchived and reused instead of creating a new channel", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "ARCHIVED", + slackChannelId: "C555", + slackChannelName: "cus-acme", + }, + }); + const client = new FakeSupportSlackClient(); + + const result = await provisionOrganizationSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result).toEqual({ status: "invited", channelId: "C555" }); + expect(client.created).toEqual([]); + expect(client.unarchived).toEqual(["C555"]); + expect(client.invited).toEqual([{ channelId: "C555", email: "owner@acme.com" }]); + + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("INVITED"); + expect(row?.slackChannelId).toBe("C555"); + expect(row?.slackChannelName).toBe("cus-acme"); + } + ); +}); + +describe("unlinkSupportChannel", () => { + postgresTest("archives a LINKED row and keeps the channel id for history", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "LINKED", + slackChannelId: "C1", + slackChannelName: "cus-acme", + }, + }); + const client = new FakeSupportSlackClient(); + + const result = await unlinkSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result).toEqual({ status: "archived" }); + expect(client.archived).toEqual(["C1"]); + + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("ARCHIVED"); + expect(row?.slackChannelId).toBe("C1"); + expect(row?.slackChannelName).toBe("cus-acme"); + }); + + postgresTest( + "no row or no channel id returns not_found without calling Slack", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + const client = new FakeSupportSlackClient(); + + const result = await unlinkSupportChannel({ + organizationId: org.id, + prisma, + slackClient: client, + }); + + expect(result).toEqual({ status: "not_found" }); + expect(client.archived).toEqual([]); + } + ); +}); + +function chan(overrides: Partial = {}): ChannelCandidate { + return { + channelId: "C1", + channelName: "cus-acme", + ...overrides, + }; +} + +function org(overrides: Partial = {}): OrgCandidate { + return { + organizationId: "org_1", + slug: "acme-9dfd", + title: "Acme", + alreadyLinked: false, + ...overrides, + }; +} + +describe("proposeOrgMatches", () => { + it("name exact match scores medium with a name reason", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-acme" })], + [org({ slug: "acme-9dfd" })] + ); + expect(result).toEqual([ + { channelId: "C1", organizationId: "org_1", confidence: "medium", reasons: ["name"] }, + ]); + }); + + it("domain match only scores medium with a domain reason", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-zzzqqq", externalTeamEmailDomain: "acme.com" })], + [org({ slug: "widget-5555", title: "Widget Co", ownerEmailDomain: "acme.com" })] + ); + expect(result).toEqual([ + { channelId: "C1", organizationId: "org_1", confidence: "medium", reasons: ["domain"] }, + ]); + }); + + it("name and domain match together score high", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-acme", externalTeamEmailDomain: "acme.com" })], + [org({ slug: "acme-9dfd", ownerEmailDomain: "acme.com" })] + ); + expect(result).toEqual([ + { + channelId: "C1", + organizationId: "org_1", + confidence: "high", + reasons: ["name", "domain"], + }, + ]); + }); + + it("contains-only match scores low", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-acme-corp" })], + [org({ slug: "acme-corp-holdings-9dfd", title: "Acme" })] + ); + expect(result).toEqual([ + { channelId: "C1", organizationId: "org_1", confidence: "low", reasons: ["name"] }, + ]); + }); + + it("excludes already-linked orgs", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-acme" })], + [org({ slug: "acme-9dfd", alreadyLinked: true })] + ); + expect(result).toEqual([]); + }); + + it("caps confidence to low and flags ambiguous on a tied top score", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-acme" })], + [ + org({ organizationId: "org_1", slug: "acme-1111", title: "Acme One" }), + org({ organizationId: "org_2", slug: "acme-2222", title: "Acme Two" }), + ] + ); + expect(result).toEqual([ + { + channelId: "C1", + organizationId: "org_1", + confidence: "low", + reasons: ["name", "ambiguous"], + }, + ]); + }); + + it("returns no proposal when nothing matches", () => { + const result = proposeOrgMatches( + [chan({ channelName: "cus-zzz" })], + [org({ slug: "acme-9dfd", title: "Acme" })] + ); + expect(result).toEqual([]); + }); +}); + +describe("linkSupportChannel", () => { + postgresTest("fresh link creates a LINKED row", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + + const result = await linkSupportChannel({ + organizationId: org.id, + prisma, + channel: { channelId: "C1", channelName: "cus-acme" }, + }); + + expect(result).toEqual({ status: "linked" }); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("LINKED"); + expect(row?.slackChannelId).toBe("C1"); + expect(row?.slackChannelName).toBe("cus-acme"); + }); + + postgresTest("linking the same channel again is idempotent", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "LINKED", + slackChannelId: "C1", + slackChannelName: "cus-acme", + }, + }); + + const result = await linkSupportChannel({ + organizationId: org.id, + prisma, + channel: { channelId: "C1", channelName: "cus-acme" }, + }); + + expect(result).toEqual({ status: "linked" }); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("LINKED"); + expect(row?.slackChannelId).toBe("C1"); + }); + + postgresTest( + "org already linked to a different channel conflicts without reassign", + async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "LINKED", + slackChannelId: "C1", + slackChannelName: "cus-acme", + }, + }); + + const result = await linkSupportChannel({ + organizationId: org.id, + prisma, + channel: { channelId: "C2", channelName: "cus-other" }, + }); + + expect(result.status).toBe("conflict"); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.slackChannelId).toBe("C1"); + } + ); + + postgresTest( + "channel already linked to another org conflicts even with reassign", + async ({ prisma }) => { + const { org: orgA } = await seedOrg(prisma, { slug: "acme" }); + const { org: orgB } = await seedOrg(prisma, { slug: "widget" }); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: orgA.id, + status: "LINKED", + slackChannelId: "C1", + slackChannelName: "cus-acme", + }, + }); + + const result = await linkSupportChannel({ + organizationId: orgB.id, + prisma, + channel: { channelId: "C1", channelName: "cus-acme" }, + reassign: true, + }); + + expect(result.status).toBe("conflict"); + const rowA = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: orgA.id }, + }); + expect(rowA?.slackChannelId).toBe("C1"); + const rowB = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: orgB.id }, + }); + expect(rowB?.slackChannelId ?? null).not.toBe("C1"); + } + ); + + postgresTest("reassign overwrites the org's own row to a new channel", async ({ prisma }) => { + const { org } = await seedOrg(prisma); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "LINKED", + slackChannelId: "C1", + slackChannelName: "cus-acme", + }, + }); + + const result = await linkSupportChannel({ + organizationId: org.id, + prisma, + channel: { channelId: "C2", channelName: "cus-acme-new" }, + reassign: true, + }); + + expect(result).toEqual({ status: "linked" }); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("LINKED"); + expect(row?.slackChannelId).toBe("C2"); + expect(row?.slackChannelName).toBe("cus-acme-new"); + }); +}); diff --git a/apps/webapp/test/supportSlackChannelModel.test.ts b/apps/webapp/test/supportSlackChannelModel.test.ts new file mode 100644 index 0000000000..33f9c21574 --- /dev/null +++ b/apps/webapp/test/supportSlackChannelModel.test.ts @@ -0,0 +1,54 @@ +import { postgresTest } from "@internal/testcontainers"; +import { expect, vi } from "vitest"; + +vi.setConfig({ testTimeout: 60_000 }); + +postgresTest("round-trips a LINKED support channel row", async ({ prisma }) => { + const org = await prisma.organization.create({ data: { title: "Acme", slug: "acme" } }); + await prisma.organizationSupportChannel.create({ + data: { + organizationId: org.id, + status: "LINKED", + slackChannelId: "C123", + slackChannelName: "cus-acme", + }, + }); + const row = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: org.id }, + }); + expect(row?.status).toBe("LINKED"); + expect(row?.slackChannelId).toBe("C123"); +}); + +postgresTest("one row per org (organizationId unique)", async ({ prisma }) => { + const org = await prisma.organization.create({ data: { title: "B", slug: "b" } }); + await prisma.organizationSupportChannel.create({ + data: { organizationId: org.id, status: "PENDING" }, + }); + await expect( + prisma.organizationSupportChannel.create({ + data: { organizationId: org.id, status: "PENDING" }, + }) + ).rejects.toThrow(); +}); + +postgresTest("one org per channel (slackChannelId unique, nulls allowed)", async ({ prisma }) => { + const a = await prisma.organization.create({ data: { title: "A2", slug: "a2" } }); + const b = await prisma.organization.create({ data: { title: "B2", slug: "b2" } }); + await prisma.organizationSupportChannel.create({ + data: { organizationId: a.id, status: "LINKED", slackChannelId: "C9" }, + }); + await expect( + prisma.organizationSupportChannel.create({ + data: { organizationId: b.id, status: "LINKED", slackChannelId: "C9" }, + }) + ).rejects.toThrow(); + // multiple NULL slackChannelIds must coexist + await prisma.organizationSupportChannel.deleteMany({}); + await prisma.organizationSupportChannel.create({ + data: { organizationId: a.id, status: "PENDING" }, + }); + await prisma.organizationSupportChannel.create({ + data: { organizationId: b.id, status: "PENDING" }, + }); +}); diff --git a/internal-packages/database/prisma/migrations/20260812150000_add_organization_support_channel/migration.sql b/internal-packages/database/prisma/migrations/20260812150000_add_organization_support_channel/migration.sql new file mode 100644 index 0000000000..18cc5ff8da --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260812150000_add_organization_support_channel/migration.sql @@ -0,0 +1,27 @@ +-- CreateEnum +CREATE TYPE "OrganizationSupportChannelStatus" AS ENUM ('PENDING', 'PROVISIONING', 'INVITED', 'FAILED', 'LINKED'); + +-- CreateTable +CREATE TABLE "OrganizationSupportChannel" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "status" "OrganizationSupportChannelStatus" NOT NULL DEFAULT 'PENDING', + "slackChannelId" TEXT, + "slackChannelName" TEXT, + "inviteUrl" TEXT, + "invitedEmail" TEXT, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationSupportChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationSupportChannel_organizationId_key" ON "OrganizationSupportChannel"("organizationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationSupportChannel_slackChannelId_key" ON "OrganizationSupportChannel"("slackChannelId"); + +-- AddForeignKey +ALTER TABLE "OrganizationSupportChannel" ADD CONSTRAINT "OrganizationSupportChannel_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20260812150100_add_organization_support_channel_archived_status/migration.sql b/internal-packages/database/prisma/migrations/20260812150100_add_organization_support_channel_archived_status/migration.sql new file mode 100644 index 0000000000..560aa0d227 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260812150100_add_organization_support_channel_archived_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OrganizationSupportChannelStatus" ADD VALUE 'ARCHIVED'; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3803e74c7f..d0dc3b66e1 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -245,6 +245,7 @@ model Organization { members OrgMember[] invites OrgMemberInvite[] organizationIntegrations OrganizationIntegration[] + supportChannel OrganizationSupportChannel? organizationAccessTokens OrganizationAccessToken[] workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] @@ -3247,3 +3248,30 @@ model OrganizationDataStore { @@index([kind]) } + +enum OrganizationSupportChannelStatus { + PENDING + PROVISIONING + INVITED + FAILED + LINKED + ARCHIVED +} + +model OrganizationSupportChannel { + id String @id @default(cuid()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String @unique + + status OrganizationSupportChannelStatus @default(PENDING) + + slackChannelId String? @unique + slackChannelName String? + inviteUrl String? + invitedEmail String? + lastError String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +}