chore(ops): restore all source files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Ignorar arquivos de configuração sensíveis locais se copiados acidentalmente
|
||||
gcal_key.json
|
||||
ssh_alerts_config.json
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,319 @@
|
||||
# Baileys Engine Swap — Plano de Implementação
|
||||
|
||||
**Data:** 2026-04-29
|
||||
**Contexto:** Mensagens ricas (botões, lista, carrossel) não renderizam no WhatsApp Web com `@whiskeysockets/baileys` padrão. A solução é usar o fork InfiniteAPI que implementa esses tipos nativamente dentro do `sendMessage()`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema Raiz
|
||||
|
||||
O `@whiskeysockets/baileys` v6.7.18 (oficial) **não suporta** os campos:
|
||||
- `nativeButtons`
|
||||
- `nativeList`
|
||||
- `nativeCarousel`
|
||||
|
||||
O WhatsApp Web exige nós binários adicionais (`<biz><interactive>`, `<bot biz_bot="1"/>`) injetados no stanza via `relayMessage(additionalNodes)`. O `rich-message.ts` atual tenta replicar isso manualmente, mas **ainda falha no WhatsApp Web**.
|
||||
|
||||
**Erro observado:** "Não foi possível carregar a mensagem. Use seu celular para acessá-la."
|
||||
|
||||
### Por que o InfiniteAPI funciona
|
||||
|
||||
O fork `github:rsalcara/InfiniteAPI` (v7.0.0-rc.9, package name: `baileys`) implementa toda a lógica de `additionalNodes` internamente no `generateWAMessageContent()` / `relayMessage()`. O dev só chama `sock.sendMessage(jid, { nativeButtons: [...] })` e o fork faz o resto.
|
||||
|
||||
### Por que whatsmeow (Go) NÃO resolve
|
||||
|
||||
Pesquisa confirmada (abril 2026):
|
||||
- `InteractiveMessage` + `NativeFlowMessage` → struct existe, botões **não aparecem** (issue #711, sem solução)
|
||||
- `ListMessage` → **não exibe** na conversa do destinatário (issue #669)
|
||||
- `CarouselMessage` → **não existe** (exclusivo da Cloud API)
|
||||
- Migração seria ~2–3 meses de reescrita para o mesmo resultado quebrado
|
||||
|
||||
---
|
||||
|
||||
## 2. Solução Escolhida: Engine Swap com Fallback
|
||||
|
||||
Instalar **os dois** packages (nomes diferentes, convivem no mesmo `node_modules`) e criar uma camada de abstração `engine/` que permite trocar via variável de ambiente + restart automático.
|
||||
|
||||
**Downtime por troca:** ~5–10 segundos (restart + reconexão automática, sem novo QR — auth state no disco é compatível entre os dois, InfiniteAPI é fork direto).
|
||||
|
||||
---
|
||||
|
||||
## 3. Estrutura de Arquivos
|
||||
|
||||
```
|
||||
backend/
|
||||
src/
|
||||
modules/whatsapp/
|
||||
engine/
|
||||
official.ts ← re-exporta @whiskeysockets/baileys
|
||||
infinite.ts ← re-exporta baileys (InfiniteAPI)
|
||||
index.ts ← exporta conforme process.env.BAILEYS_ENGINE
|
||||
connection/
|
||||
WhatsAppConnectionManager.ts ← muda import para '../engine'
|
||||
handlers/
|
||||
MessageHandler.ts ← muda import para '../../engine' (ou relativo)
|
||||
ContactHandler.ts ← idem
|
||||
rich-message.ts ← simplificado + fallback comentado
|
||||
modules/chatbot/
|
||||
chatbot.service.ts ← muda import
|
||||
shared/utils/
|
||||
whatsapp.ts ← muda import
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementação Passo a Passo
|
||||
|
||||
### 4.1 Instalar os dois packages
|
||||
|
||||
```bash
|
||||
cd /home/deploy/projetos/newwhats.local/backend
|
||||
|
||||
# Manter o oficial
|
||||
# @whiskeysockets/baileys já está em package.json
|
||||
|
||||
# Adicionar InfiniteAPI com alias para não conflitar
|
||||
npm install baileys@github:rsalcara/InfiniteAPI
|
||||
```
|
||||
|
||||
Ambos convivem no `node_modules` porque têm nomes diferentes:
|
||||
- `node_modules/@whiskeysockets/baileys/`
|
||||
- `node_modules/baileys/`
|
||||
|
||||
### 4.2 Criar `engine/official.ts`
|
||||
|
||||
```typescript
|
||||
// Re-exporta tudo do Baileys oficial
|
||||
export * from '@whiskeysockets/baileys'
|
||||
export { proto } from '@whiskeysockets/baileys'
|
||||
```
|
||||
|
||||
### 4.3 Criar `engine/infinite.ts`
|
||||
|
||||
```typescript
|
||||
// Re-exporta tudo do InfiniteAPI
|
||||
export * from 'baileys'
|
||||
export { proto } from 'baileys'
|
||||
```
|
||||
|
||||
### 4.4 Criar `engine/index.ts`
|
||||
|
||||
```typescript
|
||||
// Seleciona engine conforme BAILEYS_ENGINE (padrão: infinite)
|
||||
// IMPORTANTE: process.env lido em tempo de inicialização do módulo.
|
||||
// Para trocar a engine: alterar BAILEYS_ENGINE + reiniciar o processo.
|
||||
const engine = process.env.BAILEYS_ENGINE ?? 'infinite'
|
||||
|
||||
if (engine === 'infinite') {
|
||||
module.exports = require('./infinite')
|
||||
} else {
|
||||
module.exports = require('./official')
|
||||
}
|
||||
```
|
||||
|
||||
> **Nota TypeScript:** usar `require()` condicional ou um `export *` com re-export dinâmico.
|
||||
> Alternativa mais simples: dois arquivos de entrada separados e o `tsconfig` aponta para um deles via path alias.
|
||||
|
||||
### 4.5 Trocar imports nos 6 arquivos
|
||||
|
||||
| Arquivo | Import atual | Novo import |
|
||||
|---|---|---|
|
||||
| `WhatsAppConnectionManager.ts` | `@whiskeysockets/baileys` | `../engine` |
|
||||
| `handlers/MessageHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
|
||||
| `handlers/ContactHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
|
||||
| `rich-message.ts` | `@whiskeysockets/baileys` (3 imports) | `./engine` |
|
||||
| `modules/chatbot/chatbot.service.ts` | `@whiskeysockets/baileys` | `../whatsapp/engine` |
|
||||
| `shared/utils/whatsapp.ts` | (verificar) | path relativo para engine |
|
||||
|
||||
### 4.6 Simplificar `rich-message.ts` para InfiniteAPI
|
||||
|
||||
Com InfiniteAPI, o `sendRichMessage()` fica:
|
||||
|
||||
```typescript
|
||||
export async function sendRichMessage(
|
||||
sock: WASocket,
|
||||
jid: string,
|
||||
payload: RichPayload,
|
||||
quoted?: { key: proto.IMessageKey; message: proto.IMessage },
|
||||
): Promise<string> {
|
||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
|
||||
|
||||
if (poll) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
poll: { name: poll.name, values: poll.values, selectableCount: poll.selectableCount ?? 1 },
|
||||
}, quoted ? { quoted } : undefined)
|
||||
return sent?.key?.id ?? `poll-${Date.now()}`
|
||||
}
|
||||
|
||||
if (nativeButtons) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeButtons, // InfiniteAPI trata tudo internamente
|
||||
} as any, quoted ? { quoted } : undefined)
|
||||
return sent?.key?.id ?? `btn-${Date.now()}`
|
||||
}
|
||||
|
||||
if (nativeList) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeList,
|
||||
} as any, quoted ? { quoted } : undefined)
|
||||
return sent?.key?.id ?? `list-${Date.now()}`
|
||||
}
|
||||
|
||||
if (nativeCarousel) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeCarousel,
|
||||
} as any, quoted ? { quoted } : undefined)
|
||||
return sent?.key?.id ?? `carousel-${Date.now()}`
|
||||
}
|
||||
|
||||
// Texto simples
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined)
|
||||
return sent?.key?.id ?? `text-${Date.now()}`
|
||||
}
|
||||
```
|
||||
|
||||
> **Fallback:** Manter o código atual do proto manual em `rich-message.official.ts` caso precise debugar com a engine oficial.
|
||||
|
||||
### 4.7 Adicionar BAILEYS_ENGINE ao .env
|
||||
|
||||
```bash
|
||||
# /home/deploy/projetos/newwhats.local/backend/.env
|
||||
BAILEYS_ENGINE=infinite # ou: official
|
||||
```
|
||||
|
||||
### 4.8 Endpoint de toggle no painel admin
|
||||
|
||||
**Backend** — novo endpoint em `whatsapp.routes.ts` ou `admin.routes.ts`:
|
||||
|
||||
```typescript
|
||||
// POST /api/admin/engine
|
||||
// Body: { engine: 'infinite' | 'official' }
|
||||
router.post('/engine', adminOnly, async (req, res) => {
|
||||
const { engine } = req.body
|
||||
if (!['infinite', 'official'].includes(engine)) return res.status(400).json({ error: 'invalid engine' })
|
||||
|
||||
// 1. Persiste no banco (tabela config ou settings)
|
||||
await prisma.config.upsert({
|
||||
where: { key: 'BAILEYS_ENGINE' },
|
||||
update: { value: engine },
|
||||
create: { key: 'BAILEYS_ENGINE', value: engine },
|
||||
})
|
||||
|
||||
// 2. Reinicia o processo (pm2 ou systemctl)
|
||||
// Usar exec() com o comando correto para o ambiente
|
||||
exec('pm2 restart newwhats-backend', (err) => {
|
||||
if (err) return res.status(500).json({ error: 'restart failed' })
|
||||
res.json({ ok: true, engine })
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/admin/engine — retorna engine ativa
|
||||
router.get('/engine', adminOnly, async (req, res) => {
|
||||
const config = await prisma.config.findUnique({ where: { key: 'BAILEYS_ENGINE' } })
|
||||
res.json({ engine: config?.value ?? process.env.BAILEYS_ENGINE ?? 'infinite' })
|
||||
})
|
||||
```
|
||||
|
||||
**Frontend** — componente simples na página de administração:
|
||||
|
||||
```tsx
|
||||
// Toggle na página /admin/settings ou /admin/system
|
||||
<div>
|
||||
<span>Engine WhatsApp</span>
|
||||
<select value={engine} onChange={handleSwitch}>
|
||||
<option value="infinite">InfiniteAPI (botões/lista/carrossel)</option>
|
||||
<option value="official">Baileys Oficial (estável, sem botões)</option>
|
||||
</select>
|
||||
<span className="text-yellow-500">⚠ Troca reinicia o servidor (~10s)</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Tipos Suportados por Engine
|
||||
|
||||
| Tipo de Mensagem | Baileys Oficial | InfiniteAPI |
|
||||
|---|---|---|
|
||||
| Texto simples | ✅ | ✅ |
|
||||
| Reply / Quote | ✅ | ✅ |
|
||||
| Menções (@) | ✅ | ✅ |
|
||||
| Mídia (foto/vídeo/doc/áudio) | ✅ | ✅ |
|
||||
| Enquete (poll) | ✅ | ✅ |
|
||||
| Botões (`nativeButtons`) — reply, url, copy, call | ❌ (quebrado no Web) | ✅ |
|
||||
| Lista (`nativeList`) | ❌ (quebrado no Web) | ✅ |
|
||||
| Carrossel (`nativeCarousel`) | ❌ (quebrado no Web) | ✅ |
|
||||
| Botão com header imagem/vídeo | ❌ | ✅ (`headerImage`, `headerVideo`) |
|
||||
|
||||
### Tipos de botão suportados pelo InfiniteAPI
|
||||
|
||||
```typescript
|
||||
type NativeButton =
|
||||
| { type: 'reply'; id: string; text: string } // Quick reply
|
||||
| { type: 'url'; text: string; url: string } // Abrir URL
|
||||
| { type: 'copy'; text: string; copyText: string } // Copiar texto
|
||||
| { type: 'call'; text: string; phoneNumber: string } // Ligar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Riscos e Mitigações
|
||||
|
||||
| Risco | Mitigação |
|
||||
|---|---|
|
||||
| InfiniteAPI é RC (não estável) | Engine swap — volta pro oficial em 10s |
|
||||
| Repo `github:rsalcara/InfiniteAPI` pode sair do ar | Fazer fork próprio no GitHub interno |
|
||||
| Breaking changes na API do fork | Wrapper `engine/` isola o impacto |
|
||||
| Auth state incompatível após troca | Não é problema — InfiniteAPI usa o mesmo formato `useMultiFileAuthState` |
|
||||
| TypeScript: tipos diferentes entre engines | Usar `as any` nos campos extras do InfiniteAPI |
|
||||
|
||||
---
|
||||
|
||||
## 7. Estimativa de Esforço
|
||||
|
||||
| Tarefa | Tempo |
|
||||
|---|---|
|
||||
| Instalar packages + criar `engine/` | 30 min |
|
||||
| Trocar imports nos 6 arquivos | 30 min |
|
||||
| Simplificar `rich-message.ts` | 30 min |
|
||||
| Endpoint admin + frontend toggle | 2 horas |
|
||||
| Teste real (celular + Web) | 1 hora |
|
||||
| **Total** | **~4–5 horas** |
|
||||
|
||||
---
|
||||
|
||||
## 8. Arquivos-Chave para Referência
|
||||
|
||||
| Arquivo | Descrição |
|
||||
|---|---|
|
||||
| `backend/src/modules/whatsapp/rich-message.ts` | Lógica atual de envio rico (proto manual) |
|
||||
| `backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts` | Core da conexão Baileys (1265 linhas) |
|
||||
| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | Processa mensagens recebidas (865 linhas) |
|
||||
| `backend/src/modules/whatsapp/whatsapp.send.routes.ts` | Rotas de envio (757 linhas) |
|
||||
| `/home/deploy/projetos/whatsbotoes/baileys_interactive/` | Projeto de referência usando InfiniteAPI |
|
||||
| `/home/deploy/projetos/whatsbotoes/tipos_de_disparo.md` | Documentação dos tipos de disparo |
|
||||
| `node_modules/baileys/lib/Types/Message.d.ts` | Tipos do InfiniteAPI (após instalar) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Comandos Úteis
|
||||
|
||||
```bash
|
||||
# Compilar backend
|
||||
cd /home/deploy/projetos/newwhats.local/backend
|
||||
npx tsc --skipLibCheck
|
||||
|
||||
# Compilar só rich-message
|
||||
npx tsc src/modules/whatsapp/rich-message.ts --outDir dist/modules/whatsapp --skipLibCheck --esModuleInterop --module commonjs --moduleResolution node --target es2020
|
||||
|
||||
# Reiniciar backend
|
||||
pm2 restart newwhats-backend # ou o nome do processo
|
||||
|
||||
# Ver engine ativa
|
||||
grep BAILEYS_ENGINE .env
|
||||
```
|
||||
@@ -0,0 +1,211 @@
|
||||
# 🚀 Guia de Desenvolvimento - NewWhats
|
||||
|
||||
Este documento descreve como configurar e rodar o ambiente de desenvolvimento com **hot-reload** para desenvolvimento rápido.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Portas
|
||||
|
||||
| Serviço | Produção | Desenvolvimento |
|
||||
|---------|----------|-----------------|
|
||||
| Frontend (Next.js) | 3003 | 4004 |
|
||||
| Backend (Express) | 8008 | 8009 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Setup Inicial
|
||||
|
||||
### 1. Requisitos
|
||||
- Node.js (verifique com `node -v`)
|
||||
- npm (verifique com `npm -v`)
|
||||
- PostgreSQL rodando
|
||||
- DragonflyDB (Redis-compatible) rodando
|
||||
- NATS JetStream rodando
|
||||
- Temporal.io rodando
|
||||
|
||||
### 2. Instalar Dependências
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Backend
|
||||
cd ../backend
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏃 Executar em Desenvolvimento (Hot-Reload)
|
||||
|
||||
### Opção 1: Usar Serviços Systemd (Recomendado)
|
||||
|
||||
#### Iniciar Ambos os Serviços
|
||||
```bash
|
||||
# Iniciar dev services
|
||||
sudo systemctl start newwhats-frontend-dev newwhats-backend-dev
|
||||
|
||||
# Verificar status
|
||||
sudo systemctl status newwhats-frontend-dev newwhats-backend-dev
|
||||
|
||||
# Ver logs em tempo real
|
||||
sudo journalctl -u newwhats-frontend-dev -f
|
||||
sudo journalctl -u newwhats-backend-dev -f
|
||||
```
|
||||
|
||||
#### Parar Serviços Dev
|
||||
```bash
|
||||
sudo systemctl stop newwhats-frontend-dev newwhats-backend-dev
|
||||
```
|
||||
|
||||
#### Ativar Auto-start na Boot
|
||||
```bash
|
||||
sudo systemctl enable newwhats-frontend-dev newwhats-backend-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Opção 2: Rodar Manualmente (Terminal)
|
||||
|
||||
#### Terminal 1 - Frontend (Hot-Reload)
|
||||
```bash
|
||||
cd /home/deploy/projetos/newwhats.local/frontend
|
||||
npm run dev
|
||||
|
||||
# Output esperado:
|
||||
# ▲ Next.js 14.2.35
|
||||
# - Local: http://localhost:4004
|
||||
# ✓ Ready in X.Xs
|
||||
```
|
||||
|
||||
#### Terminal 2 - Backend (Hot-Reload)
|
||||
```bash
|
||||
cd /home/deploy/projetos/newwhats.local/backend
|
||||
npm run dev
|
||||
|
||||
# Output esperado:
|
||||
# Server running on port 8009
|
||||
# Watching for file changes...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Variáveis de Ambiente
|
||||
|
||||
### Frontend
|
||||
- `NODE_ENV=development` → Ativa React DevTools e hot-reload
|
||||
- Porta: `4004` (configurado em `package.json`)
|
||||
|
||||
### Backend
|
||||
- `NODE_ENV=development` → Logs detalhados, sem compressão
|
||||
- `PORT=8009` → Porta dev (não conflita com produção 8008)
|
||||
- `.env` já está configurado com valores corretos
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Hot-Reload em Ação
|
||||
|
||||
### Frontend
|
||||
- Edite arquivos em `frontend/pages/`, `frontend/components/`, etc.
|
||||
- Next.js recompila automaticamente e atualiza o navegador
|
||||
- Para mudanças em `next.config.mjs`, reinicie o serviço
|
||||
|
||||
### Backend
|
||||
- Edite arquivos em `backend/src/`
|
||||
- `tsx watch` reinicia o servidor automaticamente
|
||||
- Mudanças em `.env` requerem restart manual
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Porta já em uso
|
||||
```bash
|
||||
# Verificar qual processo usa a porta
|
||||
lsof -i :4004 # Frontend
|
||||
lsof -i :8009 # Backend
|
||||
|
||||
# Matar processo (último recurso)
|
||||
sudo kill -9 <PID>
|
||||
```
|
||||
|
||||
### Serviço não inicia
|
||||
```bash
|
||||
# Verificar logs detalhados
|
||||
sudo systemctl status newwhats-frontend-dev
|
||||
sudo journalctl -u newwhats-frontend-dev -n 50
|
||||
|
||||
# Recarregar systemd após editar .service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Mudanças não aparecem
|
||||
- **Frontend**: Limpar cache do navegador (Ctrl+Shift+Del)
|
||||
- **Backend**: Aguarde a mensagem de restart no terminal
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Scripts Disponíveis
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
npm run dev # Desenvolvimento com hot-reload (porta 4004)
|
||||
npm run build # Build para produção
|
||||
npm run start # Rodar build (produção, porta 3003)
|
||||
npm run lint # Linting
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
npm run dev # Desenvolvimento com tsx watch (porta 8009)
|
||||
npm run build # Compilar TypeScript
|
||||
npm run start # Rodar compilado (produção, porta 8008)
|
||||
npm run db:migrate # Migrar database
|
||||
npm run db:push # Push schema para database
|
||||
npm run db:studio # Prisma Studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 URLs de Desenvolvimento
|
||||
|
||||
- **Frontend**: http://localhost:4004
|
||||
- **Backend API**: http://localhost:8009
|
||||
- **Prisma Studio**: `npm run db:studio` (backend)
|
||||
|
||||
---
|
||||
|
||||
## 📌 Produção vs Desenvolvimento
|
||||
|
||||
| Aspecto | Produção | Desenvolvimento |
|
||||
|---------|----------|-----------------|
|
||||
| Hot-reload | ❌ | ✅ |
|
||||
| Compilação | ✅ Build prévio | ❌ On-the-fly |
|
||||
| Logs | Estruturados (JSON) | Pretty-printed |
|
||||
| Otimização | ✅ | ❌ |
|
||||
| Velocidade Dev | Lenta | ⚡ Rápida |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Boas Práticas
|
||||
|
||||
1. **Sempre use dev em desenvolvimento** — hot-reload economiza tempo
|
||||
2. **Teste em produção antes de deploy** — verifique otimizações
|
||||
3. **Mantenha `.env` sincronizado** — mudanças críticas requerem restart
|
||||
4. **Limpe logs regularmente** — `journalctl --vacuum=time=7d`
|
||||
5. **Verifique portas antes de mudar** — evite conflitos
|
||||
|
||||
---
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para problemas ou dúvidas, verifique:
|
||||
- Logs dos serviços: `journalctl -u newwhats-*-dev -f`
|
||||
- Status das dependências: `systemctl status postgresql dragonfly nats`
|
||||
- Conectividade: `curl http://localhost:8009/health`
|
||||
|
||||
---
|
||||
|
||||
**Última atualização**: 2026-04-10
|
||||
**Ambiente**: Linux (Debian/Ubuntu)
|
||||
@@ -0,0 +1,95 @@
|
||||
# 📝 Relatório Técnico de Implementação — NewWhats SaaS
|
||||
|
||||
Este relatório descreve as ações executadas, decisões arquiteturais consolidadas, riscos mapeados e próximos passos recomendados para o ecossistema NewWhats.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Decisões Arquiteturais e Infraestrutura
|
||||
|
||||
Para atender à premissa de manter o host limpo (sem dependências locais de Node/npm/PM2) e em conformidade com o design "V8 Bunker", a aplicação foi **100% containerizada**:
|
||||
|
||||
1. **Estrutura de Containerização (Multi-Stage) e Resolução do Erro do Prisma**:
|
||||
* **newwhats-backend**: Inicialmente construído com `node:20-alpine`. Identificamos que a engine binária do Prisma requer `libssl.so.1.1` (Glibc/OpenSSL 1.1) que foi removido no Alpine moderno.
|
||||
* **Solução Proativa**: Migramos a base do backend para `node:20-slim` (Debian-based) e instalamos nativamente as dependências `openssl` e `ca-certificates`.
|
||||
* Adicionamos `"debian-openssl-3.0.x"` no bloco `binaryTargets` do `schema.prisma` para compilar o driver otimizado. O backend agora compila e inicializa instantaneamente em conformidade de segurança máxima.
|
||||
2. **Reverse Proxy Unificado e Resiliente (Nginx + Traefik)**:
|
||||
* Mantivemos o Traefik gerenciando os certificados SSL (`HTTPS`) de forma nativa e automática na borda (Portas `80/443`).
|
||||
* **Correção no Nginx (`default.conf`)**: Corrigimos o typo da variável de cabeçalho (`$proxy_add_x_forwarded-for` para `$proxy_add_x_forwarded_for` com sublinha).
|
||||
* **Configuração de Alta Resiliência**: Para evitar que o Nginx falhasse caso o backend estivesse em inicialização, adicionamos a dependência `depends_on` no Compose e configuramos o resolver DNS interno do Docker (`resolver 127.0.0.11 valid=30s`) com variáveis dinâmicas de proxy. O Nginx agora inicia de forma instantânea e tolerante a falhas.
|
||||
3. **Persistência de Sessões**:
|
||||
* O Baileys armazena os arquivos físicos de conexões das contas do WhatsApp. Criamos um volume compartilhado que mapeia o diretório interno do container para a pasta física do host `/home/deploy/newwhats-sessions`, garantindo que os usuários não precisem re-escanear o QR code quando os containers forem atualizados ou recriados.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Arquivos Modificados e Criados
|
||||
|
||||
* 📝 [docker-compose.yml](file:///home/deploy/stack/clube67/docker-compose.yml): Registra os novos serviços, regras de dependências e volumes compartilhados.
|
||||
* 📝 [default.conf](file:///home/deploy/stack/clube67/nginx/default.conf): Mapeamento de proxies Nginx resilientes e regras de conexões WebSockets.
|
||||
* 📝 [Dockerfile (Backend)](file:///home/deploy/stack/clube67/newwhats.local/backend/Dockerfile): Definição de build modular baseada em Debian Slim com suporte total ao Prisma Engine.
|
||||
* 📝 [schema.prisma](file:///home/deploy/stack/clube67/newwhats.local/backend/prisma/schema.prisma): Configuração de compilação multi-target para garantir compatibilidade OpenSSL 3.0.
|
||||
* 📝 [TAREFAS.md](file:///home/deploy/stack/clube67/newwhats.local/TAREFAS.md): Planejamento detalhado por entregáveis.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Pontos de Atenção e Homologação da VPS 3
|
||||
|
||||
> [!WARNING]
|
||||
> **Status das Dependências de Rede na VPS 3 (`10.99.0.3`)**:
|
||||
> Ao inicializar o backend, verificamos nos logs do container que a conexão com o DragonflyDB (Redis local no compose) conectou perfeitamente (`{"msg":"DragonflyDB conectado"}`), mas o Prisma parou na barreira de inicialização:
|
||||
>
|
||||
> `Can't reach database server at 10.99.0.3:5432`
|
||||
>
|
||||
> * **Diagnóstico**: O IP da VPS 3 responde aos pings da VPN instantaneamente, mas a porta `5432` retorna `Connection Refused` do host de destino.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Guia de Ativação do PostgreSQL na VPS 3
|
||||
|
||||
Para liberar o acesso do backend (VPS 1) ao PostgreSQL na VPS 3 de forma totalmente segura, siga os passos abaixo diretamente na VPS 3 (como usuário `deploy`):
|
||||
|
||||
### 1. Configurar escuta do PostgreSQL na interface VPN
|
||||
Abra o arquivo de configuração principal (o caminho exato depende da versão instalada, ex: `14` ou `15`):
|
||||
```bash
|
||||
sudo nano /etc/postgresql/15/main/postgresql.conf
|
||||
```
|
||||
Procure pela linha `listen_addresses` e altere para:
|
||||
```ini
|
||||
listen_addresses = 'localhost,10.99.0.3'
|
||||
```
|
||||
*(Isso instrui o PostgreSQL a aceitar conexões locais e conexões vindas apenas da interface segura do WireGuard, mantendo a porta fechada para a internet pública).*
|
||||
|
||||
### 2. Autorizar o IP da VPS 1 no controle de acesso do PostgreSQL
|
||||
Abra o arquivo de controle de conexões do cliente:
|
||||
```bash
|
||||
sudo nano /etc/postgresql/15/main/pg_hba.conf
|
||||
```
|
||||
Adicione a seguinte linha de liberação ao final do arquivo:
|
||||
```text
|
||||
# Liberar conexões seguras da VPS 1 (Produção) para o NewWhats
|
||||
host newwhats newwhats 10.99.0.1/32 scram-sha-256
|
||||
```
|
||||
*(Substitua `scram-sha-256` por `md5` se o seu banco estiver configurado com criptografia legado).*
|
||||
|
||||
### 3. Reiniciar o serviço do PostgreSQL
|
||||
Aplique as novas configurações recarregando o serviço:
|
||||
```bash
|
||||
sudo systemctl restart postgresql
|
||||
```
|
||||
|
||||
### 4. Validar as regras de Firewall (nftables) na VPS 3
|
||||
Se a VPS 3 usar `nftables` (como indicado no layout "V8 Bunker"), verifique se as regras atuais permitem o tráfego de entrada na porta `5432` vindo de `10.99.0.1`.
|
||||
Para listar e validar as regras atuais na VPS 3:
|
||||
```bash
|
||||
sudo nft list ruleset
|
||||
```
|
||||
Certifique-se de que há uma regra de aceitação similar a esta na tabela de entrada da VPN:
|
||||
```text
|
||||
ip saddr 10.99.0.1 tcp dport 5432 accept
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ Próximos Passos
|
||||
|
||||
1. **Liberar PostgreSQL na VPS 3**: Execute os passos acima na VPS 3 para abrir o canal seguro de conexão.
|
||||
2. **Atualização Automática**: Assim que a porta `5432` estiver liberada, o container `clube67-newwhats-backend-1` (que está configurado para tentar se reconectar automaticamente) se conectará instantaneamente, executará as rotinas de inicialização e o painel em `clube67.com` estará 100% no ar!
|
||||
@@ -0,0 +1,51 @@
|
||||
# 📋 LISTA DE TAREFAS — DEPLOY NEWWHATS (FRENTE A: WASABI STORAGE)
|
||||
|
||||
Este documento registra o escopo de implementação física, build, deploy e validação das correções da **Frente A (Wasabi Storage)** realizadas no ambiente de produção da VPS 1.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 FRENTE A: Correções Wasabi Storage
|
||||
|
||||
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes |
|
||||
|---|---|---|---|---|
|
||||
| **T1** | Evitar vazamento de mídia local | `MessageHandler.ts` | **CONCLUÍDO** | Adicionado `fs.unlink()` assíncrono para limpar arquivos locais após upload para Wasabi. |
|
||||
| **T2** | Correção de `mediaPath` no DB | `MessageHandler.ts` | **CONCLUÍDO** | Salvamento de `mediaPath = null` para mídias salvas com sucesso em storage remoto, forçando o carregamento direto do remote link. |
|
||||
| **T3** | Evitar duplicação de handlers Express | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Adicionada flag privada `routesRegistered` para evitar múltiplos registros de rotas HTTP no Express em reativações. |
|
||||
| **T4** | Interface para deleção recursiva | `types.ts`, `StorageProvider.ts` | **CONCLUÍDO** | Adicionado o método `deletePrefix` no contrato `StorageProviderInterface` e wrapper no `StorageProviderRegistry`. |
|
||||
| **T5** | Implementação de limpeza Wasabi + Fallback | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Implementado `deletePrefix` usando `ListObjectsV2Command` + `DeleteObjectsCommand` paginados e remoção recursiva local. |
|
||||
| **T6** | Limpeza de mídias ao deletar instância | `whatsapp.routes.ts` | **CONCLUÍDO** | Integrada chamada assíncrona ao `storageProvider.deletePrefix` na rota `DELETE /:id` usando prefixo correto. |
|
||||
| **T7** | Correção de carregamento de plugins (Container) | `Dockerfile`, `docker-compose.yml` | **CONCLUÍDO** | Corrigida a ausência da pasta de plugins no container montando-a como volume e criando symlinks para resolução de imports. |
|
||||
|
||||
---
|
||||
|
||||
## 📈 Resumo do Status
|
||||
|
||||
- **Fase de Planejamento:** 100% Concluído
|
||||
- **Implementação do Código:** 100% Concluído
|
||||
- **Compilação e Transpilação (Plugins):** 100% Concluído
|
||||
- **Build da Imagem e Re-deploy (Docker):** 100% Concluído
|
||||
- **Validação de Logs e Ativação de Plugins:** 100% Concluído (Todos os plugins ativos sem erros!)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Próximos Passos recomendados ao Usuário
|
||||
|
||||
1. **Reconexão de Instâncias:** Reconectar as instâncias no painel administrativo para reativar as conexões de mensagens e testar o envio de mídias direto ao Wasabi.
|
||||
2. **Validação do Bucket:** Confirmar que os novos arquivos estão sendo gerados sob o prefixo correto `whatsapp/{tenantId}/{instanceId}/` dentro do bucket `whatsbasemidia`.
|
||||
3. **Validação da Exclusão:** Excluir uma instância de teste e verificar a remoção automática das mídias correspondentes tanto localmente quanto no bucket Wasabi.
|
||||
|
||||
---
|
||||
*Última atualização: 08 de Maio de 2026 às 02:00 (UTC).*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 FRENTE B: Baileys Engine Swap (Mensagens Ricas no WA Web)
|
||||
|
||||
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes / Critérios de Aceite |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | Instalar fork InfiniteAPI | `package.json` | **CONCLUÍDO** | Instalar `baileys@github:rsalcara/InfiniteAPI` sob alias/coexistência no backend. |
|
||||
| **B2** | Camada de abstração `engine` | `engine/official.ts`, `engine/infinite.ts`, `engine/index.ts` | **CONCLUÍDO** | Criar arquivos de ponte de re-export dinâmico dependentes de `BAILEYS_ENGINE` (`infinite` ou `official`). |
|
||||
| **B3** | Mapeamento de imports | `WhatsAppConnectionManager.ts`, `MessageHandler.ts`, `ContactHandler.ts`, `rich-message.ts`, etc. | **CONCLUÍDO** | Redirecionar todos os imports diretos de `@whiskeysockets/baileys` para a nova camada unificada `./engine`. |
|
||||
| **B4** | Simplificação de envio de mídias ricas | `rich-message.ts` | **CONCLUÍDO** | Refatorar a lógica complexa de binary nodes para invocar os campos nativos do fork InfiniteAPI com fallback inteligente. |
|
||||
| **B5** | Endpoints de Administração & Reinício | `admin.routes.ts`, `whatsapp.routes.ts` | **CONCLUÍDO** | Adicionar rotas `GET /engine` e `POST /engine` para persistir escolha no arquivo .env e auto-reiniciar o processo (PM2/Docker). |
|
||||
| **B6** | Componente de Toggle no Painel Admin | Frontend (Páginas Admin) | **CONCLUÍDO** | Criar interface visual de seleção de engine com aviso de reinicialização (~10s) na aba Sistema de admin/settings.tsx. |
|
||||
@@ -0,0 +1,247 @@
|
||||
# 📐 Arquitetura de Rede e Topologia de Servidores
|
||||
|
||||
Este documento descreve detalhadamente a arquitetura de servidores e rede privada projetada para garantir **alta performance, isolamento de riscos, segurança de dados e facilidade de escalonamento**.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Visão Geral da Topologia (Rede VPN WireGuard)
|
||||
|
||||
Toda a comunicação crítica entre os servidores trafega por dentro de uma rede privada criptografada baseada no protocolo **WireGuard** (`10.99.0.0/24`). Os servidores possuem seus firewalls públicos trancados, permitindo apenas acessos necessários e isolando os dados vitais.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Rede Pública (Internet)"
|
||||
Internet((Internet Pública))
|
||||
Client([Notebook Rui])
|
||||
end
|
||||
|
||||
subgraph "Rede Privada VPN (10.99.0.0/24)"
|
||||
VPS1["🖥️ VPS 1 (Produção) <br> IP: 10.99.0.1 <br> (Traefik / Apps)"]
|
||||
VPS3["🗄️ VPS 3 (Banco de Dados) <br> IP: 10.99.0.3 <br> (PostgreSQL Dedicado)"]
|
||||
VPS4["🧪 VPS 4 (Desenvolvimento) <br> IP: 10.99.0.4 <br> (Staging / Builds)"]
|
||||
VPS5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.99.0.5 <br> (Bots / APIs)"]
|
||||
end
|
||||
|
||||
%% Conexões Públicas
|
||||
Internet == HTTP/HTTPS (80/443) ==> VPS1
|
||||
Client == Conexão VPN Criptografada ==> VPS1
|
||||
|
||||
%% Conexões Privadas VPN
|
||||
VPS1 <== Canal Seguro VPN ==> VPS3
|
||||
VPS1 <== Canal Seguro VPN ==> VPS4
|
||||
VPS1 <== Canal Seguro VPN ==> VPS5
|
||||
Client <== Acesso SSH Seguro ==> VPS1
|
||||
Client <== Acesso SSH Seguro ==> VPS3
|
||||
Client <== Acesso SSH Seguro ==> VPS4
|
||||
Client <== Acesso SSH Seguro ==> VPS5
|
||||
|
||||
%% Conexões de Banco de Dados Internas
|
||||
VPS1 -- PostgreSQL (Porta 5432) --> VPS3
|
||||
VPS4 -- PostgreSQL (Porta 5432) --> VPS3
|
||||
VPS5 -- PostgreSQL (Porta 5432) --> VPS3
|
||||
|
||||
style VPS3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff
|
||||
style VPS1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
|
||||
style VPS4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
|
||||
style VPS5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Especificação Detalhada dos Servidores
|
||||
|
||||
### 1. 🗄️ VPS 3: Servidor de Banco de Dados Dedicado (Appliance)
|
||||
* **Função Principal**: Hospedar exclusivamente o banco de dados **PostgreSQL Bare-Metal (Nativo)** e serviços fundamentais de persistência.
|
||||
* **IP Privado VPN**: `10.99.0.3`
|
||||
* **Tecnologias**: PostgreSQL (Instalação Nativa via Host), WireGuard, nftables. (O Docker é **estritamente omitido** para reduzir superfície de ataque e otimizar I/O).
|
||||
* **Políticas do Firewall (nftables)**:
|
||||
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Liberada **apenas** para o IP reservado do administrador via VPN (`10.99.0.10`).
|
||||
* **Porta 5432 (PostgreSQL)**: **Bloqueada publicamente**. Liberada **apenas** para conexões originadas de IPs autorizados na VPN (`10.99.0.1` e `10.99.0.5`).
|
||||
* **Vantagens de Segurança & Performance**:
|
||||
* **Zero vazamento de dados**: O banco não pode ser acessado de fora da VPN sob hipótese alguma.
|
||||
* **Conceito de Appliance**: Sem Docker daemon (sem socket de root adicional) ou compiladores, a superfície de invasão é reduzida ao menor nível matemático possível.
|
||||
* **Performance Máxima (Tuning de Host)**: Como a máquina é nativa e dedicada, as configurações de cache e buffers de memória do PostgreSQL podem usufruir livremente do Page Cache nativo do Linux e Huge Pages do Kernel, garantindo o máximo rendimento físico do SSD e RAM.
|
||||
|
||||
---
|
||||
|
||||
### 🚀 2. VPS 1: Servidor de Sistemas em Produção
|
||||
* **Função Principal**: Gateway de entrada do tráfego público e hospedagem das aplicações em produção.
|
||||
* **IP Privado VPN**: `10.99.0.1` (Atua como Hub WireGuard)
|
||||
* **Políticas do Firewall (UFW)**:
|
||||
* **Portas 80 (HTTP) e 443 (HTTPS)**: **Abertas ao público** para permitir o acesso dos clientes aos sites e sistemas.
|
||||
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
|
||||
* **Porta 3002 (Antigo Dev)**: **Bloqueada publicamente** no Docker (atrelada apenas ao IP privado `10.99.0.1` do container).
|
||||
* **Vantagens de Segurança & Performance**:
|
||||
* **Serviços sem Estado (Stateless)**: A VPS 1 apenas processa as requisições web e consome os dados da VPS 3. Caso a VPS 1 sofra um pico extremo de acessos, o banco de dados continua intacto e seguro.
|
||||
* **Centralização com Traefik**: Proxy reverso centralizado gerenciando SSL e direcionamento interno seguro de containers.
|
||||
|
||||
---
|
||||
|
||||
### 🧪 3. VPS 4: Servidor de Desenvolvimento e Homologação (Staging)
|
||||
* **Função Principal**: Testar novos recursos, hospedar containers de staging e realizar builds de desenvolvimento.
|
||||
* **IP Privado VPN**: `10.99.0.4`
|
||||
* **Políticas do Firewall (UFW)**:
|
||||
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
|
||||
* **Portas de teste (ex: 3002, 8080)**: **Bloqueadas publicamente** ou atreladas apenas ao IP privado da VPN para acesso exclusivo dos desenvolvedores autorizados.
|
||||
* **Tecnologias**: Docker, Docker Compose, PostgreSQL 18 (Standby Replica em Container).
|
||||
* **Vantagens de Segurança & Performance**:
|
||||
* **Replicação Streaming Real-Time (Staging)**: Hospeda um container PostgreSQL 18 configurado como réplica de leitura (Standby) do banco de dados nativo de produção da VPS 3. Isso permite testar queries pesadas e validar migrações com dados reais em tempo de desenvolvimento de forma 100% segura e com zero impacto no banco principal.
|
||||
* **Isolamento de Riscos**: Compilações de código (`npm run build`, `docker build`) e testes pesados de carga rodam aqui e consomem CPU sem comprometer os servidores de produção e banco de dados.
|
||||
* **Ambiente Espelho**: Homologação idêntica à produção para validações precisas antes de aplicar modificações em produção.
|
||||
|
||||
---
|
||||
|
||||
### 💬 4. VPS 5: Servidor de Aplicação Proprietária (newwhats)
|
||||
* **Função Principal**: Hospedar o backend e workers da plataforma proprietária **newwhats** (Backend Express com Socket.IO, Workers do Temporal e broker NATS).
|
||||
* **IP Privado VPN**: `10.99.0.5`
|
||||
* **Políticas do Firewall (UFW)**:
|
||||
* **Portas de Integração Web**: Liberadas apenas para os Webhooks e requisições públicas de Socket.IO mapeadas via gateway.
|
||||
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
|
||||
* **Vantagens de Segurança & Performance**:
|
||||
* **Isolamento de Estado**: Executar o backend Node.js (Prisma/Knex), os workers de workflows do Temporal e o broker de mensagens NATS na VPS 5 impede que oscilações no tráfego de mensagens do WhatsApp ou alto uso de CPU nos workers interfiram com o banco de dados principal de produção (VPS 3).
|
||||
* **Separação de CPU**: O processamento pesado das sessões de WhatsApp e emulação de instâncias roda na VPS 5, consumindo sua CPU e RAM de forma isolada do Gateway (VPS 1) e do Banco (VPS 3).
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Diretrizes de Segurança Corporativa (Resumo)
|
||||
|
||||
1. **Acesso Administrativo**: Realizado **exclusivamente via chave SSH criptográfica**. Autenticação por senhas desativada em todas as máquinas.
|
||||
2. **Login de Root Desativado**: Bloqueado login direto de root via SSH. Todos conectam como o usuário `deploy` e elevam privilégios de forma segura via `sudo` após a conexão.
|
||||
3. **Ponto Único de Falha**: Se a VPS 1 (Hub WireGuard) cair, a comunicação VPN é temporariamente pausada. O console VNC da Contabo é mantido ativo como canal redundante de suporte imediato.
|
||||
4. **Logs Automatizados**: Logins SSH bem-sucedidos ou suspeitos geram alertas em segundo plano com marcações de 30 minutos na agenda central do Google, permitindo controle visual de acessos em tempo real.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Fase 2: Hardening Avançado e Arquitetura Zero-Trust (Roadmap)
|
||||
|
||||
Esta seção documenta as decisões estratégicas e o plano de ação de segurança de curto e médio prazo projetados para elevar a maturidade da infraestrutura de rede e servidores ao nível **Enterprise / DevSecOps Sênior**.
|
||||
|
||||
### 🗺️ Visão Geral do Fluxo de Segurança na Borda
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Camadas de Defesa (Defense in Depth)"
|
||||
Internet((Internet Pública)) --> PM[1. Proteção de Borda do Provedor]
|
||||
PM --> NFT[2. nftables + CrowdSec <br> Bloqueio Dinâmico / Comportamental]
|
||||
NFT --> TF[3. Traefik Reverse Proxy <br> SSL/TLS & Roteamento]
|
||||
TF --> DK[4. Containers Docker <br> Rootless / Hardened]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1. 🌐 Desativação Proativa de IPv6 (Curto/Médio Prazo)
|
||||
Para eliminar vetores de bypass acidentais de firewall e reduzir a complexidade da superfície de ataque, o suporte a IPv6 é **totalmente desativado** em todas as VPSs.
|
||||
|
||||
* **Ações de Configuração**:
|
||||
Para desativar em tempo de execução e garantir persistência pós-reboot, o arquivo `/etc/sysctl.d/99-disable-ipv6.conf` deve ser criado com as seguintes diretivas:
|
||||
```text
|
||||
net.ipv6.conf.all.disable_ipv6 = 1
|
||||
net.ipv6.conf.default.disable_ipv6 = 1
|
||||
net.ipv6.conf.lo.disable_ipv6 = 1
|
||||
```
|
||||
*Comando para aplicar imediatamente:* `sudo sysctl --system`
|
||||
|
||||
* **Gatilhos para Reativação do IPv6**:
|
||||
O IPv6 só será reabilitado caso surja uma necessidade real de negócio, tais como:
|
||||
* Requisitos de conformidade corporativa (*Enterprise Compliance*).
|
||||
* Integração obrigatória com CDNs IPv6-only ou arquiteturas de borda distribuída (*Anycast*).
|
||||
* Casos em que a reativação exigirá a implementação completa de **nftables dual-stack, filtros de Router Advertisement (RA), NDP e SLAAC seguros**.
|
||||
|
||||
---
|
||||
|
||||
### 2. 🐋 Isolamento de Redes Docker (Prevenção de Movimentação Lateral)
|
||||
O uso da rede de ponte padrão (`bridge`) do Docker é estritamente proibido em produção. Os containers são segmentados em **redes de domínios funcionais isolados**, limitando severamente o raio de colisão (*blast radius*) caso um container de aplicação (especialmente bots de CRM/WhatsApp ou navegadores invisíveis) seja comprometido.
|
||||
|
||||
#### 🔀 Matriz de Conectividade de Dados (newwhats):
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "VPS 1: Gateway de Borda pública"
|
||||
Traefik[Traefik Router]
|
||||
end
|
||||
|
||||
subgraph "VPS 5: Camada de Aplicação (newwhats)"
|
||||
Express[Backend Express & Socket.IO]
|
||||
TempWorker[Temporal Worker]
|
||||
NATS[NATS Broker :4222]
|
||||
|
||||
Express <--> NATS
|
||||
TempWorker <--> Express
|
||||
end
|
||||
|
||||
subgraph "Túnel VPN Criptografado (wg0)"
|
||||
WG0((Canal Privado WireGuard))
|
||||
end
|
||||
|
||||
subgraph "VPS 3: Appliance de Dados Bare-Metal"
|
||||
Postgres[(PostgreSQL :5432)]
|
||||
Dragonfly[(DragonflyDB :6379)]
|
||||
Temporal[(Temporal Server :7233)]
|
||||
end
|
||||
|
||||
%% Roteamento Web e Sockets
|
||||
Traefik == HTTPS Proxy / Sockets ==> WG0 ==> Express
|
||||
|
||||
%% Conexões de Dados da Aplicação via VPN
|
||||
Express ==> WG0 ==> Postgres
|
||||
Express ==> WG0 ==> Dragonfly
|
||||
TempWorker ==> WG0 ==> Temporal
|
||||
Temporal -. Persistência Interna .-> Postgres
|
||||
|
||||
style Postgres fill:#1E293B,stroke:#3B82F6,stroke-width:2px,color:#fff
|
||||
style Dragonfly fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
|
||||
style Temporal fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
|
||||
```
|
||||
|
||||
* **Diretrizes de Conectividade**:
|
||||
* **Tráfego de Borda**: O Traefik na VPS 1 encaminha o tráfego HTTP/HTTPS e conexões persistentes do Socket.IO diretamente para a VPS 5 (`10.99.0.5`) pela interface de túnel criptografado, mantendo as portas da VPS 5 públicas totalmente fechadas.
|
||||
* **Persistência de Negócio (Prisma + Knex)**: Ambas as ORMs/Query Builders centralizadas na VPS 5 apontam diretamente para o IP interno `10.99.0.3:5432` da VPS 3.
|
||||
* **Cache e Estados Rápidos**: O DragonflyDB na VPS 3 atende instantaneamente às solicitações de sessões e QR codes da VPS 5 via `10.99.0.3:6379`, provendo tempos de resposta na faixa de microssegundos sem concorrência local de CPU.
|
||||
* **Workflows Assíncronos**: O Temporal Server processa o agendamento de tarefas e status de reputação na VPS 3, enquanto o **Temporal Worker** na VPS 5 consome e executa as atividades pesadas locais da aplicação.
|
||||
|
||||
---
|
||||
|
||||
### 3. 🔑 Zero-Trust Interno no WireGuard (ACLs no Firewall)
|
||||
A rede VPN WireGuard (`10.99.0.0/24`) deixa de ser tratada como uma zona de confiança irrestrita. Implementa-se uma política de privilégio mínimo na comunicação entre os servidores da VPN.
|
||||
|
||||
* **O Problema de Movimentação Lateral**:
|
||||
Se a máquina de desenvolvimento/staging (VPS 4) for comprometida por um script malicioso, o atacante não pode ter permissão para escaneá-la ou conectar-se ao banco de dados de produção (VPS 3), exceto para o fluxo estritamente necessário de replicação de dados.
|
||||
|
||||
* **Regras de Isolamento Lateral (Exemplo no Firewall da VPS 3)**:
|
||||
```text
|
||||
# Bloquear por padrão toda conexão vinda da VPN exceto as estritamente autorizadas:
|
||||
- Permitir VPS 1 (Produção) -> Porta 5432 (PostgreSQL) [ACEITAR]
|
||||
- Permitir VPS 5 (CRM) -> Porta 5432 (PostgreSQL) [ACEITAR]
|
||||
- Permitir VPS 4 (Staging) -> Porta 5432 (PostgreSQL - Apenas 'replicator') [ACEITAR]
|
||||
- Permitir VPS 4 (Staging) -> VPS 3 (Produção - Outras portas / serviços) [BLOQUEAR]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 🎛️ Migração de Borda: nftables + CrowdSec
|
||||
Substitui-se a pilha clássica e reativa do `iptables` / `Fail2Ban` por uma arquitetura ativa, dinâmica e de alta performance na VPS pública 1.
|
||||
|
||||
* **Por que `nftables`?**
|
||||
A sintaxe nativa do `nftables` permite a criação de conjuntos dinâmicos (*sets*) de IP de forma extremamente eficiente, processados diretamente no kernel com consumo mínimo de CPU durante ataques volumétricos.
|
||||
* **Por que `CrowdSec`?**
|
||||
Diferente do Fail2Ban (que analisa logs localmente e de forma reativa por expressões regulares), o CrowdSec utiliza detecção baseada em comportamentos locais de ataque e sincroniza uma base global de reputação de IPs (*inteligência coletiva*). Se um IP atacou outra infraestrutura na internet, ele é bloqueado na sua VPS antes mesmo de fazer a primeira requisição ao seu Traefik.
|
||||
|
||||
---
|
||||
|
||||
### 5. 💔 Mitigação do Ponto Único de Falha (SPOF) da VPS 1
|
||||
Atualmente, a VPS 1 acumula as funções de **Gateway de Borda (Traefik)**, **Hub Central de VPN (WireGuard)** e **Hospedagem de Aplicações**.
|
||||
|
||||
* **Visão de Evolução Futura**:
|
||||
Para eliminar o risco de interrupção total dos serviços em caso de queda da VPS 1, planeja-se a separação física de responsabilidades em médio prazo:
|
||||
1. **VPS Edge Dedicated**: Uma máquina leve rodando exclusivamente o gateway de entrada (`Traefik`), `nftables` e o concentrador `WireGuard`.
|
||||
2. **VPS App Dedicated**: Servidores internos que hospedam as aplicações (sem expor portas SSH ou HTTP à internet), consumindo tráfego encaminhado diretamente pelo Gateway da Edge.
|
||||
|
||||
---
|
||||
|
||||
### 6. 🔒 Hardening Avançado de Containers
|
||||
Para garantir que a invasão de um container não resulte no comprometimento do sistema operacional host, aplicam-se as seguintes diretrizes de segurança no provisionamento de containers Docker:
|
||||
|
||||
* **No-New-Privileges**: Impede que processos dentro do container ganhem novos privilégios via binários `setuid` ou `setgid`.
|
||||
* **Read-Only Root Filesystem**: Monta o sistema de arquivos do container como apenas leitura, forçando gravações temporárias apenas em volumes declarados ou `/tmp` na memória (tmpfs).
|
||||
* **CAP Drop**: Remove capacidades de sistema desnecessárias ao container (ex: `NET_ADMIN`, `SYS_ADMIN`, `SYS_CHROOT`).
|
||||
* **Rootless Containers**: Sempre que suportado pela aplicação, os containers devem rodar com IDs de usuário não privilegiados (non-root).
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
sessions
|
||||
media
|
||||
storage
|
||||
uploads
|
||||
@@ -0,0 +1,46 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Server
|
||||
# ─────────────────────────────────────────────
|
||||
PORT=8008
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=troque_por_secret_forte_aqui
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PostgreSQL (Prisma)
|
||||
# ─────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/newwhats?schema=public"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DragonflyDB (compatível com Redis)
|
||||
# ─────────────────────────────────────────────
|
||||
DRAGONFLY_URL=redis://localhost:6379
|
||||
DRAGONFLY_TTL_SECONDS=86400
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# NATS JetStream
|
||||
# ─────────────────────────────────────────────
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Temporal.io
|
||||
# ─────────────────────────────────────────────
|
||||
TEMPORAL_ADDRESS=localhost:7233
|
||||
TEMPORAL_NAMESPACE=default
|
||||
TEMPORAL_TASK_QUEUE=newwhats-queue
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# IA (OpenAI + Google Gemini)
|
||||
# ─────────────────────────────────────────────
|
||||
OPENAI_API_KEY=sk-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# WhatsApp / Baileys
|
||||
# ─────────────────────────────────────────────
|
||||
# Pasta raiz onde as sessões multi-device são armazenadas (por instância)
|
||||
BAILEYS_SESSIONS_PATH=./sessions
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Frontend (CORS)
|
||||
# ─────────────────────────────────────────────
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json baileys-7.0.0-rc.9.tgz ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY package*.json baileys-7.0.0-rc.9.tgz ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
RUN ln -s /app/plugins /plugins && \
|
||||
ln -s /app /app/backend && \
|
||||
ln -s /app/dist /app/src
|
||||
|
||||
EXPOSE 8008
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/server.js"]
|
||||
Binary file not shown.
+9610
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "newwhats-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "SaaS Omnichannel Backend — NewWhats",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json || true",
|
||||
"start": "node dist/server.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1029.0",
|
||||
"@aws-sdk/lib-storage": "^3.1029.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@temporalio/activity": "^1.15.0",
|
||||
"@temporalio/client": "^1.15.0",
|
||||
"@temporalio/worker": "^1.15.0",
|
||||
"@temporalio/workflow": "^1.15.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@whiskeysockets/baileys": "^6.7.18",
|
||||
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"croner": "^10.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jimp": "^1.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knex": "^3.2.9",
|
||||
"multer": "^2.1.1",
|
||||
"nats": "^2.28.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"openai": "^6.34.0",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"recharts": "^3.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"systeminformation": "^5.31.5",
|
||||
"tsx": "^4.19.2",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,560 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Prisma Schema — NewWhats SaaS Omnichannel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── PLANOS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
maxInstances Int @default(1)
|
||||
maxAgents Int @default(5)
|
||||
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
}
|
||||
|
||||
// ─── USUÁRIOS (ADMIN + TENANTS) ───────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
planId String?
|
||||
trialEndsAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
plan Plan? @relation(fields: [planId], references: [id])
|
||||
instances Instance[]
|
||||
sectors Sector[]
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
aiCredentials AICredential[]
|
||||
aiBots AIBot[]
|
||||
broadcasts Broadcast[]
|
||||
pluginPairs PluginPair[]
|
||||
extWebhooks ExtWebhook[]
|
||||
messageTemplates MessageTemplate[]
|
||||
scheduledMessages ScheduledMessage[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
// ─── API KEYS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
key String @unique @default(uuid())
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
// ─── INSTÂNCIAS WHATSAPP ──────────────────────────────────────────────────────
|
||||
|
||||
model Instance {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
phone String?
|
||||
avatar String?
|
||||
status InstanceStatus @default(DISCONNECTED)
|
||||
engine String @default("infinite")
|
||||
sessionPath String?
|
||||
webhookUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
contacts Contact[]
|
||||
chats Chat[]
|
||||
messages Message[]
|
||||
broadcasts Broadcast[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("instances")
|
||||
}
|
||||
|
||||
enum InstanceStatus {
|
||||
DISCONNECTED
|
||||
CONNECTING
|
||||
QR_PENDING
|
||||
CONNECTED
|
||||
BANNED
|
||||
}
|
||||
|
||||
// ─── SETORES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Sector {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
teams Team[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("sectors")
|
||||
}
|
||||
|
||||
// ─── EQUIPES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
sectorId String
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade)
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, sectorId, name])
|
||||
@@map("teams")
|
||||
}
|
||||
|
||||
// ─── CONTATOS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Contact {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
jid String
|
||||
lidJid String?
|
||||
// Prioridade: name (agenda) > verifiedName > notify (pushname)
|
||||
name String?
|
||||
verifiedName String?
|
||||
notify String?
|
||||
phone String?
|
||||
avatarUrl String?
|
||||
scoreReputacao Int @default(100)
|
||||
flagRestricao Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
chats Chat[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, instanceId, jid])
|
||||
@@index([tenantId, instanceId, lidJid])
|
||||
@@map("contacts")
|
||||
}
|
||||
|
||||
// ─── CHATS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Chat {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
contactId String?
|
||||
jid String
|
||||
name String? // Subject para grupos (@g.us); null para individuais
|
||||
unreadCount Int @default(0)
|
||||
lastMessageAt DateTime?
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
// IA Chatbot
|
||||
botPaused Boolean @default(false) // human takeover — pausa o bot neste chat
|
||||
botSummary String? // Cérebro: resumo de 2 frases da conversa
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id])
|
||||
messages Message[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, instanceId, jid])
|
||||
// Índice composto para a query principal da inbox:
|
||||
// WHERE tenantId + instanceId + isArchived (filtro)
|
||||
// ORDER BY isPinned DESC, lastMessageAt DESC (ordenação)
|
||||
@@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)])
|
||||
@@map("chats")
|
||||
}
|
||||
|
||||
// ─── MENSAGENS ────────────────────────────────────────────────────────────────
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
chatId String
|
||||
remoteJid String
|
||||
messageId String
|
||||
fromMe Boolean @default(false)
|
||||
type MessageType @default(TEXT)
|
||||
body String?
|
||||
caption String?
|
||||
mediaUrl String?
|
||||
mediaPath String?
|
||||
mimeType String?
|
||||
fileName String?
|
||||
duration Int?
|
||||
pushName String? // Nome do remetente no momento do envio (grupos: quem enviou)
|
||||
senderJid String? // JID do remetente real (grupos: key.participant)
|
||||
replyToId String? // ID da mensagem citada (reply)
|
||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||
status MessageStatus @default(PENDING)
|
||||
timestamp DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
|
||||
replies Message[] @relation("MessageReply")
|
||||
|
||||
@@unique([instanceId, messageId])
|
||||
// Índice para "última mensagem por chat" — usado no include da inbox
|
||||
// ORDER BY timestamp DESC LIMIT 1 (por chatId)
|
||||
@@index([chatId, timestamp(sort: Desc)])
|
||||
// Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC
|
||||
// WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL
|
||||
@@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)])
|
||||
// Índice para obter a última mensagem da instância — usado na verificação de gap de sincronização
|
||||
@@index([instanceId, timestamp(sort: Desc)])
|
||||
@@map("messages")
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
TEXT
|
||||
IMAGE
|
||||
VIDEO
|
||||
AUDIO
|
||||
DOCUMENT
|
||||
STICKER
|
||||
LOCATION
|
||||
CONTACT
|
||||
REACTION
|
||||
POLL
|
||||
BUTTONS
|
||||
LIST
|
||||
CAROUSEL
|
||||
INTERACTIVE
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
PENDING
|
||||
SENT
|
||||
DELIVERED
|
||||
READ
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ─── PROTOCOLOS DE ATENDIMENTO ────────────────────────────────────────────────
|
||||
|
||||
model Protocol {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
number String @unique // Número sequencial rastreável: #0001-2024
|
||||
chatId String
|
||||
contactId String
|
||||
sectorId String?
|
||||
teamId String?
|
||||
agentId String?
|
||||
status ProtocolStatus @default(OPEN)
|
||||
summary String? // Resumo de 2 frases gerado pelo Temporal
|
||||
deadline DateTime?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id])
|
||||
contact Contact @relation(fields: [contactId], references: [id])
|
||||
sector Sector? @relation(fields: [sectorId], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
// Índice para filtro de protocolos abertos por chat (usado no include da inbox)
|
||||
// WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC
|
||||
@@index([chatId, status, createdAt(sort: Desc)])
|
||||
@@map("protocols")
|
||||
}
|
||||
|
||||
enum ProtocolStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
WAITING_CLIENT
|
||||
WAITING_AGENT
|
||||
RESOLVED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// ─── IA MULTI-AGENTE ──────────────────────────────────────────────────────────
|
||||
|
||||
model AICredential {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
name String
|
||||
provider LLMProvider @default(GEMINI)
|
||||
apiKey String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
bots AIBot[]
|
||||
|
||||
@@unique([tenantId, instanceId, name])
|
||||
@@map("ai_credentials")
|
||||
}
|
||||
|
||||
model AIBot {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
credentialId String
|
||||
name String
|
||||
systemPrompt String @db.Text
|
||||
model String @default("gemini-1.5-flash")
|
||||
enabled Boolean @default(false)
|
||||
triggerMode TriggerMode @default(ALL)
|
||||
keywords String[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
credential AICredential @relation(fields: [credentialId], references: [id])
|
||||
|
||||
@@unique([tenantId, instanceId])
|
||||
@@map("ai_bots")
|
||||
}
|
||||
|
||||
enum LLMProvider {
|
||||
GEMINI
|
||||
OPENAI
|
||||
}
|
||||
|
||||
enum TriggerMode {
|
||||
ALL
|
||||
KEYWORD
|
||||
}
|
||||
|
||||
// ─── BROADCAST (DISPARO EM MASSA) ─────────────────────────────────────────────
|
||||
|
||||
model Broadcast {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
message String @db.Text
|
||||
totalCount Int @default(0)
|
||||
sentCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
status String @default("RUNNING") // RUNNING | DONE | FAILED
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
recipients BroadcastRecipient[]
|
||||
|
||||
@@map("broadcasts")
|
||||
}
|
||||
|
||||
// ─── PLUGIN PAIRS ─────────────────────────────────────────────────────────────
|
||||
// Registra sistemas externos pareados com uma conta NewWhats.
|
||||
// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*.
|
||||
|
||||
model PluginPair {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
clientSystem String // identificador técnico ex: "alemao-conveniencias"
|
||||
clientName String // nome amigável ex: "Alemão Conveniências"
|
||||
clientUrl String? // URL base do sistema cliente
|
||||
integrationKey String @unique
|
||||
lastSeenAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("plugin_pairs")
|
||||
}
|
||||
|
||||
// ─── EXT WEBHOOKS ─────────────────────────────────────────────────────────────
|
||||
// Endpoints externos registrados por tenant para receber push de eventos.
|
||||
|
||||
model ExtWebhook {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
url String
|
||||
events String[]
|
||||
secret String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ext_webhooks")
|
||||
}
|
||||
|
||||
model BroadcastRecipient {
|
||||
id String @id @default(uuid())
|
||||
broadcastId String
|
||||
jid String
|
||||
status String @default("PENDING") // PENDING | SENT | FAILED
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("broadcast_recipients")
|
||||
}
|
||||
|
||||
// ─── TEMPLATES DE MENSAGEM ────────────────────────────────────────────────────
|
||||
|
||||
model MessageTemplate {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
description String?
|
||||
type TemplateType @default(TEXT)
|
||||
payload Json
|
||||
tags String[]
|
||||
usageCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
schedules ScheduledMessage[]
|
||||
|
||||
@@map("message_templates")
|
||||
}
|
||||
|
||||
enum TemplateType {
|
||||
TEXT
|
||||
BUTTONS
|
||||
INTERACTIVE
|
||||
LIST
|
||||
POLL
|
||||
CAROUSEL
|
||||
}
|
||||
|
||||
// ─── AGENDAMENTOS DE MENSAGEM ─────────────────────────────────────────────────
|
||||
|
||||
model ScheduledMessage {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
name String
|
||||
templateId String?
|
||||
payload Json
|
||||
recipientMode RecipientMode @default(MANUAL)
|
||||
recipients Json
|
||||
scheduleType ScheduleType @default(ONCE)
|
||||
cronExpr String?
|
||||
scheduledAt DateTime?
|
||||
eventType EventType?
|
||||
timezone String @default("America/Sao_Paulo")
|
||||
status ScheduleStatus @default(ACTIVE)
|
||||
lastRunAt DateTime?
|
||||
nextRunAt DateTime?
|
||||
runCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
template MessageTemplate? @relation(fields: [templateId], references: [id])
|
||||
logs ScheduledMessageLog[]
|
||||
|
||||
@@map("scheduled_messages")
|
||||
}
|
||||
|
||||
model ScheduledMessageLog {
|
||||
id String @id @default(uuid())
|
||||
scheduleId String
|
||||
status String
|
||||
sentCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
error String?
|
||||
runAt DateTime @default(now())
|
||||
|
||||
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("scheduled_message_logs")
|
||||
}
|
||||
|
||||
enum RecipientMode {
|
||||
MANUAL
|
||||
ALL_CONTACTS
|
||||
TAG_FILTER
|
||||
}
|
||||
|
||||
enum ScheduleType {
|
||||
ONCE
|
||||
RECURRING
|
||||
EVENT
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
BIRTHDAY
|
||||
ANNIVERSARY
|
||||
SIGNUP_DAYS
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
|
||||
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
|
||||
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
|
||||
|
||||
model Sticker {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
sha256 String // fileSha256 hex — identidade permanente do conteúdo
|
||||
wasabiPath String // stickers/{tenantId}/{sha256}.webp
|
||||
isAnimated Boolean @default(false)
|
||||
isFavorite Boolean @default(false)
|
||||
useCount Int @default(1)
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, sha256])
|
||||
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
|
||||
@@map("stickers")
|
||||
}
|
||||
|
||||
// Webhook allowed host fix
|
||||
// Webhook allowed host fix
|
||||
// Webhook query string fix
|
||||
@@ -0,0 +1,45 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const ROUNDS = 10
|
||||
|
||||
const users = [
|
||||
{
|
||||
name: 'Usuário Padrão',
|
||||
email: 'user@user.com',
|
||||
password: '12345678',
|
||||
role: 'USER' as const,
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
email: 'ruibto@gmail.com',
|
||||
password: 'Rc362514',
|
||||
role: 'ADMIN' as const,
|
||||
},
|
||||
]
|
||||
|
||||
for (const u of users) {
|
||||
const passwordHash = await bcrypt.hash(u.password, ROUNDS)
|
||||
const existing = await prisma.user.findUnique({ where: { email: u.email } })
|
||||
|
||||
if (existing) {
|
||||
await prisma.user.update({
|
||||
where: { email: u.email },
|
||||
data: { passwordHash, name: u.name, role: u.role },
|
||||
})
|
||||
console.log(`✅ Atualizado: ${u.email}`)
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: { name: u.name, email: u.email, passwordHash, role: u.role },
|
||||
})
|
||||
console.log(`✅ Criado: ${u.email} (${u.role})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1) })
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Script standalone para testar a conexão Baileys e exibir o QR Code no terminal.
|
||||
* Não requer banco de dados nem DragonflyDB.
|
||||
* Uso: node qr-test.mjs
|
||||
*/
|
||||
|
||||
import {
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
} from '@whiskeysockets/baileys'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import qrcode from 'qrcode-terminal'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const SESSION_DIR = path.join(__dirname, 'sessions', 'qr-test')
|
||||
|
||||
async function start() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR)
|
||||
const { version, isLatest } = await fetchLatestBaileysVersion()
|
||||
|
||||
console.log(`\n📱 Baileys v${version} (latest: ${isLatest})`)
|
||||
console.log('🔄 Iniciando conexão...\n')
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
printQRInTerminal: false, // controlamos manualmente
|
||||
qrTimeout: 60_000,
|
||||
browser: ['NewWhats', 'Chrome', '124.0.0'],
|
||||
syncFullHistory: false,
|
||||
})
|
||||
|
||||
sock.ev.on('creds.update', saveCreds)
|
||||
|
||||
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
|
||||
if (qr) {
|
||||
console.clear()
|
||||
console.log('📲 Escaneie o QR Code abaixo com o WhatsApp:\n')
|
||||
qrcode.generate(qr, { small: true })
|
||||
console.log('\n⏳ Aguardando escaneamento...')
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
const me = sock.user
|
||||
console.log('\n✅ Conectado com sucesso!')
|
||||
console.log(` Número: ${me?.id}`)
|
||||
console.log(` Nome: ${me?.name}`)
|
||||
console.log('\n🎉 Sessão salva em:', SESSION_DIR)
|
||||
console.log(' Agora rode o servidor completo com: npm run dev\n')
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const code = (lastDisconnect?.error)?.output?.statusCode
|
||||
const reason = DisconnectReason
|
||||
|
||||
console.log(`\n❌ Conexão encerrada (código: ${code})`)
|
||||
|
||||
const shouldReconnect =
|
||||
code !== reason.loggedOut &&
|
||||
code !== reason.forbidden
|
||||
|
||||
if (shouldReconnect) {
|
||||
console.log('🔄 Reconectando...\n')
|
||||
setTimeout(start, 3000)
|
||||
} else {
|
||||
console.log('🚫 Sessão encerrada definitivamente.')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Erro fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = exports.config = void 0;
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
var env_1 = require("./config/env");
|
||||
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
|
||||
var env_2 = require("./config/env");
|
||||
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
|
||||
//# sourceMappingURL=config.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":";;;AAAA,mEAAmE;AACnE,oCAA4C;AAAnC,6FAAA,GAAG,OAAU;AACtB,oCAAkC;AAAzB,0FAAA,GAAG,OAAA"}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
export { env as config } from './config/env'
|
||||
export { env } from './config/env'
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = void 0;
|
||||
const zod_1 = require("zod");
|
||||
require("dotenv/config");
|
||||
const envSchema = zod_1.z.object({
|
||||
PORT: zod_1.z.string().default('8008'),
|
||||
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: zod_1.z.string().min(16),
|
||||
DATABASE_URL: zod_1.z.string().url(),
|
||||
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
|
||||
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
|
||||
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
|
||||
});
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:');
|
||||
console.error(parsed.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
exports.env = parsed.data;
|
||||
//# sourceMappingURL=env.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"env.js","sourceRoot":"","sources":["env.ts"],"names":[],"mappings":";;;AAAA,6BAAuB;AACvB,yBAAsB;AAEtB,MAAM,SAAS,GAAG,OAAC,CAAC,MAAM,CAAC;IACzB,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;IAChC,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;IAC9E,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9B,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC9B,aAAa,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC;IAC3D,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAClD,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACrD,gBAAgB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACtD,kBAAkB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,mBAAmB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACzD,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IACvD,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;CAC1D,CAAC,CAAA;AAEF,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;AAE/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAA;IACnD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAEY,QAAA,GAAG,GAAG,MAAM,CAAC,IAAI,CAAA"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
import 'dotenv/config'
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('8008'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DRAGONFLY_URL: z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: z.string().default('86400'),
|
||||
NATS_URL: z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
|
||||
FRONTEND_URL: z.string().default('http://localhost:3000'),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:')
|
||||
console.error(parsed.error.format())
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logger = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
const env_1 = require("./env");
|
||||
exports.logger = (0, pino_1.default)({
|
||||
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env_1.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
});
|
||||
//# sourceMappingURL=logger.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AACvB,+BAA2B;AAEd,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,SAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;IACvD,SAAS,EACP,SAAG,CAAC,QAAQ,KAAK,YAAY;QAC3B,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE,EAAE;QACvF,CAAC,CAAC,SAAS;CAChB,CAAC,CAAA"}
|
||||
@@ -0,0 +1,10 @@
|
||||
import pino from 'pino'
|
||||
import { env } from './env'
|
||||
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport:
|
||||
env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.storageProvider = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
constructor() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
};
|
||||
}
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
return this.provider !== null;
|
||||
}
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
getLastHealthStatus() {
|
||||
return this.lastHealthStatus;
|
||||
}
|
||||
setHealthStatus(status) {
|
||||
this.lastHealthStatus = status;
|
||||
this.healthy = status.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
}
|
||||
return this.provider.upload(payload);
|
||||
}
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await this.provider.checkDetailedHealth();
|
||||
this.healthy = status.healthy;
|
||||
this.lastHealthStatus = status;
|
||||
}
|
||||
catch {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
};
|
||||
}
|
||||
}
|
||||
async getBuffer(path) {
|
||||
if (!this.provider)
|
||||
return null;
|
||||
return this.provider.getBuffer(path);
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
if (!this.provider) {
|
||||
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
|
||||
return { deleted: 0 };
|
||||
}
|
||||
return this.provider.deletePrefix(prefix);
|
||||
}
|
||||
unregister() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
logger_1.logger.info('[StorageProvider] Provider removido');
|
||||
}
|
||||
}
|
||||
exports.storageProvider = new StorageProviderRegistry();
|
||||
//# sourceMappingURL=StorageProvider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAoBvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;QACf,qBAAgB,GAAwB;YAC9C,OAAO,EAAE,KAAK;YACd,UAAU,EAAE,KAAK;YACjB,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,2BAA2B;SACrC,CAAA;IA6EH,CAAC;IA3EC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,gBAAgB,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAA;QAC1G,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,mBAAmB;QACjB,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,eAAe,CAAC,MAA2B;QACzC,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,gBAAgB;gBACxB,OAAO,EAAE,2EAA2E;aACrF,CAAA;YACD,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAA;YACxD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;YAC7B,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,IAAI;gBAChB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAA;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,IAAY;QAC1B,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAA;QAC/B,OAAO,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
import type { StorageProviderInterface, StorageUploadPayload } from './types'
|
||||
|
||||
export type StorageHealthReason =
|
||||
| 'ok'
|
||||
| 'not_configured'
|
||||
| 'needs_restart'
|
||||
| 'auth_error'
|
||||
| 'billing_error'
|
||||
| 'bucket_not_found'
|
||||
| 'network_error'
|
||||
| 'unknown'
|
||||
|
||||
export interface StorageHealthStatus {
|
||||
healthy: boolean
|
||||
configured: boolean
|
||||
reason: StorageHealthReason
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
private provider: StorageProviderInterface | null = null
|
||||
private healthy = false
|
||||
private lastHealthStatus: StorageHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
}
|
||||
|
||||
register(provider: StorageProviderInterface): void {
|
||||
this.provider = provider
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' }
|
||||
rootLogger.info('[StorageProvider] Provider registrado')
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.provider !== null
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.healthy
|
||||
}
|
||||
|
||||
getLastHealthStatus(): StorageHealthStatus {
|
||||
return this.lastHealthStatus
|
||||
}
|
||||
|
||||
setHealthStatus(status: StorageHealthStatus): void {
|
||||
this.lastHealthStatus = status
|
||||
this.healthy = status.healthy
|
||||
}
|
||||
|
||||
async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.')
|
||||
}
|
||||
return this.provider.upload(payload)
|
||||
}
|
||||
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth(): Promise<void> {
|
||||
if (!this.provider) {
|
||||
this.healthy = false
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const status = await this.provider.checkDetailedHealth()
|
||||
this.healthy = status.healthy
|
||||
this.lastHealthStatus = status
|
||||
} catch {
|
||||
this.healthy = false
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(path: string): Promise<Buffer | null> {
|
||||
if (!this.provider) return null
|
||||
return this.provider.getBuffer(path)
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
if (!this.provider) {
|
||||
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
||||
return { deleted: 0 }
|
||||
}
|
||||
return this.provider.deletePrefix(prefix)
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
this.provider = null
|
||||
this.healthy = false
|
||||
rootLogger.info('[StorageProvider] Provider removido')
|
||||
}
|
||||
}
|
||||
|
||||
export const storageProvider = new StorageProviderRegistry()
|
||||
export type { StorageUploadPayload, StorageProviderInterface }
|
||||
@@ -0,0 +1,55 @@
|
||||
import knex, { Knex } from 'knex'
|
||||
import { env } from '../config/env'
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
|
||||
/**
|
||||
* Instância Knex compartilhada entre todos os plugins.
|
||||
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
|
||||
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
|
||||
*/
|
||||
function createKnex(): Knex {
|
||||
// DATABASE_URL formato: postgresql://user:pass@host:port/db
|
||||
const connection = env.DATABASE_URL
|
||||
|
||||
const instance = knex({
|
||||
client: 'pg',
|
||||
connection,
|
||||
pool: { min: 1, max: 5 },
|
||||
acquireConnectionTimeout: 10000,
|
||||
})
|
||||
|
||||
instance.on('query-error', (err: Error, query: { sql: string }) => {
|
||||
rootLogger.error({ err, sql: query.sql }, '[Knex] Query error')
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
export const db = createKnex()
|
||||
|
||||
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
|
||||
export async function ensurePluginTables(knexInstance: Knex): Promise<void> {
|
||||
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs')
|
||||
if (!hasJanitorLogs) {
|
||||
await knexInstance.schema.createTable('janitor_logs', (t) => {
|
||||
t.increments('id').primary()
|
||||
t.string('filename').notNullable()
|
||||
t.string('category').notNullable()
|
||||
t.string('status').notNullable()
|
||||
t.text('message').nullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
rootLogger.info('[Knex] Tabela janitor_logs criada')
|
||||
}
|
||||
|
||||
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage')
|
||||
if (!hasNanobanaUsage) {
|
||||
await knexInstance.schema.createTable('nanobana_usage', (t) => {
|
||||
t.increments('id').primary()
|
||||
t.string('partner_id').notNullable()
|
||||
t.string('type').notNullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
rootLogger.info('[Knex] Tabela nanobana_usage criada')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.hookBus = exports.HookBus = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Event bus entre plugins.
|
||||
* emit() executa todos os handlers registrados e retorna array de resultados.
|
||||
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
|
||||
*/
|
||||
class HookBus {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
}
|
||||
register(event, handler) {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, []);
|
||||
}
|
||||
this.handlers.get(event).push(handler);
|
||||
logger_1.logger.debug({ event }, '[HookBus] Handler registrado');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async emit(event, data) {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (!handlers || handlers.length === 0)
|
||||
return [];
|
||||
const results = [];
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const result = await handler(data);
|
||||
results.push(result);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, event }, '[HookBus] Erro em handler');
|
||||
results.push(null);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
|
||||
removeAll(event) {
|
||||
this.handlers.delete(event);
|
||||
}
|
||||
/** Lista eventos registrados — útil para debug */
|
||||
listEvents() {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
}
|
||||
exports.HookBus = HookBus;
|
||||
exports.hookBus = new HookBus();
|
||||
//# sourceMappingURL=hook-bus.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"hook-bus.js","sourceRoot":"","sources":["hook-bus.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAKvD;;;;GAIG;AACH,MAAa,OAAO;IAApB;QACU,aAAQ,GAAG,IAAI,GAAG,EAAyB,CAAA;IAuCrD,CAAC;IArCC,QAAQ,CAAC,KAAa,EAAE,OAAoB;QAC1C,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;QAC9B,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACvC,eAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,8BAA8B,CAAC,CAAA;IAC7D,CAAC;IAED,8DAA8D;IAC9D,KAAK,CAAC,IAAI,CAAC,KAAa,EAAE,IAAS;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAA;QAEjD,MAAM,OAAO,GAAc,EAAE,CAAA;QAE7B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;gBAClC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,2BAA2B,CAAC,CAAA;gBAC7D,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACpB,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;IAED,6EAA6E;IAC7E,SAAS,CAAC,KAAa;QACrB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC;IAED,kDAAkD;IAClD,UAAU;QACR,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IACzC,CAAC;CACF;AAxCD,0BAwCC;AAEY,QAAA,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA"}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HookHandler = (data: any) => Promise<any>
|
||||
|
||||
/**
|
||||
* Event bus entre plugins.
|
||||
* emit() executa todos os handlers registrados e retorna array de resultados.
|
||||
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
|
||||
*/
|
||||
export class HookBus {
|
||||
private handlers = new Map<string, HookHandler[]>()
|
||||
|
||||
register(event: string, handler: HookHandler): void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, [])
|
||||
}
|
||||
this.handlers.get(event)!.push(handler)
|
||||
rootLogger.debug({ event }, '[HookBus] Handler registrado')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async emit(event: string, data: any): Promise<any[]> {
|
||||
const handlers = this.handlers.get(event)
|
||||
if (!handlers || handlers.length === 0) return []
|
||||
|
||||
const results: unknown[] = []
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const result = await handler(data)
|
||||
results.push(result)
|
||||
} catch (err) {
|
||||
rootLogger.error({ err, event }, '[HookBus] Erro em handler')
|
||||
results.push(null)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
|
||||
removeAll(event: string): void {
|
||||
this.handlers.delete(event)
|
||||
}
|
||||
|
||||
/** Lista eventos registrados — útil para debug */
|
||||
listEvents(): string[] {
|
||||
return Array.from(this.handlers.keys())
|
||||
}
|
||||
}
|
||||
|
||||
export const hookBus = new HookBus()
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginConfig = exports.PluginConfigStore = void 0;
|
||||
const dragonfly_1 = require("../infra/cache/dragonfly");
|
||||
const logger_1 = require("../config/logger");
|
||||
const KEY_PREFIX = 'plugin:config:';
|
||||
// Config de plugins não expira — TTL longo (1 ano)
|
||||
const TTL_SECONDS = 365 * 24 * 3600;
|
||||
/**
|
||||
* Config store para plugins, backed por DragonflyDB.
|
||||
* get() usa cache in-memory para evitar round-trips no hot-path.
|
||||
* set() persiste no Dragonfly e invalida o cache local.
|
||||
*/
|
||||
class PluginConfigStore {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/** Lê config de um plugin. Retorna {} se não houver config salva. */
|
||||
get(pluginName) {
|
||||
return this.cache.get(pluginName) ?? {};
|
||||
}
|
||||
/** Persiste config no Dragonfly e atualiza cache local. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async set(pluginName, config) {
|
||||
this.cache.set(pluginName, config);
|
||||
await dragonfly_1.dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS);
|
||||
logger_1.logger.debug({ pluginName }, '[PluginConfig] Config salva');
|
||||
}
|
||||
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
|
||||
async loadAll(pluginNames) {
|
||||
await Promise.all(pluginNames.map(async (name) => {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(`${KEY_PREFIX}${name}`);
|
||||
if (stored) {
|
||||
this.cache.set(name, stored);
|
||||
logger_1.logger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly');
|
||||
}
|
||||
}));
|
||||
}
|
||||
/** Retorna config de todos os plugins (para a API admin). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllCached() {
|
||||
const result = {};
|
||||
for (const [name, config] of this.cache.entries()) {
|
||||
result[name] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
exports.PluginConfigStore = PluginConfigStore;
|
||||
exports.pluginConfig = new PluginConfigStore();
|
||||
//# sourceMappingURL=plugin-config.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugin-config.js","sourceRoot":"","sources":["plugin-config.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AACpD,6CAAuD;AAEvD,MAAM,UAAU,GAAG,gBAAgB,CAAA;AACnC,mDAAmD;AACnD,MAAM,WAAW,GAAG,GAAG,GAAG,EAAE,GAAG,IAAI,CAAA;AAEnC;;;;GAIG;AACH,MAAa,iBAAiB;IAA9B;QACU,UAAK,GAAG,IAAI,GAAG,EAAmC,CAAA;IAsC5D,CAAC;IApCC,8DAA8D;IAC9D,qEAAqE;IACrE,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAA;IACzC,CAAC;IAED,2DAA2D;IAC3D,8DAA8D;IAC9D,KAAK,CAAC,GAAG,CAAC,UAAkB,EAAE,MAA2B;QACvD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QAClC,MAAM,qBAAS,CAAC,OAAO,CAAC,GAAG,UAAU,GAAG,UAAU,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAA;QAC1E,eAAU,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,EAAE,6BAA6B,CAAC,CAAA;IACjE,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,OAAO,CAAC,WAAqB;QACjC,MAAM,OAAO,CAAC,GAAG,CACf,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YAC7B,MAAM,MAAM,GAAG,MAAM,qBAAS,CAAC,OAAO,CAA0B,GAAG,UAAU,GAAG,IAAI,EAAE,CAAC,CAAA;YACvF,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;gBAC5B,eAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,EAAE,8CAA8C,CAAC,CAAA;YAC5E,CAAC;QACH,CAAC,CAAC,CACH,CAAA;IACH,CAAC;IAED,6DAA6D;IAC7D,8DAA8D;IAC9D,YAAY;QACV,MAAM,MAAM,GAAwC,EAAE,CAAA;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAA;QACvB,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAvCD,8CAuCC;AAEY,QAAA,YAAY,GAAG,IAAI,iBAAiB,EAAE,CAAA"}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { dragonfly } from '../infra/cache/dragonfly'
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
|
||||
const KEY_PREFIX = 'plugin:config:'
|
||||
// Config de plugins não expira — TTL longo (1 ano)
|
||||
const TTL_SECONDS = 365 * 24 * 3600
|
||||
|
||||
/**
|
||||
* Config store para plugins, backed por DragonflyDB.
|
||||
* get() usa cache in-memory para evitar round-trips no hot-path.
|
||||
* set() persiste no Dragonfly e invalida o cache local.
|
||||
*/
|
||||
export class PluginConfigStore {
|
||||
private cache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/** Lê config de um plugin. Retorna {} se não houver config salva. */
|
||||
get(pluginName: string): Record<string, any> {
|
||||
return this.cache.get(pluginName) ?? {}
|
||||
}
|
||||
|
||||
/** Persiste config no Dragonfly e atualiza cache local. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async set(pluginName: string, config: Record<string, any>): Promise<void> {
|
||||
this.cache.set(pluginName, config)
|
||||
await dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS)
|
||||
rootLogger.debug({ pluginName }, '[PluginConfig] Config salva')
|
||||
}
|
||||
|
||||
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
|
||||
async loadAll(pluginNames: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
pluginNames.map(async (name) => {
|
||||
const stored = await dragonfly.getJson<Record<string, unknown>>(`${KEY_PREFIX}${name}`)
|
||||
if (stored) {
|
||||
this.cache.set(name, stored)
|
||||
rootLogger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly')
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Retorna config de todos os plugins (para a API admin). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllCached(): Record<string, Record<string, any>> {
|
||||
const result: Record<string, Record<string, any>> = {}
|
||||
for (const [name, config] of this.cache.entries()) {
|
||||
result[name] = config
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginConfig = new PluginConfigStore()
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
import type { PluginInstance, PluginManifest } from './types'
|
||||
|
||||
export interface LoadedPlugin {
|
||||
instance: PluginInstance
|
||||
manifest: PluginManifest
|
||||
dir: string
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/plugins'
|
||||
: path.resolve(__dirname, '../../../plugins')
|
||||
|
||||
/**
|
||||
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
|
||||
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
|
||||
*/
|
||||
function topoSort(plugins: LoadedPlugin[]): LoadedPlugin[] {
|
||||
const byName = new Map(plugins.map((p) => [p.manifest.name, p]))
|
||||
const inDegree = new Map<string, number>()
|
||||
const dependents = new Map<string, string[]>()
|
||||
|
||||
for (const p of plugins) {
|
||||
inDegree.set(p.manifest.name, 0)
|
||||
dependents.set(p.manifest.name, [])
|
||||
}
|
||||
|
||||
for (const p of plugins) {
|
||||
for (const dep of p.manifest.dependencies) {
|
||||
if (!byName.has(dep)) {
|
||||
rootLogger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada')
|
||||
continue
|
||||
}
|
||||
dependents.get(dep)!.push(p.manifest.name)
|
||||
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const queue = plugins
|
||||
.filter((p) => inDegree.get(p.manifest.name) === 0)
|
||||
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1))
|
||||
|
||||
const sorted: LoadedPlugin[] = []
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
sorted.push(current)
|
||||
|
||||
for (const depName of dependents.get(current.manifest.name) ?? []) {
|
||||
const newDegree = (inDegree.get(depName) ?? 1) - 1
|
||||
inDegree.set(depName, newDegree)
|
||||
if (newDegree === 0) {
|
||||
queue.push(byName.get(depName)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length !== plugins.length) {
|
||||
rootLogger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original')
|
||||
return plugins
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
|
||||
* Retorna plugins ordenados por dependência.
|
||||
*/
|
||||
export async function loadPlugins(): Promise<LoadedPlugin[]> {
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
rootLogger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado')
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
|
||||
const loaded: LoadedPlugin[] = []
|
||||
|
||||
for (const dirName of pluginDirs) {
|
||||
const pluginDir = path.join(PLUGINS_DIR, dirName)
|
||||
const manifestPath = path.join(pluginDir, 'manifest.json')
|
||||
const indexTs = path.join(pluginDir, 'index.ts')
|
||||
const indexJs = path.join(pluginDir, 'index.js')
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
rootLogger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando')
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
|
||||
const entryPoint = fs.existsSync(indexJs) ? indexJs : fs.existsSync(indexTs) ? indexTs : null
|
||||
if (!entryPoint) {
|
||||
rootLogger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
const mod = await import(entryPoint)
|
||||
const instance: PluginInstance = mod.default ?? mod
|
||||
|
||||
if (typeof instance.activate !== 'function') {
|
||||
rootLogger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando')
|
||||
continue
|
||||
}
|
||||
|
||||
loaded.push({ instance, manifest, dir: pluginDir })
|
||||
rootLogger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado')
|
||||
} catch (err) {
|
||||
rootLogger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin')
|
||||
}
|
||||
}
|
||||
|
||||
return topoSort(loaded)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
import type { PluginContext, PluginInstance, PluginManifest } from './types'
|
||||
import type { LoadedPlugin } from './plugin-loader'
|
||||
|
||||
export interface RegistryEntry {
|
||||
instance: PluginInstance
|
||||
manifest: PluginManifest
|
||||
active: boolean
|
||||
dir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry central dos plugins.
|
||||
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
|
||||
*/
|
||||
class PluginRegistry {
|
||||
private entries = new Map<string, RegistryEntry>()
|
||||
private ctx!: PluginContext
|
||||
|
||||
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
|
||||
setContext(ctx: PluginContext): void {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
/** Registra plugins carregados (sem ativar ainda) */
|
||||
register(plugins: LoadedPlugin[]): void {
|
||||
for (const { instance, manifest, dir } of plugins) {
|
||||
this.entries.set(manifest.name, { instance, manifest, active: false, dir })
|
||||
}
|
||||
}
|
||||
|
||||
/** Ativa todos os plugins marcados como enabled no manifest.
|
||||
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
|
||||
async activateAll(): Promise<void> {
|
||||
for (const [name, entry] of this.entries) {
|
||||
if (!entry.manifest.enabled) continue
|
||||
try {
|
||||
await this.activate(name)
|
||||
} catch (err: any) {
|
||||
rootLogger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async activate(name: string): Promise<void> {
|
||||
const entry = this.entries.get(name)
|
||||
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
|
||||
if (entry.active) return
|
||||
|
||||
// Verifica dependências
|
||||
for (const dep of entry.manifest.dependencies) {
|
||||
const depEntry = this.entries.get(dep)
|
||||
if (!depEntry?.active) {
|
||||
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await entry.instance.activate(this.ctx)
|
||||
entry.active = true
|
||||
entry.manifest.enabled = true
|
||||
rootLogger.info({ name }, '[PluginRegistry] Plugin ativado')
|
||||
} catch (err) {
|
||||
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async deactivate(name: string): Promise<void> {
|
||||
const entry = this.entries.get(name)
|
||||
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
|
||||
if (!entry.active) return
|
||||
if (!entry.manifest.canDisable) throw new Error(`Plugin "${name}" não pode ser desativado`)
|
||||
|
||||
// Verifica se algum plugin ativo depende deste
|
||||
for (const [depName, depEntry] of this.entries) {
|
||||
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
|
||||
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await entry.instance.deactivate(this.ctx)
|
||||
entry.active = false
|
||||
entry.manifest.enabled = false
|
||||
rootLogger.info({ name }, '[PluginRegistry] Plugin desativado')
|
||||
} catch (err) {
|
||||
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Retorna lista de todos os plugins com status para a API admin */
|
||||
list(): Array<RegistryEntry & { name: string }> {
|
||||
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }))
|
||||
}
|
||||
|
||||
getEntry(name: string): RegistryEntry | undefined {
|
||||
return this.entries.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Força reativação de um plugin sem passar pelo deactivate.
|
||||
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
|
||||
*/
|
||||
async reactivate(name: string): Promise<void> {
|
||||
const entry = this.entries.get(name)
|
||||
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
|
||||
entry.active = false
|
||||
await this.activate(name)
|
||||
rootLogger.info({ name }, '[PluginRegistry] Plugin reativado com nova config')
|
||||
}
|
||||
|
||||
/** Retorna menu items de todos os plugins ativos */
|
||||
getActiveMenuItems(): Array<{ pluginName: string; items: PluginManifest['frontend'] }> {
|
||||
return Array.from(this.entries.values())
|
||||
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
|
||||
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }))
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistry()
|
||||
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=types.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Express } from 'express'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import type { Server as SocketServer } from 'socket.io'
|
||||
import type { Knex } from 'knex'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { Logger } from 'pino'
|
||||
import type { HookBus } from './hook-bus'
|
||||
import type { PluginConfigStore } from './plugin-config'
|
||||
|
||||
// ── Manifest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
href: string
|
||||
roles: string[]
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface Widget {
|
||||
id: string
|
||||
component: string
|
||||
span?: number
|
||||
roles: string[]
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string
|
||||
displayName: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
category: 'core' | 'business' | 'integration' | 'utility'
|
||||
enabled: boolean
|
||||
canDisable: boolean
|
||||
dependencies: string[]
|
||||
backend?: {
|
||||
routePrefix: string
|
||||
hasMigrations: boolean
|
||||
globalMiddleware?: string[]
|
||||
}
|
||||
frontend?: {
|
||||
menuItems: MenuItem[]
|
||||
widgets?: Widget[]
|
||||
pages?: Array<{ path: string; component: string; roles: string[] }>
|
||||
}
|
||||
hooks?: {
|
||||
subscribes: string[]
|
||||
emits: string[]
|
||||
}
|
||||
/** Schema de configuração para gerar o formulário no admin */
|
||||
configSchema?: PluginConfigField[]
|
||||
}
|
||||
|
||||
export interface PluginConfigField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'password' | 'select' | 'toggle'
|
||||
placeholder?: string
|
||||
description?: string
|
||||
tooltip?: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
required?: boolean
|
||||
group?: string
|
||||
default?: string
|
||||
}
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PluginContext {
|
||||
/** Express app — para registrar rotas */
|
||||
app: Express
|
||||
/** Knex — acesso às tabelas dinâmicas dos plugins */
|
||||
db: Knex
|
||||
/** Prisma — acesso ao core do sistema */
|
||||
prisma: PrismaClient
|
||||
/** Logger (pino) */
|
||||
logger: Logger
|
||||
/** Event bus entre plugins */
|
||||
hooks: HookBus
|
||||
/** Config store backed por DragonflyDB */
|
||||
config: PluginConfigStore
|
||||
/** Socket.IO — para eventos em tempo real */
|
||||
io: SocketServer
|
||||
/** HTTP server nativo — para plugins que precisam interceptar upgrade (WS raw) */
|
||||
httpServer: HttpServer
|
||||
}
|
||||
|
||||
// ── Instance ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PluginInstance {
|
||||
manifest: PluginManifest
|
||||
activate(ctx: PluginContext): Promise<void>
|
||||
deactivate(ctx: PluginContext): Promise<void>
|
||||
}
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StorageCategory =
|
||||
| 'cdi'
|
||||
| 'xrays'
|
||||
| 'documents'
|
||||
| 'exports'
|
||||
| 'posts'
|
||||
| 'cards'
|
||||
| 'partners'
|
||||
| 'whatsapp'
|
||||
| 'media'
|
||||
|
||||
export interface StorageUploadPayload {
|
||||
category: StorageCategory
|
||||
partnerId?: string
|
||||
benefitId?: string
|
||||
postId?: string
|
||||
whatsappId?: string
|
||||
/** Identificador do usuário/tenant no sistema — usado para compor o path de mídia WhatsApp */
|
||||
usuarioSis?: string
|
||||
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
|
||||
sessionJID?: string
|
||||
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
|
||||
customPath?: string
|
||||
file: {
|
||||
originalname: string
|
||||
buffer: Buffer
|
||||
mimetype: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StorageProviderInterface {
|
||||
checkHealth(): Promise<boolean>
|
||||
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
|
||||
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
|
||||
deletePrefix(prefix: string): Promise<{ deleted: number }>
|
||||
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
|
||||
getBuffer(path: string): Promise<Buffer | null>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.dragonfly = void 0;
|
||||
const ioredis_1 = __importDefault(require("ioredis"));
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
class DragonflyClient {
|
||||
constructor() {
|
||||
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
|
||||
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
|
||||
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
// Falha imediata quando desconectado — evita fila infinita em memória
|
||||
enableOfflineQueue: false,
|
||||
// Reconexão exponencial: 200ms → 400ms → ... → 30s
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(200 * 2 ** times, 30000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
|
||||
this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
|
||||
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
|
||||
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
|
||||
}
|
||||
/** true se o cliente está conectado e pronto */
|
||||
isAlive() {
|
||||
return this.client.status === 'ready';
|
||||
}
|
||||
async connect() {
|
||||
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
|
||||
// retryStrategy cuida da reconexão automática
|
||||
this.client.connect().catch(() => { });
|
||||
}
|
||||
async get(key) {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async getJson(key) {
|
||||
try {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async set(key, value, ttlSeconds) {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, value, 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async setJson(key, value, ttlSeconds) {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async psetex(key, ttlMs, value) {
|
||||
try {
|
||||
await this.client.psetex(key, ttlMs, value);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async del(key) {
|
||||
try {
|
||||
await this.client.del(key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async exists(key) {
|
||||
try {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel, message) {
|
||||
try {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
raw() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
exports.dragonfly = new DragonflyClient();
|
||||
//# sourceMappingURL=dragonfly.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,wFAAwF;YACxF,oBAAoB,EAAE,IAAI;YAC1B,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,IAAI;YACjB,sEAAsE;YACtE,kBAAkB,EAAE,KAAK;YACzB,mDAAmD;YACnD,aAAa,CAAC,KAAa;gBACzB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,KAAK,EAAE,KAAM,CAAC,CAAA;gBAChD,OAAO,KAAK,CAAA;YACd,CAAC;SACF,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAA;QAClE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACtG,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,+CAA+C,CAAC,CAAC,CAAA;IAC/F,CAAC;IAED,gDAAgD;IAChD,OAAO;QACL,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,OAAO,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,OAAO;QACX,8EAA8E;QAC9E,8CAA8C;QAC9C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAmD,CAAC,CAAC,CAAA;IACxF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACtC,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9C,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9D,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;YACnD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW,EAAE,KAAa,EAAE,KAAa;QACpD,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;QAClB,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,KAAK,CAAA;QAAC,CAAC;IAC1B,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Redis from 'ioredis'
|
||||
import { env } from '../../config/env'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
class DragonflyClient {
|
||||
private client: Redis
|
||||
private readonly ttl: number
|
||||
|
||||
constructor() {
|
||||
this.ttl = parseInt(env.DRAGONFLY_TTL_SECONDS, 10)
|
||||
|
||||
this.client = new Redis(env.DRAGONFLY_URL, {
|
||||
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
// Falha imediata quando desconectado — evita fila infinita em memória
|
||||
enableOfflineQueue: false,
|
||||
// Reconexão exponencial: 200ms → 400ms → ... → 30s
|
||||
retryStrategy(times: number) {
|
||||
const delay = Math.min(200 * 2 ** times, 30_000)
|
||||
return delay
|
||||
},
|
||||
})
|
||||
|
||||
this.client.on('connect', () => logger.info('DragonflyDB conectado'))
|
||||
this.client.on('ready', () => logger.info('DragonflyDB pronto'))
|
||||
this.client.on('error', (err) => logger.error({ msg: 'DragonflyDB erro', code: (err as any).code }))
|
||||
this.client.on('close', () => logger.warn('DragonflyDB conexão fechada — reconectando...'))
|
||||
}
|
||||
|
||||
/** true se o cliente está conectado e pronto */
|
||||
isAlive(): boolean {
|
||||
return this.client.status === 'ready'
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
|
||||
// retryStrategy cuida da reconexão automática
|
||||
this.client.connect().catch(() => { /* reconexão em background via retryStrategy */ })
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
try { return await this.client.get(key) } catch { return null }
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await this.client.get(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as T
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, value, 'EX', ttl)
|
||||
} else {
|
||||
await this.client.set(key, value)
|
||||
}
|
||||
} catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttl)
|
||||
} else {
|
||||
await this.client.set(key, JSON.stringify(value))
|
||||
}
|
||||
} catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
|
||||
async psetex(key: string, ttlMs: number, value: string): Promise<void> {
|
||||
try { await this.client.psetex(key, ttlMs, value) } catch { }
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
try { await this.client.del(key) } catch { }
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const count = await this.client.exists(key)
|
||||
return count > 0
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
try { await this.client.publish(channel, message) } catch { }
|
||||
}
|
||||
|
||||
raw(): Redis {
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
|
||||
export const dragonfly = new DragonflyClient()
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.prisma = void 0;
|
||||
const client_1 = require("@prisma/client");
|
||||
exports.prisma = global.__prisma ??
|
||||
new client_1.PrismaClient({
|
||||
log: process.env.NODE_ENV !== 'production'
|
||||
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
|
||||
: [],
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = exports.prisma;
|
||||
}
|
||||
//# sourceMappingURL=prisma.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"prisma.js","sourceRoot":"","sources":["prisma.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAQhC,QAAA,MAAM,GACjB,MAAM,CAAC,QAAQ;IACf,IAAI,qBAAY,CAAC;QACf,GAAG,EACD,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YACnC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YACzE,CAAC,CAAC,EAAE;KACT,CAAC,CAAA;AAEJ,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,MAAM,CAAC,QAAQ,GAAG,cAAM,CAAA;AAC1B,CAAC"}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
global.__prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
|
||||
: [],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { env } from '../../config/env'
|
||||
import { logger } from '../../config/logger'
|
||||
import { dragonfly } from '../cache/dragonfly'
|
||||
|
||||
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
|
||||
const PRESENCE_TTL = 70
|
||||
|
||||
interface SocketUser {
|
||||
sub: string // userId / tenantId (matches JWT payload)
|
||||
role: string
|
||||
}
|
||||
|
||||
export function buildSocketServer(httpServer: HttpServer): SocketIOServer {
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: {
|
||||
origin: env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
|
||||
// Autenticação por token JWT no handshake
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
if (!token) {
|
||||
next(new Error('Token não fornecido'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(token, env.JWT_SECRET) as SocketUser
|
||||
socket.data.userId = payload.sub
|
||||
socket.data.role = payload.role
|
||||
next()
|
||||
} catch {
|
||||
next(new Error('Token inválido'))
|
||||
}
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId: string = socket.data.userId
|
||||
logger.debug({ userId, socketId: socket.id }, 'Socket conectado')
|
||||
|
||||
// Cliente entra na sala do seu tenant automaticamente
|
||||
socket.join(`tenant:${userId}`)
|
||||
|
||||
// Registra presença do usuário no Dragonfly com TTL
|
||||
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
|
||||
|
||||
// Cliente pode entrar em salas específicas de instância ou chat
|
||||
socket.on('join:instance', (instanceId: string) => {
|
||||
socket.join(`instance:${instanceId}`)
|
||||
})
|
||||
|
||||
socket.on('join:chat', (chatId: string) => {
|
||||
socket.join(`chat:${chatId}`)
|
||||
})
|
||||
|
||||
socket.on('leave:chat', (chatId: string) => {
|
||||
socket.leave(`chat:${chatId}`)
|
||||
})
|
||||
|
||||
// Heartbeat do frontend a cada 30s — renova TTL de presença
|
||||
socket.on('user:heartbeat', () => {
|
||||
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger.debug({ userId, reason }, 'Socket desconectado')
|
||||
// Remove presença imediatamente ao desconectar
|
||||
dragonfly.del(`presence:user:${userId}`).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
return io
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
|
||||
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
|
||||
*/
|
||||
import { prisma } from '../database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
|
||||
|
||||
export async function liberarSlotAgenda(input: {
|
||||
solicitanteId: string
|
||||
tituloSlot: string
|
||||
chatIdSolicitante: string
|
||||
}): Promise<void> {
|
||||
logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante')
|
||||
|
||||
// Aqui você envia mensagem via WhatsApp avisando o solicitante
|
||||
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
|
||||
// Por ora, marca no banco como disponível
|
||||
await prisma.protocol.updateMany({
|
||||
where: {
|
||||
contactId: input.solicitanteId,
|
||||
status: 'WAITING_CLIENT',
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function notificarConflito(input: {
|
||||
chatIdB: string
|
||||
solicitanteNome: string
|
||||
tituloSlot: string
|
||||
}): Promise<void> {
|
||||
logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot')
|
||||
// Dispara mensagem via NATS → WhatsApp handler
|
||||
}
|
||||
|
||||
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
|
||||
|
||||
export async function decrementarScore(input: {
|
||||
contactId: string
|
||||
motivo: string
|
||||
pontos: number
|
||||
}): Promise<{ novoScore: number; restrito: boolean }> {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: input.contactId },
|
||||
})
|
||||
if (!contact) throw new Error(`Contato ${input.contactId} não encontrado`)
|
||||
|
||||
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos)
|
||||
const restrito = novoScore <= 20
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: {
|
||||
scoreReputacao: novoScore,
|
||||
flagRestricao: restrito,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo },
|
||||
'[Temporal] Score de reputação atualizado')
|
||||
|
||||
return { novoScore, restrito }
|
||||
}
|
||||
|
||||
export async function bloquearAgendamentosAutomaticos(input: {
|
||||
contactId: string
|
||||
}): Promise<void> {
|
||||
await prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: { flagRestricao: true },
|
||||
})
|
||||
logger.warn({ contactId: input.contactId },
|
||||
'[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados')
|
||||
}
|
||||
|
||||
export async function buscarScoreContato(contactId: string): Promise<{
|
||||
score: number
|
||||
restrito: boolean
|
||||
}> {
|
||||
const c = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { scoreReputacao: true, flagRestricao: true },
|
||||
})
|
||||
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Client, Connection } from '@temporalio/client'
|
||||
import { env } from '../../config/env'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
let _client: Client | null = null
|
||||
|
||||
export async function getTemporalClient(): Promise<Client> {
|
||||
if (_client) return _client
|
||||
|
||||
const connection = await Connection.connect({ address: env.TEMPORAL_ADDRESS })
|
||||
|
||||
_client = new Client({
|
||||
connection,
|
||||
namespace: env.TEMPORAL_NAMESPACE,
|
||||
})
|
||||
|
||||
logger.info({ address: env.TEMPORAL_ADDRESS }, 'Temporal Client conectado')
|
||||
return _client
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Worker do Temporal — roda como processo separado.
|
||||
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
|
||||
*/
|
||||
import { Worker } from '@temporalio/worker'
|
||||
import { env } from '../../config/env'
|
||||
import { logger } from '../../config/logger'
|
||||
import * as activities from './activities'
|
||||
|
||||
async function runWorker() {
|
||||
const worker = await Worker.create({
|
||||
workflowsPath: require.resolve('./workflows'),
|
||||
activities,
|
||||
taskQueue: env.TEMPORAL_TASK_QUEUE,
|
||||
namespace: env.TEMPORAL_NAMESPACE,
|
||||
})
|
||||
|
||||
logger.info({ taskQueue: env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado')
|
||||
await worker.run()
|
||||
}
|
||||
|
||||
runWorker().catch((err) => {
|
||||
logger.error(err, 'Falha fatal no Temporal Worker')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Workflow: Conflito de Agenda (1 Hora)
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
|
||||
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
|
||||
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
|
||||
* - Se B não responder em 1h → slot é liberado e transferido para A.
|
||||
*/
|
||||
import {
|
||||
proxyActivities,
|
||||
sleep,
|
||||
defineSignal,
|
||||
setHandler,
|
||||
condition,
|
||||
log,
|
||||
} from '@temporalio/workflow'
|
||||
import type * as acts from '../activities'
|
||||
|
||||
const {
|
||||
liberarSlotAgenda,
|
||||
notificarConflito,
|
||||
} = proxyActivities<typeof acts>({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
})
|
||||
|
||||
// Signal enviado quando B confirma o slot
|
||||
export const confirmarSlotSignal = defineSignal<[{ confirmado: boolean }]>('confirmarSlot')
|
||||
|
||||
export interface ConflitoAgendaInput {
|
||||
solicitanteId: string // Pessoa A
|
||||
detentorId: string // Pessoa B
|
||||
chatIdSolicitante: string
|
||||
chatIdDetentor: string
|
||||
tituloSlot: string // ex: "Consulta 14h do dia 10/04"
|
||||
}
|
||||
|
||||
export async function conflitoAgendaWorkflow(input: ConflitoAgendaInput): Promise<{
|
||||
resultado: 'MANTIDO_B' | 'LIBERADO_A'
|
||||
}> {
|
||||
let bConfirmou = false
|
||||
|
||||
// Registra o handler do signal de confirmação de B
|
||||
setHandler(confirmarSlotSignal, ({ confirmado }) => {
|
||||
bConfirmou = confirmado
|
||||
log.info('Signal recebido de B', { confirmado })
|
||||
})
|
||||
|
||||
// Notifica B sobre a disputa
|
||||
await notificarConflito({
|
||||
chatIdB: input.chatIdDetentor,
|
||||
solicitanteNome: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
})
|
||||
|
||||
log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot })
|
||||
|
||||
// Aguarda signal de B OU timeout de 1 hora
|
||||
const bRespondeu = await condition(() => bConfirmou !== false, '1 hour')
|
||||
|
||||
if (bRespondeu && bConfirmou) {
|
||||
log.info('B confirmou o slot — slot mantido com B')
|
||||
return { resultado: 'MANTIDO_B' }
|
||||
}
|
||||
|
||||
// B não respondeu ou recusou → libera para A
|
||||
log.info('B não confirmou em 1 hora — liberando slot para A')
|
||||
await liberarSlotAgenda({
|
||||
solicitanteId: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
chatIdSolicitante: input.chatIdSolicitante,
|
||||
})
|
||||
|
||||
return { resultado: 'LIBERADO_A' }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow'
|
||||
export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow'
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Workflow: Gestão de Reputação de Contato
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Cada falta/infração reduz o score_reputacao do contato.
|
||||
* - Score ≤ 20 → contato entra em flag_restricao = true.
|
||||
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
|
||||
* - Score é recuperável manualmente pelo agente (fora deste workflow).
|
||||
*/
|
||||
import {
|
||||
proxyActivities,
|
||||
defineSignal,
|
||||
setHandler,
|
||||
condition,
|
||||
log,
|
||||
} from '@temporalio/workflow'
|
||||
import type * as acts from '../activities'
|
||||
|
||||
const {
|
||||
decrementarScore,
|
||||
bloquearAgendamentosAutomaticos,
|
||||
buscarScoreContato,
|
||||
} = proxyActivities<typeof acts>({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
})
|
||||
|
||||
// Signal para registrar uma nova infração
|
||||
export const registrarInfracaoSignal = defineSignal<[{
|
||||
motivo: string
|
||||
pontos: number
|
||||
}]>('registrarInfracao')
|
||||
|
||||
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
|
||||
export const encerrarMonitoramentoSignal = defineSignal('encerrarMonitoramento')
|
||||
|
||||
export interface ReputacaoInput {
|
||||
contactId: string
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export async function reputacaoWorkflow(input: ReputacaoInput): Promise<void> {
|
||||
let encerrado = false
|
||||
const infracoesPendentes: Array<{ motivo: string; pontos: number }> = []
|
||||
|
||||
setHandler(registrarInfracaoSignal, (payload) => {
|
||||
log.info('Infração registrada via signal', payload)
|
||||
infracoesPendentes.push(payload)
|
||||
})
|
||||
|
||||
setHandler(encerrarMonitoramentoSignal, () => {
|
||||
encerrado = true
|
||||
})
|
||||
|
||||
// Loop durável — o Temporal mantém o estado mesmo após reinícios
|
||||
while (!encerrado) {
|
||||
// Aguarda próxima infração ou encerramento
|
||||
await condition(() => infracoesPendentes.length > 0 || encerrado)
|
||||
|
||||
if (encerrado) break
|
||||
|
||||
const infracao = infracoesPendentes.shift()!
|
||||
|
||||
const { novoScore, restrito } = await decrementarScore({
|
||||
contactId: input.contactId,
|
||||
motivo: infracao.motivo,
|
||||
pontos: infracao.pontos,
|
||||
})
|
||||
|
||||
log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId })
|
||||
|
||||
if (restrito) {
|
||||
await bloquearAgendamentosAutomaticos({ contactId: input.contactId })
|
||||
log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
|
||||
contactId: input.contactId,
|
||||
scoreAtual: novoScore,
|
||||
})
|
||||
// Continua monitorando (restrição é revertida manualmente pelo agente)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Monitoramento de reputação encerrado', { contactId: input.contactId })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authMiddleware = exports.authenticate = void 0;
|
||||
// Re-export do middleware de autenticação para compatibilidade com plugins
|
||||
var auth_middleware_1 = require("../shared/middlewares/auth.middleware");
|
||||
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } });
|
||||
var auth_middleware_2 = require("../shared/middlewares/auth.middleware");
|
||||
Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } });
|
||||
//# sourceMappingURL=auth.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":";;;AAAA,2EAA2E;AAC3E,yEAAsF;AAA7E,+GAAA,cAAc,OAAgB;AACvC,yEAAsE;AAA7D,iHAAA,cAAc,OAAA"}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export do middleware de autenticação para compatibilidade com plugins
|
||||
export { authMiddleware as authenticate } from '../shared/middlewares/auth.middleware'
|
||||
export { authMiddleware } from '../shared/middlewares/auth.middleware'
|
||||
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requireRole = requireRole;
|
||||
// Stub de RBAC — aceita qualquer role válida
|
||||
function requireRole(..._roles) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
//# sourceMappingURL=rbac.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,kCAEC;AAHD,6CAA6C;AAC7C,SAAgB,WAAW,CAAC,GAAG,MAAgB;IAC7C,OAAO,CAAC,IAAa,EAAE,IAAc,EAAE,IAAkB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;AACtE,CAAC"}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
// Stub de RBAC — aceita qualquer role válida
|
||||
export function requireRole(..._roles: string[]) {
|
||||
return (_req: Request, _res: Response, next: NextFunction) => next()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function buildApiKeyRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// GET /api/api-keys
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { tenantId: req.tenantId! },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
})
|
||||
// Mask key: show only last 8 chars
|
||||
const masked = keys.map((k) => ({
|
||||
...k,
|
||||
keyPreview: `••••••••${k.key.slice(-8)}`,
|
||||
key: undefined,
|
||||
}))
|
||||
res.json(masked)
|
||||
})
|
||||
|
||||
// POST /api/api-keys — cria nova chave
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
})
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
const key = `nw_${crypto.randomBytes(24).toString('hex')}`
|
||||
|
||||
const created = await prisma.apiKey.create({
|
||||
data: {
|
||||
tenantId: req.tenantId!,
|
||||
name: body.name,
|
||||
key,
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Retorna a chave completa apenas uma vez
|
||||
res.status(201).json({
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
key: created.key, // plaintext — única vez
|
||||
isActive: created.isActive,
|
||||
createdAt: created.createdAt,
|
||||
expiresAt: created.expiresAt,
|
||||
})
|
||||
})
|
||||
|
||||
// PATCH /api/api-keys/:id — ativar/desativar
|
||||
router.patch('/:id', async (req: Request, res: Response) => {
|
||||
const id = req.params['id'] as string
|
||||
const schema = z.object({ isActive: z.boolean() })
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
|
||||
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
|
||||
|
||||
const updated = await prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } })
|
||||
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive })
|
||||
})
|
||||
|
||||
// DELETE /api/api-keys/:id
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const id = req.params['id'] as string
|
||||
|
||||
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
|
||||
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
|
||||
|
||||
await prisma.apiKey.delete({ where: { id } })
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Rotas exclusivas do ADMIN (dono do SaaS).
|
||||
* Prefixo: /api/admin/
|
||||
*/
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import multer from 'multer'
|
||||
import si from 'systeminformation'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { pluginsRouter } from '../plugins/plugins.routes'
|
||||
|
||||
const SETTINGS_KEY = 'system:settings'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
systemName: 'NewWhats',
|
||||
timezone: 'America/Sao_Paulo',
|
||||
logoUrl: null as string | null,
|
||||
faviconUrl: null as string | null,
|
||||
accentColor: '#3b82f6',
|
||||
sounds: { newMessage: true, notification: true, connectionStatus: true },
|
||||
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
|
||||
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
|
||||
registration: { mode: 'open' as 'open' | 'closed' | 'invite', defaultPlanId: null as string | null },
|
||||
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
|
||||
webhook: { url: '', secret: '' },
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
const stored = await dragonfly.getJson<typeof DEFAULT_SETTINGS>(SETTINGS_KEY)
|
||||
return { ...DEFAULT_SETTINGS, ...stored }
|
||||
}
|
||||
|
||||
const uploadAsset = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true)
|
||||
else cb(new Error('Apenas imagens são permitidas'))
|
||||
},
|
||||
})
|
||||
|
||||
export const adminRouter = Router()
|
||||
|
||||
// ── Plugins ───────────────────────────────────────────────────────────────────
|
||||
adminRouter.use('/plugins', pluginsRouter)
|
||||
|
||||
// ── Plans ─────────────────────────────────────────────────────────────────────
|
||||
adminRouter.get('/plans', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const plans = await prisma.plan.findMany({
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
res.json(plans)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao listar planos' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
adminRouter.get('/settings', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
// Nunca expõe a senha SMTP ao frontend
|
||||
const { smtp, ...rest } = settings
|
||||
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } })
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao buscar configurações' })
|
||||
}
|
||||
})
|
||||
|
||||
adminRouter.patch('/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const current = await getSettings()
|
||||
const body = req.body as Partial<typeof DEFAULT_SETTINGS>
|
||||
|
||||
// Se o frontend devolver o placeholder de senha, mantém a senha atual
|
||||
if (body.smtp?.password === '••••••••') {
|
||||
body.smtp = { ...body.smtp, password: current.smtp.password }
|
||||
}
|
||||
|
||||
const updated = { ...current, ...body }
|
||||
await dragonfly.setJson(SETTINGS_KEY, updated, 0) // TTL 0 = sem expiração
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar configurações' })
|
||||
}
|
||||
})
|
||||
|
||||
// Upload de logo
|
||||
adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'png'
|
||||
const dest = path.resolve('./media/system/logo.' + ext)
|
||||
await fs.writeFile(dest, req.file.buffer)
|
||||
const url = `/media/system/logo.${ext}`
|
||||
const settings = await getSettings()
|
||||
await dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0)
|
||||
res.json({ url })
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar logo' })
|
||||
}
|
||||
})
|
||||
|
||||
// Upload de favicon
|
||||
adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'ico'
|
||||
const dest = path.resolve('./media/system/favicon.' + ext)
|
||||
await fs.writeFile(dest, req.file.buffer)
|
||||
const url = `/media/system/favicon.${ext}`
|
||||
const settings = await getSettings()
|
||||
await dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0)
|
||||
res.json({ url })
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar favicon' })
|
||||
}
|
||||
})
|
||||
|
||||
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
|
||||
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
|
||||
adminRouter.get('/settings/public', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings()
|
||||
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance })
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Métricas do sistema ───────────────────────────────────────────────────────
|
||||
adminRouter.get('/metrics', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [cpuLoad, mem, disk] = await Promise.all([
|
||||
si.currentLoad(),
|
||||
si.mem(),
|
||||
si.fsSize(),
|
||||
])
|
||||
|
||||
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0]
|
||||
|
||||
const [connectedInstances, totalUsers] = await Promise.all([
|
||||
prisma.instance.count({ where: { status: 'CONNECTED' } }),
|
||||
prisma.user.count(),
|
||||
])
|
||||
|
||||
res.json({
|
||||
cpu: { percent: Math.round(cpuLoad.currentLoad) },
|
||||
ram: {
|
||||
total: mem.total,
|
||||
used: mem.used,
|
||||
percent: Math.round((mem.used / mem.total) * 100),
|
||||
},
|
||||
disk: {
|
||||
total: rootDisk?.size ?? 0,
|
||||
used: rootDisk?.used ?? 0,
|
||||
percent: Math.round(rootDisk?.use ?? 0),
|
||||
},
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connectedInstances,
|
||||
totalUsers,
|
||||
})
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Erro ao coletar métricas' })
|
||||
}
|
||||
})
|
||||
|
||||
// Listar todos os tenants com presença online
|
||||
adminRouter.get('/users', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [users, presenceKeys] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { instances: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
dragonfly.raw().keys('presence:user:*'),
|
||||
])
|
||||
|
||||
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')))
|
||||
|
||||
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar usuários' })
|
||||
}
|
||||
})
|
||||
|
||||
// Detalhes de um tenant
|
||||
adminRouter.get('/users/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
instances: { select: { id: true, name: true, status: true } },
|
||||
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
|
||||
},
|
||||
})
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Usuário não encontrado' })
|
||||
return
|
||||
}
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar usuário' })
|
||||
}
|
||||
})
|
||||
|
||||
// Ativar / desativar tenant
|
||||
adminRouter.patch('/users/:id/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { isActive } = z.object({ isActive: z.boolean() }).parse(req.body)
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
select: { id: true, name: true, email: true, isActive: true },
|
||||
})
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'isActive deve ser boolean' })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' })
|
||||
}
|
||||
})
|
||||
|
||||
// Alterar plano do tenant
|
||||
adminRouter.patch('/users/:id/plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { planId } = z.object({ planId: z.string().uuid() }).parse(req.body)
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { planId },
|
||||
select: { id: true, name: true, planId: true },
|
||||
})
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'planId inválido' })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar plano' })
|
||||
}
|
||||
})
|
||||
|
||||
// Estender trial de um tenant
|
||||
adminRouter.patch('/users/:id/trial', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { days } = z.object({ days: z.number().int().min(1).max(365) }).parse(req.body)
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, name: true, trialEndsAt: true },
|
||||
})
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'days inválido' })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao estender trial' })
|
||||
}
|
||||
})
|
||||
|
||||
// Resetar senha de um tenant (admin)
|
||||
adminRouter.patch('/users/:id/reset-password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { newPassword } = z
|
||||
.object({ newPassword: z.string().min(8).max(128) })
|
||||
.parse(req.body)
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12)
|
||||
await prisma.user.update({ where: { id }, data: { passwordHash } })
|
||||
|
||||
res.json({ message: 'Senha redefinida com sucesso' })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'newPassword inválido' })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao redefinir senha' })
|
||||
}
|
||||
})
|
||||
|
||||
// Obter engine ativa
|
||||
adminRouter.get('/engine', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
const storedEngine = (settings as any).baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite'
|
||||
res.json({ engine: storedEngine })
|
||||
} catch (err) {
|
||||
console.error('[Admin] Erro ao obter engine:', err)
|
||||
res.status(500).json({ error: 'Erro ao obter a engine ativa' })
|
||||
}
|
||||
})
|
||||
|
||||
// Altera a engine e reinicia o backend (via PM2 ou Docker)
|
||||
adminRouter.post('/engine', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { engine } = z.object({ engine: z.enum(['infinite', 'official']) }).parse(req.body)
|
||||
|
||||
// 1. Persiste nas configurações globais
|
||||
const settings = await getSettings()
|
||||
await dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0)
|
||||
|
||||
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
|
||||
const envPath = path.resolve('./.env')
|
||||
try {
|
||||
let content = await fs.readFile(envPath, 'utf-8')
|
||||
if (content.includes('BAILEYS_ENGINE=')) {
|
||||
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`)
|
||||
} else {
|
||||
content += `\nBAILEYS_ENGINE=${engine}\n`
|
||||
}
|
||||
await fs.writeFile(envPath, content, 'utf-8')
|
||||
} catch (envErr) {
|
||||
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr)
|
||||
}
|
||||
|
||||
// 3. Responde ao frontend antes de desligar/reiniciar
|
||||
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' })
|
||||
|
||||
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
|
||||
setTimeout(() => {
|
||||
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`)
|
||||
const { exec } = require('child_process')
|
||||
exec('pm2 restart newwhats-backend', (err: any) => {
|
||||
if (err) {
|
||||
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...')
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Admin] Erro ao alternar engine:', err)
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao salvar a engine' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rota para Disparo Manual do Deploy ──
|
||||
adminRouter.post('/deploys/trigger', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trigger: 'admin_panel' })
|
||||
})
|
||||
if (!response.ok) throw new Error(`Status ${response.status}`)
|
||||
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' })
|
||||
} catch (err: any) {
|
||||
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message)
|
||||
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
export class AuthRepository {
|
||||
async findByEmail(email: string) {
|
||||
return prisma.user.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
name: string
|
||||
email: string
|
||||
passwordHash: string
|
||||
role?: UserRole
|
||||
planId?: string
|
||||
}) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role ?? 'USER',
|
||||
planId: data.planId,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async updatePassword(id: string, passwordHash: string) {
|
||||
return prisma.user.update({ where: { id }, data: { passwordHash } })
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
return prisma.user.update({ where: { id }, data: { isActive } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AuthService } from './auth.service'
|
||||
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
|
||||
|
||||
const service = new AuthService()
|
||||
|
||||
// ─── Schemas de validação ─────────────────────────────────────────────────────
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
planId: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
})
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
// ─── Helper para erros de validação / serviço ─────────────────────────────────
|
||||
|
||||
function handleError(res: Response, err: unknown) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
|
||||
return
|
||||
}
|
||||
const e = err as Error & { statusCode?: number }
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authRouter = Router()
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Cria novo tenant (usuário com role USER).
|
||||
* Retorna access + refresh token e dados do usuário.
|
||||
*/
|
||||
authRouter.post('/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = registerSchema.parse(req.body) as any
|
||||
const result = await service.register(body)
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Autentica usuário. Retorna access + refresh token.
|
||||
*/
|
||||
authRouter.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = loginSchema.parse(req.body) as any
|
||||
const result = await service.login(body)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Renova o access token usando o refresh token.
|
||||
*/
|
||||
authRouter.post('/refresh', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { refreshToken } = refreshSchema.parse(req.body)
|
||||
const result = await service.refresh(refreshToken)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Revoga o refresh token do usuário autenticado.
|
||||
*/
|
||||
authRouter.post('/logout', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
await service.logout(req.tenantId!)
|
||||
res.json({ message: 'Sessão encerrada' })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Retorna dados do usuário autenticado + dias restantes do trial.
|
||||
*/
|
||||
authRouter.get('/me', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = await service.me(req.tenantId!)
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/auth/password
|
||||
* Altera a senha do usuário autenticado.
|
||||
*/
|
||||
authRouter.patch('/password', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = changePasswordSchema.parse(req.body) as any
|
||||
await service.changePassword(req.tenantId!, body)
|
||||
res.json({ message: 'Senha alterada com sucesso' })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { env } from '../../config/env'
|
||||
import { AuthRepository } from './auth.repository'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
|
||||
const BCRYPT_ROUNDS = 12
|
||||
const ACCESS_EXPIRES = '8h'
|
||||
const REFRESH_EXPIRES = '30d'
|
||||
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
interface TokenPair {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string
|
||||
role: string
|
||||
type: 'access' | 'refresh'
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private repo = new AuthRepository()
|
||||
|
||||
// ─── Register ─────────────────────────────────────────────────────────────
|
||||
|
||||
async register(input: {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
planId?: string
|
||||
}) {
|
||||
const existing = await this.repo.findByEmail(input.email)
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS)
|
||||
|
||||
const user = await this.repo.create({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
planId: input.planId,
|
||||
})
|
||||
|
||||
const tokens = this.issueTokens(user.id, user.role)
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken)
|
||||
|
||||
return { user, ...tokens }
|
||||
}
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
|
||||
async login(input: { email: string; password: string }) {
|
||||
const user = await this.repo.findByEmail(input.email)
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(input.password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
|
||||
}
|
||||
|
||||
const tokens = this.issueTokens(user.id, user.role)
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken)
|
||||
|
||||
const { passwordHash: _, ...safeUser } = user
|
||||
return { user: safeUser, ...tokens }
|
||||
}
|
||||
|
||||
// ─── Refresh ──────────────────────────────────────────────────────────────
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
let payload: JwtPayload
|
||||
try {
|
||||
payload = jwt.verify(refreshToken, env.JWT_SECRET) as JwtPayload
|
||||
} catch {
|
||||
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 })
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 })
|
||||
}
|
||||
|
||||
// Valida que o token ainda está no cache (não foi revogado)
|
||||
const stored = await dragonfly.get(this.refreshKey(payload.sub))
|
||||
if (stored !== refreshToken) {
|
||||
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 })
|
||||
}
|
||||
|
||||
const user = await this.repo.findById(payload.sub)
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 })
|
||||
}
|
||||
|
||||
const tokens = this.issueTokens(user.id, user.role)
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken)
|
||||
|
||||
return { user, ...tokens }
|
||||
}
|
||||
|
||||
// ─── Logout ───────────────────────────────────────────────────────────────
|
||||
|
||||
async logout(userId: string) {
|
||||
await dragonfly.del(this.refreshKey(userId))
|
||||
}
|
||||
|
||||
// ─── Me ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.repo.findById(userId)
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
|
||||
}
|
||||
|
||||
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null
|
||||
const daysRemaining = trialEndsAt
|
||||
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000))
|
||||
: null
|
||||
|
||||
return { ...user, daysRemaining }
|
||||
}
|
||||
|
||||
// ─── Change Password ──────────────────────────────────────────────────────
|
||||
|
||||
async changePassword(userId: string, input: { currentPassword: string; newPassword: string }) {
|
||||
const user = await this.repo.findByEmail(
|
||||
(await this.repo.findById(userId))?.email ?? ''
|
||||
)
|
||||
if (!user) throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
|
||||
|
||||
const valid = await bcrypt.compare(input.currentPassword, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 })
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(input.newPassword, BCRYPT_ROUNDS)
|
||||
await this.repo.updatePassword(userId, newHash)
|
||||
await this.logout(userId) // invalida todas as sessões
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private issueTokens(userId: string, role: string): TokenPair {
|
||||
const base = { sub: userId, role }
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
{ ...base, type: 'access' },
|
||||
env.JWT_SECRET,
|
||||
{ expiresIn: ACCESS_EXPIRES }
|
||||
)
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ ...base, type: 'refresh' },
|
||||
env.JWT_SECRET,
|
||||
{ expiresIn: REFRESH_EXPIRES }
|
||||
)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
private async saveRefreshToken(userId: string, token: string) {
|
||||
await dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS)
|
||||
}
|
||||
|
||||
private refreshKey(userId: string): string {
|
||||
return `auth:refresh:${userId}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
|
||||
/**
|
||||
* chatbot.repository.ts — CRUD para AICredential e AIBot.
|
||||
*/
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||
exports.credentialRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aICredential.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
provider: data.provider ?? 'GEMINI',
|
||||
apiKey: data.apiKey,
|
||||
},
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
}),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Bots ─────────────────────────────────────────────────────────────────────
|
||||
exports.botRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
include: { credential: { select: { id: true, name: true, provider: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { tenantId, instanceId, enabled: true },
|
||||
include: { credential: true },
|
||||
}),
|
||||
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { credential: true },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aIBot.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
credentialId: data.credentialId,
|
||||
name: data.name,
|
||||
systemPrompt: data.systemPrompt,
|
||||
model: data.model ?? 'gemini-1.5-flash',
|
||||
enabled: data.enabled ?? false,
|
||||
triggerMode: data.triggerMode ?? 'ALL',
|
||||
keywords: data.keywords ?? [],
|
||||
},
|
||||
}),
|
||||
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Chat bot state ───────────────────────────────────────────────────────────
|
||||
exports.chatBotState = {
|
||||
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
|
||||
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||
getState: (chatId) => prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { botPaused: true, botSummary: true },
|
||||
}),
|
||||
};
|
||||
//# sourceMappingURL=chatbot.repository.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"chatbot.repository.js","sourceRoot":"","sources":["chatbot.repository.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,wDAAoD;AAEpD,gFAAgF;AAEnE,QAAA,cAAc,GAAG;IAC5B,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,YAAY,CAAC,QAAQ,CAAC;QAC3B,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;QACjE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAMR,EAAE,EAAE,CACH,eAAM,CAAC,YAAY,CAAC,MAAM,CAAC;QACzB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,QAAQ;YACnC,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB;QACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;KAClE,CAAC;IAEJ,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,YAAY,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC9D,CAAA;AAED,iFAAiF;AAEpE,QAAA,OAAO,GAAG;IACrB,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACpB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE;QAC7E,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,WAAW,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE;QAC9C,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,QAAQ,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACzC,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;QACvB,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAUR,EAAE,EAAE,CACH,eAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAClB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,kBAAkB;YACvC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,KAAK;YAC9B,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,KAAK;YACtC,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;SAC9B;KACF,CAAC;IAEJ,MAAM,EAAE,CACN,EAAU,EACV,QAAgB,EAChB,IAQE,EACF,EAAE,CACF,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAE5D,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CACvD,CAAA;AAED,iFAAiF;AAEpE,QAAA,YAAY,GAAG;IAC1B,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;IAE1E,SAAS,EAAE,CAAC,MAAc,EAAE,EAAE,CAC5B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC;IAE3E,aAAa,EAAE,CAAC,MAAc,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,EAAE,CAAC;IAErE,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;QACrB,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9C,CAAC;CACL,CAAA"}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* chatbot.repository.ts — CRUD para AICredential e AIBot.
|
||||
*/
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const credentialRepo = {
|
||||
findAll: (tenantId: string, instanceId: string) =>
|
||||
prisma.aICredential.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
|
||||
create: (data: {
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
name: string
|
||||
provider?: 'GEMINI' | 'OPENAI'
|
||||
apiKey: string
|
||||
}) =>
|
||||
prisma.aICredential.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
provider: data.provider ?? 'GEMINI',
|
||||
apiKey: data.apiKey,
|
||||
},
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
}),
|
||||
|
||||
delete: (id: string, tenantId: string) =>
|
||||
prisma.aICredential.deleteMany({ where: { id, tenantId } }),
|
||||
}
|
||||
|
||||
// ─── Bots ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const botRepo = {
|
||||
findAll: (tenantId: string, instanceId: string) =>
|
||||
prisma.aIBot.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
include: { credential: { select: { id: true, name: true, provider: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
|
||||
findEnabled: (tenantId: string, instanceId: string) =>
|
||||
prisma.aIBot.findFirst({
|
||||
where: { tenantId, instanceId, enabled: true },
|
||||
include: { credential: true },
|
||||
}),
|
||||
|
||||
findById: (id: string, tenantId: string) =>
|
||||
prisma.aIBot.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { credential: true },
|
||||
}),
|
||||
|
||||
create: (data: {
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
credentialId: string
|
||||
name: string
|
||||
systemPrompt: string
|
||||
model?: string
|
||||
enabled?: boolean
|
||||
triggerMode?: 'ALL' | 'KEYWORD'
|
||||
keywords?: string[]
|
||||
}) =>
|
||||
prisma.aIBot.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
credentialId: data.credentialId,
|
||||
name: data.name,
|
||||
systemPrompt: data.systemPrompt,
|
||||
model: data.model ?? 'gemini-1.5-flash',
|
||||
enabled: data.enabled ?? false,
|
||||
triggerMode: data.triggerMode ?? 'ALL',
|
||||
keywords: data.keywords ?? [],
|
||||
},
|
||||
}),
|
||||
|
||||
update: (
|
||||
id: string,
|
||||
tenantId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
systemPrompt: string
|
||||
model: string
|
||||
enabled: boolean
|
||||
triggerMode: 'ALL' | 'KEYWORD'
|
||||
keywords: string[]
|
||||
credentialId: string
|
||||
}>
|
||||
) =>
|
||||
prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
|
||||
|
||||
delete: (id: string, tenantId: string) =>
|
||||
prisma.aIBot.deleteMany({ where: { id, tenantId } }),
|
||||
}
|
||||
|
||||
// ─── Chat bot state ───────────────────────────────────────────────────────────
|
||||
|
||||
export const chatBotState = {
|
||||
pauseBot: (chatId: string) =>
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||
|
||||
resumeBot: (chatId: string) =>
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false, botSummary: null } }),
|
||||
|
||||
updateSummary: (chatId: string, botSummary: string) =>
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||
|
||||
getState: (chatId: string) =>
|
||||
prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { botPaused: true, botSummary: true },
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
|
||||
*
|
||||
* Montado em /api/chatbot (ver server.ts)
|
||||
* Todos os endpoints requerem authMiddleware.
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { credentialRepo, botRepo, chatBotState } from './chatbot.repository'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
export function buildChatbotRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const validateInstance = async (instanceId: string, tenantId: string) => {
|
||||
return prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CREDENTIALS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/chatbot/credentials/:instanceId
|
||||
router.get('/credentials/:instanceId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' }); return
|
||||
}
|
||||
const creds = await credentialRepo.findAll(tenantId, instanceId)
|
||||
res.json(creds)
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao listar credenciais')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/chatbot/credentials/:instanceId
|
||||
router.post('/credentials/:instanceId', async (req: Request, res: Response) => {
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
apiKey: z.string().min(10),
|
||||
provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
|
||||
})
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' }); return
|
||||
}
|
||||
const body = schema.parse(req.body)
|
||||
const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any)
|
||||
res.status(201).json(cred)
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
||||
logger.error({ err }, 'Erro ao criar credencial')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/chatbot/credentials/:instanceId/:credId
|
||||
router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const credId = req.params['credId'] as string
|
||||
await credentialRepo.delete(credId, tenantId)
|
||||
res.status(204).send()
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao deletar credencial')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOTS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/chatbot/bots/:instanceId
|
||||
router.get('/bots/:instanceId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' }); return
|
||||
}
|
||||
const bots = await botRepo.findAll(tenantId, instanceId)
|
||||
res.json(bots)
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao listar bots')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/chatbot/bots/:instanceId
|
||||
router.post('/bots/:instanceId', async (req: Request, res: Response) => {
|
||||
const schema = z.object({
|
||||
credentialId: z.string().uuid(),
|
||||
name: z.string().min(1).max(80),
|
||||
systemPrompt: z.string().min(10),
|
||||
model: z.string().default('gemini-1.5-flash'),
|
||||
enabled: z.boolean().default(false),
|
||||
triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
})
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' }); return
|
||||
}
|
||||
const body = schema.parse(req.body)
|
||||
const existing = await botRepo.findAll(tenantId, instanceId)
|
||||
if (existing.length > 0) {
|
||||
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
|
||||
return
|
||||
}
|
||||
// SEC-10: garante que a credencial pertence a este tenant
|
||||
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
|
||||
if (!cred) {
|
||||
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
|
||||
return
|
||||
}
|
||||
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
|
||||
res.status(201).json(bot)
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
||||
logger.error({ err }, 'Erro ao criar bot')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/chatbot/bots/:instanceId/:botId
|
||||
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
|
||||
const schema = z.object({
|
||||
credentialId: z.string().uuid().optional(),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
systemPrompt: z.string().min(10).optional(),
|
||||
model: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
})
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const botId = req.params['botId'] as string
|
||||
const body = schema.parse(req.body)
|
||||
await botRepo.update(botId, tenantId, body as any)
|
||||
const updated = await botRepo.findById(botId, tenantId)
|
||||
res.json(updated)
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
||||
logger.error({ err }, 'Erro ao atualizar bot')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/chatbot/bots/:instanceId/:botId
|
||||
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const botId = req.params['botId'] as string
|
||||
await botRepo.delete(botId, tenantId)
|
||||
res.status(204).send()
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao deletar bot')
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOT STATE — Human Takeover por chat
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
|
||||
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await chatBotState.pauseBot(req.params['chatId'] as string)
|
||||
res.json({ paused: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
|
||||
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await chatBotState.resumeBot(req.params['chatId'] as string)
|
||||
res.json({ paused: false })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatbotService = void 0;
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
|
||||
*
|
||||
* Padrão Cérebro (instrucoes.md §5):
|
||||
* - Não envia histórico completo para economizar tokens
|
||||
* - Mantém resumo de 2 frases por chat (botSummary no banco)
|
||||
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
|
||||
*/
|
||||
const generative_ai_1 = require("@google/generative-ai");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const chatbot_repository_1 = require("./chatbot.repository");
|
||||
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
|
||||
const geminiClients = new Map();
|
||||
function getGeminiClient(apiKey) {
|
||||
if (!geminiClients.has(apiKey)) {
|
||||
geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey));
|
||||
}
|
||||
return geminiClients.get(apiKey);
|
||||
}
|
||||
function getModel(apiKey, modelName) {
|
||||
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName });
|
||||
}
|
||||
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
|
||||
async function classifyIntent(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa até agora: ${summary}` : '',
|
||||
`Nova mensagem do cliente: "${userMsg}"`,
|
||||
`Responda APENAS com o número:`,
|
||||
`1 = Consigo responder essa pergunta dentro do meu papel`,
|
||||
`2 = Precisa de um agente humano`,
|
||||
].filter(Boolean).join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 2, temperature: 0 },
|
||||
});
|
||||
const text = result.response.text().trim();
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve';
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve');
|
||||
return 'resolve';
|
||||
}
|
||||
}
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
async function generateResponse(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
systemPrompt,
|
||||
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
|
||||
`\nMensagem do cliente: "${userMsg}"`,
|
||||
`\nResponda de forma natural, breve e direta. Não use markdown.`,
|
||||
].filter(Boolean).join('\n');
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
async function updateSummary(currentSummary, userMsg, botMsg, model) {
|
||||
const prompt = [
|
||||
currentSummary
|
||||
? `Resumo atual da conversa: ${currentSummary}`
|
||||
: 'Esta é a primeira troca da conversa.',
|
||||
`Nova troca:`,
|
||||
`Cliente: "${userMsg}"`,
|
||||
`Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
|
||||
`Responda apenas com o resumo, sem introdução.`,
|
||||
].join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
catch {
|
||||
// Em caso de erro, mantém o resumo anterior
|
||||
return currentSummary ?? '';
|
||||
}
|
||||
}
|
||||
// ─── Interface pública ────────────────────────────────────────────────────────
|
||||
class ChatbotService {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
}
|
||||
/**
|
||||
* Ponto de entrada para cada mensagem recebida.
|
||||
* Chamado pelo MessageHandler após persistir a mensagem.
|
||||
*/
|
||||
async handleIncoming(opts) {
|
||||
const { tenantId, instanceId, chatId, jid, text, sock } = opts;
|
||||
// 1. Verifica se há bot ativo para esta instância
|
||||
const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId);
|
||||
if (!bot)
|
||||
return;
|
||||
// 2. Verifica o estado do chat (human takeover)
|
||||
const chatState = await chatbot_repository_1.chatBotState.getState(chatId);
|
||||
if (!chatState || chatState.botPaused)
|
||||
return;
|
||||
// 3. Aplica modo de gatilho
|
||||
if (bot.triggerMode === 'KEYWORD') {
|
||||
const lowerText = text.toLowerCase();
|
||||
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
|
||||
if (!hasKeyword)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const model = getModel(bot.credential.apiKey, bot.model);
|
||||
const summary = chatState.botSummary ?? null;
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent(text, summary, bot.systemPrompt, model);
|
||||
if (intent === 'escalate') {
|
||||
// Pausa o bot e notifica via Socket.IO
|
||||
await chatbot_repository_1.chatBotState.pauseBot(chatId);
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
message: 'Bot pausado — atendente humano necessário',
|
||||
});
|
||||
logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano');
|
||||
return;
|
||||
}
|
||||
// 5. Gera resposta
|
||||
const responseText = await generateResponse(text, summary, bot.systemPrompt, model);
|
||||
// 6. Envia via Baileys
|
||||
const sent = await sock.sendMessage(jid, { text: responseText });
|
||||
// 7. Persiste mensagem do bot no banco
|
||||
const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (chat) {
|
||||
const botMsg = await prisma_1.prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId,
|
||||
remoteJid: jid,
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: responseText,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
// Notifica frontend
|
||||
this.io.to(`chat:${chatId}`).emit('message:new', botMsg);
|
||||
}
|
||||
// 8. Atualiza Cérebro
|
||||
const newSummary = await updateSummary(summary, text, responseText, model);
|
||||
await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary);
|
||||
logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ChatbotService = ChatbotService;
|
||||
//# sourceMappingURL=chatbot.service.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido).
|
||||
*
|
||||
* Padrão Cérebro (instrucoes.md §5):
|
||||
* - Não envia histórico completo para economizar tokens
|
||||
* - Mantém resumo de 2 frases por chat (botSummary no banco)
|
||||
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
|
||||
*
|
||||
* Providers suportados (SEC-06/SEC-07):
|
||||
* - GEMINI → Google Generative AI (via SDK)
|
||||
* - OPENAI → OpenAI Chat Completions (via fetch)
|
||||
* O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois.
|
||||
* A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama
|
||||
* via configuração de plugin — divergência intencional, pois os dois sistemas
|
||||
* têm arquiteturas independentes.
|
||||
*/
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
import type { WASocket } from '../whatsapp/engine'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
import { botRepo, chatBotState } from './chatbot.repository'
|
||||
import type { Server as SocketIOServer } from 'socket.io'
|
||||
|
||||
// ─── Cache de clientes Gemini ─────────────────────────────────────────────────
|
||||
|
||||
const geminiClients = new Map<string, GoogleGenerativeAI>()
|
||||
|
||||
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
|
||||
if (!geminiClients.has(apiKey)) {
|
||||
geminiClients.set(apiKey, new GoogleGenerativeAI(apiKey))
|
||||
}
|
||||
return geminiClients.get(apiKey)!
|
||||
}
|
||||
|
||||
// ─── Abstração de provider (SEC-06) ──────────────────────────────────────────
|
||||
|
||||
type Provider = 'GEMINI' | 'OPENAI'
|
||||
|
||||
interface AiResult {
|
||||
text: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
async function aiCall(opts: {
|
||||
provider: Provider
|
||||
apiKey: string
|
||||
model: string
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts
|
||||
|
||||
if (provider === 'OPENAI') {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
return {
|
||||
text: (data.choices[0].message.content as string).trim(),
|
||||
inputTokens: data.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: data.usage?.completion_tokens ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Default: GEMINI
|
||||
const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' })
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }],
|
||||
generationConfig: { maxOutputTokens: maxTokens, temperature },
|
||||
})
|
||||
const usage = result.response.usageMetadata
|
||||
return {
|
||||
text: result.response.text().trim(),
|
||||
inputTokens: usage?.promptTokenCount ?? 0,
|
||||
outputTokens: usage?.candidatesTokenCount ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Classificação de intenção ────────────────────────────────────────────────
|
||||
|
||||
async function classifyIntent(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<'resolve' | 'escalate'> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa: ${summary}` : '',
|
||||
`Nova mensagem do cliente: "${userMsg}"`,
|
||||
`Responda APENAS com o número: 1 = resolver, 2 = escalar humano`,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
try {
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 })
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve'
|
||||
} catch (err) {
|
||||
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
|
||||
return 'resolve'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
|
||||
async function generateResponse(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
summary ? `Contexto da conversa até agora:\n${summary}` : '',
|
||||
`Mensagem do cliente: "${userMsg}"`,
|
||||
`Responda de forma natural, breve e direta. Não use markdown.`,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 })
|
||||
}
|
||||
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
|
||||
async function updateSummary(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
currentSummary: string | null; userMsg: string; botMsg: string
|
||||
}): Promise<string> {
|
||||
const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts
|
||||
const userPrompt = [
|
||||
currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.',
|
||||
`Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases. Responda só com o resumo, sem introdução.`,
|
||||
].join('\n')
|
||||
|
||||
try {
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 })
|
||||
return text
|
||||
} catch {
|
||||
return currentSummary ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Interface pública ────────────────────────────────────────────────────────
|
||||
|
||||
export class ChatbotService {
|
||||
constructor(private io: SocketIOServer) {}
|
||||
|
||||
/**
|
||||
* Ponto de entrada para cada mensagem recebida.
|
||||
* Chamado pelo MessageHandler após persistir a mensagem.
|
||||
*/
|
||||
async handleIncoming(opts: {
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
chatId: string
|
||||
jid: string
|
||||
text: string
|
||||
sock: WASocket
|
||||
}): Promise<void> {
|
||||
const { tenantId, instanceId, chatId, jid, text, sock } = opts
|
||||
|
||||
// 1. Verifica se há bot ativo para esta instância
|
||||
const bot = await botRepo.findEnabled(tenantId, instanceId)
|
||||
if (!bot) return
|
||||
|
||||
// 2. Verifica o estado do chat (human takeover)
|
||||
const chatState = await chatBotState.getState(chatId)
|
||||
if (!chatState || chatState.botPaused) return
|
||||
|
||||
// 3. Aplica modo de gatilho
|
||||
if (bot.triggerMode === 'KEYWORD') {
|
||||
const lowerText = text.toLowerCase()
|
||||
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()))
|
||||
if (!hasKeyword) return
|
||||
}
|
||||
|
||||
const provider = (bot.credential.provider ?? 'GEMINI') as Provider
|
||||
const { apiKey } = bot.credential
|
||||
const { model } = bot
|
||||
const summary = chatState.botSummary ?? null
|
||||
|
||||
try {
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
|
||||
if (intent === 'escalate') {
|
||||
await chatBotState.pauseBot(chatId)
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
message: 'Bot pausado — atendente humano necessário',
|
||||
})
|
||||
logger.info({ chatId, jid }, '[Chatbot] Escalação para humano')
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Indicador de digitação (SEC-14)
|
||||
await sock.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
// 6. Gera resposta (com fallback para outro provider se falhar)
|
||||
let result: AiResult
|
||||
try {
|
||||
result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
} catch (primaryErr) {
|
||||
// Fallback: tenta outra credencial da mesma instância com provider diferente
|
||||
const fallbackCred = await prisma.aICredential.findFirst({
|
||||
where: { tenantId, instanceId, NOT: { id: bot.credentialId } },
|
||||
})
|
||||
if (!fallbackCred) throw primaryErr
|
||||
logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback')
|
||||
const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider
|
||||
result = await generateResponse({
|
||||
provider: fbProvider, apiKey: fallbackCred.apiKey, model,
|
||||
userMsg: text, summary, systemPrompt: bot.systemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Para indicador de digitação + envia
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
const sent = await sock.sendMessage(jid, { text: result.text })
|
||||
|
||||
// 8. Persiste mensagem do bot no banco
|
||||
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
|
||||
if (chat) {
|
||||
const botMsg = await prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId,
|
||||
remoteJid: jid,
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: result.text,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
})
|
||||
this.io.to(`chat:${chatId}`).emit('message:new', botMsg)
|
||||
}
|
||||
|
||||
// 9. Atualiza Cérebro + telemetria (SEC-13)
|
||||
const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text })
|
||||
await chatBotState.updateSummary(chatId, newSummary)
|
||||
|
||||
logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada')
|
||||
} catch (err) {
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.invalidateChatListCache = invalidateChatListCache;
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
async function invalidateChatListCache(tenantId, instanceId) {
|
||||
if (!dragonfly_1.dragonfly.isAlive())
|
||||
return;
|
||||
try {
|
||||
const client = dragonfly_1.dragonfly.raw();
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
|
||||
const stream = client.scanStream({ match: pattern, count: 100 });
|
||||
const keys = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (batch) => {
|
||||
for (const k of batch)
|
||||
keys.push(k);
|
||||
});
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=chat.cache.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"chat.cache.js","sourceRoot":"","sources":["chat.cache.ts"],"names":[],"mappings":";;AAiBA,0DAsBC;AAvCD;;;;;;;;GAQG;AACH,2DAAuD;AACvD,gDAA4C;AAE5C;;;;GAIG;AACI,KAAK,UAAU,uBAAuB,CAAC,QAAgB,EAAE,UAAkB;IAChF,IAAI,CAAC,qBAAS,CAAC,OAAO,EAAE;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,qBAAS,CAAC,GAAG,EAAE,CAAA;QAC9B,MAAM,OAAO,GAAG,cAAc,QAAQ,IAAI,UAAU,IAAI,CAAA;QACxD,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,GAAa,EAAE,CAAA;QAEzB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAe,EAAE,EAAE;gBACpC,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACrC,CAAC,CAAC,CAAA;YACF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACjC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QAEF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,eAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,kDAAkD,CAAC,CAAA;IAChG,CAAC;AACH,CAAC"}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
export async function invalidateChatListCache(tenantId: string, instanceId: string): Promise<void> {
|
||||
if (!dragonfly.isAlive()) return
|
||||
try {
|
||||
const client = dragonfly.raw()
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`
|
||||
const stream = client.scanStream({ match: pattern, count: 100 })
|
||||
const keys: string[] = []
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (batch: string[]) => {
|
||||
for (const k of batch) keys.push(k)
|
||||
})
|
||||
stream.on('end', () => resolve())
|
||||
stream.on('error', (err) => reject(err))
|
||||
})
|
||||
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
interface ContactSlim {
|
||||
id: string
|
||||
name: string | null
|
||||
notify: string | null
|
||||
verifiedName: string | null
|
||||
phone: string | null
|
||||
avatarUrl: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
}
|
||||
|
||||
interface ProtocolSlim {
|
||||
chatId: string
|
||||
number: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface LastMsgSlim {
|
||||
chatId: string
|
||||
body: string | null
|
||||
fromMe: boolean
|
||||
status: string
|
||||
timestamp: Date
|
||||
type: string
|
||||
pushName: string | null
|
||||
}
|
||||
|
||||
export class ChatRepository {
|
||||
async findAllByInstance(tenantId: string, instanceId: string, opts: {
|
||||
limit?: number
|
||||
search?: string
|
||||
archived?: boolean
|
||||
} = {}) {
|
||||
const { limit = 200, search, archived = false } = opts
|
||||
|
||||
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: archived,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
]},
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ jid: { contains: search, mode: 'insensitive' as const } },
|
||||
{ contact: { name: { contains: search, mode: 'insensitive' as const } } },
|
||||
{ contact: { notify: { contains: search, mode: 'insensitive' as const } } },
|
||||
{ contact: { phone: { contains: search, mode: 'insensitive' as const } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
})
|
||||
|
||||
if (chats.length === 0) {
|
||||
return [] as Array<typeof chats[number] & {
|
||||
contact: ContactSlim | null
|
||||
protocols: Array<{ number: string; status: string }>
|
||||
messages: Array<Omit<LastMsgSlim, 'chatId'>>
|
||||
}>
|
||||
}
|
||||
|
||||
const chatIds = chats.map((c) => c.id)
|
||||
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x): x is string => !!x)))
|
||||
|
||||
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
|
||||
const [contacts, protocols, lastMsgs] = await Promise.all([
|
||||
contactIds.length > 0
|
||||
? prisma.contact.findMany({
|
||||
where: { id: { in: contactIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
notify: true,
|
||||
verifiedName: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([] as ContactSlim[]),
|
||||
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
|
||||
prisma.$queryRaw<ProtocolSlim[]>`
|
||||
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
|
||||
FROM protocols
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
|
||||
ORDER BY "chatId", "createdAt" DESC
|
||||
`,
|
||||
// DISTINCT ON: 1 query batch — pega a última msg por chat
|
||||
prisma.$queryRaw<LastMsgSlim[]>`
|
||||
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
|
||||
FROM messages
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
ORDER BY "chatId", timestamp DESC
|
||||
`,
|
||||
])
|
||||
|
||||
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
|
||||
const contactById = new Map<string, ContactSlim>(contacts.map((c) => [c.id, c]))
|
||||
const protocolByChat = new Map<string, ProtocolSlim>(protocols.map((p) => [p.chatId, p]))
|
||||
const lastMsgByChat = new Map<string, LastMsgSlim>(lastMsgs.map((m) => [m.chatId, m]))
|
||||
|
||||
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
|
||||
return chats.map((c) => {
|
||||
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null
|
||||
const protocol = protocolByChat.get(c.id)
|
||||
const lastMsg = lastMsgByChat.get(c.id)
|
||||
|
||||
return {
|
||||
...c,
|
||||
contact: contact
|
||||
? {
|
||||
name: contact.name,
|
||||
notify: contact.notify,
|
||||
verifiedName: contact.verifiedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: null,
|
||||
protocols: protocol
|
||||
? [{ number: protocol.number, status: protocol.status }]
|
||||
: [],
|
||||
messages: lastMsg
|
||||
? [{
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
timestamp: lastMsg.timestamp,
|
||||
type: lastMsg.type,
|
||||
pushName: lastMsg.pushName,
|
||||
}]
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string) {
|
||||
return prisma.chat.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
contact: true,
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findByJid(tenantId: string, instanceId: string, jid: string) {
|
||||
return prisma.chat.findUnique({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
include: { contact: true },
|
||||
})
|
||||
}
|
||||
|
||||
async clearUnread(id: string, tenantId: string) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { unreadCount: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
async setArchived(id: string, tenantId: string, archived: boolean) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isArchived: archived },
|
||||
})
|
||||
}
|
||||
|
||||
async setPinned(id: string, tenantId: string, pinned: boolean) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isPinned: pinned },
|
||||
})
|
||||
}
|
||||
|
||||
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
|
||||
const result = await prisma.chat.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
]}
|
||||
},
|
||||
_sum: { unreadCount: true },
|
||||
})
|
||||
return result._sum.unreadCount ?? 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Rotas de Chats — API REST para a inbox do frontend.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/chats — Lista chats (com filtros e último msg)
|
||||
* GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
* GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
* GET /api/chats/:id — Detalhe de um chat
|
||||
* POST /api/chats/:id/read — Zera unread_count
|
||||
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
|
||||
* PATCH /api/chats/:id/pin — Fixa/desfixa
|
||||
* DELETE /api/chats/:id — Remove chat e mensagens
|
||||
* GET /api/chats/:id/messages — Histórico paginado por cursor
|
||||
* GET /api/chats/:id/protocol — Protocolos do chat
|
||||
* POST /api/chats/:id/protocol — Abre novo protocolo
|
||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
||||
*
|
||||
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { ChatRepository } from './chat.repository'
|
||||
import { MessageRepository } from './message.repository'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { invalidateChatListCache } from './chat.cache'
|
||||
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
||||
|
||||
const chatRepo = new ChatRepository()
|
||||
const msgRepo = new MessageRepository()
|
||||
|
||||
/** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */
|
||||
const CHAT_LIST_CACHE_TTL_S = 86400
|
||||
|
||||
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
|
||||
function handleError(res: Response, err: unknown) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: (err as Error).message ?? 'Erro interno' })
|
||||
}
|
||||
|
||||
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
|
||||
function chatListCacheKey(tenantId: string, instanceId: string, archived: boolean, limit: number): string {
|
||||
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`
|
||||
}
|
||||
|
||||
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
const router = Router()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats — Lista chats com snapshot da última mensagem
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId, search, archived, limit } = z.object({
|
||||
instanceId: z.string().uuid(),
|
||||
search: z.string().optional(),
|
||||
archived: z.coerce.boolean().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const archivedFlag = archived ?? false
|
||||
const limitVal = limit ?? 200
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
// ── Cache hit: só quando não houver search (busca tem termos únicos) ──
|
||||
const useCache = !search
|
||||
let cacheKey: string | null = null
|
||||
if (useCache) {
|
||||
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal)
|
||||
const cached = await dragonfly.getJson<unknown[]>(cacheKey)
|
||||
if (cached) {
|
||||
res.setHeader('X-Cache', 'HIT')
|
||||
res.json(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const chats = await chatRepo.findAllByInstance(tenantId, instanceId, {
|
||||
search,
|
||||
archived,
|
||||
limit,
|
||||
})
|
||||
|
||||
const result = chats.map((c) => {
|
||||
const contact = c.contact
|
||||
const isGroup = c.jid.endsWith('@g.us')
|
||||
|
||||
const lastMsg = c.messages[0]
|
||||
const activeProtocol = c.protocols[0] ?? null
|
||||
|
||||
const resolvedName = isGroup
|
||||
? (c.name ?? null)
|
||||
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
||||
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
|
||||
?? null)
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
instanceId: c.instanceId,
|
||||
jid: c.jid,
|
||||
name: c.name ?? null,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
unreadCount: c.unreadCount,
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
createdAt: c.createdAt,
|
||||
contact: contact
|
||||
? {
|
||||
name: resolvedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
|
||||
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
|
||||
avatarVersion: contact.avatarUrl
|
||||
? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
|
||||
: null,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage: lastMsg
|
||||
? {
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
type: lastMsg.type,
|
||||
timestamp: lastMsg.timestamp,
|
||||
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
|
||||
}
|
||||
: null,
|
||||
protocol: activeProtocol
|
||||
? { number: activeProtocol.number, status: activeProtocol.status }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
if (useCache && cacheKey) {
|
||||
// fire-and-forget — não bloqueia a resposta
|
||||
dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => {})
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS')
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/search/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/search/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId, q, limit } = z.object({
|
||||
instanceId: z.string().uuid(),
|
||||
q: z.string().min(2),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const results = await msgRepo.search(req.tenantId!, instanceId, q, limit)
|
||||
res.json(results)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/stats/overview
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/stats/overview', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query)
|
||||
|
||||
const cacheKey = `stats:${req.tenantId}:${instanceId}`
|
||||
const cached = await dragonfly.getJson(cacheKey)
|
||||
if (cached) { res.json(cached); return }
|
||||
|
||||
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
||||
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
|
||||
prisma.protocol.count({
|
||||
where: {
|
||||
tenantId: req.tenantId!,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
}),
|
||||
prisma.chat.count({
|
||||
where: {
|
||||
tenantId: req.tenantId!,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { jid: { endsWith: '@lid' } }
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const stats = { totalUnread, openProtocols, totalChats }
|
||||
await dragonfly.setJson(cacheKey, stats, 30)
|
||||
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
res.json(chat)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/chats/:id/read
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.post('/:id/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.clearUnread(id, tenantId)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/archive
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/archive', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { archived } = z.object({ archived: z.boolean() }).parse(req.body)
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.setArchived(id, tenantId, archived)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/pin
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/pin', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body)
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.setPinned(id, tenantId, pinned)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
await prisma.message.deleteMany({ where: { chatId: id } })
|
||||
await prisma.chat.delete({ where: { id } })
|
||||
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { before, after, limit } = z.object({
|
||||
before: z.string().datetime().optional(),
|
||||
after: z.string().datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
|
||||
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
})
|
||||
|
||||
res.json({ messages, hasMore })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROTOCOLOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/protocol', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
|
||||
const protocols = await prisma.protocol.findMany({
|
||||
where: { chatId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
sector: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
res.json(protocols)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/protocol', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { sectorId, teamId } = z.object({
|
||||
sectorId: z.string().uuid().optional(),
|
||||
teamId: z.string().uuid().optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return }
|
||||
|
||||
const count = await prisma.protocol.count({ where: { chatId: id } })
|
||||
const year = new Date().getFullYear()
|
||||
const number = `#${String(count + 1).padStart(4, '0')}-${year}`
|
||||
|
||||
const protocol = await prisma.protocol.create({
|
||||
data: {
|
||||
tenantId: req.tenantId!,
|
||||
number,
|
||||
chatId: id,
|
||||
contactId: chat.contactId,
|
||||
sectorId,
|
||||
teamId,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})
|
||||
|
||||
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
res.status(201).json(protocol)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chatId = req.params['chatId'] as string
|
||||
const protocolId = req.params['protocolId'] as string
|
||||
const { status, summary } = z.object({
|
||||
status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
|
||||
summary: z.string().max(500).optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const protocol = await prisma.protocol.updateMany({
|
||||
where: { id: protocolId, chatId, tenantId: req.tenantId! },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(summary && { summary }),
|
||||
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
|
||||
},
|
||||
})
|
||||
|
||||
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId! }, select: { instanceId: true } })
|
||||
if (chat) invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
|
||||
res.json({ updated: protocol.count })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export class MessageRepository {
|
||||
/**
|
||||
* Histórico paginado por cursor (timestamp).
|
||||
* Retorna `limit` mensagens anteriores ao cursor dado.
|
||||
* Se cursor não for fornecido, retorna as mais recentes.
|
||||
*/
|
||||
async findByChatPaginated(chatId: string, opts: {
|
||||
limit?: number
|
||||
before?: string // timestamp ISO — cursor de paginação
|
||||
after?: string // timestamp ISO — buscar mensagens mais novas
|
||||
} = {}) {
|
||||
const { limit = 40, before, after } = opts
|
||||
|
||||
const where: Record<string, unknown> = { chatId }
|
||||
|
||||
if (before) where['timestamp'] = { lt: new Date(before) }
|
||||
if (after) where['timestamp'] = { gt: new Date(after) }
|
||||
|
||||
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
|
||||
// para retornar as mensagens mais recentes em ordem cronológica.
|
||||
// Scroll-down (after): busca ASC naturalmente.
|
||||
const needsReverse = !after
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
|
||||
take: limit,
|
||||
include: {
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
body: true,
|
||||
fromMe: true,
|
||||
type: true,
|
||||
mediaUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Inverte para ordem cronológica (mais antiga → mais recente)
|
||||
if (needsReverse) messages.reverse()
|
||||
|
||||
const hasMore = messages.length === limit
|
||||
|
||||
return { messages, hasMore }
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return prisma.message.findUnique({ where: { id } })
|
||||
}
|
||||
|
||||
async search(tenantId: string, instanceId: string, query: string, limit = 20) {
|
||||
return prisma.message.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
body: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include: {
|
||||
chat: {
|
||||
select: {
|
||||
jid: true,
|
||||
contact: { select: { name: true, notify: true, avatarUrl: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
async updateStatus(messageId: string, instanceId: string, status: string) {
|
||||
return prisma.message.updateMany({
|
||||
where: { messageId, instanceId },
|
||||
data: { status: status as any },
|
||||
})
|
||||
}
|
||||
|
||||
async countByChat(chatId: string): Promise<number> {
|
||||
return prisma.message.count({ where: { chatId } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { normalizeJid } from '../../shared/utils/whatsapp'
|
||||
import { invalidateChatListCache } from '../chats/chat.cache'
|
||||
|
||||
export function buildContactRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
|
||||
router.get('/stats/overview', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.query['instanceId'] as string | undefined
|
||||
|
||||
const where: Record<string, unknown> = { tenantId }
|
||||
if (instanceId) where['instanceId'] = instanceId
|
||||
|
||||
const [total, blocked, flagged] = await Promise.all([
|
||||
prisma.contact.count({ where }),
|
||||
prisma.contact.count({ where: { ...where, isBlocked: true } }),
|
||||
prisma.contact.count({ where: { ...where, flagRestricao: true } }),
|
||||
])
|
||||
|
||||
res.json({ total, blocked, flagged, active: total - blocked })
|
||||
})
|
||||
|
||||
// ── GET /api/contacts — lista paginada ────────────────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.query['instanceId'] as string | undefined
|
||||
const search = req.query['search'] as string | undefined
|
||||
const blocked = req.query['blocked'] as string | undefined
|
||||
const limit = Math.min(Number(req.query['limit'] ?? 50), 200)
|
||||
const offset = Number(req.query['offset'] ?? 0)
|
||||
|
||||
const where: Record<string, unknown> = { tenantId }
|
||||
if (instanceId) where['instanceId'] = instanceId
|
||||
if (blocked === 'true') where['isBlocked'] = true
|
||||
if (blocked === 'false') where['isBlocked'] = false
|
||||
if (search) {
|
||||
where['OR'] = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ verifiedName: { contains: search, mode: 'insensitive' } },
|
||||
{ notify: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search } },
|
||||
{ jid: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
const [total, contacts] = await Promise.all([
|
||||
prisma.contact.count({ where }),
|
||||
prisma.contact.findMany({
|
||||
where,
|
||||
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
|
||||
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
|
||||
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
instanceId: true,
|
||||
jid: true,
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
isBlocked: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
res.json({
|
||||
total,
|
||||
contacts: contacts.map(c => ({
|
||||
...c,
|
||||
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : normalizeJid(c.jid).split('@')[0])
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = req.params['id'] as string
|
||||
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
chats: {
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
take: 1,
|
||||
select: { id: true, unreadCount: true, lastMessageAt: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!contact) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json(contact)
|
||||
})
|
||||
|
||||
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
|
||||
router.patch('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = req.params['id'] as string
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
isBlocked: z.boolean().optional(),
|
||||
})
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try {
|
||||
body = schema.parse(req.body)
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.errors ?? err.message })
|
||||
return
|
||||
}
|
||||
|
||||
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.contact.update({ where: { id }, data: body })
|
||||
|
||||
if (body.name && existing.instanceId) {
|
||||
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = req.params['id'] as string
|
||||
|
||||
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
// Desvincular chats antes de deletar o contato
|
||||
await prisma.chat.updateMany({
|
||||
where: { contactId: id },
|
||||
data: { contactId: null },
|
||||
})
|
||||
|
||||
await prisma.contact.delete({ where: { id } })
|
||||
|
||||
if (existing.instanceId) {
|
||||
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { PairService } from './pair.service'
|
||||
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
|
||||
|
||||
const service = new PairService()
|
||||
|
||||
function handleError(res: Response, err: unknown) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
|
||||
return
|
||||
}
|
||||
const e = err as Error & { statusCode?: number }
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
|
||||
}
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const requestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
clientSystem: z.string().min(1).max(100),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientUrl: z.string().url().nullable().optional(),
|
||||
})
|
||||
|
||||
const confirmSchema = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string().length(6),
|
||||
clientSystem: z.string().min(1).max(100),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientUrl: z.string().url().nullable().optional(),
|
||||
})
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const pairRouter = Router()
|
||||
|
||||
/**
|
||||
* POST /api/pair/request
|
||||
* Inicia pareamento: gera OTP e envia por e-mail.
|
||||
* Público (chamado pelo sistema cliente).
|
||||
*/
|
||||
pairRouter.post('/request', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = requestSchema.parse(req.body) as any
|
||||
const result = await service.request({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
})
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/pair/confirm
|
||||
* Confirma o OTP e retorna a integration_key.
|
||||
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
|
||||
*/
|
||||
pairRouter.post('/confirm', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = confirmSchema.parse(req.body) as any
|
||||
const result = await service.confirm({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
})
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/pair/verify
|
||||
* Verifica se uma integration_key é válida.
|
||||
* Público — autenticado pelo header x-nw-key.
|
||||
*/
|
||||
pairRouter.get('/verify', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.headers['x-nw-key']
|
||||
const key = Array.isArray(raw) ? raw[0] : raw
|
||||
if (!key) {
|
||||
res.status(401).json({ error: 'Header x-nw-key ausente' })
|
||||
return
|
||||
}
|
||||
const result = await service.verify(key)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/pair/connections
|
||||
* Lista pares ativos do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
pairRouter.get('/connections', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pairs = await service.list(req.tenantId!)
|
||||
res.json(pairs)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/pair/connections/:id
|
||||
* Revoga um par do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
pairRouter.delete('/connections/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await service.revoke(req.tenantId!, String(req.params.id))
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import crypto from 'crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
const OTP_TTL = 10 * 60 // 10 minutos
|
||||
const KEY_TTL = 90 * 24 * 3600 // 90 dias
|
||||
|
||||
interface OtpPayload {
|
||||
code: string
|
||||
clientSystem: string
|
||||
clientName: string
|
||||
clientUrl: string | null
|
||||
}
|
||||
|
||||
interface KeyPayload {
|
||||
userId: string
|
||||
email: string
|
||||
clientSystem: string
|
||||
clientName: string
|
||||
}
|
||||
|
||||
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
|
||||
|
||||
const otpKey = (email: string) => `pair:otp:${email}`
|
||||
const intKey = (integrationKey: string) => `pair:key:${integrationKey}`
|
||||
|
||||
// ─── Email sender ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendOtpEmail(to: string, code: string, clientName: string): Promise<boolean> {
|
||||
// Lê config SMTP do admin settings
|
||||
const raw = await dragonfly.getJson<any>('system:settings')
|
||||
const smtp = raw?.smtp ?? {}
|
||||
|
||||
if (!smtp.host || !smtp.user) {
|
||||
logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.')
|
||||
logger.info(`[pair] OTP para ${to}: ${code}`) // fallback: loga localmente
|
||||
return false
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port ?? 587,
|
||||
secure: smtp.secure ?? false,
|
||||
auth: { user: smtp.user, pass: smtp.password },
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtp.from ?? smtp.user,
|
||||
to,
|
||||
subject: `[NewWhats] Código de pareamento — ${clientName}`,
|
||||
text: [
|
||||
`Olá!`,
|
||||
``,
|
||||
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
|
||||
``,
|
||||
`Seu código de confirmação: ${code}`,
|
||||
``,
|
||||
`Válido por 10 minutos.`,
|
||||
``,
|
||||
`Se você não solicitou isso, ignore este email.`,
|
||||
].join('\n'),
|
||||
html: `
|
||||
<p>Olá!</p>
|
||||
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
|
||||
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
|
||||
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
|
||||
`,
|
||||
})
|
||||
|
||||
logger.info(`[pair] OTP enviado para ${to}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── PairService ──────────────────────────────────────────────────────────────
|
||||
|
||||
export class PairService {
|
||||
|
||||
// Etapa 1 — solicitar pareamento
|
||||
async request(input: {
|
||||
email: string
|
||||
clientSystem: string
|
||||
clientName: string
|
||||
clientUrl: string | null
|
||||
}) {
|
||||
const user = await prisma.user.findUnique({ where: { email: input.email } })
|
||||
if (!user) {
|
||||
// Resposta genérica — não revela se o email existe ou não
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' }
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
|
||||
}
|
||||
|
||||
// Gera OTP de 6 dígitos
|
||||
const code = Math.floor(100_000 + Math.random() * 900_000).toString()
|
||||
|
||||
const payload: OtpPayload = {
|
||||
code,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
}
|
||||
|
||||
await dragonfly.setJson(otpKey(input.email), payload, OTP_TTL)
|
||||
const emailSent = await sendOtpEmail(input.email, code, input.clientName)
|
||||
|
||||
// Quando SMTP não está configurado, devolve o código na resposta
|
||||
// para que o admin possa completar o pareamento manualmente.
|
||||
if (!emailSent) {
|
||||
return { ok: true, message: 'SMTP não configurado.', dev_code: code }
|
||||
}
|
||||
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' }
|
||||
}
|
||||
|
||||
// Etapa 2 — confirmar com o código recebido
|
||||
async confirm(input: {
|
||||
email: string
|
||||
code: string
|
||||
clientSystem: string
|
||||
clientName: string
|
||||
clientUrl: string | null
|
||||
}) {
|
||||
const stored = await dragonfly.getJson<OtpPayload>(otpKey(input.email))
|
||||
|
||||
if (!stored) {
|
||||
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 })
|
||||
}
|
||||
|
||||
if (stored.code !== input.code) {
|
||||
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 })
|
||||
}
|
||||
|
||||
if (stored.clientSystem !== input.clientSystem) {
|
||||
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: input.email } })
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 })
|
||||
}
|
||||
|
||||
// Revoga par anterior do mesmo sistema (evita duplicatas)
|
||||
await prisma.pluginPair.updateMany({
|
||||
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
|
||||
// Gera integration_key exclusiva
|
||||
const integrationKey = `nwk_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
|
||||
const pair = await prisma.pluginPair.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
integrationKey,
|
||||
},
|
||||
})
|
||||
|
||||
// Cache rápido para validação sem bater no banco
|
||||
const keyPayload: KeyPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
}
|
||||
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
|
||||
|
||||
// Limpa OTP usado
|
||||
await dragonfly.del(otpKey(input.email))
|
||||
|
||||
logger.info(`[pair] ${user.email} ↔ ${input.clientName} pareados (${pair.id})`)
|
||||
|
||||
return {
|
||||
integration_key: integrationKey,
|
||||
account: { name: user.name, email: user.email },
|
||||
paired_at: pair.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
|
||||
async verify(integrationKey: string) {
|
||||
// Fast path — Dragonfly
|
||||
const cached = await dragonfly.getJson<KeyPayload>(intKey(integrationKey))
|
||||
if (cached) {
|
||||
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
|
||||
prisma.pluginPair.updateMany({
|
||||
where: { integrationKey, revokedAt: null },
|
||||
data: { lastSeenAt: new Date() },
|
||||
}).catch(() => {})
|
||||
|
||||
return { valid: true, account: { email: cached.email }, client: cached.clientSystem }
|
||||
}
|
||||
|
||||
// Slow path — banco
|
||||
const pair = await prisma.pluginPair.findUnique({
|
||||
where: { integrationKey },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
})
|
||||
|
||||
if (!pair || pair.revokedAt || !pair.user.isActive) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
// Restaura cache
|
||||
const keyPayload: KeyPayload = {
|
||||
userId: pair.userId,
|
||||
email: pair.user.email,
|
||||
clientSystem: pair.clientSystem,
|
||||
clientName: pair.clientName,
|
||||
}
|
||||
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
|
||||
|
||||
await prisma.pluginPair.update({
|
||||
where: { integrationKey },
|
||||
data: { lastSeenAt: new Date() },
|
||||
})
|
||||
|
||||
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem }
|
||||
}
|
||||
|
||||
// Lista pares ativos do usuário autenticado
|
||||
async list(userId: string) {
|
||||
const pairs = await prisma.pluginPair.findMany({
|
||||
where: { userId, revokedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
clientSystem: true,
|
||||
clientName: true,
|
||||
clientUrl: true,
|
||||
lastSeenAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
return pairs
|
||||
}
|
||||
|
||||
// Revoga um par (usuário autenticado só pode revogar os seus)
|
||||
async revoke(userId: string, pairId: string) {
|
||||
const pair = await prisma.pluginPair.findFirst({
|
||||
where: { id: pairId, userId, revokedAt: null },
|
||||
})
|
||||
|
||||
if (!pair) {
|
||||
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 })
|
||||
}
|
||||
|
||||
await prisma.pluginPair.update({
|
||||
where: { id: pairId },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
|
||||
// Remove do cache imediatamente
|
||||
await dragonfly.del(intKey(pair.integrationKey))
|
||||
|
||||
logger.info(`[pair] Par ${pairId} revogado por ${userId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Router } from 'express'
|
||||
import { pluginRegistry } from '../../core/plugin-registry'
|
||||
import { pluginConfig } from '../../core/plugin-config'
|
||||
import { storageProvider } from '../../core/StorageProvider'
|
||||
|
||||
/**
|
||||
* Rotas de gerenciamento de plugins — exclusivo admin.
|
||||
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
|
||||
*/
|
||||
export const pluginsRouter = Router()
|
||||
|
||||
// GET /api/admin/plugins — lista todos os plugins com status e config atual
|
||||
pluginsRouter.get('/', (_req, res) => {
|
||||
const plugins = pluginRegistry.list().map((entry) => ({
|
||||
name: entry.name,
|
||||
displayName: entry.manifest.displayName,
|
||||
version: entry.manifest.version,
|
||||
description: entry.manifest.description,
|
||||
author: entry.manifest.author,
|
||||
category: entry.manifest.category,
|
||||
active: entry.active,
|
||||
canDisable: entry.manifest.canDisable,
|
||||
dependencies: entry.manifest.dependencies,
|
||||
configSchema: entry.manifest.configSchema ?? [],
|
||||
config: pluginConfig.get(entry.name),
|
||||
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
|
||||
}))
|
||||
|
||||
res.json(plugins)
|
||||
})
|
||||
|
||||
// POST /api/admin/plugins/:name/enable — ativa plugin
|
||||
pluginsRouter.post('/:name/enable', async (req, res) => {
|
||||
const { name } = req.params
|
||||
try {
|
||||
await pluginRegistry.activate(name)
|
||||
res.json({ ok: true, message: `Plugin "${name}" ativado` })
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
|
||||
pluginsRouter.post('/:name/disable', async (req, res) => {
|
||||
const { name } = req.params
|
||||
try {
|
||||
await pluginRegistry.deactivate(name)
|
||||
res.json({ ok: true, message: `Plugin "${name}" desativado` })
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/admin/plugins/:name/config — retorna config atual
|
||||
pluginsRouter.get('/:name/config', (req, res) => {
|
||||
const { name } = req.params
|
||||
const entry = pluginRegistry.getEntry(name)
|
||||
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
|
||||
|
||||
res.json({
|
||||
name,
|
||||
config: pluginConfig.get(name),
|
||||
schema: entry.manifest.configSchema ?? [],
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
|
||||
pluginsRouter.get('/storage/health', async (_req, res) => {
|
||||
if (!storageProvider.isRegistered()) {
|
||||
const cfg = pluginConfig.get('UnifiedStorageProvider')
|
||||
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket)
|
||||
return res.json({
|
||||
healthy: false,
|
||||
configured,
|
||||
reason: configured ? 'needs_restart' : 'not_configured',
|
||||
message: configured
|
||||
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
|
||||
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
|
||||
})
|
||||
}
|
||||
await storageProvider.updateHealth()
|
||||
return res.json(storageProvider.getLastHealthStatus())
|
||||
})
|
||||
|
||||
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
|
||||
pluginsRouter.put('/:name/config', async (req, res) => {
|
||||
const { name } = req.params
|
||||
const entry = pluginRegistry.getEntry(name)
|
||||
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
|
||||
|
||||
const newConfig = req.body as Record<string, unknown>
|
||||
|
||||
await pluginConfig.set(name, newConfig)
|
||||
|
||||
// Reativa o plugin para aplicar a nova config imediatamente
|
||||
if (entry.active) {
|
||||
try {
|
||||
await pluginRegistry.reactivate(name)
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig })
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Rotas de Agendamentos de Mensagem
|
||||
*
|
||||
* GET /api/scheduled — Lista agendamentos do tenant
|
||||
* POST /api/scheduled — Cria agendamento
|
||||
* GET /api/scheduled/:id — Detalhe
|
||||
* PUT /api/scheduled/:id — Atualiza
|
||||
* DELETE /api/scheduled/:id — Remove
|
||||
* POST /api/scheduled/:id/pause — Pausa
|
||||
* POST /api/scheduled/:id/resume — Retoma
|
||||
* POST /api/scheduled/:id/run-now — Executa imediatamente
|
||||
* GET /api/scheduled/:id/logs — Histórico de execuções
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { Cron } from 'croner'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import type { SchedulerService } from './scheduler.service'
|
||||
|
||||
const recipientSchema = z.object({
|
||||
jid: z.string(),
|
||||
name: z.string().optional(),
|
||||
birthday: z.string().optional(),
|
||||
anniversary: z.string().optional(),
|
||||
signupDate: z.string().optional(),
|
||||
})
|
||||
|
||||
const scheduleSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
name: z.string().min(1).max(100),
|
||||
templateId: z.string().optional(),
|
||||
payload: z.record(z.unknown()),
|
||||
recipientMode: z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
|
||||
recipients: z.array(recipientSchema).default([]),
|
||||
scheduleType: z.enum(['ONCE', 'RECURRING', 'EVENT']),
|
||||
cronExpr: z.string().optional(),
|
||||
scheduledAt: z.string().datetime().optional(),
|
||||
eventType: z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
|
||||
timezone: z.string().default('America/Sao_Paulo'),
|
||||
})
|
||||
|
||||
function getId(req: Request): string {
|
||||
const id = req.params['id']
|
||||
return Array.isArray(id) ? id[0] : id
|
||||
}
|
||||
|
||||
function calcNextRun(data: Partial<z.infer<typeof scheduleSchema>>): Date | null {
|
||||
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
|
||||
return new Date(data.scheduledAt)
|
||||
}
|
||||
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
|
||||
try {
|
||||
const job = new Cron(data.cronExpr, { paused: true })
|
||||
return job.nextRun() ?? null
|
||||
} catch { return null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildScheduledRoutes(_scheduler: SchedulerService): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'] as string | undefined
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] as string | undefined
|
||||
|
||||
const schedules = await prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(status ? { status: status as any } : {}),
|
||||
...(type ? { scheduleType: type as any } : {}),
|
||||
},
|
||||
include: {
|
||||
template: { select: { id: true, name: true, type: true } },
|
||||
_count: { select: { logs: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
res.json(schedules)
|
||||
})
|
||||
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const parse = scheduleSchema.safeParse(req.body)
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors })
|
||||
return
|
||||
}
|
||||
|
||||
const data = parse.data
|
||||
const nextRunAt = calcNextRun(data)
|
||||
|
||||
const schedule = await prisma.scheduledMessage.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
templateId: data.templateId,
|
||||
payload: data.payload as any,
|
||||
recipientMode: data.recipientMode,
|
||||
recipients: data.recipients as any,
|
||||
scheduleType: data.scheduleType,
|
||||
cronExpr: data.cronExpr,
|
||||
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
|
||||
eventType: data.eventType ?? null,
|
||||
timezone: data.timezone,
|
||||
nextRunAt,
|
||||
},
|
||||
})
|
||||
|
||||
res.status(201).json(schedule)
|
||||
})
|
||||
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const sched = await prisma.scheduledMessage.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
include: {
|
||||
template: true,
|
||||
logs: { orderBy: { runAt: 'desc' }, take: 5 },
|
||||
},
|
||||
})
|
||||
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
res.json(sched)
|
||||
})
|
||||
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
|
||||
const parse = scheduleSchema.partial().safeParse(req.body)
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors })
|
||||
return
|
||||
}
|
||||
|
||||
const data = parse.data
|
||||
const nextRunAt = calcNextRun({ ...exists, ...data } as any)
|
||||
|
||||
const { payload, recipients, scheduledAt, eventType, ...rest } = data
|
||||
const updated = await prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(payload !== undefined ? { payload: payload as any } : {}),
|
||||
...(recipients !== undefined ? { recipients: recipients as any } : {}),
|
||||
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt as string) } : {}),
|
||||
...(eventType !== undefined ? { eventType } : {}),
|
||||
status: 'ACTIVE',
|
||||
nextRunAt,
|
||||
},
|
||||
})
|
||||
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
|
||||
await prisma.scheduledMessage.delete({ where: { id } })
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
// ── PAUSE ─────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/pause', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
|
||||
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── RESUME ────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/resume', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
|
||||
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── RUN NOW ───────────────────────────────────────────────────────────────
|
||||
router.post('/:id/run-now', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const sched = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
|
||||
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
|
||||
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
|
||||
await prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
|
||||
})
|
||||
res.json({ ok: true, message: 'Execução agendada para o próximo tick' })
|
||||
})
|
||||
|
||||
// ── LOGS ──────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/logs', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
|
||||
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
|
||||
|
||||
const logs = await prisma.scheduledMessageLog.findMany({
|
||||
where: { scheduleId: id },
|
||||
orderBy: { runAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
|
||||
res.json(logs)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* SchedulerService — executa agendamentos de mensagens ricos.
|
||||
*
|
||||
* Tipos suportados:
|
||||
* ONCE — executa uma vez em scheduledAt
|
||||
* RECURRING — cron expression (cronExpr)
|
||||
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
|
||||
* verificado diariamente às 08:00 contra dados dos contatos
|
||||
*
|
||||
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
|
||||
* Eventos são checados uma vez por dia via job separado.
|
||||
*
|
||||
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
|
||||
* (sem passar pelo HTTP — evita latência e auth overhead).
|
||||
*/
|
||||
import { schedule as cronSchedule } from 'node-cron'
|
||||
import { Cron } from 'croner'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
||||
|
||||
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
|
||||
|
||||
export class SchedulerService {
|
||||
private manager: WhatsAppConnectionManager
|
||||
private minuteJob: ReturnType<typeof cronSchedule> | null = null
|
||||
private eventJob: ReturnType<typeof cronSchedule> | null = null
|
||||
|
||||
constructor(manager: WhatsAppConnectionManager) {
|
||||
this.manager = manager
|
||||
}
|
||||
|
||||
start() {
|
||||
// Tick a cada minuto — processa ONCE e RECURRING vencidos
|
||||
this.minuteJob = cronSchedule('* * * * *', () => {
|
||||
this.processTimeBased().catch((err) =>
|
||||
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
|
||||
)
|
||||
})
|
||||
|
||||
// Tick diário às 08:00 — processa agendamentos por EVENTO
|
||||
this.eventJob = cronSchedule('0 8 * * *', () => {
|
||||
this.processEventBased().catch((err) =>
|
||||
logger.error({ err }, '[Scheduler] Erro no tick de evento')
|
||||
)
|
||||
})
|
||||
|
||||
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.minuteJob?.stop()
|
||||
this.eventJob?.stop()
|
||||
}
|
||||
|
||||
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
|
||||
|
||||
private async processTimeBased() {
|
||||
const now = new Date()
|
||||
|
||||
const schedules = await prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
scheduleType: { in: ['ONCE', 'RECURRING'] },
|
||||
OR: [
|
||||
{ nextRunAt: { lte: now } },
|
||||
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
for (const sched of schedules) {
|
||||
await this.executeSchedule(sched)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
|
||||
|
||||
private async processEventBased() {
|
||||
const today = new Date()
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(today.getDate()).padStart(2, '0')
|
||||
const todayMmDd = `${mm}-${dd}`
|
||||
|
||||
const schedules = await prisma.scheduledMessage.findMany({
|
||||
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
|
||||
})
|
||||
|
||||
for (const sched of schedules) {
|
||||
const recipients = sched.recipients as Recipient[]
|
||||
const matched: Recipient[] = []
|
||||
|
||||
for (const r of recipients) {
|
||||
if (!r.jid) continue
|
||||
|
||||
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
|
||||
// birthday format: "YYYY-MM-DD" or "MM-DD"
|
||||
const parts = r.birthday.split('-')
|
||||
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
|
||||
if (bMmDd === todayMmDd) matched.push(r)
|
||||
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
|
||||
const parts = r.anniversary.split('-')
|
||||
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
|
||||
if (aMmDd === todayMmDd) matched.push(r)
|
||||
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
|
||||
// Dispara N dias após o signup — N fica em cronExpr como "30"
|
||||
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
|
||||
const signup = new Date(r.signupDate)
|
||||
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
|
||||
if (diffDays === daysAfter) matched.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length > 0) {
|
||||
await this.executeSchedule(sched, matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Executa um agendamento ─────────────────────────────────────────────────
|
||||
|
||||
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
|
||||
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
|
||||
const sock = this.manager.getSocket(sched.instanceId)
|
||||
|
||||
if (!sock) {
|
||||
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
|
||||
return
|
||||
}
|
||||
|
||||
let sentCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.jid) continue
|
||||
try {
|
||||
const payload = this.buildPayload(sched.payload as any, recipient)
|
||||
await sock.sendMessage(recipient.jid, payload)
|
||||
sentCount++
|
||||
// Pequeno delay anti-ban
|
||||
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
|
||||
} catch (err: any) {
|
||||
failedCount++
|
||||
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
|
||||
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
|
||||
}
|
||||
}
|
||||
|
||||
// Persiste log
|
||||
await prisma.scheduledMessageLog.create({
|
||||
data: {
|
||||
scheduleId: sched.id,
|
||||
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
|
||||
sentCount,
|
||||
failedCount,
|
||||
error: errors.length ? errors.join('\n') : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Calcula próximo nextRunAt ou marca como DONE
|
||||
if (sched.scheduleType === 'ONCE') {
|
||||
await prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
status: 'DONE',
|
||||
lastRunAt: new Date(),
|
||||
runCount: { increment: 1 },
|
||||
nextRunAt: null,
|
||||
},
|
||||
})
|
||||
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
|
||||
const next = this.nextCronDate(sched.cronExpr)
|
||||
await prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
lastRunAt: new Date(),
|
||||
nextRunAt: next,
|
||||
runCount: { increment: 1 },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// EVENT — apenas registra execução
|
||||
await prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
|
||||
}
|
||||
|
||||
// ── Monta payload Baileys com substituição de variáveis ────────────────────
|
||||
// Variáveis: {{name}}, {{birthday}}, etc.
|
||||
|
||||
private buildPayload(raw: any, recipient: Recipient): any {
|
||||
const replace = (str: string) =>
|
||||
str
|
||||
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
|
||||
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
|
||||
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
|
||||
|
||||
const recurse = (node: any): any => {
|
||||
if (typeof node === 'string') return replace(node)
|
||||
if (Array.isArray(node)) return node.map(recurse)
|
||||
if (node && typeof node === 'object') {
|
||||
const out: any = {}
|
||||
for (const k of Object.keys(node)) out[k] = recurse(node[k])
|
||||
return out
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
return recurse(raw)
|
||||
}
|
||||
|
||||
// ── Calcula próxima data de um cron ───────────────────────────────────────
|
||||
|
||||
private nextCronDate(expr: string): Date | null {
|
||||
try {
|
||||
const job = new Cron(expr, { paused: true })
|
||||
return job.nextRun() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export function buildSectorRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// GET /api/sectors
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const sectors = await prisma.sector.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { teams: true, protocols: true } } },
|
||||
})
|
||||
res.json(sectors)
|
||||
})
|
||||
|
||||
// POST /api/sectors
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const schema = z.object({ name: z.string().min(1).max(100) })
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
try {
|
||||
const sector = await prisma.sector.create({ data: { tenantId, name: body.name } })
|
||||
res.status(201).json(sector)
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Setor com este nome já existe.' })
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/sectors/:id
|
||||
router.patch('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = req.params['id'] as string
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
|
||||
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
|
||||
|
||||
const updated = await prisma.sector.update({ where: { id }, data: body })
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// DELETE /api/sectors/:id
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = req.params['id'] as string
|
||||
|
||||
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
|
||||
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
|
||||
|
||||
await prisma.team.deleteMany({ where: { sectorId: id } })
|
||||
await prisma.sector.delete({ where: { id } })
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
// GET /api/sectors/:id/teams
|
||||
router.get('/:id/teams', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const sectorId = req.params['id'] as string
|
||||
|
||||
const teams = await prisma.team.findMany({
|
||||
where: { sectorId, tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { protocols: true } } },
|
||||
})
|
||||
res.json(teams)
|
||||
})
|
||||
|
||||
// POST /api/sectors/:id/teams
|
||||
router.post('/:id/teams', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const sectorId = req.params['id'] as string
|
||||
const schema = z.object({ name: z.string().min(1).max(100) })
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
const sector = await prisma.sector.findFirst({ where: { id: sectorId, tenantId } })
|
||||
if (!sector) { res.status(404).json({ error: 'Setor não encontrado' }); return }
|
||||
|
||||
try {
|
||||
const team = await prisma.team.create({ data: { tenantId, sectorId, name: body.name } })
|
||||
res.status(201).json(team)
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' })
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/sectors/:sectorId/teams/:teamId
|
||||
router.patch('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const teamId = req.params['teamId'] as string
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
let body: z.infer<typeof schema>
|
||||
try { body = schema.parse(req.body) }
|
||||
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
|
||||
|
||||
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
|
||||
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
|
||||
|
||||
const updated = await prisma.team.update({ where: { id: teamId }, data: body })
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// DELETE /api/sectors/:sectorId/teams/:teamId
|
||||
router.delete('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const teamId = req.params['teamId'] as string
|
||||
|
||||
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
|
||||
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
|
||||
|
||||
await prisma.team.delete({ where: { id: teamId } })
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Rotas de Figurinhas (Stickers)
|
||||
*
|
||||
* GET /api/stickers — Lista stickers do tenant (com listVersion)
|
||||
* GET /api/stickers/recent — Últimos 30 stickers usados
|
||||
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export function buildStickerRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── LIST (com listVersion para cache invalidation) ────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
useCount: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const versionSource = stickers.length === 0
|
||||
? 'empty'
|
||||
: `${stickers.length}:${stickers[0]!.sha256}:${stickers[0]!.lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`
|
||||
const listVersion = createHash('md5').update(versionSource).digest('hex').slice(0, 8)
|
||||
|
||||
res.json({ listVersion, stickers })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
|
||||
router.get('/recent', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ stickers })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers recentes' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
|
||||
router.patch('/:id/favorite', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const idParam = req.params['id']
|
||||
const id = Array.isArray(idParam) ? idParam[0]! : idParam
|
||||
|
||||
const sticker = await prisma.sticker.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
if (!sticker) {
|
||||
res.status(404).json({ error: 'Sticker não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.sticker.update({
|
||||
where: { id: sticker.id },
|
||||
data: { isFavorite: !sticker.isFavorite },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
res.json(updated)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar favorito' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Rotas de Templates de Mensagem
|
||||
*
|
||||
* GET /api/templates — Lista templates do tenant
|
||||
* POST /api/templates — Cria template
|
||||
* GET /api/templates/:id — Detalhe do template
|
||||
* PUT /api/templates/:id — Atualiza template
|
||||
* DELETE /api/templates/:id — Remove template
|
||||
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'] as const
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
type: z.enum(templateTypeValues),
|
||||
payload: z.record(z.unknown()),
|
||||
tags: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
function getId(req: Request): string {
|
||||
const id = req.params['id']
|
||||
return Array.isArray(id) ? id[0] : id
|
||||
}
|
||||
|
||||
export function buildTemplateRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type']
|
||||
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search']
|
||||
|
||||
const templates = await prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(type ? { type: type as any } : {}),
|
||||
...(search ? { name: { contains: search as string, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
res.json(templates)
|
||||
})
|
||||
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const parse = templateSchema.safeParse(req.body)
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors })
|
||||
return
|
||||
}
|
||||
|
||||
const { name, description, type, payload, tags } = parse.data
|
||||
|
||||
const template = await prisma.messageTemplate.create({
|
||||
data: { tenantId, name, description, type, payload: payload as any, tags },
|
||||
})
|
||||
|
||||
res.status(201).json(template)
|
||||
})
|
||||
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const template = await prisma.messageTemplate.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
})
|
||||
if (!template) { res.status(404).json({ error: 'Template não encontrado' }); return }
|
||||
res.json(template)
|
||||
})
|
||||
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
|
||||
|
||||
const parse = templateSchema.partial().safeParse(req.body)
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors })
|
||||
return
|
||||
}
|
||||
|
||||
const { payload, ...rest } = parse.data
|
||||
const updated = await prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { ...rest, ...(payload !== undefined ? { payload: payload as any } : {}) },
|
||||
})
|
||||
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
|
||||
|
||||
await prisma.messageTemplate.delete({ where: { id } })
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
|
||||
router.post('/:id/use', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const id = getId(req)
|
||||
const exists = await prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
|
||||
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { usageCount: { increment: 1 } },
|
||||
})
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
+1127
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+1307
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
|
||||
exports.getEngine = getEngine;
|
||||
const official = __importStar(require("./official"));
|
||||
const infinite = __importStar(require("./infinite"));
|
||||
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
|
||||
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
function resolveEngine(type) {
|
||||
return type === 'infinite' ? infinite : official;
|
||||
}
|
||||
/**
|
||||
* Retorna os exports da engine selecionada para uma instância específica.
|
||||
* Permite que cada instância use uma engine diferente (infinite ou official).
|
||||
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
|
||||
*/
|
||||
function getEngine(engineType) {
|
||||
const e = resolveEngine(engineType ?? defaultEngineType);
|
||||
return {
|
||||
makeWASocket: e.makeWASocket,
|
||||
useMultiFileAuthState: e.useMultiFileAuthState,
|
||||
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage: e.downloadMediaMessage,
|
||||
getContentType: e.getContentType,
|
||||
generateMessageIDV2: e.generateMessageIDV2,
|
||||
generateWAMessageFromContent: e.generateWAMessageFromContent,
|
||||
isJidGroup: e.isJidGroup,
|
||||
isJidStatusBroadcast: e.isJidStatusBroadcast,
|
||||
};
|
||||
}
|
||||
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
|
||||
const selectedEngine = resolveEngine(defaultEngineType);
|
||||
exports.makeWASocket = selectedEngine.makeWASocket;
|
||||
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
|
||||
exports.DisconnectReason = selectedEngine.DisconnectReason;
|
||||
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
|
||||
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
|
||||
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
|
||||
exports.getContentType = selectedEngine.getContentType;
|
||||
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
|
||||
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
|
||||
exports.isJidGroup = selectedEngine.isJidGroup;
|
||||
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
|
||||
var official_1 = require("./official");
|
||||
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA,8BAyBC;AAxCD,qDAAsC;AACtC,qDAAsC;AAEtC,0EAA0E;AAC1E,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAA;AAElE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,UAA0B;IAYlD,MAAM,CAAC,GAAG,aAAa,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAA;IACxD,OAAO;QACL,YAAY,EAAgB,CAAC,CAAC,YAAmB;QACjD,qBAAqB,EAAO,CAAC,CAAC,qBAA4B;QAC1D,yBAAyB,EAAG,CAAC,CAAC,yBAAgC;QAC9D,2BAA2B,EAAE,CAAC,CAAC,2BAAkC;QACjE,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;QACzD,cAAc,EAAc,CAAC,CAAC,cAAqB;QACnD,mBAAmB,EAAS,CAAC,CAAC,mBAA0B;QACxD,4BAA4B,EAAE,CAAC,CAAC,4BAAmC;QACnE,UAAU,EAAkB,CAAC,CAAC,UAAiB;QAC/C,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;KAC1D,CAAA;AACH,CAAC;AAED,sFAAsF;AACtF,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAE1C,QAAA,YAAY,GAAG,cAAc,CAAC,YAAmB,CAAA;AACjD,QAAA,qBAAqB,GAAG,cAAc,CAAC,qBAA4B,CAAA;AACnE,QAAA,gBAAgB,GAAG,cAAc,CAAC,gBAAuB,CAAA;AACzD,QAAA,yBAAyB,GAAG,cAAc,CAAC,yBAAgC,CAAA;AAC3E,QAAA,2BAA2B,GAAG,cAAc,CAAC,2BAAkC,CAAA;AAC/E,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AACjE,QAAA,cAAc,GAAG,cAAc,CAAC,cAAqB,CAAA;AACrD,QAAA,mBAAmB,GAAG,cAAc,CAAC,mBAA0B,CAAA;AAC/D,QAAA,4BAA4B,GAAG,cAAc,CAAC,4BAAmC,CAAA;AACjF,QAAA,UAAU,GAAG,cAAc,CAAC,UAAiB,CAAA;AAC7C,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AAC9E,uCAAkC;AAAzB,iGAAA,KAAK,OAAA"}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as official from './official'
|
||||
import * as infinite from './infinite'
|
||||
|
||||
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
|
||||
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite'
|
||||
|
||||
function resolveEngine(type: string) {
|
||||
return type === 'infinite' ? infinite : official
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna os exports da engine selecionada para uma instância específica.
|
||||
* Permite que cada instância use uma engine diferente (infinite ou official).
|
||||
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
|
||||
*/
|
||||
export function getEngine(engineType?: string | null): {
|
||||
makeWASocket: any
|
||||
useMultiFileAuthState: any
|
||||
fetchLatestBaileysVersion: any
|
||||
makeCacheableSignalKeyStore: any
|
||||
downloadMediaMessage: any
|
||||
getContentType: any
|
||||
generateMessageIDV2: any
|
||||
generateWAMessageFromContent: any
|
||||
isJidGroup: any
|
||||
isJidStatusBroadcast: any
|
||||
} {
|
||||
const e = resolveEngine(engineType ?? defaultEngineType)
|
||||
return {
|
||||
makeWASocket: e.makeWASocket as any,
|
||||
useMultiFileAuthState: e.useMultiFileAuthState as any,
|
||||
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any,
|
||||
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any,
|
||||
downloadMediaMessage: e.downloadMediaMessage as any,
|
||||
getContentType: e.getContentType as any,
|
||||
generateMessageIDV2: e.generateMessageIDV2 as any,
|
||||
generateWAMessageFromContent: e.generateWAMessageFromContent as any,
|
||||
isJidGroup: e.isJidGroup as any,
|
||||
isJidStatusBroadcast: e.isJidStatusBroadcast as any,
|
||||
}
|
||||
}
|
||||
|
||||
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
|
||||
const selectedEngine = resolveEngine(defaultEngineType)
|
||||
|
||||
export const makeWASocket = selectedEngine.makeWASocket as any
|
||||
export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any
|
||||
export const DisconnectReason = selectedEngine.DisconnectReason as any
|
||||
export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any
|
||||
export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any
|
||||
export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any
|
||||
export const getContentType = selectedEngine.getContentType as any
|
||||
export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any
|
||||
export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any
|
||||
export const isJidGroup = selectedEngine.isJidGroup as any
|
||||
export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any
|
||||
export { proto } from './official'
|
||||
|
||||
// Exportação estática de tipos para garantir segurança em tempo de compilação
|
||||
export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official'
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("baileys"), exports);
|
||||
//# sourceMappingURL=infinite.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
|
||||
@@ -0,0 +1 @@
|
||||
export * from 'baileys'
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("@whiskeysockets/baileys"), exports);
|
||||
//# sourceMappingURL=official.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@whiskeysockets/baileys'
|
||||
@@ -0,0 +1,505 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContactHandler = void 0;
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
|
||||
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
|
||||
// forem feitas em curto período. Estas constantes controlam o throttling.
|
||||
/** Quantos avatares buscar em paralelo por lote */
|
||||
const AVATAR_BATCH_SIZE = 5;
|
||||
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
|
||||
const AVATAR_DELAY_MS = 900;
|
||||
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
|
||||
const MAX_MISSING_AVATAR_SYNC = 80;
|
||||
class ContactHandler {
|
||||
constructor(
|
||||
/** UUID da instância WhatsApp */
|
||||
instanceId,
|
||||
/** UUID do tenant (isolamento multi-tenant) */
|
||||
tenantId,
|
||||
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
|
||||
sock,
|
||||
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
|
||||
io) {
|
||||
this.instanceId = instanceId;
|
||||
this.tenantId = tenantId;
|
||||
this.sock = sock;
|
||||
this.io = io;
|
||||
/** Fila de JIDs aguardando busca de avatar */
|
||||
this.avatarQueue = [];
|
||||
/** Flag de mutex simples — evita processamento concorrente da fila */
|
||||
this.processingAvatars = false;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESOLUÇÃO DE NOME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Regra de ouro para resolução de nome de contato:
|
||||
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
|
||||
*
|
||||
* Retorna undefined se nenhum campo estiver disponível.
|
||||
*/
|
||||
resolveName(c) {
|
||||
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Cria ou atualiza contatos em massa.
|
||||
*
|
||||
* Dispara em dois cenários:
|
||||
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
|
||||
* 2. Campo contacts[] do messaging-history.set (history sync)
|
||||
*
|
||||
* Para cada contato:
|
||||
* - Cria ou atualiza no banco com nome resolvido
|
||||
* - Vincula chats órfãos (Chat sem contactId) ao contato
|
||||
* - Enfileira busca de avatar se imgUrl não disponível
|
||||
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
|
||||
*
|
||||
* TRATAMENTO DE LID:
|
||||
* O Baileys pode enviar contatos com c.id no formato @lid.
|
||||
* Quando o contato do history sync inclui campo `jid` (telefone real),
|
||||
* usamos esse JID como chave primária e salvamos o LID em lidJid.
|
||||
* Quando não há campo `jid`, o contato é criado com o LID como JID
|
||||
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
|
||||
*/
|
||||
async upsert(contacts) {
|
||||
/** JIDs que precisam buscar avatar via profilePictureUrl */
|
||||
const needFetchAvatar = [];
|
||||
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
|
||||
let directAvatarCount = 0;
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const isLid = c.id.endsWith('@lid');
|
||||
const isBroadcast = c.id.includes('@broadcast');
|
||||
if (isBroadcast)
|
||||
continue;
|
||||
// imgUrl pode vir do Baileys com valores especiais:
|
||||
// - URL string: avatar disponível (salva direto)
|
||||
// - 'changed': avatar foi alterado (precisa buscar novamente)
|
||||
// - null/undefined: não disponível (precisa buscar via API)
|
||||
const imgUrl = c.imgUrl;
|
||||
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
|
||||
// que associa o LID ao contato com JID real (@s.whatsapp.net)
|
||||
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
|
||||
const lidFromContact = c.lid;
|
||||
try {
|
||||
// Para contatos @lid que possuem o JID real do telefone:
|
||||
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
|
||||
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
|
||||
// Ambos são suportados — o primeiro disponível vence.
|
||||
// Isso evita criar contatos duplicados (um com LID, outro com phone).
|
||||
const pnJid = c.jid
|
||||
?? c.phoneNumber;
|
||||
const contactJid = isLid && pnJid ? pnJid : c.id;
|
||||
// Quando o contato É um @lid mas foi resolvido para phoneJid:
|
||||
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
|
||||
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
|
||||
// lidJid = lidFromContact
|
||||
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: {
|
||||
tenantId_instanceId_jid: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
update: {
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
});
|
||||
// Garante vínculo Chat ↔ Contact para chats órfãos
|
||||
// (chats criados antes do contato existir ficam com contactId=null)
|
||||
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
|
||||
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
contactId: null,
|
||||
},
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
// ── Lógica de avatar ──────────────────────────────────────────────
|
||||
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
|
||||
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
|
||||
const resolvedIsLid = contactJid.endsWith('@lid');
|
||||
if (!resolvedIsLid) {
|
||||
if (imgUrl && imgUrl !== 'changed') {
|
||||
// Avatar veio pronto do Baileys — notifica frontend imediatamente
|
||||
directAvatarCount++;
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
avatarUrl: imgUrl,
|
||||
});
|
||||
}
|
||||
else if (imgUrl === 'changed' || imgUrl === undefined) {
|
||||
// Precisa buscar via API — adiciona à fila com rate-limit
|
||||
needFetchAvatar.push(contactJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
|
||||
}
|
||||
}
|
||||
logger_1.logger.debug({
|
||||
instanceId: this.instanceId,
|
||||
direct: directAvatarCount,
|
||||
toFetch: needFetchAvatar.length,
|
||||
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
|
||||
// Enfileira busca de avatares com cap de 50 por batch
|
||||
// (randomiza para priorizar contatos diferentes a cada sync)
|
||||
if (this.sock && needFetchAvatar.length > 0) {
|
||||
const toQueue = needFetchAvatar.length > 50
|
||||
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
|
||||
: needFetchAvatar;
|
||||
this.enqueueAvatars(toQueue);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPDATE PARCIAL (contacts.update)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Atualiza campos parciais de contatos existentes.
|
||||
*
|
||||
* Dispara no evento contacts.update do Baileys — diferente do upsert,
|
||||
* aqui só atualizamos os campos que foram explicitamente alterados.
|
||||
* Não cria contatos novos (se não existir, ignora silenciosamente).
|
||||
*/
|
||||
async update(contacts) {
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const resolvedName = this.resolveName(c);
|
||||
// Se nenhum campo relevante mudou, pula
|
||||
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
|
||||
continue;
|
||||
try {
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
},
|
||||
data: {
|
||||
...(resolvedName !== undefined && { name: resolvedName }),
|
||||
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
|
||||
...(c.notify !== undefined && { notify: c.notify }),
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
|
||||
}
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca e salva a foto de perfil do número WhatsApp da instância.
|
||||
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
|
||||
*/
|
||||
async syncInstanceAvatar(instanceId) {
|
||||
if (!this.sock?.user?.id)
|
||||
return null;
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
|
||||
if (url) {
|
||||
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
|
||||
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Avatar privado ou não disponível — normal para contas com privacidade restrita
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Reconcilia nomes de contatos após sync do histórico.
|
||||
*
|
||||
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
|
||||
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
|
||||
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
|
||||
*
|
||||
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
|
||||
* name=null E notify=null, e preenche usando o pushName da mensagem
|
||||
* recebida mais recente para cada JID.
|
||||
*
|
||||
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
|
||||
*
|
||||
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
|
||||
*/
|
||||
async reconcileNamesFromMessages() {
|
||||
try {
|
||||
// Busca contatos "sem nome" — candidatos à reconciliação
|
||||
const unnamed = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
name: null,
|
||||
notify: null,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
{ jid: { endsWith: '@g.us' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, jid: true },
|
||||
});
|
||||
if (unnamed.length === 0) {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
|
||||
return;
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
|
||||
let updated = 0;
|
||||
for (const contact of unnamed) {
|
||||
// Busca a mensagem recebida mais recente com pushName para este JID
|
||||
const msg = await prisma_1.prisma.message.findFirst({
|
||||
where: {
|
||||
instanceId: this.instanceId,
|
||||
remoteJid: contact.jid,
|
||||
fromMe: false,
|
||||
pushName: { not: null },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
select: { pushName: true },
|
||||
});
|
||||
if (msg?.pushName) {
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { name: msg.pushName, notify: msg.pushName },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
|
||||
// Notifica o frontend para atualizar a lista de chats
|
||||
if (updated > 0) {
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
|
||||
instanceId: this.instanceId,
|
||||
count: updated,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FORCE CONTACT SYNC (resyncAppState)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Força re-sync dos contatos via app-state do Baileys.
|
||||
*
|
||||
* NOTA: Este método existe mas NÃO é chamado automaticamente.
|
||||
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
|
||||
* força o resync das app-state collections que contêm contatos.
|
||||
*
|
||||
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
|
||||
* "tried remove, but no previous op" — erro de estado corrompido
|
||||
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
|
||||
*
|
||||
* Pode ser chamado manualmente via API se necessário.
|
||||
*/
|
||||
async forceContactSync() {
|
||||
if (!this.sock)
|
||||
return;
|
||||
try {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
|
||||
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca avatares ausentes para chats ativos após reconexão.
|
||||
*
|
||||
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
|
||||
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
|
||||
* ou contatos vieram sem imgUrl.
|
||||
*
|
||||
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
|
||||
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
|
||||
*/
|
||||
async syncMissingAvatars() {
|
||||
try {
|
||||
const missing = await prisma_1.prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
isArchived: false,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
contact: { avatarUrl: null },
|
||||
},
|
||||
select: { jid: true },
|
||||
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
take: MAX_MISSING_AVATAR_SYNC,
|
||||
});
|
||||
if (missing.length === 0)
|
||||
return;
|
||||
const jids = missing.map((c) => c.jid);
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
|
||||
this.enqueueAvatars(jids);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
|
||||
* em menos de 48 horas ou já expirou.
|
||||
*
|
||||
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
|
||||
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
|
||||
*/
|
||||
async syncExpiredAvatars() {
|
||||
try {
|
||||
const contacts = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
avatarUrl: { not: null },
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
},
|
||||
select: { jid: true, avatarUrl: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
|
||||
const expiring = contacts
|
||||
.filter(c => {
|
||||
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
|
||||
if (!m)
|
||||
return false;
|
||||
return parseInt(m[1], 16) < threshold;
|
||||
})
|
||||
.map(c => c.jid);
|
||||
if (expiring.length === 0)
|
||||
return;
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
|
||||
// Limpa as URLs expiradas antes de re-buscar, para que o browser
|
||||
// não use URLs inválidas enquanto a fila processa
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: { in: expiring },
|
||||
},
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FILA DE AVATARES COM RATE-LIMIT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
|
||||
*
|
||||
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
|
||||
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
|
||||
*
|
||||
* Se a fila já está sendo processada, os novos JIDs são adicionados
|
||||
* e serão processados na próxima iteração do loop.
|
||||
*/
|
||||
enqueueAvatars(jids) {
|
||||
const inQueue = new Set(this.avatarQueue);
|
||||
for (const jid of jids) {
|
||||
if (!inQueue.has(jid))
|
||||
this.avatarQueue.push(jid);
|
||||
}
|
||||
this.processAvatarQueue();
|
||||
}
|
||||
/**
|
||||
* Processa a fila de avatares com rate-limit.
|
||||
*
|
||||
* Usa Promise.allSettled() para que falhas individuais (foto privada,
|
||||
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
|
||||
* 1. Salvo no campo avatarUrl do contato no banco
|
||||
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
|
||||
*/
|
||||
async processAvatarQueue() {
|
||||
// Mutex simples: apenas uma execução por vez
|
||||
if (this.processingAvatars || this.avatarQueue.length === 0)
|
||||
return;
|
||||
this.processingAvatars = true;
|
||||
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
|
||||
while (this.avatarQueue.length > 0 && this.sock) {
|
||||
// Pega o próximo lote (remove da fila)
|
||||
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
|
||||
await Promise.allSettled(batch.map(async (jid) => {
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(jid, 'image');
|
||||
if (url) {
|
||||
// Persiste no banco
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
// Notifica o frontend para atualizar o avatar na UI
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
avatarUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Foto privada ou JID inválido — ignora silenciosamente
|
||||
// (não loga para evitar spam com milhares de contatos)
|
||||
}
|
||||
}));
|
||||
// Delay entre lotes para respeitar rate-limit do WhatsApp
|
||||
if (this.avatarQueue.length > 0) {
|
||||
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
this.processingAvatars = false;
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
|
||||
}
|
||||
}
|
||||
exports.ContactHandler = ContactHandler;
|
||||
//# sourceMappingURL=ContactHandler.js.map
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user