Add webhook blocks API public endpoints

This commit is contained in:
Baptiste Arnaud
2022-11-30 13:57:28 +01:00
parent f9ffdbc4c5
commit c799717905
67 changed files with 3030 additions and 429 deletions

View File

@@ -12,6 +12,7 @@ import { ChangePlanForm } from '../ChangePlanForm'
export const BillingContent = () => {
const { workspace, refreshWorkspace } = useWorkspace()
console.log(workspace)
if (!workspace) return null
return (
<Stack spacing="10" w="full">
@@ -20,13 +21,7 @@ export const BillingContent = () => {
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
onCancelSuccess={refreshWorkspace}
/>
<HStack maxW="500px">
<StripeClimateLogo />

View File

@@ -52,11 +52,7 @@ export const ChangePlanForm = () => {
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace({
plan,
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace()
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,

View File

@@ -0,0 +1 @@
export * from './router'

View File

@@ -0,0 +1,74 @@
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import prisma from '@/lib/prisma'
import { canReadTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook } from 'models'
import { z } from 'zod'
import { parseResultExample } from '../utils'
export const getResultExampleProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample',
protect: true,
summary: 'Get result example',
description:
'Returns "fake" result for webhook block to help you anticipate how the webhook will behave.',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
})
)
.output(
z.object({
resultExample: z
.object({
message: z.literal(
'This is a sample result, it has been generated ⬇️'
),
'Submitted at': z.string(),
})
.and(z.record(z.string().optional()))
.describe('Can contain any fields.'),
})
)
.query(async ({ input: { typebotId, blockId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canReadTypebot(typebotId, user),
select: {
groups: true,
edges: true,
variables: true,
webhooks: true,
},
})) as
| (Pick<Typebot, 'groups' | 'edges' | 'variables'> & {
webhooks: Webhook[]
})
| null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const block = typebot.groups
.flatMap((g) => g.blocks)
.find((s) => s.id === blockId)
if (!block)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Block not found' })
const linkedTypebots = await getLinkedTypebots(typebot, user)
return {
resultExample: await parseResultExample(
typebot,
linkedTypebots
)(block.groupId),
}
})

View File

@@ -0,0 +1,4 @@
export * from './getResultExampleProcedure'
export * from './listWebhookBlocksProcedure'
export * from './subscribeWebhookProcedure'
export * from './unsubscribeWebhookProcedure'

View File

@@ -0,0 +1,65 @@
import prisma from '@/lib/prisma'
import { canReadTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Group, Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const listWebhookBlocksProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks',
protect: true,
summary: 'List webhook blocks',
description:
'Returns a list of all the webhook blocks that you can subscribe to.',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
webhookBlocks: z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string().optional(),
})
),
})
)
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canReadTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlocks = (typebot?.groups as Group[]).reduce<
{ id: string; label: string; url: string | undefined }[]
>((webhookBlocks, group) => {
const blocks = group.blocks.filter((block) =>
isWebhookBlock(block)
) as WebhookBlock[]
return [
...webhookBlocks,
...blocks.map((b) => ({
id: b.id,
label: `${group.title} > ${b.id}`,
url: typebot?.webhooks.find(byId(b.webhookId))?.url ?? undefined,
})),
]
}, [])
return { webhookBlocks }
})

View File

@@ -0,0 +1,64 @@
import prisma from '@/lib/prisma'
import { canWriteTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const subscribeWebhookProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe',
protect: true,
summary: 'Subscribe to webhook block',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
url: z.string(),
})
)
.output(
z.object({
id: z.string(),
url: z.string().nullable(),
})
)
.query(async ({ input: { typebotId, blockId, url }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlock = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock | null
if (!webhookBlock || !isWebhookBlock(webhookBlock))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Webhook block not found',
})
await prisma.webhook.upsert({
where: { id: webhookBlock.webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: { url, body: '{{state}}', method: 'POST', typebotId },
})
return {
id: blockId,
url,
}
})

View File

@@ -0,0 +1,62 @@
import prisma from '@/lib/prisma'
import { canWriteTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const unsubscribeWebhookProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe',
protect: true,
summary: 'Unsubscribe from webhook block',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
})
)
.output(
z.object({
id: z.string(),
url: z.string().nullable(),
})
)
.query(async ({ input: { typebotId, blockId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlock = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock | null
if (!webhookBlock || !isWebhookBlock(webhookBlock))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Webhook block not found',
})
await prisma.webhook.update({
where: { id: webhookBlock.webhookId },
data: { url: null },
})
return {
id: blockId,
url: null,
}
})

View File

@@ -0,0 +1,14 @@
import { router } from '@/utils/server/trpc'
import {
listWebhookBlocksProcedure,
subscribeWebhookProcedure,
unsubscribeWebhookProcedure,
getResultExampleProcedure,
} from './procedures'
export const webhookRouter = router({
listWebhookBlocks: listWebhookBlocksProcedure,
getResultExample: getResultExampleProcedure,
subscribeWebhook: subscribeWebhookProcedure,
unsubscribeWebhook: unsubscribeWebhookProcedure,
})

View File

@@ -0,0 +1 @@
export * from './parseResultExample'

View File

