From f3ecb948a1cc863b93b499bed8f87a5d1d483962 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 18 Feb 2022 14:57:10 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Add=20custom=20domains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/builder/.env.local.example | 5 + apps/builder/components/share/EditableUrl.tsx | 21 +- .../builder/components/share/ShareContent.tsx | 71 ++++++- .../share/customDomain/CustomDomainModal.tsx | 189 ++++++++++++++++++ .../customDomain/CustomDomainsDropdown.tsx | 133 ++++++++++++ .../shared/buttons/UpgradeButton.tsx | 2 +- .../TypebotContext/TypebotContext.tsx | 37 ++-- apps/builder/contexts/UserContext.tsx | 18 +- .../pages/api/users/[id]/customDomains.ts | 51 +++++ .../api/users/[id]/customDomains/[domain].ts | 35 ++++ apps/builder/playwright/services/database.ts | 2 + .../playwright/tests/customDomains.spec.ts | 63 ++++++ apps/builder/services/customDomains.ts | 47 +++++ apps/builder/services/publicTypebot.tsx | 1 + apps/builder/services/typebots.ts | 7 +- .../{[publicId].tsx => [[...publicId]].tsx} | 19 +- .../migration.sql | 30 +++ packages/db/prisma/schema.prisma | 29 ++- 18 files changed, 694 insertions(+), 66 deletions(-) create mode 100644 apps/builder/components/share/customDomain/CustomDomainModal.tsx create mode 100644 apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx create mode 100644 apps/builder/pages/api/users/[id]/customDomains.ts create mode 100644 apps/builder/pages/api/users/[id]/customDomains/[domain].ts create mode 100644 apps/builder/playwright/tests/customDomains.spec.ts create mode 100644 apps/builder/services/customDomains.ts rename apps/viewer/pages/{[publicId].tsx => [[...publicId]].tsx} (67%) create mode 100644 packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql diff --git a/apps/builder/.env.local.example b/apps/builder/.env.local.example index 6971e98ef..c35465d98 100644 --- a/apps/builder/.env.local.example +++ b/apps/builder/.env.local.example @@ -49,3 +49,8 @@ NEXT_PUBLIC_VIEWER_HOST=http://localhost:3001 # (Optional) Error tracking with Sentry NEXT_PUBLIC_SENTRY_DSN= + +# Vercel +VERCEL_TOKEN= +VERCEL_VIEWER_PROJECT_NAME= +VERCEL_TEAM_ID= \ No newline at end of file diff --git a/apps/builder/components/share/EditableUrl.tsx b/apps/builder/components/share/EditableUrl.tsx index 24e040d92..b72a6837e 100644 --- a/apps/builder/components/share/EditableUrl.tsx +++ b/apps/builder/components/share/EditableUrl.tsx @@ -14,23 +14,25 @@ import { CopyButton } from 'components/shared/buttons/CopyButton' import React from 'react' type EditableUrlProps = { - publicId?: string - onPublicIdChange: (publicId: string) => void + hostname: string + pathname?: string + onPathnameChange: (pathname: string) => void } export const EditableUrl = ({ - publicId, - onPublicIdChange, + hostname, + pathname, + onPathnameChange, }: EditableUrlProps) => { return ( - {process.env.NEXT_PUBLIC_VIEWER_HOST}/ + {hostname}/ - + ) diff --git a/apps/builder/components/share/ShareContent.tsx b/apps/builder/components/share/ShareContent.tsx index 15da97c27..938aa047c 100644 --- a/apps/builder/components/share/ShareContent.tsx +++ b/apps/builder/components/share/ShareContent.tsx @@ -1,16 +1,38 @@ -import { Flex, Heading, Stack, Wrap } from '@chakra-ui/react' +import { + Flex, + Heading, + HStack, + IconButton, + Stack, + Tag, + useToast, + Wrap, + Text, +} from '@chakra-ui/react' +import { TrashIcon } from 'assets/icons' +import { UpgradeButton } from 'components/shared/buttons/UpgradeButton' import { useTypebot } from 'contexts/TypebotContext/TypebotContext' +import { useUser } from 'contexts/UserContext' import React from 'react' import { parseDefaultPublicId } from 'services/typebots' -import { isDefined } from 'utils' +import { isFreePlan } from 'services/user' +import { isDefined, isNotDefined } from 'utils' +import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown' import { EditableUrl } from './EditableUrl' import { integrationsList } from './integrations/EmbedButton' export const ShareContent = () => { + const { user } = useUser() const { typebot, updateOnBothTypebots } = useTypebot() + const toast = useToast({ + position: 'top-right', + status: 'error', + }) const handlePublicIdChange = (publicId: string) => { if (publicId === typebot?.publicId) return + if (publicId.length < 4) + return toast({ description: 'ID must be longer than 4 characters' }) updateOnBothTypebots({ publicId }) } @@ -19,19 +41,58 @@ export const ShareContent = () => { : '' const isPublished = isDefined(typebot?.publishedTypebotId) + const handleCustomDomainChange = (customDomain: string | null) => + updateOnBothTypebots({ customDomain }) + return ( - + Your typebot link {typebot && ( )} + {typebot?.customDomain && ( + + + handleCustomDomainChange( + typebot.customDomain?.split('/')[0] + pathname + ) + } + /> + } + aria-label="Remove custom domain" + size="xs" + onClick={() => handleCustomDomainChange(null)} + /> + + )} + {isFreePlan(user) ? ( + + Add my domain{' '} + Pro + + ) : ( + <> + {isNotDefined(typebot?.customDomain) && ( + + )} + + )} diff --git a/apps/builder/components/share/customDomain/CustomDomainModal.tsx b/apps/builder/components/share/customDomain/CustomDomainModal.tsx new file mode 100644 index 000000000..b794bab6e --- /dev/null +++ b/apps/builder/components/share/customDomain/CustomDomainModal.tsx @@ -0,0 +1,189 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + Heading, + ModalCloseButton, + ModalBody, + Stack, + Input, + HStack, + Alert, + ModalFooter, + Button, + useToast, + Text, + Tooltip, +} from '@chakra-ui/react' +import { useEffect, useRef, useState } from 'react' +import { createCustomDomain } from 'services/customDomains' + +const hostnameRegex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/ + +type CustomDomainModalProps = { + userId: string + isOpen: boolean + onClose: () => void + domain?: string + onNewDomain: (customDomain: string) => void +} + +export const CustomDomainModal = ({ + userId, + isOpen, + onClose, + onNewDomain, + domain = '', +}: CustomDomainModalProps) => { + const inputRef = useRef(null) + const [isLoading, setIsLoading] = useState(false) + const [inputValue, setInputValue] = useState(domain) + const [hostname, setHostname] = useState({ + domain: splitHostname(domain)?.domain ?? '', + subdomain: splitHostname(domain)?.subdomain ?? '', + }) + + const toast = useToast({ + position: 'top-right', + status: 'error', + description: 'An error occured', + }) + + useEffect(() => { + if (inputValue === '' || !isOpen) return + if (!hostnameRegex.test(inputValue)) + return setHostname({ domain: '', subdomain: '' }) + const hostnameDetails = splitHostname(inputValue) + if (!hostnameDetails) return setHostname({ domain: '', subdomain: '' }) + setHostname({ + domain: hostnameDetails.domain, + subdomain: hostnameDetails.subdomain, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue]) + + const onAddDomainClick = async () => { + if (!hostnameRegex.test(inputValue)) return + setIsLoading(true) + const { error } = await createCustomDomain(userId, { + name: inputValue, + }) + setIsLoading(false) + if (error) return toast({ title: error.name, description: error.message }) + onNewDomain(inputValue) + onClose() + } + return ( + + + + + Add a custom domain + + + + + setInputValue(e.target.value)} + placeholder="bot.my-domain.com" + /> + {hostname.domain !== '' && ( + <> + + Add the following record in your DNS provider to continue: + + {hostname.subdomain ? ( + + + Type + CNAME + + + Name + {hostname.subdomain} + + + Value + viewer.typebot.io + + + ) : ( + + + Type + A + + + Name + * + + + Value + 76.76.21.21 + + + )} + + Depending on your provider, it might take some time for the + changes to apply + + + )} + + + + + + + + + + + + ) +} + +const splitHostname = ( + hostname: string +): { domain: string; type: string; subdomain: string } | undefined => { + const urlParts = /([a-z-0-9]{2,63}).([a-z.]{2,5})$/.exec(hostname) + if (!urlParts) return + const [, domain, type] = urlParts + const subdomain = hostname.replace(`${domain}.${type}`, '').slice(0, -1) + return { + domain, + type, + subdomain, + } +} diff --git a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx new file mode 100644 index 000000000..da6c34159 --- /dev/null +++ b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx @@ -0,0 +1,133 @@ +import { + Button, + IconButton, + Menu, + MenuButton, + MenuButtonProps, + MenuItem, + MenuList, + Stack, + Text, + useDisclosure, + useToast, +} from '@chakra-ui/react' +import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons' +import React, { useState } from 'react' +import { useUser } from 'contexts/UserContext' +import { CustomDomainModal } from './CustomDomainModal' +import { deleteCustomDomain, useCustomDomains } from 'services/customDomains' + +type Props = Omit & { + currentCustomDomain?: string + onCustomDomainSelect: (domain: string) => void +} + +export const CustomDomainsDropdown = ({ + currentCustomDomain, + onCustomDomainSelect, + ...props +}: Props) => { + const [isDeleting, setIsDeleting] = useState('') + const { isOpen, onOpen, onClose } = useDisclosure() + const { user } = useUser() + const { customDomains, mutate } = useCustomDomains({ + userId: user?.id, + onError: (error) => + toast({ title: error.name, description: error.message }), + }) + const toast = useToast({ + position: 'top-right', + status: 'error', + }) + + const handleMenuItemClick = (customDomain: string) => () => + onCustomDomainSelect(customDomain) + + const handleDeleteDomainClick = + (domainName: string) => async (e: React.MouseEvent) => { + if (!user) return + e.stopPropagation() + setIsDeleting(domainName) + const { error } = await deleteCustomDomain(user.id, domainName) + setIsDeleting('') + if (error) return toast({ title: error.name, description: error.message }) + mutate({ + customDomains: (customDomains ?? []).filter( + (cd) => cd.name !== domainName + ), + }) + } + + const handleNewDomain = (domain: string) => { + if (!user) return + mutate({ + customDomains: [ + ...(customDomains ?? []), + { name: domain, ownerId: user?.id }, + ], + }) + handleMenuItemClick(domain)() + } + + return ( + + {user?.id && ( + + )} + } + colorScheme="gray" + justifyContent="space-between" + textAlign="left" + {...props} + > + + {currentCustomDomain ?? 'Add my domain'} + + + + + {(customDomains ?? []).map((customDomain) => ( + + ))} + } + onClick={onOpen} + > + Connect new + + + + + ) +} diff --git a/apps/builder/components/shared/buttons/UpgradeButton.tsx b/apps/builder/components/shared/buttons/UpgradeButton.tsx index 0307b08f8..12f67f82d 100644 --- a/apps/builder/components/shared/buttons/UpgradeButton.tsx +++ b/apps/builder/components/shared/buttons/UpgradeButton.tsx @@ -9,7 +9,7 @@ export const UpgradeButton = ({ type, ...props }: Props) => { const { isOpen, onOpen, onClose } = useDisclosure() return ( ) diff --git a/apps/builder/contexts/TypebotContext/TypebotContext.tsx b/apps/builder/contexts/TypebotContext/TypebotContext.tsx index e0267f50d..42de77542 100644 --- a/apps/builder/contexts/TypebotContext/TypebotContext.tsx +++ b/apps/builder/contexts/TypebotContext/TypebotContext.tsx @@ -60,6 +60,7 @@ const typebotContext = createContext< updateOnBothTypebots: (updates: { publicId?: string name?: string + customDomain?: string | null }) => void publishTypebot: () => void } & BlocksActions & @@ -144,8 +145,6 @@ export const TypebotContext = ({ debounceTimeout: autoSaveTimeout, }) - const [localPublishedTypebot, setLocalPublishedTypebot] = - useState() const [isSavingLoading, setIsSavingLoading] = useState(false) const [isPublishing, setIsPublishing] = useState(false) @@ -177,7 +176,6 @@ export const TypebotContext = ({ return } setLocalTypebot({ ...typebot }) - if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading]) @@ -206,25 +204,15 @@ export const TypebotContext = ({ const updateLocalTypebot = (updates: UpdateTypebotPayload) => localTypebot && setLocalTypebot({ ...localTypebot, ...updates }) - const updateLocalPublishedTypebot = (updates: UpdateTypebotPayload) => - publishedTypebot && - setLocalPublishedTypebot({ - ...localPublishedTypebot, - ...(updates as PublicTypebot), - }) - const publishTypebot = async () => { if (!localTypebot) return const publishedTypebotId = generate() const newLocalTypebot = { ...localTypebot } - if ( - localPublishedTypebot && - isNotDefined(localTypebot.publishedTypebotId) - ) { - updateLocalTypebot({ publishedTypebotId: localPublishedTypebot.id }) + if (publishedTypebot && isNotDefined(localTypebot.publishedTypebotId)) { + updateLocalTypebot({ publishedTypebotId: publishedTypebot.id }) await saveTypebot() } - if (!localPublishedTypebot) { + if (!publishedTypebot) { const newPublicId = parseDefaultPublicId( localTypebot.name, localTypebot.id @@ -233,10 +221,10 @@ export const TypebotContext = ({ newLocalTypebot.publicId = newPublicId await saveTypebot() } - if (localPublishedTypebot) { + if (publishedTypebot) { await savePublishedTypebot({ ...parseTypebotToPublicTypebot(newLocalTypebot), - id: localPublishedTypebot.id, + id: publishedTypebot.id, }) } else { setIsPublishing(true) @@ -244,24 +232,23 @@ export const TypebotContext = ({ ...parseTypebotToPublicTypebot(newLocalTypebot), id: publishedTypebotId, }) - setLocalPublishedTypebot(data) setIsPublishing(false) if (error) return toast({ title: error.name, description: error.message }) - mutate({ typebot: localTypebot }) + mutate({ typebot: localTypebot, publishedTypebot: data }) } } const updateOnBothTypebots = async (updates: { publicId?: string name?: string + customDomain?: string | null }) => { updateLocalTypebot(updates) await saveTypebot() - if (!localPublishedTypebot) return - updateLocalPublishedTypebot(updates) + if (!publishedTypebot) return await savePublishedTypebot({ - ...localPublishedTypebot, - ...(updates as PublicTypebot), + ...publishedTypebot, + ...updates, }) } @@ -269,7 +256,7 @@ export const TypebotContext = ({ { const router = useRouter() const { data: session, status } = useSession() const [user, setUser] = useState() - const { credentials, mutate } = useCredentials({ + const toast = useToast({ + position: 'top-right', + status: 'error', + }) + const { credentials, mutate: mutateCredentials } = useCredentials({ userId: user?.id, onError: (error) => toast({ title: error.name, description: error.message }), }) + const [isSaving, setIsSaving] = useState(false) const isOAuthProvider = useMemo( () => (session?.providerType as boolean | undefined) ?? false, @@ -53,11 +58,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => { [session?.user, user] ) - const toast = useToast({ - position: 'top-right', - status: 'error', - }) - useEffect(() => { if (isDefined(user) || isNotDefined(session)) return setUser(session.user as User) @@ -92,14 +92,14 @@ export const UserContext = ({ children }: { children: ReactNode }) => { {children} diff --git a/apps/builder/pages/api/users/[id]/customDomains.ts b/apps/builder/pages/api/users/[id]/customDomains.ts new file mode 100644 index 000000000..3c5704dc3 --- /dev/null +++ b/apps/builder/pages/api/users/[id]/customDomains.ts @@ -0,0 +1,51 @@ +import { withSentry } from '@sentry/nextjs' +import { CustomDomain, Prisma, User } from 'db' +import { got, HTTPError } from 'got' +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getSession } from 'next-auth/react' +import { methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getSession({ req }) + + if (!session?.user) + return res.status(401).json({ message: 'Not authenticated' }) + + const user = session.user as User + const id = req.query.id.toString() + if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + if (req.method === 'GET') { + const customDomains = await prisma.customDomain.findMany({ + where: { ownerId: user.id }, + }) + return res.send({ customDomains }) + } + if (req.method === 'POST') { + const data = JSON.parse(req.body) as Omit + try { + await createDomainOnVercel(data.name) + } catch (err) { + if (err instanceof HTTPError && err.response.statusCode !== 409) + return res.status(err.response.statusCode).send(err.response.body) + } + + const customDomains = await prisma.customDomain.create({ + data: { + ...data, + ownerId: user.id, + } as Prisma.CustomDomainUncheckedCreateInput, + }) + return res.send({ customDomains }) + } + return methodNotAllowed(res) +} + +const createDomainOnVercel = (name: string) => + got.post({ + url: `https://api.vercel.com/v8/projects/${process.env.VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`, + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + json: { name }, + }) + +export default withSentry(handler) diff --git a/apps/builder/pages/api/users/[id]/customDomains/[domain].ts b/apps/builder/pages/api/users/[id]/customDomains/[domain].ts new file mode 100644 index 000000000..93d962138 --- /dev/null +++ b/apps/builder/pages/api/users/[id]/customDomains/[domain].ts @@ -0,0 +1,35 @@ +import { withSentry } from '@sentry/nextjs' +import { User } from 'db' +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getSession } from 'next-auth/react' +import { methodNotAllowed } from 'utils' +import { got } from 'got' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getSession({ req }) + + if (!session?.user) + return res.status(401).json({ message: 'Not authenticated' }) + + const user = session.user as User + const id = req.query.id.toString() + if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + if (req.method === 'DELETE') { + const domain = req.query.domain.toString() + await deleteDomainOnVercel(domain) + const customDomains = await prisma.customDomain.delete({ + where: { name: domain }, + }) + return res.send({ customDomains }) + } + return methodNotAllowed(res) +} + +const deleteDomainOnVercel = (name: string) => + got.delete({ + url: `https://api.vercel.com/v8/projects/${process.env.VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + }) + +export default withSentry(handler) diff --git a/apps/builder/playwright/services/database.ts b/apps/builder/playwright/services/database.ts index 53ff87b34..404a48f05 100644 --- a/apps/builder/playwright/services/database.ts +++ b/apps/builder/playwright/services/database.ts @@ -139,6 +139,7 @@ const parseTypebotToPublicTypebot = ( publicId: typebot.publicId, variables: typebot.variables, edges: typebot.edges, + customDomain: null, }) const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] => @@ -160,6 +161,7 @@ const parseTestTypebot = (partialTypebot: Partial): Typebot => ({ publicId: null, publishedTypebotId: null, updatedAt: new Date(), + customDomain: null, variables: [{ id: 'var1', name: 'var1' }], ...partialTypebot, edges: [ diff --git a/apps/builder/playwright/tests/customDomains.spec.ts b/apps/builder/playwright/tests/customDomains.spec.ts new file mode 100644 index 000000000..e083b5e39 --- /dev/null +++ b/apps/builder/playwright/tests/customDomains.spec.ts @@ -0,0 +1,63 @@ +import test, { expect } from '@playwright/test' +import { InputStepType, defaultTextInputOptions } from 'models' +import { createTypebots, parseDefaultBlockWithStep } from '../services/database' +import { generate } from 'short-uuid' +import path from 'path' + +const typebotId = generate() +test.describe('Dashboard page', () => { + test('folders navigation should work', async ({ page }) => { + await createTypebots([ + { + id: typebotId, + ...parseDefaultBlockWithStep({ + type: InputStepType.TEXT, + options: defaultTextInputOptions, + }), + }, + ]) + + await page.goto(`/typebots/${typebotId}/share`) + await page.click('text=Add my domain') + await page.click('text=Connect new') + await page.fill('input[placeholder="bot.my-domain.com"]', 'test') + await expect(page.locator('text=Save')).toBeDisabled() + await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com') + await expect(page.locator('text="A"')).toBeVisible() + await page.fill( + 'input[placeholder="bot.my-domain.com"]', + 'sub.yolozeeer.com' + ) + await expect(page.locator('text="CNAME"')).toBeVisible() + await page.click('text=Save') + await expect( + page.locator('text="https://sub.yolozeeer.com/"') + ).toBeVisible() + await page.click('text="Edit" >> nth=1') + await page.fill( + 'text=https://sub.yolozeeer.com/Copy >> input', + 'custom-path' + ) + await page.press( + 'text=https://sub.yolozeeer.com/custom-path >> input', + 'Enter' + ) + await expect(page.locator('text="custom-path"')).toBeVisible() + await page.click('[aria-label="Remove custom domain"]') + await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden() + await page.click('button >> text=Add my domain') + await page.click('[aria-label="Remove domain"]') + await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden() + }) + + test.describe('Free user', () => { + test.use({ + storageState: path.join(__dirname, '../freeUser.json'), + }) + test("create folder shouldn't be available", async ({ page }) => { + await page.goto(`/typebots/${typebotId}/share`) + await page.click('text=Add my domain') + await expect(page.locator('text=Upgrade now')).toBeVisible() + }) + }) +}) diff --git a/apps/builder/services/customDomains.ts b/apps/builder/services/customDomains.ts new file mode 100644 index 000000000..dfb73a3cf --- /dev/null +++ b/apps/builder/services/customDomains.ts @@ -0,0 +1,47 @@ +import { CustomDomain } from 'db' +import { Credentials } from 'models' +import useSWR from 'swr' +import { sendRequest } from 'utils' +import { fetcher } from './utils' + +export const useCustomDomains = ({ + userId, + onError, +}: { + userId?: string + onError: (error: Error) => void +}) => { + const { data, error, mutate } = useSWR< + { customDomains: CustomDomain[] }, + Error + >(userId ? `/api/users/${userId}/customDomains` : null, fetcher) + if (error) onError(error) + return { + customDomains: data?.customDomains, + isLoading: !error && !data, + mutate, + } +} + +export const createCustomDomain = async ( + userId: string, + customDomain: Omit +) => + sendRequest<{ + credentials: Credentials + }>({ + url: `/api/users/${userId}/customDomains`, + method: 'POST', + body: customDomain, + }) + +export const deleteCustomDomain = async ( + userId: string, + customDomain: string +) => + sendRequest<{ + credentials: Credentials + }>({ + url: `/api/users/${userId}/customDomains/${customDomain}`, + method: 'DELETE', + }) diff --git a/apps/builder/services/publicTypebot.tsx b/apps/builder/services/publicTypebot.tsx index 0031c693d..028675ea6 100644 --- a/apps/builder/services/publicTypebot.tsx +++ b/apps/builder/services/publicTypebot.tsx @@ -18,6 +18,7 @@ export const parseTypebotToPublicTypebot = ( settings: typebot.settings, theme: typebot.theme, variables: typebot.variables, + customDomain: typebot.customDomain, }) export const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] => diff --git a/apps/builder/services/typebots.ts b/apps/builder/services/typebots.ts index f45db6149..bd2318dde 100644 --- a/apps/builder/services/typebots.ts +++ b/apps/builder/services/typebots.ts @@ -241,7 +241,12 @@ export const parseNewTypebot = ({ name: string }): Omit< Typebot, - 'createdAt' | 'updatedAt' | 'id' | 'publishedTypebotId' | 'publicId' + | 'createdAt' + | 'updatedAt' + | 'id' + | 'publishedTypebotId' + | 'publicId' + | 'customDomain' > => { const startBlockId = shortId.generate() const startStepId = shortId.generate() diff --git a/apps/viewer/pages/[publicId].tsx b/apps/viewer/pages/[[...publicId]].tsx similarity index 67% rename from apps/viewer/pages/[publicId].tsx rename to apps/viewer/pages/[[...publicId]].tsx index 46a7892c6..9053ff219 100644 --- a/apps/viewer/pages/[publicId].tsx +++ b/apps/viewer/pages/[[...publicId]].tsx @@ -12,7 +12,13 @@ export const getServerSideProps: GetServerSideProps = async ( const pathname = context.resolvedUrl.split('?')[0] try { if (!context.req.headers.host) return { props: {} } - typebot = await getTypebotFromPublicId(context.query.publicId?.toString()) + typebot = context.req.headers.host.includes( + (process.env.NEXT_PUBLIC_VIEWER_HOST ?? '').split('//')[1] + ) + ? await getTypebotFromPublicId(context.query.publicId?.toString()) + : await getTypebotFromCustomDomain( + `${context.req.headers.host}${pathname}` + ) return { props: { typebot, @@ -31,9 +37,7 @@ export const getServerSideProps: GetServerSideProps = async ( } } -const getTypebotFromPublicId = async ( - publicId?: string -): Promise => { +const getTypebotFromPublicId = async (publicId?: string) => { if (!publicId) return null const typebot = await prisma.publicTypebot.findUnique({ where: { publicId }, @@ -41,6 +45,13 @@ const getTypebotFromPublicId = async ( return (typebot as unknown as PublicTypebot) ?? null } +const getTypebotFromCustomDomain = async (customDomain: string) => { + const typebot = await prisma.publicTypebot.findUnique({ + where: { customDomain }, + }) + return (typebot as unknown as PublicTypebot) ?? null +} + const App = ({ typebot, ...props }: TypebotPageProps) => typebot ? : diff --git a/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql b/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql new file mode 100644 index 000000000..85d2d89f1 --- /dev/null +++ b/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - A unique constraint covering the columns `[customDomain]` on the table `PublicTypebot` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[customDomain]` on the table `Typebot` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "PublicTypebot" ADD COLUMN "customDomain" TEXT; + +-- AlterTable +ALTER TABLE "Typebot" ADD COLUMN "customDomain" TEXT; + +-- CreateTable +CREATE TABLE "CustomDomain" ( + "ownerId" TEXT NOT NULL, + "name" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "CustomDomain_name_key" ON "CustomDomain"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicTypebot_customDomain_key" ON "PublicTypebot"("customDomain"); + +-- CreateIndex +CREATE UNIQUE INDEX "Typebot_customDomain_key" ON "Typebot"("customDomain"); + +-- AddForeignKey +ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e5f9cf4c6..0dd72264a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -50,6 +50,13 @@ model User { plan Plan @default(FREE) stripeId String? @unique credentials Credentials[] + customDomains CustomDomain[] +} + +model CustomDomain { + ownerId String + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + name String @unique } model Credentials { @@ -109,19 +116,21 @@ model Typebot { theme Json settings Json publicId String? @unique + customDomain String? @unique } model PublicTypebot { - id String @id @default(cuid()) - typebotId String @unique - typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) - name String - blocks Json[] - variables Json[] - edges Json[] - theme Json - settings Json - publicId String? @unique + id String @id @default(cuid()) + typebotId String @unique + typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) + name String + blocks Json[] + variables Json[] + edges Json[] + theme Json + settings Json + publicId String? @unique + customDomain String? @unique } model Result {