2
0

🐛 (usage) Archive typebot to be able to compute usage

This commit is contained in:
Baptiste Arnaud
2022-10-01 08:36:49 +02:00
committed by Baptiste Arnaud
parent 75ca255af2
commit 15dbc9577d
20 changed files with 152 additions and 84 deletions

View File

@@ -26,6 +26,7 @@ import { useWorkspace } from 'contexts/WorkspaceContext'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal' import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
import { isNotDefined } from 'utils' import { isNotDefined } from 'utils'
import { PlanTag } from 'components/shared/PlanTag'
export const DashboardHeader = () => { export const DashboardHeader = () => {
const { user } = useUser() const { user } = useUser()
@@ -90,9 +91,13 @@ export const DashboardHeader = () => {
/> />
</SkeletonCircle> </SkeletonCircle>
{workspace && ( {workspace && (
<>
<Text noOfLines={1} maxW="200px"> <Text noOfLines={1} maxW="200px">
{workspace.name} {workspace.name}
</Text> </Text>
<PlanTag plan={workspace.plan}/>
</>
)} )}
<ChevronLeftIcon transform="rotate(-90deg)" /> <ChevronLeftIcon transform="rotate(-90deg)" />
</HStack> </HStack>
@@ -112,6 +117,7 @@ export const DashboardHeader = () => {
defaultIcon={HardDriveIcon} defaultIcon={HardDriveIcon}
/> />
<Text>{workspace.name}</Text> <Text>{workspace.name}</Text>
<PlanTag plan={workspace.plan}/>
</HStack> </HStack>
</MenuItem> </MenuItem>
))} ))}

View File