@@ -0,0 +1,197 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isInputBlock, byId, parseResultHeader, isNotDefined } from 'utils'
export const parseResultExample =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId: string
): Promise<
{
message: 'This is a sample result, it has been generated ⬇️'
'Submitted at': string
} & { [k: string]: string | undefined }
> => {
const header = parseResultHeader(typebot, linkedTypebots)
const linkedInputBlocks = await extractLinkedInputBlocks(
typebot,
linkedTypebots
)(currentGroupId)
return {
message: 'This is a sample result, it has been generated ⬇️',
'Submitted at': new Date().toISOString(),
...parseResultSample(linkedInputBlocks, header),
}
}
const extractLinkedInputBlocks =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId?: string,
direction: 'backward' | 'forward' = 'backward'
): Promise<InputBlock[]> => {
const previousLinkedTypebotBlocks = walkEdgesAndExtract(
'linkedBot',
direction,
typebot
)({
groupId: currentGroupId,
}) as TypebotLinkBlock[]
const linkedBotInputs =
previousLinkedTypebotBlocks.length > 0
? await Promise.all(
previousLinkedTypebotBlocks.map((linkedBot) =>
extractLinkedInputBlocks(
linkedTypebots.find((t) =>
'typebotId' in t
? t.typebotId === linkedBot.options.typebotId
: t.id === linkedBot.options.typebotId
) as Typebot | PublicTypebot,
linkedTypebots
)(linkedBot.options.groupId, 'forward')
)
)
: []
return (
walkEdgesAndExtract(
'input',
direction,
typebot
)({
groupId: currentGroupId,
}) as InputBlock[]
).concat(linkedBotInputs.flatMap((l) => l))
}
const parseResultSample = (
inputBlocks: InputBlock[],
headerCells: ResultHeaderCell[]
) =>
headerCells.reduce<Record<string, string | undefined>>(
(resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id)
)
if (isNotDefined(inputBlock)) {
if (cell.variableIds)
return {
...resultSample,
[cell.label]: 'content',
}
return resultSample
}
const value = getSampleValue(inputBlock)
return {
...resultSample,
[cell.label]: value,
}
},
{}
)
const getSampleValue = (block: InputBlock) => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
? block.items.map((i) => i.content).join(', ')
: block.items[0]?.content ?? 'Item'
case InputBlockType.DATE:
return new Date().toUTCString()
case InputBlockType.EMAIL:
return 'test@email.com'
case InputBlockType.NUMBER:
return '20'
case InputBlockType.PHONE:
return '+33665566773'
case InputBlockType.TEXT:
return 'answer value'
case InputBlockType.URL:
return 'https://test.com'
}
}
const walkEdgesAndExtract =
(
type: 'input' | 'linkedBot',
direction: 'backward' | 'forward',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId }: { groupId?: string }): Block[] => {
const currentGroupId =
groupId ??
(typebot.groups.find((b) => b.blocks[0].type === 'start')?.id as string)
const blocksInGroup = extractBlocksInGroup(
type,
typebot
)({
groupId: currentGroupId,
})
const otherGroupIds = getGroupIds(typebot, direction)(currentGroupId)
return [
...blocksInGroup,
...otherGroupIds.flatMap((groupId) =>
extractBlocksInGroup(type, typebot)({ groupId })
),
]
}
const getGroupIds =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
direction: 'backward' | 'forward',
existingGroupIds?: string[]
) =>
(groupId: string): string[] => {
const groups = typebot.edges.reduce<string[]>((groupIds, edge) => {
if (direction === 'forward')
return (!existingGroupIds ||
!existingGroupIds?.includes(edge.to.groupId)) &&
edge.from.groupId === groupId
? [...groupIds, edge.to.groupId]
: groupIds
return (!existingGroupIds ||
!existingGroupIds.includes(edge.from.groupId)) &&
edge.to.groupId === groupId
? [...groupIds, edge.from.groupId]
: groupIds
}, [])
const newGroups = [...(existingGroupIds ?? []), ...groups]
return groups.concat(
groups.flatMap(getGroupIds(typebot, direction, newGroups))
)
}
const extractBlocksInGroup =
(
type: 'input' | 'linkedBot',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId, blockId }: { groupId: string; blockId?: string }) => {
const currentGroup = typebot.groups.find(byId(groupId))
if (!currentGroup) return []
const blocks: Block[] = []
for (const block of currentGroup.blocks) {
if (block.id === blockId) break
if (type === 'input' && isInputBlock(block)) blocks.push(block)
if (type === 'linkedBot' && block.type === LogicBlockType.TYPEBOT_LINK)
blocks.push(block)
}
return blocks
}

View File

