From 0f2a006650ee9eb1322efdb05e8497b86d051ddf Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:40:08 +0100 Subject: [PATCH 01/26] feat(webapp): add support Slack client + channel-name helper --- .../services/supportSlackChannel.server.ts | 61 +++++++++++++++++++ apps/webapp/test/supportSlackChannel.test.ts | 14 +++++ 2 files changed, 75 insertions(+) create mode 100644 apps/webapp/app/services/supportSlackChannel.server.ts create mode 100644 apps/webapp/test/supportSlackChannel.test.ts diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts new file mode 100644 index 0000000000..b72be3259b --- /dev/null +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -0,0 +1,61 @@ +import { WebClient } from "@slack/web-api"; + +export interface SupportSlackClient { + createPrivateChannel(name: string): Promise<{ channelId: string; channelName: string }>; + inviteSharedByEmail( + channelId: string, + email: string + ): Promise<{ inviteId: string; url?: string }>; +} + +// 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 { + private readonly client: WebClient; + + constructor(token: string) { + this.client = new WebClient(token); + } + + 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 }; + } +} + +/** + * 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); +} diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts new file mode 100644 index 0000000000..d141cee22d --- /dev/null +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { supportChannelName } from "~/services/supportSlackChannel.server"; + +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); + }); +}); From 1b24d9df1c62e640ec0e44dff69f002868eef0fd Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:26:41 +0100 Subject: [PATCH 02/26] feat(database): add OrganizationSupportChannel model --- .../test/supportSlackChannelModel.test.ts | 35 +++++++++++++++++++ .../migration.sql | 27 ++++++++++++++ .../database/prisma/schema.prisma | 27 ++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 apps/webapp/test/supportSlackChannelModel.test.ts create mode 100644 internal-packages/database/prisma/migrations/20260713120000_add_organization_support_channel/migration.sql diff --git a/apps/webapp/test/supportSlackChannelModel.test.ts b/apps/webapp/test/supportSlackChannelModel.test.ts new file mode 100644 index 0000000000..a6926813d5 --- /dev/null +++ b/apps/webapp/test/supportSlackChannelModel.test.ts @@ -0,0 +1,35 @@ +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/20260713120000_add_organization_support_channel/migration.sql b/internal-packages/database/prisma/migrations/20260713120000_add_organization_support_channel/migration.sql new file mode 100644 index 0000000000..18cc5ff8da --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260713120000_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/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1b8642e866..2165ac880f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -242,6 +242,7 @@ model Organization { members OrgMember[] invites OrgMemberInvite[] organizationIntegrations OrganizationIntegration[] + supportChannel OrganizationSupportChannel? organizationAccessTokens OrganizationAccessToken[] workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] @@ -3194,3 +3195,29 @@ model OrganizationDataStore { @@index([kind]) } + +enum OrganizationSupportChannelStatus { + PENDING + PROVISIONING + INVITED + FAILED + LINKED +} + +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 +} From fcbc5da525e1e45a600f8171eeb5d8700b080b74 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:17:16 +0100 Subject: [PATCH 03/26] feat(webapp): add support-channel provisioning orchestrator Adds provisionOrganizationSupportChannel, which creates a private Slack channel for an organization, invites the org owner by email, and records the outcome on OrganizationSupportChannel. Idempotent when a channel already exists (INVITED or LINKED), and records FAILED with the error message when the owner email is missing or Slack calls throw. --- .../services/supportSlackChannel.server.ts | 91 ++++++++++++ apps/webapp/test/supportSlackChannel.test.ts | 136 +++++++++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index b72be3259b..37c8bf72f6 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -1,4 +1,5 @@ import { WebClient } from "@slack/web-api"; +import { type PrismaClientOrTransaction } from "~/db.server"; export interface SupportSlackClient { createPrivateChannel(name: string): Promise<{ channelId: string; channelName: string }>; @@ -59,3 +60,93 @@ export function createSupportSlackClient(token: string | undefined): SupportSlac if (!token) return null; return new SupportSlackClientLive(token); } + +async function getOrganizationOwnerEmail( + prisma: PrismaClientOrTransaction, + organizationId: string +): Promise { + // First ADMIN member is treated as the org owner. + const adminMember = await prisma.orgMember.findFirst({ + where: { organizationId, role: "ADMIN" }, + include: { user: { select: { email: true } } }, + }); + return adminMember?.user.email ?? null; +} + +async function setStatus( + prisma: PrismaClientOrTransaction, + organizationId: string, + status: "PENDING" | "PROVISIONING" | "INVITED" | "FAILED", + 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<{ status: "invited" | "exists" | "failed"; channelId?: string }> { + const existing = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId }, + }); + if (existing?.slackChannelId) { + 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", + }); + return { status: "failed" }; + } + + const org = await prisma.organization.findFirst({ + where: { id: organizationId }, + select: { slug: true }, + }); + if (!org) { + await setStatus(prisma, organizationId, "FAILED", { lastError: "Organization not found" }); + return { status: "failed" }; + } + + await setStatus(prisma, organizationId, "PROVISIONING", { invitedEmail: ownerEmail }); + + try { + const { channelId, channelName } = await slackClient.createPrivateChannel( + supportChannelName(org.slug) + ); + 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", { + lastError: error instanceof Error ? error.message : String(error), + }); + return { status: "failed" }; + } +} diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index d141cee22d..44f1986559 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { supportChannelName } from "~/services/supportSlackChannel.server"; +import { postgresTest } from "@internal/testcontainers"; +import { + provisionOrganizationSupportChannel, + supportChannelName, + type SupportSlackClient, +} from "~/services/supportSlackChannel.server"; +import type { PrismaClientOrTransaction } from "~/db.server"; describe("supportChannelName", () => { it("prefixes cus- and lowercases", () => { @@ -12,3 +18,131 @@ describe("supportChannelName", () => { expect(supportChannelName("a".repeat(100)).length).toBe(80); }); }); + +class FakeSupportSlackClient implements SupportSlackClient { + public created: string[] = []; + public invited: Array<{ channelId: string; email: string }> = []; + constructor(private readonly opts: { failInvite?: boolean } = {}) {} + 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 function seedOrg( + prisma: PrismaClientOrTransaction, + { withAdmin = true }: { withAdmin?: boolean } = {} +) { + const user = await prisma.user.create({ + data: { email: "owner@acme.com", name: "Owner", authenticationMethod: "MAGIC_LINK" }, + }); + const org = await prisma.organization.create({ data: { title: "Acme", slug: "acme" } }); + 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("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("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([]); + }); +}); From c23ad8e65e232747530bb9848abfe82dd69bc697 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:47:54 +0100 Subject: [PATCH 04/26] feat(webapp): enqueue Slack support-channel provisioning via the common worker Wires the support-channel provisioning orchestrator into the background job system so callers can enqueue it by organization id, keeping Slack API calls off the request path. --- .../services/supportSlackChannel.server.ts | 28 +++++++++++++++++++ apps/webapp/app/v3/commonWorker.server.ts | 22 +++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 37c8bf72f6..1583b2f06e 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -1,5 +1,14 @@ import { WebClient } from "@slack/web-api"; +import { z } from "zod"; import { type PrismaClientOrTransaction } from "~/db.server"; +import { logger } from "./logger.server"; + +export const OrganizationSupportChannelSchema = z.object({ + organizationId: z.string(), +}); +export type OrganizationSupportChannelPayload = z.infer< + typeof OrganizationSupportChannelSchema +>; export interface SupportSlackClient { createPrivateChannel(name: string): Promise<{ channelId: string; channelName: string }>; @@ -150,3 +159,22 @@ export async function provisionOrganizationSupportChannel({ return { status: "failed" }; } } + +export async function enqueueProvisionSupportChannel( + payload: OrganizationSupportChannelPayload +) { + try { + // 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, + }); + } catch (error) { + logger.error("Failed to enqueue support channel provisioning", { + organizationId: payload.organizationId, + error, + }); + } +} diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 38f9ea9426..712913c153 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 { 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"; @@ -12,6 +13,11 @@ import { runAttioWorkspaceSync, } from "~/services/attio.server"; import { logger } from "~/services/logger.server"; +import { + createSupportSlackClient, + 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"; @@ -135,6 +141,13 @@ function initializeWorker() { maxAttempts: 5, }, }, + "supportChannel.provision": { + schema: OrganizationSupportChannelSchema, + visibilityTimeoutMs: 30_000, + retry: { + maxAttempts: 3, + }, + }, }, concurrency: { workers: env.COMMON_WORKER_CONCURRENCY_WORKERS, @@ -190,6 +203,15 @@ function initializeWorker() { const service = new BulkActionService(); await service.process(payload.bulkActionId); }, + "supportChannel.provision": async ({ payload }) => { + const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN); + if (!slackClient) return; + await provisionOrganizationSupportChannel({ + organizationId: payload.organizationId, + prisma, + slackClient, + }); + }, }, }); From 7d81d7a7edd84141d61a578b4301d622dbb57cf7 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:51:08 +0100 Subject: [PATCH 05/26] feat(webapp): add support-channel settings nav item --- .server-changes/org-support-settings-nav-item.md | 6 ++++++ .../navigation/OrganizationSettingsSideMenu.tsx | 12 ++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 4 ++++ apps/webapp/test/pathBuilder.supportPath.test.ts | 6 ++++++ 4 files changed, 28 insertions(+) create mode 100644 .server-changes/org-support-settings-nav-item.md create mode 100644 apps/webapp/test/pathBuilder.supportPath.test.ts diff --git a/.server-changes/org-support-settings-nav-item.md b/.server-changes/org-support-settings-nav-item.md new file mode 100644 index 0000000000..a24e931096 --- /dev/null +++ b/.server-changes/org-support-settings-nav-item.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Added a Support item to the organization settings side menu, linking to a new support-channel settings page. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 4158d30945..d1ff29b14e 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -17,6 +17,7 @@ import { organizationSettingsPath, organizationSlackIntegrationPath, organizationSsoPath, + organizationSupportPath, organizationTeamPath, organizationVercelIntegrationPath, rootPath, @@ -127,6 +128,17 @@ export function OrganizationSettingsSideMenu({ to={organizationTeamPath(organization)} data-action="team" /> + {isManagedCloud && ( + + )} {featureFlags.hasPrivateConnections && ( { + expect(organizationSupportPath({ slug: "acme-1234" })).toBe("/orgs/acme-1234/settings/support"); +}); From ece5820c1899db321662add667dcca68a521de6b Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:55:12 +0100 Subject: [PATCH 06/26] feat(webapp): add Slack support-channel settings route with paid gate Adds the loader and action for the organization Support settings page, gated so only paid plans can connect a Slack support channel. --- .../slack-support-channel-settings-route.md | 6 ++ ...rgs.$organizationSlug.settings.support.tsx | 58 +++++++++++++++++++ .../services/supportSlackChannel.server.ts | 6 ++ apps/webapp/test/supportSlackChannel.test.ts | 10 ++++ 4 files changed, 80 insertions(+) create mode 100644 .server-changes/slack-support-channel-settings-route.md create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx diff --git a/.server-changes/slack-support-channel-settings-route.md b/.server-changes/slack-support-channel-settings-route.md new file mode 100644 index 0000000000..9f14b91385 --- /dev/null +++ b/.server-changes/slack-support-channel-settings-route.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Added the backend for a new organization Support settings page that lets paid customers connect a shared Slack channel with the team. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx new file mode 100644 index 0000000000..521c98ea64 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx @@ -0,0 +1,58 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireOrganization } from "~/services/org.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; +import { + enqueueProvisionSupportChannel, + isPaidPlan, +} from "~/services/supportSlackChannel.server"; +import { OrganizationParamsSchema, organizationSupportPath } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organization } = await requireOrganization(request, organizationSlug); + + const supportChannel = await prisma.organizationSupportChannel.findFirst({ + where: { organizationId: organization.id }, + }); + + const plan = await getCurrentPlan(organization.id); + + return typedjson({ + organization, + supportChannel, + isPaying: isPaidPlan(plan), + }); +}; + +const ActionSchema = z.object({ + intent: z.literal("connect"), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organization } = await requireOrganization(request, organizationSlug); + + 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(organization.id); + if (!isPaidPlan(plan)) { + return json({ error: "Upgrade required" }, { status: 403 }); + } + + await prisma.organizationSupportChannel.upsert({ + where: { organizationId: organization.id }, + create: { organizationId: organization.id, status: "PROVISIONING" }, + update: { status: "PROVISIONING" }, + }); + + await enqueueProvisionSupportChannel({ organizationId: organization.id }); + + return redirect(organizationSupportPath(organization)); +}; diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 1583b2f06e..47697bf6fe 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -18,6 +18,12 @@ export interface SupportSlackClient { ): Promise<{ inviteId: string; url?: string }>; } +export function isPaidPlan( + plan: { v3Subscription?: { isPaying?: boolean } } | null | undefined +): boolean { + return plan?.v3Subscription?.isPaying === true; +} + // Slack channel names: lowercase, only [a-z0-9-], <= 80 chars. export function supportChannelName(orgSlug: string): string { const cleaned = orgSlug diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 44f1986559..090337d8e2 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -1,12 +1,22 @@ import { describe, it, expect } from "vitest"; import { postgresTest } from "@internal/testcontainers"; import { + isPaidPlan, provisionOrganizationSupportChannel, supportChannelName, type SupportSlackClient, } from "~/services/supportSlackChannel.server"; import type { PrismaClientOrTransaction } from "~/db.server"; +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("supportChannelName", () => { it("prefixes cus- and lowercases", () => { expect(supportChannelName("Acme-Corp")).toBe("cus-acme-corp"); From 6242c0ceb24b7f74585890ec7fd8040cc082dd25 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:12:44 +0100 Subject: [PATCH 07/26] feat(webapp): add support-channel settings page UI Shows the Slack support channel connection state on the organization settings page: an upgrade prompt for free orgs, a connect button for paid orgs without a channel yet, and status for invited, linked, and in-progress channels. --- .../slack-support-channel-settings-page-ui.md | 6 + ...rgs.$organizationSlug.settings.support.tsx | 106 +++++++++++++++- .../supportChannelSettings.e2e.full.test.ts | 115 ++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 .server-changes/slack-support-channel-settings-page-ui.md create mode 100644 apps/webapp/test/supportChannelSettings.e2e.full.test.ts diff --git a/.server-changes/slack-support-channel-settings-page-ui.md b/.server-changes/slack-support-channel-settings-page-ui.md new file mode 100644 index 0000000000..9d31d2b59b --- /dev/null +++ b/.server-changes/slack-support-channel-settings-page-ui.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The organization Support settings page now shows the state of your Slack support channel connection and lets paid customers connect one with a single click. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx index 521c98ea64..25eae92897 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx @@ -1,14 +1,26 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node"; -import { typedjson } from "remix-typedjson"; +import { Form, 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 { requireOrganization } from "~/services/org.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { enqueueProvisionSupportChannel, isPaidPlan, } from "~/services/supportSlackChannel.server"; -import { OrganizationParamsSchema, organizationSupportPath } from "~/utils/pathBuilder"; +import { OrganizationParamsSchema, organizationSupportPath, v3BillingPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); @@ -56,3 +68,93 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return redirect(organizationSupportPath(organization)); }; + +export default function Page() { + const { supportChannel, isPaying } = useTypedLoaderData(); + 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. + + + {!isPaying ? ( +
+ + A private Slack support channel is available on paid 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}.` + : ""} + + {supportChannel.status === "LINKED" ? ( + + Your support channel is #{supportChannel.slackChannelName} + + ) : null} + {supportChannel.slackChannelId ? ( + + Open in Slack + + ) : supportChannel.inviteUrl ? ( + + Join the channel + + ) : null} +
+ ) : supportChannel?.status === "PROVISIONING" ? ( + + Setting up your channel — check your email shortly for the Slack Connect invite. + + ) : ( +
+ {supportChannel?.status === "FAILED" ? ( + + Something went wrong setting up your channel. Try again, or contact us. + + ) : null} + +
+ )} +
+
+
+ ); +} 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..04dfeac204 --- /dev/null +++ b/apps/webapp/test/supportChannelSettings.e2e.full.test.ts @@ -0,0 +1,115 @@ +// 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, + }, + }); + 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("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); + }); +}); From 0a688d3b4ea544aebbb96bca91c5bf0b7c6df3d6 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:17:02 +0100 Subject: [PATCH 08/26] feat(webapp): add Slack Connect channel discovery client Adds discovery helpers for finding existing customer support Slack channels: isCustomerSupportChannel identifies cus- prefixed Connect channels, and pickExternalTeamId resolves the customer's workspace id from a channel's connected teams. Extends SupportSlackClientLive with ownTeamId, listCustomerChannels, and getTeamDomains so a later step can propose links between organizations and existing channels. --- .../services/supportSlackChannel.server.ts | 100 +++++++++++++++++- apps/webapp/test/supportSlackChannel.test.ts | 19 ++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 47697bf6fe..25ff512142 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -18,6 +18,35 @@ export interface SupportSlackClient { ): Promise<{ inviteId: string; url?: 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 isPaidPlan( plan: { v3Subscription?: { isPaying?: boolean } } | null | undefined ): boolean { @@ -34,13 +63,71 @@ export function supportChannelName(orgSlug: string): string { return `cus-${cleaned}`.slice(0, 80); } -export class SupportSlackClientLive implements SupportSlackClient { +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, + connectedTeamIds: (c as { connected_team_ids?: string[] }).connected_team_ids ?? [], + }); + } + + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + + return channels; + } + + 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 { + return {}; + } + } + async createPrivateChannel(name: string) { const res = await this.client.conversations.create({ name, is_private: true }); const channelId = res.channel?.id; @@ -76,6 +163,17 @@ export function createSupportSlackClient(token: string | undefined): SupportSlac 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 diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 090337d8e2..2015d5cabe 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { postgresTest } from "@internal/testcontainers"; import { isPaidPlan, + isCustomerSupportChannel, + pickExternalTeamId, provisionOrganizationSupportChannel, supportChannelName, type SupportSlackClient, @@ -29,6 +31,23 @@ describe("supportChannelName", () => { }); }); +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 }> = []; From 94a48f7114b4808437d8d23f0613507622fc3d71 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:21:00 +0100 Subject: [PATCH 09/26] feat(webapp): add support-channel org match proposer Proposes which organization a discovered cus- Slack channel likely belongs to, scoring on channel/org name similarity and email domain match, so an admin can review and approve links instead of us guessing silently. --- .../services/supportSlackChannel.server.ts | 119 ++++++++++++++++++ apps/webapp/test/supportSlackChannel.test.ts | 102 +++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 25ff512142..8b491d329d 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -264,6 +264,125 @@ export async function provisionOrganizationSupportChannel({ } } +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 ) { diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 2015d5cabe..9e830a6b38 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -4,8 +4,11 @@ import { isPaidPlan, isCustomerSupportChannel, pickExternalTeamId, + proposeOrgMatches, provisionOrganizationSupportChannel, supportChannelName, + type ChannelCandidate, + type OrgCandidate, type SupportSlackClient, } from "~/services/supportSlackChannel.server"; import type { PrismaClientOrTransaction } from "~/db.server"; @@ -175,3 +178,102 @@ describe("provisionOrganizationSupportChannel", () => { expect(client.created).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([]); + }); +}); From e07db7cda2369f59266849c0c97a4a8aebbd8ed9 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:25:09 +0100 Subject: [PATCH 10/26] feat(webapp): add support-channel link writer Adds linkSupportChannel, which records an admin-approved match between an organization and a Slack support channel. Handles idempotent re-linking, and refuses to overwrite an org's existing link or steal a channel already linked to another org unless explicitly reassigning. --- .../services/supportSlackChannel.server.ts | 64 ++++++++ apps/webapp/test/supportSlackChannel.test.ts | 140 +++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 8b491d329d..805db145fc 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -264,6 +264,70 @@ export async function provisionOrganizationSupportChannel({ } } +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; diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 9e830a6b38..2312fdfea0 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -3,6 +3,7 @@ import { postgresTest } from "@internal/testcontainers"; import { isPaidPlan, isCustomerSupportChannel, + linkSupportChannel, pickExternalTeamId, proposeOrgMatches, provisionOrganizationSupportChannel, @@ -68,12 +69,13 @@ class FakeSupportSlackClient implements SupportSlackClient { async function seedOrg( prisma: PrismaClientOrTransaction, - { withAdmin = true }: { withAdmin?: boolean } = {} + { 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: "owner@acme.com", name: "Owner", authenticationMethod: "MAGIC_LINK" }, + data: { email, name: "Owner", authenticationMethod: "MAGIC_LINK" }, }); - const org = await prisma.organization.create({ data: { title: "Acme", slug: "acme" } }); + 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" }, @@ -277,3 +279,135 @@ describe("proposeOrgMatches", () => { 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"); + }); +}); From f0e1c6ff451389704d73425d5eb90d7ccbf50545 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:30:25 +0100 Subject: [PATCH 11/26] feat(webapp): add admin Slack support-channel linking page Adds a super-admin page at /admin/slack-channels that lists existing customer Slack Connect channels, proposes an organization match for each using name and email-domain heuristics, and lets an admin approve or reassign the link with one click. --- .../admin-slack-support-channel-linking.md | 6 + .../app/routes/admin.slack-channels.tsx | 290 ++++++++++++++++++ apps/webapp/app/routes/admin.tsx | 4 + 3 files changed, 300 insertions(+) create mode 100644 .server-changes/admin-slack-support-channel-linking.md create mode 100644 apps/webapp/app/routes/admin.slack-channels.tsx diff --git a/.server-changes/admin-slack-support-channel-linking.md b/.server-changes/admin-slack-support-channel-linking.md new file mode 100644 index 0000000000..bf1490ce5e --- /dev/null +++ b/.server-changes/admin-slack-support-channel-linking.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Added an admin page for linking existing Slack Connect support channels to organizations, with suggested matches to speed up manual review. 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..122918ebc7 --- /dev/null +++ b/apps/webapp/app/routes/admin.slack-channels.tsx @@ -0,0 +1,290 @@ +import { useFetcher } from "@remix-run/react"; +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 { + createSupportSlackDiscoveryClient, + linkSupportChannel, + pickExternalTeamId, + proposeOrgMatches, + type ChannelCandidate, + type MatchProposal, + type OrgCandidate, +} from "~/services/supportSlackChannel.server"; + +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: [], + linkedOrgTitleByChannelId: {} 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 }, + }, + 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 the org title it's already linked to, so the + // table can show per-channel link status. Built separately from + // `OrgCandidate` since that type only carries a boolean for matching. + const linkedOrgTitleByChannelId: Record = {}; + for (const organization of organizations) { + if (organization.supportChannel?.slackChannelId) { + linkedOrgTitleByChannelId[organization.supportChannel.slackChannelId] = organization.title; + } + } + + const proposals = proposeOrgMatches(channels, orgs); + + return typedjson({ + notConfigured: false as const, + channels, + proposals, + orgs, + linkedOrgTitleByChannelId, + }); +}); + +const ActionBody = z.object({ + _action: z.enum(["link", "reassign"]), + channelId: z.string(), + channelName: z.string(), + organizationId: z.string(), +}); + +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 }); + } + + 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, linkedOrgTitleByChannelId } = + 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, + linkedOrgTitle, +}: { + channel: LoaderChannel; + proposal: MatchProposal | undefined; + orgs: LoaderOrg[]; + linkedOrgTitle: string | undefined; +}) { + const fetcher = useFetcher<{ error?: string; success?: boolean }>(); + const defaultOrganizationId = proposal?.organizationId ?? orgs[0]?.organizationId ?? ""; + const isBusy = fetcher.state !== "idle"; + + return ( + + + {channel.channelName} + + + {linkedOrgTitle ? ( + Linked: {linkedOrgTitle} + ) : ( + Unlinked + )} + + + + + + + + + + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + + {proposal ? ( + + {proposal.confidence} ({proposal.reasons.join(", ")}) + + ) : ( + + )} + + + + ); +} diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index 34ba6c62ca..72a03acb94 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -38,6 +38,10 @@ export default function Page() { label: "Notifications", to: "/admin/notifications", }, + { + label: "Slack Channels", + to: "/admin/slack-channels", + }, { label: "Back office", to: "/admin/back-office", From 05aae7d13169c4a35548df89b7180c1b59afc499 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:32:04 +0100 Subject: [PATCH 12/26] chore(webapp): add Slack support channel release note --- .server-changes/admin-slack-support-channel-linking.md | 6 ------ .server-changes/org-support-settings-nav-item.md | 6 ------ .server-changes/slack-support-channel-settings-page-ui.md | 6 ------ .server-changes/slack-support-channel-settings-route.md | 6 ------ .server-changes/slack-support-channel.md | 6 ++++++ 5 files changed, 6 insertions(+), 24 deletions(-) delete mode 100644 .server-changes/admin-slack-support-channel-linking.md delete mode 100644 .server-changes/org-support-settings-nav-item.md delete mode 100644 .server-changes/slack-support-channel-settings-page-ui.md delete mode 100644 .server-changes/slack-support-channel-settings-route.md create mode 100644 .server-changes/slack-support-channel.md diff --git a/.server-changes/admin-slack-support-channel-linking.md b/.server-changes/admin-slack-support-channel-linking.md deleted file mode 100644 index bf1490ce5e..0000000000 --- a/.server-changes/admin-slack-support-channel-linking.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Added an admin page for linking existing Slack Connect support channels to organizations, with suggested matches to speed up manual review. diff --git a/.server-changes/org-support-settings-nav-item.md b/.server-changes/org-support-settings-nav-item.md deleted file mode 100644 index a24e931096..0000000000 --- a/.server-changes/org-support-settings-nav-item.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Added a Support item to the organization settings side menu, linking to a new support-channel settings page. diff --git a/.server-changes/slack-support-channel-settings-page-ui.md b/.server-changes/slack-support-channel-settings-page-ui.md deleted file mode 100644 index 9d31d2b59b..0000000000 --- a/.server-changes/slack-support-channel-settings-page-ui.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -The organization Support settings page now shows the state of your Slack support channel connection and lets paid customers connect one with a single click. diff --git a/.server-changes/slack-support-channel-settings-route.md b/.server-changes/slack-support-channel-settings-route.md deleted file mode 100644 index 9f14b91385..0000000000 --- a/.server-changes/slack-support-channel-settings-route.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Added the backend for a new organization Support settings page that lets paid customers connect a shared Slack channel with the team. diff --git a/.server-changes/slack-support-channel.md b/.server-changes/slack-support-channel.md new file mode 100644 index 0000000000..1c441f2794 --- /dev/null +++ b/.server-changes/slack-support-channel.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Paid organizations can now set up a private Slack support channel from Organization settings; organizations on the free plan see an option to upgrade to unlock it. From 16fd42c274dd87e1dd750f722e8c120ba2763997 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:42:20 +0100 Subject: [PATCH 13/26] fix(webapp): make Slack support-channel provisioning retry-safe and surface enqueue failures Persist the Slack channel id right after creation instead of only on final invite success, so a redis-worker retry reuses the existing channel instead of hitting Slack's name_taken error and orphaning it. Also let enqueue failures in the settings action surface as an error instead of silently stranding the row at PROVISIONING with no way to retry. --- ...rgs.$organizationSlug.settings.support.tsx | 8 +- .../services/supportSlackChannel.server.ts | 79 ++++++++++++------- apps/webapp/test/supportSlackChannel.test.ts | 50 +++++++++++- 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx index 25eae92897..2355eb24c4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx @@ -58,14 +58,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json({ error: "Upgrade required" }, { status: 403 }); } + try { + await enqueueProvisionSupportChannel({ organizationId: organization.id }); + } catch (error) { + return json({ error: "Failed to start Slack channel provisioning" }, { status: 500 }); + } + await prisma.organizationSupportChannel.upsert({ where: { organizationId: organization.id }, create: { organizationId: organization.id, status: "PROVISIONING" }, update: { status: "PROVISIONING" }, }); - await enqueueProvisionSupportChannel({ organizationId: organization.id }); - return redirect(organizationSupportPath(organization)); }; diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 805db145fc..4412ecd67f 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -1,7 +1,6 @@ import { WebClient } from "@slack/web-api"; import { z } from "zod"; import { type PrismaClientOrTransaction } from "~/db.server"; -import { logger } from "./logger.server"; export const OrganizationSupportChannelSchema = z.object({ organizationId: z.string(), @@ -217,7 +216,7 @@ export async function provisionOrganizationSupportChannel({ const existing = await prisma.organizationSupportChannel.findFirst({ where: { organizationId }, }); - if (existing?.slackChannelId) { + if (existing?.slackChannelId && (existing.status === "INVITED" || existing.status === "LINKED")) { return { status: "exists", channelId: existing.slackChannelId }; } @@ -229,21 +228,50 @@ export async function provisionOrganizationSupportChannel({ return { status: "failed" }; } - const org = await prisma.organization.findFirst({ - where: { id: organizationId }, - select: { slug: true }, - }); - if (!org) { - await setStatus(prisma, organizationId, "FAILED", { lastError: "Organization not found" }); - return { status: "failed" }; - } + // 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" }); + return { status: "failed" }; + } - await setStatus(prisma, organizationId, "PROVISIONING", { invitedEmail: ownerEmail }); + 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" }; + } + } else { + await setStatus(prisma, organizationId, "PROVISIONING", { + slackChannelId: channelId, + slackChannelName: channelName, + invitedEmail: ownerEmail, + }); + } try { - const { channelId, channelName } = await slackClient.createPrivateChannel( - supportChannelName(org.slug) - ); const { url } = await slackClient.inviteSharedByEmail(channelId, ownerEmail); await prisma.organizationSupportChannel.update({ where: { organizationId }, @@ -258,6 +286,8 @@ export async function provisionOrganizationSupportChannel({ 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" }; @@ -450,18 +480,11 @@ export function proposeOrgMatches( export async function enqueueProvisionSupportChannel( payload: OrganizationSupportChannelPayload ) { - try { - // 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, - }); - } catch (error) { - logger.error("Failed to enqueue support channel provisioning", { - organizationId: payload.organizationId, - error, - }); - } + // 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/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 2312fdfea0..6112276028 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -55,7 +55,10 @@ describe("pickExternalTeamId", () => { class FakeSupportSlackClient implements SupportSlackClient { public created: string[] = []; public invited: Array<{ channelId: string; email: string }> = []; - constructor(private readonly opts: { failInvite?: boolean } = {}) {} + constructor(private opts: { failInvite?: boolean } = {}) {} + setFailInvite(failInvite: boolean) { + this.opts = { ...this.opts, failInvite }; + } async createPrivateChannel(name: string) { this.created.push(name); return { channelId: "C123", channelName: name }; @@ -165,6 +168,51 @@ describe("provisionOrganizationSupportChannel", () => { 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({ From 6afe7e2fff16bc2457b9f999a9ff18625e29cb1c Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:34:35 +0100 Subject: [PATCH 14/26] feat(webapp): support-channel unlink (archive) and re-provision on re-upgrade Adds an ARCHIVED status to OrganizationSupportChannel, archive/unarchive methods on the Slack client, and unlinkSupportChannel to disconnect a support channel. Re-provisioning an archived channel unarchives and reuses it instead of creating a new one, avoiding a Slack name_taken error on the cus- channel name. --- .../slack-support-channel-unlink.md | 6 + .../services/supportSlackChannel.server.ts | 107 ++++++++++++++++-- apps/webapp/test/supportSlackChannel.test.ts | 91 +++++++++++++++ .../migration.sql | 2 + .../database/prisma/schema.prisma | 1 + 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 .server-changes/slack-support-channel-unlink.md create mode 100644 internal-packages/database/prisma/migrations/20260714120000_add_organization_support_channel_archived_status/migration.sql diff --git a/.server-changes/slack-support-channel-unlink.md b/.server-changes/slack-support-channel-unlink.md new file mode 100644 index 0000000000..cbf9577c2a --- /dev/null +++ b/.server-changes/slack-support-channel-unlink.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Organizations can now disconnect their Slack support channel. Re-enabling it later reuses the same channel instead of creating a new one. diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 4412ecd67f..5cdbd4511a 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -5,9 +5,7 @@ import { type PrismaClientOrTransaction } from "~/db.server"; export const OrganizationSupportChannelSchema = z.object({ organizationId: z.string(), }); -export type OrganizationSupportChannelPayload = z.infer< - typeof OrganizationSupportChannelSchema ->; +export type OrganizationSupportChannelPayload = z.infer; export interface SupportSlackClient { createPrivateChannel(name: string): Promise<{ channelId: string; channelName: string }>; @@ -15,6 +13,21 @@ export interface SupportSlackClient { 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 + ); } export interface SupportSlackDiscoveryClient { @@ -149,6 +162,28 @@ export class SupportSlackClientLive implements SupportSlackClient, SupportSlackD } 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; + } + } } /** @@ -188,7 +223,7 @@ async function getOrganizationOwnerEmail( async function setStatus( prisma: PrismaClientOrTransaction, organizationId: string, - status: "PENDING" | "PROVISIONING" | "INVITED" | "FAILED", + status: "PENDING" | "PROVISIONING" | "INVITED" | "FAILED" | "ARCHIVED", data: { slackChannelId?: string | null; slackChannelName?: string | null; @@ -228,6 +263,41 @@ export async function provisionOrganizationSupportChannel({ return { status: "failed" }; } + // 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) { + await setStatus(prisma, organizationId, "FAILED", { + slackChannelId: channelId, + slackChannelName: channelName, + lastError: error instanceof Error ? error.message : String(error), + }); + return { status: "failed" }; + } + } + // 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). @@ -294,6 +364,31 @@ export async function provisionOrganizationSupportChannel({ } } +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, @@ -477,9 +572,7 @@ export function proposeOrgMatches( return proposals; } -export async function enqueueProvisionSupportChannel( - payload: OrganizationSupportChannelPayload -) { +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({ diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 6112276028..6ef49df997 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -8,6 +8,7 @@ import { proposeOrgMatches, provisionOrganizationSupportChannel, supportChannelName, + unlinkSupportChannel, type ChannelCandidate, type OrgCandidate, type SupportSlackClient, @@ -55,6 +56,8 @@ describe("pickExternalTeamId", () => { 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 } = {}) {} setFailInvite(failInvite: boolean) { this.opts = { ...this.opts, failInvite }; @@ -68,6 +71,12 @@ class FakeSupportSlackClient implements SupportSlackClient { 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); + } } async function seedOrg( @@ -227,6 +236,88 @@ describe("provisionOrganizationSupportChannel", () => { 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 { diff --git a/internal-packages/database/prisma/migrations/20260714120000_add_organization_support_channel_archived_status/migration.sql b/internal-packages/database/prisma/migrations/20260714120000_add_organization_support_channel_archived_status/migration.sql new file mode 100644 index 0000000000..560aa0d227 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260714120000_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 2165ac880f..29e59c694e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -3202,6 +3202,7 @@ enum OrganizationSupportChannelStatus { INVITED FAILED LINKED + ARCHIVED } model OrganizationSupportChannel { From 4abbd1056f20e5dd390c41f34fbf5ef674a4c33a Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:42:29 +0100 Subject: [PATCH 15/26] feat(webapp): flag downgraded orgs and add unlink in the admin Slack page Admins can now see which orgs kept a linked Slack support channel after downgrading off a paid plan, and unlink a channel directly from the admin page instead of going through a script. --- .../slack-support-channel-admin-downgrade.md | 6 + .../app/routes/admin.slack-channels.tsx | 123 +++++++++++++++--- .../services/supportSlackChannel.server.ts | 12 ++ apps/webapp/test/supportSlackChannel.test.ts | 14 ++ 4 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 .server-changes/slack-support-channel-admin-downgrade.md diff --git a/.server-changes/slack-support-channel-admin-downgrade.md b/.server-changes/slack-support-channel-admin-downgrade.md new file mode 100644 index 0000000000..54f6e4875b --- /dev/null +++ b/.server-changes/slack-support-channel-admin-downgrade.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The admin Slack support-channel page now flags organizations that downgraded after linking a channel, and lets an admin unlink a channel directly from the page. diff --git a/apps/webapp/app/routes/admin.slack-channels.tsx b/apps/webapp/app/routes/admin.slack-channels.tsx index 122918ebc7..7147d6c61a 100644 --- a/apps/webapp/app/routes/admin.slack-channels.tsx +++ b/apps/webapp/app/routes/admin.slack-channels.tsx @@ -1,4 +1,5 @@ import { useFetcher } from "@remix-run/react"; +import type { OrganizationSupportChannelStatus } from "@trigger.dev/database"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button } from "~/components/primitives/Buttons"; @@ -15,16 +16,28 @@ import { 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"; +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) { @@ -33,7 +46,7 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } }, channels: [], proposals: [], orgs: [], - linkedOrgTitleByChannelId: {} as Record, + linkedChannelInfoByChannelId: {} as Record, }); } @@ -69,7 +82,7 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } }, slug: true, title: true, supportChannel: { - select: { slackChannelId: true, slackChannelName: true }, + select: { slackChannelId: true, slackChannelName: true, status: true }, }, members: { where: { role: "ADMIN" }, @@ -92,14 +105,36 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } }, }; }); - // Maps a Slack channel id to the org title it's already linked to, so the - // table can show per-channel link status. Built separately from + // 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. - const linkedOrgTitleByChannelId: Record = {}; + // 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) { - if (organization.supportChannel?.slackChannelId) { - linkedOrgTitleByChannelId[organization.supportChannel.slackChannelId] = organization.title; + 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); @@ -109,17 +144,24 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } }, channels, proposals, orgs, - linkedOrgTitleByChannelId, + linkedChannelInfoByChannelId, }); }); -const ActionBody = z.object({ +const LinkActionBody = z.object({ _action: z.enum(["link", "reassign"]), channelId: z.string(), channelName: z.string(), organizationId: z.string(), }); +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 }) => { @@ -129,6 +171,24 @@ export const action = dashboardAction( 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({ @@ -150,7 +210,7 @@ type LoaderChannel = ChannelCandidate; type LoaderOrg = OrgCandidate & { organizationId: string }; export default function AdminSlackChannelsRoute() { - const { notConfigured, channels, proposals, orgs, linkedOrgTitleByChannelId } = + const { notConfigured, channels, proposals, orgs, linkedChannelInfoByChannelId } = useTypedLoaderData(); if (notConfigured) { @@ -197,7 +257,7 @@ export default function AdminSlackChannelsRoute() { channel={channel} proposal={proposalByChannelId.get(channel.channelId)} orgs={orgs} - linkedOrgTitle={linkedOrgTitleByChannelId[channel.channelId]} + linkedInfo={linkedChannelInfoByChannelId[channel.channelId]} /> )) )} @@ -212,16 +272,18 @@ function ChannelRow({ channel, proposal, orgs, - linkedOrgTitle, + linkedInfo, }: { channel: LoaderChannel; proposal: MatchProposal | undefined; orgs: LoaderOrg[]; - linkedOrgTitle: string | undefined; + linkedInfo: LinkedChannelInfo | undefined; }) { const fetcher = useFetcher<{ error?: string; success?: boolean }>(); + const unlinkFetcher = useFetcher<{ error?: string; success?: boolean }>(); const defaultOrganizationId = proposal?.organizationId ?? orgs[0]?.organizationId ?? ""; const isBusy = fetcher.state !== "idle"; + const isUnlinking = unlinkFetcher.state !== "idle"; return ( @@ -229,8 +291,17 @@ function ChannelRow({ {channel.channelName} - {linkedOrgTitle ? ( - Linked: {linkedOrgTitle} + {linkedInfo ? ( +
+ + Linked: {linkedInfo.title} ({linkedInfo.status}) + + {linkedInfo.downgraded && ( + + Downgraded + + )} +
) : ( Unlinked )} @@ -284,7 +355,27 @@ function ChannelRow({ )}
- + + {linkedInfo && ( + + + + + )} + {unlinkFetcher.data?.error && ( + + {unlinkFetcher.data.error} + + )} +
); } diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index 5cdbd4511a..eb5583e9a0 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -65,6 +65,18 @@ export function isPaidPlan( 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 diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index 6ef49df997..e3d599f865 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { postgresTest } from "@internal/testcontainers"; import { + isDowngradedLink, isPaidPlan, isCustomerSupportChannel, linkSupportChannel, @@ -24,6 +25,19 @@ describe("isPaidPlan", () => { }); }); +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"); From bcc55d60fe4f3c1a317f509b4cad72bfecbf0454 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:00:02 +0100 Subject: [PATCH 16/26] chore(webapp): tidy Slack support-channel release note and admin unlink Collapse the release note back to a single entry, add a confirmation to the admin unlink action, and clarify the best-effort team-domain lookup. --- .../slack-support-channel-admin-downgrade.md | 6 ------ .server-changes/slack-support-channel-unlink.md | 6 ------ ..._app.orgs.$organizationSlug.settings.support.tsx | 2 +- apps/webapp/app/routes/admin.slack-channels.tsx | 13 ++++++++++++- .../app/services/supportSlackChannel.server.ts | 2 ++ 5 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 .server-changes/slack-support-channel-admin-downgrade.md delete mode 100644 .server-changes/slack-support-channel-unlink.md diff --git a/.server-changes/slack-support-channel-admin-downgrade.md b/.server-changes/slack-support-channel-admin-downgrade.md deleted file mode 100644 index 54f6e4875b..0000000000 --- a/.server-changes/slack-support-channel-admin-downgrade.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -The admin Slack support-channel page now flags organizations that downgraded after linking a channel, and lets an admin unlink a channel directly from the page. diff --git a/.server-changes/slack-support-channel-unlink.md b/.server-changes/slack-support-channel-unlink.md deleted file mode 100644 index cbf9577c2a..0000000000 --- a/.server-changes/slack-support-channel-unlink.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Organizations can now disconnect their Slack support channel. Re-enabling it later reuses the same channel instead of creating a new one. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx index 2355eb24c4..565a118043 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.support.tsx @@ -137,7 +137,7 @@ export default function Page() { ) : supportChannel?.status === "PROVISIONING" ? ( - Setting up your channel — check your email shortly for the Slack Connect invite. + Setting up your channel. Check your email shortly for the Slack Connect invite. ) : (
diff --git a/apps/webapp/app/routes/admin.slack-channels.tsx b/apps/webapp/app/routes/admin.slack-channels.tsx index 7147d6c61a..c8a923971e 100644 --- a/apps/webapp/app/routes/admin.slack-channels.tsx +++ b/apps/webapp/app/routes/admin.slack-channels.tsx @@ -357,7 +357,18 @@ function ChannelRow({ {linkedInfo && ( - + { + if ( + !window.confirm( + "Archive this Slack support channel and unlink it from the organization? The customer will lose access." + ) + ) { + e.preventDefault(); + } + }} + > From ba1f9eb7271f94e3f3b795ceca87a08d6b3ff184 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 12 Aug 2026 12:37:52 -0400 Subject: [PATCH 21/26] fix(webapp): invite the longest-standing admin to the support channel The owner lookup had no orderBy, so which admin received the Slack Connect invite varied between runs and a retry could email someone else. Orders by createdAt, matching the admin page's lookup. --- .../services/supportSlackChannel.server.ts | 6 ++- apps/webapp/test/supportSlackChannel.test.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/supportSlackChannel.server.ts b/apps/webapp/app/services/supportSlackChannel.server.ts index c9873a5883..a8b8a6456b 100644 --- a/apps/webapp/app/services/supportSlackChannel.server.ts +++ b/apps/webapp/app/services/supportSlackChannel.server.ts @@ -239,9 +239,13 @@ async function getOrganizationOwnerEmail( prisma: PrismaClientOrTransaction, organizationId: string ): Promise { - // First ADMIN member is treated as the org owner. + // 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; diff --git a/apps/webapp/test/supportSlackChannel.test.ts b/apps/webapp/test/supportSlackChannel.test.ts index f4f5f7306f..fe159548f4 100644 --- a/apps/webapp/test/supportSlackChannel.test.ts +++ b/apps/webapp/test/supportSlackChannel.test.ts @@ -151,6 +151,51 @@ describe("provisionOrganizationSupportChannel", () => { 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("is idempotent — existing channel makes no Slack calls", async ({ prisma }) => { const { org } = await seedOrg(prisma); await prisma.organizationSupportChannel.create({ From d4539c031aba2d088a662fe106a1d6476d0ca65e Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 12 Aug 2026 12:37:52 -0400 Subject: [PATCH 22/26] fix(webapp): require an explicit org pick in the admin Slack linking page The dropdown defaulted to the first organization in the list when no match was proposed, so a single click on Approve could link the wrong org. Adds a placeholder default, disables the buttons until one is chosen, and rejects the sentinel server-side. --- .../app/routes/admin.slack-channels.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/admin.slack-channels.tsx b/apps/webapp/app/routes/admin.slack-channels.tsx index c8a923971e..5dbedd61cc 100644 --- a/apps/webapp/app/routes/admin.slack-channels.tsx +++ b/apps/webapp/app/routes/admin.slack-channels.tsx @@ -1,4 +1,5 @@ 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"; @@ -31,6 +32,10 @@ import { 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; @@ -152,7 +157,9 @@ const LinkActionBody = z.object({ _action: z.enum(["link", "reassign"]), channelId: z.string(), channelName: z.string(), - organizationId: z.string(), + organizationId: z.string().refine((value) => value !== UNSET_ORGANIZATION_ID, { + message: "Select an organization", + }), }); const UnlinkActionBody = z.object({ @@ -281,9 +288,12 @@ function ChannelRow({ }) { const fetcher = useFetcher<{ error?: string; success?: boolean }>(); const unlinkFetcher = useFetcher<{ error?: string; success?: boolean }>(); - const defaultOrganizationId = proposal?.organizationId ?? orgs[0]?.organizationId ?? ""; + 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 ( @@ -312,9 +322,11 @@ function ChannelRow({