diff --git a/package.json b/package.json index 3e8c473..3dcf62f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3001", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/src/app/api/message-providers/[id]/chatbot-flow/route.ts b/src/app/api/message-providers/[id]/chatbot-flow/route.ts new file mode 100644 index 0000000..689ee8c --- /dev/null +++ b/src/app/api/message-providers/[id]/chatbot-flow/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ApiErrorHandler } from "@/common/error/api-error-handler"; +import { UserSessionManager } from "@/domains/auth/services/user-session-maganer"; +import { PrismaClientFactory } from "@/common/database/prisma-factory"; +import { GetProviderChatbotFlow } from "@/domains/message-providers/use-cases/get-provider-chatbot-flow"; +import { UpdateProviderChatbotFlow, updateProviderChatbotFlowInputSchema } from "@/domains/message-providers/use-cases/update-provider-chatbot-flow"; + + +export async function GET(request: NextRequest, { params }: { params: RequestParams }) { + + try { + const useCase = new GetProviderChatbotFlow({ + userLogged: await new UserSessionManager().getUserOrThrow(), + prismaClient: PrismaClientFactory.create() + }); + + const result = await useCase.execute({ + providerId: parseInt(params.id) + }); + + return NextResponse.json(result); + } + catch(error) { + return ApiErrorHandler.handler(error); + } +} + + +export async function PUT(request: NextRequest, { params }: { params: RequestParams }) { + + try { + const input = updateProviderChatbotFlowInputSchema.parse(await request.json()); + + const useCase = new UpdateProviderChatbotFlow({ + userLogged: await new UserSessionManager().getUserOrThrow(), + prismaClient: PrismaClientFactory.create() + }); + + await useCase.execute({ + providerId: parseInt(params.id), + chatbotFlow: input.chatbotFlow + }); + + return new NextResponse(null, { status: 204 }); + } + catch(error) { + return ApiErrorHandler.handler(error); + } +} + +interface RequestParams { + id: string; +} \ No newline at end of file diff --git a/src/app/chatbot/flow/page.tsx b/src/app/chatbot/flow/page.tsx new file mode 100644 index 0000000..d08030d --- /dev/null +++ b/src/app/chatbot/flow/page.tsx @@ -0,0 +1,26 @@ +import { AppLayout } from '@/components/AppLayout'; +import { AppLayoutBody } from '@/components/AppLayout/Body'; +import { AppLayoutHeader } from '@/components/AppLayout/Header'; +import { AppLayoutMenuWithTitle } from '@/components/AppLayout/MenuWithTitle'; +import { AppNavMenuDefault, AppNavMenuItens } from '@/components/AppLayout/NavMenu'; +import { AppLayoutPreLoading } from '@/components/AppLayout/PreLoading'; +import { ModalContainer } from '@/components/Modal/Container'; +import { ChatbotFlowView } from '@/components/ChatbotFlowView'; + +export default function Settings() { + + return ( + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/common/routes/index.tsx b/src/common/routes/index.tsx index 1d5d108..ec18b1d 100644 --- a/src/common/routes/index.tsx +++ b/src/common/routes/index.tsx @@ -19,4 +19,5 @@ export abstract class AppRoutes { public static viewGroup(groupId: string) { return '/groups/' + groupId; } public static groups() { return '/groups'; } public static newGroup() { return '/groups/new'; } + public static chatbotFlow() { return '/chatbot/flow'; } } \ No newline at end of file diff --git a/src/common/services/messaging/models.ts b/src/common/services/messaging/models.ts index f971599..28ea9ed 100644 --- a/src/common/services/messaging/models.ts +++ b/src/common/services/messaging/models.ts @@ -38,4 +38,27 @@ export interface ProviderWithStatus { status: ProviderStatus; message?: string; qrCodeContent?: string; +} + +export interface ChatNode { + id: string; + label: string; + pattern: string; + output: ChatNodeOutput[]; + invalidOutput?: ChatNodeOutput[]; + childs: ChatNode[]; + delayChildren?: number; // Delay antes de selecionar o próximo estado. CasoUso: O bot pede para o cliente digitar algo. Só que o cliente enviar em varias mensagens. Para exibir que o bot responda na primeira mensagem esperar algum tempo + action?: { + type: ChatNodeAction; + }; + delay?: number; // seconds +} + +export interface ChatNodeOutput { + type: 'text' | 'media-link'; + content: string; +} + +export enum ChatNodeAction { + GoToPrevious = 1 } \ No newline at end of file diff --git a/src/common/services/messaging/providers-api.ts b/src/common/services/messaging/providers-api.ts index 724778c..6c07c2f 100644 --- a/src/common/services/messaging/providers-api.ts +++ b/src/common/services/messaging/providers-api.ts @@ -1,7 +1,7 @@ import { AppConfig } from "../../configuration"; import { HttpClient } from "../../http/client"; import { UrlFormatter } from "../../http/url/url-formatter"; -import { IProviderConfig, Provider, ProviderType, ProviderWithStatus } from "./models"; +import { ChatNode, IProviderConfig, Provider, ProviderType, ProviderWithStatus } from "./models"; export class ProvidersApi { @@ -53,6 +53,18 @@ export class ProvidersApi { const url = UrlFormatter.format('{id}/finalize', { id: args.id }); return await this._client.post(url, undefined); } + + public async getChatbotFlow(args: GetChatbotFlowArgs) { + + const url = UrlFormatter.format('{id}/chatbot-flow', { id: args.id }); + return await this._client.get(url) ?? null; + } + + public async updateChatbotFlow({ id, ...args }: UpdateChatbotFlowArgs) { + + const url = UrlFormatter.format('{id}/chatbot-flow', { id: id }); + return await this._client.put(url, args) ?? null; + } } export interface ProviderCreateArgs { @@ -79,4 +91,13 @@ export interface FinalizeArgs { export interface GetStatusArgs { id?: number; -} \ No newline at end of file +} + +export interface GetChatbotFlowArgs { + id: number; +} + +export interface UpdateChatbotFlowArgs { + id: number; + chatbotFlow: ChatNode; +} diff --git a/src/components/AppLayout/NavMenu/MoreItem/index.tsx b/src/components/AppLayout/NavMenu/MoreItem/index.tsx index 8617a5b..274e7de 100644 --- a/src/components/AppLayout/NavMenu/MoreItem/index.tsx +++ b/src/components/AppLayout/NavMenu/MoreItem/index.tsx @@ -22,6 +22,9 @@ export function MoreItem() { {user && {user.name}}
+ diff --git a/src/components/ChatbotFlowView/hooks/index.tsx b/src/components/ChatbotFlowView/hooks/index.tsx new file mode 100644 index 0000000..67b60ed --- /dev/null +++ b/src/components/ChatbotFlowView/hooks/index.tsx @@ -0,0 +1,282 @@ +import { useState, useMemo, ChangeEvent, MouseEvent, useEffect } from "react"; +import { AppError } from "@/common/error"; +import { ChatNode, ChatNodeOutput, ProviderType } from "@/common/services/messaging/models"; +import { AppToast } from "@/common/ui/toast"; +import { MessageProvidersApi } from "@/domains/message-providers/client-api"; +import { useLoading } from "../../AppLayout/Loading/hooks"; +import { useDrodownMenu } from "../../Form/DropdownMenu/hooks"; +import { ModalUtils } from "../../Modal/Container/state"; +import { PromptModal } from "../../Modal/Prompt"; +import { ChatbotFlowView } from ".."; + +export function useChatbotFlow() { + + const { setLoading } = useLoading(); + const [ ready, setReady ] = useState(false); + + const [ providerId, setProviderId ] = useState(0); + const [ rootNode, setRootNode ] = useState(null); + const [ nodesPath, setNodesPath ] = useState([]); + const { visible, handleToggleDropdown, handleDropdownItemClick } = useDrodownMenu(ChatbotFlowView.name); + + const nodesIndex = useMemo(() => rootNode ? makeChatNodesIndex(rootNode) : {}, [ rootNode ]); + + const curretNodeId = nodesPath.at(-1); + const currentNode = curretNodeId ? nodesIndex[curretNodeId] : null; + + + const handleNext = (node: ChatNode) => () => { + + setNodesPath(nodes => [...nodes, node.id ]); + }; + + const handlePrevious = (node: ChatNode) => () => { + + setNodesPath(nodes => { + + const nodeIndex = nodes.findIndex(nodeId => nodeId === node.id); + const nodesUpdateds = nodes.slice(0, nodeIndex + 1); + + return nodesUpdateds; + }); + }; + + const handleAddResponse = (type: 'text' | 'media-link') => (event: MouseEvent) => { + + handleDropdownItemClick(event); + + if (!currentNode || !rootNode) return; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.output = [ ...node.output, { type, content: '' } ] + })); + }; + + const handleShowAddChild = () => { + + if (!currentNode) return; + + ModalUtils.show({ + id: 'chatbot-flow-new-child', + modal: + }); + }; + + const handleAddChild = (confirmed: boolean, childLabel: string | null) => { + + ModalUtils.hide('chatbot-flow-new-child'); + + if (!currentNode || !rootNode || !confirmed || !childLabel) return; + + const newNode: ChatNode = { + id: new Date().getTime().toString(), + label: childLabel, + pattern: '', + output: [{ type: 'text', content: '' }], + childs: [] + }; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.childs = [ ...node.childs, newNode ] + })); + + setNodesPath(nodes => [ ...nodes, newNode.id ]); + }; + + const handleRemoveChild = (nodeSelected: ChatNode) => () => { + + if (!currentNode || !rootNode) return; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.childs = node.childs.filter(x => x.id !== nodeSelected.id) + })); + }; + + const handlePatternChange = (event: ChangeEvent) => { + + if (!currentNode || !rootNode) return; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.pattern = event.target.value ?? ''; + })); + } + + const handleOutputContentChange = ({ type, content }: ChatNodeOutput) => (event: ChangeEvent) => { + + if (!currentNode || !rootNode) return; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.output = updateArrayItem(node.output, + x => x.content === content, + () => ({ type, content: event.currentTarget.innerText ?? '' })) + })); + }; + + const handleRemoveOutput = ({ content }: ChatNodeOutput) => (event: MouseEvent) => { + + if (!currentNode || !rootNode) return; + + setRootNode(updateNode(rootNode, node => { + + if (node.id !== currentNode.id) return; + + node.output = node.output.filter(x => x.content !== content) + })); + }; + + const handleSave = async () => { + + if (!rootNode) return; + + setLoading(true); + + try { + const api = new MessageProvidersApi(); + + await api.updateChatbotFlow({ + providerId: providerId, + chatbotFlow: rootNode + }); + + AppToast.success('Diálogo salvo'); + } + catch (error) { + + AppToast.error(AppError.parse(error).message); + } + finally { + setLoading(false); + } + }; + + useEffect(() => { + + (async () => { + + try { + const api = new MessageProvidersApi(); + const providersStatus = await api.getProvidersStatus(); + + const whatsappProvider = providersStatus?.find(x => x.type === ProviderType.Whatsapp); + if (!whatsappProvider) { + AppToast.error('Configure a integração com Whatsapp para ter acesso ao chatbot'); + return; + } + + const rootNode = await api.getChatbotFlow({ id: whatsappProvider.id }) ?? { id: 'inicial', label: 'Mensagem inicial', childs: [], output: [{ type: 'text', content: 'Olá! Pronto pra começar' }], pattern: '.*' }; + + setRootNode(rootNode); + setNodesPath([rootNode.id]); + setProviderId(whatsappProvider.id); + setReady(true); + } + catch (error) { + + AppToast.error(AppError.parse(error).message); + } + + })(); + + }, []); + + return { + ready, + currentNode, + nodesIndex, + nodesPath, + visible, + handleToggleDropdown, + handleNext, + handlePrevious, + handleAddResponse, + handleShowAddChild, + handleRemoveChild, + handlePatternChange, + handleOutputContentChange, + handleRemoveOutput, + handleSave, + }; +} + + +const updateArrayItem = (array: T[], selector: (item: T) => boolean, newItem: (item: T) => T) => { + + const newArray = []; + for(let i = 0; i < array.length; i++) { + + if (selector(array[i])) + newArray[i] = newItem(array[i]); + else + newArray[i] = array[i]; + } + + return newArray; +}; + +function makeChatNodesIndex(root: ChatNode) { + + const index: Record = {}; + + const visit = (node: ChatNode) => { + + if (index[node.id]) + throw new Error('Duplicate node id: ' + node.id); + + index[node.id] = node; + for(const child of node.childs) { + visit(child); + } + }; + + visit(root); + + return index; +} + +function getNodeFromPath(nodesPath: string[], rootNode: ChatNode) { + + let parentNode = null; + let node = rootNode; + for(const nodeId of nodesPath) { + + if (nodeId === node.id) + continue; + + const child = node.childs.find(x => x.id == nodeId); + if (!child) + throw new Error('Node not found in path: ' + nodesPath.join("/")); + + parentNode = node; + node = child; + } + + return { parentNode, node }; +} + +function updateNode(rootNode: ChatNode, update: (node: ChatNode) => void) { + + const newRootNode = { + ...rootNode + }; + + update(newRootNode); + + newRootNode.childs = newRootNode.childs.map(child => updateNode(child, update)); + + return newRootNode; +} \ No newline at end of file diff --git a/src/components/ChatbotFlowView/index.tsx b/src/components/ChatbotFlowView/index.tsx new file mode 100644 index 0000000..1e39da0 --- /dev/null +++ b/src/components/ChatbotFlowView/index.tsx @@ -0,0 +1,142 @@ +'use client' + +import Skeleton from "react-loading-skeleton"; +import { ArrowRightIcon, ChatBubbleBottomCenterTextIcon, ChevronDownIcon, ChevronRightIcon, EnvelopeIcon, FilmIcon, PlusIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Button, DropdownMenu, DropdownMenuItem, Input } from "../Form"; +import { useChatbotFlow } from "./hooks"; + + +export function ChatbotFlowView() { + + const { + ready, + currentNode, + nodesIndex, + nodesPath, + visible, + handleToggleDropdown, + handleNext, + handlePrevious, + handleAddResponse, + handleShowAddChild, + handleRemoveChild, + handlePatternChange, + handleOutputContentChange, + handleRemoveOutput, + handleSave, + } = useChatbotFlow(); + + if (!ready) + return ; + + return ( +
+
+
+
    + {nodesPath.map((nodeId, index) => { + + const node = nodesIndex[nodeId]; + + return ( + <> + {index > 0 &&
  • } +
  • + {node.id === currentNode?.id ? node.label : {node.label}} +
  • + + ); + })} +
+
+
+
+ +
+
+ + +
+ +
+
+ +
+ + Nova resposta + + + }> + + Texto + + + Image/Vídeo/Audio + + +
+
+ {currentNode?.output.map((output) => +
+
+ {output.type === 'text' && Mensagem de texto} + {output.type === 'media-link' && Link multimidia} + +
+
{output.content}
+
)} +
+ +
+ + +
+
    + {currentNode?.childs.length === 0 &&
  • Fim da conversa. Clique no botão + para continuar o diálogo.
  • } + {currentNode?.childs.map(node => { console.log(node); return ( +
  • + +
    + + +
    +
  • + )})} +
+
+
+
+ ); +} +ChatbotFlowView.Skeleton = function ChatbotFlowViewSkeleton() { + + return ( +
+
+
+ +
+
+ +
+
+ + +
+
+
+ +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Form/DropdownMenu/hooks/index.ts b/src/components/Form/DropdownMenu/hooks/index.ts index 6cbe615..2b4d5b8 100644 --- a/src/components/Form/DropdownMenu/hooks/index.ts +++ b/src/components/Form/DropdownMenu/hooks/index.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { MouseEvent, useMemo } from "react"; import { useSnapshot } from "valtio"; import { nanoid } from "nanoid/non-secure"; import { dropdownMenuState } from "../state"; @@ -10,14 +10,33 @@ export function useDrodownMenu(context: string) { const visible = activeMenu[context] === id; + const setVisible = (isVisible: boolean) => { + + if (isVisible) + dropdownMenuState.activeMenu[context] = id; + else + dropdownMenuState.activeMenu[context] = null; + }; + + const handleToggleDropdown = (event: MouseEvent) => { + + event.stopPropagation(); + event.preventDefault(); + setVisible(!visible); + }; + + const handleDropdownItemClick = (event: MouseEvent) => { + + event.stopPropagation(); + event.preventDefault(); + setVisible(false); + }; + + return { visible, - setVisible: (isVisible: boolean): void => { - - if (isVisible) - dropdownMenuState.activeMenu[context] = id; - else - dropdownMenuState.activeMenu[context] = null; - } + setVisible, + handleToggleDropdown, + handleDropdownItemClick }; } \ No newline at end of file diff --git a/src/components/Modal/Prompt/index.tsx b/src/components/Modal/Prompt/index.tsx new file mode 100644 index 0000000..099b926 --- /dev/null +++ b/src/components/Modal/Prompt/index.tsx @@ -0,0 +1,35 @@ +import { ReactNode, useRef } from "react"; +import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Button, Input } from "../../Form"; +import { Modal, ModalBody, ModalHeader } from ".."; + + +export function PromptModal({ title, message, defaultValue, btnConfirmText, btnConfirmCancel, onDone }: PromptModalProps) { + + const inputRef = useRef(null); + btnConfirmText ??= <> Confirmar; + btnConfirmCancel ??= <> Cancelar; + + return ( + + {title} + + + +
+ + +
+
+
+ ); +} + +export interface PromptModalProps { + title: string; + message: string; + defaultValue?: string; + btnConfirmText?: ReactNode; + btnConfirmCancel?: ReactNode; + onDone: (confirmed: boolean, value: string | null) => void | Promise; +} \ No newline at end of file diff --git a/src/domains/message-providers/client-api.ts b/src/domains/message-providers/client-api.ts index a17a934..eb373e8 100644 --- a/src/domains/message-providers/client-api.ts +++ b/src/domains/message-providers/client-api.ts @@ -1,7 +1,7 @@ import { HttpClient } from "@/common/http/client"; import { HttpClientFactory } from "@/common/http/client/factory"; import { UrlFormatter } from "@/common/http/url/url-formatter"; -import { Provider, ProviderWithStatus } from "@/common/services/messaging/models"; +import { ChatNode, Provider, ProviderWithStatus } from "@/common/services/messaging/models"; export class MessageProvidersApi { @@ -44,4 +44,21 @@ export class MessageProvidersApi { const url = UrlFormatter.format('{id}/finalize', { id: args.id }); await this._client.post(url, null); } + + public async getChatbotFlow(args: { id : number }): Promise { + + const url = UrlFormatter.format('{id}/chatbot-flow', { id: args.id }); + return await this._client.get(url) ?? null; + } + + public async updateChatbotFlow({ providerId, ...args }: UpdateChatbotFlowInput): Promise { + + const url = UrlFormatter.format('{id}/chatbot-flow', { id: providerId }); + await this._client.put(url, args) + } +} + +export interface UpdateChatbotFlowInput { + providerId: number; + chatbotFlow: ChatNode; } \ No newline at end of file diff --git a/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts b/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts new file mode 100644 index 0000000..f49a457 --- /dev/null +++ b/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts @@ -0,0 +1,48 @@ +import { PrismaClient } from "@prisma/client"; +import { UseCase } from "@/common/use-cases"; +import { ProvidersApi } from "@/common/services/messaging"; +import { ChatNode, ProviderWithStatus } from "@/common/services/messaging/models"; +import { UserLogged } from "@/common/auth/user"; +import { AppError } from "@/common/error"; + + +export class GetProviderChatbotFlow implements UseCase { + + private _user: UserLogged; + private _db: PrismaClient; + + constructor({ userLogged, prismaClient }: { userLogged: UserLogged, prismaClient: PrismaClient }) { + + this._user = userLogged; + this._db = prismaClient; + } + + public async execute(input: GetProviderChatbotFlowInput): Promise { + + const account = await this._db.account.findFirstOrThrow({ + where: { + id: this._user.accountId + }, + select: { + messagingApiToken: true + } + }); + + if (!account?.messagingApiToken) + throw new AppError('Messaging not configured'); + + const api = new ProvidersApi({ + accessToken: account?.messagingApiToken + }); + + const result = await api.getChatbotFlow({ + id: input.providerId + }); + + return result; + } +} + +export interface GetProviderChatbotFlowInput { + providerId: number; +} \ No newline at end of file diff --git a/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts b/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts new file mode 100644 index 0000000..b74c8c7 --- /dev/null +++ b/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { PrismaClient } from "@prisma/client"; +import { UseCase } from "@/common/use-cases"; +import { ProvidersApi } from "@/common/services/messaging"; +import { ChatNode } from "@/common/services/messaging/models"; +import { UserLogged } from "@/common/auth/user"; +import { AppError } from "@/common/error"; + + +export class UpdateProviderChatbotFlow implements UseCase { + + private _user: UserLogged; + private _db: PrismaClient; + + constructor({ userLogged, prismaClient }: { userLogged: UserLogged, prismaClient: PrismaClient }) { + + this._user = userLogged; + this._db = prismaClient; + } + + public async execute(input: UpdateProviderChatbotFlowInput): Promise { + + const account = await this._db.account.findFirstOrThrow({ + where: { + id: this._user.accountId + }, + select: { + messagingApiToken: true + } + }); + + if (!account?.messagingApiToken) + throw new AppError('Messaging not configured'); + + const api = new ProvidersApi({ + accessToken: account?.messagingApiToken + }); + + await api.updateChatbotFlow({ + id: input.providerId, + chatbotFlow: input.chatbotFlow + }); + } +} + +export interface UpdateProviderChatbotFlowInput { + providerId: number; + chatbotFlow: ChatNode; +} + +export const updateProviderChatbotFlowInputSchema = z.object({ + chatbotFlow: z.any() +}); \ No newline at end of file