mirror of https://github.com/buster-so/buster.git
Add slack integration endpoints
This commit is contained in:
parent
679a8f4eb3
commit
c223309df2
|
@ -33,15 +33,30 @@ const createCspHeader = (isEmbed = false) => {
|
||||||
// Frame sources
|
// Frame sources
|
||||||
"frame-src 'self' https://vercel.live",
|
"frame-src 'self' https://vercel.live",
|
||||||
// Connect sources for API calls
|
// 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, ' ')
|
const connectSources = [
|
||||||
.trim(),
|
"'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
|
||||||
"media-src 'self'",
|
"media-src 'self'",
|
||||||
// Object
|
// Object
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
// Form actions
|
// Form actions
|
||||||
"form-action 'self'",
|
"form-action 'self' https://*.slack.com",
|
||||||
// Base URI
|
// Base URI
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
// Manifest
|
// Manifest
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { useBusterNotifications } from '@/context/BusterNotifications';
|
||||||
import { useMemoizedFn } from '@/hooks';
|
import { useMemoizedFn } from '@/hooks';
|
||||||
import type {
|
|
||||||
InitiateOAuthRequest,
|
|
||||||
UpdateIntegrationRequest
|
|
||||||
} from '@buster/server-shared/slack';
|
|
||||||
import {
|
import {
|
||||||
initiateSlackOAuth,
|
initiateSlackOAuth,
|
||||||
getSlackIntegration,
|
getSlackIntegration,
|
||||||
|
@ -17,7 +13,7 @@ import {
|
||||||
// GET /api/v2/slack/integration
|
// GET /api/v2/slack/integration
|
||||||
export const useGetSlackIntegration = (enabled = true) => {
|
export const useGetSlackIntegration = (enabled = true) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
...queryKeys.slackGetIntegration,
|
...slackQueryKeys.slackGetIntegration,
|
||||||
queryFn: getSlackIntegration,
|
queryFn: getSlackIntegration,
|
||||||
enabled
|
enabled
|
||||||
});
|
});
|
||||||
|
@ -26,7 +22,7 @@ export const useGetSlackIntegration = (enabled = true) => {
|
||||||
// GET /api/v2/slack/channels
|
// GET /api/v2/slack/channels
|
||||||
export const useGetSlackChannels = (enabled = true) => {
|
export const useGetSlackChannels = (enabled = true) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
...queryKeys.slackGetChannels,
|
...slackQueryKeys.slackGetChannels,
|
||||||
queryFn: getSlackChannels,
|
queryFn: getSlackChannels,
|
||||||
enabled
|
enabled
|
||||||
});
|
});
|
||||||
|
@ -34,21 +30,35 @@ export const useGetSlackChannels = (enabled = true) => {
|
||||||
|
|
||||||
// POST /api/v2/slack/auth/init
|
// POST /api/v2/slack/auth/init
|
||||||
export const useInitiateSlackOAuth = () => {
|
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({
|
return useMutation({
|
||||||
mutationFn: initiateSlackOAuth
|
mutationFn
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// PUT /api/v2/slack/integration
|
// PUT /api/v2/slack/integration
|
||||||
export const useUpdateSlackIntegration = () => {
|
export const useUpdateSlackIntegration = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: updateSlackIntegration,
|
mutationFn: updateSlackIntegration,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate the integration query to refetch the updated data
|
// Invalidate the integration query to refetch the updated data
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.slackGetIntegration.queryKey,
|
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
|
||||||
refetchType: 'all'
|
refetchType: 'all'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -60,37 +70,38 @@ export const useRemoveSlackIntegration = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { openConfirmModal } = useBusterNotifications();
|
const { openConfirmModal } = useBusterNotifications();
|
||||||
|
|
||||||
const mutationFn = useMemoizedFn(
|
const mutationFn = useMemoizedFn(async () => {
|
||||||
async ({ ignoreConfirm = false }: { ignoreConfirm?: boolean } = {}) => {
|
const ignoreConfirm = false;
|
||||||
const method = async () => {
|
|
||||||
return await removeSlackIntegration();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ignoreConfirm) {
|
const method = async () => {
|
||||||
return method();
|
return await removeSlackIntegration();
|
||||||
}
|
};
|
||||||
|
|
||||||
return openConfirmModal({
|
if (ignoreConfirm) {
|
||||||
title: 'Remove Slack Integration',
|
return method();
|
||||||
content: 'Are you sure you want to remove the Slack integration? This will disconnect your workspace from Slack.',
|
|
||||||
primaryButtonProps: {
|
|
||||||
text: 'Remove'
|
|
||||||
},
|
|
||||||
onOk: method
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return openConfirmModal({
|
||||||
|
title: 'Remove Slack Integration',
|
||||||
|
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({
|
return useMutation({
|
||||||
mutationFn,
|
mutationFn,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate both integration and channels queries
|
// Invalidate both integration and channels queries
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.slackGetIntegration.queryKey,
|
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
|
||||||
refetchType: 'all'
|
refetchType: 'all'
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.slackGetChannels.queryKey,
|
queryKey: slackQueryKeys.slackGetChannels.queryKey,
|
||||||
refetchType: 'all'
|
refetchType: 'all'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
} from '@buster/server-shared/slack';
|
} from '@buster/server-shared/slack';
|
||||||
|
|
||||||
// POST /api/v2/slack/auth/init
|
// 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);
|
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
|
// PUT /api/v2/slack/integration
|
||||||
export const updateSlackIntegration = async (data: UpdateIntegrationRequest) => {
|
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
|
// GET /api/v2/slack/channels
|
||||||
|
|
|
@ -1,15 +1,37 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SettingsCards } from '../settings/SettingsCard';
|
import { SettingsCards } from '../settings/SettingsCard';
|
||||||
import { SlackIcon } from '@/components/ui/icons/customIcons/SlackIcon';
|
import { SlackIcon } from '@/components/ui/icons/customIcons/SlackIcon';
|
||||||
import { Text } from '@/components/ui/typography';
|
import { Text } from '@/components/ui/typography';
|
||||||
import { Button } from '@/components/ui/buttons';
|
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(() => {
|
export const SlackIntegrations = React.memo(() => {
|
||||||
|
const { data: slackIntegration } = useGetSlackIntegration();
|
||||||
|
const isConnected = slackIntegration?.connected ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsCards
|
<SettingsCards
|
||||||
title="Slack"
|
title="Slack"
|
||||||
description="Connect Buster with 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';
|
SlackIntegrations.displayName = 'SlackIntegrations';
|
||||||
|
|
||||||
const ConnectSlackCard = React.memo(() => {
|
const ConnectSlackCard = React.memo(() => {
|
||||||
|
const { data: slackIntegration } = useGetSlackIntegration();
|
||||||
|
const { mutate: initiateSlackOAuth } = useInitiateSlackOAuth();
|
||||||
|
|
||||||
|
const isConnected = slackIntegration?.connected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
<div className="flex items-center justify-between gap-x-2">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
|
@ -30,9 +57,110 @@ const ConnectSlackCard = React.memo(() => {
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button prefix={<SlackIcon size={16} />} size={'tall'}>
|
|
||||||
Connect Slack
|
{isConnected ? (
|
||||||
</Button>
|
<ConnectedDropdown />
|
||||||
|
) : (
|
||||||
|
<Button prefix={<SlackIcon size={16} />} onClick={() => initiateSlackOAuth()} size={'tall'}>
|
||||||
|
Connect Slack
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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';
|
||||||
|
|
|
@ -9,6 +9,8 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from './SelectBase';
|
} from './SelectBase';
|
||||||
|
import { CircleSpinnerLoader } from '../loaders';
|
||||||
|
import { cn } from '@/lib/classMerge';
|
||||||
|
|
||||||
interface SelectItemGroup<T = string> {
|
interface SelectItemGroup<T = string> {
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -36,6 +38,7 @@ export interface SelectProps<T> {
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Select = <T extends string>({
|
export const Select = <T extends string>({
|
||||||
|
@ -47,6 +50,7 @@ export const Select = <T extends string>({
|
||||||
value,
|
value,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
open,
|
open,
|
||||||
|
loading = false,
|
||||||
className = '',
|
className = '',
|
||||||
defaultValue,
|
defaultValue,
|
||||||
dataTestId
|
dataTestId
|
||||||
|
@ -62,7 +66,7 @@ export const Select = <T extends string>({
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}>
|
onValueChange={onValueChange}>
|
||||||
<SelectTrigger className={className} data-testid={dataTestId}>
|
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
|
||||||
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
|
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/classMerge';
|
import { cn } from '@/lib/classMerge';
|
||||||
import { Check3 as Check, ChevronDown, ChevronUp } from '../icons/NucleoIconOutlined';
|
import { Check3 as Check, ChevronDown, ChevronUp } from '../icons/NucleoIconOutlined';
|
||||||
|
import CircleSpinnerLoader from '../loaders/CircleSpinnerLoader';
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
@ -36,18 +37,28 @@ export const selectVariants = cva(
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
|
||||||
VariantProps<typeof selectVariants>
|
VariantProps<typeof selectVariants> & {
|
||||||
>(({ className, variant = 'default', size = 'default', children, ...props }, ref) => (
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, variant = 'default', size = 'default', children, loading, ...props }, ref) => (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(selectVariants({ variant, size }), className)}
|
className={cn('relative', selectVariants({ variant, size }), className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
{!loading && (
|
||||||
<div className="flex items-center justify-center opacity-50">
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown />
|
<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>
|
</div>
|
||||||
</SelectPrimitive.Icon>
|
)}
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
));
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
|
@ -118,12 +118,12 @@ ${issuesAndAssumptions.flagged_issues}
|
||||||
|
|
||||||
Major Assumptions Identified:
|
Major Assumptions Identified:
|
||||||
${
|
${
|
||||||
issuesAndAssumptions.major_assumptions.length > 0
|
issuesAndAssumptions.major_assumptions.length > 0
|
||||||
? issuesAndAssumptions.major_assumptions
|
? issuesAndAssumptions.major_assumptions
|
||||||
.map((a) => `- ${a.descriptiveTitle}: ${a.explanation}`)
|
.map((a) => `- ${a.descriptiveTitle}: ${a.explanation}`)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
: 'No major assumptions identified'
|
: 'No major assumptions identified'
|
||||||
}
|
}
|
||||||
|
|
||||||
Generate a concise update message for the data team.`;
|
Generate a concise update message for the data team.`;
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
// POST /api/v2/slack/auth/init
|
// POST /api/v2/slack/auth/init
|
||||||
export const InitiateOAuthSchema = z.object({
|
export const InitiateOAuthSchema = z
|
||||||
metadata: z
|
.object({
|
||||||
.object({
|
metadata: z
|
||||||
return_url: z.string().optional(),
|
.object({
|
||||||
source: z.string().optional(),
|
return_url: z.string().optional(),
|
||||||
project_id: z.string().uuid().optional(),
|
source: z.string().optional(),
|
||||||
})
|
project_id: z.string().uuid().optional(),
|
||||||
.optional(),
|
})
|
||||||
});
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export type InitiateOAuthRequest = z.infer<typeof InitiateOAuthSchema>;
|
export type InitiateOAuthRequest = z.infer<typeof InitiateOAuthSchema>;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue