Add OpenAI block

Also migrate credentials to tRPC

Closes #253
This commit is contained in:
Baptiste Arnaud
2023-03-09 08:46:36 +01:00
parent 97cfdfe79f
commit ff04edf139
86 changed files with 2583 additions and 1055 deletions

View File

@@ -0,0 +1,66 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { stripeCredentialsSchema } from 'models/features/blocks/inputs/payment/schemas'
import { googleSheetsCredentialsSchema } from 'models/features/blocks/integrations/googleSheets/schemas'
import { openAICredentialsSchema } from 'models/features/blocks/integrations/openai'
import { smtpCredentialsSchema } from 'models/features/blocks/integrations/sendEmail'
import { encrypt } from 'utils/api/encryption'
import { z } from 'zod'
const inputShape = {
data: true,
type: true,
workspaceId: true,
name: true,
} as const
export const createCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/credentials',
protect: true,
summary: 'Create credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
credentials: z.discriminatedUnion('type', [
stripeCredentialsSchema.pick(inputShape),
smtpCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape),
]),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(async ({ input: { credentials }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: credentials.workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const { encryptedData, iv } = encrypt(credentials.data)
const createdCredentials = await prisma.credentials.create({
data: {
...credentials,
data: encryptedData,
iv,
},
select: {
id: true,
},
})
return { credentialsId: createdCredentials.id }
})

View File

@@ -0,0 +1,49 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const deleteCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/credentials/:credentialsId',
protect: true,
summary: 'Delete credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
credentialsId: z.string(),
workspaceId: z.string(),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(
async ({ input: { credentialsId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
await prisma.credentials.delete({
where: {
id: credentialsId,
},
})
return { credentialsId }
}
)

View File

@@ -0,0 +1,55 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { stripeCredentialsSchema } from 'models/features/blocks/inputs/payment/schemas'
import { googleSheetsCredentialsSchema } from 'models/features/blocks/integrations/googleSheets/schemas'
import { openAICredentialsSchema } from 'models/features/blocks/integrations/openai'
import { smtpCredentialsSchema } from 'models/features/blocks/integrations/sendEmail'
import { z } from 'zod'
export const listCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/credentials',
protect: true,
summary: 'List workspace credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
workspaceId: z.string(),
type: stripeCredentialsSchema.shape.type
.or(smtpCredentialsSchema.shape.type)
.or(googleSheetsCredentialsSchema.shape.type)
.or(openAICredentialsSchema.shape.type),
})
)
.output(
z.object({
credentials: z.array(z.object({ id: z.string(), name: z.string() })),
})
)
.query(async ({ input: { workspaceId, type }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const credentials = await prisma.credentials.findMany({
where: {
type,
workspaceId,
},
select: {
id: true,
name: true,
},
})
return { credentials }
})

View File

@@ -0,0 +1,10 @@
import { router } from '@/utils/server/trpc'
import { createCredentials } from './createCredentials'
import { deleteCredentials } from './deleteCredentials'
import { listCredentials } from './listCredentials'
export const credentialsRouter = router({
createCredentials,
listCredentials,
deleteCredentials,
})

View File

@@ -10,58 +10,73 @@ import {
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '../../../hooks/useToast'
import { deleteCredentialsQuery, useCredentials } from '@/features/credentials'
import { Credentials } from 'models'
import { trpc } from '@/lib/trpc'
type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType
type: Credentials['type']
workspaceId: string
currentCredentialsId?: string
onCredentialsSelect: (credentialId?: string) => void
onCreateNewClick: () => void
defaultCredentialLabel?: string
refreshDropdownKey?: number
}
export const CredentialsDropdown = ({
type,
workspaceId,
currentCredentialsId,
onCredentialsSelect,
onCreateNewClick,
defaultCredentialLabel,
refreshDropdownKey,
...props
}: Props) => {
const router = useRouter()
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { credentials, mutate } = useCredentials({
workspaceId: workspace?.id,
const { data, refetch } = trpc.credentials.listCredentials.useQuery({
workspaceId,
type,
})
const [isDeleting, setIsDeleting] = useState<string>()
const { mutate } = trpc.credentials.deleteCredentials.useMutation({
onMutate: ({ credentialsId }) => {
setIsDeleting(credentialsId)
},
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: ({ credentialsId }) => {
if (credentialsId === currentCredentialsId) onCredentialsSelect(undefined)
refetch()
},
onSettled: () => {
setIsDeleting(undefined)
},
})
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
const credentialsList = useMemo(() => {
return credentials.filter((credential) => credential.type === type)
}, [type, credentials])
const currentCredential = useMemo(
() => credentials.find((c) => c.id === currentCredentialsId),
[currentCredentialsId, credentials]
const currentCredential = data?.credentials.find(
(c) => c.id === currentCredentialsId
)
const handleMenuItemClick = (credentialsId: string) => () => {
onCredentialsSelect(credentialsId)
}
const handleMenuItemClick = useCallback(
(credentialsId: string) => () => {
onCredentialsSelect(credentialsId)
},
[onCredentialsSelect]
)
useEffect(() => {
if ((refreshDropdownKey ?? 0) > 0) mutate({ credentials })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshDropdownKey])
const clearQueryParams = useCallback(() => {
const hasQueryParams = router.asPath.includes('?')
if (hasQueryParams)
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
}, [router])
useEffect(() => {
if (!router.isReady) return
@@ -69,29 +84,17 @@ export const CredentialsDropdown = ({
handleMenuItemClick(router.query.credentialsId.toString())()
clearQueryParams()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady])
}, [
clearQueryParams,
handleMenuItemClick,
router.isReady,
router.query.credentialsId,
])
const clearQueryParams = () => {
const hasQueryParams = router.asPath.includes('?')
if (hasQueryParams)
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
}
const handleDeleteDomainClick =
const deleteCredentials =
(credentialsId: string) => async (e: React.MouseEvent) => {
e.stopPropagation()
if (!workspace?.id) return
setIsDeleting(credentialsId)
const { error } = await deleteCredentialsQuery(
workspace.id,
credentialsId
)
setIsDeleting(undefined)
if (error)
return showToast({ title: error.name, description: error.message })
onCredentialsSelect(undefined)
mutate({ credentials: credentials.filter((c) => c.id !== credentialsId) })
mutate({ workspaceId, credentialsId })
}
return (
@@ -121,7 +124,7 @@ export const CredentialsDropdown = ({
{defaultCredentialLabel}
</MenuItem>
)}
{credentialsList.map((credentials) => (
{data?.credentials.map((credentials) => (
<MenuItem
role="menuitem"
minH="40px"
@@ -137,7 +140,7 @@ export const CredentialsDropdown = ({
icon={<TrashIcon />}
aria-label="Remove credentials"
size="xs"
onClick={handleDeleteDomainClick(credentials.id)}
onClick={deleteCredentials(credentials.id)}
isLoading={isDeleting === credentials.id}
/>
</MenuItem>

View File

@@ -1,23 +0,0 @@
import { fetcher } from '@/utils/helpers'
import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr'
export const useCredentials = ({
workspaceId,
onError,
}: {
workspaceId?: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null,
fetcher
)
if (error && onError) onError(error)
return {
credentials: data?.credentials ?? [],
isLoading: !error && !data,
mutate,
}
}

View File

@@ -1,4 +1 @@
export { CredentialsDropdown } from './components/CredentialsDropdown'
export { useCredentials } from './hooks/useCredentials'
export { createCredentialsQuery } from './queries/createCredentialsQuery'
export { deleteCredentialsQuery } from './queries/deleteCredentialsQuery'

View File

@@ -1,16 +0,0 @@
import { Credentials } from 'models'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const createCredentialsQuery = async (
credentials: Omit<Credentials, 'id' | 'iv' | 'createdAt'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials?${stringify({
workspaceId: credentials.workspaceId,
})}`,
method: 'POST',
body: credentials,
})

View File

@@ -1,14 +0,0 @@
import { Credentials } from 'db'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const deleteCredentialsQuery = async (
workspaceId: string,
credentialsId: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`,
method: 'DELETE',
})