@@ -6,8 +6,9 @@ import {
import { HttpMethod } from 'models'
import cuid from 'cuid'
import { getTestAsset } from '@/test/utils/playwright'
import { apiToken } from 'utils/playwright/databaseSetup'
test.describe('Webhook block', () => {
test.describe('Builder', () => {
test('easy configuration should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
@@ -98,3 +99,83 @@ const addTestVariable = async (page: Page, name: string, value: string) => {
await page.click(`text="${name}"`)
await page.fill('input >> nth=-1', value)
}
test.describe('API', () => {
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await createWebhook(typebotId)
} catch (err) {
console.log(err)
}
})
test('can get webhook blocks', async ({ request }) => {
const response = await request.get(
`/api/v1/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { webhookBlocks } = await response.json()
expect(webhookBlocks).toHaveLength(1)
expect(webhookBlocks[0]).toEqual({
id: 'webhookBlock',
label: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
const url = 'https://test.com'
const response = await request.post(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/subscribe`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url },
}
)
const body = await response.json()
expect(body).toEqual({
id: 'webhookBlock',
url,
})
})
test('can unsubscribe webhook', async ({ request }) => {
const response = await request.post(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/unsubscribe`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
id: 'webhookBlock',
url: null,
})
})
test('can get a sample result', async ({ request }) => {
const response = await request.get(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/getResultExample`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data.resultExample).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
})

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,41 @@
import prisma from '@/lib/prisma'
import { canReadTypebots } from '@/utils/api/dbRules'
import { User } from 'db'
import {
LogicBlockType,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isDefined } from 'utils'
export const getLinkedTypebots = async (
typebot: Pick<PublicTypebot, 'groups'>,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (
typebot.groups
.flatMap((g) => g.blocks)
.filter(
(s) =>
s.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(s.options.typebotId)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId as string)
if (linkedTypebotIds.length === 0) return []
const typebots = (await ('typebotId' in typebot
? prisma.publicTypebot.findMany({
where: { id: { in: linkedTypebotIds } },
})
: prisma.typebot.findMany({
where: user
? {
AND: [
{ id: { in: linkedTypebotIds } },
canReadTypebots(linkedTypebotIds, user as User),
],
}
: { id: { in: linkedTypebotIds } },
}))) as unknown as (Typebot | PublicTypebot)[]
return typebots
}

View File

@@ -0,0 +1 @@
export * from './getLinkedTypebots'

View File

@@ -24,7 +24,6 @@ export const TypebotsDropdown = ({
const { showToast } = useToast()
const { typebots, isLoading } = useTypebots({
workspaceId: currentWorkspaceId,
allFolders: true,
onError: (e) => showToast({ title: e.name, description: e.message }),
})
const currentTypebot = useMemo(

View File

@@ -1,38 +1,18 @@
import React from 'react'
import {
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
HStack,
Flex,
SkeletonCircle,
Button,
useDisclosure,
} from '@chakra-ui/react'
import {
ChevronLeftIcon,
HardDriveIcon,
LogOutIcon,
PlusIcon,
SettingsIcon,
} from '@/components/icons'
import { HStack, Flex, Button, useDisclosure } from '@chakra-ui/react'
import { SettingsIcon } from '@/components/icons'
import { signOut } from 'next-auth/react'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import { useWorkspace, WorkspaceDropdown } from '@/features/workspace'
import { isNotDefined } from 'utils'
import Link from 'next/link'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { TypebotLogo } from '@/components/TypebotLogo'
import { PlanTag } from '@/features/billing'
import { WorkspaceSettingsModal } from '@/features/workspace'
export const DashboardHeader = () => {
const { user } = useUser()
const { workspace, switchWorkspace, createWorkspace } = useWorkspace()
const { workspace, workspaces, switchWorkspace, createWorkspace } =
useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const handleLogOut = () => {
@@ -71,63 +51,12 @@ export const DashboardHeader = () => {
>
Settings & Members
</Button>
<Menu placement="bottom-end">
<MenuButton as={Button} variant="outline" px="2">
<HStack>
<SkeletonCircle
isLoaded={workspace !== undefined}
alignItems="center"
display="flex"
boxSize="20px"
>
<EmojiOrImageIcon
boxSize="20px"
icon={workspace?.icon}
defaultIcon={HardDriveIcon}
/>
</SkeletonCircle>
{workspace && (
<>
<Text noOfLines={1} maxW="200px">
{workspace.name}
</Text>
<PlanTag plan={workspace.plan} />
</>
)}
<ChevronLeftIcon transform="rotate(-90deg)" />
</HStack>
</MenuButton>
<MenuList>
{workspaces
?.filter((w) => w.id !== workspace?.id)
.map((workspace) => (
<MenuItem
key={workspace.id}
onClick={() => switchWorkspace(workspace.id)}
>
<HStack>
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="16px"
defaultIcon={HardDriveIcon}
/>
<Text>{workspace.name}</Text>
<PlanTag plan={workspace.plan} />
</HStack>
</MenuItem>
))}
<MenuItem onClick={handleCreateNewWorkspace} icon={<PlusIcon />}>
New workspace
</MenuItem>
<MenuItem
onClick={handleLogOut}
icon={<LogOutIcon />}
color="orange.500"
>
Log out
</MenuItem>
</MenuList>
</Menu>
<WorkspaceDropdown
currentWorkspace={workspace}
onLogoutClick={handleLogOut}
onCreateNewWorkspaceClick={handleCreateNewWorkspace}
onWorkspaceSelected={switchWorkspace}
/>
</HStack>
</Flex>
</Flex>

View File

@@ -1,31 +1,29 @@
import { fetcher } from '@/utils/helpers'
import { stringify } from 'qs'
import useSWR from 'swr'
import { env } from 'utils'
import { TypebotInDashboard } from '../types'
import { trpc } from '@/lib/trpc'
export const useTypebots = ({
folderId,
workspaceId,
allFolders,
onError,
}: {
workspaceId?: string
folderId?: string
allFolders?: boolean
folderId?: string | 'root'
onError: (error: Error) => void
}) => {
const params = stringify({ folderId, allFolders, workspaceId })
const { data, error, mutate } = useSWR<
{ typebots: TypebotInDashboard[] },
Error
>(workspaceId ? `/api/typebots?${params}` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
if (error) onError(error)
const { data, isLoading, refetch } = trpc.typebot.listTypebots.useQuery(
{
workspaceId: workspaceId!,
folderId,
},
{
enabled: !!workspaceId,
onError: (error) => {
onError(new Error(error.message))
},
}
)
return {
typebots: data?.typebots,
isLoading: !error && !data,
mutate,
isLoading,
refetch,
}
}

View File

@@ -67,10 +67,10 @@ export const FolderContent = ({ folder }: Props) => {
const {
typebots,
isLoading: isTypebotLoading,
mutate: mutateTypebots,
refetch: refetchTypebots,
} = useTypebots({
workspaceId: workspace?.id,
folderId: folder?.id,
folderId: folder === null ? 'root' : folder.id,
onError: (error) => {
showToast({
title: "Couldn't fetch typebots",
@@ -85,7 +85,7 @@ export const FolderContent = ({ folder }: Props) => {
folderId: folderId === 'root' ? null : folderId,
})
if (error) showToast({ description: error.message })
mutateTypebots({ typebots: typebots.filter((t) => t.id !== typebotId) })
refetchTypebots()
}
const handleCreateFolder = async () => {
@@ -105,7 +105,7 @@ export const FolderContent = ({ folder }: Props) => {
const handleTypebotDeleted = (deletedId: string) => {
if (!typebots) return
mutateTypebots({ typebots: typebots.filter((t) => t.id !== deletedId) })
refetchTypebots()
}
const handleFolderDeleted = (deletedId: string) => {

View File

@@ -17,12 +17,17 @@ export const deleteResultsProcedure = authenticatedProcedure
.input(
z.object({
typebotId: z.string(),
ids: z.string().optional(),
resultIds: z
.string()
.describe(
'Comma separated list of ids. If not provided, all results will be deleted. ⚠️'
)
.optional(),
})
)
.output(z.void())
.mutation(async ({ input, ctx: { user } }) => {
const idsArray = input.ids?.split(',')
const idsArray = input.resultIds?.split(',')
const { typebotId } = input
const { success } = await archiveResults({
typebotId,

View File

@@ -90,7 +90,7 @@ export const ResultsActionButtons = ({
if (!workspaceId || !typebotId) return
deleteResultsMutation.mutate({
typebotId,
ids:
resultIds:
totalSelected === totalResults
? undefined
: selectedResultsId.join(','),

View File

@@ -0,0 +1 @@
export * from './router'

View File

@@ -0,0 +1 @@
export * from './listTypebotsProcedure'

View File

@@ -0,0 +1,68 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from 'db'
import { Typebot, typebotSchema } from 'models'
import { z } from 'zod'
export const listTypebotsProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots',
protect: true,
summary: 'List typebots',
tags: ['Typebot'],
},
})
.input(z.object({ workspaceId: z.string(), folderId: z.string().optional() }))
.output(
z.object({
typebots: z.array(
typebotSchema.pick({
name: true,
icon: true,
id: true,
publishedTypebotId: true,
})
),
})
)
.query(async ({ input: { workspaceId, folderId }, ctx: { user } }) => {
const typebots = (await prisma.typebot.findMany({
where: {
OR: [
{
isArchived: { not: true },
folderId: folderId === 'root' ? null : folderId,
workspace: {
id: workspaceId,
members: {
some: {
userId: user.id,
role: { not: WorkspaceRole.GUEST },
},
},
},
},
{
isArchived: { not: true },
workspace: {
id: workspaceId,
members: {
some: { userId: user.id, role: WorkspaceRole.GUEST },
},
},
collaborators: { some: { userId: user.id } },
},
],
},
orderBy: { createdAt: 'desc' },
select: { name: true, publishedTypebotId: true, id: true, icon: true },
})) as Pick<Typebot, 'name' | 'id' | 'icon' | 'publishedTypebotId'>[]
if (!typebots)
throw new TRPCError({ code: 'NOT_FOUND', message: 'No typebots found' })
return { typebots }
})

View File

@@ -0,0 +1,6 @@
import { router } from '@/utils/server/trpc'
import { listTypebotsProcedure } from './procedures'
export const typebotRouter = router({
listTypebots: listTypebotsProcedure,
})

View File

@@ -1,186 +1,170 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { byId } from 'utils'
import { Plan, Workspace, WorkspaceRole } from 'db'
import { WorkspaceRole } from 'db'
import { useUser } from '../account/UserProvider'
import { useRouter } from 'next/router'
import { useTypebot } from '../editor/providers/TypebotProvider'
import { useWorkspaces } from './hooks/useWorkspaces'
import { createWorkspaceQuery } from './queries/createWorkspaceQuery'
import { deleteWorkspaceQuery } from './queries/deleteWorkspaceQuery'
import { updateWorkspaceQuery } from './queries/updateWorkspaceQuery'
import { WorkspaceWithMembers } from './types'
import { trpc } from '@/lib/trpc'
import { Workspace } from 'models'
import { useToast } from '@/hooks/useToast'
import { parseNewName, setWorkspaceIdInLocalStorage } from './utils'
import { useTypebot } from '../editor'
const workspaceContext = createContext<{
workspaces?: WorkspaceWithMembers[]
isLoading: boolean
workspace?: WorkspaceWithMembers
canEdit: boolean
workspaces: Pick<Workspace, 'id' | 'name' | 'icon' | 'plan'>[]
workspace?: Workspace
currentRole?: WorkspaceRole
switchWorkspace: (workspaceId: string) => void
createWorkspace: (name?: string) => Promise<void>
updateWorkspace: (
workspaceId: string,
updates: Partial<Workspace>
) => Promise<void>
updateWorkspace: (updates: { icon?: string; name?: string }) => void
deleteCurrentWorkspace: () => Promise<void>
refreshWorkspace: (expectedUpdates: Partial<Workspace>) => void
refreshWorkspace: () => void
//@ts-ignore
}>({})
type WorkspaceContextProps = {
typebotId?: string
children: ReactNode
}
const getNewWorkspaceName = (
userFullName: string | undefined,
existingWorkspaces: Workspace[]
) => {
const workspaceName = userFullName
? `${userFullName}'s workspace`
: 'My workspace'
let newName = workspaceName
let i = 1
while (existingWorkspaces.find((w) => w.name === newName)) {
newName = `${workspaceName} (${i})`
i++
}
return newName
}
export const WorkspaceProvider = ({ children }: WorkspaceContextProps) => {
export const WorkspaceProvider = ({
typebotId,
children,
}: WorkspaceContextProps) => {
const { query } = useRouter()
const { user } = useUser()
const userId = user?.id
const [workspaceId, setWorkspaceId] = useState<string | undefined>()
const { typebot } = useTypebot()
const { workspaces, isLoading, mutate } = useWorkspaces({ userId })
const [currentWorkspace, setCurrentWorkspace] =
useState<WorkspaceWithMembers>()
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string>()
const canEdit =
workspaces
?.find(byId(currentWorkspace?.id))
?.members.find((m) => m.userId === userId)?.role === WorkspaceRole.ADMIN
const trpcContext = trpc.useContext()
const currentRole = currentWorkspace?.members.find(
(m) => m.userId === userId
const { data: workspacesData } = trpc.workspace.listWorkspaces.useQuery(
undefined,
{
enabled: !!user,
}
)
const workspaces = useMemo(
() => workspacesData?.workspaces ?? [],
[workspacesData?.workspaces]
)
const { data: workspaceData } = trpc.workspace.getWorkspace.useQuery(
{ workspaceId: workspaceId! },
{ enabled: !!workspaceId }
)
const { data: membersData } = trpc.workspace.listMembersInWorkspace.useQuery(
{ workspaceId: workspaceId! },
{ enabled: !!workspaceId }
)
const workspace = workspaceData?.workspace
const members = membersData?.members
const { showToast } = useToast()
const createWorkspaceMutation = trpc.workspace.createWorkspace.useMutation({
onError: (error) => showToast({ description: error.message }),
onSuccess: async () => {
trpcContext.workspace.listWorkspaces.invalidate()
},
})
const updateWorkspaceMutation = trpc.workspace.updateWorkspace.useMutation({
onError: (error) => showToast({ description: error.message }),
onSuccess: async () => {
trpcContext.workspace.getWorkspace.invalidate()
},
})
const deleteWorkspaceMutation = trpc.workspace.deleteWorkspace.useMutation({
onError: (error) => showToast({ description: error.message }),
onSuccess: async () => {
trpcContext.workspace.listWorkspaces.invalidate()
},
})
const currentRole = members?.find(
(member) =>
member.user.email === user?.email && member.workspaceId === workspaceId
)?.role
useEffect(() => {
if (!workspaces || workspaces.length === 0 || currentWorkspace) return
if (
!workspaces ||
workspaces.length === 0 ||
workspaceId ||
(typebotId && !typebot?.workspaceId)
)
return
const lastWorspaceId =
pendingWorkspaceId ??
typebot?.workspaceId ??
query.workspaceId?.toString() ??
localStorage.getItem('workspaceId')
const defaultWorkspace = lastWorspaceId
? workspaces.find(byId(lastWorspaceId))
: workspaces.find((w) =>
w.members.some(
(m) => m.userId === userId && m.role === WorkspaceRole.ADMIN
)
)
setCurrentWorkspace(defaultWorkspace ?? workspaces[0])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaces?.length])
useEffect(() => {
if (!currentWorkspace?.id) return
localStorage.setItem('workspaceId', currentWorkspace.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkspace?.id])
const defaultWorkspaceId = lastWorspaceId
? workspaces.find(byId(lastWorspaceId))?.id
: members?.find((member) => member.role === WorkspaceRole.ADMIN)
?.workspaceId
useEffect(() => {
if (!currentWorkspace) return setPendingWorkspaceId(typebot?.workspaceId)
if (!typebot?.workspaceId || typebot.workspaceId === currentWorkspace.id)
return
switchWorkspace(typebot.workspaceId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot?.workspaceId])
const newWorkspaceId = defaultWorkspaceId ?? workspaces[0].id
setWorkspaceIdInLocalStorage(newWorkspaceId)
setWorkspaceId(newWorkspaceId)
}, [
members,
query.workspaceId,
typebot?.workspaceId,
typebotId,
userId,
workspaceId,
workspaces,
])
const switchWorkspace = (workspaceId: string) => {
const newWorkspace = workspaces?.find(byId(workspaceId))
if (!newWorkspace) return
setCurrentWorkspace(newWorkspace)
setWorkspaceId(workspaceId)
setWorkspaceIdInLocalStorage(workspaceId)
}
const createWorkspace = async (userFullName?: string) => {
if (!workspaces) return
const newWorkspaceName = getNewWorkspaceName(userFullName, workspaces)
const { data, error } = await createWorkspaceQuery({
name: newWorkspaceName,
plan: Plan.FREE,
})
if (error || !data) return
const { workspace } = data
const newWorkspace = {
...workspace,
members: [
{
role: WorkspaceRole.ADMIN,
userId: userId as string,
workspaceId: workspace.id as string,
},
],
}
mutate({
workspaces: [...workspaces, newWorkspace],
})
setCurrentWorkspace(newWorkspace)
const name = parseNewName(userFullName, workspaces)
const { workspace } = await createWorkspaceMutation.mutateAsync({ name })
setWorkspaceId(workspace.id)
}
const updateWorkspace = async (
workspaceId: string,
updates: Partial<Workspace>
) => {
const { data } = await updateWorkspaceQuery({ id: workspaceId, ...updates })
if (!data || !currentWorkspace) return
setCurrentWorkspace({ ...currentWorkspace, ...updates })
mutate({
workspaces: (workspaces ?? []).map((w) =>
w.id === workspaceId ? { ...data.workspace, members: w.members } : w
),
const updateWorkspace = (updates: { icon?: string; name?: string }) => {
if (!workspaceId) return
updateWorkspaceMutation.mutate({
workspaceId,
...updates,
})
}
const deleteCurrentWorkspace = async () => {
if (!currentWorkspace || !workspaces || workspaces.length < 2) return
const { data } = await deleteWorkspaceQuery(currentWorkspace.id)
if (!data || !currentWorkspace) return
const newWorkspaces = (workspaces ?? []).filter((w) =>
w.id === currentWorkspace.id
? { ...data.workspace, members: w.members }
: w
)
setCurrentWorkspace(newWorkspaces[0])
mutate({
workspaces: newWorkspaces,
})
if (!workspaceId || !workspaces || workspaces.length < 2) return
await deleteWorkspaceMutation.mutateAsync({ workspaceId })
setWorkspaceId(workspaces[0].id)
}
const refreshWorkspace = (expectedUpdates: Partial<Workspace>) => {
if (!currentWorkspace) return
const updatedWorkspace = { ...currentWorkspace, ...expectedUpdates }
mutate({
workspaces: (workspaces ?? []).map((w) =>
w.id === currentWorkspace.id ? updatedWorkspace : w
),
})
setCurrentWorkspace(updatedWorkspace)
const refreshWorkspace = () => {
trpcContext.workspace.getWorkspace.invalidate()
}
return (
<workspaceContext.Provider
value={{
workspaces,
workspace: currentWorkspace,
isLoading,
canEdit,
workspace,
currentRole,
switchWorkspace,
createWorkspace,

View File

@@ -0,0 +1 @@
export * from './router'

View File

@@ -0,0 +1,56 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan } from 'db'
import { Workspace, workspaceSchema } from 'models'
import { z } from 'zod'
export const createWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/workspaces',
protect: true,
summary: 'Create workspace',
tags: ['Workspace'],
},
})
.input(workspaceSchema.pick({ name: true }))
.output(
z.object({
workspace: workspaceSchema,
})
)
.mutation(async ({ input: { name }, ctx: { user } }) => {
const existingWorkspaceNames = (await prisma.workspace.findMany({
where: {
members: {
some: {
userId: user.id,
},
},
},
select: { name: true },
})) satisfies Pick<Workspace, 'name'>[]
if (existingWorkspaceNames.some((workspace) => workspace.name === name))
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Workspace with same name already exists',
})
const plan =
process.env.ADMIN_EMAIL === user.email ? Plan.LIFETIME : Plan.FREE
const newWorkspace = (await prisma.workspace.create({
data: {
name,
members: { create: [{ role: 'ADMIN', userId: user.id }] },
plan,
},
})) satisfies Workspace
return {
workspace: newWorkspace,
}
})

View File

@@ -0,0 +1,33 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { z } from 'zod'
export const deleteWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/workspaces/{workspaceId}',
protect: true,
summary: 'Delete workspace',
tags: ['Workspace'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
message: z.string(),
})
)
.mutation(async ({ input: { workspaceId }, ctx: { user } }) => {
await prisma.workspace.deleteMany({
where: { members: { some: { userId: user.id } }, id: workspaceId },
})
return {
message: 'Workspace deleted',
}
})

View File

@@ -0,0 +1,38 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Workspace, workspaceSchema } from 'models'
import { z } from 'zod'
export const getWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}',
protect: true,
summary: 'Get workspace',
tags: ['Workspace'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
workspace: workspaceSchema,
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = (await prisma.workspace.findFirst({
where: { members: { some: { userId: user.id } }, id: workspaceId },
})) satisfies Workspace | null
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' })
return {
workspace,
}
})

View File

@@ -0,0 +1,7 @@
export * from './createWorkspaceProcedure'
export * from './deleteWorkspaceProcedure'
export * from './getWorkspaceProcedure'
export * from './listInvitationsInWorkspaceProcedure'
export * from './listMembersInWorkspaceProcedure'
export * from './listWorkspacesProcedure'
export * from './updateWorkspaceProcedure'

View File

@@ -0,0 +1,43 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceInvitation, workspaceInvitationSchema } from 'models'
import { z } from 'zod'
export const listInvitationsInWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}/invitations',
protect: true,
summary: 'List invitations in workspace',
tags: ['Workspace'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
invitations: z.array(workspaceInvitationSchema),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const invitations = (await prisma.workspaceInvitation.findMany({
where: {
workspaceId,
workspace: { members: { some: { userId: user.id } } },
},
select: { createdAt: true, email: true, type: true },
})) satisfies WorkspaceInvitation[]
if (!invitations)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No invitations found',
})
return { invitations }
})

View File

@@ -0,0 +1,37 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceMember, workspaceMemberSchema } from 'models'
import { z } from 'zod'
export const listMembersInWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}/members',
protect: true,
summary: 'List members in workspace',
tags: ['Workspace'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
members: z.array(workspaceMemberSchema),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const members = (await prisma.memberInWorkspace.findMany({
where: { userId: user.id, workspaceId },
include: { user: { select: { name: true, email: true, image: true } } },
})) satisfies WorkspaceMember[]
if (!members)
throw new TRPCError({ code: 'NOT_FOUND', message: 'No members found' })
return { members }
})

View File

@@ -0,0 +1,35 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Workspace, workspaceSchema } from 'models'
import { z } from 'zod'
export const listWorkspacesProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces',
protect: true,
summary: 'List workspaces',
tags: ['Workspace'],
},
})
.input(z.void())
.output(
z.object({
workspaces: z.array(
workspaceSchema.pick({ id: true, name: true, icon: true, plan: true })
),
})
)
.query(async ({ ctx: { user } }) => {
const workspaces = (await prisma.workspace.findMany({
where: { members: { some: { userId: user.id } } },
select: { name: true, id: true, icon: true, plan: true },
})) satisfies Pick<Workspace, 'id' | 'name' | 'icon' | 'plan'>[]
if (!workspaces)
throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' })
return { workspaces }
})

View File

@@ -0,0 +1,45 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Workspace, workspaceSchema } from 'models'
import { z } from 'zod'
export const updateWorkspaceProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/workspaces/{workspaceId}',
protect: true,
summary: 'Update workspace',
tags: ['Workspace'],
},
})
.input(
z.object({
name: z.string().optional(),
icon: z.string().optional(),
workspaceId: z.string(),
})
)
.output(
z.object({
workspace: workspaceSchema,
})
)
.mutation(async ({ input: { workspaceId, ...updates }, ctx: { user } }) => {
await prisma.workspace.updateMany({
where: { members: { some: { userId: user.id } }, id: workspaceId },
data: updates,
})
const workspace = (await prisma.workspace.findFirst({
where: { members: { some: { userId: user.id } }, id: workspaceId },
})) satisfies Workspace | null
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
return {
workspace,
}
})

View File

@@ -0,0 +1,18 @@
import { router } from '@/utils/server/trpc'
import {
createWorkspaceProcedure,
deleteWorkspaceProcedure,
getWorkspaceProcedure,
listMembersInWorkspaceProcedure,
listWorkspacesProcedure,
updateWorkspaceProcedure,
} from './procedures'
export const workspaceRouter = router({
listWorkspaces: listWorkspacesProcedure,
getWorkspace: getWorkspaceProcedure,
listMembersInWorkspace: listMembersInWorkspaceProcedure,
createWorkspace: createWorkspaceProcedure,
updateWorkspace: updateWorkspaceProcedure,
deleteWorkspace: deleteWorkspaceProcedure,
})

View File

@@ -23,11 +23,13 @@ import { Member } from '../../types'
export const MembersList = () => {
const { user } = useUser()
const { workspace, canEdit } = useWorkspace()
const { workspace, currentRole } = useWorkspace()
const { members, invitations, isLoading, mutate } = useMembers({
workspaceId: workspace?.id,
})
const canEdit = currentRole === WorkspaceRole.ADMIN
const handleDeleteMemberClick = (memberId: string) => async () => {
if (!workspace) return
await deleteMemberQuery(workspace.id, memberId)

View File

@@ -0,0 +1,98 @@
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import {
HardDriveIcon,
ChevronLeftIcon,
PlusIcon,
LogOutIcon,
} from '@/components/icons'
import { PlanTag } from '@/features/billing'
import { trpc } from '@/lib/trpc'
import {
Menu,
MenuButton,
Button,
HStack,
SkeletonCircle,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react'
import { Workspace } from 'models'
type Props = {
currentWorkspace?: Workspace
onWorkspaceSelected: (workspaceId: string) => void
onCreateNewWorkspaceClick: () => void
onLogoutClick: () => void
}
export const WorkspaceDropdown = ({
currentWorkspace,
onWorkspaceSelected,
onLogoutClick,
onCreateNewWorkspaceClick,
}: Props) => {
const { data } = trpc.workspace.listWorkspaces.useQuery()
const workspaces = data?.workspaces ?? []
return (
<Menu placement="bottom-end">
<MenuButton as={Button} variant="outline" px="2">
<HStack>
<SkeletonCircle
isLoaded={currentWorkspace !== undefined}
alignItems="center"
display="flex"
boxSize="20px"
>
<EmojiOrImageIcon
boxSize="20px"
icon={currentWorkspace?.icon}
defaultIcon={HardDriveIcon}
/>
</SkeletonCircle>
{currentWorkspace && (
<>
<Text noOfLines={1} maxW="200px">
{currentWorkspace.name}
</Text>
<PlanTag plan={currentWorkspace.plan} />
</>
)}
<ChevronLeftIcon transform="rotate(-90deg)" />
</HStack>
</MenuButton>
<MenuList>
{workspaces
?.filter((workspace) => workspace.id !== currentWorkspace?.id)
.map((workspace) => (
<MenuItem
key={workspace.id}
onClick={() => onWorkspaceSelected(workspace.id)}
>
<HStack>
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="16px"
defaultIcon={HardDriveIcon}
/>
<Text>{workspace.name}</Text>
<PlanTag plan={workspace.plan} />
</HStack>
</MenuItem>
))}
<MenuItem onClick={onCreateNewWorkspaceClick} icon={<PlusIcon />}>
New workspace
</MenuItem>
<MenuItem
onClick={onLogoutClick}
icon={<LogOutIcon />}
color="orange.500"
>
Log out
</MenuItem>
</MenuList>
</Menu>
)
}

View File

@@ -19,12 +19,11 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
const handleNameChange = (name: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { name })
updateWorkspace({ name })
}
const handleChangeIcon = (icon: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { icon })
updateWorkspace({ icon })
}
const handleDeleteClick = async () => {

View File

@@ -15,7 +15,7 @@ import {
UsersIcon,
} from '@/components/icons'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { GraphNavigation, User, Workspace } from 'db'
import { GraphNavigation, User, Workspace, WorkspaceRole } from 'db'
import { useState } from 'react'
import { MembersList } from './MembersList'
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
@@ -44,9 +44,13 @@ export const WorkspaceSettingsModal = ({
workspace,
onClose,
}: Props) => {
const { canEdit } = useWorkspace()
const { currentRole } = useWorkspace()
const [selectedTab, setSelectedTab] = useState<SettingsTab>('my-account')
console.log(currentRole)
const canEditWorkspace = currentRole === WorkspaceRole.ADMIN
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
@@ -94,7 +98,7 @@ export const WorkspaceSettingsModal = ({
<Text pl="4" color="gray.500">
Workspace
</Text>
{canEdit && (
{canEditWorkspace && (
<Button
variant={
selectedTab === 'workspace-settings' ? 'solid' : 'ghost'
@@ -124,7 +128,7 @@ export const WorkspaceSettingsModal = ({
>
Members
</Button>
{canEdit && (
{canEditWorkspace && (
<Button
variant={selectedTab === 'billing' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('billing')}

View File

@@ -1 +1,2 @@
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
export * from './WorkspaceSettingsModal'
export * from './WorkspaceDropdown'

View File

@@ -1,17 +0,0 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { WorkspaceWithMembers } from '../types'
export const useWorkspaces = ({ userId }: { userId?: string }) => {
const { data, error, mutate } = useSWR<
{
workspaces: WorkspaceWithMembers[]
},
Error
>(userId ? `/api/workspaces` : null, fetcher)
return {
workspaces: data?.workspaces,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -1,2 +1,2 @@
export { WorkspaceProvider, useWorkspace } from './WorkspaceProvider'
export { WorkspaceSettingsModal } from './components/WorkspaceSettingsModal'
export * from './components'

View File

@@ -1,28 +0,0 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const createWorkspaceQuery = async (
body: Omit<
Workspace,
| 'id'
| 'icon'
| 'createdAt'
| 'stripeId'
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt'
| 'customChatsLimit'
| 'customStorageLimit'
| 'customSeatsLimit'
>
) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces`,
method: 'POST',
body,
})

View File

@@ -1,10 +0,0 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const deleteWorkspaceQuery = (workspaceId: string) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${workspaceId}`,
method: 'DELETE',
})

View File

@@ -1,11 +0,0 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const updateWorkspaceQuery = async (updates: Partial<Workspace>) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${updates.id}`,
method: 'PATCH',
body: updates,
})

View File

@@ -1,9 +1,7 @@
import { MemberInWorkspace, Workspace } from 'db'
import { MemberInWorkspace } from 'db'
export type Member = MemberInWorkspace & {
name: string | null
image: string | null
email: string | null
}
export type WorkspaceWithMembers = Workspace & { members: MemberInWorkspace[] }

View File

@@ -0,0 +1,2 @@
export * from './parseNewName'
export * from './setWorkspaceIdInLocalStorage'

View File

@@ -0,0 +1,17 @@
import { Workspace } from 'models'
export const parseNewName = (
userFullName: string | undefined,
existingWorkspaces: Pick<Workspace, 'name'>[]
) => {
const workspaceName = userFullName
? `${userFullName}'s workspace`
: 'My workspace'
let newName = workspaceName
let i = 1
while (existingWorkspaces.find((w) => w.name === newName)) {
newName = `${workspaceName} (${i})`
i++
}
return newName
}

View File

@@ -0,0 +1,3 @@
export const setWorkspaceIdInLocalStorage = (workspaceId: string) => {
localStorage.setItem('workspaceId', workspaceId)
}