Files
clube67_newwhats.local/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts
T
VPS 4 Deploy Agent 3adbc0b59d
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
fix(secretaria): corrige 7 inconsistências do issue #14
SEC-01+SEC-11: /secretaria/ask agora envia reply via WhatsApp (sock.sendMessage)
  e persiste a mensagem no banco Prisma após brain.chat(), com notify Socket.IO.

SEC-02+SEC-03: MessageHandler emite hookBus 'ext:message.new' para todo msg
  recebido. Listener no ext-api auto-dispara a Secretária apenas se NÃO
  houver AIBot ativo na instância (exclusão mútua — chatbot tem prioridade).

SEC-04: ChatListItem exibe ícone BotOff (âmbar) quando chat.bot_paused = true,
  com tooltip "Bot pausado — aguardando atendente".

SEC-05: chatToLegacy() mapeia c.botPaused → bot_paused na bridge legada.
  NewWhatsChat recebe campo bot_paused opcional.

SEC-08: POST /secretaria/ask valida que existe instância CONNECTED antes
  de criar conversa. Retorna 503 se nenhuma instância disponível.

SEC-10: POST /api/chatbot/bots/:instanceId valida que credentialId pertence
  ao mesmo tenantId antes de criar o bot. Retorna 403 se não pertencer.

SEC-12: chatBotState.resumeBot() limpa botSummary (= null) ao retomar,
  evitando contexto defasado após escalação humana.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 05:29:19 +02:00

200 lines
8.6 KiB
TypeScript

/**
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
*
* Montado em /api/chatbot (ver server.ts)
* Todos os endpoints requerem authMiddleware.
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { credentialRepo, botRepo, chatBotState } from './chatbot.repository'
import { logger } from '../../config/logger'
export function buildChatbotRoutes(): Router {
const router = Router()
// ── Helpers ──────────────────────────────────────────────────────────────
const validateInstance = async (instanceId: string, tenantId: string) => {
return prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
}
// ─────────────────────────────────────────────────────────────────────────
// CREDENTIALS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/credentials/:instanceId
router.get('/credentials/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const creds = await credentialRepo.findAll(tenantId, instanceId)
res.json(creds)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar credenciais')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/credentials/:instanceId
router.post('/credentials/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
apiKey: z.string().min(10),
provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(cred)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar credencial')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/credentials/:instanceId/:credId
router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const credId = req.params['credId'] as string
await credentialRepo.delete(credId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar credencial')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOTS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/bots/:instanceId
router.get('/bots/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const bots = await botRepo.findAll(tenantId, instanceId)
res.json(bots)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar bots')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/bots/:instanceId
router.post('/bots/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid(),
name: z.string().min(1).max(80),
systemPrompt: z.string().min(10),
model: z.string().default('gemini-1.5-flash'),
enabled: z.boolean().default(false),
triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'),
keywords: z.array(z.string()).default([]),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const existing = await botRepo.findAll(tenantId, instanceId)
if (existing.length > 0) {
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
return
}
// SEC-10: garante que a credencial pertence a este tenant
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
if (!cred) {
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
return
}
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(bot)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar bot')
res.status(500).json({ error: err.message })
}
})
// PUT /api/chatbot/bots/:instanceId/:botId
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid().optional(),
name: z.string().min(1).max(80).optional(),
systemPrompt: z.string().min(10).optional(),
model: z.string().optional(),
enabled: z.boolean().optional(),
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
keywords: z.array(z.string()).optional(),
})
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
const body = schema.parse(req.body)
await botRepo.update(botId, tenantId, body as any)
const updated = await botRepo.findById(botId, tenantId)
res.json(updated)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao atualizar bot')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/bots/:instanceId/:botId
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
await botRepo.delete(botId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar bot')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOT STATE — Human Takeover por chat
// ─────────────────────────────────────────────────────────────────────────
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
try {
await chatBotState.pauseBot(req.params['chatId'] as string)
res.json({ paused: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
try {
await chatBotState.resumeBot(req.params['chatId'] as string)
res.json({ paused: false })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
return router
}