11import { useFetcher } from "@remix-run/react" ;
2+ import type { OrganizationSupportChannelStatus } from "@trigger.dev/database" ;
23import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
34import { z } from "zod" ;
45import { Button } from "~/components/primitives/Buttons" ;
@@ -15,16 +16,28 @@ import {
1516import { prisma } from "~/db.server" ;
1617import { env } from "~/env.server" ;
1718import { dashboardAction , dashboardLoader } from "~/services/routeBuilders/dashboardBuilder" ;
19+ import { getCurrentPlan } from "~/services/platform.v3.server" ;
1820import {
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+
2841export 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+
123165export 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;
150210type LoaderOrg = OrgCandidate & { organizationId : string } ;
151211
152212export 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}
0 commit comments