@@ -1,10 +1,10 @@
import { Button, HStack, useDisclosure, Text } from '@chakra-ui/react' import { Button, HStack, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons' import { FolderPlusIcon } from 'assets/icons'
import { LockTag } from 'components/shared/LockTag'
import { import {
LimitReached, LimitReached,
ChangePlanModal, ChangePlanModal,
} from 'components/shared/modals/ChangePlanModal' } from 'components/shared/modals/ChangePlanModal'
import { PlanTag } from 'components/shared/PlanTag'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
import React from 'react' import React from 'react'
@@ -28,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
> >
<HStack> <HStack>
<Text>Create a folder</Text> <Text>Create a folder</Text>
{isFreePlan(workspace) && <PlanTag plan={Plan.STARTER} />} {isFreePlan(workspace) && <LockTag plan={Plan.STARTER} />}
</HStack> </HStack>
<ChangePlanModal <ChangePlanModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -46,7 +46,7 @@ export const CurrentSubscriptionContent = ({
return ( return (
<Stack gap="2"> <Stack gap="2">
<Heading fontSize="3xl">Subscription</Heading> <Heading fontSize="3xl">Subscription</Heading>
<HStack> <HStack data-testid="current-subscription">
<Text>Current workspace subscription: </Text> <Text>Current workspace subscription: </Text>
{isCancelling ? ( {isCancelling ? (
<Spinner color="gray.500" size="xs" /> <Spinner color="gray.500" size="xs" />

View File

@@ -1,5 +1,5 @@
import { HStack, Text, Tooltip } from '@chakra-ui/react' import { HStack, Text, Tooltip } from '@chakra-ui/react'
import { PlanTag } from 'components/shared/PlanTag' import { LockTag } from 'components/shared/LockTag'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
import { import {
@@ -54,7 +54,7 @@ export const BlockTypeLabel = ({ type }: Props): JSX.Element => {
<Tooltip label="Upload Files"> <Tooltip label="Upload Files">
<HStack> <HStack>
<Text>File</Text> <Text>File</Text>
{isFreePlan(workspace) && <PlanTag plan={Plan.STARTER} />} {isFreePlan(workspace) && <LockTag plan={Plan.STARTER} />}
</HStack> </HStack>
</Tooltip> </Tooltip>
) )

View File

@@ -1,9 +1,9 @@
import { Flex, FormLabel, Stack, Switch, useDisclosure } from '@chakra-ui/react' import { Flex, FormLabel, Stack, Switch, useDisclosure } from '@chakra-ui/react'
import { LockTag } from 'components/shared/LockTag'
import { import {
ChangePlanModal, ChangePlanModal,
LimitReached, LimitReached,
} from 'components/shared/modals/ChangePlanModal' } from 'components/shared/modals/ChangePlanModal'
import { PlanTag } from 'components/shared/PlanTag'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel' import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
@@ -66,7 +66,7 @@ export const GeneralSettingsForm = ({
> >
<FormLabel htmlFor="branding" mb="0"> <FormLabel htmlFor="branding" mb="0">
Typebot.io branding{' '} Typebot.io branding{' '}
{isWorkspaceFreePlan && <PlanTag plan={Plan.STARTER} />} {isWorkspaceFreePlan && <LockTag plan={Plan.STARTER} />}
</FormLabel> </FormLabel>
<Switch <Switch
id="branding" id="branding"

View File

@@ -10,8 +10,8 @@ import {
import { TrashIcon } from 'assets/icons' import { TrashIcon } from 'assets/icons'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton' import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useToast } from 'components/shared/hooks/useToast' import { useToast } from 'components/shared/hooks/useToast'
import { LockTag } from 'components/shared/LockTag'
import { LimitReached } from 'components/shared/modals/ChangePlanModal' import { LimitReached } from 'components/shared/modals/ChangePlanModal'
import { PlanTag } from 'components/shared/PlanTag'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
@@ -82,19 +82,22 @@ export const ShareContent = () => {
/> />
</HStack> </HStack>
)} )}
{isWorkspaceProPlan(workspace) && {isNotDefined(typebot?.customDomain) ? (
isNotDefined(typebot?.customDomain) ? ( <>
<CustomDomainsDropdown {isWorkspaceProPlan(workspace) ? (
onCustomDomainSelect={handleCustomDomainChange} <CustomDomainsDropdown
/> onCustomDomainSelect={handleCustomDomainChange}
) : ( />
<UpgradeButton ) : (
colorScheme="gray" <UpgradeButton
limitReachedType={LimitReached.CUSTOM_DOMAIN} colorScheme="gray"
> limitReachedType={LimitReached.CUSTOM_DOMAIN}
<Text mr="2">Add my domain</Text> <PlanTag plan={Plan.PRO} /> >
</UpgradeButton> <Text mr="2">Add my domain</Text> <LockTag plan={Plan.PRO} />
)} </UpgradeButton>
)}
</>
) : null}
</Stack> </Stack>
<Stack spacing={4}> <Stack spacing={4}>

View File

@@ -0,0 +1,14 @@
import { Tag, TagProps } from '@chakra-ui/react'
import { LockedIcon } from 'assets/icons'
import { Plan } from 'db'
import { planColorSchemes } from './PlanTag'
export const LockTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => (
<Tag
colorScheme={plan ? planColorSchemes[plan] : 'gray'}
data-testid={`${plan?.toLowerCase()}-lock-tag`}
{...props}
>
<LockedIcon />
</Tag>
)

View File

@@ -1,18 +1,34 @@
import { Tag, TagProps } from '@chakra-ui/react' import { Tag, TagProps, ThemeTypings } from '@chakra-ui/react'
import { Plan } from 'db' import { Plan } from 'db'
export const planColorSchemes: Record<Plan, ThemeTypings['colorSchemes']> = {
[Plan.LIFETIME]: 'purple',
[Plan.PRO]: 'blue',
[Plan.OFFERED]: 'orange',
[Plan.STARTER]: 'orange',
[Plan.FREE]: 'gray',
}
export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => { export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
switch (plan) { switch (plan) {
case Plan.LIFETIME: { case Plan.LIFETIME: {
return ( return (
<Tag colorScheme="purple" data-testid="lifetime-plan-tag" {...props}> <Tag
colorScheme={planColorSchemes[plan]}
data-testid="lifetime-plan-tag"
{...props}
>
Lifetime Lifetime
</Tag> </Tag>
) )
} }
case Plan.PRO: { case Plan.PRO: {
return ( return (
<Tag colorScheme="blue" data-testid="pro-plan-tag" {...props}> <Tag
colorScheme={planColorSchemes[plan]}
data-testid="pro-plan-tag"
{...props}
>
Pro Pro
</Tag> </Tag>
) )
@@ -20,14 +36,22 @@ export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
case Plan.OFFERED: case Plan.OFFERED:
case Plan.STARTER: { case Plan.STARTER: {
return ( return (
<Tag colorScheme="orange" data-testid="starter-plan-tag" {...props}> <Tag
colorScheme={planColorSchemes[plan]}
data-testid="starter-plan-tag"
{...props}
>
Starter Starter
</Tag> </Tag>
) )
} }
default: { default: {
return ( return (
<Tag colorScheme="gray" data-testid="free-plan-tag" {...props}> <Tag
colorScheme={planColorSchemes[Plan.FREE]}
data-testid="free-plan-tag"
{...props}
>
Free Free
</Tag> </Tag>
) )

View File

@@ -139,13 +139,14 @@ export const WorkspaceContext = ({ children }: WorkspaceContextProps) => {
if (!currentWorkspace || !workspaces || workspaces.length < 2) return if (!currentWorkspace || !workspaces || workspaces.length < 2) return
const { data } = await deleteWorkspace(currentWorkspace.id) const { data } = await deleteWorkspace(currentWorkspace.id)
if (!data || !currentWorkspace) return if (!data || !currentWorkspace) return
setCurrentWorkspace(workspaces[0]) const newWorkspaces = (workspaces ?? []).filter((w) =>
w.id === currentWorkspace.id
? { ...data.workspace, members: w.members }
: w
)
setCurrentWorkspace(newWorkspaces[0])
mutate({ mutate({
workspaces: (workspaces ?? []).filter((w) => workspaces: newWorkspaces,
w.id === currentWorkspace.id
? { ...data.workspace, members: w.members }
: w
),
}) })
} }

View File

@@ -31,6 +31,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
{ {
workspace: { members: { some: { userId: user.id } } }, workspace: { members: { some: { userId: user.id } } },
id: { in: typebotIds }, id: { in: typebotIds },
isArchived: { not: true },
}, },
{ {
id: { in: typebotIds }, id: { in: typebotIds },
@@ -39,6 +40,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id, userId: user.id,
}, },
}, },
isArchived: { not: true },
}, },
], ],
}, },
@@ -51,6 +53,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { where: {
OR: [ OR: [
{ {
isArchived: { not: true },
folderId, folderId,
workspace: { workspace: {
id: workspaceId, id: workspaceId,
@@ -63,6 +66,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}, },
}, },
{ {
isArchived: { not: true },
workspace: { workspace: {
id: workspaceId, id: workspaceId,
members: { members: {

View File

@@ -13,13 +13,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const typebotId = req.query.typebotId as string const typebotId = req.query.typebotId as string
if (req.method === 'GET') { if (req.method === 'GET') {
const typebot = await prisma.typebot.findFirst({ const typebot = await prisma.typebot.findFirst({
where: canReadTypebot(typebotId, user), where: {
...canReadTypebot(typebotId, user),
isArchived: { not: true },
},
include: { include: {
publishedTypebot: true, publishedTypebot: true,
collaborators: { select: { userId: true, type: true } }, collaborators: { select: { userId: true, type: true } },
webhooks: true, webhooks: true,
}, },
}) })
console.log(typebot)
if (!typebot) return res.send({ typebot: null }) if (!typebot) return res.send({ typebot: null })
const { publishedTypebot, collaborators, webhooks, ...restOfTypebot } = const { publishedTypebot, collaborators, webhooks, ...restOfTypebot } =
typebot typebot
@@ -35,8 +39,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
const typebots = await prisma.typebot.deleteMany({ const typebots = await prisma.typebot.updateMany({
where: canWriteTypebot(typebotId, user), where: canWriteTypebot(typebotId, user),
data: { isArchived: true },
}) })
await archiveResults(res)({ await archiveResults(res)({
typebotId, typebotId,

View File

@@ -2,7 +2,7 @@ import { withSentry } from '@sentry/nextjs'
import { Prisma, Workspace, WorkspaceRole } from 'db' import { Prisma, Workspace, WorkspaceRole } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { archiveResults, getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils/api' import { methodNotAllowed, notAuthenticated } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@@ -28,23 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
id, id,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
} }
const deletedTypebots = await prisma.typebot.findMany({
where: {
workspace: workspaceFilter,
},
})
await prisma.workspace.deleteMany({ await prisma.workspace.deleteMany({
where: workspaceFilter, where: workspaceFilter,
}) })
await Promise.all(
deletedTypebots.map((typebot) =>
archiveResults(res)({
typebotId: typebot.id,
user,
resultsFilter: { typebotId: typebot.id },
})
)
)
return res.status(200).json({ return res.status(200).json({
message: 'success', message: 'success',
}) })

View File

@@ -102,14 +102,16 @@ export const setupDatabase = async () => {
return setupCredentials() return setupCredentials()
} }
export const setupWorkspaces = async () => export const setupWorkspaces = async () => {
prisma.workspace.createMany({ await prisma.workspace.create({
data: {
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
})
await prisma.workspace.createMany({
data: [ data: [
{
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
{ {
id: starterWorkspaceId, id: starterWorkspaceId,
name: 'Starter workspace', name: 'Starter workspace',
@@ -128,6 +130,7 @@ export const setupWorkspaces = async () =>
}, },
], ],
}) })
}
export const createWorkspaces = async (workspaces: Partial<Workspace>[]) => { export const createWorkspaces = async (workspaces: Partial<Workspace>[]) => {
const workspaceIds = workspaces.map((workspace) => workspace.id ?? cuid()) const workspaceIds = workspaces.map((workspace) => workspace.id ?? cuid())
@@ -231,11 +234,15 @@ export const getSignedInUser = (email: string) =>
prisma.user.findFirst({ where: { email } }) prisma.user.findFirst({ where: { email } })
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => { export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
const typebotsWithId = partialTypebots.map((typebot) => ({
...typebot,
id: typebot.id ?? cuid(),
}))
await prisma.typebot.createMany({ await prisma.typebot.createMany({
data: partialTypebots.map(parseTestTypebot), data: typebotsWithId.map(parseTestTypebot),
}) })
return prisma.publicTypebot.createMany({ return prisma.publicTypebot.createMany({
data: partialTypebots.map((t) => data: typebotsWithId.map((t) =>
parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t)) parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t))
), ),
}) })
@@ -304,7 +311,7 @@ const parseTypebotToPublicTypebot = (
}) })
const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot', id: cuid(),
workspaceId: proWorkspaceId, workspaceId: proWorkspaceId,
folderId: null, folderId: null,
name: 'My typebot', name: 'My typebot',

View File

@@ -165,9 +165,13 @@ test('plan changes should work', async ({ page }) => {
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('[data-testid="pro-plan-tag"]')).toBeVisible() await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: ProCancel my subscription'
)
await page.click('button >> text="Cancel my subscription"') await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="free-plan-tag"]')).toBeVisible() await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: Free'
)
}) })
test('should display invoices', async ({ page }) => { test('should display invoices', async ({ page }) => {

View File

@@ -57,7 +57,7 @@ test.describe('Starter workspace', () => {
}, },
]) ])
await page.goto(`/typebots/${typebotId}/share`) await page.goto(`/typebots/${typebotId}/share`)
await expect(page.locator('text=Pro')).toBeVisible() await expect(page.locator('[data-testid="pro-lock-tag"]')).toBeVisible()
await page.click('text=Add my domain') await page.click('text=Add my domain')
await expect( await expect(
page.locator( page.locator(

View File

@@ -75,7 +75,7 @@ test.describe('Free user', () => {
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text="Pro workspace"') await page.click('text="Pro workspace"')
await page.click('text="Free workspace"') await page.click('text="Free workspace"')
await expect(page.locator('[data-testid="starter-plan-tag"]')).toBeVisible() await expect(page.locator('[data-testid="starter-lock-tag"]')).toBeVisible()
await page.click('text=Create a folder') await page.click('text=Create a folder')
await expect( await expect(
page.locator( page.locator(

View File

@@ -135,7 +135,9 @@ test.describe.parallel('Settings page', () => {
typebotViewer(page).locator('text="What\'s your name?"') typebotViewer(page).locator('text="What\'s your name?"')
).toBeVisible() ).toBeVisible()
await page.click('button:has-text("General")') await page.click('button:has-text("General")')
await expect(page.locator('text=Starter')).toBeVisible() await expect(
page.locator('[data-testid="starter-lock-tag"]')
).toBeVisible()
await page.click('text=Typebot.io branding') await page.click('text=Typebot.io branding')
await expect( await expect(
page.locator( page.locator(

View File

@@ -144,7 +144,7 @@ test('can manage members', async ({ page }) => {
test("can't add new members when limit is reached", async ({ page }) => { test("can't add new members when limit is reached", async ({ page }) => {
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text="Pro workspace"') await page.click('text="My awesome workspace"')
await page.click('text="Free workspace"') await page.click('text="Free workspace"')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text="Members"') await page.click('text="Members"')

View File

@@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "Result" DROP CONSTRAINT "Result_typebotId_fkey";
-- AlterTable
ALTER TABLE "Result" ALTER COLUMN "isArchived" SET DEFAULT false;
-- AlterTable
ALTER TABLE "Typebot" ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT false;
-- AddForeignKey
ALTER TABLE "Result" ADD CONSTRAINT "Result_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -66,23 +66,23 @@ model ApiToken {
} }
model Workspace { model Workspace {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
icon String? icon String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
plan Plan @default(FREE) plan Plan @default(FREE)
stripeId String? @unique stripeId String? @unique
credentials Credentials[] credentials Credentials[]
customDomains CustomDomain[] customDomains CustomDomain[]
folders DashboardFolder[] folders DashboardFolder[]
members MemberInWorkspace[] members MemberInWorkspace[]
typebots Typebot[] typebots Typebot[]
invitations WorkspaceInvitation[] invitations WorkspaceInvitation[]
additionalChatsIndex Int @default(0) additionalChatsIndex Int @default(0)
additionalStorageIndex Int @default(0) additionalStorageIndex Int @default(0)
chatsLimitFirstEmailSentAt DateTime? chatsLimitFirstEmailSentAt DateTime?
storageLimitFirstEmailSentAt DateTime? storageLimitFirstEmailSentAt DateTime?
chatsLimitSecondEmailSentAt DateTime? chatsLimitSecondEmailSentAt DateTime?
storageLimitSecondEmailSentAt DateTime? storageLimitSecondEmailSentAt DateTime?
} }
@@ -168,6 +168,7 @@ model Typebot {
publishedTypebot PublicTypebot? publishedTypebot PublicTypebot?
results Result[] results Result[]
webhooks Webhook[] webhooks Webhook[]
isArchived Boolean @default(false)
} }
model Invitation { model Invitation {
@@ -211,8 +212,8 @@ model Result {
variables Json[] variables Json[]
isCompleted Boolean isCompleted Boolean
hasStarted Boolean? hasStarted Boolean?
isArchived Boolean? isArchived Boolean? @default(false)
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: NoAction) typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
answers Answer[] answers Answer[]
logs Log[] logs Log[]
} }