1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

chore: incoming webhook events UI (#6317)

https://linear.app/unleash/issue/2-1937/incoming-webhook-events-ui

This PR implements the UI for incoming webhook events.

We're also introducing a new `SidePanelList` component that we'll be
able to reuse when we tackle action set events. This PR also promotes
`ReactJSONEditor` to a common component and adapts it slightly for this
use case.


![image](https://github.com/Unleash/unleash/assets/14320932/b1abc2e0-3971-4882-b6f6-0ae48d1523d5)


![image](https://github.com/Unleash/unleash/assets/14320932/ce5c31e4-650a-4df5-a966-2ce06fd6baa8)

We're refreshing the events view every 5s, so if you're monitoring
events for a specific incoming webhook you can see the latest ones
coming in.
We load 20 (configurable through the hook) events by default. Everytime
you reach the end of the list you can load 20 more events until you
reach the end of the event list.


![image](https://github.com/Unleash/unleash/assets/14320932/94f187a1-8b0f-4138-8dbc-d3ebc9914bfd)
This commit is contained in:
Nuno Góis 2024-02-23 11:01:27 +00:00 committed by GitHub
parent a54ef27adc
commit 12ff4abe6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 603 additions and 9 deletions

View File

@ -32,6 +32,7 @@ interface ICreateProps {
formatApiCode?: () => string;
footer?: ReactNode;
compact?: boolean;
showGuidance?: boolean;
}
const StyledContainer = styled('section', {
@ -202,6 +203,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
showLink = true,
footer,
compact,
showGuidance = true,
}) => {
const { setToastData } = useToast();
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
@ -252,7 +254,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
return (
<StyledContainer modal={modal} compact={compact}>
<ConditionallyRender
condition={smallScreen}
condition={showGuidance && smallScreen}
show={
<StyledRelativeDiv>
<MobileGuidance
@ -293,7 +295,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
/>
</StyledMain>
<ConditionallyRender
condition={!smallScreen}
condition={showGuidance && !smallScreen}
show={
<Guidance
description={description}

View File

@ -4,7 +4,11 @@ import 'vanilla-jsoneditor/themes/jse-theme-dark.css';
import { styled } from '@mui/material';
import UIContext from 'contexts/UIContext';
const JSONEditorThemeWrapper = styled('div')(({ theme }) => ({
type EditorStyle = 'default' | 'sidePanel';
const JSONEditorThemeWrapper = styled('div', {
shouldForwardProp: (prop) => prop !== 'editorStyle',
})<{ editorStyle?: EditorStyle }>(({ theme, editorStyle = 'default' }) => ({
'&.jse-theme-dark': {
'--jse-background-color': theme.palette.background.default,
'--jse-panel-background': theme.palette.background.default,
@ -24,9 +28,40 @@ const JSONEditorThemeWrapper = styled('div')(({ theme }) => ({
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
...(editorStyle === 'sidePanel' && {
'&&&': {
'& .jse-main': {
minHeight: 0,
},
'--jse-main-border': 0,
'& > div': {
height: '100%',
},
'& .jse-focus': {
'--jse-main-border': 0,
},
'& .cm-gutters': {
'--jse-panel-background': 'transparent',
'--jse-panel-border': 'transparent',
},
'& .cm-gutter-lint': {
width: 0,
},
'& .jse-text-mode': {
borderBottomRightRadius: theme.shape.borderRadiusMedium,
},
'& .cm-scroller': {
'--jse-delimiter-color': theme.palette.text.primary,
},
},
}),
}));
const VanillaJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
interface IReactJSONEditorProps extends JSONEditorPropsOptional {
editorStyle?: EditorStyle;
}
const VanillaJSONEditor: React.FC<IReactJSONEditorProps> = (props) => {
const refContainer = useRef<HTMLDivElement | null>(null);
const refEditor = useRef<JSONEditor | null>(null);
@ -58,11 +93,12 @@ const VanillaJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
return <div ref={refContainer} />;
};
const ReactJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
const ReactJSONEditor: React.FC<IReactJSONEditorProps> = (props) => {
const { themeMode } = useContext(UIContext);
return (
<JSONEditorThemeWrapper
className={themeMode === 'dark' ? 'jse-theme-dark' : ''}
editorStyle={props.editorStyle}
>
<VanillaJSONEditor
mainMenuBar={false}

View File

@ -0,0 +1,122 @@
import { styled } from '@mui/material';
import { ReactNode, useState } from 'react';
import { SidePanelListHeader } from './SidePanelListHeader';
import { SidePanelListItem } from './SidePanelListItem';
const StyledSidePanelListWrapper = styled('div')({
display: 'flex',
flexDirection: 'column',
width: '100%',
});
const StyledSidePanelListBody = styled('div')({
display: 'flex',
flexDirection: 'row',
});
const StyledSidePanelHalf = styled('div')({
display: 'flex',
flexDirection: 'column',
flex: 1,
});
const StyledSidePanelHalfLeft = styled(StyledSidePanelHalf, {
shouldForwardProp: (prop) => prop !== 'height',
})<{ height?: number }>(({ theme, height }) => ({
border: `1px solid ${theme.palette.divider}`,
borderTop: 0,
borderBottomLeftRadius: theme.shape.borderRadiusMedium,
overflow: 'auto',
...(height && { height }),
}));
const StyledSidePanelHalfRight = styled(StyledSidePanelHalf)(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderTop: 0,
borderLeft: 0,
borderBottomRightRadius: theme.shape.borderRadiusMedium,
}));
type ColumnAlignment = 'start' | 'end' | 'center';
export const StyledSidePanelListColumn = styled('div', {
shouldForwardProp: (prop) => prop !== 'maxWidth' && prop !== 'align',
})<{ maxWidth?: number; align?: ColumnAlignment }>(
({ theme, maxWidth, align = 'start' }) => ({
flex: 1,
padding: theme.spacing(2),
fontSize: theme.fontSizes.smallBody,
justifyContent: align,
...(maxWidth && { maxWidth }),
textAlign: align,
}),
);
export type SidePanelListColumn<T> = {
header: string;
maxWidth?: number;
align?: ColumnAlignment;
cell: (item: T) => ReactNode;
};
interface ISidePanelListProps<T> {
items: T[];
columns: SidePanelListColumn<T>[];
sidePanelHeader: string;
renderContent: (item: T) => ReactNode;
height?: number;
listEnd?: ReactNode;
}
export const SidePanelList = <T extends { id: string | number }>({
items,
columns,
sidePanelHeader,
renderContent,
height,
listEnd,
}: ISidePanelListProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T>(items[0]);
if (items.length === 0) {
return null;
}
const activeItem = selectedItem || items[0];
return (
<StyledSidePanelListWrapper>
<SidePanelListHeader
columns={columns}
sidePanelHeader={sidePanelHeader}
/>
<StyledSidePanelListBody>
<StyledSidePanelHalfLeft height={height}>
{items.map((item) => (
<SidePanelListItem
key={item.id}
selected={activeItem.id === item.id}
onClick={() => setSelectedItem(item)}
>
{columns.map(
({ header, maxWidth, align, cell }) => (
<StyledSidePanelListColumn
key={header}
maxWidth={maxWidth}
align={align}
>
{cell(item)}
</StyledSidePanelListColumn>
),
)}
</SidePanelListItem>
))}
{listEnd}
</StyledSidePanelHalfLeft>
<StyledSidePanelHalfRight>
{renderContent(activeItem)}
</StyledSidePanelHalfRight>
</StyledSidePanelListBody>
</StyledSidePanelListWrapper>
);
};

View File

@ -0,0 +1,48 @@
import { styled } from '@mui/material';
import {
SidePanelListColumn,
StyledSidePanelListColumn,
} from './SidePanelList';
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderTopLeftRadius: theme.shape.borderRadiusMedium,
borderTopRightRadius: theme.shape.borderRadiusMedium,
backgroundColor: theme.palette.table.headerBackground,
}));
const StyledHeaderHalf = styled('div')({
display: 'flex',
flex: 1,
});
interface ISidePanelListHeaderProps<T> {
columns: SidePanelListColumn<T>[];
sidePanelHeader: string;
}
export const SidePanelListHeader = <T,>({
columns,
sidePanelHeader,
}: ISidePanelListHeaderProps<T>) => (
<StyledHeader>
<StyledHeaderHalf>
{columns.map(({ header, maxWidth, align }) => (
<StyledSidePanelListColumn
key={header}
maxWidth={maxWidth}
align={align}
>
{header}
</StyledSidePanelListColumn>
))}
</StyledHeaderHalf>
<StyledHeaderHalf>
<StyledSidePanelListColumn>
{sidePanelHeader}
</StyledSidePanelListColumn>
</StyledHeaderHalf>
</StyledHeader>
);

View File

@ -0,0 +1,58 @@
import { Button, styled } from '@mui/material';
import { ReactNode } from 'react';
const StyledItemRow = styled('div')(({ theme }) => ({
borderBottom: `1px solid ${theme.palette.divider}`,
}));
const StyledItem = styled(Button, {
shouldForwardProp: (prop) => prop !== 'selected',
})<{ selected: boolean }>(({ theme, selected }) => ({
'&.MuiButton-root': {
width: '100%',
backgroundColor: selected
? theme.palette.secondary.light
: 'transparent',
borderRight: `${theme.spacing(0.5)} solid ${
selected ? theme.palette.background.alternative : 'transparent'
}`,
padding: 0,
borderRadius: 0,
justifyContent: 'start',
transition: 'background-color 0.2s ease',
color: theme.palette.text.primary,
textAlign: 'left',
fontWeight: selected ? theme.fontWeight.bold : theme.fontWeight.medium,
fontSize: theme.fontSizes.smallBody,
overflow: 'auto',
},
'&:hover': {
backgroundColor: selected
? theme.palette.secondary.light
: theme.palette.neutral.light,
},
'&.Mui-disabled': {
pointerEvents: 'auto',
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
},
}));
interface ISidePanelListItemProps<T> {
selected: boolean;
onClick: () => void;
children: ReactNode;
}
export const SidePanelListItem = <T,>({
selected,
onClick,
children,
}: ISidePanelListItemProps<T>) => (
<StyledItemRow>
<StyledItem selected={selected} onClick={onClick}>
{children}
</StyledItem>
</StyledItemRow>
);

View File

@ -20,7 +20,9 @@ import { WeightType } from 'constants/variantTypes';
import { IFeatureVariantEdit } from '../EnvironmentVariantsModal';
import { Delete } from '@mui/icons-material';
const LazyReactJSONEditor = React.lazy(() => import('./ReactJSONEditor'));
const LazyReactJSONEditor = React.lazy(
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
);
const StyledVariantForm = styled('div')(({ theme }) => ({
position: 'relative',

View File

@ -0,0 +1,179 @@
import { Button, Link, styled } from '@mui/material';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
import { useIncomingWebhookEvents } from 'hooks/api/getters/useIncomingWebhookEvents/useIncomingWebhookEvents';
import { Suspense, lazy } from 'react';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { SidePanelList } from 'component/common/SidePanelList/SidePanelList';
import { formatDateYMDHMS } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const LazyReactJSONEditor = lazy(
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
);
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
marginBottom: theme.fontSizes.mainHeader,
}));
const StyledHeaderRow = styled('div')({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
});
const StyledHeaderSubtitle = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(2),
fontSize: theme.fontSizes.smallBody,
}));
const StyledDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledTitle = styled('h1')({
fontWeight: 'normal',
});
const StyledForm = styled('form')({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
paddingTop: theme.spacing(4),
}));
interface IIncomingWebhooksEventsModalProps {
incomingWebhook?: IIncomingWebhook;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onOpenConfiguration: () => void;
}
export const IncomingWebhooksEventsModal = ({
incomingWebhook,
open,
setOpen,
onOpenConfiguration,
}: IIncomingWebhooksEventsModalProps) => {
const { uiConfig } = useUiConfig();
const { locationSettings } = useLocationSettings();
const { incomingWebhookEvents, hasMore, loadMore, loading } =
useIncomingWebhookEvents(incomingWebhook?.id, 20, {
refreshInterval: 5000,
});
if (!incomingWebhook) {
return null;
}
const title = `Events: ${incomingWebhook.name}`;
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={title}
>
<FormTemplate
loading={loading && incomingWebhookEvents.length === 0}
modal
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
documentationLink='https://docs.getunleash.io/reference/incoming-webhooks'
documentationLinkLabel='Incoming webhooks documentation'
showGuidance={false}
>
<StyledHeader>
<StyledHeaderRow>
<StyledTitle>{title}</StyledTitle>
<Link onClick={onOpenConfiguration}>
View configuration
</Link>
</StyledHeaderRow>
<StyledHeaderSubtitle>
<p>
{uiConfig.unleashUrl}/api/incoming-webhook/
{incomingWebhook.name}
</p>
<StyledDescription>
{incomingWebhook.description}
</StyledDescription>
</StyledHeaderSubtitle>
</StyledHeader>
<StyledForm>
<SidePanelList
height={960}
items={incomingWebhookEvents}
columns={[
{
header: 'Date',
cell: (event) =>
formatDateYMDHMS(
event.createdAt,
locationSettings?.locale,
),
},
{
header: 'Token',
cell: (event) => event.tokenName,
},
]}
sidePanelHeader='Payload'
renderContent={(event) => (
<Suspense fallback={null}>
<LazyReactJSONEditor
content={{ json: event.payload }}
readOnly
statusBar={false}
editorStyle='sidePanel'
/>
</Suspense>
)}
listEnd={
<ConditionallyRender
condition={hasMore}
show={
<Button onClick={loadMore}>
Load more
</Button>
}
/>
}
/>
<ConditionallyRender
condition={incomingWebhookEvents.length === 0}
show={
<p>
No events have been received for this incoming
webhook.
</p>
}
/>
<StyledButtonContainer>
<Button
onClick={() => {
setOpen(false);
}}
>
Close
</Button>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { Button, styled } from '@mui/material';
import { Button, Link, styled } from '@mui/material';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
@ -18,6 +18,18 @@ import {
useIncomingWebhooksForm,
} from './IncomingWebhooksForm/useIncomingWebhooksForm';
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
marginBottom: theme.fontSizes.mainHeader,
}));
const StyledTitle = styled('h1')({
fontWeight: 'normal',
});
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
@ -40,6 +52,7 @@ interface IIncomingWebhooksModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
newToken: (token: string) => void;
onOpenEvents: () => void;
}
export const IncomingWebhooksModal = ({
@ -47,6 +60,7 @@ export const IncomingWebhooksModal = ({
open,
setOpen,
newToken,
onOpenEvents,
}: IIncomingWebhooksModalProps) => {
const { refetch } = useIncomingWebhooks();
const { addIncomingWebhook, updateIncomingWebhook, loading } =
@ -137,12 +151,15 @@ export const IncomingWebhooksModal = ({
<FormTemplate
loading={loading}
modal
title={title}
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
documentationLink='https://docs.getunleash.io/reference/incoming-webhooks'
documentationLinkLabel='Incoming webhooks documentation'
formatApiCode={formatApiCode}
>
<StyledHeader>
<StyledTitle>{title}</StyledTitle>
<Link onClick={onOpenEvents}>View events</Link>
</StyledHeader>
<StyledForm onSubmit={onSubmit}>
<IncomingWebhooksForm
incomingWebhook={incomingWebhook}

View File

@ -13,7 +13,7 @@ import {
} from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import FileCopyIcon from '@mui/icons-material/FileCopy';
import { Delete, Edit } from '@mui/icons-material';
import { Delete, Edit, Visibility } from '@mui/icons-material';
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { defaultBorderRadius } from 'themes/themeStyles';
@ -26,6 +26,7 @@ const StyledBoxCell = styled(Box)({
interface IIncomingWebhooksActionsCellProps {
incomingWebhookId: number;
onCopyToClipboard: (event: React.SyntheticEvent) => void;
onOpenEvents: (event: React.SyntheticEvent) => void;
onEdit: (event: React.SyntheticEvent) => void;
onDelete: (event: React.SyntheticEvent) => void;
}
@ -33,6 +34,7 @@ interface IIncomingWebhooksActionsCellProps {
export const IncomingWebhooksActionsCell = ({
incomingWebhookId,
onCopyToClipboard,
onOpenEvents,
onEdit,
onDelete,
}: IIncomingWebhooksActionsCellProps) => {
@ -94,6 +96,24 @@ export const IncomingWebhooksActionsCell = ({
<Typography variant='body2'>Copy URL</Typography>
</ListItemText>
</MenuItem>
<PermissionHOC permission={ADMIN}>
{({ hasAccess }) => (
<MenuItem
sx={defaultBorderRadius}
onClick={onOpenEvents}
disabled={!hasAccess}
>
<ListItemIcon>
<Visibility />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>
View events
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
<PermissionHOC permission={ADMIN}>
{({ hasAccess }) => (
<MenuItem

View File

@ -22,6 +22,7 @@ import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
import { IncomingWebhooksTokensDialog } from '../IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { IncomingWebhooksEventsModal } from '../IncomingWebhooksEvents/IncomingWebhooksEventsModal';
interface IIncomingWebhooksTableProps {
modalOpen: boolean;
@ -49,6 +50,8 @@ export const IncomingWebhooksTable = ({
const [newToken, setNewToken] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [eventsModalOpen, setEventsModalOpen] = useState(false);
const onToggleIncomingWebhook = async (
incomingWebhook: IIncomingWebhook,
enabled: boolean,
@ -174,6 +177,10 @@ export const IncomingWebhooksTable = ({
title: 'Copied to clipboard',
});
}}
onOpenEvents={() => {
setSelectedIncomingWebhook(incomingWebhook);
setEventsModalOpen(true);
}}
onEdit={() => {
setSelectedIncomingWebhook(incomingWebhook);
setModalOpen(true);
@ -248,6 +255,19 @@ export const IncomingWebhooksTable = ({
setNewToken(token);
setTokenDialog(true);
}}
onOpenEvents={() => {
setModalOpen(false);
setEventsModalOpen(true);
}}
/>
<IncomingWebhooksEventsModal
incomingWebhook={selectedIncomingWebhook}
open={eventsModalOpen}
setOpen={setEventsModalOpen}
onOpenConfiguration={() => {
setEventsModalOpen(false);
setModalOpen(true);
}}
/>
<IncomingWebhooksTokensDialog
open={tokenDialog}

View File

@ -0,0 +1,79 @@
import useSWRInfinite, {
SWRInfiniteConfiguration,
SWRInfiniteKeyLoader,
} from 'swr/infinite';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import useUiConfig from '../useUiConfig/useUiConfig';
import { IIncomingWebhookEvent } from 'interfaces/incomingWebhook';
import { useUiFlag } from 'hooks/useUiFlag';
const ENDPOINT = 'api/admin/incoming-webhooks';
type IncomingWebhookEventsResponse = {
incomingWebhookEvents: IIncomingWebhookEvent[];
};
const fetcher = async (url: string) => {
const response = await fetch(url);
await handleErrorResponses('Incoming webhook events')(response);
return response.json();
};
export const useIncomingWebhookEvents = (
incomingWebhookId?: number,
limit = 50,
options: SWRInfiniteConfiguration = {},
) => {
const { isEnterprise } = useUiConfig();
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const getKey: SWRInfiniteKeyLoader = (
pageIndex: number,
previousPageData: IncomingWebhookEventsResponse,
) => {
// Does not meet conditions
if (!incomingWebhookId || !isEnterprise || !incomingWebhooksEnabled)
return null;
// Reached the end
if (previousPageData && !previousPageData.incomingWebhookEvents.length)
return null;
return formatApiPath(
`${ENDPOINT}/${incomingWebhookId}/events?limit=${limit}&offset=${
pageIndex * limit
}`,
);
};
const { data, error, size, setSize, mutate } =
useSWRInfinite<IncomingWebhookEventsResponse>(getKey, fetcher, {
...options,
revalidateAll: true,
});
const incomingWebhookEvents = data
? data.flatMap(({ incomingWebhookEvents }) => incomingWebhookEvents)
: [];
const isLoadingInitialData = !data && !error;
const isLoadingMore = size > 0 && !data?.[size - 1];
const loading = isLoadingInitialData || isLoadingMore;
const hasMore = data?.[size - 1]?.incomingWebhookEvents.length === limit;
const loadMore = () => {
if (loading || !hasMore) return;
setSize(size + 1);
};
return {
incomingWebhookEvents,
hasMore,
loadMore,
loading,
refetch: () => mutate(),
error,
};
};

View File

@ -15,3 +15,14 @@ export interface IIncomingWebhookToken {
createdAt: string;
createdByUserId: number;
}
type EventSource = 'incoming-webhook';
export interface IIncomingWebhookEvent {
id: number;
payload: Record<string, unknown>;
createdAt: string;
source: EventSource;
sourceId: number;
tokenName: string;
}