✨ (preview) Add preview runtime dropdown
User can select between Web and API previews Closes #247
This commit is contained in:
@ -37,6 +37,12 @@ export const ChevronRightIcon = (props: IconProps) => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ChevronDownIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PlusIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
|
@ -90,8 +90,16 @@ test('Variable buttons should work', async ({ page }) => {
|
||||
await page.click('[data-testid="block1-icon"]')
|
||||
await page.click('text=Multiple choice?')
|
||||
await page.click('text="Restart"')
|
||||
await page.getByTestId('button').first().click()
|
||||
await page.getByTestId('button').nth(1).click()
|
||||
await page
|
||||
.locator('typebot-standard')
|
||||
.getByRole('checkbox', { name: 'Variable item' })
|
||||
.first()
|
||||
.click()
|
||||
await page
|
||||
.locator('typebot-standard')
|
||||
.getByRole('checkbox', { name: 'Variable item' })
|
||||
.nth(1)
|
||||
.click()
|
||||
await page.locator('text="Send"').click()
|
||||
await expect(
|
||||
page.locator('text="Variable item, Variable item"')
|
||||
|
@ -47,7 +47,7 @@ test.describe('Builder', () => {
|
||||
`${process.env.NEXTAUTH_URL}/api/mock/webhook`
|
||||
)
|
||||
await page.click('text=Advanced configuration')
|
||||
await page.click('text=GET')
|
||||
await page.getByRole('button', { name: 'GET' }).click()
|
||||
await page.click('text=POST')
|
||||
|
||||
await page.click('text=Query params')
|
||||
|
@ -15,7 +15,7 @@ import { useTypebot } from '../providers/TypebotProvider'
|
||||
import { BlocksSideBar } from './BlocksSideBar'
|
||||
import { BoardMenuButton } from './BoardMenuButton'
|
||||
import { GettingStartedModal } from './GettingStartedModal'
|
||||
import { PreviewDrawer } from './PreviewDrawer'
|
||||
import { PreviewDrawer } from '@/features/preview/components/PreviewDrawer'
|
||||
import { TypebotHeader } from './TypebotHeader'
|
||||
|
||||
export const EditorPage = () => {
|
||||
|
@ -1,143 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CloseButton,
|
||||
Fade,
|
||||
Flex,
|
||||
FlexProps,
|
||||
useColorMode,
|
||||
useColorModeValue,
|
||||
useEventListener,
|
||||
UseToastOptions,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { useEditor } from '../providers/EditorProvider'
|
||||
import { useGraph } from '@/features/graph'
|
||||
import { useTypebot } from '../providers/TypebotProvider'
|
||||
import React, { useState } from 'react'
|
||||
import { headerHeight } from '../constants'
|
||||
import { Standard } from '@typebot.io/react'
|
||||
import { ChatReply } from 'models'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
|
||||
export const PreviewDrawer = () => {
|
||||
const isDark = useColorMode().colorMode === 'dark'
|
||||
const { typebot, save, isSavingLoading } = useTypebot()
|
||||
const { setRightPanel, startPreviewAtGroup } = useEditor()
|
||||
const { setPreviewingBlock } = useGraph()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [width, setWidth] = useState(500)
|
||||
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
|
||||
const [restartKey, setRestartKey] = useState(0)
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsResizing(true)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return
|
||||
setWidth(width - e.movementX)
|
||||
}
|
||||
useEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
useEventListener('mouseup', handleMouseUp)
|
||||
|
||||
const handleRestartClick = async () => {
|
||||
await save()
|
||||
setRestartKey((key) => key + 1)
|
||||
}
|
||||
|
||||
const handleCloseClick = () => {
|
||||
setPreviewingBlock(undefined)
|
||||
setRightPanel(undefined)
|
||||
}
|
||||
|
||||
const handleNewLogs = (logs: ChatReply['logs']) => {
|
||||
logs?.forEach((log) => showToast(log as UseToastOptions))
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
right="0"
|
||||
top={`0`}
|
||||
h={`100%`}
|
||||
w={`${width}px`}
|
||||
bgColor={useColorModeValue('white', 'gray.900')}
|
||||
borderLeftWidth={'1px'}
|
||||
shadow="lg"
|
||||
borderLeftRadius={'lg'}
|
||||
onMouseOver={() => setIsResizeHandleVisible(true)}
|
||||
onMouseLeave={() => setIsResizeHandleVisible(false)}
|
||||
p="6"
|
||||
zIndex={10}
|
||||
>
|
||||
<Fade in={isResizeHandleVisible}>
|
||||
<ResizeHandle
|
||||
isDark={isDark}
|
||||
pos="absolute"
|
||||
left="-7.5px"
|
||||
top={`calc(50% - ${headerHeight}px)`}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</Fade>
|
||||
|
||||
<VStack w="full" spacing={4}>
|
||||
<Flex justifyContent={'space-between'} w="full">
|
||||
<Button onClick={handleRestartClick} isLoading={isSavingLoading}>
|
||||
Restart
|
||||
</Button>
|
||||
<CloseButton onClick={handleCloseClick} />
|
||||
</Flex>
|
||||
|
||||
{typebot && (
|
||||
<Standard
|
||||
key={restartKey + (startPreviewAtGroup ?? '')}
|
||||
typebot={typebot}
|
||||
startGroupId={startPreviewAtGroup}
|
||||
onNewInputBlock={setPreviewingBlock}
|
||||
onNewLogs={handleNewLogs}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderRadius: '0.25rem',
|
||||
pointerEvents: isResizing ? 'none' : 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const ResizeHandle = (props: FlexProps & { isDark: boolean }) => {
|
||||
return (
|
||||
<Flex
|
||||
w="15px"
|
||||
h="50px"
|
||||
borderWidth={'1px'}
|
||||
bgColor={useColorModeValue('white', 'gray.800')}
|
||||
cursor={'col-resize'}
|
||||
justifyContent={'center'}
|
||||
align={'center'}
|
||||
borderRadius={'sm'}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
w="2px"
|
||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
h="70%"
|
||||
mr="0.5"
|
||||
/>
|
||||
<Box
|
||||
w="2px"
|
||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
h="70%"
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { useEditor } from '@/features/editor/providers/EditorProvider'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { Code, ListItem, OrderedList, Stack, Text } from '@chakra-ui/react'
|
||||
import { env, getViewerUrl } from 'utils'
|
||||
|
||||
export const ApiPreviewInstructions = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { startPreviewAtGroup } = useEditor()
|
||||
|
||||
const startParamsBody = startPreviewAtGroup
|
||||
? `{
|
||||
"startParams": {
|
||||
"typebot": "${typebot?.id}",
|
||||
"isPreview": true,
|
||||
"startGroupId": "${startPreviewAtGroup}"
|
||||
}
|
||||
}`
|
||||
: `{
|
||||
"startParams": {
|
||||
"typebot": "${typebot?.id}",
|
||||
"isPreview": true
|
||||
}
|
||||
}`
|
||||
|
||||
const replyBody = `{
|
||||
"message": "This is my reply",
|
||||
"sessionId": "<ID_FROM_FIRST_RESPONSE>"
|
||||
}`
|
||||
|
||||
return (
|
||||
<OrderedList
|
||||
p="4"
|
||||
spacing={6}
|
||||
w="full"
|
||||
overflowY="scroll"
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
<ListItem>
|
||||
All your requests need to be authenticated with an API token.{' '}
|
||||
<TextLink href="https://docs.typebot.io/api/builder/authenticate">
|
||||
See instructions
|
||||
</TextLink>
|
||||
.
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Stack>
|
||||
<Text>
|
||||
To start the chat, send a <Code>POST</Code> request to
|
||||
</Text>
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
lang={'shell'}
|
||||
value={`${
|
||||
env('VIEWER_INTERNAL_URL') ?? getViewerUrl()
|
||||
}/api/v1/sendMessage`}
|
||||
/>
|
||||
<Text>with the following JSON body:</Text>
|
||||
<CodeEditor isReadOnly lang={'json'} value={startParamsBody} />
|
||||
</Stack>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
The first response will contain a <Code>sessionId</Code> that you will
|
||||
need for subsequent requests.
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Stack>
|
||||
<Text>
|
||||
To send replies, send <Code>POST</Code> requests to
|
||||
</Text>
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
lang={'shell'}
|
||||
value={`${
|
||||
env('VIEWER_INTERNAL_URL') ?? getViewerUrl()
|
||||
}/api/v1/sendMessage`}
|
||||
/>
|
||||
<Text>With the following JSON body:</Text>
|
||||
<CodeEditor isReadOnly lang={'json'} value={replyBody} />
|
||||
<Text>
|
||||
Replace <Code>{'<ID_FROM_FIRST_RESPONSE>'}</Code> with{' '}
|
||||
<Code>sessionId</Code>.
|
||||
</Text>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
)
|
||||
}
|
103
apps/builder/src/features/preview/components/PreviewDrawer.tsx
Normal file
103
apps/builder/src/features/preview/components/PreviewDrawer.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Button,
|
||||
CloseButton,
|
||||
Fade,
|
||||
Flex,
|
||||
HStack,
|
||||
useColorMode,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { useEditor } from '../../editor/providers/EditorProvider'
|
||||
import { useGraph } from '@/features/graph'
|
||||
import { useTypebot } from '../../editor/providers/TypebotProvider'
|
||||
import React, { useState } from 'react'
|
||||
import { headerHeight } from '../../editor/constants'
|
||||
import { RuntimeMenu } from './RuntimeMenu'
|
||||
import { runtimes } from '../data'
|
||||
import { PreviewDrawerBody } from './PreviewDrawerBody'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
|
||||
export const PreviewDrawer = () => {
|
||||
const isDark = useColorMode().colorMode === 'dark'
|
||||
const { save, isSavingLoading } = useTypebot()
|
||||
const { setRightPanel } = useEditor()
|
||||
const { setPreviewingBlock } = useGraph()
|
||||
const [width, setWidth] = useState(500)
|
||||
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
|
||||
const [restartKey, setRestartKey] = useState(0)
|
||||
const [selectedRuntime, setSelectedRuntime] = useState<
|
||||
(typeof runtimes)[number]
|
||||
>(runtimes[0])
|
||||
|
||||
const handleRestartClick = async () => {
|
||||
await save()
|
||||
setRestartKey((key) => key + 1)
|
||||
}
|
||||
|
||||
const handleCloseClick = () => {
|
||||
setPreviewingBlock(undefined)
|
||||
setRightPanel(undefined)
|
||||
}
|
||||
|
||||
const useResizeHandleDrag = useDrag(
|
||||
(state) => {
|
||||
setWidth(-state.offset[0])
|
||||
},
|
||||
{
|
||||
from: () => [-width, 0],
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
right="0"
|
||||
top={`0`}
|
||||
h={`100%`}
|
||||
w={`${width}px`}
|
||||
bgColor={useColorModeValue('white', 'gray.900')}
|
||||
borderLeftWidth={'1px'}
|
||||
shadow="lg"
|
||||
borderLeftRadius={'lg'}
|
||||
onMouseOver={() => setIsResizeHandleVisible(true)}
|
||||
onMouseLeave={() => setIsResizeHandleVisible(false)}
|
||||
p="6"
|
||||
zIndex={10}
|
||||
>
|
||||
<Fade in={isResizeHandleVisible}>
|
||||
<ResizeHandle
|
||||
{...useResizeHandleDrag()}
|
||||
isDark={isDark}
|
||||
pos="absolute"
|
||||
left="-7.5px"
|
||||
top={`calc(50% - ${headerHeight}px)`}
|
||||
/>
|
||||
</Fade>
|
||||
|
||||
<VStack w="full" spacing={4}>
|
||||
<HStack justifyContent={'space-between'} w="full">
|
||||
<HStack>
|
||||
<RuntimeMenu
|
||||
selectedRuntime={selectedRuntime}
|
||||
onSelectRuntime={(runtime) => setSelectedRuntime(runtime)}
|
||||
/>
|
||||
{selectedRuntime.name === 'Web' ? (
|
||||
<Button
|
||||
onClick={handleRestartClick}
|
||||
isLoading={isSavingLoading}
|
||||
variant="ghost"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<CloseButton onClick={handleCloseClick} />
|
||||
</HStack>
|
||||
<PreviewDrawerBody key={restartKey} runtime={selectedRuntime.name} />
|
||||
</VStack>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import { runtimes } from '../data'
|
||||
import { ApiPreviewInstructions } from './ApiPreviewInstructions'
|
||||
import { WebPreview } from './WebPreview'
|
||||
|
||||
type Props = {
|
||||
runtime: (typeof runtimes)[number]['name']
|
||||
}
|
||||
|
||||
export const PreviewDrawerBody = ({ runtime }: Props) => {
|
||||
switch (runtime) {
|
||||
case 'Web': {
|
||||
return <WebPreview />
|
||||
}
|
||||
case 'API': {
|
||||
return <ApiPreviewInstructions />
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { FlexProps, Flex, useColorModeValue, Box } from '@chakra-ui/react'
|
||||
|
||||
export const ResizeHandle = (props: { isDark: boolean } & FlexProps) => {
|
||||
return (
|
||||
<Flex
|
||||
w="15px"
|
||||
h="50px"
|
||||
borderWidth={'1px'}
|
||||
bgColor={useColorModeValue('white', 'gray.800')}
|
||||
cursor={'col-resize'}
|
||||
justifyContent={'center'}
|
||||
align={'center'}
|
||||
borderRadius={'sm'}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
w="2px"
|
||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
h="70%"
|
||||
mr="0.5"
|
||||
/>
|
||||
<Box
|
||||
w="2px"
|
||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
h="70%"
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
54
apps/builder/src/features/preview/components/RuntimeMenu.tsx
Normal file
54
apps/builder/src/features/preview/components/RuntimeMenu.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { ChevronDownIcon } from '@/components/icons'
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
Button,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Tag,
|
||||
HStack,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { runtimes } from '../data'
|
||||
|
||||
type Runtime = (typeof runtimes)[number]
|
||||
|
||||
type Props = {
|
||||
selectedRuntime: Runtime
|
||||
onSelectRuntime: (runtime: Runtime) => void
|
||||
}
|
||||
|
||||
export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={selectedRuntime.icon}
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
>
|
||||
<HStack justifyContent="space-between">
|
||||
<Text>{selectedRuntime.name}</Text>
|
||||
{'status' in selectedRuntime ? (
|
||||
<Tag colorScheme="orange">{selectedRuntime.status}</Tag>
|
||||
) : null}
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList w="100px">
|
||||
{runtimes
|
||||
.filter((runtime) => runtime.name !== selectedRuntime.name)
|
||||
.map((runtime) => (
|
||||
<MenuItem
|
||||
key={runtime.name}
|
||||
icon={runtime.icon}
|
||||
onClick={() => onSelectRuntime(runtime)}
|
||||
>
|
||||
<HStack justifyContent="space-between">
|
||||
<Text>{runtime.name}</Text>
|
||||
{'status' in runtime ? (
|
||||
<Tag colorScheme="orange">{runtime.status}</Tag>
|
||||
) : null}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
35
apps/builder/src/features/preview/components/WebPreview.tsx
Normal file
35
apps/builder/src/features/preview/components/WebPreview.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useEditor } from '@/features/editor/providers/EditorProvider'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { useGraph } from '@/features/graph'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { UseToastOptions } from '@chakra-ui/react'
|
||||
import { Standard } from '@typebot.io/react'
|
||||
import { ChatReply } from 'models'
|
||||
|
||||
export const WebPreview = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { startPreviewAtGroup } = useEditor()
|
||||
const { setPreviewingBlock } = useGraph()
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleNewLogs = (logs: ChatReply['logs']) => {
|
||||
logs?.forEach((log) => showToast(log as UseToastOptions))
|
||||
}
|
||||
|
||||
if (!typebot) return null
|
||||
|
||||
return (
|
||||
<Standard
|
||||
key={`web-preview${startPreviewAtGroup ?? ''}`}
|
||||
typebot={typebot}
|
||||
startGroupId={startPreviewAtGroup}
|
||||
onNewInputBlock={setPreviewingBlock}
|
||||
onNewLogs={handleNewLogs}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderRadius: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
9
apps/builder/src/features/preview/data.tsx
Normal file
9
apps/builder/src/features/preview/data.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { GlobeIcon, CodeIcon } from '@/components/icons'
|
||||
|
||||
export const runtimes = [
|
||||
{
|
||||
name: 'Web',
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
{ name: 'API', icon: <CodeIcon />, status: 'beta' },
|
||||
] as const
|
@ -1,3 +1,3 @@
|
||||
{
|
||||
"label": "Chat (Experimental 🧪)"
|
||||
"label": "Chat (Beta)"
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ import {
|
||||
setResultAsCompleted,
|
||||
startBotFlow,
|
||||
} from '../utils'
|
||||
import { omit } from 'utils'
|
||||
import { env, omit } from 'utils'
|
||||
|
||||
export const sendMessageProcedure = publicProcedure
|
||||
.meta({
|
||||
@ -41,61 +41,63 @@ export const sendMessageProcedure = publicProcedure
|
||||
})
|
||||
.input(sendMessageInputSchema)
|
||||
.output(chatReplySchema)
|
||||
.query(async ({ input: { sessionId, message, startParams } }) => {
|
||||
const session = sessionId ? await getSession(sessionId) : null
|
||||
.query(
|
||||
async ({ input: { sessionId, message, startParams }, ctx: { user } }) => {
|
||||
const session = sessionId ? await getSession(sessionId) : null
|
||||
|
||||
if (!session) {
|
||||
const {
|
||||
sessionId,
|
||||
typebot,
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
} = await startSession(startParams)
|
||||
return {
|
||||
sessionId,
|
||||
typebot: typebot
|
||||
? {
|
||||
id: typebot.id,
|
||||
theme: typebot.theme,
|
||||
settings: typebot.settings,
|
||||
}
|
||||
: undefined,
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
}
|
||||
} else {
|
||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||
await continueBotFlow(session.state)(message)
|
||||
if (!session) {
|
||||
const {
|
||||
sessionId,
|
||||
typebot,
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
} = await startSession(startParams, user?.id)
|
||||
return {
|
||||
sessionId,
|
||||
typebot: typebot
|
||||
? {
|
||||
id: typebot.id,
|
||||
theme: typebot.theme,
|
||||
settings: typebot.settings,
|
||||
}
|
||||
: undefined,
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
}
|
||||
} else {
|
||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||
await continueBotFlow(session.state)(message)
|
||||
|
||||
await prisma.chatSession.updateMany({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
state: newSessionState,
|
||||
},
|
||||
})
|
||||
await prisma.chatSession.updateMany({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
state: newSessionState,
|
||||
},
|
||||
})
|
||||
|
||||
if (!input && session.state.result?.hasStarted)
|
||||
await setResultAsCompleted(session.state.result.id)
|
||||
if (!input && session.state.result?.hasStarted)
|
||||
await setResultAsCompleted(session.state.result.id)
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||
logs,
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||
logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const startSession = async (startParams?: StartParams) => {
|
||||
const startSession = async (startParams?: StartParams, userId?: string) => {
|
||||
if (!startParams?.typebot)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@ -105,7 +107,7 @@ const startSession = async (startParams?: StartParams) => {
|
||||
const isPreview =
|
||||
startParams?.isPreview || typeof startParams?.typebot !== 'string'
|
||||
|
||||
const typebot = await getTypebot(startParams)
|
||||
const typebot = await getTypebot(startParams, userId)
|
||||
|
||||
const startVariables = startParams.prefilledVariables
|
||||
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
|
||||
@ -198,14 +200,16 @@ const startSession = async (startParams?: StartParams) => {
|
||||
} satisfies ChatReply
|
||||
}
|
||||
|
||||
const getTypebot = async ({
|
||||
typebot,
|
||||
isPreview,
|
||||
}: Pick<StartParams, 'typebot' | 'isPreview'>): Promise<StartTypebot> => {
|
||||
const getTypebot = async (
|
||||
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
|
||||
userId?: string
|
||||
): Promise<StartTypebot> => {
|
||||
if (typeof typebot !== 'string') return typebot
|
||||
if (isPreview && !userId && env('E2E_TEST') !== 'true')
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
|
||||
const typebotQuery = isPreview
|
||||
? await prisma.typebot.findUnique({
|
||||
where: { id: typebot },
|
||||
? await prisma.typebot.findFirst({
|
||||
where: { id: typebot, workspace: { members: { some: { userId } } } },
|
||||
select: {
|
||||
id: true,
|
||||
groups: true,
|
||||
|
@ -3,12 +3,14 @@ import { captureException } from '@sentry/nextjs'
|
||||
import { createOpenApiNextHandler } from 'trpc-openapi'
|
||||
import cors from 'nextjs-cors'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { createContext } from '@/utils/server/context'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await cors(req, res)
|
||||
|
||||
return createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ error }) {
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
captureException(error)
|
||||
|
35
apps/viewer/src/utils/server/context.ts
Normal file
35
apps/viewer/src/utils/server/context.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { inferAsyncReturnType } from '@trpc/server'
|
||||
import * as trpcNext from '@trpc/server/adapters/next'
|
||||
import { User } from 'db'
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
|
||||
const user = await getAuthenticatedUser(opts.req)
|
||||
|
||||
return {
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthenticatedUser = async (
|
||||
req: NextApiRequest
|
||||
): Promise<User | undefined> => {
|
||||
const bearerToken = extractBearerToken(req)
|
||||
if (!bearerToken) return
|
||||
return authenticateByToken(bearerToken)
|
||||
}
|
||||
|
||||
const authenticateByToken = async (
|
||||
apiToken: string
|
||||
): Promise<User | undefined> => {
|
||||
if (typeof window !== 'undefined') return
|
||||
return (await prisma.user.findFirst({
|
||||
where: { apiTokens: { some: { token: apiToken } } },
|
||||
})) as User
|
||||
}
|
||||
|
||||
const extractBearerToken = (req: NextApiRequest) =>
|
||||
req.headers['authorization']?.slice(7)
|
||||
|
||||
export type Context = inferAsyncReturnType<typeof createContext>
|
@ -1,13 +1,22 @@
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import { OpenApiMeta } from 'trpc-openapi'
|
||||
import superjson from 'superjson'
|
||||
import { Context } from './context'
|
||||
|
||||
const t = initTRPC.meta<OpenApiMeta>().create({
|
||||
const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
|
||||
transformer: superjson,
|
||||
})
|
||||
|
||||
const injectUser = t.middleware(({ next, ctx }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const middleware = t.middleware
|
||||
|
||||
export const router = t.router
|
||||
|
||||
export const publicProcedure = t.procedure
|
||||
export const publicProcedure = t.procedure.use(injectUser)
|
||||
|
Reference in New Issue
Block a user