Skip to content

Commit 4abbd10

Browse files
committed
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.
1 parent 6afe7e2 commit 4abbd10

4 files changed

Lines changed: 139 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.

apps/webapp/app/routes/admin.slack-channels.tsx

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useFetcher } from "@remix-run/react";
2+
import type { OrganizationSupportChannelStatus } from "@trigger.dev/database";
23
import { typedjson, useTypedLoaderData } from "remix-typedjson";
34
import { z } from "zod";
45
import { Button } from "~/components/primitives/Buttons";
@@ -15,16 +16,28 @@ import {
1516
import { prisma } from "~/db.server";
1617
import { env } from "~/env.server";
1718
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
19+
import { getCurrentPlan } from "~/services/platform.v3.server";
1820
import {
21+
createSupportSlackClient,
1922
createSupportSlackDiscoveryClient,
23+
isDowngradedLink,
24+
isPaidPlan,
2025
linkSupportChannel,
2126
pickExternalTeamId,
2227
proposeOrgMatches,
28+
unlinkSupportChannel,
2329
type ChannelCandidate,
2430
type MatchProposal,
2531
type OrgCandidate,
2632
} from "~/services/supportSlackChannel.server";
2733

34+
type LinkedChannelInfo = {
35+
organizationId: string;
36+
title: string;
37+
status: OrganizationSupportChannelStatus;
38+
downgraded: boolean;
39+
};
40+
2841
export const loader = dashboardLoader({ authorization: { requireSuper: true } }, async () => {
2942
const client = createSupportSlackDiscoveryClient(env.SLACK_BOT_TOKEN);
3043
if (!client) {
@@ -33,7 +46,7 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } },
3346
channels: [],
3447
proposals: [],
3548
orgs: [],
36-
linkedOrgTitleByChannelId: {} as Record<string, string>,
49+
linkedChannelInfoByChannelId: {} as Record<string, LinkedChannelInfo>,
3750
});
3851
}
3952

@@ -69,7 +82,7 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } },
6982
slug: true,
7083
title: true,
7184
supportChannel: {
72-
select: { slackChannelId: true, slackChannelName: true },
85+
select: { slackChannelId: true, slackChannelName: true, status: true },
7386
},
7487
members: {
7588
where: { role: "ADMIN" },
@@ -92,14 +105,36 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } },
92105
};
93106
});
94107

95-
// Maps a Slack channel id to the org title it's already linked to, so the
96-
// table can show per-channel link status. Built separately from
108+
// Maps a Slack channel id to its linked org + status, so the table can show
109+
// per-channel link status and a downgraded flag. Built separately from
97110
// `OrgCandidate` since that type only carries a boolean for matching.
98-
const linkedOrgTitleByChannelId: Record<string, string> = {};
111+
// Plan lookups are cached per org since the same org can only appear once
112+
// here, but this keeps the pattern safe if that ever changes.
113+
const planCache = new Map<string, boolean>();
114+
async function isOrgPaying(organizationId: string): Promise<boolean> {
115+
const cached = planCache.get(organizationId);
116+
if (cached !== undefined) {
117+
return cached;
118+
}
119+
const plan = await getCurrentPlan(organizationId);
120+
const paying = isPaidPlan(plan);
121+
planCache.set(organizationId, paying);
122+
return paying;
123+
}
124+
125+
const linkedChannelInfoByChannelId: Record<string, LinkedChannelInfo> = {};
99126
for (const organization of organizations) {
100-
if (organization.supportChannel?.slackChannelId) {
101-
linkedOrgTitleByChannelId[organization.supportChannel.slackChannelId] = organization.title;
127+
const supportChannel = organization.supportChannel;
128+
if (!supportChannel?.slackChannelId) {
129+
continue;
102130
}
131+
const isPaying = await isOrgPaying(organization.id);
132+
linkedChannelInfoByChannelId[supportChannel.slackChannelId] = {
133+
organizationId: organization.id,
134+
title: organization.title,
135+
status: supportChannel.status,
136+
downgraded: isDowngradedLink({ hasChannel: true, isPaying }),
137+
};
103138
}
104139

105140
const proposals = proposeOrgMatches(channels, orgs);
@@ -109,17 +144,24 @@ export const loader = dashboardLoader({ authorization: { requireSuper: true } },
109144
channels,
110145
proposals,
111146
orgs,
112-
linkedOrgTitleByChannelId,
147+
linkedChannelInfoByChannelId,
113148
});
114149
});
115150

