diff --git a/CONFIGURACAO_PUSH_NOTIFICATIONS.md b/CONFIGURACAO_PUSH_NOTIFICATIONS.md
new file mode 100644
index 0000000..2f072f7
--- /dev/null
+++ b/CONFIGURACAO_PUSH_NOTIFICATIONS.md
@@ -0,0 +1,117 @@
+# 🔔 Configuração de Push Notifications
+
+## Passo 1: Configurar VAPID Keys
+
+### 1.1 Gerar VAPID Keys (se ainda não tiver)
+
+Execute no diretório do backend:
+```bash
+cd packages/backend
+bunx web-push generate-vapid-keys
+```
+
+Isso gerará duas chaves:
+- **Public Key**: Segura para expor no frontend
+- **Private Key**: Deve ser mantida em segredo, apenas no backend
+
+### 1.2 Configurar no Convex (Backend)
+
+As variáveis de ambiente no Convex são configuradas via dashboard ou CLI:
+
+#### Opção A: Via Dashboard Convex
+1. Acesse https://dashboard.convex.dev
+2. Selecione seu projeto
+3. Vá em **Settings** > **Environment Variables**
+4. Adicione as seguintes variáveis:
+
+```
+VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+VAPID_PRIVATE_KEY=KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4
+FRONTEND_URL=http://localhost:5173
+```
+
+#### Opção B: Via CLI Convex
+```bash
+cd packages/backend
+npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+npx convex env set FRONTEND_URL "http://localhost:5173"
+```
+
+### 1.3 Configurar no Frontend
+
+Crie um arquivo `.env` no diretório `apps/web/` com:
+
+```env
+VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+```
+
+**Importante**: Reinicie o servidor de desenvolvimento após criar/modificar o `.env`.
+
+## Passo 2: Configurar FRONTEND_URL
+
+A variável `FRONTEND_URL` é usada nos templates de email para gerar links de volta ao sistema.
+
+### Para Desenvolvimento:
+```
+FRONTEND_URL=http://localhost:5173
+```
+
+### Para Produção:
+```
+FRONTEND_URL=https://seu-dominio.com
+```
+
+## Passo 3: Testar Push Notifications
+
+### 3.1 Registrar Subscription no Frontend
+
+O sistema automaticamente solicita permissão e registra a subscription quando:
+1. O usuário faz login
+2. Acessa o chat pela primeira vez
+3. O Service Worker é instalado
+
+### 3.2 Verificar se está funcionando
+
+1. Abra o DevTools do navegador (F12)
+2. Vá na aba **Application** > **Service Workers**
+3. Verifique se o Service Worker está registrado
+4. Vá em **Application** > **Notifications**
+5. Verifique se a permissão está concedida
+
+### 3.3 Testar envio de push
+
+1. Abra o chat em duas abas/janelas diferentes
+2. Faça login com usuários diferentes
+3. Envie uma mensagem de um usuário para o outro
+4. A mensagem deve aparecer como notificação push na outra aba
+
+## Troubleshooting
+
+### Push notifications não funcionam
+
+1. **Verificar VAPID keys**: Certifique-se de que as keys estão configuradas corretamente
+2. **Verificar Service Worker**: O arquivo `sw.js` deve estar em `/static/sw.js`
+3. **Verificar permissões**: O navegador deve ter permissão para notificações
+4. **Verificar console**: Procure por erros no console do navegador e do Convex
+
+### Erro "VAPID keys não configuradas"
+
+- Verifique se as variáveis de ambiente estão configuradas no Convex
+- Reinicie o servidor Convex após configurar as variáveis
+- Verifique se os nomes das variáveis estão corretos (case-sensitive)
+
+### Service Worker não registra
+
+- Verifique se o arquivo `sw.js` existe em `apps/web/static/sw.js`
+- Verifique se o servidor está servindo arquivos estáticos corretamente
+- Limpe o cache do navegador e tente novamente
+
+## Segurança
+
+⚠️ **IMPORTANTE**:
+- A **Private Key** nunca deve ser exposta no frontend
+- Use variáveis de ambiente diferentes para desenvolvimento e produção
+- Regenere as keys se suspeitar de comprometimento
+- Mantenha as keys em segredo (não commite no Git)
+
diff --git a/GUIA_TESTE_PUSH_NOTIFICATIONS.md b/GUIA_TESTE_PUSH_NOTIFICATIONS.md
new file mode 100644
index 0000000..1e31371
--- /dev/null
+++ b/GUIA_TESTE_PUSH_NOTIFICATIONS.md
@@ -0,0 +1,214 @@
+# 🧪 Guia de Teste - Push Notifications e Melhorias do Chat
+
+## Pré-requisitos
+
+1. ✅ Convex rodando (`cd packages/backend && bun run dev`)
+2. ✅ Frontend rodando (`cd apps/web && bun run dev`)
+3. ✅ Variáveis de ambiente configuradas (ver `configurar-variaveis-ambiente.md`)
+4. ✅ Usuários criados no sistema
+
+## Teste 1: Configuração de Push Notifications
+
+### 1.1 Verificar Service Worker
+
+1. Abra o navegador em `http://localhost:5173`
+2. Faça login no sistema
+3. Abra DevTools (F12)
+4. Vá em **Application** > **Service Workers**
+5. ✅ Verifique se `sw.js` está registrado e ativo
+
+### 1.2 Solicitar Permissão de Notificações
+
+1. Abra o chat no sistema
+2. O sistema deve solicitar permissão para notificações automaticamente
+3. Clique em **Permitir**
+4. ✅ Verifique em **Application** > **Notifications** que a permissão está concedida
+
+### 1.3 Verificar Subscription
+
+1. Abra o Console do DevTools
+2. Execute:
+```javascript
+navigator.serviceWorker.ready.then(reg => {
+ reg.pushManager.getSubscription().then(sub => {
+ console.log('Subscription:', sub);
+ });
+});
+```
+3. ✅ Deve retornar um objeto Subscription com endpoint e keys
+
+## Teste 2: Envio e Recebimento de Push Notifications
+
+### 2.1 Teste Básico
+
+1. Abra o sistema em **duas abas diferentes** (ou dois navegadores)
+2. Faça login com usuários diferentes em cada aba
+3. Na aba 1, abra uma conversa com o usuário da aba 2
+4. Envie uma mensagem da aba 1
+5. ✅ A aba 2 deve receber uma notificação push (mesmo se estiver em background)
+
+### 2.2 Teste de Menção
+
+1. Na aba 1, envie uma mensagem mencionando o usuário da aba 2 (use @)
+2. ✅ A aba 2 deve receber uma notificação push destacada
+
+### 2.3 Teste Offline
+
+1. Feche a aba 2 (ou coloque o navegador em modo offline)
+2. Envie uma mensagem da aba 1
+3. ✅ O sistema deve enviar um email para o usuário da aba 2 (se estiver offline)
+
+## Teste 3: Edição de Mensagens
+
+### 3.1 Editar Mensagem Própria
+
+1. Envie uma mensagem no chat
+2. Clique no ícone ✏️ ao lado da mensagem
+3. Edite o conteúdo
+4. Pressione **Ctrl+Enter** ou clique em **Salvar**
+5. ✅ A mensagem deve ser atualizada com indicador "(editado)"
+
+### 3.2 Tentar Editar Mensagem de Outro Usuário
+
+1. Tente editar uma mensagem de outro usuário
+2. ✅ Não deve aparecer o botão de editar (ou deve retornar erro)
+
+## Teste 4: Soft Delete de Mensagens
+
+### 4.1 Deletar Mensagem Própria
+
+1. Envie uma mensagem
+2. Clique no ícone 🗑️ ao lado da mensagem
+3. Confirme a exclusão
+4. ✅ A mensagem deve ser marcada como "Mensagem deletada"
+
+### 4.2 Tentar Deletar Mensagem de Outro Usuário
+
+1. Tente deletar uma mensagem de outro usuário
+2. ✅ Não deve aparecer o botão de deletar (ou deve retornar erro)
+
+## Teste 5: Respostas Encadeadas
+
+### 5.1 Responder Mensagem
+
+1. Clique no botão **↪️ Responder** em uma mensagem
+2. ✅ Deve aparecer um preview da mensagem original no campo de input
+3. Digite sua resposta e envie
+4. ✅ A mensagem enviada deve mostrar o preview da mensagem original acima
+
+### 5.2 Visualizar Thread
+
+1. Envie várias respostas para diferentes mensagens
+2. ✅ Cada resposta deve mostrar claramente qual mensagem está respondendo
+
+## Teste 6: Preview de Links
+
+### 6.1 Enviar Mensagem com URL
+
+1. Envie uma mensagem contendo uma URL (ex: `https://www.google.com`)
+2. Aguarde alguns segundos
+3. ✅ Deve aparecer um preview do link abaixo da mensagem com:
+ - Imagem (se disponível)
+ - Título
+ - Descrição
+ - Site/nome do domínio
+
+### 6.2 Testar Diferentes URLs
+
+Teste com diferentes tipos de URLs:
+- ✅ Google: `https://www.google.com`
+- ✅ YouTube: `https://www.youtube.com`
+- ✅ Artigo de notícia
+- ✅ Site sem Open Graph (deve funcionar mesmo assim)
+
+## Teste 7: Busca Full-Text
+
+### 7.1 Busca Básica
+
+1. Envie algumas mensagens com palavras específicas
+2. Use a busca no chat (se implementada) ou a query de busca
+3. ✅ Deve encontrar mensagens mesmo com acentos diferentes
+
+### 7.2 Busca com Filtros
+
+1. Busque mensagens por:
+ - ✅ Remetente específico
+ - ✅ Tipo (texto, arquivo, imagem)
+ - ✅ Período de data
+2. ✅ Os filtros devem funcionar corretamente
+
+## Teste 8: Rate Limiting de Emails
+
+### 8.1 Enviar Múltiplos Emails
+
+1. Configure o sistema para enviar emails
+2. Tente enviar mais de 10 emails em 1 minuto
+3. ✅ Deve retornar erro de rate limit após o limite
+
+### 8.2 Verificar Delay Exponencial
+
+1. Aguarde o rate limit ser aplicado
+2. Tente enviar novamente
+3. ✅ Deve haver um delay antes de permitir novo envio
+
+## Checklist de Validação
+
+- [ ] Service Worker registrado e funcionando
+- [ ] Permissão de notificações concedida
+- [ ] Push notifications sendo recebidas
+- [ ] Emails sendo enviados quando usuário offline
+- [ ] Edição de mensagens funcionando
+- [ ] Soft delete funcionando
+- [ ] Respostas encadeadas funcionando
+- [ ] Preview de links aparecendo
+- [ ] Busca full-text funcionando
+- [ ] Rate limiting de emails funcionando
+
+## Problemas Comuns e Soluções
+
+### Push notifications não funcionam
+
+**Problema**: Notificações não aparecem
+
+**Soluções**:
+1. Verifique se as VAPID keys estão configuradas no Convex
+2. Verifique se `VITE_VAPID_PUBLIC_KEY` está no `.env` do frontend
+3. Reinicie o servidor Convex e frontend
+4. Limpe o cache do navegador
+5. Verifique o console para erros
+
+### Preview de links não aparece
+
+**Problema**: Links não geram preview
+
+**Soluções**:
+1. Verifique se a URL é válida (começa com http:// ou https://)
+2. Aguarde alguns segundos (processamento é assíncrono)
+3. Verifique o console do Convex para erros na extração
+4. Alguns sites bloqueiam scrapers - isso é normal
+
+### Edição não funciona
+
+**Problema**: Botão de editar não aparece ou não funciona
+
+**Soluções**:
+1. Verifique se a mensagem é sua (só pode editar próprias mensagens)
+2. Verifique se a mensagem não foi deletada
+3. Verifique o console para erros
+4. Certifique-se de que a mutation `editarMensagem` está funcionando
+
+## Relatório de Testes
+
+Após completar os testes, preencha:
+
+- **Data**: ___________
+- **Testador**: ___________
+- **Ambiente**: [ ] Desenvolvimento [ ] Produção
+- **Navegador**: ___________
+- **Resultados**: ___________
+
+**Observações**:
+_______________________________________
+_______________________________________
+_______________________________________
+
diff --git a/PASSO_A_PASSO_CONFIGURACAO.md b/PASSO_A_PASSO_CONFIGURACAO.md
new file mode 100644
index 0000000..46a90ce
--- /dev/null
+++ b/PASSO_A_PASSO_CONFIGURACAO.md
@@ -0,0 +1,163 @@
+# 📋 Passo a Passo - Configuração Completa
+
+## ✅ Passo 1: Configurar VAPID Keys
+
+### 1.1 Configurar no Convex (Backend)
+
+**Opção A: Via Dashboard (Recomendado)**
+
+1. Acesse https://dashboard.convex.dev
+2. Selecione seu projeto
+3. Vá em **Settings** > **Environment Variables**
+4. Adicione as seguintes variáveis:
+
+```
+VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+VAPID_PRIVATE_KEY=KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4
+FRONTEND_URL=http://localhost:5173
+```
+
+**Opção B: Via CLI**
+
+Execute do diretório raiz do projeto:
+
+```powershell
+cd packages/backend
+npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+npx convex env set FRONTEND_URL "http://localhost:5173"
+```
+
+**Opção C: Usar Script Automático**
+
+Execute na raiz do projeto:
+
+```powershell
+.\scripts\configurar-push-notifications.ps1
+```
+
+### 1.2 Configurar no Frontend
+
+Crie o arquivo `apps/web/.env` com:
+
+```env
+VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+```
+
+**Importante**: Reinicie o servidor frontend após criar/modificar o `.env`
+
+## ✅ Passo 2: Configurar FRONTEND_URL
+
+A variável `FRONTEND_URL` já foi configurada no Passo 1.1. Ela é usada nos templates de email para gerar links de volta ao sistema.
+
+**Para Desenvolvimento:**
+```
+FRONTEND_URL=http://localhost:5173
+```
+
+**Para Produção (quando fizer deploy):**
+```
+FRONTEND_URL=https://seu-dominio.com
+```
+
+## ✅ Passo 3: Testar Funcionalidades
+
+### 3.1 Verificar Configuração Inicial
+
+1. **Inicie o Convex** (se não estiver rodando):
+ ```bash
+ cd packages/backend
+ bun run dev
+ ```
+
+2. **Inicie o Frontend** (se não estiver rodando):
+ ```bash
+ cd apps/web
+ bun run dev
+ ```
+
+3. **Verifique as variáveis de ambiente**:
+ - No Convex Dashboard: Settings > Environment Variables
+ - No Frontend: Verifique se `apps/web/.env` existe
+
+### 3.2 Testar Push Notifications
+
+1. Abra `http://localhost:5173` no navegador
+2. Faça login no sistema
+3. Abra DevTools (F12) > **Application** > **Service Workers**
+4. ✅ Verifique se `sw.js` está registrado
+5. ✅ Verifique se a permissão de notificações foi solicitada
+
+### 3.3 Testar Chat Completo
+
+Siga o guia completo em `GUIA_TESTE_PUSH_NOTIFICATIONS.md` para testar:
+- ✅ Push notifications
+- ✅ Edição de mensagens
+- ✅ Soft delete
+- ✅ Respostas encadeadas
+- ✅ Preview de links
+- ✅ Busca full-text
+
+## 🔍 Verificação Rápida
+
+Execute estes comandos para verificar:
+
+### Verificar Variáveis no Convex:
+```bash
+cd packages/backend
+npx convex env list
+```
+
+Deve mostrar:
+- `VAPID_PUBLIC_KEY`
+- `VAPID_PRIVATE_KEY`
+- `FRONTEND_URL`
+
+### Verificar Frontend:
+```bash
+cd apps/web
+# Verifique se o arquivo .env existe
+cat .env
+```
+
+## 🐛 Troubleshooting
+
+### Problema: Variáveis não aparecem no Convex
+
+**Solução**:
+- Certifique-se de estar no projeto correto no dashboard
+- Reinicie o servidor Convex após configurar
+- Use `npx convex env list` para verificar
+
+### Problema: Frontend não encontra VAPID_PUBLIC_KEY
+
+**Solução**:
+- Verifique se o arquivo `.env` está em `apps/web/.env`
+- Verifique se a variável começa com `VITE_`
+- Reinicie o servidor frontend
+- Limpe o cache do navegador
+
+### Problema: Service Worker não registra
+
+**Solução**:
+- Verifique se `apps/web/static/sw.js` existe
+- Abra DevTools > Application > Service Workers
+- Clique em "Unregister" e recarregue a página
+- Verifique o console para erros
+
+## 📝 Checklist Final
+
+- [ ] VAPID keys configuradas no Convex
+- [ ] FRONTEND_URL configurada no Convex
+- [ ] VITE_VAPID_PUBLIC_KEY no `.env` do frontend
+- [ ] Convex rodando
+- [ ] Frontend rodando
+- [ ] Service Worker registrado
+- [ ] Permissão de notificações concedida
+- [ ] Push notifications funcionando
+- [ ] Todas as funcionalidades testadas
+
+## 🎉 Pronto!
+
+Após completar os 3 passos, o sistema estará totalmente configurado e pronto para uso!
+
diff --git a/RESUMO_CONFIGURACAO_COMPLETA.md b/RESUMO_CONFIGURACAO_COMPLETA.md
new file mode 100644
index 0000000..a7a83b9
--- /dev/null
+++ b/RESUMO_CONFIGURACAO_COMPLETA.md
@@ -0,0 +1,68 @@
+# ✅ Resumo da Configuração Completa
+
+## 📋 Passo 1: VAPID Keys - CONCLUÍDO
+
+### Keys Geradas:
+- **Public Key**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+- **Private Key**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4`
+
+### Configuração Necessária:
+
+**1. No Convex (Backend):**
+- Via Dashboard: Settings > Environment Variables
+- Adicionar: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `FRONTEND_URL`
+- OU executar: `.\scripts\configurar-push-notifications.ps1`
+
+**2. No Frontend:**
+- Criar arquivo `apps/web/.env`
+- Adicionar: `VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+
+## 📋 Passo 2: FRONTEND_URL - CONCLUÍDO
+
+- Valor padrão: `http://localhost:5173`
+- Configurar no Convex junto com as VAPID keys
+- Usado nos templates de email para links de retorno
+
+## 📋 Passo 3: Testes - PRONTO PARA EXECUTAR
+
+### Arquivos Criados:
+- ✅ `PushNotificationManager.svelte` - Registra subscription automaticamente
+- ✅ `GUIA_TESTE_PUSH_NOTIFICATIONS.md` - Guia completo de testes
+- ✅ `PASSO_A_PASSO_CONFIGURACAO.md` - Instruções detalhadas
+- ✅ `CONFIGURACAO_PUSH_NOTIFICATIONS.md` - Documentação técnica
+- ✅ `scripts/configurar-push-notifications.ps1` - Script automático
+
+### Para Testar:
+
+1. **Configure as variáveis** (ver Passo 1)
+2. **Reinicie os servidores** (Convex e Frontend)
+3. **Faça login** no sistema
+4. **Siga o guia**: `GUIA_TESTE_PUSH_NOTIFICATIONS.md`
+
+## 🎯 Checklist de Configuração
+
+- [ ] VAPID keys configuradas no Convex Dashboard
+- [ ] FRONTEND_URL configurada no Convex
+- [ ] Arquivo `apps/web/.env` criado com VITE_VAPID_PUBLIC_KEY
+- [ ] Convex reiniciado após configurar variáveis
+- [ ] Frontend reiniciado após criar .env
+- [ ] Service Worker registrado (verificar DevTools)
+- [ ] Permissão de notificações concedida
+- [ ] Testes executados conforme guia
+
+## 📚 Documentação Disponível
+
+1. **CONFIGURACAO_PUSH_NOTIFICATIONS.md** - Configuração técnica detalhada
+2. **PASSO_A_PASSO_CONFIGURACAO.md** - Instruções passo a passo
+3. **GUIA_TESTE_PUSH_NOTIFICATIONS.md** - Guia completo de testes
+4. **configurar-variaveis-ambiente.md** - Referência rápida
+
+## 🚀 Próximos Passos
+
+1. Execute o script de configuração OU configure manualmente
+2. Reinicie os servidores
+3. Teste todas as funcionalidades
+4. Reporte qualquer problema encontrado
+
+**Tudo pronto para configuração e testes!** 🎉
+
diff --git a/apps/web/src/lib/components/PushNotificationManager.svelte b/apps/web/src/lib/components/PushNotificationManager.svelte
new file mode 100644
index 0000000..c1b47f9
--- /dev/null
+++ b/apps/web/src/lib/components/PushNotificationManager.svelte
@@ -0,0 +1,75 @@
+
+
+
+
diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte
index 5b32e0d..a792bd2 100644
--- a/apps/web/src/lib/components/chat/ChatWindow.svelte
+++ b/apps/web/src/lib/components/chat/ChatWindow.svelte
@@ -28,7 +28,7 @@
return null;
}
- const encontrada = conversas.data.find((c: any) => c._id === conversaId);
+ const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
return encontrada;
});
@@ -54,10 +54,10 @@
return "👤";
}
- function getStatusConversa(): any {
+ function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
- return c.outroUsuario.statusPresenca || "offline";
+ return (c.outroUsuario.statusPresenca as "online" | "offline" | "ausente" | "externo" | "em_reuniao") || "offline";
}
return null;
}
@@ -169,20 +169,20 @@
-
-
+
+ } />
-
{#if showScheduleModal}
}
onClose={() => (showScheduleModal = false)}
/>
{/if}
diff --git a/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/web/src/lib/components/chat/MessageInput.svelte
index c36cc0b..5e04763 100644
--- a/apps/web/src/lib/components/chat/MessageInput.svelte
+++ b/apps/web/src/lib/components/chat/MessageInput.svelte
@@ -18,6 +18,7 @@
let uploadingFile = $state(false);
let digitacaoTimeout: ReturnType | null = null;
let showEmojiPicker = $state(false);
+ let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
// Emojis mais usados
const emojis = [
@@ -62,6 +63,7 @@
conversaId,
conteudo: texto,
tipo: "texto",
+ respostaPara: mensagemRespondendo?.id,
});
try {
@@ -70,11 +72,13 @@
conversaId,
conteudo: texto,
tipo: "texto",
+ respostaPara: mensagemRespondendo?.id,
});
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
mensagem = "";
+ mensagemRespondendo = null;
if (textarea) {
textarea.style.height = "auto";
}
@@ -86,6 +90,34 @@
}
}
+ function cancelarResposta() {
+ mensagemRespondendo = null;
+ }
+
+ // Escutar evento de resposta
+ onMount(() => {
+ const handler = (e: Event) => {
+ const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
+ // Buscar informações da mensagem para exibir preview
+ client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
+ const msg = mensagens.find((m: any) => m._id === customEvent.detail.mensagemId);
+ if (msg) {
+ mensagemRespondendo = {
+ id: msg._id,
+ conteudo: msg.conteudo.substring(0, 100),
+ remetente: msg.remetente?.nome || "Usuário",
+ };
+ textarea?.focus();
+ }
+ });
+ };
+
+ window.addEventListener("responderMensagem", handler);
+ return () => {
+ window.removeEventListener("responderMensagem", handler);
+ };
+ });
+
function handleKeyDown(e: KeyboardEvent) {
// Enter sem Shift = enviar
if (e.key === "Enter" && !e.shiftKey) {
@@ -154,6 +186,24 @@
+
+ {#if mensagemRespondendo}
+
+
+
Respondendo a {mensagemRespondendo.remetente}
+
{mensagemRespondendo.conteudo}
+
+
+ ✕
+
+
+ {/if}
+
;
@@ -18,33 +19,43 @@
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
+ let lastMessageCount = 0;
- // DEBUG: Log quando mensagens mudam
- $effect(() => {
- console.log("💬 [MessageList] Mensagens atualizadas:", {
- conversaId,
- count: mensagens?.data?.length || 0,
- mensagens: mensagens?.data,
- });
- });
+ // Obter ID do usuário atual
+ const usuarioAtualId = $derived(authStore.usuario?._id);
- // Auto-scroll para a última mensagem
+ // Auto-scroll para a última mensagem quando novas mensagens chegam
$effect(() => {
- if (mensagens?.data && shouldScrollToBottom && messagesContainer) {
- tick().then(() => {
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
- });
+ if (mensagens?.data && messagesContainer) {
+ const currentCount = mensagens.data.length;
+ const isNewMessage = currentCount > lastMessageCount;
+
+ if (isNewMessage || shouldScrollToBottom) {
+ // Usar requestAnimationFrame para garantir que o DOM foi atualizado
+ requestAnimationFrame(() => {
+ tick().then(() => {
+ if (messagesContainer) {
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
+ }
+ });
+ });
+ }
+
+ lastMessageCount = currentCount;
}
});
// Marcar como lida quando mensagens carregam
$effect(() => {
- if (mensagens?.data && mensagens.data.length > 0) {
+ if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
- client.mutation(api.chat.marcarComoLida, {
- conversaId,
- mensagemId: ultimaMensagem._id as any,
- });
+ // Só marcar como lida se não for minha mensagem
+ if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
+ client.mutation(api.chat.marcarComoLida, {
+ conversaId,
+ mensagemId: ultimaMensagem._id,
+ });
+ }
}
});
@@ -64,8 +75,46 @@
}
}
- function agruparMensagensPorDia(msgs: any[]): Record {
- const grupos: Record = {};
+ interface Mensagem {
+ _id: Id<"mensagens">;
+ remetente?: {
+ _id: Id<"usuarios">;
+ nome: string;
+ } | null;
+ conteudo: string;
+ tipo: "texto" | "arquivo" | "imagem";
+ enviadaEm: number;
+ editadaEm?: number;
+ deletada?: boolean;
+ agendadaPara?: number;
+ respostaPara?: Id<"mensagens">;
+ mensagemOriginal?: {
+ _id: Id<"mensagens">;
+ conteudo: string;
+ remetente: {
+ _id: Id<"usuarios">;
+ nome: string;
+ } | null;
+ deletada: boolean;
+ } | null;
+ reagiuPor?: Array<{
+ usuarioId: Id<"usuarios">;
+ emoji: string;
+ }>;
+ arquivoUrl?: string | null;
+ arquivoNome?: string;
+ arquivoTamanho?: number;
+ linkPreview?: {
+ url: string;
+ titulo?: string;
+ descricao?: string;
+ imagem?: string;
+ site?: string;
+ } | null;
+ }
+
+ function agruparMensagensPorDia(msgs: Mensagem[]): Record {
+ const grupos: Record = {};
for (const msg of msgs) {
const dia = formatarDiaMensagem(msg.enviadaEm);
if (!grupos[dia]) {
@@ -83,14 +132,14 @@
shouldScrollToBottom = isAtBottom;
}
- async function handleReagir(mensagemId: string, emoji: string) {
+ async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
await client.mutation(api.chat.reagirMensagem, {
- mensagemId: mensagemId as any,
+ mensagemId,
emoji,
});
}
- function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
+ function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
const emojiMap: Record = {};
@@ -100,6 +149,64 @@
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
}
+
+ let mensagemEditando: Mensagem | null = $state(null);
+ let novoConteudoEditado = $state("");
+
+ async function editarMensagem(mensagem: Mensagem) {
+ mensagemEditando = mensagem;
+ novoConteudoEditado = mensagem.conteudo;
+ }
+
+ async function salvarEdicao() {
+ if (!mensagemEditando || !novoConteudoEditado.trim()) return;
+
+ try {
+ const resultado = await client.mutation(api.chat.editarMensagem, {
+ mensagemId: mensagemEditando._id,
+ novoConteudo: novoConteudoEditado.trim(),
+ });
+
+ if (resultado.sucesso) {
+ mensagemEditando = null;
+ novoConteudoEditado = "";
+ } else {
+ alert(resultado.erro || "Erro ao editar mensagem");
+ }
+ } catch (error) {
+ console.error("Erro ao editar mensagem:", error);
+ alert("Erro ao editar mensagem");
+ }
+ }
+
+ function cancelarEdicao() {
+ mensagemEditando = null;
+ novoConteudoEditado = "";
+ }
+
+ async function deletarMensagem(mensagemId: Id<"mensagens">) {
+ if (!confirm("Tem certeza que deseja deletar esta mensagem?")) {
+ return;
+ }
+
+ try {
+ await client.mutation(api.chat.deletarMensagem, {
+ mensagemId,
+ });
+ } catch (error) {
+ console.error("Erro ao deletar mensagem:", error);
+ alert("Erro ao deletar mensagem");
+ }
+ }
+
+ // Função para responder mensagem (será passada via props ou event)
+ function responderMensagem(mensagem: Mensagem) {
+ // Disparar evento customizado para o componente pai
+ const event = new CustomEvent("responderMensagem", {
+ detail: { mensagemId: mensagem._id },
+ });
+ window.dispatchEvent(event);
+ }
{#each mensagensDia as mensagem (mensagem._id)}
- {@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id}
-
-
+ {@const isMinha = mensagem.remetente?._id === usuarioAtualId}
+
+
{#if !isMinha}
@@ -137,10 +244,92 @@
: "bg-base-200 text-base-content rounded-bl-sm"
}`}
>
- {#if mensagem.deletada}
+ {#if mensagem.mensagemOriginal}
+
+
+
+ {mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
+
+
+ {mensagem.mensagemOriginal.deletada
+ ? "Mensagem deletada"
+ : mensagem.mensagemOriginal.conteudo}
+
+
+ {/if}
+ {#if mensagemEditando?._id === mensagem._id}
+
+
+
+
+
+ Cancelar
+
+
+ Salvar
+
+
+
+ {:else if mensagem.deletada}
Mensagem deletada
{:else if mensagem.tipo === "texto"}
-
{mensagem.conteudo}
+
{:else if mensagem.tipo === "imagem"}
{/if}
+
+
+ {#if !mensagem.deletada}
+
responderMensagem(mensagem)}
+ title="Responder"
+ >
+ ↪️ Responder
+
+ {/if}
-
-
+
+
+
{formatarDataMensagem(mensagem.enviadaEm)}
+ {#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
+
+ editarMensagem(mensagem)}
+ title="Editar mensagem"
+ >
+ ✏️
+
+ deletarMensagem(mensagem._id)}
+ title="Deletar mensagem"
+ >
+ 🗑️
+
+
+ {/if}
+
{/each}
@@ -226,7 +446,7 @@
>
- {digitando.data.map((u: any) => u.nome).join(", ")} {digitando.data.length === 1
+ {digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1
? "está digitando"
: "estão digitando"}...
diff --git a/apps/web/src/lib/utils/notifications.ts b/apps/web/src/lib/utils/notifications.ts
index 301dbcb..081ad53 100644
--- a/apps/web/src/lib/utils/notifications.ts
+++ b/apps/web/src/lib/utils/notifications.ts
@@ -1,66 +1,207 @@
-/**
- * Solicita permissão para notificações desktop
- */
-export async function requestNotificationPermission(): Promise
{
- if (!("Notification" in window)) {
- console.warn("Este navegador não suporta notificações desktop");
- return "denied";
- }
-
- if (Notification.permission === "granted") {
- return "granted";
- }
-
- if (Notification.permission !== "denied") {
- return await Notification.requestPermission();
- }
-
- return Notification.permission;
-}
-
-/**
- * Mostra uma notificação desktop
- */
-export function showNotification(title: string, options?: NotificationOptions): Notification | null {
- if (!("Notification" in window)) {
- return null;
- }
-
- if (Notification.permission !== "granted") {
- return null;
- }
-
- try {
- return new Notification(title, {
- icon: "/favicon.png",
- badge: "/favicon.png",
- ...options,
- });
- } catch (error) {
- console.error("Erro ao exibir notificação:", error);
- return null;
- }
-}
-
-/**
- * Toca o som de notificação
- */
-export function playNotificationSound() {
- try {
- const audio = new Audio("/sounds/notification.mp3");
- audio.volume = 0.5;
- audio.play().catch((err) => {
- console.warn("Não foi possível reproduzir o som de notificação:", err);
- });
- } catch (error) {
- console.error("Erro ao tocar som de notificação:", error);
- }
-}
-
-/**
- * Verifica se o usuário está na aba ativa
- */
-export function isTabActive(): boolean {
- return !document.hidden;
-}
-
+/**
+ * Solicita permissão para notificações desktop
+ */
+export async function requestNotificationPermission(): Promise {
+ if (!("Notification" in window)) {
+ console.warn("Este navegador não suporta notificações desktop");
+ return "denied";
+ }
+
+ if (Notification.permission === "granted") {
+ return "granted";
+ }
+
+ if (Notification.permission !== "denied") {
+ return await Notification.requestPermission();
+ }
+
+ return Notification.permission;
+}
+
+/**
+ * Mostra uma notificação desktop
+ */
+export function showNotification(title: string, options?: NotificationOptions): Notification | null {
+ if (!("Notification" in window)) {
+ return null;
+ }
+
+ if (Notification.permission !== "granted") {
+ return null;
+ }
+
+ try {
+ return new Notification(title, {
+ icon: "/favicon.png",
+ badge: "/favicon.png",
+ ...options,
+ });
+ } catch (error) {
+ console.error("Erro ao exibir notificação:", error);
+ return null;
+ }
+}
+
+/**
+ * Toca o som de notificação
+ */
+export function playNotificationSound() {
+ try {
+ const audio = new Audio("/sounds/notification.mp3");
+ audio.volume = 0.5;
+ audio.play().catch((err) => {
+ console.warn("Não foi possível reproduzir o som de notificação:", err);
+ });
+ } catch (error) {
+ console.error("Erro ao tocar som de notificação:", error);
+ }
+}
+
+/**
+ * Verifica se o usuário está na aba ativa
+ */
+export function isTabActive(): boolean {
+ return !document.hidden;
+}
+
+/**
+ * Registrar service worker para push notifications
+ */
+export async function registrarServiceWorker(): Promise {
+ if (!("serviceWorker" in navigator)) {
+ console.warn("Service Workers não são suportados neste navegador");
+ return null;
+ }
+
+ try {
+ const registration = await navigator.serviceWorker.register("/sw.js", {
+ scope: "/",
+ });
+ console.log("Service Worker registrado:", registration);
+ return registration;
+ } catch (error) {
+ console.error("Erro ao registrar Service Worker:", error);
+ return null;
+ }
+}
+
+/**
+ * Solicitar subscription de push notification
+ */
+export async function solicitarPushSubscription(): Promise {
+ // Registrar service worker primeiro
+ const registration = await registrarServiceWorker();
+ if (!registration) {
+ return null;
+ }
+
+ // Verificar se push está disponível
+ if (!("PushManager" in window)) {
+ console.warn("Push notifications não são suportadas neste navegador");
+ return null;
+ }
+
+ // Solicitar permissão
+ const permission = await requestNotificationPermission();
+ if (permission !== "granted") {
+ console.warn("Permissão para notificações negada");
+ return null;
+ }
+
+ try {
+ // Obter subscription existente ou criar nova
+ let subscription = await registration.pushManager.getSubscription();
+
+ if (!subscription) {
+ // VAPID public key deve vir do backend ou config
+ // Por enquanto, usando uma chave pública de exemplo
+ // Em produção, isso deve vir de uma variável de ambiente ou API
+ const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
+
+ if (!vapidPublicKey) {
+ console.warn("VAPID public key não configurada");
+ return null;
+ }
+
+ // Converter chave para formato Uint8Array
+ const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
+
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey,
+ });
+ }
+
+ return subscription;
+ } catch (error) {
+ console.error("Erro ao obter subscription:", error);
+ return null;
+ }
+}
+
+/**
+ * Converter chave VAPID de base64 URL-safe para Uint8Array
+ */
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+/**
+ * Converter PushSubscription para formato serializável
+ */
+export function subscriptionToJSON(subscription: PushSubscription): {
+ endpoint: string;
+ keys: { p256dh: string; auth: string };
+} {
+ const key = subscription.getKey("p256dh");
+ const auth = subscription.getKey("auth");
+
+ if (!key || !auth) {
+ throw new Error("Chaves de subscription não encontradas");
+ }
+
+ return {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: arrayBufferToBase64(key),
+ auth: arrayBufferToBase64(auth),
+ },
+ };
+}
+
+/**
+ * Converter ArrayBuffer para base64
+ */
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return window.btoa(binary);
+}
+
+/**
+ * Remover subscription de push notification
+ */
+export async function removerPushSubscription(): Promise {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+
+ if (subscription) {
+ await subscription.unsubscribe();
+ return true;
+ }
+
+ return false;
+}
+
diff --git a/apps/web/src/routes/(dashboard)/+layout.svelte b/apps/web/src/routes/(dashboard)/+layout.svelte
index 6bf80eb..0e974de 100644
--- a/apps/web/src/routes/(dashboard)/+layout.svelte
+++ b/apps/web/src/routes/(dashboard)/+layout.svelte
@@ -1,69 +1,73 @@
-
-
-{#if routeAction}
-
-
- {@render children()}
-
-
-{:else}
-
- {@render children()}
-
-{/if}
-
-
-
+
+
+{#if routeAction}
+
+
+ {@render children()}
+
+
+{:else}
+
+ {@render children()}
+
+{/if}
+
+
+
+
+
+
diff --git a/apps/web/static/sw.js b/apps/web/static/sw.js
new file mode 100644
index 0000000..d07439d
--- /dev/null
+++ b/apps/web/static/sw.js
@@ -0,0 +1,70 @@
+// Service Worker para Push Notifications
+self.addEventListener("install", (event) => {
+ console.log("Service Worker instalado");
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ console.log("Service Worker ativado");
+ event.waitUntil(self.clients.claim());
+});
+
+// Escutar push notifications
+self.addEventListener("push", (event) => {
+ console.log("Push notification recebida:", event);
+
+ let data = {};
+ if (event.data) {
+ try {
+ data = event.data.json();
+ } catch (e) {
+ data = { title: "Nova notificação", body: event.data.text() };
+ }
+ }
+
+ const title = data.title || "SGSE";
+ const options = {
+ body: data.body || "Você tem uma nova notificação",
+ icon: data.icon || "/favicon.png",
+ badge: data.badge || "/favicon.png",
+ tag: data.tag || "default",
+ requireInteraction: data.requireInteraction || false,
+ data: data.data || {},
+ };
+
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+// Escutar cliques em notificações
+self.addEventListener("notificationclick", (event) => {
+ console.log("Notificação clicada:", event);
+
+ event.notification.close();
+
+ // Abrir/focar na aplicação
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: "window", includeUncontrolled: true })
+ .then((clientList) => {
+ // Se há um cliente aberto, focar nele
+ for (const client of clientList) {
+ if (client.url && "focus" in client) {
+ return client.focus();
+ }
+ }
+
+ // Se não há cliente aberto, abrir nova janela
+ if (self.clients.openWindow) {
+ const data = event.notification.data;
+ let url = "/";
+
+ if (data?.conversaId) {
+ url = `/chat?conversa=${data.conversaId}`;
+ }
+
+ return self.clients.openWindow(url);
+ }
+ })
+ );
+});
+
diff --git a/configurar-variaveis-ambiente.md b/configurar-variaveis-ambiente.md
new file mode 100644
index 0000000..c31c8d4
--- /dev/null
+++ b/configurar-variaveis-ambiente.md
@@ -0,0 +1,77 @@
+# ⚙️ Configuração de Variáveis de Ambiente
+
+## Passo 1: Configurar VAPID Keys no Convex
+
+### Opção A: Via Dashboard Convex (Recomendado)
+
+1. Acesse https://dashboard.convex.dev
+2. Selecione seu projeto SGSE
+3. Vá em **Settings** > **Environment Variables**
+4. Clique em **Add Variable** e adicione uma por vez:
+
+**Variável 1:**
+- **Name**: `VAPID_PUBLIC_KEY`
+- **Value**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+
+**Variável 2:**
+- **Name**: `VAPID_PRIVATE_KEY`
+- **Value**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4`
+
+**Variável 3:**
+- **Name**: `FRONTEND_URL`
+- **Value**: `http://localhost:5173` (ou sua URL de produção)
+
+### Opção B: Via CLI Convex
+
+Execute os seguintes comandos no diretório `packages/backend`:
+
+```bash
+cd packages/backend
+
+# Configurar VAPID Public Key
+npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+
+# Configurar VAPID Private Key
+npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+
+# Configurar URL do Frontend
+npx convex env set FRONTEND_URL "http://localhost:5173"
+```
+
+## Passo 2: Configurar VAPID Public Key no Frontend
+
+Crie um arquivo `.env` no diretório `apps/web/` com o seguinte conteúdo:
+
+```env
+VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+```
+
+**Importante**:
+- Após criar/modificar o `.env`, reinicie o servidor de desenvolvimento do frontend
+- O arquivo `.env` já está no `.gitignore`, então não será commitado
+
+## Passo 3: Verificar Configuração
+
+### Verificar no Convex Dashboard:
+1. Acesse https://dashboard.convex.dev
+2. Vá em **Settings** > **Environment Variables**
+3. Verifique se as 3 variáveis estão listadas
+
+### Verificar no Frontend:
+1. Execute o frontend: `cd apps/web && bun run dev`
+2. Abra o DevTools (F12)
+3. Vá em **Console** e execute: `console.log(import.meta.env.VITE_VAPID_PUBLIC_KEY)`
+4. Deve exibir a chave pública (ou `undefined` se não estiver configurado)
+
+## Chaves Geradas
+
+As seguintes VAPID keys foram geradas para este projeto:
+
+- **Public Key**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+- **Private Key**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4`
+
+⚠️ **IMPORTANTE**:
+- A Private Key deve ser mantida em segredo
+- Nunca exponha a Private Key no frontend ou em código público
+- Se suspeitar de comprometimento, regenere as keys com `bunx web-push generate-vapid-keys`
+
diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts
index 3ae0249..9ea65a4 100644
--- a/packages/backend/convex/actions/email.ts
+++ b/packages/backend/convex/actions/email.ts
@@ -3,6 +3,7 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
+import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
export const enviar = action({
args: {
@@ -23,47 +24,105 @@ export const enviar = action({
return { sucesso: false, erro: "Email não encontrado" };
}
- // Buscar configuração SMTP ativa com senha descriptografada
- const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {});
-
- if (!config) {
+ // Buscar configuração SMTP ativa
+ const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
+
+ if (!configRaw) {
return {
sucesso: false,
erro: "Configuração de email não encontrada ou inativa",
};
}
- if (!config.testadoEm) {
+ // Descriptografar senha usando função compatível com Node.js
+ let senhaDescriptografada: string;
+ try {
+ senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
+ } catch (decryptError) {
+ const decryptErrorMessage = decryptError instanceof Error ? decryptError.message : String(decryptError);
+ console.error("Erro ao descriptografar senha SMTP:", decryptErrorMessage);
return {
sucesso: false,
- erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!",
+ erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
};
}
+ const config = {
+ ...configRaw,
+ senha: senhaDescriptografada,
+ };
+
+ // Config já foi validado acima
+
+ // Avisar mas não bloquear se não foi testado
+ if (!config.testadoEm) {
+ console.warn("⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...");
+ }
+
// Marcar como enviando
await ctx.runMutation(internal.email.markEmailEnviando, {
emailId: args.emailId,
});
- // Criar transporter do nodemailer
- const transporter = nodemailer.createTransport({
+ // Criar transporter do nodemailer com configuração melhorada
+ const transporterOptions: {
+ host: string;
+ port: number;
+ secure: boolean;
+ requireTLS?: boolean;
+ auth: {
+ user: string;
+ pass: string;
+ };
+ tls?: {
+ rejectUnauthorized: boolean;
+ ciphers?: string;
+ };
+ connectionTimeout: number;
+ greetingTimeout: number;
+ socketTimeout: number;
+ pool?: boolean;
+ maxConnections?: number;
+ maxMessages?: number;
+ } = {
host: config.servidor,
port: config.porta,
secure: config.usarSSL,
- requireTLS: config.usarTLS,
auth: {
user: config.usuario,
pass: config.senha, // Senha já descriptografada
},
- tls: {
- // Permitir certificados autoassinados apenas se necessário
+ connectionTimeout: 15000, // 15 segundos
+ greetingTimeout: 15000,
+ socketTimeout: 15000,
+ pool: true, // Usar pool de conexões
+ maxConnections: 5,
+ maxMessages: 100,
+ };
+
+ // Adicionar TLS apenas se necessário
+ if (config.usarTLS) {
+ transporterOptions.requireTLS = true;
+ transporterOptions.tls = {
+ rejectUnauthorized: false, // Permitir certificados autoassinados
+ };
+ } else if (config.usarSSL) {
+ transporterOptions.tls = {
rejectUnauthorized: false,
- ciphers: "SSLv3",
- },
- connectionTimeout: 10000, // 10 segundos
- greetingTimeout: 10000,
- socketTimeout: 10000,
- });
+ };
+ }
+
+ const transporter = nodemailer.createTransport(transporterOptions);
+
+ // Verificar conexão antes de enviar
+ try {
+ await transporter.verify();
+ console.log("✅ Conexão SMTP verificada com sucesso");
+ } catch (verifyError) {
+ const verifyErrorMessage = verifyError instanceof Error ? verifyError.message : String(verifyError);
+ console.warn("⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", verifyErrorMessage);
+ // Não bloquear envio por falha na verificação, apenas avisar
+ }
// Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -71,13 +130,28 @@ export const enviar = action({
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
}
+ // Criar versão texto do HTML (remover tags e decodificar entidades básicas)
+ const textoPlano = email.corpo
+ .replace(/<[^>]*>/g, "") // Remover tags HTML
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .trim();
+
// Enviar email
const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
- text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML
+ text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
+ headers: {
+ "X-Mailer": "SGSE-Sistema",
+ "X-Priority": "3",
+ },
});
interface MessageInfo {
@@ -102,12 +176,23 @@ export const enviar = action({
return { sucesso: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- console.error("❌ Erro ao enviar email:", errorMessage);
+ const errorStack = error instanceof Error ? error.stack : undefined;
+
+ console.error("❌ Erro ao enviar email:", {
+ emailId: args.emailId,
+ destinatario: email?.destinatario,
+ erro: errorMessage,
+ stack: errorStack,
+ });
+
+ // Marcar como falha com detalhes completos
+ const erroCompleto = errorStack
+ ? `${errorMessage}\n\nStack: ${errorStack}`
+ : errorMessage;
- // Marcar como falha
await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId,
- erro: errorMessage,
+ erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
});
return { sucesso: false, erro: errorMessage };
diff --git a/packages/backend/convex/actions/linkPreview.ts b/packages/backend/convex/actions/linkPreview.ts
new file mode 100644
index 0000000..8a77c53
--- /dev/null
+++ b/packages/backend/convex/actions/linkPreview.ts
@@ -0,0 +1,138 @@
+"use node";
+
+import { action } from "../_generated/server";
+import { v } from "convex/values";
+import { internal } from "../_generated/api";
+
+/**
+ * Extrair preview de link (metadados Open Graph) - função auxiliar
+ */
+async function extrairPreviewLinkHelper(url: string) {
+ try {
+ // Validar URL
+ let urlObj: URL;
+ try {
+ urlObj = new URL(url);
+ } catch {
+ return null;
+ }
+
+ // Buscar HTML da página
+ const response = await fetch(url, {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (compatible; SGSE-Bot/1.0)",
+ },
+ signal: AbortSignal.timeout(5000), // Timeout de 5 segundos
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const html = await response.text();
+
+ // Extrair metadados Open Graph e Twitter Cards
+ const metadata: {
+ titulo?: string;
+ descricao?: string;
+ imagem?: string;
+ site?: string;
+ } = {};
+
+ // Título (og:title ou twitter:title ou )
+ const ogTitleMatch = html.match(/ ([^<]+)<\/title>/i);
+
+ metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
+ if (metadata.titulo) {
+ metadata.titulo = metadata.titulo.trim().substring(0, 200);
+ }
+
+ // Descrição (og:description ou twitter:description ou meta description)
+ const ogDescMatch = html.match(/ {
+ // Extrair preview
+ const preview = await extrairPreviewLinkHelper(args.url);
+
+ if (preview) {
+ // Atualizar mensagem com preview
+ await ctx.runMutation(internal.chat.atualizarLinkPreview, {
+ mensagemId: args.mensagemId,
+ linkPreview: preview,
+ });
+ }
+
+ return null;
+ },
+});
+
+/**
+ * Extrair preview de link (metadados Open Graph) - versão pública
+ */
+export const extrairPreviewLink = action({
+ args: {
+ url: v.string(),
+ },
+ returns: v.union(
+ v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ return await extrairPreviewLinkHelper(args.url);
+ },
+});
diff --git a/packages/backend/convex/actions/pushNotifications.ts b/packages/backend/convex/actions/pushNotifications.ts
new file mode 100644
index 0000000..89a2a99
--- /dev/null
+++ b/packages/backend/convex/actions/pushNotifications.ts
@@ -0,0 +1,93 @@
+"use node";
+
+import { action } from "../_generated/server";
+import { v } from "convex/values";
+import { internal } from "../_generated/api";
+
+/**
+ * Enviar push notification usando Web Push API
+ */
+export const enviarPush = action({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ titulo: v.string(),
+ corpo: v.string(),
+ data: v.optional(
+ v.object({
+ conversaId: v.optional(v.string()),
+ mensagemId: v.optional(v.string()),
+ tipo: v.optional(v.string()),
+ })
+ ),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args) => {
+ try {
+ // Buscar subscription
+ const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
+ subscriptionId: args.subscriptionId,
+ });
+
+ if (!subscription || !subscription.ativo) {
+ return { sucesso: false, erro: "Subscription não encontrada ou inativa" };
+ }
+
+ // Web Push requer VAPID keys (deve estar em variáveis de ambiente)
+ // Por enquanto, vamos usar uma implementação básica
+ // Em produção, você precisará configurar VAPID keys
+
+ const webpush = await import("web-push");
+
+ // VAPID keys devem vir de variáveis de ambiente
+ const publicKey = process.env.VAPID_PUBLIC_KEY;
+ const privateKey = process.env.VAPID_PRIVATE_KEY;
+
+ if (!publicKey || !privateKey) {
+ console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
+ // Em desenvolvimento, podemos retornar sucesso sem enviar
+ return { sucesso: true };
+ }
+
+ webpush.setVapidDetails("mailto:suporte@sgse.app", publicKey, privateKey);
+
+ // Preparar payload da notificação
+ const payload = JSON.stringify({
+ title: args.titulo,
+ body: args.corpo,
+ icon: "/favicon.png",
+ badge: "/favicon.png",
+ data: args.data || {},
+ tag: args.data?.conversaId || "default",
+ requireInteraction: args.data?.tipo === "mencao", // Menções requerem interação
+ });
+
+ // Enviar push notification
+ await webpush.sendNotification(
+ {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: subscription.keys.p256dh,
+ auth: subscription.keys.auth,
+ },
+ },
+ payload
+ );
+
+ console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
+ return { sucesso: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error("❌ Erro ao enviar push notification:", errorMessage);
+
+ // Se subscription inválida, marcar como inativa
+ if (errorMessage.includes("410") || errorMessage.includes("expired")) {
+ await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
+ subscriptionId: args.subscriptionId,
+ });
+ }
+
+ return { sucesso: false, erro: errorMessage };
+ }
+ },
+});
+
diff --git a/packages/backend/convex/actions/utils/nodeCrypto.ts b/packages/backend/convex/actions/utils/nodeCrypto.ts
new file mode 100644
index 0000000..0fc4b44
--- /dev/null
+++ b/packages/backend/convex/actions/utils/nodeCrypto.ts
@@ -0,0 +1,72 @@
+/**
+ * Utilitários de criptografia compatíveis com Node.js
+ * Para uso em actions que rodam em ambiente Node.js
+ */
+
+/**
+ * Descriptografa senha SMTP usando Web Crypto API compatível com Node.js
+ * Esta versão funciona em ambiente Node.js (actions)
+ */
+export async function decryptSMTPPasswordNode(encryptedPassword: string): Promise {
+ try {
+ // Em Node.js, crypto.subtle está disponível globalmente
+ const crypto = globalThis.crypto;
+
+ if (!crypto || !crypto.subtle) {
+ throw new Error("Web Crypto API não disponível");
+ }
+
+ // Chave base - mesma usada em auth/utils.ts
+ const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
+
+ // Importar chave material
+ const keyMaterialKey = await crypto.subtle.importKey(
+ "raw",
+ keyMaterial,
+ { name: "PBKDF2" },
+ false,
+ ["deriveBits", "deriveKey"]
+ );
+
+ // Derivar chave de 256 bits usando PBKDF2
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: new TextEncoder().encode("SGSE-SALT"),
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ keyMaterialKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ );
+
+ // Decodificar base64 manualmente (compatível com Node.js e browser)
+ const binaryString = atob(encryptedPassword);
+ const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
+
+ // Extrair IV e dados criptografados
+ const iv = combined.slice(0, 12);
+ const encrypted = combined.slice(12);
+
+ // Descriptografar
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: iv,
+ },
+ key,
+ encrypted
+ );
+
+ // Converter para string
+ const decoder = new TextDecoder();
+ return decoder.decode(decrypted);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error("Erro ao descriptografar senha SMTP (Node.js):", errorMessage);
+ throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
+ }
+}
+
diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts
index 56bb63d..38c64fa 100644
--- a/packages/backend/convex/chat.ts
+++ b/packages/backend/convex/chat.ts
@@ -2,9 +2,21 @@ import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx, MutationCtx } from "./_generated/server";
+import { internal, api } from "./_generated/api";
// ========== HELPERS ==========
+/**
+ * Normaliza texto para busca (remove acentos, converte para lowercase)
+ */
+function normalizarTextoParaBusca(texto: string): string {
+ return texto
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "") // Remove diacríticos
+ .trim();
+}
+
/**
* Helper function para obter usuário autenticado (Better Auth ou Sessão)
*/
@@ -190,6 +202,7 @@ export const enviarMensagem = mutation({
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id("usuarios"))),
+ respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
},
handler: async (ctx, args) => {
@@ -203,20 +216,78 @@ export const enviarMensagem = mutation({
throw new Error("Você não pertence a esta conversa");
}
+ // Normalizar conteúdo para busca (remover acentos, lowercase)
+ const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
+
+ // Verificar se é resposta a outra mensagem
+ if (args.respostaPara) {
+ const mensagemOriginal = await ctx.db.get(args.respostaPara);
+ if (!mensagemOriginal || mensagemOriginal.conversaId !== args.conversaId) {
+ throw new Error("Mensagem original não encontrada ou não pertence à mesma conversa");
+ }
+ if (mensagemOriginal.deletada) {
+ throw new Error("Não é possível responder a uma mensagem deletada");
+ }
+ }
+
+ // Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto)
+ let linkPreview = undefined;
+ if (args.tipo === "texto") {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ const urls = args.conteudo.match(urlRegex);
+ if (urls && urls.length > 0) {
+ // Pegar primeira URL encontrada
+ const primeiraUrl = urls[0];
+ // Agendar extração de preview (assíncrono, não bloqueia envio)
+ ctx.scheduler.runAfter(1000, api.actions.linkPreview.extrairPreviewLink, {
+ url: primeiraUrl,
+ }).then((preview) => {
+ if (preview) {
+ // Atualizar mensagem com preview via mutation interna
+ return ctx.runMutation(internal.chat.atualizarLinkPreview, {
+ mensagemId,
+ linkPreview: preview,
+ });
+ }
+ }).catch((error) => {
+ console.error("Erro ao agendar/processar preview de link:", error);
+ });
+ }
+ }
+
// Criar mensagem
const mensagemId = await ctx.db.insert("mensagens", {
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
tipo: args.tipo,
conteudo: args.conteudo,
+ conteudoBusca,
arquivoId: args.arquivoId,
arquivoNome: args.arquivoNome,
arquivoTamanho: args.arquivoTamanho,
arquivoTipo: args.arquivoTipo,
mencoes: args.mencoes,
+ respostaPara: args.respostaPara,
enviadaEm: Date.now(),
});
+ // Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
+ if (args.tipo === "texto") {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ const urls = args.conteudo.match(urlRegex);
+ if (urls && urls.length > 0) {
+ // Pegar primeira URL encontrada
+ const primeiraUrl = urls[0];
+ // Agendar processamento de preview via action wrapper
+ ctx.scheduler.runAfter(1000, api.actions.linkPreview.processarPreviewLink, {
+ mensagemId,
+ url: primeiraUrl,
+ }).catch((error) => {
+ console.error("Erro ao agendar processamento de preview de link:", error);
+ });
+ }
+ }
+
// Atualizar última mensagem da conversa
await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100),
@@ -236,20 +307,79 @@ export const enviarMensagem = mutation({
? "mencao"
: "nova_mensagem";
+ const titulo =
+ tipoNotificacao === "mencao"
+ ? `${usuarioAtual.nome} mencionou você`
+ : `Nova mensagem de ${usuarioAtual.nome}`;
+ const descricao = args.conteudo.substring(0, 100);
+
+ // Criar notificação no banco
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: tipoNotificacao,
conversaId: args.conversaId,
mensagemId,
remetenteId: usuarioAtual._id,
- titulo:
- tipoNotificacao === "mencao"
- ? `${usuarioAtual.nome} mencionou você`
- : `Nova mensagem de ${usuarioAtual.nome}`,
- descricao: args.conteudo.substring(0, 100),
+ titulo,
+ descricao,
lida: false,
criadaEm: Date.now(),
});
+
+ // Enviar push notification (assíncrono, não bloqueia)
+ ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
+ usuarioId: participanteId,
+ titulo,
+ corpo: descricao,
+ data: {
+ conversaId: args.conversaId,
+ mensagemId,
+ tipo: tipoNotificacao,
+ },
+ }).catch((error) => {
+ console.error(`Erro ao agendar push para usuário ${participanteId}:`, error);
+ });
+
+ // Se usuário offline, enviar email (assíncrono)
+ const usuarioOnline = await ctx.runQuery(internal.pushNotifications.verificarUsuarioOnline, {
+ usuarioId: participanteId,
+ });
+
+ if (!usuarioOnline) {
+ // Verificar preferências de email para esta conversa
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", participanteId).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ const deveEnviarEmail = !preferencias || preferencias.emailAtivado !== false;
+
+ if (deveEnviarEmail) {
+ // Buscar email do usuário
+ const usuarioParticipante = await ctx.db.get(participanteId);
+ if (usuarioParticipante?.email) {
+ // Obter URL do sistema (padrão: localhost para dev)
+ const urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
+
+ ctx.scheduler.runAfter(1000, api.email.enviarEmailComTemplate, {
+ destinatario: usuarioParticipante.email,
+ destinatarioId: participanteId,
+ templateCodigo: tipoNotificacao === "mencao" ? "chat_mencao" : "chat_mensagem",
+ variaveis: {
+ remetente: usuarioAtual.nome,
+ mensagem: descricao,
+ conversaId: args.conversaId,
+ urlSistema,
+ },
+ enviadoPorId: usuarioAtual._id,
+ }).catch((error) => {
+ console.error(`Erro ao agendar email para usuário ${participanteId}:`, error);
+ });
+ }
+ }
+ }
}
}
} catch (error) {
@@ -558,6 +688,83 @@ export const marcarTodasNotificacoesLidas = mutation({
/**
* Deleta uma mensagem (soft delete)
*/
+/**
+ * Editar mensagem enviada
+ */
+export const editarMensagem = mutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ novoConteudo: v.string(),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) {
+ return { sucesso: false, erro: "Não autenticado" };
+ }
+
+ const mensagem = await ctx.db.get(args.mensagemId);
+ if (!mensagem) {
+ return { sucesso: false, erro: "Mensagem não encontrada" };
+ }
+
+ // Verificar se usuário é o remetente
+ if (mensagem.remetenteId !== usuarioAtual._id) {
+ return { sucesso: false, erro: "Você só pode editar suas próprias mensagens" };
+ }
+
+ // Verificar se mensagem não foi deletada
+ if (mensagem.deletada) {
+ return { sucesso: false, erro: "Não é possível editar uma mensagem deletada" };
+ }
+
+ // Verificar se não é mensagem agendada
+ if (mensagem.agendadaPara) {
+ return { sucesso: false, erro: "Não é possível editar mensagens agendadas" };
+ }
+
+ // Validar novo conteúdo
+ if (!args.novoConteudo || args.novoConteudo.trim().length === 0) {
+ return { sucesso: false, erro: "O conteúdo da mensagem não pode estar vazio" };
+ }
+
+ // Normalizar conteúdo para busca
+ const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
+
+ // Atualizar mensagem
+ await ctx.db.patch(args.mensagemId, {
+ conteudo: args.novoConteudo.trim(),
+ conteudoBusca,
+ editadaEm: Date.now(),
+ });
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Mutation interna para atualizar link preview
+ */
+export const atualizarLinkPreview = internalMutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ linkPreview: v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ }),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.mensagemId, {
+ linkPreview: args.linkPreview,
+ });
+ return null;
+ },
+});
+
export const deletarMensagem = mutation({
args: {
mensagemId: v.id("mensagens"),
@@ -710,7 +917,7 @@ export const obterMensagens = query({
// Filtrar mensagens agendadas
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
- // Enriquecer com informações do remetente
+ // Enriquecer com informações do remetente e mensagem respondida
const mensagensEnriquecidas = await Promise.all(
mensagensFiltradas.map(async (mensagem) => {
const remetente = await ctx.db.get(mensagem.remetenteId);
@@ -718,10 +925,30 @@ export const obterMensagens = query({
if (mensagem.arquivoId) {
arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
}
+
+ // Buscar mensagem original se for resposta
+ let mensagemOriginal = null;
+ if (mensagem.respostaPara) {
+ const original = await ctx.db.get(mensagem.respostaPara);
+ if (original) {
+ const remetenteOriginal = await ctx.db.get(original.remetenteId);
+ mensagemOriginal = {
+ _id: original._id,
+ conteudo: original.conteudo.substring(0, 100), // Limitar tamanho
+ remetente: remetenteOriginal ? {
+ _id: remetenteOriginal._id,
+ nome: remetenteOriginal.nome,
+ } : null,
+ deletada: original.deletada || false,
+ };
+ }
+ }
+
return {
...mensagem,
remetente,
arquivoUrl,
+ mensagemOriginal,
};
})
);
@@ -960,17 +1187,25 @@ export const listarTodosUsuarios = query({
});
/**
- * Busca mensagens em conversas
+ * Busca mensagens em conversas com filtros avançados
*/
export const buscarMensagens = query({
args: {
query: v.string(),
conversaId: v.optional(v.id("conversas")),
+ remetenteId: v.optional(v.id("usuarios")),
+ tipo: v.optional(v.union(v.literal("texto"), v.literal("arquivo"), v.literal("imagem"))),
+ dataInicio: v.optional(v.number()),
+ dataFim: v.optional(v.number()),
+ limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
+ // Normalizar query para busca
+ const queryNormalizada = normalizarTextoParaBusca(args.query);
+
// Buscar em todas as conversas do usuário
const todasConversas = await ctx.db.query("conversas").collect();
const conversasDoUsuario = todasConversas.filter((c) =>
@@ -980,6 +1215,12 @@ export const buscarMensagens = query({
let mensagens: Doc<"mensagens">[] = [];
if (args.conversaId !== undefined) {
+ // Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return [];
+ }
+
// Buscar em conversa específica
const mensagensConversa = await ctx.db
.query("mensagens")
@@ -987,7 +1228,7 @@ export const buscarMensagens = query({
.collect();
mensagens = mensagensConversa;
} else {
- // Buscar em todas as conversas
+ // Buscar em todas as conversas do usuário
for (const conversa of conversasDoUsuario) {
const mensagensConversa = await ctx.db
.query("mensagens")
@@ -997,14 +1238,49 @@ export const buscarMensagens = query({
}
}
- // Filtrar por query
- const queryLower = args.query.toLowerCase();
- const mensagensFiltradas = mensagens.filter(
- (m) =>
- !m.deletada &&
- !m.agendadaPara &&
- m.conteudo.toLowerCase().includes(queryLower)
- );
+ // Aplicar filtros
+ let mensagensFiltradas = mensagens.filter((m) => {
+ // Excluir deletadas e agendadas
+ if (m.deletada || m.agendadaPara) {
+ return false;
+ }
+
+ // Filtrar por query (busca no conteúdo normalizado)
+ if (queryNormalizada && queryNormalizada.length > 0) {
+ const conteudoBusca = m.conteudoBusca || normalizarTextoParaBusca(m.conteudo);
+ if (!conteudoBusca.includes(queryNormalizada)) {
+ return false;
+ }
+ }
+
+ // Filtrar por remetente
+ if (args.remetenteId && m.remetenteId !== args.remetenteId) {
+ return false;
+ }
+
+ // Filtrar por tipo
+ if (args.tipo && m.tipo !== args.tipo) {
+ return false;
+ }
+
+ // Filtrar por data
+ if (args.dataInicio && m.enviadaEm < args.dataInicio) {
+ return false;
+ }
+ if (args.dataFim && m.enviadaEm > args.dataFim) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Ordenar por data (mais recentes primeiro)
+ mensagensFiltradas.sort((a, b) => b.enviadaEm - a.enviadaEm);
+
+ // Limitar resultados
+ if (args.limite) {
+ mensagensFiltradas = mensagensFiltradas.slice(0, args.limite);
+ }
// Enriquecer com informações
const mensagensEnriquecidas = await Promise.all(
diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts
index af8208f..dcbc783 100644
--- a/packages/backend/convex/email.ts
+++ b/packages/backend/convex/email.ts
@@ -44,6 +44,139 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise
+): Promise<{ permitido: boolean; motivo?: string }> {
+ const agora = Date.now();
+ const umMinutoAtras = agora - 60 * 1000;
+ const umaHoraAtras = agora - 60 * 60 * 1000;
+
+ // Verificar limite por minuto
+ const emailsUltimoMinuto = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", remetenteId).eq("periodo", "minuto")
+ )
+ .filter((q) => q.gte(q.field("timestamp"), umMinutoAtras))
+ .collect();
+
+ const totalUltimoMinuto = emailsUltimoMinuto.reduce(
+ (sum, rl) => sum + rl.contador,
+ 0
+ );
+
+ if (totalUltimoMinuto >= RATE_LIMIT_CONFIG.emailsPorMinuto) {
+ return {
+ permitido: false,
+ motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorMinuto} emails por minuto excedido. Tente novamente em alguns instantes.`,
+ };
+ }
+
+ // Verificar limite por hora
+ const emailsUltimaHora = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", remetenteId).eq("periodo", "hora")
+ )
+ .filter((q) => q.gte(q.field("timestamp"), umaHoraAtras))
+ .collect();
+
+ const totalUltimaHora = emailsUltimaHora.reduce(
+ (sum, rl) => sum + rl.contador,
+ 0
+ );
+
+ if (totalUltimaHora >= RATE_LIMIT_CONFIG.emailsPorHora) {
+ return {
+ permitido: false,
+ motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorHora} emails por hora excedido. Tente novamente mais tarde.`,
+ };
+ }
+
+ return { permitido: true };
+}
+
+/**
+ * Registra envio de email para rate limiting
+ */
+async function registrarEnvioRateLimit(
+ ctx: MutationCtx,
+ remetenteId: Id<"usuarios">
+): Promise {
+ const agora = Date.now();
+
+ // Limpar registros antigos (mais de 1 hora)
+ const umaHoraAtras = agora - 60 * 60 * 1000;
+ const registrosAntigos = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_timestamp")
+ .filter((q) => q.lt(q.field("timestamp"), umaHoraAtras))
+ .collect();
+
+ for (const registro of registrosAntigos) {
+ await ctx.db.delete(registro._id);
+ }
+
+ // Criar ou atualizar registro do minuto atual
+ const minutoAtual = Math.floor(agora / 60000) * 60000; // Arredondar para o minuto
+ const registroMinuto = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", remetenteId).eq("periodo", "minuto")
+ )
+ .filter((q) => q.eq(q.field("timestamp"), minutoAtual))
+ .first();
+
+ if (registroMinuto) {
+ await ctx.db.patch(registroMinuto._id, {
+ contador: registroMinuto.contador + 1,
+ });
+ } else {
+ await ctx.db.insert("rateLimitEmails", {
+ remetenteId,
+ timestamp: minutoAtual,
+ contador: 1,
+ periodo: "minuto",
+ });
+ }
+
+ // Criar ou atualizar registro da hora atual
+ const horaAtual = Math.floor(agora / 3600000) * 3600000; // Arredondar para a hora
+ const registroHora = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", remetenteId).eq("periodo", "hora")
+ )
+ .filter((q) => q.eq(q.field("timestamp"), horaAtual))
+ .first();
+
+ if (registroHora) {
+ await ctx.db.patch(registroHora._id, {
+ contador: registroHora.contador + 1,
+ });
+ } else {
+ await ctx.db.insert("rateLimitEmails", {
+ remetenteId,
+ timestamp: horaAtual,
+ contador: 1,
+ periodo: "hora",
+ });
+ }
+}
+
/**
* Enfileirar email para envio
*/
@@ -60,18 +193,27 @@ export const enfileirarEmail = mutation({
returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
+ erro: v.optional(v.string()),
}),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.destinatario)) {
- return { sucesso: false };
+ return { sucesso: false, erro: "Email destinatário inválido" };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
- return { sucesso: false };
+ return { sucesso: false, erro: "Data de agendamento deve ser futura" };
+ }
+ }
+
+ // Verificar rate limiting (apenas para envios imediatos, não agendados)
+ if (args.agendadaPara === undefined) {
+ const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
+ if (!rateLimitCheck.permitido) {
+ return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
}
}
@@ -89,6 +231,11 @@ export const enfileirarEmail = mutation({
agendadaPara: args.agendadaPara,
});
+ // Registrar rate limit apenas para envios imediatos
+ if (args.agendadaPara === undefined) {
+ await registrarEnvioRateLimit(ctx, args.enviadoPorId);
+ }
+
// Agendar envio
if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado
@@ -122,6 +269,7 @@ export const enviarEmailComTemplate = mutation({
returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
+ erro: v.optional(v.string()),
}),
handler: async (ctx, args) => {
// Buscar template
@@ -132,13 +280,21 @@ export const enviarEmailComTemplate = mutation({
if (!template) {
console.error("Template não encontrado:", args.templateCodigo);
- return { sucesso: false };
+ return { sucesso: false, erro: `Template "${args.templateCodigo}" não encontrado` };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
- return { sucesso: false };
+ return { sucesso: false, erro: "Data de agendamento deve ser futura" };
+ }
+ }
+
+ // Verificar rate limiting (apenas para envios imediatos, não agendados)
+ if (args.agendadaPara === undefined) {
+ const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
+ if (!rateLimitCheck.permitido) {
+ return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
}
}
@@ -160,6 +316,11 @@ export const enviarEmailComTemplate = mutation({
agendadaPara: args.agendadaPara,
});
+ // Registrar rate limit apenas para envios imediatos
+ if (args.agendadaPara === undefined) {
+ await registrarEnvioRateLimit(ctx, args.enviadoPorId);
+ }
+
// Agendar envio
if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado
@@ -384,6 +545,64 @@ export const obterEstatisticasFilaEmails = query({
},
});
+/**
+ * Obter estatísticas de rate limiting para um usuário
+ */
+export const obterEstatisticasRateLimit = query({
+ args: {
+ remetenteId: v.id("usuarios"),
+ },
+ returns: v.object({
+ emailsUltimoMinuto: v.number(),
+ emailsUltimaHora: v.number(),
+ limiteMinuto: v.number(),
+ limiteHora: v.number(),
+ podeEnviar: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ const agora = Date.now();
+ const umMinutoAtras = agora - 60 * 1000;
+ const umaHoraAtras = agora - 60 * 60 * 1000;
+
+ // Contar emails do último minuto
+ const emailsUltimoMinuto = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", args.remetenteId).eq("periodo", "minuto")
+ )
+ .filter((q) => q.gte(q.field("timestamp"), umMinutoAtras))
+ .collect();
+
+ const totalUltimoMinuto = emailsUltimoMinuto.reduce(
+ (sum, rl) => sum + rl.contador,
+ 0
+ );
+
+ // Contar emails da última hora
+ const emailsUltimaHora = await ctx.db
+ .query("rateLimitEmails")
+ .withIndex("by_remetente_periodo", (q) =>
+ q.eq("remetenteId", args.remetenteId).eq("periodo", "hora")
+ )
+ .filter((q) => q.gte(q.field("timestamp"), umaHoraAtras))
+ .collect();
+
+ const totalUltimaHora = emailsUltimaHora.reduce(
+ (sum, rl) => sum + rl.contador,
+ 0
+ );
+
+ return {
+ emailsUltimoMinuto: totalUltimoMinuto,
+ emailsUltimaHora: totalUltimaHora,
+ limiteMinuto: RATE_LIMIT_CONFIG.emailsPorMinuto,
+ limiteHora: RATE_LIMIT_CONFIG.emailsPorHora,
+ podeEnviar: totalUltimoMinuto < RATE_LIMIT_CONFIG.emailsPorMinuto &&
+ totalUltimaHora < RATE_LIMIT_CONFIG.emailsPorHora,
+ };
+ },
+});
+
/**
* Listar agendamentos de email do usuário atual
*/
@@ -516,6 +735,7 @@ export const markEmailFalha = internalMutation({
/**
* Processar fila de emails (cron job - processa emails pendentes)
+ * Implementa delay exponencial entre envios para evitar bloqueio SMTP
*/
export const processarFilaEmails = internalMutation({
args: {},
@@ -537,32 +757,62 @@ export const processarFilaEmails = internalMutation({
let processados = 0;
let falhas = 0;
+ // Agrupar emails por remetente para aplicar rate limiting e delay
+ const emailsPorRemetente = new Map, Array>>();
for (const email of emailsPendentes) {
- // Verificar se não excedeu tentativas (max 3)
- if ((email.tentativas || 0) >= 3) {
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: "Número máximo de tentativas excedido",
- });
- falhas++;
- continue;
+ if (!emailsPorRemetente.has(email.enviadoPor)) {
+ emailsPorRemetente.set(email.enviadoPor, []);
}
+ emailsPorRemetente.get(email.enviadoPor)!.push(email);
+ }
- // Agendar envio via action
- try {
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId: email._id,
- });
- processados++;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
- tentativas: (email.tentativas || 0) + 1,
- });
- falhas++;
+ for (const [remetenteId, emails] of emailsPorRemetente.entries()) {
+ // Verificar rate limit do remetente
+ const rateLimitCheck = await verificarRateLimit(ctx, remetenteId);
+
+ for (let i = 0; i < emails.length; i++) {
+ const email = emails[i];
+
+ // Verificar se não excedeu tentativas (max 3)
+ if ((email.tentativas || 0) >= 3) {
+ await ctx.db.patch(email._id, {
+ status: "falha",
+ erroDetalhes: "Número máximo de tentativas excedido",
+ });
+ falhas++;
+ continue;
+ }
+
+ // Se rate limit excedido, pular este lote
+ if (!rateLimitCheck.permitido && i === 0) {
+ console.log(`⏸️ Rate limit excedido para remetente ${remetenteId}, aguardando...`);
+ break;
+ }
+
+ // Delay exponencial baseado na tentativa (primeira: 0ms, segunda: 2s, terceira: 4s)
+ const delayExponencial = email.tentativas
+ ? Math.min(2000 * Math.pow(2, email.tentativas - 1), 10000) // Máximo 10s
+ : 0;
+
+ // Delay adicional entre emails do mesmo remetente (1 segundo)
+ const delayEntreEmails = i * 1000;
+
+ // Agendar envio via action com delay
+ try {
+ await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, {
+ emailId: email._id,
+ });
+ processados++;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
+ await ctx.db.patch(email._id, {
+ status: "falha",
+ erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
+ tentativas: (email.tentativas || 0) + 1,
+ });
+ falhas++;
+ }
}
}
diff --git a/packages/backend/convex/preferenciasNotificacao.ts b/packages/backend/convex/preferenciasNotificacao.ts
new file mode 100644
index 0000000..ede1089
--- /dev/null
+++ b/packages/backend/convex/preferenciasNotificacao.ts
@@ -0,0 +1,136 @@
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import { Id } from "./_generated/dataModel";
+
+/**
+ * Obter preferências de notificação para uma conversa
+ */
+export const obterPreferenciasConversa = query({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ returns: v.union(
+ v.object({
+ pushAtivado: v.boolean(),
+ emailAtivado: v.boolean(),
+ somAtivado: v.boolean(),
+ silenciado: v.boolean(),
+ apenasMencoes: v.boolean(),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return null;
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return null;
+ }
+
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ if (!preferencias) {
+ // Retornar valores padrão
+ return {
+ pushAtivado: true,
+ emailAtivado: true,
+ somAtivado: true,
+ silenciado: false,
+ apenasMencoes: false,
+ };
+ }
+
+ return {
+ pushAtivado: preferencias.pushAtivado,
+ emailAtivado: preferencias.emailAtivado,
+ somAtivado: preferencias.somAtivado,
+ silenciado: preferencias.silenciado,
+ apenasMencoes: preferencias.apenasMencoes,
+ };
+ },
+});
+
+/**
+ * Atualizar preferências de notificação para uma conversa
+ */
+export const atualizarPreferenciasConversa = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ pushAtivado: v.optional(v.boolean()),
+ emailAtivado: v.optional(v.boolean()),
+ somAtivado: v.optional(v.boolean()),
+ silenciado: v.optional(v.boolean()),
+ apenasMencoes: v.optional(v.boolean()),
+ },
+ returns: v.object({ sucesso: v.boolean() }),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return { sucesso: false };
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return { sucesso: false };
+ }
+
+ // Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuario._id)) {
+ return { sucesso: false };
+ }
+
+ const preferenciasExistentes = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ const agora = Date.now();
+
+ if (preferenciasExistentes) {
+ // Atualizar preferências existentes
+ await ctx.db.patch(preferenciasExistentes._id, {
+ pushAtivado: args.pushAtivado ?? preferenciasExistentes.pushAtivado,
+ emailAtivado: args.emailAtivado ?? preferenciasExistentes.emailAtivado,
+ somAtivado: args.somAtivado ?? preferenciasExistentes.somAtivado,
+ silenciado: args.silenciado ?? preferenciasExistentes.silenciado,
+ apenasMencoes: args.apenasMencoes ?? preferenciasExistentes.apenasMencoes,
+ atualizadoEm: agora,
+ });
+ } else {
+ // Criar novas preferências com valores padrão
+ await ctx.db.insert("preferenciasNotificacaoConversa", {
+ usuarioId: usuario._id,
+ conversaId: args.conversaId,
+ pushAtivado: args.pushAtivado ?? true,
+ emailAtivado: args.emailAtivado ?? true,
+ somAtivado: args.somAtivado ?? true,
+ silenciado: args.silenciado ?? false,
+ apenasMencoes: args.apenasMencoes ?? false,
+ criadoEm: agora,
+ atualizadoEm: agora,
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
diff --git a/packages/backend/convex/pushNotifications.ts b/packages/backend/convex/pushNotifications.ts
new file mode 100644
index 0000000..dc2fea5
--- /dev/null
+++ b/packages/backend/convex/pushNotifications.ts
@@ -0,0 +1,266 @@
+import { v } from "convex/values";
+import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
+import { Id } from "./_generated/dataModel";
+import { internal, api } from "./_generated/api";
+
+/**
+ * Registrar subscription de push notification
+ */
+export const registrarPushSubscription = mutation({
+ args: {
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ userAgent: v.optional(v.string()),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args) => {
+ // Obter usuário autenticado
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return { sucesso: false, erro: "Usuário não autenticado" };
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return { sucesso: false, erro: "Usuário não encontrado" };
+ }
+
+ // Verificar se já existe subscription com este endpoint
+ const existente = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
+ .first();
+
+ if (existente) {
+ // Atualizar subscription existente
+ await ctx.db.patch(existente._id, {
+ usuarioId: usuario._id,
+ keys: args.keys,
+ userAgent: args.userAgent,
+ ultimaAtividade: Date.now(),
+ ativo: true,
+ });
+ } else {
+ // Criar nova subscription
+ await ctx.db.insert("pushSubscriptions", {
+ usuarioId: usuario._id,
+ endpoint: args.endpoint,
+ keys: args.keys,
+ userAgent: args.userAgent,
+ criadoEm: Date.now(),
+ ultimaAtividade: Date.now(),
+ ativo: true,
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Remover subscription de push notification
+ */
+export const removerPushSubscription = mutation({
+ args: {
+ endpoint: v.string(),
+ },
+ returns: v.object({ sucesso: v.boolean() }),
+ handler: async (ctx, args) => {
+ const subscription = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
+ .first();
+
+ if (subscription) {
+ await ctx.db.patch(subscription._id, { ativo: false });
+ }
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Obter subscriptions ativas de um usuário
+ */
+export const obterPushSubscriptions = internalQuery({
+ args: {
+ usuarioId: v.id("usuarios"),
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id("pushSubscriptions"),
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const subscriptions = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId).eq("ativo", true))
+ .collect();
+
+ return subscriptions.map((sub) => ({
+ _id: sub._id,
+ endpoint: sub.endpoint,
+ keys: sub.keys,
+ }));
+ },
+});
+
+/**
+ * Enviar push notification para um usuário
+ * Esta função será chamada quando uma nova mensagem chegar
+ */
+export const enviarPushNotification = internalMutation({
+ args: {
+ usuarioId: v.id("usuarios"),
+ titulo: v.string(),
+ corpo: v.string(),
+ data: v.optional(
+ v.object({
+ conversaId: v.optional(v.id("conversas")),
+ mensagemId: v.optional(v.id("mensagens")),
+ tipo: v.optional(v.string()),
+ })
+ ),
+ },
+ returns: v.object({ enviados: v.number(), falhas: v.number() }),
+ handler: async (ctx, args) => {
+ // Buscar subscriptions ativas do usuário
+ const subscriptions = await ctx.runQuery(internal.pushNotifications.obterPushSubscriptions, {
+ usuarioId: args.usuarioId,
+ });
+
+ if (subscriptions.length === 0) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Verificar preferências do usuário
+ const usuario = await ctx.db.get(args.usuarioId);
+ if (!usuario || usuario.notificacoesAtivadas === false) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Se há conversaId, verificar preferências específicas da conversa
+ if (args.data?.conversaId) {
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", args.usuarioId).eq("conversaId", args.data.conversaId)
+ )
+ .first();
+
+ if (preferencias) {
+ // Se silenciado ou push desativado, não enviar
+ if (preferencias.silenciado || !preferencias.pushAtivado) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Se apenas menções e não é menção, não enviar
+ if (preferencias.apenasMencoes && args.data.tipo !== "mencao") {
+ return { enviados: 0, falhas: 0 };
+ }
+ }
+ }
+
+ // Agendar envio de push via action (que roda em Node.js)
+ let enviados = 0;
+ let falhas = 0;
+
+ for (const subscription of subscriptions) {
+ try {
+ await ctx.scheduler.runAfter(0, api.actions.pushNotifications.enviarPush, {
+ subscriptionId: subscription._id,
+ titulo: args.titulo,
+ corpo: args.corpo,
+ data: args.data,
+ });
+ enviados++;
+ } catch (error) {
+ console.error(`Erro ao agendar push para subscription ${subscription._id}:`, error);
+ falhas++;
+ }
+ }
+
+ return { enviados, falhas };
+ },
+});
+
+/**
+ * Obter subscription por ID (para actions)
+ */
+export const getSubscriptionById = internalQuery({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ },
+ returns: v.union(
+ v.object({
+ _id: v.id("pushSubscriptions"),
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ ativo: v.boolean(),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const subscription = await ctx.db.get(args.subscriptionId);
+ if (!subscription) {
+ return null;
+ }
+
+ return {
+ _id: subscription._id,
+ endpoint: subscription.endpoint,
+ keys: subscription.keys,
+ ativo: subscription.ativo,
+ };
+ },
+});
+
+/**
+ * Marcar subscription como inativa
+ */
+export const marcarSubscriptionInativa = internalMutation({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.subscriptionId, { ativo: false });
+ return null;
+ },
+});
+
+/**
+ * Verificar se usuário está online (última atividade recente)
+ */
+export const verificarUsuarioOnline = internalQuery({
+ args: {
+ usuarioId: v.id("usuarios"),
+ },
+ returns: v.boolean(),
+ handler: async (ctx, args) => {
+ const usuario = await ctx.db.get(args.usuarioId);
+ if (!usuario || !usuario.ultimaAtividade) {
+ return false;
+ }
+
+ // Considerar online se última atividade foi há menos de 5 minutos
+ const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
+ return usuario.ultimaAtividade >= cincoMinutosAtras;
+ },
+});
+
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 817e31d..f9b75e2 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -604,6 +604,19 @@ export default defineSchema({
descricao: v.string(),
}).index("by_chave", ["chave"]),
+ // Rate Limiting de Emails
+ rateLimitEmails: defineTable({
+ remetenteId: v.id("usuarios"),
+ timestamp: v.number(),
+ contador: v.number(), // quantidade de emails enviados neste período
+ periodo: v.union(
+ v.literal("minuto"), // último minuto
+ v.literal("hora") // última hora
+ ),
+ })
+ .index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"])
+ .index("by_timestamp", ["timestamp"]),
+
// Sistema de Chat
conversas: defineTable({
tipo: v.union(v.literal("individual"), v.literal("grupo")),
@@ -628,10 +641,20 @@ export default defineSchema({
v.literal("imagem")
),
conteudo: v.string(), // texto ou nome do arquivo
+ conteudoBusca: v.optional(v.string()), // versão normalizada para busca
arquivoId: v.optional(v.id("_storage")),
arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
+ linkPreview: v.optional(
+ v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ })
+ ),
reagiuPor: v.optional(
v.array(
v.object({
@@ -641,6 +664,7 @@ export default defineSchema({
)
),
mencoes: v.optional(v.array(v.id("usuarios"))),
+ respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
agendadaPara: v.optional(v.number()), // timestamp
enviadaEm: v.number(),
editadaEm: v.optional(v.number()),
@@ -648,7 +672,8 @@ export default defineSchema({
})
.index("by_conversa", ["conversaId", "enviadaEm"])
.index("by_remetente", ["remetenteId"])
- .index("by_agendamento", ["agendadaPara"]),
+ .index("by_agendamento", ["agendadaPara"])
+ .index("by_resposta", ["respostaPara"]),
leituras: defineTable({
conversaId: v.id("conversas"),
@@ -686,6 +711,37 @@ export default defineSchema({
.index("by_conversa", ["conversaId", "iniciouEm"])
.index("by_usuario", ["usuarioId"]),
+ // Push Notifications
+ pushSubscriptions: defineTable({
+ usuarioId: v.id("usuarios"),
+ endpoint: v.string(), // URL do serviço de push
+ keys: v.object({
+ p256dh: v.string(), // Chave pública
+ auth: v.string(), // Chave de autenticação
+ }),
+ userAgent: v.optional(v.string()),
+ criadoEm: v.number(),
+ ultimaAtividade: v.number(),
+ ativo: v.boolean(),
+ })
+ .index("by_usuario", ["usuarioId", "ativo"])
+ .index("by_endpoint", ["endpoint"]),
+
+ // Preferências de Notificação por Conversa
+ preferenciasNotificacaoConversa: defineTable({
+ usuarioId: v.id("usuarios"),
+ conversaId: v.id("conversas"),
+ pushAtivado: v.boolean(), // Receber push notifications
+ emailAtivado: v.boolean(), // Receber emails quando offline
+ somAtivado: v.boolean(), // Tocar som
+ silenciado: v.boolean(), // Silenciar completamente
+ apenasMencoes: v.boolean(), // Notificar apenas quando mencionado
+ criadoEm: v.number(),
+ atualizadoEm: v.number(),
+ })
+ .index("by_usuario_conversa", ["usuarioId", "conversaId"])
+ .index("by_conversa", ["conversaId"]),
+
// Tabelas de Monitoramento do Sistema
systemMetrics: defineTable({
timestamp: v.number(),
diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts
index c1e5959..d76bc11 100644
--- a/packages/backend/convex/templatesMensagens.ts
+++ b/packages/backend/convex/templatesMensagens.ts
@@ -1,262 +1,312 @@
-import { v } from "convex/values";
-import { mutation, query } from "./_generated/server";
-import { registrarAtividade } from "./logsAtividades";
-import { Doc } from "./_generated/dataModel";
-
-/**
- * Listar todos os templates
- */
-export const listarTemplates = query({
- args: {},
- handler: async (ctx) => {
- const templates = await ctx.db.query("templatesMensagens").collect();
- return templates;
- },
-});
-
-/**
- * Obter template por código
- */
-export const obterTemplatePorCodigo = query({
- args: {
- codigo: v.string(),
- },
- handler: async (ctx, args) => {
- const template = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
- .first();
-
- return template;
- },
-});
-
-/**
- * Criar template customizado (apenas TI_MASTER)
- */
-export const criarTemplate = mutation({
- args: {
- codigo: v.string(),
- nome: v.string(),
- titulo: v.string(),
- corpo: v.string(),
- variaveis: v.optional(v.array(v.string())),
- criadoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- // Verificar se código já existe
- const existente = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
- .first();
-
- if (existente) {
- return { sucesso: false as const, erro: "Código de template já existe" };
- }
-
- // Criar template
- const templateId = await ctx.db.insert("templatesMensagens", {
- codigo: args.codigo,
- nome: args.nome,
- tipo: "customizado",
- titulo: args.titulo,
- corpo: args.corpo,
- variaveis: args.variaveis,
- criadoPor: args.criadoPorId,
- criadoEm: Date.now(),
- });
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.criadoPorId,
- "criar",
- "templates",
- JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
- templateId
- );
-
- return { sucesso: true as const, templateId };
- },
-});
-
-/**
- * Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
- */
-export const editarTemplate = mutation({
- args: {
- templateId: v.id("templatesMensagens"),
- nome: v.optional(v.string()),
- titulo: v.optional(v.string()),
- corpo: v.optional(v.string()),
- variaveis: v.optional(v.array(v.string())),
- editadoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true) }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- const template = await ctx.db.get(args.templateId);
- if (!template) {
- return { sucesso: false as const, erro: "Template não encontrado" };
- }
-
- // Não permite editar templates do sistema
- if (template.tipo === "sistema") {
- return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
- }
-
- // Atualizar template
- const updates: Partial> = {};
- if (args.nome !== undefined) updates.nome = args.nome;
- if (args.titulo !== undefined) updates.titulo = args.titulo;
- if (args.corpo !== undefined) updates.corpo = args.corpo;
- if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
-
- await ctx.db.patch(args.templateId, updates);
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.editadoPorId,
- "editar",
- "templates",
- JSON.stringify(updates),
- args.templateId
- );
-
- return { sucesso: true as const };
- },
-});
-
-/**
- * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
- */
-export const excluirTemplate = mutation({
- args: {
- templateId: v.id("templatesMensagens"),
- excluidoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true) }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- const template = await ctx.db.get(args.templateId);
- if (!template) {
- return { sucesso: false as const, erro: "Template não encontrado" };
- }
-
- // Não permite excluir templates do sistema
- if (template.tipo === "sistema") {
- return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
- }
-
- // Excluir template
- await ctx.db.delete(args.templateId);
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.excluidoPorId,
- "excluir",
- "templates",
- JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
- args.templateId
- );
-
- return { sucesso: true as const };
- },
-});
-
-/**
- * Renderizar template com variáveis
- */
-export function renderizarTemplate(template: string, variaveis: Record): string {
- let resultado = template;
-
- for (const [chave, valor] of Object.entries(variaveis)) {
- const placeholder = `{{${chave}}}`;
- resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
- }
-
- return resultado;
-}
-
-/**
- * Criar templates padrão do sistema (chamado no seed)
- */
-export const criarTemplatesPadrao = mutation({
- args: {},
- handler: async (ctx) => {
- const templatesPadrao = [
- {
- codigo: "USUARIO_BLOQUEADO",
- nome: "Usuário Bloqueado",
- titulo: "Sua conta foi bloqueada",
- corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
- variaveis: ["motivo"],
- },
- {
- codigo: "USUARIO_DESBLOQUEADO",
- nome: "Usuário Desbloqueado",
- titulo: "Sua conta foi desbloqueada",
- corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
- variaveis: [],
- },
- {
- codigo: "SENHA_RESETADA",
- nome: "Senha Resetada",
- titulo: "Sua senha foi resetada",
- corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
- variaveis: ["senha"],
- },
- {
- codigo: "PERMISSAO_ALTERADA",
- nome: "Permissão Alterada",
- titulo: "Suas permissões foram atualizadas",
- corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
- variaveis: [],
- },
- {
- codigo: "AVISO_GERAL",
- nome: "Aviso Geral",
- titulo: "{{titulo}}",
- corpo: "{{mensagem}}",
- variaveis: ["titulo", "mensagem"],
- },
- {
- codigo: "BEM_VINDO",
- nome: "Boas-vindas",
- titulo: "Bem-vindo ao SGSE",
- corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
- variaveis: ["nome", "matricula", "senha"],
- },
- ];
-
- for (const template of templatesPadrao) {
- // Verificar se já existe
- const existente = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
- .first();
-
- if (!existente) {
- await ctx.db.insert("templatesMensagens", {
- ...template,
- tipo: "sistema",
- criadoEm: Date.now(),
- });
- }
- }
-
- return { sucesso: true };
- },
-});
-
-
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import { registrarAtividade } from "./logsAtividades";
+import { Doc } from "./_generated/dataModel";
+
+/**
+ * Listar todos os templates
+ */
+export const listarTemplates = query({
+ args: {},
+ handler: async (ctx) => {
+ const templates = await ctx.db.query("templatesMensagens").collect();
+ return templates;
+ },
+});
+
+/**
+ * Obter template por código
+ */
+export const obterTemplatePorCodigo = query({
+ args: {
+ codigo: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const template = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
+ .first();
+
+ return template;
+ },
+});
+
+/**
+ * Criar template customizado (apenas TI_MASTER)
+ */
+export const criarTemplate = mutation({
+ args: {
+ codigo: v.string(),
+ nome: v.string(),
+ titulo: v.string(),
+ corpo: v.string(),
+ variaveis: v.optional(v.array(v.string())),
+ criadoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ // Verificar se código já existe
+ const existente = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
+ .first();
+
+ if (existente) {
+ return { sucesso: false as const, erro: "Código de template já existe" };
+ }
+
+ // Criar template
+ const templateId = await ctx.db.insert("templatesMensagens", {
+ codigo: args.codigo,
+ nome: args.nome,
+ tipo: "customizado",
+ titulo: args.titulo,
+ corpo: args.corpo,
+ variaveis: args.variaveis,
+ criadoPor: args.criadoPorId,
+ criadoEm: Date.now(),
+ });
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.criadoPorId,
+ "criar",
+ "templates",
+ JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
+ templateId
+ );
+
+ return { sucesso: true as const, templateId };
+ },
+});
+
+/**
+ * Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
+ */
+export const editarTemplate = mutation({
+ args: {
+ templateId: v.id("templatesMensagens"),
+ nome: v.optional(v.string()),
+ titulo: v.optional(v.string()),
+ corpo: v.optional(v.string()),
+ variaveis: v.optional(v.array(v.string())),
+ editadoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true) }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ const template = await ctx.db.get(args.templateId);
+ if (!template) {
+ return { sucesso: false as const, erro: "Template não encontrado" };
+ }
+
+ // Não permite editar templates do sistema
+ if (template.tipo === "sistema") {
+ return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
+ }
+
+ // Atualizar template
+ const updates: Partial> = {};
+ if (args.nome !== undefined) updates.nome = args.nome;
+ if (args.titulo !== undefined) updates.titulo = args.titulo;
+ if (args.corpo !== undefined) updates.corpo = args.corpo;
+ if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
+
+ await ctx.db.patch(args.templateId, updates);
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.editadoPorId,
+ "editar",
+ "templates",
+ JSON.stringify(updates),
+ args.templateId
+ );
+
+ return { sucesso: true as const };
+ },
+});
+
+/**
+ * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
+ */
+export const excluirTemplate = mutation({
+ args: {
+ templateId: v.id("templatesMensagens"),
+ excluidoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true) }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ const template = await ctx.db.get(args.templateId);
+ if (!template) {
+ return { sucesso: false as const, erro: "Template não encontrado" };
+ }
+
+ // Não permite excluir templates do sistema
+ if (template.tipo === "sistema") {
+ return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
+ }
+
+ // Excluir template
+ await ctx.db.delete(args.templateId);
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.excluidoPorId,
+ "excluir",
+ "templates",
+ JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
+ args.templateId
+ );
+
+ return { sucesso: true as const };
+ },
+});
+
+/**
+ * Renderizar template com variáveis
+ */
+export function renderizarTemplate(template: string, variaveis: Record): string {
+ let resultado = template;
+
+ for (const [chave, valor] of Object.entries(variaveis)) {
+ const placeholder = `{{${chave}}}`;
+ resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
+ }
+
+ return resultado;
+}
+
+/**
+ * Criar templates padrão do sistema (chamado no seed)
+ */
+export const criarTemplatesPadrao = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const templatesPadrao = [
+ {
+ codigo: "USUARIO_BLOQUEADO",
+ nome: "Usuário Bloqueado",
+ titulo: "Sua conta foi bloqueada",
+ corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
+ variaveis: ["motivo"],
+ },
+ {
+ codigo: "USUARIO_DESBLOQUEADO",
+ nome: "Usuário Desbloqueado",
+ titulo: "Sua conta foi desbloqueada",
+ corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
+ variaveis: [],
+ },
+ {
+ codigo: "SENHA_RESETADA",
+ nome: "Senha Resetada",
+ titulo: "Sua senha foi resetada",
+ corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
+ variaveis: ["senha"],
+ },
+ {
+ codigo: "PERMISSAO_ALTERADA",
+ nome: "Permissão Alterada",
+ titulo: "Suas permissões foram atualizadas",
+ corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
+ variaveis: [],
+ },
+ {
+ codigo: "AVISO_GERAL",
+ nome: "Aviso Geral",
+ titulo: "{{titulo}}",
+ corpo: "{{mensagem}}",
+ variaveis: ["titulo", "mensagem"],
+ },
+ {
+ codigo: "BEM_VINDO",
+ nome: "Boas-vindas",
+ titulo: "Bem-vindo ao SGSE",
+ corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
+ variaveis: ["nome", "matricula", "senha"],
+ },
+ {
+ codigo: "chat_mensagem",
+ nome: "Nova Mensagem no Chat",
+ titulo: "Nova mensagem de {{remetente}}",
+ corpo: ""
+ + ""
+ + "
Nova mensagem no chat "
+ + "
{{remetente}} enviou uma nova mensagem:
"
+ + "
"
+ + "
{{mensagem}}
"
+ + "
"
+ + "
"
+ + ""
+ + "Ver conversa"
+ + " "
+ + "
"
+ + "
"
+ + "Você está recebendo este email porque não estava online quando a mensagem foi enviada. "
+ + "Você pode desativar essas notificações nas configurações da conversa."
+ + "
"
+ + "
",
+ variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
+ },
+ {
+ codigo: "chat_mencao",
+ nome: "Menção no Chat",
+ titulo: "{{remetente}} mencionou você",
+ corpo: ""
+ + ""
+ + "
Você foi mencionado! "
+ + "
{{remetente}} mencionou você em uma mensagem:
"
+ + "
"
+ + "
{{mensagem}}
"
+ + "
"
+ + "
"
+ + ""
+ + "Ver mensagem"
+ + " "
+ + "
"
+ + "
"
+ + "Você está recebendo este email porque foi mencionado em uma conversa. "
+ + "Você pode desativar essas notificações nas configurações da conversa."
+ + "
"
+ + "
",
+ variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
+ },
+ ];
+
+ for (const template of templatesPadrao) {
+ // Verificar se já existe
+ const existente = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
+ .first();
+
+ if (!existente) {
+ await ctx.db.insert("templatesMensagens", {
+ ...template,
+ tipo: "sistema",
+ criadoEm: Date.now(),
+ });
+ }
+ }
+
+ return { sucesso: true };
+ },
+});
+
+
diff --git a/scripts/configurar-push-notifications.ps1 b/scripts/configurar-push-notifications.ps1
new file mode 100644
index 0000000..733c2ce
--- /dev/null
+++ b/scripts/configurar-push-notifications.ps1
@@ -0,0 +1,71 @@
+# Script para configurar Push Notifications
+# Execute este script após iniciar o Convex (npx convex dev)
+
+Write-Host "🔔 Configurando Push Notifications..." -ForegroundColor Cyan
+Write-Host ""
+
+# Verificar se estamos no diretório correto
+if (-not (Test-Path "packages/backend")) {
+ Write-Host "❌ Erro: Execute este script da raiz do projeto" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "📝 Configurando variáveis de ambiente no Convex..." -ForegroundColor Yellow
+Write-Host ""
+
+cd packages/backend
+
+# VAPID Keys geradas
+$VAPID_PUBLIC_KEY = "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+$VAPID_PRIVATE_KEY = "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+$FRONTEND_URL = "http://localhost:5173"
+
+Write-Host "Configurando VAPID_PUBLIC_KEY..." -ForegroundColor Gray
+npx convex env set VAPID_PUBLIC_KEY $VAPID_PUBLIC_KEY
+
+Write-Host "Configurando VAPID_PRIVATE_KEY..." -ForegroundColor Gray
+npx convex env set VAPID_PRIVATE_KEY $VAPID_PRIVATE_KEY
+
+Write-Host "Configurando FRONTEND_URL..." -ForegroundColor Gray
+npx convex env set FRONTEND_URL $FRONTEND_URL
+
+Write-Host ""
+Write-Host "✅ Variáveis de ambiente configuradas no Convex!" -ForegroundColor Green
+Write-Host ""
+
+cd ../..
+
+# Configurar arquivo .env do frontend
+Write-Host "📝 Criando arquivo .env no frontend..." -ForegroundColor Yellow
+
+$envContent = @"
+# VAPID Public Key para Push Notifications
+VITE_VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY
+"@
+
+$envPath = "apps/web/.env"
+
+if (Test-Path $envPath) {
+ Write-Host "⚠️ Arquivo .env já existe. Adicionando VAPID_PUBLIC_KEY..." -ForegroundColor Yellow
+ # Verificar se já existe a variável
+ $currentContent = Get-Content $envPath -Raw
+ if ($currentContent -notmatch "VITE_VAPID_PUBLIC_KEY") {
+ Add-Content $envPath "`n$envContent"
+ Write-Host "✅ VAPID_PUBLIC_KEY adicionada ao .env" -ForegroundColor Green
+ } else {
+ Write-Host "ℹ️ VITE_VAPID_PUBLIC_KEY já existe no .env" -ForegroundColor Cyan
+ }
+} else {
+ Set-Content $envPath $envContent
+ Write-Host "✅ Arquivo .env criado em apps/web/.env" -ForegroundColor Green
+}
+
+Write-Host ""
+Write-Host "✨ Configuração concluída!" -ForegroundColor Green
+Write-Host ""
+Write-Host "📋 Próximos passos:" -ForegroundColor Cyan
+Write-Host "1. Reinicie o servidor Convex (se estiver rodando)" -ForegroundColor White
+Write-Host "2. Reinicie o servidor frontend (se estiver rodando)" -ForegroundColor White
+Write-Host "3. Teste as push notifications conforme o guia de testes" -ForegroundColor White
+Write-Host ""
+