Add slack integration endpoints

This commit is contained in:
Nate Kelley 2025-07-09 14:04:54 -06:00
parent 679a8f4eb3
commit c223309df2
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
8 changed files with 235 additions and 62 deletions

View File

@ -33,15 +33,30 @@ const createCspHeader = (isEmbed = false) => {
// Frame sources
"frame-src 'self' https://vercel.live",
// Connect sources for API calls
`connect-src 'self' ${localDomains} https://*.vercel.app https://*.supabase.co wss://*.supabase.co https://*.posthog.com ${apiUrl} ${api2Url} ${profilePictureURL}`
.replace(/\s+/g, ' ')
.trim(),
(() => {
const connectSources = [
"'self'",
localDomains,
'https://*.vercel.app',
'https://*.supabase.co',
'wss://*.supabase.co',
'https://*.posthog.com',
'https://*.slack.com',
apiUrl,
api2Url,
profilePictureURL
]
.map((source) => source.replace(/\s+/g, ' ').trim())
.filter(Boolean);
return `connect-src ${connectSources.join(' ')}`;
})(),
// Media
"media-src 'self'",
// Object
"object-src 'none'",
// Form actions
"form-action 'self'",
"form-action 'self' https://*.slack.com",
// Base URI
"base-uri 'self'",
// Manifest

View File

@ -1,11 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/api/query_keys';
import { slackQueryKeys } from '@/api/query_keys/slack';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useMemoizedFn } from '@/hooks';
import type {
InitiateOAuthRequest,
UpdateIntegrationRequest
} from '@buster/server-shared/slack';
import {
initiateSlackOAuth,
getSlackIntegration,
@ -17,7 +13,7 @@ import {
// GET /api/v2/slack/integration
export const useGetSlackIntegration = (enabled = true) => {
return useQuery({
...queryKeys.slackGetIntegration,
...slackQueryKeys.slackGetIntegration,
queryFn: getSlackIntegration,
enabled
});
@ -26,7 +22,7 @@ export const useGetSlackIntegration = (enabled = true) => {
// GET /api/v2/slack/channels
export const useGetSlackChannels = (enabled = true) => {
return useQuery({
...queryKeys.slackGetChannels,
...slackQueryKeys.slackGetChannels,
queryFn: getSlackChannels,
enabled
});
@ -34,8 +30,22 @@ export const useGetSlackChannels = (enabled = true) => {
// POST /api/v2/slack/auth/init
export const useInitiateSlackOAuth = () => {
const { openErrorNotification } = useBusterNotifications();
const mutationFn = useMemoizedFn(async () => {
const result = await initiateSlackOAuth();
if (result.auth_url) {
window.location.href = result.auth_url;
} else {
openErrorNotification({
title: 'Failed to initiate Slack OAuth',
description: 'Please try again later',
duration: 5000
});
}
});
return useMutation({
mutationFn: initiateSlackOAuth
mutationFn
});
};
@ -48,7 +58,7 @@ export const useUpdateSlackIntegration = () => {
onSuccess: () => {
// Invalidate the integration query to refetch the updated data
queryClient.invalidateQueries({
queryKey: queryKeys.slackGetIntegration.queryKey,
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
refetchType: 'all'
});
}
@ -60,8 +70,9 @@ export const useRemoveSlackIntegration = () => {
const queryClient = useQueryClient();
const { openConfirmModal } = useBusterNotifications();
const mutationFn = useMemoizedFn(
async ({ ignoreConfirm = false }: { ignoreConfirm?: boolean } = {}) => {
const mutationFn = useMemoizedFn(async () => {
const ignoreConfirm = false;
const method = async () => {
return await removeSlackIntegration();
};
@ -72,25 +83,25 @@ export const useRemoveSlackIntegration = () => {
return openConfirmModal({
title: 'Remove Slack Integration',
content: 'Are you sure you want to remove the Slack integration? This will disconnect your workspace from Slack.',
content:
'Are you sure you want to remove the Slack integration? This will disconnect your workspace from Slack.',
primaryButtonProps: {
text: 'Remove'
},
onOk: method
});
}
);
});
return useMutation({
mutationFn,
onSuccess: () => {
// Invalidate both integration and channels queries
queryClient.invalidateQueries({
queryKey: queryKeys.slackGetIntegration.queryKey,
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
refetchType: 'all'
});
queryClient.invalidateQueries({
queryKey: queryKeys.slackGetChannels.queryKey,
queryKey: slackQueryKeys.slackGetChannels.queryKey,
refetchType: 'all'
});
}

View File

@ -10,7 +10,7 @@ import type {
} from '@buster/server-shared/slack';
// POST /api/v2/slack/auth/init
export const initiateSlackOAuth = async (data: InitiateOAuthRequest) => {
export const initiateSlackOAuth = async (data?: InitiateOAuthRequest) => {
return mainApiV2.post<InitiateOAuthResponse>('/slack/auth/init', data).then((res) => res.data);
};
@ -26,7 +26,9 @@ export const removeSlackIntegration = async () => {
// PUT /api/v2/slack/integration
export const updateSlackIntegration = async (data: UpdateIntegrationRequest) => {
return mainApiV2.put<UpdateIntegrationResponse>('/slack/integration', data).then((res) => res.data);
return mainApiV2
.put<UpdateIntegrationResponse>('/slack/integration', data)
.then((res) => res.data);
};
// GET /api/v2/slack/channels

View File

@ -1,15 +1,37 @@
'use client';
import React from 'react';
import { SettingsCards } from '../settings/SettingsCard';
import { SlackIcon } from '@/components/ui/icons/customIcons/SlackIcon';
import { Text } from '@/components/ui/typography';
import { Button } from '@/components/ui/buttons';
import {
useGetSlackChannels,
useGetSlackIntegration,
useInitiateSlackOAuth,
useRemoveSlackIntegration,
useUpdateSlackIntegration
} from '@/api/buster_rest/slack/queryRequests';
import { Dropdown, type DropdownItems } from '@/components/ui/dropdown';
import { LinkSlash, Refresh2 } from '@/components/ui/icons';
import { Select } from '@/components/ui/select';
export const SlackIntegrations = React.memo(() => {
const { data: slackIntegration } = useGetSlackIntegration();
const isConnected = slackIntegration?.connected ?? false;
return (
<SettingsCards
title="Slack"
description="Connect Buster with Slack"
cards={[{ sections: [<ConnectSlackCard />] }]}
cards={[
{
sections: [
<ConnectSlackCard key="connect-slack-card" />,
isConnected && <ConnectedSlackChannels key="connected-slack-channels" />
].filter(Boolean)
}
]}
/>
);
});
@ -17,6 +39,11 @@ export const SlackIntegrations = React.memo(() => {
SlackIntegrations.displayName = 'SlackIntegrations';
const ConnectSlackCard = React.memo(() => {
const { data: slackIntegration } = useGetSlackIntegration();
const { mutate: initiateSlackOAuth } = useInitiateSlackOAuth();
const isConnected = slackIntegration?.connected;
return (
<div className="flex items-center justify-between gap-x-2">
<div className="flex space-x-2">
@ -30,9 +57,110 @@ const ConnectSlackCard = React.memo(() => {
</Text>
</div>
</div>
<Button prefix={<SlackIcon size={16} />} size={'tall'}>
{isConnected ? (
<ConnectedDropdown />
) : (
<Button prefix={<SlackIcon size={16} />} onClick={() => initiateSlackOAuth()} size={'tall'}>
Connect Slack
</Button>
)}
</div>
);
});
ConnectSlackCard.displayName = 'ConnectSlackCard';
const ConnectedDropdown = React.memo(() => {
const { mutate: removeSlackIntegration, isPending } = useRemoveSlackIntegration();
const dropdownItems: DropdownItems = [
{
value: 'disconnect',
label: 'Disconnect',
icon: <LinkSlash />,
onClick: () => {
removeSlackIntegration();
},
loading: isPending
}
];
return (
<Dropdown items={dropdownItems} align="end" side="bottom">
<div className="hover:bg-item-hover flex! cursor-pointer items-center space-x-1.5 rounded p-1.5">
<div className="bg-success-foreground h-2.5 w-2.5 rounded-full" />
<Text className="select-none">Connected</Text>
</div>
</Dropdown>
);
});
ConnectedDropdown.displayName = 'ConnectedDropdown';
const ConnectedSlackChannels = React.memo(() => {
const { data: slackIntegration, isLoading: isLoadingSlackIntegration } = useGetSlackIntegration();
const {
data: slackChannelsData,
isLoading: isLoadingSlackChannels,
refetch: refetchSlackChannels,
isFetched: isFetchedSlackChannels,
error: slackChannelsError
} = useGetSlackChannels();
const { mutate: updateSlackIntegration } = useUpdateSlackIntegration();
const channels = slackChannelsData?.channels || [];
const selectedChannelId = slackIntegration?.integration?.default_channel?.id;
return (
<div className="flex items-center justify-between space-x-4">
<div className="flex flex-col space-y-0.5">
<Text>Alerts channels</Text>
<Text variant="secondary" size={'xs'}>
Select which slack channel Buster should send alerts to
</Text>
</div>
<div className="flex min-w-0 flex-1 items-center justify-end space-x-2">
{!slackChannelsError ? (
<>
{isFetchedSlackChannels && (
<Button
size={'small'}
variant="ghost"
suffix={<Refresh2 />}
onClick={() => refetchSlackChannels()}>
Refresh
</Button>
)}
<Select
className="w-fit min-w-40"
items={channels.map((channel) => ({
label: channel.name,
value: channel.id
}))}
placeholder="Select a channel"
value={selectedChannelId}
onChange={(channelId) => {
const channel = channels.find((channel) => channel.id === channelId);
if (!channel) return;
updateSlackIntegration({
default_channel: channel
});
}}
loading={isLoadingSlackChannels || isLoadingSlackIntegration}
/>
</>
) : (
<div className="flex items-center space-x-2">
<Text variant="danger" size={'xs'}>
Error fetching channels.
</Text>
</div>
)}
</div>
</div>
);
});
ConnectedSlackChannels.displayName = 'ConnectedSlackChannels';

View File

@ -9,6 +9,8 @@ import {
SelectTrigger,
SelectValue
} from './SelectBase';
import { CircleSpinnerLoader } from '../loaders';
import { cn } from '@/lib/classMerge';
interface SelectItemGroup<T = string> {
label: string;
@ -36,6 +38,7 @@ export interface SelectProps<T> {
className?: string;
defaultValue?: string;
dataTestId?: string;
loading?: boolean;
}
export const Select = <T extends string>({
@ -47,6 +50,7 @@ export const Select = <T extends string>({
value,
onOpenChange,
open,
loading = false,
className = '',
defaultValue,
dataTestId
@ -62,7 +66,7 @@ export const Select = <T extends string>({
defaultValue={defaultValue}
value={value}
onValueChange={onValueChange}>
<SelectTrigger className={className} data-testid={dataTestId}>
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
</SelectTrigger>
<SelectContent>

View File

@ -5,6 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/classMerge';
import { Check3 as Check, ChevronDown, ChevronUp } from '../icons/NucleoIconOutlined';
import CircleSpinnerLoader from '../loaders/CircleSpinnerLoader';
const Select = SelectPrimitive.Root;
@ -36,18 +37,28 @@ export const selectVariants = cva(
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
VariantProps<typeof selectVariants>
>(({ className, variant = 'default', size = 'default', children, ...props }, ref) => (
VariantProps<typeof selectVariants> & {
loading?: boolean;
}
>(({ className, variant = 'default', size = 'default', children, loading, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(selectVariants({ variant, size }), className)}
className={cn('relative', selectVariants({ variant, size }), className)}
{...props}>
{children}
{!loading && (
<SelectPrimitive.Icon asChild>
<div className="flex items-center justify-center opacity-50">
<ChevronDown />
</div>
</SelectPrimitive.Icon>
)}
{loading && (
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center justify-center">
<CircleSpinnerLoader size={12} />
</div>
)}
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

View File

@ -123,7 +123,7 @@ ${
.map((a) => `- ${a.descriptiveTitle}: ${a.explanation}`)
.join('\n\n')
: 'No major assumptions identified'
}
}
Generate a concise update message for the data team.`;

View File

@ -1,7 +1,8 @@
import { z } from 'zod/v4';
// POST /api/v2/slack/auth/init
export const InitiateOAuthSchema = z.object({
export const InitiateOAuthSchema = z
.object({
metadata: z
.object({
return_url: z.string().optional(),
@ -9,7 +10,8 @@ export const InitiateOAuthSchema = z.object({
project_id: z.string().uuid().optional(),
})
.optional(),
});
})
.optional();
export type InitiateOAuthRequest = z.infer<typeof InitiateOAuthSchema>;