116-
const ActionBody = z.object({
151+
const LinkActionBody = z.object({
117152
_action: z.enum(["link", "reassign"]),
118153
channelId: z.string(),
119154
channelName: z.string(),
120155
organizationId: z.string(),
121156
});
122157

158+
const UnlinkActionBody = z.object({
159+
_action: z.literal("unlink"),
160+
organizationId: z.string(),
161+
});
162+
163+
const ActionBody = z.union([LinkActionBody, UnlinkActionBody]);
164+
123165
export const action = dashboardAction(
124166
{ authorization: { requireSuper: true } },
125167
async ({ request }) => {
@@ -129,6 +171,24 @@ export const action = dashboardAction(
129171
return typedjson({ error: "Invalid form submission" }, { status: 400 });
130172
}
131173

174+
if (parsed.data._action === "unlink") {
175+
const { organizationId } = parsed.data;
176+
const slackClient = createSupportSlackClient(env.SLACK_BOT_TOKEN);
177+
if (!slackClient) {
178+
return typedjson({ error: "Slack is not configured" }, { status: 400 });
179+
}
180+
181+
const result = await unlinkSupportChannel({ organizationId, prisma, slackClient });
182+
if (result.status === "not_found") {
183+
return typedjson(
184+
{ error: "No linked channel found for this organization" },
185+
{ status: 404 }
186+
);
187+
}
188+
189+
return typedjson({ success: true as const });
190+
}
191+
132192
const { _action, channelId, channelName, organizationId } = parsed.data;
133193

134194
const result = await linkSupportChannel({
@@ -150,7 +210,7 @@ type LoaderChannel = ChannelCandidate;
150210
type LoaderOrg = OrgCandidate & { organizationId: string };
151211

152212
export default function AdminSlackChannelsRoute() {
153-
const { notConfigured, channels, proposals, orgs, linkedOrgTitleByChannelId } =
213+
const { notConfigured, channels, proposals, orgs, linkedChannelInfoByChannelId } =
154214
useTypedLoaderData<typeof loader>();
155215

156216
if (notConfigured) {
@@ -197,7 +257,7 @@ export default function AdminSlackChannelsRoute() {
197257
channel={channel}
198258
proposal={proposalByChannelId.get(channel.channelId)}
199259
orgs={orgs}
200-
linkedOrgTitle={linkedOrgTitleByChannelId[channel.channelId]}
260+
linkedInfo={linkedChannelInfoByChannelId[channel.channelId]}
201261
/>
202262
))
203263
)}
@@ -212,25 +272,36 @@ function ChannelRow({
212272
channel,
213273
proposal,
214274
orgs,
215-
linkedOrgTitle,
275+
linkedInfo,
216276
}: {
217277
channel: LoaderChannel;
218278
proposal: MatchProposal | undefined;
219279
orgs: LoaderOrg[];
220-
linkedOrgTitle: string | undefined;
280+
linkedInfo: LinkedChannelInfo | undefined;
221281
}) {
222282
const fetcher = useFetcher<{ error?: string; success?: boolean }>();
283+
const unlinkFetcher = useFetcher<{ error?: string; success?: boolean }>();
223284
const defaultOrganizationId = proposal?.organizationId ?? orgs[0]?.organizationId ?? "";
224285
const isBusy = fetcher.state !== "idle";
286+
const isUnlinking = unlinkFetcher.state !== "idle";
225287

226288
return (
227289
<TableRow>
228290
<TableCell>
229291
<span className="font-mono text-xs text-text-bright">{channel.channelName}</span>
230292
</TableCell>
231293
<TableCell>
232-
{linkedOrgTitle ? (
233-
<span className="text-xs text-text-dimmed">Linked: {linkedOrgTitle}</span>
294+
{linkedInfo ? (
295+
<div className="flex items-center gap-2">
296+
<span className="text-xs text-text-dimmed">
297+
Linked: {linkedInfo.title} ({linkedInfo.status})
298+
</span>
299+
{linkedInfo.downgraded && (
300+
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium text-amber-400">
301+
Downgraded
302+
</span>
303+
)}
304+
</div>
234305
) : (
235306
<span className="text-xs text-text-dimmed">Unlinked</span>
236307
)}
@@ -284,7 +355,27 @@ function ChannelRow({
284355
<span className="text-xs text-text-dimmed"></span>
285356
)}
286357
</TableCell>
287-
<TableCell />
358+
<TableCell>
359+
{linkedInfo && (
360+
<unlinkFetcher.Form method="post">
361+
<input type="hidden" name="organizationId" value={linkedInfo.organizationId} />
362+
<Button
363+
type="submit"
364+
name="_action"
365+
value="unlink"
366+
variant="danger/small"
367+
disabled={isUnlinking}
368+
>
369+
Unlink
370+
</Button>
371+
</unlinkFetcher.Form>
372+
)}
373+
{unlinkFetcher.data?.error && (
374+
<Paragraph variant="extra-small" className="mt-1 text-red-400">
375+
{unlinkFetcher.data.error}
376+
</Paragraph>
377+
)}
378+
</TableCell>
288379
</TableRow>
289380
);
290381
}

apps/webapp/app/services/supportSlackChannel.server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ export function isPaidPlan(
6565
return plan?.v3Subscription?.isPaying === true;
6666
}
6767

68+
// An org is "downgraded" when it still has a support channel linked but is no
69+
// longer on a paying plan, e.g. it downgraded after the channel was created.
70+
export function isDowngradedLink({
71+
hasChannel,
72+
isPaying,
73+
}: {
74+
hasChannel: boolean;
75+
isPaying: boolean;
76+
}): boolean {
77+
return hasChannel && !isPaying;
78+
}
79+
6880
// Slack channel names: lowercase, only [a-z0-9-], <= 80 chars.
6981
export function supportChannelName(orgSlug: string): string {
7082
const cleaned = orgSlug

apps/webapp/test/supportSlackChannel.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import { postgresTest } from "@internal/testcontainers";
33
import {
4+
isDowngradedLink,
45
isPaidPlan,
56
isCustomerSupportChannel,
67
linkSupportChannel,
@@ -24,6 +25,19 @@ describe("isPaidPlan", () => {
2425
});
2526
});
2627

28+
describe("isDowngradedLink", () => {
29+
it("flags a linked channel on a non-paying org as downgraded", () => {
30+
expect(isDowngradedLink({ hasChannel: true, isPaying: false })).toBe(true);
31+
});
32+
it("does not flag a linked channel on a paying org", () => {
33+
expect(isDowngradedLink({ hasChannel: true, isPaying: true })).toBe(false);
34+
});
35+
it("does not flag an org with no channel regardless of plan", () => {
36+
expect(isDowngradedLink({ hasChannel: false, isPaying: false })).toBe(false);
37+
expect(isDowngradedLink({ hasChannel: false, isPaying: true })).toBe(false);
38+
});
39+
});
40+
2741
describe("supportChannelName", () => {
2842
it("prefixes cus- and lowercases", () => {
2943
expect(supportChannelName("Acme-Corp")).toBe("cus-acme-corp");

0 commit comments

Comments
 (0)