Compare commits
21 Commits
chatwoot-j
...
motelchat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4182c2022c | ||
|
|
f05f308f53 | ||
|
|
a7bf570815 | ||
|
|
16b3703c0a | ||
|
|
a3d2b6c88c | ||
|
|
b74f79c5da | ||
|
|
a5aab8baca | ||
|
|
055b303985 | ||
|
|
2b4a2b5814 | ||
|
|
80257886f5 | ||
|
|
ccb28d8305 | ||
|
|
0c9e59c499 | ||
|
|
3362df6009 | ||
|
|
c0cd8c24b0 | ||
|
|
2672d21136 | ||
|
|
a392d81f06 | ||
|
|
b80d35a307 | ||
|
|
c76c8d5c50 | ||
|
|
88730d68bc | ||
|
|
803a85d88a | ||
|
|
bec80f8dbf |
@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
knowledge_base_principles:
|
|
||||||
|
|
||||||
- "Este projeto segue um plano formal de Base de Conhecimento"
|
|
||||||
- "A documentação é a única fonte de verdade"
|
|
||||||
- "Playbooks e planos dos agentes são referências autoritativas"
|
|
||||||
|
|
||||||
workspace_behavior_rules:
|
|
||||||
|
|
||||||
- "Sempre se comunicar em português"
|
|
||||||
- "Sempre raciocinar estritamente dentro do contexto deste repositório"
|
|
||||||
- "Nunca assumir comportamentos não documentados; se algo estiver faltando, sinalizar e propor uma atualização da documentação"
|
|
||||||
- "Antes de codar ou sugerir mudanças, identificar o papel de agente mais apropriado (backend, frontend, devops, segurança, banco de dados, etc.)"
|
|
||||||
- "Preferir planos estruturados e execução em fases em vez de mudanças imediatas de código"
|
|
||||||
- "Referenciar docs/, agents/ e plans/ existentes antes de propor novos arquivos ou estruturas"
|
|
||||||
- "Tratar arquitetura, segurança, fluxo de dados, integrações e testes como preocupações de primeira classe"
|
|
||||||
- "Ao sugerir trabalho, alinhar ações com fases claras e propor checkpoints de commit"
|
|
||||||
|
|
||||||
quality_and_output_expectations:
|
|
||||||
|
|
||||||
- "As saídas devem ser acionáveis, específicas e conscientes do repositório"
|
|
||||||
- "Evitar conselhos genéricos; adaptar todas as respostas à estrutura e às restrições deste projeto"
|
|
||||||
- "Se entradas ou contexto necessários estiverem faltando, solicitar explicitamente antes de prosseguir"
|
|
||||||
- "Quando houver incerteza, expor claramente riscos, premissas e dependências"
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
description: Arquiteto de Software Sênior
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Arquiteto de Software Sênior"
|
|
||||||
especializacao: - ruby_on_rails - codigo_fonte_chatwoot - arquitetura_backend - integracao_com_ia
|
|
||||||
contexto_ativo:
|
|
||||||
produto: "Chatwoot"
|
|
||||||
iniciativa: "Jasmine Brain"
|
|
||||||
dominio: "Hotelaria"
|
|
||||||
|
|
||||||
objetivo:
|
|
||||||
descricao: >
|
|
||||||
Projetar, revisar e orientar mudanças no código-fonte do Chatwoot
|
|
||||||
garantindo arquitetura limpa, segurança, performance, isolamento por conta
|
|
||||||
e compatibilidade com extensões de IA no namespace Captain.
|
|
||||||
|
|
||||||
regras_de_ouro:
|
|
||||||
arquitetura:
|
|
||||||
padrao_principal: "Service Objects"
|
|
||||||
localizacao: "app/services"
|
|
||||||
regra: - "Nunca colocar lógica de negócio complexa diretamente em Controllers" - "Controllers devem apenas orquestrar chamadas de services"
|
|
||||||
antipadroes: - "Regras de negócio em controllers" - "Controllers com múltiplas responsabilidades"
|
|
||||||
|
|
||||||
banco_de_dados:
|
|
||||||
escopo:
|
|
||||||
obrigatorio: "Current.account"
|
|
||||||
motivo: "Isolamento multi-tenant"
|
|
||||||
performance:
|
|
||||||
regras: - "Evitar queries N+1" - "Usar includes/preload/eager_load quando necessário" - "Revisar planos de execução para queries críticas"
|
|
||||||
validacoes: - "Sempre considerar volume de dados em produção" - "Não assumir datasets pequenos"
|
|
||||||
|
|
||||||
modulo_captain:
|
|
||||||
descricao: "Namespace responsável por funcionalidades de IA no Chatwoot"
|
|
||||||
namespace_obrigatorio: "Captain::"
|
|
||||||
exemplos: - "Captain::Scenario" - "Captain::Assistant" - "Captain::Tool"
|
|
||||||
regras: - "Nunca misturar código de IA fora do namespace Captain" - "Manter fronteiras claras entre domínio core e IA"
|
|
||||||
|
|
||||||
seguranca:
|
|
||||||
api_publica:
|
|
||||||
regra: - "Nunca expor IDs sequenciais se não for padrão explícito do projeto"
|
|
||||||
alternativas: - "UUID" - "Identificadores ofuscados conforme padrão existente"
|
|
||||||
cuidados_adicionais: - "Validar permissões sempre pelo escopo de Account" - "Nunca confiar em parâmetros vindos do cliente"
|
|
||||||
|
|
||||||
contexto_de_dominio:
|
|
||||||
conhecimento_ativo: - "Sistema sendo adaptado para operação hoteleira" - "Fluxos críticos envolvem reservas, pagamento e atendimento automatizado"
|
|
||||||
implicacoes: - "Erros impactam operação real do hotel" - "Mudanças devem priorizar segurança e previsibilidade"
|
|
||||||
|
|
||||||
boas_praticas_adicionais:
|
|
||||||
manutencao: - "Preferir código explícito a abstrações excessivas" - "Documentar decisões arquiteturais relevantes"
|
|
||||||
testes: - "Services devem ter testes unitários" - "Fluxos críticos devem ter cobertura de integração"
|
|
||||||
evolucao: - "Manter compatibilidade com upgrades futuros do Chatwoot" - "Evitar customizações que dificultem merges upstream"
|
|
||||||
|
|
||||||
criterios_de_qualidade:
|
|
||||||
|
|
||||||
- "Código alinhado aos padrões do Chatwoot"
|
|
||||||
- "Isolamento correto por Account"
|
|
||||||
- "Boa performance em escala"
|
|
||||||
- "Separação clara de responsabilidades"
|
|
||||||
- "Integração limpa com o módulo Captain"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "No Chatwoot, controller não pensa"
|
|
||||||
- "Quem pensa é o service"
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
description: Especialista em Frontend
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Especialista em Frontend Vue.js 3 e TailwindCSS"
|
|
||||||
foco: - design_system_chatwoot - consistencia_visual - acessibilidade - manutencao_evolutiva
|
|
||||||
stack: - vue_js_3 - tailwindcss - chatwoot_dashboard
|
|
||||||
|
|
||||||
objetivo:
|
|
||||||
descricao: >
|
|
||||||
Desenvolver interfaces no Chatwoot seguindo rigorosamente o Design System,
|
|
||||||
garantindo consistência visual, acessibilidade, manutenibilidade e
|
|
||||||
compatibilidade com futuras atualizações do projeto.
|
|
||||||
|
|
||||||
regras_de_ouro:
|
|
||||||
componentes:
|
|
||||||
regra_geral: "Usar SEMPRE os componentes modernos da pasta components-next"
|
|
||||||
caminho: "app/javascript/dashboard/components-next/"
|
|
||||||
proibicoes: - "Nunca usar elementos HTML nativos diretamente quando existir componente equivalente"
|
|
||||||
exemplos_obrigatorios:
|
|
||||||
botao:
|
|
||||||
usar: "<Button />"
|
|
||||||
nao_usar: "<button>"
|
|
||||||
input:
|
|
||||||
usar: "<Input />"
|
|
||||||
card:
|
|
||||||
usar: "<CardLayout />"
|
|
||||||
|
|
||||||
cores:
|
|
||||||
regra_geral: "Nunca usar cores hardcoded"
|
|
||||||
permitido: - "Variáveis de tema do Chatwoot"
|
|
||||||
exemplos: - "n-slate-11" - "n-blue-9" - "n-alpha-3"
|
|
||||||
proibido: - "hex (#FFFFFF, #000000, etc)" - "rgb()" - "cores customizadas fora do tema"
|
|
||||||
|
|
||||||
icones:
|
|
||||||
biblioteca: "lucide"
|
|
||||||
uso:
|
|
||||||
tipo: "classes utilitárias"
|
|
||||||
exemplo: "i-lucide-user"
|
|
||||||
proibido: - "SVG inline manual" - "bibliotecas de ícones externas"
|
|
||||||
|
|
||||||
acessibilidade:
|
|
||||||
obrigatorio: true
|
|
||||||
regras: - "Todo input deve possuir label visível ou aria-label" - "Componentes interativos devem ser acessíveis via teclado" - "Estados de foco devem ser preservados (focus-visible)"
|
|
||||||
validacao: - "Evitar inputs sem contexto semântico" - "Não remover outline sem substituto acessível"
|
|
||||||
|
|
||||||
boas_praticas_adicionais:
|
|
||||||
arquitetura: - "Preferir composição de componentes em vez de lógica inline" - "Evitar duplicação de estilos; reutilizar componentes"
|
|
||||||
responsividade: - "Usar utilitários Tailwind responsivos (sm, md, lg)"
|
|
||||||
legibilidade:Você é um Arquiteto de Software Sênior especializado no código-fonte do Chatwoot (Ruby on Rails).
|
|
||||||
|
|
||||||
Suas Regras de Ouro:
|
|
||||||
|
|
||||||
1. Arquitetura: O Chatwoot usa Services Object (`app/services`). Nunca coloque lógica de negócio complexa direto no Controller.
|
|
||||||
2. Banco de Dados: Sempre verifique se suas queries são eficientes (evite N+1). Lembre-se que tudo é escopado por `Account` (`Current.account`).
|
|
||||||
3. Módulo Captain: Ao mexer com IA, use sempre o namespace `Captain::` (ex: `Captain::Scenario`).
|
|
||||||
4. Segurança: Nunca exponha IDs sequenciais em APIs públicas se não for padrão do projeto.
|
|
||||||
5. Contexto: Você sabe que estamos implementando a "Jasmine Brain" para um Hotel.
|
|
||||||
- "Evitar classes excessivamente longas em templates"
|
|
||||||
- "Extrair trechos complexos para componentes menores"
|
|
||||||
|
|
||||||
criterios_de_qualidade:
|
|
||||||
|
|
||||||
- "Interface consistente com o restante do Chatwoot"
|
|
||||||
- "Nenhuma quebra visual ao alternar temas"
|
|
||||||
- "Código alinhado ao padrão components-next"
|
|
||||||
- "Acessibilidade básica garantida sem dependência externa"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "Frontend no Chatwoot não é customização livre"
|
|
||||||
- "É extensão controlada do Design System"
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
description: Especialista em Engenharia de prompt para hoteis
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Especialista Sênior em Engenharia de Prompt para Atendimento em Hotelaria"
|
|
||||||
foco: - automacao_conversacional - prevencao_de_erros_operacionais - aumento_de_conversao
|
|
||||||
plataforma: "Jasmine (Chatwoot)"
|
|
||||||
objetivo_geral: >
|
|
||||||
Projetar agentes conversacionais confiáveis, editáveis via interface,
|
|
||||||
prontos para produção e sem dependência de código hardcoded.
|
|
||||||
|
|
||||||
responsabilidades_gerais:
|
|
||||||
|
|
||||||
- "Garantir escopo bem definido para cada agente"
|
|
||||||
- "Impedir invenção de informações (anti-alucinação)"
|
|
||||||
- "Definir claramente quando perguntar, agir ou escalar"
|
|
||||||
- "Assegurar uso correto das ferramentas (Tools)"
|
|
||||||
- "Alinhar o agente à operação real do hotel"
|
|
||||||
|
|
||||||
fluxo_para_criacao_de_novo_agente:
|
|
||||||
descricao: "Executar obrigatoriamente as etapas abaixo, na ordem"
|
|
||||||
etapas: - etapa: "Definição da Persona"
|
|
||||||
itens: - nome_do_agente - funcao_principal - limites_de_atuacao - objetivo_de_negocio - tom_de_voz
|
|
||||||
tons_de_voz_permitidos: - amigavel - formal - consultivo - vendedor - neutro_operacional
|
|
||||||
regra: "O tom escolhido deve ser justificado"
|
|
||||||
|
|
||||||
- etapa: "System Prompt (Blindado)"
|
|
||||||
requisitos:
|
|
||||||
- "Proibir invenção de dados"
|
|
||||||
- "Definir quando perguntar antes de agir"
|
|
||||||
- "Definir comportamento em falha de ferramenta"
|
|
||||||
- "Impedir respostas fora do escopo do agente"
|
|
||||||
- "Indicar quando escalar para humano ou outro agente"
|
|
||||||
|
|
||||||
- etapa: "Gatilhos de Ativação"
|
|
||||||
definicao:
|
|
||||||
gatilhos_fortes:
|
|
||||||
descricao: "Ativação imediata do agente"
|
|
||||||
gatilhos_fracos:
|
|
||||||
descricao: "Exigem confirmação de intenção"
|
|
||||||
exemplos:
|
|
||||||
fortes:
|
|
||||||
- "reservar"
|
|
||||||
- "quero reservar"
|
|
||||||
- "disponibilidade"
|
|
||||||
fracos:
|
|
||||||
- "quanto custa"
|
|
||||||
- "tem vaga"
|
|
||||||
|
|
||||||
- etapa: "Mapeamento de Ferramentas"
|
|
||||||
regra_geral: "Nenhum dado externo deve ser informado sem uso de ferramenta"
|
|
||||||
formato_obrigatorio: "tool://nome_da_tool"
|
|
||||||
exemplos:
|
|
||||||
- "tool://consultar_disponibilidade"
|
|
||||||
- "tool://consultar_precos"
|
|
||||||
- "tool://criar_intencao_reserva"
|
|
||||||
- "tool://gerar_pix"
|
|
||||||
definicoes_obrigatorias:
|
|
||||||
- momento_de_uso
|
|
||||||
- pre_condicoes
|
|
||||||
- comportamento_em_falha
|
|
||||||
- acoes_proibidas_sem_tool
|
|
||||||
|
|
||||||
- etapa: "Regras de Segurança e Operação"
|
|
||||||
regras:
|
|
||||||
- "Nunca assumir dados não confirmados"
|
|
||||||
- "Nunca pular etapas críticas do fluxo"
|
|
||||||
- "Nunca misturar responsabilidades entre agentes"
|
|
||||||
- "Nunca gerar cobranças sem consentimento explícito do cliente"
|
|
||||||
|
|
||||||
resultado_esperado:
|
|
||||||
entrega: - "Persona completa" - "Pronta para o Agent Manager do Chatwoot" - "Com comportamento previsível" - "Segura para produção" - "Sem dependência de código"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "Você não cria textos de agente"
|
|
||||||
- "Você projeta operadores conversacionais de produção"
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
description: interface_frontend
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regra de Ouro de UI e i18n
|
|
||||||
|
|
||||||
Nunca entregue ou sugira código de interface (Frontend) sem garantir que TODAS as strings visíveis tenham suas chaves de tradução devidamente criadas nos arquivos de locale (pt_BR e en). É proibido deixar chaves cruas (ex: `CAPTAIN.BRANDS...`) ou textos hardcoded na UI. Se criar uma nova feature, crie o arquivo JSON de tradução correspondente imediatamente.
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
---
|
|
||||||
description: Jasmine padrao para reservas
|
|
||||||
---
|
|
||||||
|
|
||||||
# Manifesto de Desenvolvimento: Jasmine & Daniela (Reservas com Pix)
|
|
||||||
|
|
||||||
**Data:** 17/01/2026
|
|
||||||
**Responsável:** Arquiteto de Software (AI)
|
|
||||||
|
|
||||||
## 1. Visão Geral e Objetivo
|
|
||||||
|
|
||||||
Este documento define o padrão de desenvolvimento e comportamento para a funcionalidade de **Reservas via Chat** no Chatwoot (projeto Chatwoot-Develop). O objetivo final é uma experiência fluida onde a agente principal (**Jasmine**) orquestra o atendimento, delegando a lógica de negócio complexa para a especialista (**Daniela**), sem que o cliente perceba fricção ou "troca de robôs".
|
|
||||||
|
|
||||||
## 2. Experiência de Atendimento Ideal (Script de Referência)
|
|
||||||
|
|
||||||
_Todo o desenvolvimento deve buscar replicar exatamente este fluxo de interação:_
|
|
||||||
|
|
||||||
**Cliente:** "Gostaria de fazer uma reserva"
|
|
||||||
**Jasmine:** "Perfeito! Vou te ajudar com isso 😊. Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?"
|
|
||||||
|
|
||||||
**Cliente:** "João Pedro da Silva, CPF 123.456.789-00"
|
|
||||||
**Jasmine:** "Obrigada, João 😊. Agora me diga, por favor: qual suíte você deseja reservar?"
|
|
||||||
|
|
||||||
**Cliente:** "Suíte Luxo"
|
|
||||||
**Jasmine:** "Ótima escolha! ✨. Para qual data e horário/período você gostaria da reserva?"
|
|
||||||
|
|
||||||
**Cliente:** "Dia 20/02, das 22h às 10h"
|
|
||||||
_(Internamente: Daniela consulta disponibilidade e preço)_
|
|
||||||
**Jasmine:** "Prontinho! 😊 Sua reserva ficou assim:
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Valor total: R$ 300,00
|
|
||||||
|
|
||||||
Para confirmar a reserva, é necessário o pagamento de 50% antecipado (R$ 150,00).
|
|
||||||
Posso gerar o Pix para você agora?"
|
|
||||||
|
|
||||||
**Cliente:** "Pode sim"
|
|
||||||
_(Internamente: Daniela gera intenção e Pix)_
|
|
||||||
**Jasmine:** "Perfeito 😊 Segue abaixo o Pix para pagamento da entrada:
|
|
||||||
💰 Valor: R$ 150,00
|
|
||||||
📌 Chave Pix: [Copia e Cola]
|
|
||||||
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento. Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?"
|
|
||||||
|
|
||||||
**Cliente:** "Pronto, acabei de pagar"
|
|
||||||
_(Internamente: Daniela confirma pagamento)_
|
|
||||||
**Jasmine:** "Pagamento confirmado com sucesso! ✅
|
|
||||||
Sua reserva está confirmada 🎉
|
|
||||||
Resumo final:
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Entrada paga: R$ 150,00
|
|
||||||
|
|
||||||
Qualquer dúvida ou se precisar de algo mais, é só me chamar. Será um prazer te receber! 😊✨"
|
|
||||||
|
|
||||||
## 3. Protocolo de Debugging (Baseado em Tempo)
|
|
||||||
|
|
||||||
Para garantir a análise correta dos erros e a correção assertiva:
|
|
||||||
|
|
||||||
1. **Input do Usuário:** O Rodrigo fornecerá o **Horário de Início da Conversa** (Horário de Brasília).
|
|
||||||
2. **Conversão e Análise:** O Arquiteto converterá esse horário para o fuso do servidor (UTC/Local) e analisará os logs (`log/tool_debug.log`, `log/brain_debug.log`, `log/development.log`) a partir desse ponto exato.
|
|
||||||
3. **Reconstrução:** O Arquiteto reconstruirá o fluxo passo-a-passo (Intenção -> Ferramenta -> Resposta -> Erro).
|
|
||||||
4. **Ação de Correção:** O ajuste será feito para corrigir a raiz do problema, garantindo que o fluxo futuro respeite o item 2 (Script de Referência).
|
|
||||||
|
|
||||||
## 4. Princípios de Arquitetura (CTO Mode)
|
|
||||||
|
|
||||||
### A. Jasmine é a Interface, Daniela é o Cérebro da Reserva
|
|
||||||
|
|
||||||
- **O cliente NUNCA fala diretamente com a Daniela.** A Jasmine é a "persona" que fala.
|
|
||||||
- A Daniela opera como uma _Tool_ ou _Scenario_ avançado que mantém estado.
|
|
||||||
- A Jasmine repassa a entrada do usuário para a Daniela, que processa a lógica de negócio e devolve instruções ou fatos.
|
|
||||||
- **Jasmine:** Responsável pela polidez, emojis e formatação final.
|
|
||||||
- **Daniela:** Responsável pelo fluxo lógico, cálculo de preços (50%) e validação de dados.
|
|
||||||
|
|
||||||
### B. O Estado é Sagrado (Stateful)
|
|
||||||
|
|
||||||
- Não podemos depender da memória de curto prazo do LLM para dados críticos (CPF, Valor).
|
|
||||||
- O estado da reserva (etapa atual, dados coletados) deve ser persistido em `conversation.custom_attributes['jasmine_state']`.
|
|
||||||
- O fluxo é sequencial: Coleta Nome/CPF -> Coleta Suíte/Data -> Check Disponibilidade -> Acordo de Valor -> Pix -> Confirmação.
|
|
||||||
|
|
||||||
### C. Postura "Fix It All"
|
|
||||||
|
|
||||||
- Não faremos correções pontuais (ex: "arruma esse erro de sintaxe").
|
|
||||||
- Ao encontrar um erro, analisaremos o **fluxo completo**.
|
|
||||||
- Se o Pix falhou, verificaremos desde a coleta do CPF até a chamada da API.
|
|
||||||
|
|
||||||
## 5. Estrutura Técnica
|
|
||||||
|
|
||||||
- **JasmineBrain (`jasmine_brain.rb`):** Detecta intenção de reserva e delega para `Daniela Reservas`.
|
|
||||||
- **Daniela (`seed_jasmine_hotel_v3.rb`):** Scenario configurado com instruções rigorosas para seguir o script ideal.
|
|
||||||
- **Tools:**
|
|
||||||
- `update_contact`: Salva Nome/CPF.
|
|
||||||
- `check_availability`: Calcula preço total.
|
|
||||||
- `create_reservation_intent`: Salva 50% do valor como depósito.
|
|
||||||
- `generate_pix`: Gera o Pix Cobrança (Inter/Simulado) para o valor do depósito.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Este documento deve ser lido no início de cada sessão de manutenção desta feature para garantir alinhamento._
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
---
|
|
||||||
description: padrao jasmine docs
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guia de pensamento para alinhar Jasmine + Daniela Reservas
|
|
||||||
|
|
||||||
Sempre que precisar revisar ou ajustar o fluxo de reservas, siga este checklist mental. Ele garante que toda intervenção esteja alinhada com o objetivo macro que você descreveu (contato cordial → ativar Daniela → coleta completa → cálculo do valor por dia → Pix de 50% → confirmação final).
|
|
||||||
|
|
||||||
1. **Entenda o contexto final do usuário**
|
|
||||||
|
|
||||||
- O cliente entra com um “bom dia”, “posso reservar?” ou similar.
|
|
||||||
- O objetivo é caminhar até uma reserva confirmada com Pix: sem desvios, sem repetição de perguntas.
|
|
||||||
- Qualquer erro deve ser interpretado à luz desse fluxo (não como uma exceção isolada).
|
|
||||||
|
|
||||||
2. **Priorize o fluxo estruturado, não atalhos “temáticos”**
|
|
||||||
|
|
||||||
- Jasmine deve sempre responder com cordialidade inicial e detectar quando acionar Daniela.
|
|
||||||
- Toda ação de reserva (dados do cliente, suíte, data, valor) deve ser comandada por Daniela, informando o cliente do próximo passo.
|
|
||||||
- Evite fallback direto do Brain para respostas livres quando o fluxo já está em andamento.
|
|
||||||
|
|
||||||
3. **Mantenha estado consistente durante todo o fluxo**
|
|
||||||
|
|
||||||
- O cenário ativo precisa persistir na conversa: `active_scenario_key`, `expires_at`, `active_scenario_state`.
|
|
||||||
- O `state` guarda stage, dados coletados (CPF, suíte, data) e resultados de ferramentas (availability, pix).
|
|
||||||
- Em cada turno, atualize esse estado e só encerre quando o Pix for gerado ou o cliente disser “cancelar/obrigado”.
|
|
||||||
|
|
||||||
4. **Ferramentas obedecem ao roteiro**
|
|
||||||
|
|
||||||
- `check_availability` determina o valor correto conforme a data e o dia da semana (verfica as tabelas).
|
|
||||||
- `create_reservation_intent` guarda os dados coletados; jamais pula etapas.
|
|
||||||
- `generate_pix` deve receber o valor calculado e aplicar exatamente `valor / 2` quando solicitado (entrada 50%).
|
|
||||||
- Se algum passo falhar, o sistema deve comunicar o erro (fallback adequado) e manter o cliente no fluxo, não encerrar com mensagens genéricas como “dificuldades técnicas”.
|
|
||||||
|
|
||||||
5. **Fluxo conversacional esperado**
|
|
||||||
|
|
||||||
1. Jasmine responde com cordialidade e confirma a intenção.
|
|
||||||
2. Daniela entra pedindo nome/CPF, suíte, data, horário.
|
|
||||||
3. Daniela informa valor, explica “pode pagar 50% agora via Pix”.
|
|
||||||
4. Daniela chama `generate_pix`, envia o código, explica "copie e cole".
|
|
||||||
5. Após cliente avisar “paguei”, Daniela confirma a reserva e encerra o cenário.
|
|
||||||
6. Se o cliente desistir ou pedir humano, encerre/limpe sticky e deixe Lucia (Jasmine) responder com fallback apropriado.
|
|
||||||
|
|
||||||
6. **Logs e documentação**
|
|
||||||
- Sempre que alterar algo, registre nas instruções/prompt das agents (ex: prompt da Daniela deve explicitar o roteiro).
|
|
||||||
- Se necessário fazer mudanças pontuais, vincule essas mudanças ao fluxo (não apenas "ajustar o Pix").
|
|
||||||
- Use este documento como ponto de referência antes de testar: se você “esquecer”, leia e repita mentalmente o que o Rodrigo quer.
|
|
||||||
|
|
||||||
Se precisar de ajuda para transformar isso em tarefas técnicas (migrations, short-circuit, state), é só avisar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documento de referência ampliado (fluxo reserva + Pix)
|
|
||||||
|
|
||||||
### 1. Objetivo “padrão-ouro”
|
|
||||||
|
|
||||||
O sistema deve rodar este fluxo previsível para cada pedido de reserva:
|
|
||||||
|
|
||||||
1. Cliente cumprimenta → Jasmine responde educadamente.
|
|
||||||
2. Cliente manifesta “quero reservar” → Jasmine delega para Daniela.
|
|
||||||
3. Daniela conduz todo o plano: coleta dados, chama ferramentas (precio/disponibilidade/pix), monta a próxima mensagem.
|
|
||||||
4. Jasmine publica o texto final no tom oficial (voz única para o cliente).
|
|
||||||
5. Coleta obrigatória concentrada na Daniela: nome completo, CPF, suíte, data, horário/período.
|
|
||||||
6. Daniela confirma: “Sua reserva ficou assim… total R$T. Posso gerar o Pix (50%)?”
|
|
||||||
7. Cliente confirma → Daniela gera Pix de metade do total e instrui “copie e cole no seu banco”.
|
|
||||||
8. Após o cliente avisar “paguei”, Daniela confirma o pagamento e informa que a reserva está confirmada.
|
|
||||||
|
|
||||||
Sucesso significa que a reserva segue esse roteiro, com Pix gerado/confirmado e sem decisões aleatórias.
|
|
||||||
|
|
||||||
### 2. Papéis e responsabilidades
|
|
||||||
|
|
||||||
- **Jasmine (“interface única”)**: cumprimenta, decide se deve delegar, publica as respostas da Daniela e mantém o tom padrão. Nunca responde diretamente sobre reservas, preços ou Pix — sempre delega ao subagente adequado.
|
|
||||||
- **Daniela (“motor transacional”)**: permanece na camada interna. Recebe contexto, coleta dados, valida, chama `check_availability`, `create_reservation_intent`, `generate_pix`, constrói o `mensagem_para_cliente` e retorna também o `estado_atualizado` e as ações requisitadas.
|
|
||||||
|
|
||||||
### 3. Regras de comportamento
|
|
||||||
|
|
||||||
- Tom educado e uniforme mesmo quando a resposta vem da Daniela.
|
|
||||||
- Não inventar preços ou disponibilidade: os valores derivam exclusivamente de `check_availability` e do histórico fornecido.
|
|
||||||
- Sempre perguntar “posso gerar o Pix?” antes de chamar `generate_pix`.
|
|
||||||
- Não sair do fluxo: enquanto o sticky scenario estiver ativo, o Brain não deve responder com mensagens independentes ou fallback.
|
|
||||||
|
|
||||||
### 4. Arquitetura mínima para consistência
|
|
||||||
|
|
||||||
A) **Sticky “modo reserva”** – Conversa persiste: `active_scenario_key`, `active_scenario_expires_at`, `active_scenario_state`. Uma vez ativado por Daniela, todas as próximas mensagens vão direto para ela até conclusão/cancelamento.
|
|
||||||
B) **Estado mínimo persistido** – `active_scenario_state` deve conter:
|
|
||||||
|
|
||||||
- `stage` (p.ex. collecting_data, quoting, waiting_payment)
|
|
||||||
- `collected` (nome, CPF, suíte, data, horário)
|
|
||||||
- `last_quote` (preço, disponibilidade, dia/turno)
|
|
||||||
- `pix_status` (pending/paid/confirmed)
|
|
||||||
Isso evita perda de memória entre turnos e mantém o fluxo determinístico.
|
|
||||||
|
|
||||||
### 5. Tratamento de erros (visão macro)
|
|
||||||
|
|
||||||
Quando surgir um erro (resposta desalinhada, ferramenta falhando, handoff disparado), trace o contexto:
|
|
||||||
|
|
||||||
1. Qual stage estava ativo?
|
|
||||||
2. Jasmine desviou e respondeu sozinha?
|
|
||||||
3. Daniela repetiu perguntas ou perdeu dados?
|
|
||||||
4. Qual tool falhou ou retornou inválido?
|
|
||||||
Corrija sempre com base nesse fluxo padrão — não apenas “ajuste a frase”, mas reforce o sticky/state/sequence.
|
|
||||||
|
|
||||||
### 6. Como trabalhamos na prática
|
|
||||||
|
|
||||||
Sempre que você me mostrar um erro, envie:
|
|
||||||
|
|
||||||
1. Mensagem do cliente.
|
|
||||||
2. Resposta publicada pela Jasmine.
|
|
||||||
3. Trecho do log relevante (20–60 linhas) indicando decisão/tool.
|
|
||||||
|
|
||||||
Com isso eu devolvo:
|
|
||||||
|
|
||||||
- Onde travar o sticky/state.
|
|
||||||
- Qual prompt deve ser ajustado.
|
|
||||||
- Qual ferramenta/filtro precisa validar o valor ou o Pix.
|
|
||||||
|
|
||||||
### 7. Regra mestre
|
|
||||||
|
|
||||||
“Daniela é o cérebro do fluxo transacional e retorna o texto pronto; Jasmine é a única voz que publica para o cliente no tom oficial. O sistema deve manter estado e sticky para que o fluxo seja determinístico.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Simulação de atendimento ideal – experiência perfeita de reserva
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Boa noite
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Boa noite! 😊
|
|
||||||
Seja bem-vindo. Como posso te ajudar hoje?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Gostaria de fazer uma reserva
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito! Vou te ajudar com isso 😊
|
|
||||||
Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
João Pedro da Silva, CPF 123.456.789-00
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Obrigada, João 😊
|
|
||||||
Agora me diga, por favor: qual suíte você deseja reservar?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Suíte Luxo
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Ótima escolha! ✨
|
|
||||||
Para qual data e horário/período você gostaria da reserva?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Dia 20/02, das 22h às 10h
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito! Só um instante que vou verificar a disponibilidade e os valores para você 😊
|
|
||||||
|
|
||||||
(internamente: Daniela consulta disponibilidade e preço)
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Prontinho! 😊
|
|
||||||
Sua reserva ficou assim:
|
|
||||||
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Valor total: R$ 300,00
|
|
||||||
|
|
||||||
Para confirmar a reserva, é necessário o pagamento de 50% (R$ 150,00).
|
|
||||||
Posso gerar o Pix para você agora?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Pode sim
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito 😊
|
|
||||||
Segue abaixo o Pix para pagamento da entrada:
|
|
||||||
|
|
||||||
💰 Valor: R$ 150,00
|
|
||||||
📌 Chave Pix: 123e4567-e89b-12d3-a456-426614174000
|
|
||||||
|
|
||||||
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento.
|
|
||||||
Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Pronto, acabei de pagar
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Só um instante que vou confirmar o pagamento para você 😊
|
|
||||||
|
|
||||||
(internamente: Daniela confirma pagamento)
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Pagamento confirmado com sucesso! ✅
|
|
||||||
|
|
||||||
Sua reserva está confirmada 🎉
|
|
||||||
Resumo final:
|
|
||||||
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Entrada paga: R$ 150,00
|
|
||||||
|
|
||||||
Qualquer dúvida ou se precisar de algo mais, é só me chamar.
|
|
||||||
Será um prazer te receber! 😊✨
|
|
||||||
|
|
||||||
### 🔑 Por que essa experiência é boa
|
|
||||||
|
|
||||||
- Linguagem humana e educada
|
|
||||||
- Um pedido por vez (sem sobrecarregar)
|
|
||||||
- Confirmação clara antes da cobrança
|
|
||||||
- Segurança no Pix
|
|
||||||
- Encerramento com sensação de sucesso
|
|
||||||
|
|
||||||
Se quiser, nos próximos passos posso:
|
|
||||||
|
|
||||||
- marcar onde entra cada stage,
|
|
||||||
- transformar isso em script técnico para os prompts da Daniela/Jasmine, ou
|
|
||||||
- criar a versão “anti-confusão” (quando o cliente muda de assunto ou responde errado).
|
|
||||||
|
|
||||||
### 💬 Instrução extra: Pix Copia e Cola completo
|
|
||||||
|
|
||||||
Quando o GeneratePixTool retornar o código, **garanta que o bloco de código contenha a string inteira**, iniciando em `0002010...` e terminando no checksum `...6304XXXX`. Esse é o valor que o cliente precisa colar no banco; qualquer corte causa “Pix inválido”. Copie exatamente o `raw_payload` do tool (ou o `formatted_message` caso já contenha a string completa) e anexe no bloco antes de enviar. Nada deve ser truncado ou substituído pela Jasmine depois de a mensagem sair.
|
|
||||||
24
.github/workflows/deploy_check.yml
vendored
24
.github/workflows/deploy_check.yml
vendored
@ -14,37 +14,35 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
deployment_check:
|
deployment_check:
|
||||||
name: Check Deployment
|
name: Check Deployment
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, oracle-arm]
|
||||||
steps:
|
steps:
|
||||||
- name: Install jq
|
|
||||||
run: sudo apt-get install -y jq
|
|
||||||
- name: Print Deployment URL
|
- name: Print Deployment URL
|
||||||
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
|
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
|
||||||
- name: Check Deployment Status
|
- name: Check Deployment Status
|
||||||
run: |
|
run: |
|
||||||
max_attempts=10
|
max_attempts=15
|
||||||
attempt=1
|
attempt=1
|
||||||
status_code=0
|
status_code=0
|
||||||
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..."
|
echo "Checking review app status..."
|
||||||
sleep 600
|
|
||||||
while [ $attempt -le $max_attempts ]; do
|
while [ $attempt -le $max_attempts ]; do
|
||||||
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||||
status_code=$(echo $response | head -n 1)
|
status_code=$(echo $response | head -n 1)
|
||||||
if [ $status_code -eq 200 ]; then
|
if [ $status_code -eq 200 ]; then
|
||||||
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||||
if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then
|
# Check for required fields using grep to avoid jq dependency on self-hosted runners
|
||||||
|
if echo "$body" | grep -q '"version"' && echo "$body" | grep -q '"timestamp"' && echo "$body" | grep -q '"queue_services":"ok"' && echo "$body" | grep -q '"data_services":"ok"'; then
|
||||||
echo "Deployment successful"
|
echo "Deployment successful"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
echo "Deployment status unknown, retrying in 3 minutes..."
|
echo "Deployment status healthy but services not ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
|
||||||
sleep 180
|
sleep 30
|
||||||
|
attempt=$((attempt + 1))
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Waiting for review app to be ready, retrying in 3 minutes..."
|
echo "Waiting for review app to be ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
|
||||||
sleep 180
|
sleep 30
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "Deployment failed after $max_attempts attempts"
|
echo "Deployment check timed out after $max_attempts attempts"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
93
.github/workflows/run_foss_spec.yml
vendored
93
.github/workflows/run_foss_spec.yml
vendored
@ -21,68 +21,33 @@ jobs:
|
|||||||
- name: Run Rubocop
|
- name: Run Rubocop
|
||||||
run: bundle exec rubocop --parallel
|
run: bundle exec rubocop --parallel
|
||||||
|
|
||||||
lint-frontend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 23
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run ESLint
|
|
||||||
run: pnpm run eslint
|
|
||||||
|
|
||||||
# Frontend tests run in parallel with backend
|
|
||||||
frontend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 23
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Backend tests with parallelization
|
# Backend tests with parallelization
|
||||||
backend-tests:
|
backend-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, oracle-arm]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 8
|
||||||
matrix:
|
matrix:
|
||||||
ci_node_total: [16]
|
ci_node_total: [16]
|
||||||
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_HOST: localhost
|
||||||
POSTGRES_PASSWORD: ''
|
RAILS_ENV: test
|
||||||
POSTGRES_DB: postgres
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: --entrypoint redis-server
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Start services
|
||||||
|
run: |
|
||||||
|
docker run -d --name pg-${{ matrix.ci_node_index }}-${{ github.run_id }} \
|
||||||
|
-e POSTGRES_PASSWORD= -e POSTGRES_DB=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
|
||||||
|
-p ${{ 5432 + matrix.ci_node_index }}:5432 \
|
||||||
|
pgvector/pgvector:pg16 --mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
|
|
||||||
|
docker run -d --name redis-${{ matrix.ci_node_index }}-${{ github.run_id }} \
|
||||||
|
-p ${{ 6379 + matrix.ci_node_index }}:6379 \
|
||||||
|
redis:alpine
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
@ -105,12 +70,32 @@ jobs:
|
|||||||
rm -rf enterprise
|
rm -rf enterprise
|
||||||
rm -rf spec/enterprise
|
rm -rf spec/enterprise
|
||||||
|
|
||||||
|
- name: Wait for services
|
||||||
|
run: |
|
||||||
|
echo "Waiting for services to be ready..."
|
||||||
|
attempt=0
|
||||||
|
until pg_isready -h localhost -p $POSTGRES_PORT || [ $attempt -eq 5 ]; do
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
- name: Set up database
|
||||||
|
run: |
|
||||||
|
psql -U postgres -h localhost -p $POSTGRES_PORT -c "CREATE ROLE root LOGIN SUPERUSER;"
|
||||||
|
psql -U postgres -h localhost -p $POSTGRES_PORT -c "CREATE DATABASE chatwoot_dev OWNER root;"
|
||||||
|
env:
|
||||||
|
PGPASSWORD: ''
|
||||||
|
|
||||||
- name: Create database
|
- name: Create database
|
||||||
run: bundle exec rake db:create
|
run: bundle exec rake db:create
|
||||||
|
|
||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: bundle exec rake db:migrate
|
||||||
|
|
||||||
- name: Run backend tests (parallelized)
|
- name: Run backend tests (parallelized)
|
||||||
run: |
|
run: |
|
||||||
# Get all spec files and split them using round-robin distribution
|
# Get all spec files and split them using round-robin distribution
|
||||||
@ -144,3 +129,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: rails-log-folder-${{ matrix.ci_node_index }}
|
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||||
path: log
|
path: log
|
||||||
|
|
||||||
|
- name: Stop services
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker stop $PG_CONTAINER_NAME || true
|
||||||
|
docker rm $PG_CONTAINER_NAME || true
|
||||||
|
docker stop $RD_CONTAINER_NAME || true
|
||||||
|
docker rm $RD_CONTAINER_NAME || true
|
||||||
|
|||||||
2
.github/workflows/run_mfa_spec.yml
vendored
2
.github/workflows/run_mfa_spec.yml
vendored
@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
2
.github/workflows/size-limit.yml
vendored
2
.github/workflows/size-limit.yml
vendored
@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -95,9 +95,20 @@ yarn-debug.log*
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
.agent/
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
local/
|
local/
|
||||||
|
|
||||||
|
# Reference and temporary files
|
||||||
|
reference_chatwoot/
|
||||||
|
*.jpg
|
||||||
|
*.png
|
||||||
|
*.txt
|
||||||
|
reproduce_encoding.rb
|
||||||
|
debug_media.rb
|
||||||
|
test.jpg
|
||||||
|
teste_imagem.jpg
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
inherit_from: .rubocop_todo.yml
|
||||||
|
|
||||||
|
inherit_mode:
|
||||||
|
merge:
|
||||||
|
- Exclude
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- rubocop-performance
|
- rubocop-performance
|
||||||
- rubocop-rails
|
- rubocop-rails
|
||||||
@ -23,6 +29,7 @@ Metrics/MethodLength:
|
|||||||
Max: 19
|
Max: 19
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'enterprise/lib/captain/agent.rb'
|
- 'enterprise/lib/captain/agent.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
|
||||||
|
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
Max: 50
|
Max: 50
|
||||||
@ -230,6 +237,7 @@ AllCops:
|
|||||||
- 'tmp/**/*'
|
- 'tmp/**/*'
|
||||||
- 'storage/**/*'
|
- 'storage/**/*'
|
||||||
- 'db/migrate/20230426130150_init_schema.rb'
|
- 'db/migrate/20230426130150_init_schema.rb'
|
||||||
|
- 'reference_chatwoot/**/*'
|
||||||
|
|
||||||
FactoryBot/SyntaxMethods:
|
FactoryBot/SyntaxMethods:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|||||||
331
.rubocop_todo.yml
Normal file
331
.rubocop_todo.yml
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
# This configuration was generated by
|
||||||
|
# `rubocop --auto-gen-config --exclude-limit 10000`
|
||||||
|
# on 2026-01-25 12:20:43 UTC using RuboCop version 1.75.6.
|
||||||
|
# The point is for the user to remove these configuration records
|
||||||
|
# one by one as the offenses are removed from the code base.
|
||||||
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
# versions of RuboCop, may require this file to be generated again.
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
Chatwoot/AttachmentDownload:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi_service.rb'
|
||||||
|
- 'debug_media.rb'
|
||||||
|
|
||||||
|
# Offense count: 32
|
||||||
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
|
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
|
||||||
|
# URISchemes: http, https
|
||||||
|
Layout/LineLength:
|
||||||
|
Exclude:
|
||||||
|
- 'app/jobs/conversations/cluster_job.rb'
|
||||||
|
- 'app/models/channel/whatsapp.rb'
|
||||||
|
- 'app/services/captain/reservations/sync_service.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi_service.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/conversation/debounce_response_job.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
|
||||||
|
- 'enterprise/app/services/enterprise/message_templates/hook_execution_service.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
|
||||||
|
- 'lib/wuzapi/client.rb'
|
||||||
|
- 'scripts/debug_faq_search.rb'
|
||||||
|
- 'spec/factories/contacts.rb'
|
||||||
|
|
||||||
|
# Offense count: 4
|
||||||
|
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
|
||||||
|
Lint/DuplicateBranch:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
Lint/DuplicateMethods:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/jasmine/semantic_search_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
# Configuration parameters: MaximumRangeSize.
|
||||||
|
Lint/MissingCopEnableDirective:
|
||||||
|
Exclude:
|
||||||
|
- 'db/migrate/20260114100000_create_captain_inbox_automations.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
Lint/NonLocalExitFromIterator:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
Lint/ShadowedException:
|
||||||
|
Exclude:
|
||||||
|
- 'enterprise/app/services/captain/llm/paginated_faq_generator_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
|
# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
|
||||||
|
# NotImplementedExceptions: NotImplementedError
|
||||||
|
Lint/UnusedMethodArgument:
|
||||||
|
Exclude:
|
||||||
|
- 'enterprise/app/services/llm/base_ai_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 63
|
||||||
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
||||||
|
Metrics/AbcSize:
|
||||||
|
Exclude:
|
||||||
|
- 'app/jobs/conversations/auto_label_job.rb'
|
||||||
|
- 'app/jobs/conversations/resolution_job.rb'
|
||||||
|
- 'app/jobs/webhooks/whatsapp_events_job.rb'
|
||||||
|
- 'app/listeners/jasmine_listener.rb'
|
||||||
|
- 'app/models/channel/whatsapp.rb'
|
||||||
|
- 'app/models/message.rb'
|
||||||
|
- 'app/services/captain/reservations/sync_service.rb'
|
||||||
|
- 'app/services/crm_insights/update_service.rb'
|
||||||
|
- 'app/services/jasmine/brain_service.rb'
|
||||||
|
- 'app/services/jasmine/semantic_search_service.rb'
|
||||||
|
- 'app/services/jasmine/tool_runner.rb'
|
||||||
|
- 'app/services/whatsapp/decryption_service.rb'
|
||||||
|
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi_service.rb'
|
||||||
|
- 'config/initializers/fix_null_message_crash.rb'
|
||||||
|
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
|
||||||
|
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
|
||||||
|
- 'debug_token.rb'
|
||||||
|
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/api/v1/accounts/captain/tools_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
|
||||||
|
- 'enterprise/app/models/captain/assistant.rb'
|
||||||
|
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/inter/auth_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/inter/cob_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
|
||||||
|
- 'enterprise/app/services/captain/reminders/create_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/reservations/create_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/http_tool.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
|
||||||
|
- 'lib/wuzapi/client.rb'
|
||||||
|
|
||||||
|
# Offense count: 6
|
||||||
|
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
|
||||||
|
# AllowedMethods: refine
|
||||||
|
Metrics/BlockLength:
|
||||||
|
Exclude:
|
||||||
|
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
|
||||||
|
- 'enterprise/app/models/concerns/captain_tools_helpers.rb'
|
||||||
|
- 'script/test_auto_resolve_inbox.rb'
|
||||||
|
- 'seed_captain_tools.rb'
|
||||||
|
- 'seed_jasmine_hotel_v2.rb'
|
||||||
|
|
||||||
|
# Offense count: 11
|
||||||
|
# Configuration parameters: CountComments, Max, CountAsOne.
|
||||||
|
Metrics/ClassLength:
|
||||||
|
Exclude:
|
||||||
|
- 'app/models/channel/whatsapp.rb'
|
||||||
|
- 'app/services/crm_insights/update_service.rb'
|
||||||
|
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
|
||||||
|
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/tool_runner.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
|
||||||
|
|
||||||
|
# Offense count: 67
|
||||||
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||||
|
Metrics/CyclomaticComplexity:
|
||||||
|
Max: 39
|
||||||
|
|
||||||
|
# Offense count: 78
|
||||||
|
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
|
||||||
|
Metrics/MethodLength:
|
||||||
|
Exclude:
|
||||||
|
- 'app/jobs/conversations/auto_label_job.rb'
|
||||||
|
- 'app/jobs/conversations/cluster_job.rb'
|
||||||
|
- 'app/jobs/jasmine/response_job.rb'
|
||||||
|
- 'app/listeners/jasmine_listener.rb'
|
||||||
|
- 'app/models/channel/whatsapp.rb'
|
||||||
|
- 'app/services/captain/inter_service.rb'
|
||||||
|
- 'app/services/captain/reservations/sync_service.rb'
|
||||||
|
- 'app/services/crm_insights/update_service.rb'
|
||||||
|
- 'app/services/jasmine/brain_service.rb'
|
||||||
|
- 'app/services/jasmine/embedding_service.rb'
|
||||||
|
- 'app/services/jasmine/semantic_search_service.rb'
|
||||||
|
- 'app/services/jasmine/tool_runner.rb'
|
||||||
|
- 'app/services/jasmine/vision_service.rb'
|
||||||
|
- 'app/services/whatsapp/decryption_service.rb'
|
||||||
|
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
|
||||||
|
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi_service.rb'
|
||||||
|
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
|
||||||
|
- 'db/migrate/20260104150000_add_crm_insights_history_fields.rb'
|
||||||
|
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
|
||||||
|
- 'db/migrate/20260114090200_create_captain_reminders.rb'
|
||||||
|
- 'debug_token.rb'
|
||||||
|
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/intent_classification_job.rb'
|
||||||
|
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
|
||||||
|
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/reminders/create_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/reservations/create_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/reminder_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/webhook_sender_service.rb'
|
||||||
|
- 'enterprise/app/services/captain/whatsapp_notification_service.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
|
||||||
|
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
|
||||||
|
- 'lib/wuzapi/client.rb'
|
||||||
|
- 'scripts/force_create_captain_tables.rb'
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
# Configuration parameters: CountComments, CountAsOne.
|
||||||
|
Metrics/ModuleLength:
|
||||||
|
Max: 132
|
||||||
|
|
||||||
|
# Offense count: 4
|
||||||
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
||||||
|
Metrics/ParameterLists:
|
||||||
|
Max: 7
|
||||||
|
|
||||||
|
# Offense count: 46
|
||||||
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||||
|
Metrics/PerceivedComplexity:
|
||||||
|
Max: 40
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
Naming/AccessorMethodName:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/captain/inter_service.rb'
|
||||||
|
- 'app/services/jasmine/vision_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 7
|
||||||
|
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
|
||||||
|
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
|
||||||
|
Naming/MethodParameterName:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/whatsapp/decryption_service.rb'
|
||||||
|
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
# Configuration parameters: MinSize.
|
||||||
|
Performance/CollectionLiteralInLoop:
|
||||||
|
Exclude:
|
||||||
|
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
Rails/AfterCommitOverride:
|
||||||
|
Exclude:
|
||||||
|
- 'app/models/channel/whatsapp.rb'
|
||||||
|
|
||||||
|
# Offense count: 5
|
||||||
|
# Configuration parameters: Include.
|
||||||
|
# Include: **/app/models/**/*.rb
|
||||||
|
Rails/HasManyOrHasOneDependent:
|
||||||
|
Exclude:
|
||||||
|
- 'enterprise/app/models/captain/brand.rb'
|
||||||
|
- 'enterprise/app/models/captain/unit.rb'
|
||||||
|
|
||||||
|
# Offense count: 17
|
||||||
|
# Configuration parameters: IgnoreScopes, Include.
|
||||||
|
# Include: **/app/models/**/*.rb
|
||||||
|
Rails/InverseOf:
|
||||||
|
Exclude:
|
||||||
|
- 'app/models/captain/unit.rb'
|
||||||
|
- 'app/models/captain_assistant.rb'
|
||||||
|
- 'enterprise/app/models/captain/brand.rb'
|
||||||
|
- 'enterprise/app/models/captain/pricing.rb'
|
||||||
|
- 'enterprise/app/models/captain/pricing_inbox.rb'
|
||||||
|
- 'enterprise/app/models/captain/reservation.rb'
|
||||||
|
- 'enterprise/app/models/captain/unit.rb'
|
||||||
|
- 'enterprise/app/models/captain_inbox.rb'
|
||||||
|
|
||||||
|
# Offense count: 9
|
||||||
|
# Configuration parameters: ForbiddenMethods, AllowedMethods.
|
||||||
|
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
|
||||||
|
Rails/SkipsModelValidations:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/jasmine/tool_runner.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/documents/response_builder_job.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
|
||||||
|
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
|
||||||
|
- 'promote_super_admin.rb'
|
||||||
|
- 'script/test_auto_resolve_inbox.rb'
|
||||||
|
|
||||||
|
# Offense count: 8
|
||||||
|
# Configuration parameters: Include.
|
||||||
|
# Include: db/**/*.rb
|
||||||
|
Rails/ThreeStateBooleanColumn:
|
||||||
|
Exclude:
|
||||||
|
- 'db/migrate/20240523120000_create_captain_configurations.rb'
|
||||||
|
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
|
||||||
|
- 'db/migrate/20251227201733_create_captain_tool_configs.rb'
|
||||||
|
- 'db/migrate/20260114101004_create_captain_extras.rb'
|
||||||
|
- 'db/migrate/20260120141736_add_message_signature_enabled_to_inboxes.rb'
|
||||||
|
- 'db/migrate/20260121165034_add_plug_play_to_captain_units.rb'
|
||||||
|
|
||||||
|
# Offense count: 2
|
||||||
|
# Configuration parameters: TransactionMethods.
|
||||||
|
Rails/TransactionExitStatement:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/jasmine/embedding_service.rb'
|
||||||
|
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 1
|
||||||
|
# Configuration parameters: Include.
|
||||||
|
# Include: **/app/models/**/*.rb
|
||||||
|
Rails/UniqueValidationWithoutIndex:
|
||||||
|
Exclude:
|
||||||
|
- 'app/models/jasmine/inbox_collection.rb'
|
||||||
|
|
||||||
|
# Offense count: 3
|
||||||
|
# Configuration parameters: MinBranchesCount.
|
||||||
|
Style/HashLikeCase:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
|
||||||
|
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
|
||||||
|
|
||||||
|
# Offense count: 4
|
||||||
|
Style/OneClassPerFile:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/jasmine/brain_service.rb'
|
||||||
|
|
||||||
|
# Offense count: 8
|
||||||
|
UseFromEmail:
|
||||||
|
Exclude:
|
||||||
|
- 'app/services/captain/reservations/sync_service.rb'
|
||||||
|
- 'clear_chat_history.rb'
|
||||||
|
- 'debug_auth.rb'
|
||||||
|
- 'debug_token.rb'
|
||||||
|
- 'force_reset.rb'
|
||||||
|
- 'promote_super_admin.rb'
|
||||||
|
- 'setup_docker_env.rb'
|
||||||
3
Gemfile
3
Gemfile
@ -60,6 +60,7 @@ gem 'aws-actionmailbox-ses', '~> 0'
|
|||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
|
gem 'fiddle'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
gem 'redis'
|
gem 'redis'
|
||||||
gem 'redis-namespace'
|
gem 'redis-namespace'
|
||||||
@ -271,4 +272,4 @@ group :development, :test do
|
|||||||
gem 'spring-watcher-listen'
|
gem 'spring-watcher-listen'
|
||||||
end
|
end
|
||||||
|
|
||||||
gem "rqrcode", "~> 3.2"
|
gem 'rqrcode', '~> 3.2'
|
||||||
|
|||||||
20
Gemfile.lock
20
Gemfile.lock
@ -198,7 +198,7 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
csv (3.3.0)
|
csv (3.3.5)
|
||||||
csv-safe (3.3.1)
|
csv-safe (3.3.1)
|
||||||
csv (~> 3.0)
|
csv (~> 3.0)
|
||||||
database_cleaner (2.0.2)
|
database_cleaner (2.0.2)
|
||||||
@ -214,7 +214,7 @@ GEM
|
|||||||
logger
|
logger
|
||||||
msgpack
|
msgpack
|
||||||
datadog-ruby_core_source (3.4.1)
|
datadog-ruby_core_source (3.4.1)
|
||||||
date (3.4.1)
|
date (3.5.1)
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@ -338,6 +338,7 @@ GEM
|
|||||||
ffi-compiler (1.0.1)
|
ffi-compiler (1.0.1)
|
||||||
ffi (>= 1.0.0)
|
ffi (>= 1.0.0)
|
||||||
rake
|
rake
|
||||||
|
fiddle (1.1.8)
|
||||||
flag_shih_tzu (0.3.23)
|
flag_shih_tzu (0.3.23)
|
||||||
foreman (0.87.2)
|
foreman (0.87.2)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
@ -564,11 +565,11 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.6.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@ -653,7 +654,7 @@ GEM
|
|||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.8.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
@ -958,7 +959,7 @@ GEM
|
|||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
i18n
|
i18n
|
||||||
timeout (0.4.3)
|
timeout (0.6.0)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
ttfunk (1.8.0)
|
ttfunk (1.8.0)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
@ -982,7 +983,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@ -1071,6 +1072,7 @@ DEPENDENCIES
|
|||||||
faker
|
faker
|
||||||
faraday_middleware-aws-sigv4
|
faraday_middleware-aws-sigv4
|
||||||
fcm
|
fcm
|
||||||
|
fiddle
|
||||||
flag_shih_tzu
|
flag_shih_tzu
|
||||||
foreman
|
foreman
|
||||||
geocoder
|
geocoder
|
||||||
@ -1183,4 +1185,4 @@ RUBY VERSION
|
|||||||
ruby 3.4.4p34
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.11
|
2.5.5
|
||||||
|
|||||||
9
Makefile
9
Makefile
@ -36,13 +36,13 @@ run:
|
|||||||
@if [ -f ./.overmind.sock ]; then \
|
@if [ -f ./.overmind.sock ]; then \
|
||||||
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
|
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
|
||||||
else \
|
else \
|
||||||
overmind start -f Procfile.dev; \
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
force_run:
|
force_run:
|
||||||
rm -f ./.overmind.sock
|
rm -f ./.overmind.sock
|
||||||
rm -f tmp/pids/*.pid
|
rm -f tmp/pids/*.pid
|
||||||
overmind start -f Procfile.dev
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev
|
||||||
|
|
||||||
force_run_tunnel:
|
force_run_tunnel:
|
||||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||||
@ -59,4 +59,7 @@ debug_worker:
|
|||||||
docker:
|
docker:
|
||||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||||
|
|
||||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
|
sidekiq:
|
||||||
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
|
||||||
|
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker sidekiq
|
||||||
|
|||||||
1
app.json
1
app.json
@ -62,7 +62,6 @@
|
|||||||
"plan": "heroku-postgresql:essential-0"
|
"plan": "heroku-postgresql:essential-0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stack": "heroku-24",
|
|
||||||
"buildpacks": [
|
"buildpacks": [
|
||||||
{
|
{
|
||||||
"url": "heroku/nodejs"
|
"url": "heroku/nodejs"
|
||||||
|
|||||||
@ -6,18 +6,13 @@ class Messages::MessageBuilder
|
|||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@params = params
|
|
||||||
@private = params[:private] || false
|
|
||||||
@conversation = conversation
|
|
||||||
@user = user
|
@user = user
|
||||||
|
@conversation = conversation
|
||||||
@account = conversation.account
|
@account = conversation.account
|
||||||
@message_type = params[:message_type] || 'outgoing'
|
@params = params
|
||||||
@attachments = params[:attachments]
|
|
||||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
|
||||||
return unless params.instance_of?(ActionController::Parameters)
|
return unless params.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
init_message_attributes
|
||||||
@items = content_attributes&.dig(:items)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
@ -56,14 +51,12 @@ class Messages::MessageBuilder
|
|||||||
file: uploaded_attachment
|
file: uploaded_attachment
|
||||||
)
|
)
|
||||||
|
|
||||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
attachment.file_type = resolve_file_type(uploaded_attachment)
|
||||||
file_type_by_signed_id(
|
|
||||||
uploaded_attachment
|
|
||||||
)
|
|
||||||
else
|
|
||||||
file_type(uploaded_attachment&.content_type)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_file_type(attachment)
|
||||||
|
attachment.is_a?(String) ? file_type_by_signed_id(attachment) : file_type(attachment&.content_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_emails
|
def process_emails
|
||||||
@ -140,7 +133,7 @@ class Messages::MessageBuilder
|
|||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
content_attributes: content_attributes.presence,
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to_id: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
source_id: @params[:source_id]
|
source_id: @params[:source_id]
|
||||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||||
@ -222,6 +215,23 @@ class Messages::MessageBuilder
|
|||||||
'agent' => UserDrop.new(sender)
|
'agent' => UserDrop.new(sender)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def init_message_attributes
|
||||||
|
@private = @params[:private] || false
|
||||||
|
@message_type = @params[:message_type] || 'outgoing'
|
||||||
|
@attachments = @params[:attachments]
|
||||||
|
@automation_rule = extract_automation_rule
|
||||||
|
@in_reply_to = extract_in_reply_to
|
||||||
|
@items = content_attributes&.dig(:items)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_automation_rule
|
||||||
|
content_attributes&.dig(:automation_rule_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_in_reply_to
|
||||||
|
@params[:in_reply_to_id] || @params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Captain
|
|
||||||
class AssistantsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
|
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -66,7 +62,3 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,19 +1,88 @@
|
|||||||
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_pricings, only: [:index]
|
before_action :fetch_pricings, only: [:index]
|
||||||
|
before_action :fetch_pricing, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: @pricings
|
render json: @pricings
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @pricing
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
inbox_ids_param = params[:pricing][:inbox_ids]
|
||||||
|
|
||||||
|
if inbox_ids_param.is_a?(Array) && inbox_ids_param.present?
|
||||||
|
render json: create_for_multiple_inboxes(inbox_ids_param)
|
||||||
|
else
|
||||||
|
render json: create_single_pricing
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating pricing: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
# On update, if multiple inboxes are passed, we technically can't 'split' this ID.
|
||||||
|
# We assume usage of the first inbox or nil.
|
||||||
|
# If the user wants to assign to multiple, they should create new ones.
|
||||||
|
target_inbox_id = params[:pricing][:inbox_ids]&.first
|
||||||
|
|
||||||
|
@pricing.update!(pricing_params.merge(inbox_id: target_inbox_id))
|
||||||
|
render json: @pricing
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@pricing.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_pricings
|
def fetch_pricing
|
||||||
@pricings = current_account.captain_pricings
|
@pricing = current_account.captain_pricings.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
return unless params[:query].present?
|
def fetch_pricings
|
||||||
|
@pricings = current_account.captain_pricings.order(created_at: :desc)
|
||||||
|
|
||||||
|
# Filter by inbox if provided (returns Specific Inbox + Global rules)
|
||||||
|
@pricings = @pricings.where(inbox_id: [params[:inbox_id], nil]) if params[:inbox_id].present?
|
||||||
|
|
||||||
|
return if params[:query].blank?
|
||||||
|
|
||||||
# Fuzzy search using ILIKE for case-insensitive matching
|
# Fuzzy search using ILIKE for case-insensitive matching
|
||||||
# We wrap the query in % for wildcard matching on both sides
|
@pricings = @pricings.left_outer_joins(:captain_brand).where(
|
||||||
@pricings = @pricings.where('suite_category ILIKE ?', "%#{params[:query]}%")
|
'suite_category ILIKE :query OR captain_brands.name ILIKE :query',
|
||||||
|
query: "%#{params[:query]}%"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pricing_params
|
||||||
|
params.require(:pricing).permit(:captain_brand_id, :day_range, :suite_category, :duration, :price)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_for_multiple_inboxes(inbox_ids)
|
||||||
|
last_pricing = nil
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
inbox_ids.each do |iid|
|
||||||
|
pricing = current_account.captain_pricings.new(pricing_params)
|
||||||
|
pricing.inbox_id = iid.to_i
|
||||||
|
pricing.save!
|
||||||
|
last_pricing = pricing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
last_pricing
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_single_pricing
|
||||||
|
@pricing = current_account.captain_pricings.new(pricing_params)
|
||||||
|
@pricing.inbox_id = nil
|
||||||
|
@pricing.save!
|
||||||
|
@pricing
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Captain
|
|
||||||
class ScenariosController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_assistant
|
before_action :fetch_assistant
|
||||||
before_action :fetch_scenario, only: [:show, :update, :destroy]
|
before_action :fetch_scenario, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
@ -65,7 +61,3 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Captain::ToolsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Captain
|
|
||||||
class ToolsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_assistant
|
before_action :fetch_assistant
|
||||||
|
|
||||||
NATIVE_TOOLS = [
|
NATIVE_TOOLS = [
|
||||||
@ -14,7 +10,7 @@ module Api
|
|||||||
{ key: 'list_reservations', name: 'List Reservations', description: 'Lista reservas anteriores do cliente.' },
|
{ key: 'list_reservations', name: 'List Reservations', description: 'Lista reservas anteriores do cliente.' },
|
||||||
{ key: 'status_suites', name: 'Status Suites', description: 'Verifica o status atual de ocupação das suítes.' },
|
{ key: 'status_suites', name: 'Status Suites', description: 'Verifica o status atual de ocupação das suítes.' },
|
||||||
{ key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
|
{ key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
|
||||||
]
|
].freeze
|
||||||
|
|
||||||
def index
|
def index
|
||||||
tools = NATIVE_TOOLS.map do |tool|
|
tools = NATIVE_TOOLS.map do |tool|
|
||||||
@ -67,7 +63,3 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Captain::Units::ReservationsSyncController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Captain
|
|
||||||
module Units
|
|
||||||
class ReservationsSyncController < Api::V1::Accounts::BaseController
|
|
||||||
def create
|
def create
|
||||||
unit = Current.account.captain_units.find(params[:unit_id])
|
unit = Current.account.captain_units.find(params[:unit_id])
|
||||||
::Captain::Reservations::SyncService.new(unit).perform
|
::Captain::Reservations::SyncService.new(unit).perform
|
||||||
@ -14,8 +9,3 @@ module Api
|
|||||||
render_error(e.message)
|
render_error(e.message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Captain
|
|
||||||
class UnitsController < Api::V1::Accounts::BaseController
|
|
||||||
def index
|
def index
|
||||||
@units = Current.account.captain_units
|
@units = Current.account.captain_units
|
||||||
end
|
end
|
||||||
@ -59,7 +55,3 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -19,25 +19,7 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
|
|||||||
def serialize_insight(insight)
|
def serialize_insight(insight)
|
||||||
return nil if insight.blank?
|
return nil if insight.blank?
|
||||||
|
|
||||||
{
|
insight_attributes(insight)
|
||||||
id: insight.id,
|
|
||||||
conversation_id: insight.conversation_id,
|
|
||||||
account_id: insight.account_id,
|
|
||||||
contact_id: insight.contact_id,
|
|
||||||
summary_text: insight.summary_text,
|
|
||||||
structured_data: insight.structured_data,
|
|
||||||
contact_sessions_count: insight.contact_sessions_count,
|
|
||||||
last_contact_at: insight.last_contact_at,
|
|
||||||
updated_at: insight.updated_at,
|
|
||||||
generated_at: insight.generated_at,
|
|
||||||
range_from_message_id: insight.range_from_message_id,
|
|
||||||
range_to_message_id: insight.range_to_message_id,
|
|
||||||
status: insight.status,
|
|
||||||
error_message: insight.error_message,
|
|
||||||
schema_version: insight.schema_version,
|
|
||||||
model: insight.model,
|
|
||||||
confidence: insight.confidence
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def insights_payload
|
def insights_payload
|
||||||
@ -68,4 +50,26 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
|
|||||||
|
|
||||||
meta
|
meta
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def insight_attributes(insight)
|
||||||
|
{
|
||||||
|
id: insight.id,
|
||||||
|
conversation_id: insight.conversation_id,
|
||||||
|
account_id: insight.account_id,
|
||||||
|
contact_id: insight.contact_id,
|
||||||
|
summary_text: insight.summary_text,
|
||||||
|
structured_data: insight.structured_data,
|
||||||
|
contact_sessions_count: insight.contact_sessions_count,
|
||||||
|
last_contact_at: insight.last_contact_at,
|
||||||
|
updated_at: insight.updated_at,
|
||||||
|
generated_at: insight.generated_at,
|
||||||
|
range_from_message_id: insight.range_from_message_id,
|
||||||
|
range_to_message_id: insight.range_to_message_id,
|
||||||
|
status: insight.status,
|
||||||
|
error_message: insight.error_message,
|
||||||
|
schema_version: insight.schema_version,
|
||||||
|
model: insight.model,
|
||||||
|
confidence: insight.confidence
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
module Api
|
class Api::V1::Accounts::FrequentQuestionsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
class FrequentQuestionsController < Api::V1::Accounts::BaseController
|
|
||||||
def index
|
def index
|
||||||
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
|
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Inboxes::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Inboxes
|
|
||||||
module Jasmine
|
|
||||||
class CollectionsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -36,8 +31,3 @@ module Api
|
|||||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Inboxes::Jasmine::ConfigsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Inboxes
|
|
||||||
module Jasmine
|
|
||||||
class ConfigsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
before_action :fetch_or_initialize_config
|
before_action :fetch_or_initialize_config
|
||||||
|
|
||||||
@ -46,8 +41,3 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,43 +1,16 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Inboxes::Jasmine::PlaygroundController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Inboxes
|
|
||||||
module Jasmine
|
|
||||||
class PlaygroundController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
before_action :fetch_config
|
before_action :fetch_config
|
||||||
|
|
||||||
def test
|
def test
|
||||||
message_content = params[:message]
|
return render json: { error: 'Message is required' }, status: :bad_request if params[:message].blank?
|
||||||
|
|
||||||
return render json: { error: 'Message is required' }, status: :bad_request if message_content.blank?
|
|
||||||
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
|
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
|
||||||
|
|
||||||
# Create a mock message object for BrainService
|
|
||||||
mock_message = OpenStruct.new(
|
|
||||||
content: message_content,
|
|
||||||
inbox: @inbox,
|
|
||||||
conversation: mock_conversation
|
|
||||||
)
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
response = ::Jasmine::BrainService.new(
|
response = generate_response(params[:message])
|
||||||
inbox: @inbox,
|
render json: build_success_payload(response)
|
||||||
conversation: mock_conversation,
|
|
||||||
message: mock_message
|
|
||||||
).respond
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
response: response,
|
|
||||||
debug: {
|
|
||||||
model: @config.model,
|
|
||||||
temperature: @config.temperature,
|
|
||||||
rag_threshold: @config.rag_distance_threshold
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[Jasmine::Playground] Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
handle_error(e)
|
||||||
render json: { error: e.message }, status: :internal_server_error
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -54,6 +27,28 @@ module Api
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_response(content)
|
||||||
|
# Create a mock message object for BrainService
|
||||||
|
mock_message = OpenStruct.new(content: content, inbox: @inbox, conversation: mock_conversation)
|
||||||
|
::Jasmine::BrainService.new(inbox: @inbox, conversation: mock_conversation, message: mock_message).respond
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_success_payload(response)
|
||||||
|
{
|
||||||
|
response: response,
|
||||||
|
debug: {
|
||||||
|
model: @config.model,
|
||||||
|
temperature: @config.temperature,
|
||||||
|
rag_threshold: @config.rag_distance_threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_error(err)
|
||||||
|
Rails.logger.error "[Jasmine::Playground] Error: #{err.message}\n#{err.backtrace.first(5).join("\n")}"
|
||||||
|
render json: { error: err.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
def mock_conversation
|
def mock_conversation
|
||||||
# Create a minimal mock conversation for playground testing
|
# Create a minimal mock conversation for playground testing
|
||||||
@mock_conversation ||= begin
|
@mock_conversation ||= begin
|
||||||
@ -78,8 +73,3 @@ module Api
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,69 +1,21 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Inboxes::Jasmine::ToolsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Inboxes
|
|
||||||
module Jasmine
|
|
||||||
class ToolsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
|
|
||||||
def index
|
def index
|
||||||
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
|
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
|
||||||
|
render json: serialize_tools(configs)
|
||||||
tools = ::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
|
|
||||||
config = configs[key]
|
|
||||||
{
|
|
||||||
key: key,
|
|
||||||
name: definition[:name],
|
|
||||||
method: definition[:method].to_s.upcase,
|
|
||||||
url: definition[:url],
|
|
||||||
description: definition[:description],
|
|
||||||
is_enabled: config&.is_enabled || false,
|
|
||||||
plug_play_id: config&.plug_play_id,
|
|
||||||
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
|
|
||||||
last_test: config ? {
|
|
||||||
at: config.last_tested_at,
|
|
||||||
status: config.last_test_status,
|
|
||||||
error: config.last_test_error,
|
|
||||||
duration: config.last_test_duration_ms
|
|
||||||
} : nil
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: tools
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
tool_key = params[:id] # Using :id from route as tool_key
|
return render(json: { error: 'Invalid tool key' }, status: :bad_request) unless valid_tool_key?(params[:id])
|
||||||
|
|
||||||
unless ::Jasmine::ToolConfig::DEFINITIONS.key?(tool_key)
|
config = find_config(params[:id])
|
||||||
return render json: { error: 'Invalid tool key' }, status: :bad_request
|
update_config_attributes(config)
|
||||||
end
|
|
||||||
|
|
||||||
config = ::Jasmine::ToolConfig.find_or_initialize_by(
|
|
||||||
account: Current.account,
|
|
||||||
inbox: @inbox,
|
|
||||||
tool_key: tool_key
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update attributes
|
|
||||||
config.is_enabled = params[:is_enabled]
|
|
||||||
config.plug_play_id = params[:plug_play_id]
|
|
||||||
|
|
||||||
# Secure token update: only update if present and not masked/empty
|
|
||||||
new_token = params[:plug_play_token]
|
|
||||||
if new_token.present? && new_token != '****'
|
|
||||||
config.plug_play_token = new_token
|
|
||||||
end
|
|
||||||
|
|
||||||
if config.save
|
if config.save
|
||||||
render json: {
|
render json: success_payload(config)
|
||||||
key: config.tool_key,
|
|
||||||
is_enabled: config.is_enabled,
|
|
||||||
plug_play_id: config.plug_play_id,
|
|
||||||
plug_play_token: '****'
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
render(json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -74,7 +26,7 @@ module Api
|
|||||||
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
|
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
|
||||||
result = runner.run
|
result = runner.run
|
||||||
render json: result
|
render json: result
|
||||||
rescue => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
|
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
|
||||||
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
|
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
|
||||||
end
|
end
|
||||||
@ -85,9 +37,54 @@ module Api
|
|||||||
def fetch_inbox
|
def fetch_inbox
|
||||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def valid_tool_key?(key)
|
||||||
|
::Jasmine::ToolConfig::DEFINITIONS.key?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_config(key)
|
||||||
|
::Jasmine::ToolConfig.find_or_initialize_by(account: Current.account, inbox: @inbox, tool_key: key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_config_attributes(config)
|
||||||
|
config.is_enabled = params[:is_enabled]
|
||||||
|
config.plug_play_id = params[:plug_play_id]
|
||||||
|
new_token = params[:plug_play_token]
|
||||||
|
config.plug_play_token = new_token if new_token.present? && new_token != '****'
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_payload(config)
|
||||||
|
{ key: config.tool_key, is_enabled: config.is_enabled, plug_play_id: config.plug_play_id, plug_play_token: '****' }
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_tools(configs)
|
||||||
|
::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
|
||||||
|
build_tool_hash(key, definition, configs[key])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
def build_tool_hash(key, definition, config)
|
||||||
|
{
|
||||||
|
key: key,
|
||||||
|
name: definition[:name],
|
||||||
|
method: definition[:method].to_s.upcase,
|
||||||
|
url: definition[:url],
|
||||||
|
description: definition[:description],
|
||||||
|
is_enabled: config&.is_enabled || false,
|
||||||
|
plug_play_id: config&.plug_play_id,
|
||||||
|
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
|
||||||
|
last_test: serialize_last_test(config)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_last_test(config)
|
||||||
|
return unless config
|
||||||
|
|
||||||
|
{
|
||||||
|
at: config.last_tested_at,
|
||||||
|
status: config.last_test_status,
|
||||||
|
error: config.last_test_error,
|
||||||
|
duration: config.last_test_duration_ms
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -26,18 +26,10 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
|
|||||||
status = status_data['status'] || status_data['state'] || status_data
|
status = status_data['status'] || status_data['state'] || status_data
|
||||||
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
|
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
|
||||||
|
|
||||||
if %w[CONNECTED inChat success].include?(status)
|
return if already_connected?(status)
|
||||||
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
|
|
||||||
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
qr_data = client.get_qr_code(user_token)
|
qr_data = client.get_qr_code(user_token)
|
||||||
Rails.logger.info "Wuzapi QR Data Response keys: #{begin
|
log_qr_data_keys(qr_data)
|
||||||
qr_data.keys
|
|
||||||
rescue StandardError
|
|
||||||
'nil'
|
|
||||||
end}"
|
|
||||||
render json: qr_data
|
render json: qr_data
|
||||||
rescue Wuzapi::Client::Error => e
|
rescue Wuzapi::Client::Error => e
|
||||||
Rails.logger.error "Wuzapi QR Error: #{e.message}"
|
Rails.logger.error "Wuzapi QR Error: #{e.message}"
|
||||||
@ -118,8 +110,24 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
|
|||||||
Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
|
Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
|
||||||
raise 'Token Wuzapi ausente; reprovisionar usuário'
|
raise 'Token Wuzapi ausente; reprovisionar usuário'
|
||||||
else
|
else
|
||||||
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..-1]}"
|
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..]}"
|
||||||
end
|
end
|
||||||
token
|
token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def already_connected?(status)
|
||||||
|
if %w[CONNECTED inChat success].include?(status)
|
||||||
|
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
|
||||||
|
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
|
||||||
|
true
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_qr_data_keys(qr_data)
|
||||||
|
Rails.logger.info "Wuzapi QR Data Response keys: #{qr_data.keys}"
|
||||||
|
rescue StandardError
|
||||||
|
Rails.logger.info 'Wuzapi QR Data Response keys: nil'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::
|
|||||||
Rails.logger.error(
|
Rails.logger.error(
|
||||||
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}"
|
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}"
|
||||||
)
|
)
|
||||||
hook.update_columns(settings: settings)
|
hook.update_columns(settings: settings) # rubocop:disable Rails/SkipsModelValidations
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Jasmine
|
|
||||||
class CollectionsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :find_collection, only: [:destroy]
|
before_action :find_collection, only: [:destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -44,8 +40,3 @@ module Api
|
|||||||
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
|
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Jasmine
|
|
||||||
class DocumentsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_collection
|
before_action :fetch_collection
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -36,7 +32,3 @@ module Api
|
|||||||
params.require(:document).permit(:title, :content)
|
params.require(:document).permit(:title, :content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Jasmine::InboxCollectionsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Jasmine
|
|
||||||
class InboxCollectionsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -37,7 +33,3 @@ module Api
|
|||||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Api
|
class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
|
||||||
module V1
|
|
||||||
module Accounts
|
|
||||||
module Jasmine
|
|
||||||
class InboxConfigsController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -30,7 +26,3 @@ module Api
|
|||||||
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
|
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
module Public
|
class Public::Api::V1::Captain::PaymentsController < ApplicationController
|
||||||
module Api
|
|
||||||
module V1
|
|
||||||
module Captain
|
|
||||||
class PaymentsController < ApplicationController
|
|
||||||
layout false
|
layout false
|
||||||
skip_before_action :authenticate_user!, raise: false
|
skip_before_action :authenticate_user!, raise: false
|
||||||
skip_before_action :check_current_user_is_active, raise: false
|
skip_before_action :check_current_user_is_active, raise: false
|
||||||
@ -19,7 +15,3 @@ module Public
|
|||||||
# It should be a Captain::PixCharge model
|
# It should be a Captain::PixCharge model
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -20,7 +20,12 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def instance_meta
|
def instance_meta
|
||||||
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
|
@metrics['Database Migrations'] = begin
|
||||||
|
ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "Migration context check failed: #{e.message}"
|
||||||
|
'unknown'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def chatwoot_version
|
def chatwoot_version
|
||||||
@ -28,7 +33,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sha
|
def sha
|
||||||
@metrics['Git SHA'] = GIT_HASH
|
@metrics['Git SHA'] = defined?(GIT_HASH) ? GIT_HASH : 'n/a'
|
||||||
end
|
end
|
||||||
|
|
||||||
def postgres_status
|
def postgres_status
|
||||||
@ -53,7 +58,8 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
|||||||
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
|
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
|
||||||
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
|
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
|
||||||
end
|
end
|
||||||
rescue Redis::CannotConnectError
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "Redis status check failed: #{e.message}"
|
||||||
@metrics['Redis alive'] = false
|
@metrics['Redis alive'] = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -2,6 +2,10 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
include MetaTokenVerifyConcern
|
include MetaTokenVerifyConcern
|
||||||
|
|
||||||
def process_payload
|
def process_payload
|
||||||
|
# CRITICAL: Remove RawMessage IMMEDIATELY to prevent Rails logging from serializing binary data
|
||||||
|
params[:event]&.delete('RawMessage')
|
||||||
|
params.dig(:event, 'Message')&.delete('RawMessage')
|
||||||
|
|
||||||
if inactive_whatsapp_number?
|
if inactive_whatsapp_number?
|
||||||
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
||||||
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
|
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
|
||||||
@ -17,18 +21,158 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
perform_sync if params[:awaitResponse].present?
|
perform_sync if params[:awaitResponse].present?
|
||||||
return if performed?
|
return if performed?
|
||||||
|
|
||||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
Webhooks::WhatsappEventsJob.perform_later(sanitize_payload_for_sidekiq)
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_sync
|
def perform_sync
|
||||||
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
|
Webhooks::WhatsappEventsJob.perform_now(sanitize_payload_for_sidekiq)
|
||||||
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
|
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
|
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
|
||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WHITELIST approach: Build a clean payload with ONLY allowed fields
|
||||||
|
# This prevents ANY binary data from leaking into JSON serialization
|
||||||
|
def sanitize_payload_for_sidekiq
|
||||||
|
raw = params.to_unsafe_hash
|
||||||
|
clean_payload = build_base_payload(raw)
|
||||||
|
|
||||||
|
clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash)
|
||||||
|
|
||||||
|
if raw['whatsapp'].is_a?(Hash)
|
||||||
|
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
|
||||||
|
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
|
||||||
|
deep_force_utf8(clean_payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_base_payload(raw)
|
||||||
|
{
|
||||||
|
'type' => raw['type'],
|
||||||
|
'state' => raw['state'],
|
||||||
|
'instanceName' => raw['instanceName'],
|
||||||
|
'userID' => raw['userID'],
|
||||||
|
'controller' => raw['controller'],
|
||||||
|
'action' => raw['action'],
|
||||||
|
'phone_number' => raw['phone_number']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_clean_event(raw_event)
|
||||||
|
clean_event = {}
|
||||||
|
|
||||||
|
if raw_event['Info'].is_a?(Hash)
|
||||||
|
clean_event['Info'] = raw_event['Info'].slice(
|
||||||
|
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
|
||||||
|
'Timestamp', 'PushName', 'MessageSource'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Safe event metadata
|
||||||
|
%w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner
|
||||||
|
BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key|
|
||||||
|
clean_event[key] = raw_event[key] if raw_event.key?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
if raw_event['Message'].is_a?(Hash)
|
||||||
|
clean_msg = build_clean_message(raw_event['Message'])
|
||||||
|
clean_event['Message'] = clean_msg unless clean_msg.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
clean_event
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_clean_message(msg)
|
||||||
|
clean_msg = {}
|
||||||
|
clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String)
|
||||||
|
|
||||||
|
clean_msg.merge!(clean_extended_text_message(msg['extendedTextMessage']))
|
||||||
|
clean_msg.merge!(clean_media_message(msg, 'imageMessage'))
|
||||||
|
clean_msg.merge!(clean_media_message(msg, 'videoMessage'))
|
||||||
|
clean_msg.merge!(clean_media_message(msg, 'audioMessage'))
|
||||||
|
clean_msg.merge!(clean_document_message(msg['documentMessage']))
|
||||||
|
|
||||||
|
clean_msg
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_extended_text_message(ext_msg)
|
||||||
|
return {} unless ext_msg.is_a?(Hash)
|
||||||
|
|
||||||
|
result = { 'extendedTextMessage' => { 'text' => ext_msg['text'] } }
|
||||||
|
|
||||||
|
result['extendedTextMessage']['contextInfo'] = clean_context_info(ext_msg['contextInfo']) if ext_msg['contextInfo'].is_a?(Hash)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_context_info(ctx)
|
||||||
|
{
|
||||||
|
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
|
||||||
|
'participant' => ctx['participant']
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_media_message(msg, type)
|
||||||
|
media = msg[type]
|
||||||
|
return {} unless media.is_a?(Hash)
|
||||||
|
|
||||||
|
clean_data = {
|
||||||
|
'URL' => media['URL'] || media['url'],
|
||||||
|
'directPath' => media['directPath'],
|
||||||
|
'mediaKey' => media['mediaKey'],
|
||||||
|
'fileEncSha256' => media['fileEncSha256'] || media['fileEncSHA256'],
|
||||||
|
'fileSha256' => media['fileSha256'] || media['fileSHA256'],
|
||||||
|
'fileLength' => media['fileLength'],
|
||||||
|
'mimetype' => media['mimetype'],
|
||||||
|
'seconds' => media['seconds'],
|
||||||
|
'caption' => media['caption'],
|
||||||
|
'ptt' => media['ptt'],
|
||||||
|
'width' => media['width'],
|
||||||
|
'height' => media['height']
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_data['contextInfo'] = clean_context_info(media['contextInfo']) if media['contextInfo'].is_a?(Hash)
|
||||||
|
|
||||||
|
{ type => clean_data.compact }
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_document_message(doc)
|
||||||
|
return {} unless doc.is_a?(Hash)
|
||||||
|
|
||||||
|
{
|
||||||
|
'documentMessage' => {
|
||||||
|
'URL' => doc['URL'] || doc['url'],
|
||||||
|
'directPath' => doc['directPath'],
|
||||||
|
'mediaKey' => doc['mediaKey'],
|
||||||
|
'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'],
|
||||||
|
'fileSha256' => doc['fileSha256'] || doc['fileSHA256'],
|
||||||
|
'fileLength' => doc['fileLength'],
|
||||||
|
'mimetype' => doc['mimetype'],
|
||||||
|
'fileName' => doc['fileName'],
|
||||||
|
'title' => doc['title']
|
||||||
|
}.compact
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_force_utf8(obj)
|
||||||
|
case obj
|
||||||
|
when String
|
||||||
|
(obj.frozen? ? obj.dup : obj).force_encoding('UTF-8')
|
||||||
|
.encode('UTF-8', invalid: :replace, undef: :replace)
|
||||||
|
when Hash
|
||||||
|
obj.transform_values { |v| deep_force_utf8(v) }
|
||||||
|
when Array
|
||||||
|
obj.map { |v| deep_force_utf8(v) }
|
||||||
|
else
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def valid_token?(token)
|
def valid_token?(token)
|
||||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||||
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
class Webhooks::WuzapiController < ActionController::Base
|
class Webhooks::WuzapiController < ApplicationController
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
before_action :verify_secret
|
before_action :verify_secret
|
||||||
|
|||||||
@ -1,965 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/* eslint-disable no-console, no-alert, vue/no-bare-strings-in-template, vue/no-static-inline-styles */
|
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const view = ref('form'); // form, payment, success
|
|
||||||
const isDataLoading = ref(true);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const isCopied = ref(false);
|
|
||||||
const submissionStatus = ref({
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
success: false,
|
|
||||||
reservationId: null, // Track ID for polling
|
|
||||||
pix: {
|
|
||||||
copyPasteCode: '',
|
|
||||||
qrCodeValue: '',
|
|
||||||
},
|
|
||||||
scarcityText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const appConfig = reactive({
|
|
||||||
title: 'Reserva Premium',
|
|
||||||
subtitle: 'Hotel 1001 Noites Prime',
|
|
||||||
phone_number: '',
|
|
||||||
primary_color: '#1E90FF',
|
|
||||||
secondary_color: '#1B3B5F',
|
|
||||||
});
|
|
||||||
|
|
||||||
const formData = reactive({
|
|
||||||
nome: '',
|
|
||||||
checkInDateTime: '',
|
|
||||||
telefone: '',
|
|
||||||
email: '',
|
|
||||||
cpf: '',
|
|
||||||
observacao: '',
|
|
||||||
selectedBrand: '',
|
|
||||||
selectedUnit: '',
|
|
||||||
selectedCategory: '',
|
|
||||||
stayDuration: '',
|
|
||||||
selectedExtras: [], // Future Phase
|
|
||||||
});
|
|
||||||
|
|
||||||
// Options State
|
|
||||||
const brands = ref([]);
|
|
||||||
// const units = ref([]); // Removed unused var
|
|
||||||
const pricings = ref([]);
|
|
||||||
const extras = ref([]);
|
|
||||||
|
|
||||||
const unitOptions = ref([]);
|
|
||||||
const categoryOptions = ref([]);
|
|
||||||
const durationOptions = ref([]);
|
|
||||||
|
|
||||||
// Price State
|
|
||||||
const calculatedPrice = ref(null);
|
|
||||||
const isPriceLoading = ref(false);
|
|
||||||
|
|
||||||
// --- API Methods ---
|
|
||||||
const fetchMasterData = async () => {
|
|
||||||
isDataLoading.value = true;
|
|
||||||
try {
|
|
||||||
const pathParts = window.location.pathname.split('/');
|
|
||||||
const accountIdIndex = pathParts.indexOf('accounts') + 1;
|
|
||||||
const accountId = pathParts[accountIdIndex];
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/master_data?account_id=${accountId}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error('Failed to load data');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
brands.value = data.brands;
|
|
||||||
pricings.value = data.pricings;
|
|
||||||
extras.value = data.extras;
|
|
||||||
|
|
||||||
if (data.app_config) {
|
|
||||||
appConfig.title = data.app_config.title || appConfig.title;
|
|
||||||
appConfig.subtitle = data.app_config.subtitle || appConfig.subtitle;
|
|
||||||
appConfig.phone_number = data.app_config.phone_number || '';
|
|
||||||
appConfig.primary_color =
|
|
||||||
data.app_config.primary_color || appConfig.primary_color;
|
|
||||||
appConfig.secondary_color =
|
|
||||||
data.app_config.secondary_color || appConfig.secondary_color;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("Master Data Error:", error);
|
|
||||||
} finally {
|
|
||||||
isDataLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Watchers & Computed ---
|
|
||||||
|
|
||||||
// When Brand changes
|
|
||||||
const handleBrandChange = () => {
|
|
||||||
formData.selectedUnit = '';
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
formData.stayDuration = '';
|
|
||||||
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
if (brand) {
|
|
||||||
unitOptions.value = brand.units.map(u => ({
|
|
||||||
value: String(u.id),
|
|
||||||
label: u.name,
|
|
||||||
}));
|
|
||||||
durationOptions.value = brand.stay_durations.map(d => ({
|
|
||||||
value: d,
|
|
||||||
label: d,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
unitOptions.value = [];
|
|
||||||
durationOptions.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When Unit changes
|
|
||||||
const handleUnitChange = () => {
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
const unit = brand?.units.find(u => String(u.id) === formData.selectedUnit);
|
|
||||||
|
|
||||||
if (
|
|
||||||
unit &&
|
|
||||||
unit.visible_suite_categories &&
|
|
||||||
unit.visible_suite_categories.length > 0
|
|
||||||
) {
|
|
||||||
categoryOptions.value = unit.visible_suite_categories.map(c => ({
|
|
||||||
value: c,
|
|
||||||
label: c,
|
|
||||||
}));
|
|
||||||
} else if (
|
|
||||||
brand &&
|
|
||||||
brand.suite_categories &&
|
|
||||||
brand.suite_categories.length > 0
|
|
||||||
) {
|
|
||||||
categoryOptions.value = brand.suite_categories.map(c => ({
|
|
||||||
value: c,
|
|
||||||
label: c,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
categoryOptions.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Price Calculation Logic
|
|
||||||
const suiteImage = computed(() => {
|
|
||||||
if (!formData.selectedBrand || !formData.selectedCategory) return null;
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
if (!brand) return null;
|
|
||||||
|
|
||||||
const images = brand.suiteImages || brand.suite_images || {};
|
|
||||||
return images[formData.selectedCategory] || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Price Calculation Logic
|
|
||||||
const calculatePrice = () => {
|
|
||||||
if (
|
|
||||||
!formData.selectedBrand ||
|
|
||||||
!formData.selectedCategory ||
|
|
||||||
!formData.stayDuration ||
|
|
||||||
!formData.checkInDateTime
|
|
||||||
) {
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPriceLoading.value = true;
|
|
||||||
// Simulate async price lookup or local calc
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const brandId = parseInt(formData.selectedBrand, 10);
|
|
||||||
const date = new Date(formData.checkInDateTime);
|
|
||||||
const dayIndex = date.getDay(); // 0 = Sunday, 6 = Saturday
|
|
||||||
|
|
||||||
const daysMap = [
|
|
||||||
'DOMINGO',
|
|
||||||
'SEGUNDA',
|
|
||||||
'TERÇA',
|
|
||||||
'QUARTA',
|
|
||||||
'QUINTA',
|
|
||||||
'SEXTA',
|
|
||||||
'SÁBADO',
|
|
||||||
];
|
|
||||||
const currentDayName = daysMap[dayIndex];
|
|
||||||
|
|
||||||
// Find matching pricing row
|
|
||||||
const priceRow = pricings.value.find(p => {
|
|
||||||
if (p.captain_brand_id !== brandId) return false;
|
|
||||||
if (p.suite_category !== formData.selectedCategory) return false;
|
|
||||||
if (p.duration !== formData.stayDuration) return false;
|
|
||||||
|
|
||||||
// Check day range
|
|
||||||
const range = (p.day_range || p.dayRange || '').toUpperCase();
|
|
||||||
|
|
||||||
if (range.includes(' A ')) {
|
|
||||||
if (range === 'SEGUNDA A QUARTA') {
|
|
||||||
return ['SEGUNDA', 'TERÇA', 'QUARTA'].includes(currentDayName);
|
|
||||||
}
|
|
||||||
if (range === 'QUINTA A DOMINGO') {
|
|
||||||
return ['QUINTA', 'SEXTA', 'SÁBADO', 'DOMINGO'].includes(
|
|
||||||
currentDayName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = range.split(',').map(d => d.trim());
|
|
||||||
return days.includes(currentDayName);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (priceRow) {
|
|
||||||
calculatedPrice.value = parseFloat(priceRow.price);
|
|
||||||
} else {
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// console.error(e);
|
|
||||||
} finally {
|
|
||||||
isPriceLoading.value = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [
|
|
||||||
formData.selectedBrand,
|
|
||||||
formData.selectedCategory,
|
|
||||||
formData.stayDuration,
|
|
||||||
formData.checkInDateTime,
|
|
||||||
],
|
|
||||||
calculatePrice
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
const calculateCheckOut = (startStr, durationStr) => {
|
|
||||||
const start = new Date(startStr);
|
|
||||||
let hoursToAdd = 4;
|
|
||||||
if (durationStr?.toUpperCase().includes('PERNOITE')) {
|
|
||||||
hoursToAdd = 12;
|
|
||||||
}
|
|
||||||
const end = new Date(start.getTime() + hoursToAdd * 60 * 60 * 1000);
|
|
||||||
return end.toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = val => {
|
|
||||||
return new Intl.NumberFormat('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'BRL',
|
|
||||||
}).format(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyPix = () => {
|
|
||||||
if (submissionStatus.value?.pix?.copyPasteCode) {
|
|
||||||
navigator.clipboard.writeText(submissionStatus.value.pix.copyPasteCode);
|
|
||||||
isCopied.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
isCopied.value = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetForm = () => {
|
|
||||||
view.value = 'form';
|
|
||||||
formData.nome = '';
|
|
||||||
formData.telefone = '';
|
|
||||||
formData.cpf = '';
|
|
||||||
formData.email = '';
|
|
||||||
formData.checkInDateTime = '';
|
|
||||||
formData.observacao = '';
|
|
||||||
formData.selectedBrand = '';
|
|
||||||
formData.selectedUnit = '';
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
formData.stayDuration = '';
|
|
||||||
submissionStatus.value = null;
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateScarcityMessage = () => {
|
|
||||||
const messages = [
|
|
||||||
'🔥 Resta apenas 1 suíte disponível para esta data!',
|
|
||||||
'⚡ Alta demanda! 2 pessoas estão vendo esta suíte agora.',
|
|
||||||
'💎 Última chance! O hotel está quase lotado.',
|
|
||||||
'⏳ Segure sua vaga! Restam apenas 2 suítes.',
|
|
||||||
'👀 Muita procura para esta data. Garanta sua reserva!',
|
|
||||||
];
|
|
||||||
return messages[Math.floor(Math.random() * messages.length)];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Functions defined before use
|
|
||||||
const triggerConfetti = () => {
|
|
||||||
import('canvas-confetti')
|
|
||||||
.then(confetti => {
|
|
||||||
confetti.default({
|
|
||||||
particleCount: 150,
|
|
||||||
spread: 70,
|
|
||||||
origin: { y: 0.6 },
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let pollingInterval = null;
|
|
||||||
const checkPaymentStatus = async () => {
|
|
||||||
if (!submissionStatus.value?.reservationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/reservations/${submissionStatus.value.reservationId}/status`
|
|
||||||
);
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.payment_status === 'paid') {
|
|
||||||
clearInterval(pollingInterval);
|
|
||||||
view.value = 'success';
|
|
||||||
triggerConfetti();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('Error polling status:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPolling = () => {
|
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
|
||||||
pollingInterval = setInterval(checkPaymentStatus, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!calculatedPrice.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
submissionStatus.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pathParts = window.location.pathname.split('/');
|
|
||||||
const accountId = pathParts[pathParts.indexOf('accounts') + 1];
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
brand_id: formData.selectedBrand,
|
|
||||||
unit_id: formData.selectedUnit,
|
|
||||||
contact_name: formData.nome,
|
|
||||||
phone_number: formData.telefone.replace(/\D/g, ''),
|
|
||||||
email: formData.email,
|
|
||||||
cpf: formData.cpf.replace(/\D/g, ''),
|
|
||||||
check_in_at: formData.checkInDateTime,
|
|
||||||
duration_minutes: 0,
|
|
||||||
check_out_at: calculateCheckOut(
|
|
||||||
formData.checkInDateTime,
|
|
||||||
formData.stayDuration
|
|
||||||
),
|
|
||||||
total_amount: calculatedPrice.value,
|
|
||||||
metadata: {
|
|
||||||
category: formData.selectedCategory,
|
|
||||||
stay_duration: formData.stayDuration,
|
|
||||||
observacao: formData.observacao,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/reservations?account_id=${accountId}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (result.metadata?.pix) {
|
|
||||||
submissionStatus.value = {
|
|
||||||
message:
|
|
||||||
'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.',
|
|
||||||
type: 'success',
|
|
||||||
reservationId: result.reservation_id,
|
|
||||||
pix: result.metadata.pix,
|
|
||||||
scarcityText: generateScarcityMessage(),
|
|
||||||
};
|
|
||||||
view.value = 'payment';
|
|
||||||
// Trigger polling
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
submissionStatus.value = {
|
|
||||||
message: 'Reserva criada, mas falha ao gerar Pix. Entre em contato.',
|
|
||||||
type: 'error',
|
|
||||||
};
|
|
||||||
view.value = 'payment';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Falha ao criar reserva');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('Submit Error:', error);
|
|
||||||
// alert('Erro: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
fetchMasterData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatPhone = event => {
|
|
||||||
let value = event.target.value.replace(/\D/g, '');
|
|
||||||
if (value.length > 11) value = value.slice(0, 11);
|
|
||||||
|
|
||||||
if (value.length > 10) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{5})(\d{4}).*/, '($1) $2-$3');
|
|
||||||
} else if (value.length > 5) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{4})(\d{0,4}).*/, '($1) $2-$3');
|
|
||||||
} else if (value.length > 2) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{0,5}).*/, '($1) $2');
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.telefone = value;
|
|
||||||
event.target.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCPF = event => {
|
|
||||||
let value = event.target.value.replace(/\D/g, '');
|
|
||||||
if (value.length > 11) value = value.slice(0, 11);
|
|
||||||
|
|
||||||
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
|
||||||
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
|
||||||
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
|
||||||
|
|
||||||
formData.cpf = value;
|
|
||||||
event.target.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidEmail = email => {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
|
||||||
return (
|
|
||||||
formData.nome.trim().length > 0 &&
|
|
||||||
formData.telefone.length >= 14 && // (XX) XXXXX-XXXX is 15 chars, or (XX) XXXX-XXXX is 14
|
|
||||||
formData.cpf.length === 14 && // XXX.XXX.XXX-XX
|
|
||||||
isValidEmail(formData.email) &&
|
|
||||||
formData.checkInDateTime &&
|
|
||||||
formData.selectedBrand &&
|
|
||||||
formData.selectedUnit &&
|
|
||||||
formData.selectedCategory &&
|
|
||||||
formData.stayDuration
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewTitle = computed(() => {
|
|
||||||
if (view.value === 'payment') return 'Pagamento Seguro';
|
|
||||||
if (view.value === 'success') return 'Reserva Confirmada';
|
|
||||||
return appConfig.title;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- eslint-disable vue/no-bare-strings-in-template, vue/no-static-inline-styles -->
|
|
||||||
<div
|
|
||||||
class="min-h-screen py-6 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center bg-fixed"
|
|
||||||
:style="`background: linear-gradient(135deg, ${appConfig.secondary_color} 0%, ${appConfig.primary_color} 100%)`"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative"
|
|
||||||
>
|
|
||||||
<!-- Decorative Top Accent -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="p-6 sm:p-12">
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1
|
|
||||||
class="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
|
||||||
>
|
|
||||||
{{ viewTitle }}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
v-if="view === 'form'"
|
|
||||||
class="text-[#9CA3AF] text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ appConfig.subtitle }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="appConfig.phone_number"
|
|
||||||
class="text-sm font-bold text-emerald-600 mt-2"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-phone mr-1" />
|
|
||||||
Suporte: {{ appConfig.phone_number }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LOADING STATE -->
|
|
||||||
<div
|
|
||||||
v-if="isDataLoading"
|
|
||||||
class="text-center py-20 flex flex-col items-center justify-center space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 border-4 border-[#1E90FF] border-t-transparent rounded-full animate-spin"
|
|
||||||
/>
|
|
||||||
<p class="text-[#9CA3AF] font-medium animate-pulse">
|
|
||||||
Carregando dados...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SUCCESS VIEW -->
|
|
||||||
<div
|
|
||||||
v-else-if="view === 'success'"
|
|
||||||
class="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner animate-fade-in"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md"
|
|
||||||
>
|
|
||||||
<!-- Success Icon -->
|
|
||||||
<svg
|
|
||||||
class="h-12 w-12 text-green-600"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-extrabold text-[#1B3B5F]">
|
|
||||||
Pagamento Confirmado!
|
|
||||||
</h2>
|
|
||||||
<p class="text-[#9CA3AF] text-lg">
|
|
||||||
Sua reserva está 100% garantida.<br />Enviamos os detalhes para o
|
|
||||||
seu e-mail.
|
|
||||||
</p>
|
|
||||||
<div class="pt-6">
|
|
||||||
<button
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
@click="handleResetForm"
|
|
||||||
>
|
|
||||||
Fazer Nova Reserva
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PAYMENT VIEW -->
|
|
||||||
<div
|
|
||||||
v-else-if="view === 'payment'"
|
|
||||||
class="text-center space-y-6 animate-fade-in"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6"
|
|
||||||
>
|
|
||||||
<p class="text-[#1B3B5F] font-medium">
|
|
||||||
{{ submissionStatus?.message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scarcity Trigger -->
|
|
||||||
<div
|
|
||||||
v-if="submissionStatus?.scarcityText"
|
|
||||||
class="animate-pulse bg-red-50 border border-red-100 p-3 rounded-xl"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-red-600 font-bold text-sm flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-flame text-lg" />
|
|
||||||
{{ submissionStatus.scarcityText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WhatsApp Warning -->
|
|
||||||
<div class="text-center px-4">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Após o pagamento, você receberá a confirmação imediatamente no seu
|
|
||||||
<strong class="text-green-600">WhatsApp</strong>
|
|
||||||
<span class="font-mono text-xs">({{ formData.telefone }})</span>.
|
|
||||||
<br />
|
|
||||||
<span class="text-xs text-gray-400 block mt-1"
|
|
||||||
>Certifique-se que o número informado está correto.</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 pb-2 text-left">
|
|
||||||
<label
|
|
||||||
class="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2"
|
|
||||||
>Código Pix Copia e Cola</label
|
|
||||||
>
|
|
||||||
<div class="relative group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
:value="submissionStatus?.pix?.copyPasteCode || ''"
|
|
||||||
class="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF]"
|
|
||||||
/>
|
|
||||||
<div class="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
|
|
||||||
@click="handleCopyPix"
|
|
||||||
>
|
|
||||||
{{ isCopied ? 'Copiado!' : 'Copiar' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QR Code Image -->
|
|
||||||
<div
|
|
||||||
v-if="submissionStatus?.pix?.qrCodeValue"
|
|
||||||
class="flex justify-center mt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="p-4 bg-white rounded-xl shadow-lg border border-gray-100"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-gray-500 mb-2">
|
|
||||||
Escaneie o QR Code no app do seu banco
|
|
||||||
</p>
|
|
||||||
<img
|
|
||||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(
|
|
||||||
submissionStatus.pix.copyPasteCode
|
|
||||||
)}`"
|
|
||||||
alt="QR Code do PIX"
|
|
||||||
class="w-48 h-48 object-contain mix-blend-multiply"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 border-t border-[#1B3B5F]/10">
|
|
||||||
<button
|
|
||||||
class="w-full text-[#9CA3AF] hover:text-[#1B3B5F] py-2"
|
|
||||||
@click="handleResetForm"
|
|
||||||
>
|
|
||||||
Cancelar e Voltar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FORM VIEW -->
|
|
||||||
<form v-else class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div
|
|
||||||
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
|
||||||
>
|
|
||||||
Detalhes da Estadia
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- Brand Selection -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="brand"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Marca <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="brand"
|
|
||||||
v-model="formData.selectedBrand"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm"
|
|
||||||
@change="handleBrandChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a marca</option>
|
|
||||||
<option
|
|
||||||
v-for="brand in brands"
|
|
||||||
:key="brand.id"
|
|
||||||
:value="String(brand.id)"
|
|
||||||
>
|
|
||||||
{{ brand.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unit and Duration -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="unit"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Unidade <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="unit"
|
|
||||||
v-model="formData.selectedUnit"
|
|
||||||
:disabled="!unitOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
@change="handleUnitChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a unidade</option>
|
|
||||||
<option
|
|
||||||
v-for="unit in unitOptions"
|
|
||||||
:key="unit.value"
|
|
||||||
:value="unit.value"
|
|
||||||
>
|
|
||||||
{{ unit.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="duration"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Permanência <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="duration"
|
|
||||||
v-model="formData.stayDuration"
|
|
||||||
:disabled="!durationOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione o tempo</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in durationOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="category"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Categoria da Suíte <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="category"
|
|
||||||
v-model="formData.selectedCategory"
|
|
||||||
:disabled="!categoryOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a categoria</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in categoryOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkin Date -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="checkin"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Data e Horário do Check-in
|
|
||||||
<span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="checkin"
|
|
||||||
v-model="formData.checkInDateTime"
|
|
||||||
type="datetime-local"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photos View -->
|
|
||||||
<div v-if="suiteImage" class="my-6 animate-fade-in">
|
|
||||||
<div
|
|
||||||
class="rounded-2xl overflow-hidden shadow-lg border border-[#1B3B5F]/10"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="suiteImage"
|
|
||||||
alt="Suite Preview"
|
|
||||||
class="w-full h-64 object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Price Display -->
|
|
||||||
<div class="my-8">
|
|
||||||
<div
|
|
||||||
v-if="isPriceLoading"
|
|
||||||
class="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"
|
|
||||||
/>
|
|
||||||
<p class="text-sm text-[#9CA3AF]">Calculando valor...</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="calculatedPrice !== null"
|
|
||||||
class="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg"
|
|
||||||
>
|
|
||||||
PREÇO ESTIMADO
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center text-sm text-[#1B3B5F]"
|
|
||||||
>
|
|
||||||
<span class="font-medium">Valor Total da Reserva</span>
|
|
||||||
<span class="font-bold text-lg">{{
|
|
||||||
formatCurrency(calculatedPrice)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center text-sm text-[#9CA3AF]"
|
|
||||||
>
|
|
||||||
<span>Pagar no check-in (50%)</span>
|
|
||||||
<span class="font-medium">{{
|
|
||||||
formatCurrency(calculatedPrice / 2)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1"
|
|
||||||
>
|
|
||||||
Entrada via Pix (50%)
|
|
||||||
</p>
|
|
||||||
<p class="text-[#9CA3AF] text-xs">
|
|
||||||
Necessário para confirmar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
|
||||||
>
|
|
||||||
{{ formatCurrency(calculatedPrice / 2) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Details -->
|
|
||||||
<div
|
|
||||||
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
|
||||||
>
|
|
||||||
Seus Dados
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Nome Completo <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.nome"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Seu nome completo"
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Telefone / WhatsApp
|
|
||||||
<span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:value="formData.telefone"
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
placeholder="(99) 99999-9999"
|
|
||||||
maxlength="15"
|
|
||||||
@input="formatPhone"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-400 mt-1 ml-1">
|
|
||||||
Formato: (99) 99999-9999
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>CPF <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:value="formData.cpf"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
placeholder="000.000.000-00"
|
|
||||||
maxlength="14"
|
|
||||||
@input="formatCPF"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>E-mail <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Observação</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
v-model="formData.observacao"
|
|
||||||
rows="2"
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="!isFormValid || isLoading || isDataLoading"
|
|
||||||
class="w-full flex justify-center py-4 px-6 border border-transparent rounded-xl shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 text-lg font-bold text-white bg-[#1E90FF] hover:bg-[#1B3B5F] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1E90FF] disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed transition-all duration-300 uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
{{ isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="text-center text-xs font-medium text-white/40 mt-8">
|
|
||||||
© {{ new Date().getFullYear() }} {{ appConfig.title }} •
|
|
||||||
Experiência Exclusiva
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Global overrides to ensure background covers the entire page */
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%) fixed !important;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Specific overrides if needed */
|
|
||||||
</style>
|
|
||||||
@ -46,23 +46,32 @@ class JasmineAPI extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unlinkCollection(inboxId, collectionId) {
|
unlinkCollection(inboxId, collectionId) {
|
||||||
return axios.delete(`${this.url}/${inboxId}/jasmine/collections/${collectionId}`);
|
return axios.delete(
|
||||||
|
`${this.url}/${inboxId}/jasmine/collections/${collectionId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
getDocuments(collectionId) {
|
getDocuments(collectionId) {
|
||||||
return axios.get(`${this.jasmineUrl}/collections/${collectionId}/documents`);
|
return axios.get(
|
||||||
|
`${this.jasmineUrl}/collections/${collectionId}/documents`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadDocument(collectionId, content, title) {
|
uploadDocument(collectionId, content, title) {
|
||||||
return axios.post(`${this.jasmineUrl}/collections/${collectionId}/documents`, {
|
return axios.post(
|
||||||
|
`${this.jasmineUrl}/collections/${collectionId}/documents`,
|
||||||
|
{
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDocument(collectionId, documentId) {
|
deleteDocument(collectionId, documentId) {
|
||||||
return axios.delete(`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`);
|
return axios.delete(
|
||||||
|
`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playground
|
// Playground
|
||||||
@ -85,4 +94,3 @@ class JasmineAPI extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default new JasmineAPI();
|
export default new JasmineAPI();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, computed, reactive } from 'vue';
|
import { defineProps, computed, reactive, ref } from 'vue';
|
||||||
import Message from './Message.vue';
|
import Message from './Message.vue';
|
||||||
import { MESSAGE_TYPES } from './constants.js';
|
import { MESSAGE_TYPES } from './constants.js';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
@ -48,7 +48,9 @@ const allMessages = computed(() => {
|
|||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|
||||||
// Cache for fetched reply messages to avoid duplicate API calls
|
// Cache for fetched reply messages to avoid duplicate API calls
|
||||||
|
// Using a ref to trigger reactivity when messages are fetched
|
||||||
const fetchedReplyMessages = reactive(new Map());
|
const fetchedReplyMessages = reactive(new Map());
|
||||||
|
const fetchTrigger = ref(0); // Trigger to force re-render when async fetch completes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a specific message from the API by trying to get messages around it
|
* Fetches a specific message from the API by trying to get messages around it
|
||||||
@ -62,6 +64,9 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
|||||||
return fetchedReplyMessages.get(messageId);
|
return fetchedReplyMessages.get(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as loading to prevent duplicate fetches
|
||||||
|
fetchedReplyMessages.set(messageId, 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await MessageApi.getPreviousMessages({
|
const response = await MessageApi.getPreviousMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -75,14 +80,17 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
|||||||
if (targetMessage) {
|
if (targetMessage) {
|
||||||
const camelCaseMessage = useCamelCase(targetMessage);
|
const camelCaseMessage = useCamelCase(targetMessage);
|
||||||
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return camelCaseMessage;
|
return camelCaseMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache null result to avoid repeated API calls
|
// Cache null result to avoid repeated API calls
|
||||||
fetchedReplyMessages.set(messageId, null);
|
fetchedReplyMessages.set(messageId, null);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fetchedReplyMessages.set(messageId, null);
|
fetchedReplyMessages.set(messageId, null);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -126,9 +134,14 @@ const shouldGroupWithNext = (index, searchList) => {
|
|||||||
* @returns {Object|null} - The message being replied to, or null if not found
|
* @returns {Object|null} - The message being replied to, or null if not found
|
||||||
*/
|
*/
|
||||||
const getInReplyToMessage = parentMessage => {
|
const getInReplyToMessage = parentMessage => {
|
||||||
|
// Access fetchTrigger to make this function reactive to async fetches
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
fetchTrigger.value;
|
||||||
|
|
||||||
if (!parentMessage) return null;
|
if (!parentMessage) return null;
|
||||||
|
|
||||||
const inReplyToMessageId =
|
const inReplyToMessageId =
|
||||||
|
parentMessage.inReplyToId ??
|
||||||
parentMessage.contentAttributes?.inReplyTo ??
|
parentMessage.contentAttributes?.inReplyTo ??
|
||||||
parentMessage.content_attributes?.in_reply_to;
|
parentMessage.content_attributes?.in_reply_to;
|
||||||
|
|
||||||
@ -144,15 +157,18 @@ const getInReplyToMessage = parentMessage => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check fetch cache
|
// Then check fetch cache (ignore 'loading' placeholder)
|
||||||
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
||||||
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
|
const cached = fetchedReplyMessages.get(inReplyToMessageId);
|
||||||
|
if (cached && cached !== 'loading') {
|
||||||
|
replyMessage = cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still not found and we have conversation context, fetch it
|
// If still not found and we have conversation context, fetch it
|
||||||
if (!replyMessage && currentChat.value?.id) {
|
if (!replyMessage && currentChat.value?.id) {
|
||||||
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
||||||
return null; // Let UI handle loading state
|
return null; // Will re-render when fetchTrigger updates
|
||||||
}
|
}
|
||||||
|
|
||||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||||
|
|||||||
@ -63,9 +63,11 @@ const messageClass = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scrollToMessage = () => {
|
const scrollToMessage = () => {
|
||||||
|
if (inReplyTo.value?.id) {
|
||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||||
messageId: inReplyTo.value.id,
|
messageId: inReplyTo.value.id,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowMeta = computed(
|
const shouldShowMeta = computed(
|
||||||
@ -76,7 +78,7 @@ const shouldShowMeta = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const replyToPreview = computed(() => {
|
const replyToPreview = computed(() => {
|
||||||
if (!inReplyTo) return '';
|
if (!inReplyTo.value) return '';
|
||||||
|
|
||||||
const { content, attachments } = inReplyTo.value;
|
const { content, attachments } = inReplyTo.value;
|
||||||
|
|
||||||
|
|||||||
@ -102,7 +102,7 @@ describe('useUISettings', () => {
|
|||||||
conversation_sidebar_items_order:
|
conversation_sidebar_items_order:
|
||||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||||
email_signature_enabled: true,
|
inbox_email_signature_enabled: true,
|
||||||
editor_message_key: 'enter',
|
editor_message_key: 'enter',
|
||||||
channel_email_quoted_reply_enabled: true,
|
channel_email_quoted_reply_enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -109,7 +109,8 @@ export function usePolicy() {
|
|||||||
if (!flag) return false;
|
if (!flag) return false;
|
||||||
|
|
||||||
// Bypass paywall for Captain in development
|
// Bypass paywall for Captain in development
|
||||||
if (['captain_integration', 'captain_integration_v2'].includes(flag)) return false;
|
if (['captain_integration', 'captain_integration_v2'].includes(flag))
|
||||||
|
return false;
|
||||||
|
|
||||||
if (isACustomBrandedInstance.value) {
|
if (isACustomBrandedInstance.value) {
|
||||||
// custom branded instances never show paywall
|
// custom branded instances never show paywall
|
||||||
|
|||||||
@ -98,13 +98,13 @@ export const getEmailDate = lastEmail => {
|
|||||||
* @returns {string} Formatted date string
|
* @returns {string} Formatted date string
|
||||||
*/
|
*/
|
||||||
export const formatQuotedEmailDate = date => {
|
export const formatQuotedEmailDate = date => {
|
||||||
try {
|
let parsedDate = date;
|
||||||
return format(date, "EEE, MMM d, yyyy 'at' p");
|
if (typeof date === 'string') {
|
||||||
} catch (error) {
|
parsedDate = parseISO(date);
|
||||||
const fallbackDate = new Date(date);
|
|
||||||
if (!Number.isNaN(fallbackDate.getTime())) {
|
|
||||||
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValidDate(parsedDate)) {
|
||||||
|
return format(parsedDate, "EEE, MMM d, yyyy 'at' p");
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
|||||||
import contentTemplates from './contentTemplates.json';
|
import contentTemplates from './contentTemplates.json';
|
||||||
import mfa from './mfa.json';
|
import mfa from './mfa.json';
|
||||||
import yearInReview from './yearInReview.json';
|
import yearInReview from './yearInReview.json';
|
||||||
|
import jasmine from './jasmine.json';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...advancedFilters,
|
...advancedFilters,
|
||||||
@ -84,4 +85,5 @@ export default {
|
|||||||
...contentTemplates,
|
...contentTemplates,
|
||||||
...mfa,
|
...mfa,
|
||||||
...yearInReview,
|
...yearInReview,
|
||||||
|
...jasmine,
|
||||||
};
|
};
|
||||||
|
|||||||
72
app/javascript/dashboard/i18n/locale/en/jasmine.json
Normal file
72
app/javascript/dashboard/i18n/locale/en/jasmine.json
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"JASMINE": {
|
||||||
|
"HEADER": {
|
||||||
|
"TITLE": "Jasmine AI Agents",
|
||||||
|
"DESCRIPTION": "Manage your AI SDR agents. Select an inbox to configure your knowledge base.",
|
||||||
|
"EMPTY": "No inboxes found"
|
||||||
|
},
|
||||||
|
"CONFIG": {
|
||||||
|
"TITLE": "Jasmine AI Configuration",
|
||||||
|
"DESCRIPTION": "Configure the AI agent for this inbox.",
|
||||||
|
"ENABLE": "Enable Jasmine AI Agent",
|
||||||
|
"SYSTEM_PROMPT": "System Prompt",
|
||||||
|
"SYSTEM_PROMPT_HELP": "Define the persona and behavioral rules for the agent.",
|
||||||
|
"UPDATE_BUTTON": "Update Configuration"
|
||||||
|
},
|
||||||
|
"KNOWLEDGE_BASE": {
|
||||||
|
"TITLE": "Knowledge Base",
|
||||||
|
"DESCRIPTION": "Manage knowledge collections for this inbox",
|
||||||
|
"ADD_BUTTON": "+ New Collection",
|
||||||
|
"DOCUMENTS": "Documents",
|
||||||
|
"LOADING_DOCS": "Loading documents...",
|
||||||
|
"UNTITLED_DOC": "Untitled Document",
|
||||||
|
"NO_DOCS": "No documents yet. Add your first document below.",
|
||||||
|
"ADD_DOC_HEADER": "Add New Document",
|
||||||
|
"DOC_TITLE_PLACEHOLDER": "Document title (optional)",
|
||||||
|
"DOC_CONTENT_PLACEHOLDER": "Paste or type your knowledge content here...",
|
||||||
|
"ADD_DOC_BUTTON": "Add Document",
|
||||||
|
"NO_COLLECTIONS": "No collections yet. Create one to get started.",
|
||||||
|
"CREATE_MODAL": {
|
||||||
|
"TITLE": "Create Collection",
|
||||||
|
"NAME_PLACEHOLDER": "Collection name",
|
||||||
|
"VISIBILITY_PRIVATE": "Private (This inbox only)",
|
||||||
|
"VISIBILITY_SHARED": "Shared (All inboxes)",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CREATE": "Create"
|
||||||
|
},
|
||||||
|
"DELETE_CONFIRM": "Are you sure you want to delete this document?",
|
||||||
|
"DOCUMENT_DELETE_SUCCESS": "Document deleted successfully",
|
||||||
|
"COLLECTION_DELETE_SUCCESS": "Collection deleted successfully",
|
||||||
|
"SAVE_SUCCESS": "Changes saved successfully",
|
||||||
|
"DOCUMENT_CREATE_SUCCESS": "Document created successfully",
|
||||||
|
"COLLECTION_CREATE_SUCCESS": "Collection created successfully"
|
||||||
|
},
|
||||||
|
"PLAYGROUND": {
|
||||||
|
"TITLE": "Jasmine AI Playground",
|
||||||
|
"DESCRIPTION": "Test Jasmine responses in real-time before enabling for customers.",
|
||||||
|
"SELECT_INBOX": "Select an Inbox to test",
|
||||||
|
"CHOOSE_INBOX": "Choose an inbox...",
|
||||||
|
"WARNING": "Make sure Jasmine is enabled and configured for this inbox",
|
||||||
|
"EMPTY_STATE_TITLE": "Send a message to test Jasmine",
|
||||||
|
"EMPTY_STATE_EXAMPLES": "Try: \"Hello\", \"How much does it cost?\", \"How does it work?\"",
|
||||||
|
"LOADING": "Jasmine is thinking...",
|
||||||
|
"INPUT_PLACEHOLDER": "Type a test message...",
|
||||||
|
"CLEAR_TOOLTIP": "Clear conversation",
|
||||||
|
"NO_INBOX_SELECTED": "Select an inbox above to start testing"
|
||||||
|
},
|
||||||
|
"INBOX_LIST": {
|
||||||
|
"ACTIVE": "Active",
|
||||||
|
"CONFIGURE": "Configure",
|
||||||
|
"DESCRIPTION": "Channel {channel} configured for Jasmine AI"
|
||||||
|
},
|
||||||
|
"WUZAPI": {
|
||||||
|
"STATUS": "Status: {status}",
|
||||||
|
"ACCOUNT_ERROR": "Error: Account ID not loaded. Please refresh the page.",
|
||||||
|
"CONNECT_FALLBACK": "Click to initiate connection",
|
||||||
|
"CONNECT_BUTTON_FALLBACK": "Connect WhatsApp",
|
||||||
|
"WEBHOOK_SECTION": "Webhook Configuration",
|
||||||
|
"GET_WEBHOOK_INFO": "Get Webhook Info",
|
||||||
|
"UPDATE_WEBHOOK": "Update Webhook Connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,54 @@
|
|||||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PRICINGS": {
|
||||||
|
"LIST": {
|
||||||
|
"GLOBAL": "Global (Todos os Inboxes)"
|
||||||
|
},
|
||||||
|
"DELETE_BTN": "Excluir Regra",
|
||||||
|
"DELETE_CONFIRMATION": "Tem certeza que deseja excluir esta regra de preço?",
|
||||||
|
"FIELDS": {
|
||||||
|
"INBOX": "Inbox",
|
||||||
|
"BRAND": "Marca",
|
||||||
|
"DAYS": "Dias",
|
||||||
|
"CATEGORY": "Categoria",
|
||||||
|
"DURATION": "Duração",
|
||||||
|
"PRICE": "Preço",
|
||||||
|
"ACTIONS": "Ações",
|
||||||
|
"PRICE_DISPLAY": "R$ %{price}",
|
||||||
|
"MIN_PRICE": "Preço Mínimo",
|
||||||
|
"MAX_PRICE": "Preço Máximo",
|
||||||
|
"DAY": "Dia",
|
||||||
|
"KEYWORDS": "Palavras-chave"
|
||||||
|
},
|
||||||
|
"FILTERS": {
|
||||||
|
"ALL": "Todas",
|
||||||
|
"ALL_DAYS": "Todos os Dias",
|
||||||
|
"CLEAR": "Limpar Filtros"
|
||||||
|
},
|
||||||
|
"EDIT": "Editar",
|
||||||
|
"DELETE": "Excluir",
|
||||||
|
"HEADER": "Gerenciamento de Tarifas",
|
||||||
|
"TITLE": "Tarifas e Disponibilidade",
|
||||||
|
"DESCRIPTION": "Gerencie os preços das suítes por dia da semana, categoria e duração.",
|
||||||
|
"ADD_BUTTON": "Nova Regra",
|
||||||
|
"EMPTY_STATE": "Nenhuma regra de preço encontrada.",
|
||||||
|
"MODAL": {
|
||||||
|
"EDIT_TITLE": "Editar Regra de Preço",
|
||||||
|
"ADD_TITLE": "Nova Regra de Preço",
|
||||||
|
"SAVE": "Salvar",
|
||||||
|
"CANCEL": "Cancelar",
|
||||||
|
"SELECT_DAYS_REQUIRED": "Selecione pelo menos um dia",
|
||||||
|
"SELECT_CATEGORY": "Selecione uma categoria",
|
||||||
|
"SELECT_BRAND_FIRST": "Selecione uma marca primeiro",
|
||||||
|
"NO_CATEGORIES": "Nenhuma categoria encontrada",
|
||||||
|
"SELECT_DURATION": "Selecione uma duração",
|
||||||
|
"NO_DURATIONS": "Nenhuma duração encontrada",
|
||||||
|
"PRICE_PLACEHOLDER": "0.00",
|
||||||
|
"REMOVE_INBOX": "Remover Inbox",
|
||||||
|
"CLOSE": "Remover"
|
||||||
|
}
|
||||||
|
},
|
||||||
"CUSTOM_TOOLS": {
|
"CUSTOM_TOOLS": {
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"TEST_TOOL": "Testar Ferramenta",
|
"TEST_TOOL": "Testar Ferramenta",
|
||||||
|
|||||||
@ -67,10 +67,13 @@ export default {
|
|||||||
this.brand.suiteCategories || this.brand.suite_categories || [];
|
this.brand.suiteCategories || this.brand.suite_categories || [];
|
||||||
const images =
|
const images =
|
||||||
this.brand.suiteImages || this.brand.suite_images || {};
|
this.brand.suiteImages || this.brand.suite_images || {};
|
||||||
|
const keywords =
|
||||||
|
this.brand.suiteKeywords || this.brand.suite_keywords || {};
|
||||||
|
|
||||||
this.suiteCategories = categories.map(cat => ({
|
this.suiteCategories = categories.map(cat => ({
|
||||||
name: cat,
|
name: cat,
|
||||||
image: images[cat] || '',
|
image: images[cat] || '',
|
||||||
|
keywords: keywords[cat] || '',
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
@ -85,6 +88,7 @@ export default {
|
|||||||
this.stayDurations = '';
|
this.stayDurations = '';
|
||||||
this.newCategoryName = '';
|
this.newCategoryName = '';
|
||||||
this.newCategoryImage = '';
|
this.newCategoryImage = '';
|
||||||
|
this.newCategoryKeywords = '';
|
||||||
this.v$.$reset();
|
this.v$.$reset();
|
||||||
},
|
},
|
||||||
addCategory() {
|
addCategory() {
|
||||||
@ -92,9 +96,11 @@ export default {
|
|||||||
this.suiteCategories.push({
|
this.suiteCategories.push({
|
||||||
name: this.newCategoryName,
|
name: this.newCategoryName,
|
||||||
image: this.newCategoryImage,
|
image: this.newCategoryImage,
|
||||||
|
keywords: this.newCategoryKeywords,
|
||||||
});
|
});
|
||||||
this.newCategoryName = '';
|
this.newCategoryName = '';
|
||||||
this.newCategoryImage = '';
|
this.newCategoryImage = '';
|
||||||
|
this.newCategoryKeywords = '';
|
||||||
},
|
},
|
||||||
removeCategory(index) {
|
removeCategory(index) {
|
||||||
this.suiteCategories.splice(index, 1);
|
this.suiteCategories.splice(index, 1);
|
||||||
@ -108,11 +114,16 @@ export default {
|
|||||||
if (curr.image) acc[curr.name] = curr.image;
|
if (curr.image) acc[curr.name] = curr.image;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
const keywords = this.suiteCategories.reduce((acc, curr) => {
|
||||||
|
if (curr.keywords) acc[curr.name] = curr.keywords;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
suite_categories: categories,
|
suite_categories: categories,
|
||||||
suite_images: images,
|
suite_images: images,
|
||||||
|
suite_keywords: keywords,
|
||||||
stay_durations: this.stayDurations
|
stay_durations: this.stayDurations
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
@ -126,21 +137,25 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
||||||
<WootModal :show="show" :on-close="() => $emit('close')">
|
<WootModal
|
||||||
|
:show="show"
|
||||||
|
:on-close="() => $emit('close')"
|
||||||
|
class-name="!max-w-5xl !w-full"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col w-[600px] bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
|
class="flex flex-col w-full bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div
|
||||||
class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
|
class="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable Body -->
|
<!-- Body -->
|
||||||
<div class="flex-1 overflow-y-auto p-6 max-h-[65vh] flex flex-col gap-5">
|
<div class="flex-1 p-5 flex flex-col gap-3">
|
||||||
<!-- Brand Name -->
|
<!-- Brand Name -->
|
||||||
<WootInput
|
<WootInput
|
||||||
v-model="name"
|
v-model="name"
|
||||||
@ -151,9 +166,9 @@ export default {
|
|||||||
|
|
||||||
<!-- Suite Categories -->
|
<!-- Suite Categories -->
|
||||||
<div
|
<div
|
||||||
class="bg-slate-50 dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700"
|
class="bg-slate-50 dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-200"
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
>
|
>
|
||||||
@ -161,45 +176,70 @@ export default {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 mb-3">
|
<!-- Add New Category - Function Bar -->
|
||||||
|
<div class="flex flex-col gap-2 mb-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="newCategoryName"
|
v-model="newCategoryName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nome (Ex: Standard)"
|
placeholder="Nome (Ex: Standard)"
|
||||||
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
@keydown.enter.prevent="addCategory"
|
@keydown.enter.prevent="addCategory"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="newCategoryImage"
|
v-model="newCategoryImage"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="URL da Imagem"
|
placeholder="URL da Imagem"
|
||||||
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
@keydown.enter.prevent="addCategory"
|
@keydown.enter.prevent="addCategory"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium shrink-0"
|
||||||
|
title="Adicionar Categoria"
|
||||||
@click.prevent="addCategory"
|
@click.prevent="addCategory"
|
||||||
>
|
>
|
||||||
<i class="i-lucide-plus" />
|
<i class="i-lucide-plus" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="newCategoryKeywords"
|
||||||
|
type="text"
|
||||||
|
placeholder="Palavras-chave (Ex: jacuzzi, hidro) - Opcional"
|
||||||
|
class="w-full text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
|
@keydown.enter.prevent="addCategory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="suiteCategories.length > 0" class="space-y-2">
|
<!-- Categories Grid -->
|
||||||
|
<div v-if="suiteCategories.length > 0" class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(cat, idx) in suiteCategories"
|
v-for="(cat, idx) in suiteCategories"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex items-center justify-between bg-white dark:bg-slate-900 p-2 rounded border border-slate-200 dark:border-slate-600"
|
class="flex items-center justify-between bg-white dark:bg-slate-900 p-1.5 rounded border border-slate-200 dark:border-slate-600"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col flex-1 min-w-0 mr-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="font-medium text-sm text-slate-800 dark:text-slate-100"
|
class="font-medium text-sm text-slate-800 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
{{ cat.name }}
|
{{ cat.name }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="cat.image"
|
v-if="cat.keywords"
|
||||||
class="text-xs text-slate-500 truncate max-w-[200px]"
|
class="text-xs text-slate-500 truncate"
|
||||||
|
:title="cat.keywords"
|
||||||
>
|
>
|
||||||
|
<i class="i-lucide-key size-3 inline-block mr-0.5" />
|
||||||
|
{{ cat.keywords }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="cat.image"
|
||||||
|
class="text-xs text-slate-400 truncate"
|
||||||
|
:title="cat.image"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-image size-3 inline-block mr-0.5" />
|
||||||
{{ cat.image }}
|
{{ cat.image }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -216,7 +256,7 @@ export default {
|
|||||||
v-else
|
v-else
|
||||||
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2"
|
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2"
|
||||||
>
|
>
|
||||||
Adicione as categorias de quartos disponíveis para esta marca.
|
Nenhuma categoria adicionada.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,7 +264,7 @@ export default {
|
|||||||
<WootInput
|
<WootInput
|
||||||
v-model="stayDurations"
|
v-model="stayDurations"
|
||||||
label="Durações Aceitas"
|
label="Durações Aceitas"
|
||||||
placeholder="Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
placeholder="Ex: 2h, 4h, Pernoite, Diária"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -122,9 +122,11 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteDialogRef = ref(null);
|
||||||
|
|
||||||
const confirmDelete = pricing => {
|
const confirmDelete = pricing => {
|
||||||
pricingToDelete.value = pricing;
|
pricingToDelete.value = pricing;
|
||||||
showDeleteConfirmation.value = true;
|
deleteDialogRef.value.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePricing = async () => {
|
const deletePricing = async () => {
|
||||||
@ -341,15 +343,27 @@ const clearFilters = () => {
|
|||||||
? pricing.inboxNames.join(', ')
|
? pricing.inboxNames.join(', ')
|
||||||
: pricing.inbox_names?.length
|
: pricing.inbox_names?.length
|
||||||
? pricing.inbox_names.join(', ')
|
? pricing.inbox_names.join(', ')
|
||||||
: pricing.inboxName ||
|
: inboxes.find(
|
||||||
pricing.inbox_name ||
|
i => String(i.id) === String(pricing.inbox_id)
|
||||||
pricing.inbox_id
|
)?.name || 'Global (Todos os Inboxes)'
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100"
|
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
{{ pricing.brandName || pricing.brand_id }}
|
{{
|
||||||
|
brands.find(
|
||||||
|
b =>
|
||||||
|
String(b.id) === String(pricing.captain_brand_id) ||
|
||||||
|
String(b.id) === String(pricing.brand_id) ||
|
||||||
|
String(b.id) === String(pricing.captainBrandId) ||
|
||||||
|
String(b.id) === String(pricing.brandId)
|
||||||
|
)?.name ||
|
||||||
|
pricing.captain_brand_id ||
|
||||||
|
pricing.brand_id ||
|
||||||
|
pricing.captainBrandId ||
|
||||||
|
pricing.brandId
|
||||||
|
}}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600 dark:text-slate-300">
|
<td class="px-6 py-4 text-slate-600 dark:text-slate-300">
|
||||||
{{ pricing.dayRange || pricing.day_range }}
|
{{ pricing.dayRange || pricing.day_range }}
|
||||||
@ -374,13 +388,13 @@ const clearFilters = () => {
|
|||||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||||
@click="openEditModal(pricing)"
|
@click="openEditModal(pricing)"
|
||||||
>
|
>
|
||||||
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.EDIT') }}
|
{{ $t('CAPTAIN.PRICINGS.EDIT') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-red-600 hover:text-red-800 font-medium transition-colors"
|
class="text-red-600 hover:text-red-800 font-medium transition-colors"
|
||||||
@click="confirmDelete(pricing)"
|
@click="confirmDelete(pricing)"
|
||||||
>
|
>
|
||||||
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.DELETE') }}
|
{{ $t('CAPTAIN.PRICINGS.DELETE') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -396,6 +410,7 @@ const clearFilters = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PricingModal
|
<PricingModal
|
||||||
|
:key="selectedPricing?.id || 'new'"
|
||||||
:show="showModal"
|
:show="showModal"
|
||||||
:pricing="selectedPricing"
|
:pricing="selectedPricing"
|
||||||
:brands="brands"
|
:brands="brands"
|
||||||
@ -405,12 +420,14 @@ const clearFilters = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
:show="showDeleteConfirmation"
|
ref="deleteDialogRef"
|
||||||
:title="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
|
:title="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
|
||||||
:message="t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
|
:description="$t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
|
||||||
:confirm-text="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
|
:confirm-button-label="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
|
||||||
:cancel-text="t('CAPTAIN.BRAND_MODAL.CANCEL')"
|
:cancel-button-label="$t('CAPTAIN.PRICINGS.MODAL.CANCEL')"
|
||||||
variant="danger"
|
type="alert"
|
||||||
|
show-cancel-button
|
||||||
|
show-confirm-button
|
||||||
@close="showDeleteConfirmation = false"
|
@close="showDeleteConfirmation = false"
|
||||||
@confirm="deletePricing"
|
@confirm="deletePricing"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@ -58,14 +59,23 @@ const toggleDay = day => {
|
|||||||
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b)
|
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
formData.value.day_range = selectedDays.value.join(', ');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedDays,
|
||||||
|
newDays => {
|
||||||
|
formData.value.day_range = newDays.join(', ');
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
const parseDays = rangeString => {
|
const parseDays = rangeString => {
|
||||||
if (!rangeString) return [];
|
if (!rangeString) return [];
|
||||||
|
const normalizedRange = rangeString.toUpperCase();
|
||||||
|
|
||||||
// Handle "SEGUNDA A QUARTA" range format legacy support
|
// Handle "SEGUNDA A QUARTA" range format legacy support
|
||||||
if (rangeString.includes(' A ')) {
|
if (normalizedRange.includes(' A ')) {
|
||||||
const [start, end] = rangeString.split(' A ');
|
const [start, end] = normalizedRange.split(' A ');
|
||||||
const startIndex = daysOptions.indexOf(start);
|
const startIndex = daysOptions.indexOf(start);
|
||||||
const endIndex = daysOptions.indexOf(end);
|
const endIndex = daysOptions.indexOf(end);
|
||||||
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
|
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
|
||||||
@ -73,7 +83,7 @@ const parseDays = rangeString => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle comma separated
|
// Handle comma separated
|
||||||
return rangeString
|
return normalizedRange
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(s => daysOptions.includes(s));
|
.filter(s => daysOptions.includes(s));
|
||||||
@ -96,7 +106,10 @@ const removeInbox = inboxId => {
|
|||||||
const isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
|
const isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
|
||||||
|
|
||||||
const selectedBrand = computed(() => {
|
const selectedBrand = computed(() => {
|
||||||
return props.brands.find(b => b.id === formData.value.captain_brand_id);
|
if (!formData.value.captain_brand_id) return undefined;
|
||||||
|
return props.brands.find(
|
||||||
|
b => String(b.id) === String(formData.value.captain_brand_id)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandCategories = computed(() => {
|
const brandCategories = computed(() => {
|
||||||
@ -126,11 +139,23 @@ watch(
|
|||||||
let normalizedInboxIds = [];
|
let normalizedInboxIds = [];
|
||||||
if (newVal.inbox_ids?.length) {
|
if (newVal.inbox_ids?.length) {
|
||||||
normalizedInboxIds = newVal.inbox_ids;
|
normalizedInboxIds = newVal.inbox_ids;
|
||||||
} else if (newVal.inbox_id) {
|
} else if (newVal.inbox_id || newVal.inboxId) {
|
||||||
normalizedInboxIds = [newVal.inbox_id];
|
normalizedInboxIds = [newVal.inbox_id || newVal.inboxId];
|
||||||
}
|
}
|
||||||
formData.value = { ...newVal, inbox_ids: normalizedInboxIds };
|
formData.value = {
|
||||||
selectedDays.value = parseDays(newVal.day_range || newVal.dayRange);
|
captain_brand_id:
|
||||||
|
newVal.captain_brand_id ||
|
||||||
|
newVal.captainBrandId ||
|
||||||
|
newVal.brand_id ||
|
||||||
|
newVal.brandId ||
|
||||||
|
'',
|
||||||
|
inbox_ids: normalizedInboxIds,
|
||||||
|
day_range: newVal.day_range || newVal.dayRange || '',
|
||||||
|
suite_category: newVal.suite_category || newVal.suiteCategory || '',
|
||||||
|
duration: newVal.duration || '',
|
||||||
|
price: newVal.price || '',
|
||||||
|
};
|
||||||
|
selectedDays.value = parseDays(formData.value.day_range);
|
||||||
selectedInboxes.value = normalizedInboxIds;
|
selectedInboxes.value = normalizedInboxIds;
|
||||||
} else {
|
} else {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
@ -256,7 +281,7 @@ const savePricing = async () => {
|
|||||||
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
|
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
selectedDays.includes(day)
|
selectedDays.includes(day)
|
||||||
? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
|
? '!bg-blue-600 text-white border-blue-600 shadow-md'
|
||||||
: 'bg-white text-slate-600 border-slate-300 hover:border-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700',
|
: 'bg-white text-slate-600 border-slate-300 hover:border-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700',
|
||||||
]"
|
]"
|
||||||
@click="toggleDay(day)"
|
@click="toggleDay(day)"
|
||||||
|
|||||||
@ -44,4 +44,3 @@ const routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|
||||||
|
|||||||
@ -45,8 +45,8 @@ const getChannelName = channelType => {
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BaseSettingsHeader
|
<BaseSettingsHeader
|
||||||
title="Agentes Jasmine AI"
|
:title="$t('JASMINE.HEADER.TITLE')"
|
||||||
description="Gerencie seus agentes de IA SDR. Selecione uma caixa de entrada para configurar sua base de conhecimento."
|
:description="$t('JASMINE.HEADER.DESCRIPTION')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -64,14 +64,12 @@ const getChannelName = channelType => {
|
|||||||
class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2"
|
class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
:class="[
|
class="size-6 text-n-blue-text"
|
||||||
getChannelIcon(inbox.channel_type),
|
:class="[getChannelIcon(inbox.channel_type)]"
|
||||||
'size-6 text-n-blue-text',
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-tooltip="'Ativo'"
|
v-tooltip="$t('JASMINE.INBOX_LIST.ACTIVE')"
|
||||||
class="text-white p-0.5 rounded-full size-5 flex items-center justify-center bg-n-teal-9"
|
class="text-white p-0.5 rounded-full size-5 flex items-center justify-center bg-n-teal-9"
|
||||||
>
|
>
|
||||||
<i class="i-ph-check-bold text-sm" />
|
<i class="i-ph-check-bold text-sm" />
|
||||||
@ -83,13 +81,20 @@ const getChannelName = channelType => {
|
|||||||
<span class="text-base font-semibold text-n-slate-12">{{
|
<span class="text-base font-semibold text-n-slate-12">{{
|
||||||
inbox.name
|
inbox.name
|
||||||
}}</span>
|
}}</span>
|
||||||
<Button label="Configurar" link @click.stop="openInbox(inbox.id)" />
|
<Button
|
||||||
|
:label="$t('JASMINE.INBOX_LIST.CONFIGURE')"
|
||||||
|
link
|
||||||
|
@click.stop="openInbox(inbox.id)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
Canal {{ getChannelName(inbox.channel_type) }} configurado para
|
{{
|
||||||
Jasmine AI
|
$t('JASMINE.INBOX_LIST.DESCRIPTION', {
|
||||||
|
channel: getChannelName(inbox.channel_type),
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -63,8 +63,8 @@ const clearChat = () => {
|
|||||||
<SettingsLayout :is-loading="false">
|
<SettingsLayout :is-loading="false">
|
||||||
<template #header>
|
<template #header>
|
||||||
<BaseSettingsHeader
|
<BaseSettingsHeader
|
||||||
title="Playground Jasmine AI"
|
:title="$t('JASMINE.PLAYGROUND.TITLE')"
|
||||||
description="Teste as respostas da Jasmine em tempo real antes de ativar para os clientes."
|
:description="$t('JASMINE.PLAYGROUND.DESCRIPTION')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -73,20 +73,25 @@ const clearChat = () => {
|
|||||||
<!-- Inbox Selector -->
|
<!-- Inbox Selector -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-n-slate-12 mb-2">
|
<label class="block text-sm font-medium text-n-slate-12 mb-2">
|
||||||
Selecione uma Inbox para testar
|
{{ $t('JASMINE.PLAYGROUND.SELECT_INBOX') }}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="selectedInboxId"
|
v-model="selectedInboxId"
|
||||||
class="w-full max-w-md px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
|
class="w-full max-w-md px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
|
||||||
>
|
>
|
||||||
<option :value="null">Escolha uma inbox...</option>
|
<option :value="null">
|
||||||
|
{{ $t('JASMINE.PLAYGROUND.CHOOSE_INBOX') }}
|
||||||
|
</option>
|
||||||
<option v-for="inbox in inboxes" :key="inbox.id" :value="inbox.id">
|
<option v-for="inbox in inboxes" :key="inbox.id" :value="inbox.id">
|
||||||
{{ inbox.name }}
|
{{ inbox.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p v-if="selectedInbox" class="text-xs text-n-slate-11 mt-1">
|
<p v-if="selectedInbox" class="text-xs text-n-slate-11 mt-1">
|
||||||
⚠️ Certifique-se de que a Jasmine está ativada e configurada para
|
{{
|
||||||
esta inbox
|
$t('JASMINE.PLAYGROUND.FETCH_ERROR', {
|
||||||
|
error: $t('JASMINE.PLAYGROUND.WARNING'),
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -102,17 +107,17 @@ const clearChat = () => {
|
|||||||
class="text-center text-n-slate-11 py-12"
|
class="text-center text-n-slate-11 py-12"
|
||||||
>
|
>
|
||||||
<span class="i-lucide-message-square size-12 mb-4 opacity-50" />
|
<span class="i-lucide-message-square size-12 mb-4 opacity-50" />
|
||||||
<p>Envie uma mensagem para testar a Jasmine</p>
|
<p>{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_TITLE') }}</p>
|
||||||
<p class="text-xs mt-2">
|
<p class="text-xs mt-2">
|
||||||
Experimente: "Olá", "Quanto custa?", "Como funciona?"
|
{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_EXAMPLES') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(msg, index) in messages"
|
v-for="(msg, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
class="max-w-[80%] rounded-lg p-3"
|
||||||
:class="[
|
:class="[
|
||||||
'max-w-[80%] rounded-lg p-3',
|
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'ml-auto bg-n-blue-9 text-white'
|
? 'ml-auto bg-n-blue-9 text-white'
|
||||||
: msg.role === 'error'
|
: msg.role === 'error'
|
||||||
@ -125,10 +130,16 @@ const clearChat = () => {
|
|||||||
v-if="msg.debug"
|
v-if="msg.debug"
|
||||||
class="mt-2 pt-2 border-t border-n-weak text-xs text-n-slate-11"
|
class="mt-2 pt-2 border-t border-n-weak text-xs text-n-slate-11"
|
||||||
>
|
>
|
||||||
<span class="font-mono"
|
<span class="font-mono">
|
||||||
>{{ msg.debug.model }} | temp:
|
{{
|
||||||
{{ msg.debug.temperature }}</span
|
$t('JASMINE.PLAYGROUND.MODEL', { model: msg.debug.model })
|
||||||
>
|
}}
|
||||||
|
{{
|
||||||
|
$t('JASMINE.PLAYGROUND.TEMPERATURE', {
|
||||||
|
temp: msg.debug.temperature,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -137,7 +148,9 @@ const clearChat = () => {
|
|||||||
class="flex items-center gap-2 text-n-slate-11"
|
class="flex items-center gap-2 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<span class="i-lucide-loader-2 size-4 animate-spin" />
|
<span class="i-lucide-loader-2 size-4 animate-spin" />
|
||||||
<span class="text-sm">Jasmine está pensando...</span>
|
<span class="text-sm">{{
|
||||||
|
$t('JASMINE.PLAYGROUND.LOADING')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -148,7 +161,7 @@ const clearChat = () => {
|
|||||||
v-model="inputMessage"
|
v-model="inputMessage"
|
||||||
type="text"
|
type="text"
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
|
||||||
placeholder="Digite uma mensagem de teste..."
|
:placeholder="$t('JASMINE.PLAYGROUND.INPUT_PLACEHOLDER')"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
@ -158,7 +171,7 @@ const clearChat = () => {
|
|||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip="'Limpar conversa'"
|
v-tooltip="$t('JASMINE.PLAYGROUND.CLEAR_TOOLTIP')"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
faded
|
faded
|
||||||
slate
|
slate
|
||||||
@ -176,7 +189,7 @@ const clearChat = () => {
|
|||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="i-lucide-inbox size-16 mb-4 opacity-30" />
|
<span class="i-lucide-inbox size-16 mb-4 opacity-30" />
|
||||||
<p>Selecione uma inbox acima para começar a testar</p>
|
<p>{{ $t('JASMINE.PLAYGROUND.NO_INBOX_SELECTED') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<template>
|
|
||||||
<router-view />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'JasmineWrapper',
|
name: 'JasmineWrapper',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|||||||
@ -70,10 +70,10 @@ export default {
|
|||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="flex flex-col gap-1 items-start mb-4">
|
<div class="flex flex-col gap-1 items-start mb-4">
|
||||||
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
|
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
|
||||||
Jasmine AI Configuration
|
{{ $t('JASMINE.CONFIG.TITLE') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||||
Configure the AI agent for this inbox.
|
{{ $t('JASMINE.CONFIG.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ export default {
|
|||||||
class="form-checkbox h-5 w-5 text-woot-500 rounded border-gray-300 focus:ring-woot-500"
|
class="form-checkbox h-5 w-5 text-woot-500 rounded border-gray-300 focus:ring-woot-500"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
|
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
Enable Jasmine AI Agent
|
{{ $t('JASMINE.CONFIG.ENABLE') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -94,21 +94,21 @@ export default {
|
|||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
|
||||||
>
|
>
|
||||||
System Prompt
|
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT') }}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="systemPrompt"
|
v-model="systemPrompt"
|
||||||
rows="6"
|
rows="6"
|
||||||
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
|
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
|
||||||
placeholder="You are a helpful SDR agent..."
|
:placeholder="$t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP')"
|
||||||
></textarea>
|
/>
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
Define the persona and behavioral rules for the agent.
|
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<woot-button :is-loading="isUpdating" @click="updateSettings">
|
<woot-button :is-loading="isUpdating" @click="updateSettings">
|
||||||
Update Configuration
|
{{ $t('JASMINE.CONFIG.UPDATE_BUTTON') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
|
|
||||||
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" />
|
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" />
|
||||||
|
|||||||
@ -34,20 +34,25 @@ export default defineComponent({
|
|||||||
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/wuzapi${endpoint}`;
|
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/wuzapi${endpoint}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
function stopPolling() {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
if (!accountId.value) return;
|
if (!accountId.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get(getApiUrl(''));
|
const response = await window.axios.get(getApiUrl(''));
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
// Wuzapi format: { data: { connected: true, jid: "...", details: "..." } }
|
|
||||||
const wuzapiData = data.data || {};
|
const wuzapiData = data.data || {};
|
||||||
|
|
||||||
const isWuzapiConnected =
|
const isWuzapiConnected =
|
||||||
wuzapiData.connected === true && !!wuzapiData.jid;
|
wuzapiData.connected === true && !!wuzapiData.jid;
|
||||||
|
|
||||||
// Also keep legacy check just in case payload differs
|
|
||||||
const legacyStatus = data.status || data.state;
|
const legacyStatus = data.status || data.state;
|
||||||
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
|
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
|
||||||
legacyStatus
|
legacyStatus
|
||||||
@ -64,13 +69,22 @@ export default defineComponent({
|
|||||||
statusMessage.value =
|
statusMessage.value =
|
||||||
error.response?.data?.error || error.message || 'Check failed';
|
error.response?.data?.error || error.message || 'Check failed';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const fetchQrCode = async () => {
|
/* eslint-disable no-use-before-define */
|
||||||
|
function startPolling() {
|
||||||
|
if (pollInterval) return;
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
await fetchStatus();
|
||||||
|
if (pollInterval && !isConnected.value) {
|
||||||
|
await fetchQrCode();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQrCode() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get(getApiUrl('/qr'));
|
const response = await window.axios.get(getApiUrl('/qr'));
|
||||||
|
|
||||||
// Backend now normalizes to 'qrcode' in most cases, but we keep robust checks
|
|
||||||
const d = response.data;
|
const d = response.data;
|
||||||
const qrcodeData =
|
const qrcodeData =
|
||||||
d.qrcode ||
|
d.qrcode ||
|
||||||
@ -84,7 +98,6 @@ export default defineComponent({
|
|||||||
qrCode.value = qrcodeData;
|
qrCode.value = qrcodeData;
|
||||||
startPolling();
|
startPolling();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: maybe we are already connected?
|
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
statusMessage.value = 'QR Code not received and not connected.';
|
statusMessage.value = 'QR Code not received and not connected.';
|
||||||
@ -94,7 +107,7 @@ export default defineComponent({
|
|||||||
statusMessage.value =
|
statusMessage.value =
|
||||||
error.response?.data?.error || 'Failed to load QR';
|
error.response?.data?.error || 'Failed to load QR';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!accountId.value) {
|
if (!accountId.value) {
|
||||||
@ -131,26 +144,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function hoisting allows use before definition
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (pollInterval) return;
|
|
||||||
// Poll every 5 seconds to check status AND refresh QR code
|
|
||||||
pollInterval = setInterval(async () => {
|
|
||||||
await fetchStatus();
|
|
||||||
// If still not connected (and polling hasn't been stopped by fetchStatus), refresh QR
|
|
||||||
if (pollInterval && !isConnected.value) {
|
|
||||||
await fetchQrCode();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoadingWebhook = ref(false);
|
const isLoadingWebhook = ref(false);
|
||||||
const webhookInfo = ref(null);
|
const webhookInfo = ref(null);
|
||||||
|
|
||||||
@ -173,7 +166,7 @@ export default defineComponent({
|
|||||||
const response = await window.axios.put(getApiUrl('/update_webhook'));
|
const response = await window.axios.put(getApiUrl('/update_webhook'));
|
||||||
webhookInfo.value = {
|
webhookInfo.value = {
|
||||||
message: response.data.message,
|
message: response.data.message,
|
||||||
url: response.data.webhook_url
|
url: response.data.webhook_url,
|
||||||
};
|
};
|
||||||
useAlert('Webhook updated successfully');
|
useAlert('Webhook updated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -213,8 +206,8 @@ export default defineComponent({
|
|||||||
<div class="mx-8 mt-6">
|
<div class="mx-8 mt-6">
|
||||||
<div class="bg-white p-6 rounded-lg border border-n-weak">
|
<div class="bg-white p-6 rounded-lg border border-n-weak">
|
||||||
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
|
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
|
||||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }} -
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
|
||||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG') }}
|
{{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="accountId" class="flex flex-col items-center">
|
<div v-if="accountId" class="flex flex-col items-center">
|
||||||
@ -264,29 +257,29 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-n-slate-10">
|
<div class="mt-4 text-xs text-n-slate-10">
|
||||||
Status: {{ statusMessage }}
|
{{ $t('JASMINE.WUZAPI.STATUS', { status: statusMessage }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-red-600 p-4">
|
<div v-else class="text-red-600 p-4">
|
||||||
Error: Account ID not loaded. Please refresh the page.
|
{{ $t('JASMINE.WUZAPI.ACCOUNT_ERROR') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 pt-6 border-t border-n-weak w-full">
|
<div class="mt-8 pt-6 border-t border-n-weak w-full">
|
||||||
<h4 class="text-md font-medium text-n-slate-12 mb-4">
|
<h4 class="text-md font-medium text-n-slate-12 mb-4">
|
||||||
Webhook Configuration
|
{{ $t('JASMINE.WUZAPI.WEBHOOK_SECTION') }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="flex gap-4 mb-4">
|
<div class="flex gap-4 mb-4">
|
||||||
<NextButton
|
<NextButton
|
||||||
icon="i-woot-refresh"
|
icon="i-woot-refresh"
|
||||||
:is-loading="isLoadingWebhook"
|
:is-loading="isLoadingWebhook"
|
||||||
label="Get Webhook Info"
|
:label="$t('JASMINE.WUZAPI.GET_WEBHOOK_INFO')"
|
||||||
@click="fetchWebhookInfo"
|
@click="fetchWebhookInfo"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
icon="i-woot-upload"
|
icon="i-woot-upload"
|
||||||
:is-loading="isLoadingWebhook"
|
:is-loading="isLoadingWebhook"
|
||||||
label="Update Webhook Connection"
|
:label="$t('JASMINE.WUZAPI.UPDATE_WEBHOOK')"
|
||||||
@click="updateWebhook"
|
@click="updateWebhook"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -101,11 +101,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteDocument(collectionId, documentId) {
|
async deleteDocument(collectionId, documentId) {
|
||||||
if (!confirm('Are you sure you want to delete this document?')) return;
|
// eslint-disable-next-line no-alert
|
||||||
|
if (!window.confirm(this.$t('JASMINE.KNOWLEDGE_BASE.DELETE_CONFIRM')))
|
||||||
|
return;
|
||||||
this.isDeletingDocument = documentId;
|
this.isDeletingDocument = documentId;
|
||||||
try {
|
try {
|
||||||
await JasmineAPI.deleteDocument(collectionId, documentId);
|
await JasmineAPI.deleteDocument(collectionId, documentId);
|
||||||
useAlert('Document deleted successfully');
|
useAlert(this.$t('JASMINE.KNOWLEDGE_BASE.DOCUMENT_DELETE_SUCCESS'));
|
||||||
this.fetchDocuments(collectionId);
|
this.fetchDocuments(collectionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert('Failed to delete document');
|
useAlert('Failed to delete document');
|
||||||
@ -143,14 +145,14 @@ export default {
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||||
Knowledge Base
|
{{ $t('JASMINE.KNOWLEDGE_BASE.TITLE') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
Manage knowledge collections for this inbox
|
{{ $t('JASMINE.KNOWLEDGE_BASE.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<woot-button size="small" @click="showCreateCollectionModal = true">
|
<woot-button size="small" @click="showCreateCollectionModal = true">
|
||||||
+ New Collection
|
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_BUTTON') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -173,8 +175,8 @@ export default {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
|
class="i-lucide-chevron-right size-4 transition-transform text-slate-400"
|
||||||
:class="[
|
:class="[
|
||||||
'i-lucide-chevron-right size-4 transition-transform text-slate-400',
|
|
||||||
expandedCollectionId === collection.id ? 'rotate-90' : '',
|
expandedCollectionId === collection.id ? 'rotate-90' : '',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@ -197,7 +199,7 @@ export default {
|
|||||||
class="border-t border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 p-4"
|
class="border-t border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 p-4"
|
||||||
>
|
>
|
||||||
<h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
|
<h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
|
||||||
Documents
|
{{ $t('JASMINE.KNOWLEDGE_BASE.DOCUMENTS') }}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<!-- Loading Documents -->
|
<!-- Loading Documents -->
|
||||||
@ -206,7 +208,7 @@ export default {
|
|||||||
class="flex items-center gap-2 text-sm text-slate-400 py-2"
|
class="flex items-center gap-2 text-sm text-slate-400 py-2"
|
||||||
>
|
>
|
||||||
<span class="i-lucide-loader-2 size-4 animate-spin" />
|
<span class="i-lucide-loader-2 size-4 animate-spin" />
|
||||||
Loading documents...
|
{{ $t('JASMINE.KNOWLEDGE_BASE.LOADING_DOCS') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documents List -->
|
<!-- Documents List -->
|
||||||
@ -224,7 +226,7 @@ export default {
|
|||||||
<p
|
<p
|
||||||
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate"
|
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate"
|
||||||
>
|
>
|
||||||
{{ doc.title || 'Untitled Document' }}
|
{{ doc.title || $t('JASMINE.KNOWLEDGE_BASE.UNTITLED_DOC') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-slate-400 truncate">
|
<p class="text-xs text-slate-400 truncate">
|
||||||
{{ new Date(doc.created_at).toLocaleDateString() }}
|
{{ new Date(doc.created_at).toLocaleDateString() }}
|
||||||
@ -234,10 +236,8 @@ export default {
|
|||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<span
|
<span
|
||||||
:class="[
|
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
|
:class="[getStatusClass(doc.status)]"
|
||||||
getStatusClass(doc.status),
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="isProcessing(doc.status)"
|
v-if="isProcessing(doc.status)"
|
||||||
@ -264,7 +264,7 @@ export default {
|
|||||||
v-if="documents.length === 0"
|
v-if="documents.length === 0"
|
||||||
class="text-center py-6 text-sm text-slate-400"
|
class="text-center py-6 text-sm text-slate-400"
|
||||||
>
|
>
|
||||||
No documents yet. Add your first document below.
|
{{ $t('JASMINE.KNOWLEDGE_BASE.NO_DOCS') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -273,19 +273,21 @@ export default {
|
|||||||
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4"
|
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4"
|
||||||
>
|
>
|
||||||
<h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
|
<h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
|
||||||
Add New Document
|
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_HEADER') }}
|
||||||
</h6>
|
</h6>
|
||||||
<input
|
<input
|
||||||
v-model="newDocTitle"
|
v-model="newDocTitle"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full mb-2 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
class="w-full mb-2 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
||||||
placeholder="Document title (optional)"
|
:placeholder="$t('JASMINE.KNOWLEDGE_BASE.DOC_TITLE_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newDocContent"
|
v-model="newDocContent"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="w-full mb-3 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 resize-none"
|
class="w-full mb-3 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 resize-none"
|
||||||
placeholder="Paste or type your knowledge content here..."
|
:placeholder="
|
||||||
|
$t('JASMINE.KNOWLEDGE_BASE.DOC_CONTENT_PLACEHOLDER')
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<woot-button
|
<woot-button
|
||||||
@ -294,7 +296,7 @@ export default {
|
|||||||
:disabled="!newDocContent.trim()"
|
:disabled="!newDocContent.trim()"
|
||||||
@click="addDocument(collection.id)"
|
@click="addDocument(collection.id)"
|
||||||
>
|
>
|
||||||
Add Document
|
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_BUTTON') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -307,7 +309,7 @@ export default {
|
|||||||
class="text-center py-12 text-slate-400"
|
class="text-center py-12 text-slate-400"
|
||||||
>
|
>
|
||||||
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" />
|
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" />
|
||||||
<p class="text-sm">No collections yet. Create one to get started.</p>
|
<p class="text-sm">{{ $t('JASMINE.KNOWLEDGE_BASE.NO_COLLECTIONS') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -319,34 +321,40 @@ export default {
|
|||||||
>
|
>
|
||||||
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl">
|
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
|
<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
|
||||||
Create Collection
|
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.TITLE') }}
|
||||||
</h3>
|
</h3>
|
||||||
<input
|
<input
|
||||||
v-model="newCollectionName"
|
v-model="newCollectionName"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||||
placeholder="Collection name"
|
:placeholder="
|
||||||
|
$t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.NAME_PLACEHOLDER')
|
||||||
|
"
|
||||||
@keyup.enter="createCollection"
|
@keyup.enter="createCollection"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-model="newCollectionVisibility"
|
v-model="newCollectionVisibility"
|
||||||
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
<option value="private">Private (This inbox only)</option>
|
<option value="private">
|
||||||
<option value="shared">Shared (All inboxes)</option>
|
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_PRIVATE') }}
|
||||||
|
</option>
|
||||||
|
<option value="shared">
|
||||||
|
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_SHARED') }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<woot-button
|
<woot-button
|
||||||
variant="clear"
|
variant="clear"
|
||||||
@click="showCreateCollectionModal = false"
|
@click="showCreateCollectionModal = false"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CANCEL') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
<woot-button
|
<woot-button
|
||||||
:disabled="!newCollectionName.trim()"
|
:disabled="!newCollectionName.trim()"
|
||||||
@click="createCollection"
|
@click="createCollection"
|
||||||
>
|
>
|
||||||
Create
|
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CREATE') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import BookingApp from '../captain_booking/App.vue';
|
|
||||||
import '../captain_booking/assets/main.css';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const app = createApp(BookingApp);
|
|
||||||
app.mount('#root');
|
|
||||||
});
|
|
||||||
16
app/javascript/entrypoints/captain_booking.tsx
Normal file
16
app/javascript/entrypoints/captain_booking.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from '../../../enterprise/app/javascript/captain_booking_app/App';
|
||||||
|
import '../../../enterprise/app/javascript/captain_booking_app/index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Root element not found');
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
module Conversations
|
class Conversations::AutoLabelJob < ApplicationJob
|
||||||
class AutoLabelJob < ApplicationJob
|
|
||||||
queue_as :low_priority
|
queue_as :low_priority
|
||||||
|
|
||||||
TAXONOMY = {
|
TAXONOMY = {
|
||||||
@ -17,7 +16,7 @@ module Conversations
|
|||||||
def perform(conversation_id)
|
def perform(conversation_id)
|
||||||
conversation = Conversation.find_by(id: conversation_id)
|
conversation = Conversation.find_by(id: conversation_id)
|
||||||
return unless conversation
|
return unless conversation
|
||||||
return unless conversation.messages.count > 0
|
return unless conversation.messages.count.positive?
|
||||||
|
|
||||||
# Evita re-classificar se já tiver alguma label de IA (opcional)
|
# Evita re-classificar se já tiver alguma label de IA (opcional)
|
||||||
# return if (conversation.label_list & TAXONOMY.keys).any?
|
# return if (conversation.label_list & TAXONOMY.keys).any?
|
||||||
@ -131,4 +130,3 @@ module Conversations
|
|||||||
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
|
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module Conversations
|
class Conversations::ClusterJob < ApplicationJob
|
||||||
class ClusterJob < ApplicationJob
|
|
||||||
queue_as :low_priority
|
queue_as :low_priority
|
||||||
|
|
||||||
def perform(account_id, days_back = 7)
|
def perform(account_id, days_back = 7)
|
||||||
@ -9,7 +8,7 @@ module Conversations
|
|||||||
start_date = days_back.days.ago.beginning_of_day
|
start_date = days_back.days.ago.beginning_of_day
|
||||||
|
|
||||||
# Labels que queremos agrupar (todas da taxonomia)
|
# Labels que queremos agrupar (todas da taxonomia)
|
||||||
Conversations::AutoLabelJob::TAXONOMY.keys.each do |label|
|
Conversations::AutoLabelJob::TAXONOMY.each_key do |label|
|
||||||
# Busca perguntas dessa categoria
|
# Busca perguntas dessa categoria
|
||||||
# Note: estamos queryng o campo JSONB additional_attributes
|
# Note: estamos queryng o campo JSONB additional_attributes
|
||||||
account.conversations
|
account.conversations
|
||||||
@ -83,4 +82,3 @@ module Conversations
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module CrmInsights
|
class CrmInsights::UpdateJob < ApplicationJob
|
||||||
class UpdateJob < ApplicationJob
|
|
||||||
queue_as :low
|
queue_as :low
|
||||||
|
|
||||||
def perform(conversation_id, reason: nil)
|
def perform(conversation_id, reason: nil)
|
||||||
@ -14,4 +13,3 @@ module CrmInsights
|
|||||||
UpdateService.new(conversation: conversation, reason: reason).call
|
UpdateService.new(conversation: conversation, reason: reason).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module Jasmine
|
class Jasmine::ResponseJob < ApplicationJob
|
||||||
class ResponseJob < ApplicationJob
|
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
retry_on StandardError, wait: :polynomially_longer, attempts: 2
|
retry_on StandardError, wait: :polynomially_longer, attempts: 2
|
||||||
@ -58,4 +57,3 @@ module Jasmine
|
|||||||
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
|
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -37,11 +37,12 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
|||||||
return unless params[:phone_number]
|
return unless params[:phone_number]
|
||||||
|
|
||||||
# Try exact match first, then try adding + if missing
|
# Try exact match first, then try adding + if missing
|
||||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
phone = params[:phone_number].to_s.strip
|
||||||
|
channel = Channel::Whatsapp.find_by(phone_number: phone)
|
||||||
return channel if channel
|
return channel if channel
|
||||||
|
|
||||||
# If params[:phone_number] doesn't have +, try adding it
|
# If phone doesn't have +, try adding it
|
||||||
Channel::Whatsapp.find_by(phone_number: "+#{params[:phone_number]}")
|
Channel::Whatsapp.find_by(phone_number: "+#{phone}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_channel_from_whatsapp_business_payload(params)
|
def find_channel_from_whatsapp_business_payload(params)
|
||||||
|
|||||||
@ -30,6 +30,29 @@ class WebhookListener < BaseListener
|
|||||||
|
|
||||||
payload = message.webhook_data.merge(event: __method__.to_s)
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
deliver_webhook_payloads(payload, inbox)
|
deliver_webhook_payloads(payload, inbox)
|
||||||
|
|
||||||
|
message_incoming(event)
|
||||||
|
message_outgoing(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_incoming(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
|
||||||
|
return unless message.webhook_sendable?
|
||||||
|
return unless message.incoming?
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_account_webhooks(payload, message.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_outgoing(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
|
||||||
|
return unless message.webhook_sendable?
|
||||||
|
return unless message.outgoing?
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_account_webhooks(payload, message.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_updated(event)
|
def message_updated(event)
|
||||||
@ -91,6 +114,19 @@ class WebhookListener < BaseListener
|
|||||||
handle_typing_status(__method__.to_s, event)
|
handle_typing_status(__method__.to_s, event)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def provider_event_received(event)
|
||||||
|
inbox, account = extract_inbox_and_account(event)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: __method__.to_s,
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: account.webhook_data,
|
||||||
|
provider_event: event.data[:event],
|
||||||
|
provider_event_data: event.data[:payload]
|
||||||
|
}
|
||||||
|
deliver_account_webhooks(payload, account)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def handle_typing_status(event_name, event)
|
def handle_typing_status(event_name, event)
|
||||||
@ -110,6 +146,7 @@ class WebhookListener < BaseListener
|
|||||||
def deliver_account_webhooks(payload, account)
|
def deliver_account_webhooks(payload, account)
|
||||||
account.webhooks.account_type.each do |webhook|
|
account.webhooks.account_type.each do |webhook|
|
||||||
next unless webhook.subscriptions.include?(payload[:event])
|
next unless webhook.subscriptions.include?(payload[:event])
|
||||||
|
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
|
||||||
|
|
||||||
WebhookJob.perform_later(webhook.url, payload)
|
WebhookJob.perform_later(webhook.url, payload)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,7 +1,32 @@
|
|||||||
module Captain
|
# == Schema Information
|
||||||
class Pricing < ApplicationRecord
|
#
|
||||||
|
# Table name: captain_pricings
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# day_range :string not null
|
||||||
|
# duration :string not null
|
||||||
|
# keywords :text
|
||||||
|
# price :decimal(10, 2) not null
|
||||||
|
# suite_category :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint not null
|
||||||
|
# inbox_id :bigint
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_pricings_on_account_id (account_id)
|
||||||
|
# index_captain_pricings_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_pricings_on_inbox_id (inbox_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
#
|
||||||
|
class Captain::Pricing < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :captain_brand, optional: true
|
belongs_to :captain_brand, optional: true
|
||||||
belongs_to :inbox, optional: true
|
belongs_to :inbox, optional: true
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,54 @@
|
|||||||
module Captain
|
# == Schema Information
|
||||||
class Reservation < ApplicationRecord
|
#
|
||||||
|
# Table name: captain_reservations
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# check_in_at :datetime not null
|
||||||
|
# check_out_at :datetime not null
|
||||||
|
# created_by_type :string
|
||||||
|
# metadata :jsonb not null
|
||||||
|
# payment_status :string default("pending")
|
||||||
|
# status :integer default("scheduled"), not null
|
||||||
|
# suite_identifier :string
|
||||||
|
# total_amount :decimal(10, 2)
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint
|
||||||
|
# captain_unit_id :bigint
|
||||||
|
# contact_id :bigint not null
|
||||||
|
# contact_inbox_id :bigint not null
|
||||||
|
# conversation_id :bigint
|
||||||
|
# created_by_id :bigint
|
||||||
|
# current_pix_charge_id :bigint
|
||||||
|
# inbox_id :bigint not null
|
||||||
|
# integracao_id :string
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_reservations_on_account_id (account_id)
|
||||||
|
# index_captain_reservations_on_account_id_and_inbox_id (account_id,inbox_id)
|
||||||
|
# index_captain_reservations_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_reservations_on_captain_unit_id (captain_unit_id)
|
||||||
|
# index_captain_reservations_on_contact_id (contact_id)
|
||||||
|
# index_captain_reservations_on_contact_id_and_inbox_id (contact_id,inbox_id)
|
||||||
|
# index_captain_reservations_on_contact_inbox_id (contact_inbox_id)
|
||||||
|
# index_captain_reservations_on_conversation_id (conversation_id)
|
||||||
|
# index_captain_reservations_on_inbox_id (inbox_id)
|
||||||
|
# index_captain_reservations_on_integracao_id (integracao_id)
|
||||||
|
# index_captain_reservations_on_integracao_id_and_unit_id (integracao_id,captain_unit_id) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
# fk_rails_... (captain_unit_id => captain_units.id)
|
||||||
|
# fk_rails_... (contact_id => contacts.id)
|
||||||
|
# fk_rails_... (contact_inbox_id => contact_inboxes.id)
|
||||||
|
# fk_rails_... (conversation_id => conversations.id)
|
||||||
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
|
#
|
||||||
|
class Captain::Reservation < ApplicationRecord
|
||||||
self.table_name = 'captain_reservations'
|
self.table_name = 'captain_reservations'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
@ -33,4 +82,3 @@ module Captain
|
|||||||
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,44 @@
|
|||||||
module Captain
|
# == Schema Information
|
||||||
class Unit < ApplicationRecord
|
#
|
||||||
|
# Table name: captain_units
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# inter_account_number :string
|
||||||
|
# inter_cert_path :string
|
||||||
|
# inter_client_secret :string
|
||||||
|
# inter_key_path :string
|
||||||
|
# inter_pix_key :string
|
||||||
|
# last_synced_at :datetime
|
||||||
|
# leader_whatsapp :string
|
||||||
|
# name :string not null
|
||||||
|
# plug_play_token :string
|
||||||
|
# reservation_source_tag :string
|
||||||
|
# reservations_sync_enabled :boolean
|
||||||
|
# status :string
|
||||||
|
# suite_category_images :jsonb not null
|
||||||
|
# visible_suite_categories :jsonb not null
|
||||||
|
# webhook_url :string
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint not null
|
||||||
|
# inbox_id :bigint
|
||||||
|
# inter_client_id :string
|
||||||
|
# plug_play_id :string
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_units_on_account_id (account_id)
|
||||||
|
# index_captain_units_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_units_on_inbox_id (inbox_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
|
#
|
||||||
|
class Captain::Unit < ApplicationRecord
|
||||||
self.table_name = 'captain_units'
|
self.table_name = 'captain_units'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
@ -17,4 +56,3 @@ module Captain
|
|||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
# phone_number :string not null
|
# phone_number :string not null
|
||||||
# provider :string default("default")
|
# provider :string default("default")
|
||||||
# provider_config :jsonb
|
# provider_config :jsonb
|
||||||
|
# provider_connection :jsonb
|
||||||
# wuzapi_admin_token :string
|
# wuzapi_admin_token :string
|
||||||
# wuzapi_admin_token_iv :string
|
# wuzapi_admin_token_iv :string
|
||||||
# wuzapi_user_token :string
|
# wuzapi_user_token :string
|
||||||
@ -19,6 +20,7 @@
|
|||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||||
|
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
|
||||||
#
|
#
|
||||||
|
|
||||||
class Channel::Whatsapp < ApplicationRecord
|
class Channel::Whatsapp < ApplicationRecord
|
||||||
@ -195,7 +197,7 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
provider_config.delete('wuzapi_user_token')
|
provider_config.delete('wuzapi_user_token')
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless provider_config['wuzapi_admin_token'].present?
|
return if provider_config['wuzapi_admin_token'].blank?
|
||||||
|
|
||||||
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
|
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
|
||||||
provider_config.delete('wuzapi_admin_token')
|
provider_config.delete('wuzapi_admin_token')
|
||||||
@ -207,13 +209,13 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
|
|
||||||
def perform_webhook_setup
|
def perform_webhook_setup
|
||||||
if provider == 'wuzapi'
|
if provider == 'wuzapi'
|
||||||
return unless inbox.present?
|
return if inbox.blank?
|
||||||
|
|
||||||
base_url = provider_config['wuzapi_base_url']
|
base_url = provider_config['wuzapi_base_url']
|
||||||
# Use encrypted token
|
# Use encrypted token
|
||||||
user_token = wuzapi_user_token
|
user_token = wuzapi_user_token
|
||||||
|
|
||||||
return unless user_token.present?
|
return if user_token.blank?
|
||||||
|
|
||||||
# Construct Chatwoot Webhook URL
|
# Construct Chatwoot Webhook URL
|
||||||
# Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix
|
# Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix
|
||||||
@ -284,7 +286,7 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
|
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
|
||||||
self.wuzapi_user_token = result[:wuzapi_user_token]
|
self.wuzapi_user_token = result[:wuzapi_user_token]
|
||||||
|
|
||||||
masked_token = result[:wuzapi_user_token].to_s[-4..-1]
|
masked_token = result[:wuzapi_user_token].to_s[-4..]
|
||||||
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
|
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# content :jsonb
|
# content :jsonb
|
||||||
|
# show_on_sidebar :boolean default(FALSE), not null
|
||||||
# title :string not null
|
# title :string not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
|||||||
@ -77,7 +77,6 @@ class Inbox < ApplicationRecord
|
|||||||
has_one :agent_bot, through: :agent_bot_inbox
|
has_one :agent_bot, through: :agent_bot_inbox
|
||||||
has_many :webhooks, dependent: :destroy_async
|
has_many :webhooks, dependent: :destroy_async
|
||||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||||
has_many :inbox_automations, dependent: :destroy_async, class_name: 'Captain::InboxAutomation'
|
|
||||||
|
|
||||||
# Jasmine
|
# Jasmine
|
||||||
has_one :jasmine_inbox_config, class_name: 'Jasmine::InboxConfig', dependent: :destroy
|
has_one :jasmine_inbox_config, class_name: 'Jasmine::InboxConfig', dependent: :destroy
|
||||||
|
|||||||
@ -24,15 +24,14 @@
|
|||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
# fk_rails_... (owner_inbox_id => inboxes.id)
|
# fk_rails_... (owner_inbox_id => inboxes.id)
|
||||||
#
|
#
|
||||||
module Jasmine
|
class Jasmine::Collection < ApplicationRecord
|
||||||
class Collection < ApplicationRecord
|
|
||||||
self.table_name = 'jasmine_collections'
|
self.table_name = 'jasmine_collections'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :owner_inbox, class_name: 'Inbox', optional: true
|
belongs_to :owner_inbox, class_name: 'Inbox', optional: true
|
||||||
|
|
||||||
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', foreign_key: :collection_id, dependent: :destroy
|
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy
|
||||||
has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, dependent: :destroy
|
has_many :documents, class_name: 'Jasmine::Document', dependent: :destroy
|
||||||
|
|
||||||
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
|
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
|
||||||
|
|
||||||
@ -43,9 +42,8 @@ module Jasmine
|
|||||||
private
|
private
|
||||||
|
|
||||||
def validate_owner_if_private
|
def validate_owner_if_private
|
||||||
if visibility_private? && owner_inbox_id.nil?
|
return unless visibility_private? && owner_inbox_id.nil?
|
||||||
|
|
||||||
errors.add(:owner_inbox_id, 'must be present for private collections')
|
errors.add(:owner_inbox_id, 'must be present for private collections')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -25,13 +25,12 @@
|
|||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
# fk_rails_... (collection_id => jasmine_collections.id)
|
# fk_rails_... (collection_id => jasmine_collections.id)
|
||||||
#
|
#
|
||||||
module Jasmine
|
class Jasmine::Document < ApplicationRecord
|
||||||
class Document < ApplicationRecord
|
|
||||||
self.table_name = 'jasmine_documents'
|
self.table_name = 'jasmine_documents'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :collection, class_name: 'Jasmine::Collection'
|
belongs_to :collection, class_name: 'Jasmine::Collection'
|
||||||
has_many :chunks, class_name: 'Jasmine::DocumentChunk', foreign_key: :document_id, dependent: :delete_all
|
has_many :chunks, class_name: 'Jasmine::DocumentChunk', dependent: :delete_all
|
||||||
|
|
||||||
enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
|
enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
|
||||||
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
|
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
|
||||||
@ -46,6 +45,7 @@ module Jasmine
|
|||||||
|
|
||||||
def validate_account_consistency
|
def validate_account_consistency
|
||||||
return if collection.nil?
|
return if collection.nil?
|
||||||
|
|
||||||
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
|
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -53,4 +53,3 @@ module Jasmine
|
|||||||
Jasmine::EmbedDocumentJob.perform_later(id)
|
Jasmine::EmbedDocumentJob.perform_later(id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -26,8 +26,7 @@
|
|||||||
# fk_rails_... (collection_id => jasmine_collections.id)
|
# fk_rails_... (collection_id => jasmine_collections.id)
|
||||||
# fk_rails_... (inbox_id => inboxes.id)
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
#
|
#
|
||||||
module Jasmine
|
class Jasmine::InboxCollection < ApplicationRecord
|
||||||
class InboxCollection < ApplicationRecord
|
|
||||||
self.table_name = 'jasmine_inbox_collections'
|
self.table_name = 'jasmine_inbox_collections'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
@ -53,9 +52,8 @@ module Jasmine
|
|||||||
def validate_visibility_rules
|
def validate_visibility_rules
|
||||||
return if collection.nil? || inbox.nil?
|
return if collection.nil? || inbox.nil?
|
||||||
|
|
||||||
if collection.visibility_private? && collection.owner_inbox_id != inbox_id
|
return unless collection.visibility_private? && collection.owner_inbox_id != inbox_id
|
||||||
|
|
||||||
errors.add(:base, 'Private collections can only be linked to their owner inbox')
|
errors.add(:base, 'Private collections can only be linked to their owner inbox')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -29,8 +29,7 @@
|
|||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
# fk_rails_... (inbox_id => inboxes.id)
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
#
|
#
|
||||||
module Jasmine
|
class Jasmine::InboxConfig < ApplicationRecord
|
||||||
class InboxConfig < ApplicationRecord
|
|
||||||
self.table_name = 'jasmine_inbox_settings'
|
self.table_name = 'jasmine_inbox_settings'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
@ -44,7 +43,7 @@ module Jasmine
|
|||||||
|
|
||||||
def validate_account_consistency
|
def validate_account_consistency
|
||||||
return if inbox.nil?
|
return if inbox.nil?
|
||||||
|
|
||||||
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
|
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -28,8 +28,7 @@
|
|||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
# fk_rails_... (inbox_id => inboxes.id)
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
#
|
#
|
||||||
module Jasmine
|
class Jasmine::ToolConfig < ApplicationRecord
|
||||||
class ToolConfig < ApplicationRecord
|
|
||||||
self.table_name = 'jasmine_tool_configs'
|
self.table_name = 'jasmine_tool_configs'
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
@ -68,4 +67,3 @@ module Jasmine
|
|||||||
DEFINITIONS
|
DEFINITIONS
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ class Message < ApplicationRecord
|
|||||||
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
|
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
|
||||||
# [:external_error : Can specify if the message creation failed due to an error at external API
|
# [:external_error : Can specify if the message creation failed due to an error at external API
|
||||||
# [:data] : Used for structured content types such as voice_call
|
# [:data] : Used for structured content types such as voice_call
|
||||||
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
|
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :deleted,
|
||||||
:external_created_at, :story_sender, :story_id, :external_error,
|
:external_created_at, :story_sender, :story_id, :external_error,
|
||||||
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
|
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
|
||||||
|
|
||||||
@ -127,6 +127,7 @@ class Message < ApplicationRecord
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
belongs_to :conversation, touch: true
|
belongs_to :conversation, touch: true
|
||||||
belongs_to :sender, polymorphic: true, optional: true
|
belongs_to :sender, polymorphic: true, optional: true
|
||||||
|
belongs_to :in_reply_to, class_name: 'Message', optional: true
|
||||||
|
|
||||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||||
has_one :csat_survey_response, dependent: :destroy_async
|
has_one :csat_survey_response, dependent: :destroy_async
|
||||||
@ -257,16 +258,30 @@ class Message < ApplicationRecord
|
|||||||
# Returns message content suitable for LLM consumption
|
# Returns message content suitable for LLM consumption
|
||||||
# Falls back to audio transcription or attachment placeholder when content is nil
|
# Falls back to audio transcription or attachment placeholder when content is nil
|
||||||
def content_for_llm
|
def content_for_llm
|
||||||
return content if content.present?
|
parts = []
|
||||||
|
parts << content if content.present?
|
||||||
|
|
||||||
audio_transcription = attachments
|
# Audio handling
|
||||||
|
audio_transcriptions = attachments
|
||||||
.where(file_type: :audio)
|
.where(file_type: :audio)
|
||||||
.filter_map { |att| att.meta&.dig('transcribed_text') }
|
.filter_map { |att| att.meta&.dig('transcribed_text') }
|
||||||
.join(' ')
|
|
||||||
.presence
|
|
||||||
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
|
|
||||||
|
|
||||||
'[Attachment]' if attachments.any?
|
parts << "[Mensagem de Voz]: #{audio_transcriptions.join(' ')}" if audio_transcriptions.any?
|
||||||
|
|
||||||
|
# Image handling (placeholders if not analyzed yet)
|
||||||
|
image_descriptions = attachments
|
||||||
|
.where(file_type: :image)
|
||||||
|
.filter_map { |att| att.meta&.dig('description') }
|
||||||
|
|
||||||
|
parts << "[Imagem]: #{image_descriptions.join(', ')}" if image_descriptions.any?
|
||||||
|
|
||||||
|
# Fallback for other attachments
|
||||||
|
if parts.empty? && attachments.any?
|
||||||
|
other_types = attachments.where.not(file_type: [:audio, :image]).pluck(:file_type).uniq
|
||||||
|
parts << "[Anexo: #{other_types.join(', ')}]"
|
||||||
|
end
|
||||||
|
|
||||||
|
parts.join("\n").presence || ''
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@ -147,8 +147,10 @@ class Notification < ApplicationRecord
|
|||||||
|
|
||||||
if content.present?
|
if content.present?
|
||||||
transform_user_mention_content(content.truncate_words(10))
|
transform_user_mention_content(content.truncate_words(10))
|
||||||
|
elsif attachments.present?
|
||||||
|
'Attachment'
|
||||||
else
|
else
|
||||||
attachments.present? ? I18n.t('notifications.attachment') : I18n.t('notifications.no_content')
|
I18n.t('notifications.no_content')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,7 @@ class User < ApplicationRecord
|
|||||||
accepts_nested_attributes_for :account_users
|
accepts_nested_attributes_for :account_users
|
||||||
|
|
||||||
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
|
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
|
||||||
alias_attribute :conversations, :assigned_conversations
|
alias conversations assigned_conversations
|
||||||
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
|
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
|
||||||
has_many :conversation_participants, dependent: :destroy_async
|
has_many :conversation_participants, dependent: :destroy_async
|
||||||
has_many :participating_conversations, through: :conversation_participants, source: :conversation
|
has_many :participating_conversations, through: :conversation_participants, source: :conversation
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class Webhook < ApplicationRecord
|
|||||||
enum webhook_type: { account_type: 0, inbox_type: 1 }
|
enum webhook_type: { account_type: 0, inbox_type: 1 }
|
||||||
|
|
||||||
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
||||||
message_created message_updated webwidget_triggered inbox_created inbox_updated
|
message_created message_incoming message_outgoing message_updated webwidget_triggered inbox_created inbox_updated
|
||||||
conversation_typing_on conversation_typing_off].freeze
|
conversation_typing_on conversation_typing_off].freeze
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
4
app/services/captain/assistant.rb
Normal file
4
app/services/captain/assistant.rb
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Base module to fix NameError
|
||||||
|
module Captain::Assistant
|
||||||
|
# Base module to fix NameError
|
||||||
|
end
|
||||||
@ -4,8 +4,7 @@ require 'json'
|
|||||||
require 'openssl'
|
require 'openssl'
|
||||||
require 'base64'
|
require 'base64'
|
||||||
|
|
||||||
module Captain
|
class Captain::InterService
|
||||||
class InterService
|
|
||||||
# Constants for API URLs
|
# Constants for API URLs
|
||||||
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
|
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
|
||||||
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
|
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
|
||||||
@ -118,4 +117,3 @@ module Captain
|
|||||||
http.request(req)
|
http.request(req)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
module Captain
|
class Captain::Reservations::SyncService
|
||||||
module Reservations
|
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
|
||||||
class SyncService
|
|
||||||
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
|
|
||||||
|
|
||||||
def initialize(unit)
|
def initialize(unit)
|
||||||
@unit = unit
|
@unit = unit
|
||||||
@ -116,7 +114,7 @@ module Captain
|
|||||||
contact = nil
|
contact = nil
|
||||||
|
|
||||||
# Try finding by phone
|
# Try finding by phone
|
||||||
contact = @account.contacts.find_by_phone_number(phone) if phone.present?
|
contact = @account.contacts.find_by(phone_number: phone) if phone.present?
|
||||||
|
|
||||||
# Try finding by email
|
# Try finding by email
|
||||||
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
|
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
|
||||||
@ -162,18 +160,12 @@ module Captain
|
|||||||
|
|
||||||
return :scheduled unless check_in && check_out
|
return :scheduled unless check_in && check_out
|
||||||
|
|
||||||
if check_in.to_date == now.to_date
|
if now >= check_out
|
||||||
:scheduled # Or 'awaiting_checkin' if we want to be more specific, but MVP 'scheduled' is usually 'Entrada'
|
:completed
|
||||||
elsif now >= check_in && now < check_out
|
elsif now >= check_in && now < check_out
|
||||||
:active # 'Hospedada'
|
:active
|
||||||
elsif now >= check_out
|
|
||||||
:completed # 'Saída' / checkout done
|
|
||||||
elsif now < check_in
|
|
||||||
:scheduled
|
|
||||||
else
|
else
|
||||||
:scheduled # Default
|
:scheduled
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -65,7 +65,7 @@ class Conversations::MessageWindowService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_messaging_window
|
def whatsapp_messaging_window
|
||||||
return nil if @conversation.inbox.channel.provider == 'wuzapi'
|
return nil if @conversation.inbox.channel&.provider == 'wuzapi'
|
||||||
|
|
||||||
MESSAGING_WINDOW_24_HOURS
|
MESSAGING_WINDOW_24_HOURS
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module CrmInsights
|
class CrmInsights::ContactSessionCounter
|
||||||
class ContactSessionCounter
|
|
||||||
WINDOW = 24.hours
|
WINDOW = 24.hours
|
||||||
|
|
||||||
def initialize(conversation)
|
def initialize(conversation)
|
||||||
@ -28,4 +27,3 @@ module CrmInsights
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
module CrmInsights
|
class CrmInsights::GenerateService < Llm::BaseAiService
|
||||||
class GenerateService < Llm::BaseAiService
|
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
|
||||||
DEFAULT_MODEL = 'gpt-4o-mini'
|
|
||||||
|
|
||||||
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
|
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
|
||||||
super()
|
super()
|
||||||
@ -169,4 +168,3 @@ module CrmInsights
|
|||||||
"#{@from_message_id} ate #{@to_message_id}"
|
"#{@from_message_id} ate #{@to_message_id}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module CrmInsights
|
class CrmInsights::UpdateService
|
||||||
class UpdateService
|
|
||||||
def initialize(conversation:, reason: nil)
|
def initialize(conversation:, reason: nil)
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@reason = reason
|
@reason = reason
|
||||||
@ -356,4 +355,3 @@ module CrmInsights
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module Jasmine
|
class Jasmine::BrainService
|
||||||
class BrainService
|
|
||||||
# Default intent keywords for hotel/motel business
|
# Default intent keywords for hotel/motel business
|
||||||
DEFAULT_INTENT_KEYWORDS = {
|
DEFAULT_INTENT_KEYWORDS = {
|
||||||
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
|
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
|
||||||
@ -26,17 +25,20 @@ module Jasmine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def respond
|
def respond
|
||||||
intent = IntentDetector.new(message.content, intent_keywords).detect
|
trigger_media_analysis if message.attachments.any?
|
||||||
|
llm_content = message.content_for_llm
|
||||||
|
|
||||||
|
intent = IntentDetector.new(llm_content, intent_keywords).detect
|
||||||
strategy = StrategyDecider.new(intent, jasmine_state).decide
|
strategy = StrategyDecider.new(intent, jasmine_state).decide
|
||||||
|
|
||||||
rag_context = fetch_rag_if_needed(strategy, message.content)
|
rag_context = fetch_rag_if_needed(strategy, llm_content)
|
||||||
|
|
||||||
prompt = PromptAssembler.new(
|
prompt = PromptAssembler.new(
|
||||||
config: config,
|
config: config,
|
||||||
state: jasmine_state,
|
state: jasmine_state,
|
||||||
history: recent_history,
|
history: recent_history,
|
||||||
rag_context: rag_context,
|
rag_context: rag_context,
|
||||||
current_message: message.content
|
current_message: llm_content
|
||||||
).assemble
|
).assemble
|
||||||
|
|
||||||
response = call_llm(prompt)
|
response = call_llm(prompt)
|
||||||
@ -114,6 +116,15 @@ module Jasmine
|
|||||||
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
|
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trigger_media_analysis
|
||||||
|
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
|
||||||
|
Jasmine::MediaAnalyzerService.new(message: message).perform
|
||||||
|
message.attachments.reload # CRITICAL: Ensure we see the new metadata
|
||||||
|
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
# =========================================
|
# =========================================
|
||||||
# COMPONENT: Intent Detector
|
# COMPONENT: Intent Detector
|
||||||
# =========================================
|
# =========================================
|
||||||
@ -271,4 +282,3 @@ module Jasmine
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
module Jasmine
|
class Jasmine::EmbeddingService
|
||||||
class EmbeddingService
|
|
||||||
CHUNK_SIZE = 1000
|
CHUNK_SIZE = 1000
|
||||||
CHUNK_OVERLAP = 200
|
CHUNK_OVERLAP = 200
|
||||||
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
|
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
|
||||||
@ -91,7 +90,7 @@ module Jasmine
|
|||||||
else
|
else
|
||||||
# Fallback: Generate deterministic hash-based embedding for testing
|
# Fallback: Generate deterministic hash-based embedding for testing
|
||||||
# This won't provide semantic search but allows the system to function
|
# This won't provide semantic search but allows the system to function
|
||||||
Rails.logger.warn "OpenAI not configured, using fallback embedding for Jasmine"
|
Rails.logger.warn 'OpenAI not configured, using fallback embedding for Jasmine'
|
||||||
generate_fallback_embedding(text)
|
generate_fallback_embedding(text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -125,5 +124,3 @@ module Jasmine
|
|||||||
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
|
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
|
|||||||
51
app/services/jasmine/media_analyzer_service.rb
Normal file
51
app/services/jasmine/media_analyzer_service.rb
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Jasmine::MediaAnalyzerService
|
||||||
|
attr_reader :message
|
||||||
|
|
||||||
|
def initialize(message:)
|
||||||
|
@message = message
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
analyze_audio
|
||||||
|
analyze_images
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def analyze_audio
|
||||||
|
message.attachments.where(file_type: :audio).find_each do |attachment|
|
||||||
|
next if attachment.meta&.dig('transcribed_text').present?
|
||||||
|
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
|
||||||
|
# Try to use the standard AudioTranscriptionService (usually in enterprise folder)
|
||||||
|
begin
|
||||||
|
if defined?(Messages::AudioTranscriptionService)
|
||||||
|
# This service updates the attachment meta internally
|
||||||
|
Messages::AudioTranscriptionService.new(attachment).perform
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze_images
|
||||||
|
message.attachments.where(file_type: :image).find_each do |attachment|
|
||||||
|
next if attachment.meta&.dig('description').present?
|
||||||
|
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
|
||||||
|
begin
|
||||||
|
description = Jasmine::VisionService.new(attachment: attachment).perform
|
||||||
|
if description.present?
|
||||||
|
new_meta = (attachment.meta || {}).merge('description' => description)
|
||||||
|
attachment.update!(meta: new_meta)
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}"
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,5 +1,4 @@
|
|||||||
module Jasmine
|
class Jasmine::SemanticSearchService
|
||||||
class SemanticSearchService
|
|
||||||
CANDIDATES_PER_PRIORITY = 50
|
CANDIDATES_PER_PRIORITY = 50
|
||||||
TOP_K_PER_PRIORITY = 10
|
TOP_K_PER_PRIORITY = 10
|
||||||
MAX_CHUNKS_PER_DOC = 2
|
MAX_CHUNKS_PER_DOC = 2
|
||||||
@ -175,4 +174,3 @@ module Jasmine
|
|||||||
vector.to_s
|
vector.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
require 'rest-client'
|
require 'rest-client'
|
||||||
|
|
||||||
module Jasmine
|
class Jasmine::ToolRunner
|
||||||
class ToolRunner
|
|
||||||
attr_reader :inbox, :tool_key, :config
|
attr_reader :inbox, :tool_key, :config
|
||||||
|
|
||||||
def initialize(inbox, tool_key)
|
def initialize(inbox, tool_key)
|
||||||
@ -13,7 +12,7 @@ module Jasmine
|
|||||||
def run
|
def run
|
||||||
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
|
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
|
||||||
raise "Tool not found definition: #{@tool_key}" unless definition
|
raise "Tool not found definition: #{@tool_key}" unless definition
|
||||||
raise "Tool not configured or disabled" unless config&.is_enabled?
|
raise 'Tool not configured or disabled' unless config&.is_enabled?
|
||||||
|
|
||||||
start_time = Time.current
|
start_time = Time.current
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ module Jasmine
|
|||||||
response = make_request(definition)
|
response = make_request(definition)
|
||||||
duration = (Time.current - start_time) * 1000
|
duration = (Time.current - start_time) * 1000
|
||||||
|
|
||||||
success = (200..299).include?(response.code)
|
success = (200..299).cover?(response.code)
|
||||||
body = response.body.to_s
|
body = response.body.to_s
|
||||||
|
|
||||||
# Save stats
|
# Save stats
|
||||||
@ -38,7 +37,11 @@ module Jasmine
|
|||||||
error_msg = e.message
|
error_msg = e.message
|
||||||
|
|
||||||
# Try to parse body from error response if available
|
# Try to parse body from error response if available
|
||||||
error_body = e.response&.body rescue nil
|
error_body = begin
|
||||||
|
e.response&.body
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
error_msg = "#{error_msg} - #{error_body}" if error_body
|
error_msg = "#{error_msg} - #{error_body}" if error_body
|
||||||
|
|
||||||
sanitized_error = sanitize(error_msg)
|
sanitized_error = sanitize(error_msg)
|
||||||
@ -97,8 +100,8 @@ module Jasmine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sanitize(text)
|
def sanitize(text)
|
||||||
return text unless @config&.plug_play_token.present?
|
return text if @config&.plug_play_token.blank?
|
||||||
|
|
||||||
text.to_s.gsub(@config.plug_play_token, '****')
|
text.to_s.gsub(@config.plug_play_token, '****')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
67
app/services/jasmine/vision_service.rb
Normal file
67
app/services/jasmine/vision_service.rb
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'openai'
|
||||||
|
require 'base64'
|
||||||
|
|
||||||
|
class Jasmine::VisionService
|
||||||
|
attr_reader :attachment
|
||||||
|
|
||||||
|
def initialize(attachment:)
|
||||||
|
@attachment = attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return nil unless attachment.image?
|
||||||
|
|
||||||
|
api_key = ENV.fetch('OPENAI_API_KEY', nil)
|
||||||
|
return nil if api_key.blank?
|
||||||
|
|
||||||
|
client = OpenAI::Client.new(access_token: api_key)
|
||||||
|
|
||||||
|
image_data = get_image_data
|
||||||
|
return nil if image_data.blank?
|
||||||
|
|
||||||
|
response = client.chat(
|
||||||
|
parameters: {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
|
||||||
|
image_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: 300
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.dig('choices', 0, 'message', 'content')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get_image_data
|
||||||
|
# Always return base64 for better compatibility (OpenAI can reach it even if local)
|
||||||
|
# and it avoids issues with signed URL expiration or private buckets.
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: "data:#{attachment.file.content_type};base64,#{encode_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode_image
|
||||||
|
attachment.file.blob.open do |file|
|
||||||
|
Base64.strict_encode64(file.read)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
app/services/llm/base_ai_service.rb
Normal file
5
app/services/llm/base_ai_service.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
class Llm::BaseAiService
|
||||||
|
def initialize
|
||||||
|
# Base initialization logic if needed
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -10,6 +10,7 @@ class Messages::InReplyToMessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_in_reply_to_attribute
|
def set_in_reply_to_attribute
|
||||||
|
@message.in_reply_to = in_reply_to_message
|
||||||
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
|
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
|
||||||
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
|
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user