From c08e0cdb0ac03d747f70f6983303a88ab9bbfdea Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 21 Aug 2023 15:32:27 +0200 Subject: [PATCH] :zap: (customDomains) Fix custom domain update feedback --- .../customDomains/api/createCustomDomain.ts | 85 ++++++++ .../customDomains/api/deleteCustomDomain.ts | 68 ++++++ .../customDomains/api/listCustomDomains.ts | 54 +++++ .../src/features/customDomains/api/router.ts | 10 + .../components/CustomDomainModal.tsx | 30 ++- .../components/CustomDomainsDropdown.tsx | 61 ++++-- .../customDomains/customDomains.spec.ts | 2 +- .../customDomains/hooks/useCustomDomains.ts | 26 --- .../queries/createCustomDomainQuery.ts | 15 -- .../queries/deleteCustomDomainQuery.ts | 14 -- .../features/publish/components/SharePage.tsx | 10 +- .../src/features/typebot/api/updateTypebot.ts | 11 +- .../helpers/isWriteWorkspaceForbidden copy.ts | 2 +- .../helpers/server/routers/v1/trpcRouter.ts | 2 + apps/builder/src/pages/api/customDomains.ts | 1 + apps/docs/openapi/builder/_spec_.json | 205 +++++++++++++++++- packages/embeds/react/package.json | 2 +- packages/schemas/features/customDomains.ts | 10 + packages/schemas/features/typebot/typebot.ts | 2 +- 19 files changed, 506 insertions(+), 104 deletions(-) create mode 100644 apps/builder/src/features/customDomains/api/createCustomDomain.ts create mode 100644 apps/builder/src/features/customDomains/api/deleteCustomDomain.ts create mode 100644 apps/builder/src/features/customDomains/api/listCustomDomains.ts create mode 100644 apps/builder/src/features/customDomains/api/router.ts delete mode 100644 apps/builder/src/features/customDomains/hooks/useCustomDomains.ts delete mode 100644 apps/builder/src/features/customDomains/queries/createCustomDomainQuery.ts delete mode 100644 apps/builder/src/features/customDomains/queries/deleteCustomDomainQuery.ts create mode 100644 packages/schemas/features/customDomains.ts diff --git a/apps/builder/src/features/customDomains/api/createCustomDomain.ts b/apps/builder/src/features/customDomains/api/createCustomDomain.ts new file mode 100644 index 000000000..6f79c1069 --- /dev/null +++ b/apps/builder/src/features/customDomains/api/createCustomDomain.ts @@ -0,0 +1,85 @@ +import prisma from '@/lib/prisma' +import { authenticatedProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { customDomainSchema } from '@typebot.io/schemas/features/customDomains' +import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' +import got, { HTTPError } from 'got' + +export const createCustomDomain = authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/custom-domains', + protect: true, + summary: 'Create custom domain', + tags: ['Custom domains'], + }, + }) + .input( + z.object({ + workspaceId: z.string(), + name: z.string(), + }) + ) + .output( + z.object({ + customDomain: customDomainSchema.pick({ + name: true, + createdAt: true, + }), + }) + ) + .mutation(async ({ input: { workspaceId, name }, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId }, + select: { + members: { + select: { + userId: true, + role: true, + }, + }, + }, + }) + + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' }) + + const existingCustomDomain = await prisma.customDomain.findFirst({ + where: { name }, + }) + + if (existingCustomDomain) + throw new TRPCError({ + code: 'CONFLICT', + message: 'Custom domain already registered', + }) + + try { + await createDomainOnVercel(name) + } catch (err) { + console.log(err) + if (err instanceof HTTPError && err.response.statusCode !== 409) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create custom domain on Vercel', + }) + } + + const customDomain = await prisma.customDomain.create({ + data: { + name, + workspaceId, + }, + }) + + return { customDomain } + }) + +const createDomainOnVercel = (name: string) => + got.post({ + url: `https://api.vercel.com/v10/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`, + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + json: { name }, + }) diff --git a/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts b/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts new file mode 100644 index 000000000..e9ce99813 --- /dev/null +++ b/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts @@ -0,0 +1,68 @@ +import prisma from '@/lib/prisma' +import { authenticatedProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' +import got from 'got' + +export const deleteCustomDomain = authenticatedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/custom-domains', + protect: true, + summary: 'Delete custom domain', + tags: ['Custom domains'], + }, + }) + .input( + z.object({ + workspaceId: z.string(), + name: z.string(), + }) + ) + .output( + z.object({ + message: z.literal('success'), + }) + ) + .mutation(async ({ input: { workspaceId, name }, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId }, + select: { + members: { + select: { + userId: true, + role: true, + }, + }, + }, + }) + + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' }) + + try { + await deleteDomainOnVercel(name) + } catch (error) { + console.error(error) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to delete domain on Vercel', + }) + } + await prisma.customDomain.deleteMany({ + where: { + name, + workspaceId, + }, + }) + + return { message: 'success' } + }) + +const deleteDomainOnVercel = (name: string) => + got.delete({ + url: `https://api.vercel.com/v9/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + }) diff --git a/apps/builder/src/features/customDomains/api/listCustomDomains.ts b/apps/builder/src/features/customDomains/api/listCustomDomains.ts new file mode 100644 index 000000000..94a25feaa --- /dev/null +++ b/apps/builder/src/features/customDomains/api/listCustomDomains.ts @@ -0,0 +1,54 @@ +import prisma from '@/lib/prisma' +import { authenticatedProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' +import { customDomainSchema } from '@typebot.io/schemas/features/customDomains' + +export const listCustomDomains = authenticatedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/custom-domains', + protect: true, + summary: 'List custom domains', + tags: ['Custom domains'], + }, + }) + .input( + z.object({ + workspaceId: z.string(), + }) + ) + .output( + z.object({ + customDomains: z.array( + customDomainSchema.pick({ + name: true, + createdAt: true, + }) + ), + }) + ) + .query(async ({ input: { workspaceId }, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId }, + select: { + members: { + select: { + userId: true, + }, + }, + customDomains: true, + }, + }) + + if (!workspace || isReadWorkspaceFobidden(workspace, user)) + throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' }) + + const descSortedCustomDomains = workspace.customDomains.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ) + + return { customDomains: descSortedCustomDomains } + }) diff --git a/apps/builder/src/features/customDomains/api/router.ts b/apps/builder/src/features/customDomains/api/router.ts new file mode 100644 index 000000000..c0133187e --- /dev/null +++ b/apps/builder/src/features/customDomains/api/router.ts @@ -0,0 +1,10 @@ +import { router } from '@/helpers/server/trpc' +import { createCustomDomain } from './createCustomDomain' +import { deleteCustomDomain } from './deleteCustomDomain' +import { listCustomDomains } from './listCustomDomains' + +export const customDomainsRouter = router({ + createCustomDomain, + deleteCustomDomain, + listCustomDomains, +}) diff --git a/apps/builder/src/features/customDomains/components/CustomDomainModal.tsx b/apps/builder/src/features/customDomains/components/CustomDomainModal.tsx index 1cdf0b21f..7ee52a800 100644 --- a/apps/builder/src/features/customDomains/components/CustomDomainModal.tsx +++ b/apps/builder/src/features/customDomains/components/CustomDomainModal.tsx @@ -17,7 +17,7 @@ import { } from '@chakra-ui/react' import { useToast } from '@/hooks/useToast' import { useEffect, useRef, useState } from 'react' -import { createCustomDomainQuery } from '../queries/createCustomDomainQuery' +import { trpc } from '@/lib/trpc' 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])$/ @@ -46,6 +46,24 @@ export const CustomDomainModal = ({ }) const { showToast } = useToast() + const { mutate } = trpc.customDomains.createCustomDomain.useMutation({ + onMutate: () => { + setIsLoading(true) + }, + onError: (error) => { + showToast({ + title: 'Error while creating custom domain', + description: error.message, + }) + }, + onSettled: () => { + setIsLoading(false) + }, + onSuccess: (data) => { + onNewDomain(data.customDomain.name) + onClose() + }, + }) useEffect(() => { if (inputValue === '' || !isOpen) return @@ -62,15 +80,7 @@ export const CustomDomainModal = ({ const onAddDomainClick = async () => { if (!hostnameRegex.test(inputValue)) return - setIsLoading(true) - const { error } = await createCustomDomainQuery(workspaceId, { - name: inputValue, - }) - setIsLoading(false) - if (error) - return showToast({ title: error.name, description: error.message }) - onNewDomain(inputValue) - onClose() + mutate({ name: inputValue, workspaceId }) } return ( & { currentCustomDomain?: string @@ -32,10 +31,36 @@ export const CustomDomainsDropdown = ({ const { isOpen, onOpen, onClose } = useDisclosure() const { workspace } = useWorkspace() const { showToast } = useToast() - const { customDomains, mutate } = useCustomDomains({ - workspaceId: workspace?.id, - onError: (error) => - showToast({ title: error.name, description: error.message }), + const { data, refetch } = trpc.customDomains.listCustomDomains.useQuery( + { + workspaceId: workspace?.id as string, + }, + { + enabled: !!workspace?.id, + onError: (error) => { + showToast({ + title: 'Error while fetching custom domains', + description: error.message, + }) + }, + } + ) + const { mutate } = trpc.customDomains.deleteCustomDomain.useMutation({ + onMutate: ({ name }) => { + setIsDeleting(name) + }, + onError: (error) => { + showToast({ + title: 'Error while deleting custom domain', + description: error.message, + }) + }, + onSettled: () => { + setIsDeleting('') + }, + onSuccess: () => { + refetch() + }, }) const handleMenuItemClick = (customDomain: string) => () => @@ -45,27 +70,14 @@ export const CustomDomainsDropdown = ({ (domainName: string) => async (e: React.MouseEvent) => { if (!workspace) return e.stopPropagation() - setIsDeleting(domainName) - const { error } = await deleteCustomDomainQuery(workspace.id, domainName) - setIsDeleting('') - if (error) - return showToast({ title: error.name, description: error.message }) mutate({ - customDomains: (customDomains ?? []).filter( - (cd) => cd.name !== domainName - ), + name: domainName, + workspaceId: workspace.id, }) } - const handleNewDomain = (domain: string) => { - if (!workspace) return - mutate({ - customDomains: [ - ...(customDomains ?? []), - { name: domain, workspaceId: workspace?.id }, - ], - }) - handleMenuItemClick(domain)() + const handleNewDomain = (name: string) => { + onCustomDomainSelect(name) } return ( @@ -92,7 +104,7 @@ export const CustomDomainsDropdown = ({ - {(customDomains ?? []).map((customDomain) => ( + {(data?.customDomains ?? []).map((customDomain) => (