Feat ausencia #7

Merged
deyvisonwanderley merged 8 commits from feat-ausencia into master 2025-11-05 13:49:12 +00:00
38 changed files with 7830 additions and 572 deletions
Showing only changes of commit 12db52a8a7 - Show all commits

View File

@@ -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)

View File

@@ -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**:
_______________________________________
_______________________________________
_______________________________________

View File

@@ -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!

View File

@@ -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!** 🎉

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { authStore } from "$lib/stores/auth.svelte";
import {
registrarServiceWorker,
solicitarPushSubscription,
subscriptionToJSON,
removerPushSubscription,
} from "$lib/utils/notifications";
const client = useConvexClient();
onMount(async () => {
// Aguardar usuário estar autenticado
const checkAuth = setInterval(async () => {
if (authStore.usuario) {
clearInterval(checkAuth);
await registrarPushSubscription();
}
}, 500);
// Limpar intervalo após 30 segundos (timeout)
setTimeout(() => {
clearInterval(checkAuth);
}, 30000);
return () => {
clearInterval(checkAuth);
};
});
async function registrarPushSubscription() {
try {
// Solicitar subscription
const subscription = await solicitarPushSubscription();
if (!subscription) {
console.log(" Push subscription não disponível ou permissão negada");
return;
}
// Converter para formato serializável
const subscriptionData = subscriptionToJSON(subscription);
// Registrar no backend
const resultado = await client.mutation(api.pushNotifications.registrarPushSubscription, {
endpoint: subscriptionData.endpoint,
keys: subscriptionData.keys,
userAgent: navigator.userAgent,
});
if (resultado.sucesso) {
console.log("✅ Push subscription registrada com sucesso");
} else {
console.error("❌ Erro ao registrar push subscription:", resultado.erro);
}
} catch (error) {
console.error("❌ Erro ao configurar push notifications:", error);
}
}
// Remover subscription ao fazer logout
$effect(() => {
if (!authStore.usuario) {
removerPushSubscription().then(() => {
console.log("Push subscription removida");
});
}
});
</script>
<!-- Componente invisível - apenas lógica -->

View File

@@ -28,7 +28,7 @@
return null; 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); console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
return encontrada; return encontrada;
}); });
@@ -54,10 +54,10 @@
return "👤"; return "👤";
} }
function getStatusConversa(): any { function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null {
const c = conversa(); const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) { 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; return null;
} }
@@ -169,20 +169,20 @@
</div> </div>
<!-- Mensagens --> <!-- Mensagens -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden min-h-0">
<MessageList conversaId={conversaId as any} /> <MessageList conversaId={conversaId as Id<"conversas">} />
</div> </div>
<!-- Input --> <!-- Input -->
<div class="border-t border-base-300"> <div class="border-t border-base-300 flex-shrink-0">
<MessageInput conversaId={conversaId as any} /> <MessageInput conversaId={conversaId as Id<"conversas">} />
</div> </div>
</div> </div>
<!-- Modal de Agendamento --> <!-- Modal de Agendamento -->
{#if showScheduleModal} {#if showScheduleModal}
<ScheduleMessageModal <ScheduleMessageModal
conversaId={conversaId as any} conversaId={conversaId as Id<"conversas">}
onClose={() => (showScheduleModal = false)} onClose={() => (showScheduleModal = false)}
/> />
{/if} {/if}

View File

@@ -18,6 +18,7 @@
let uploadingFile = $state(false); let uploadingFile = $state(false);
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null; let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false); let showEmojiPicker = $state(false);
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
// Emojis mais usados // Emojis mais usados
const emojis = [ const emojis = [
@@ -62,6 +63,7 @@
conversaId, conversaId,
conteudo: texto, conteudo: texto,
tipo: "texto", tipo: "texto",
respostaPara: mensagemRespondendo?.id,
}); });
try { try {
@@ -70,11 +72,13 @@
conversaId, conversaId,
conteudo: texto, conteudo: texto,
tipo: "texto", tipo: "texto",
respostaPara: mensagemRespondendo?.id,
}); });
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result); console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
mensagem = ""; mensagem = "";
mensagemRespondendo = null;
if (textarea) { if (textarea) {
textarea.style.height = "auto"; 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) { function handleKeyDown(e: KeyboardEvent) {
// Enter sem Shift = enviar // Enter sem Shift = enviar
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
@@ -154,6 +186,24 @@
</script> </script>
<div class="p-4"> <div class="p-4">
<!-- Preview da mensagem respondendo -->
{#if mensagemRespondendo}
<div class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between">
<div class="flex-1">
<p class="text-xs font-medium text-base-content/70">Respondendo a {mensagemRespondendo.remetente}</p>
<p class="text-xs text-base-content/50 truncate">{mensagemRespondendo.conteudo}</p>
</div>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={cancelarResposta}
title="Cancelar resposta"
>
</button>
</div>
{/if}
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<!-- Botão de anexar arquivo MODERNO --> <!-- Botão de anexar arquivo MODERNO -->
<label <label

View File

@@ -5,6 +5,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { authStore } from "$lib/stores/auth.svelte";
interface Props { interface Props {
conversaId: Id<"conversas">; conversaId: Id<"conversas">;
@@ -18,34 +19,44 @@
let messagesContainer: HTMLDivElement; let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true; let shouldScrollToBottom = true;
let lastMessageCount = 0;
// DEBUG: Log quando mensagens mudam // Obter ID do usuário atual
$effect(() => { const usuarioAtualId = $derived(authStore.usuario?._id);
console.log("💬 [MessageList] Mensagens atualizadas:", {
conversaId,
count: mensagens?.data?.length || 0,
mensagens: mensagens?.data,
});
});
// Auto-scroll para a última mensagem // Auto-scroll para a última mensagem quando novas mensagens chegam
$effect(() => { $effect(() => {
if (mensagens?.data && shouldScrollToBottom && messagesContainer) { 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(() => { tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}); });
});
}
lastMessageCount = currentCount;
} }
}); });
// Marcar como lida quando mensagens carregam // Marcar como lida quando mensagens carregam
$effect(() => { $effect(() => {
if (mensagens?.data && mensagens.data.length > 0) { if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1]; const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
// Só marcar como lida se não for minha mensagem
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
client.mutation(api.chat.marcarComoLida, { client.mutation(api.chat.marcarComoLida, {
conversaId, conversaId,
mensagemId: ultimaMensagem._id as any, mensagemId: ultimaMensagem._id,
}); });
} }
}
}); });
function formatarDataMensagem(timestamp: number): string { function formatarDataMensagem(timestamp: number): string {
@@ -64,8 +75,46 @@
} }
} }
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> { interface Mensagem {
const grupos: Record<string, any[]> = {}; _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<string, Mensagem[]> {
const grupos: Record<string, Mensagem[]> = {};
for (const msg of msgs) { for (const msg of msgs) {
const dia = formatarDiaMensagem(msg.enviadaEm); const dia = formatarDiaMensagem(msg.enviadaEm);
if (!grupos[dia]) { if (!grupos[dia]) {
@@ -83,14 +132,14 @@
shouldScrollToBottom = isAtBottom; shouldScrollToBottom = isAtBottom;
} }
async function handleReagir(mensagemId: string, emoji: string) { async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
await client.mutation(api.chat.reagirMensagem, { await client.mutation(api.chat.reagirMensagem, {
mensagemId: mensagemId as any, mensagemId,
emoji, 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 []; if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
const emojiMap: Record<string, number> = {}; const emojiMap: Record<string, number> = {};
@@ -100,6 +149,64 @@
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count })); 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);
}
</script> </script>
<div <div
@@ -119,9 +226,9 @@
<!-- Mensagens do dia --> <!-- Mensagens do dia -->
{#each mensagensDia as mensagem (mensagem._id)} {#each mensagensDia as mensagem (mensagem._id)}
{@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id} {@const isMinha = mensagem.remetente?._id === usuarioAtualId}
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}> <div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}> <div class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
<!-- Nome do remetente (apenas se não for minha) --> <!-- Nome do remetente (apenas se não for minha) -->
{#if !isMinha} {#if !isMinha}
<p class="text-xs text-base-content/60 mb-1 px-3"> <p class="text-xs text-base-content/60 mb-1 px-3">
@@ -137,10 +244,92 @@
: "bg-base-200 text-base-content rounded-bl-sm" : "bg-base-200 text-base-content rounded-bl-sm"
}`} }`}
> >
{#if mensagem.deletada} {#if mensagem.mensagemOriginal}
<!-- Preview da mensagem respondida -->
<div class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70">
<p class="text-xs font-medium">
{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
</p>
<p class="text-xs truncate">
{mensagem.mensagemOriginal.deletada
? "Mensagem deletada"
: mensagem.mensagemOriginal.conteudo}
</p>
</div>
{/if}
{#if mensagemEditando?._id === mensagem._id}
<!-- Modo de edição -->
<div class="space-y-2">
<textarea
bind:value={novoConteudoEditado}
class="w-full p-2 rounded-lg bg-base-100 text-base-content text-sm resize-none"
rows="3"
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
salvarEdicao();
} else if (e.key === "Escape") {
cancelarEdicao();
}
}}
></textarea>
<div class="flex gap-2 justify-end">
<button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
>
Cancelar
</button>
<button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
>
Salvar
</button>
</div>
</div>
{:else if mensagem.deletada}
<p class="text-sm italic opacity-70">Mensagem deletada</p> <p class="text-sm italic opacity-70">Mensagem deletada</p>
{:else if mensagem.tipo === "texto"} {:else if mensagem.tipo === "texto"}
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p> <div class="space-y-2">
<div class="flex items-start gap-2">
<p class="text-sm whitespace-pre-wrap break-words flex-1">{mensagem.conteudo}</p>
{#if mensagem.editadaEm}
<span class="text-xs opacity-50 italic" title="Editado">(editado)</span>
{/if}
</div>
<!-- Preview de link -->
{#if mensagem.linkPreview}
<a
href={mensagem.linkPreview.url}
target="_blank"
rel="noopener noreferrer"
class="block border border-base-300 rounded-lg overflow-hidden hover:border-primary transition-colors"
>
{#if mensagem.linkPreview.imagem}
<img
src={mensagem.linkPreview.imagem}
alt={mensagem.linkPreview.titulo || "Preview"}
class="w-full h-48 object-cover"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
{/if}
<div class="p-3 bg-base-200">
{#if mensagem.linkPreview.site}
<p class="text-xs text-base-content/50 mb-1">{mensagem.linkPreview.site}</p>
{/if}
{#if mensagem.linkPreview.titulo}
<p class="text-sm font-medium text-base-content mb-1">{mensagem.linkPreview.titulo}</p>
{/if}
{#if mensagem.linkPreview.descricao}
<p class="text-xs text-base-content/70 line-clamp-2">{mensagem.linkPreview.descricao}</p>
{/if}
</div>
</a>
{/if}
</div>
{:else if mensagem.tipo === "imagem"} {:else if mensagem.tipo === "imagem"}
<div class="mb-2"> <div class="mb-2">
<img <img
@@ -198,14 +387,45 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Botão de responder -->
{#if !mensagem.deletada}
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors mt-1"
onclick={() => responderMensagem(mensagem)}
title="Responder"
>
↪️ Responder
</button>
{/if}
</div> </div>
<!-- Timestamp --> <!-- Timestamp e ações -->
<p <div
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`} class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
> >
<p class="text-xs text-base-content/50">
{formatarDataMensagem(mensagem.enviadaEm)} {formatarDataMensagem(mensagem.enviadaEm)}
</p> </p>
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
<div class="flex gap-1">
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors"
onclick={() => editarMensagem(mensagem)}
title="Editar mensagem"
>
✏️
</button>
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id)}
title="Deletar mensagem"
>
🗑️
</button>
</div>
{/if}
</div>
</div> </div>
</div> </div>
{/each} {/each}
@@ -226,7 +446,7 @@
></div> ></div>
</div> </div>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/60">
{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á digitando"
: "estão digitando"}... : "estão digitando"}...
</p> </p>

View File

@@ -64,3 +64,144 @@ export function isTabActive(): boolean {
return !document.hidden; return !document.hidden;
} }
/**
* Registrar service worker para push notifications
*/
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
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<PushSubscription | null> {
// 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<boolean> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
return true;
}
return false;
}

View File

@@ -2,6 +2,7 @@
import { page } from "$app/state"; import { page } from "$app/state";
import ActionGuard from "$lib/components/ActionGuard.svelte"; import ActionGuard from "$lib/components/ActionGuard.svelte";
import { Toaster } from "svelte-sonner"; import { Toaster } from "svelte-sonner";
import PushNotificationManager from "$lib/components/PushNotificationManager.svelte";
const { children } = $props(); const { children } = $props();
// Resolver recurso/ação a partir da rota // Resolver recurso/ação a partir da rota
@@ -67,3 +68,6 @@
<!-- Toast Notifications (Sonner) --> <!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} /> <Toaster position="top-right" richColors closeButton expand={true} />
<!-- Push Notification Manager (registra subscription automaticamente) -->
<PushNotificationManager />

70
apps/web/static/sw.js Normal file
View File

@@ -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);
}
})
);
});

View File

@@ -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`

View File

@@ -3,6 +3,7 @@
import { action } from "../_generated/server"; import { action } from "../_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { internal } from "../_generated/api"; import { internal } from "../_generated/api";
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
export const enviar = action({ export const enviar = action({
args: { args: {
@@ -23,47 +24,105 @@ export const enviar = action({
return { sucesso: false, erro: "Email não encontrado" }; return { sucesso: false, erro: "Email não encontrado" };
} }
// Buscar configuração SMTP ativa com senha descriptografada // Buscar configuração SMTP ativa
const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {}); const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
if (!config) { if (!configRaw) {
return { return {
sucesso: false, sucesso: false,
erro: "Configuração de email não encontrada ou inativa", 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 { return {
sucesso: false, 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 // Marcar como enviando
await ctx.runMutation(internal.email.markEmailEnviando, { await ctx.runMutation(internal.email.markEmailEnviando, {
emailId: args.emailId, emailId: args.emailId,
}); });
// Criar transporter do nodemailer // Criar transporter do nodemailer com configuração melhorada
const transporter = nodemailer.createTransport({ 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, host: config.servidor,
port: config.porta, port: config.porta,
secure: config.usarSSL, secure: config.usarSSL,
requireTLS: config.usarTLS,
auth: { auth: {
user: config.usuario, user: config.usuario,
pass: config.senha, // Senha já descriptografada pass: config.senha, // Senha já descriptografada
}, },
tls: { connectionTimeout: 15000, // 15 segundos
// Permitir certificados autoassinados apenas se necessário 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, rejectUnauthorized: false,
ciphers: "SSLv3", };
}, }
connectionTimeout: 10000, // 10 segundos
greetingTimeout: 10000, const transporter = nodemailer.createTransport(transporterOptions);
socketTimeout: 10000,
}); // 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 // Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -71,13 +130,28 @@ export const enviar = action({
throw new Error(`Email destinatário inválido: ${email.destinatario}`); 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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
// Enviar email // Enviar email
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`, from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario, to: email.destinatario,
subject: email.assunto, subject: email.assunto,
html: email.corpo, 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 { interface MessageInfo {
@@ -102,12 +176,23 @@ export const enviar = action({
return { sucesso: true }; return { sucesso: true };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(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, { await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId, emailId: args.emailId,
erro: errorMessage, erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
}); });
return { sucesso: false, erro: errorMessage }; return { sucesso: false, erro: errorMessage };

View File

@@ -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 <title>)
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i);
const twitterTitleMatch = html.match(/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i);
const titleMatch = html.match(/<title>([^<]+)<\/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(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i);
const twitterDescMatch = html.match(/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i);
const metaDescMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
metadata.descricao = ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
if (metadata.descricao) {
metadata.descricao = metadata.descricao.trim().substring(0, 300);
}
// Imagem (og:image ou twitter:image)
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i);
const twitterImageMatch = html.match(/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i);
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
if (imageUrl) {
// Resolver URL relativa
try {
metadata.imagem = new URL(imageUrl, url).href;
} catch {
metadata.imagem = imageUrl;
}
}
// Site (og:site_name ou domínio)
const ogSiteMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i);
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, "");
return {
url,
titulo: metadata.titulo,
descricao: metadata.descricao,
imagem: metadata.imagem,
site: metadata.site,
};
} catch (error) {
console.error("Erro ao extrair preview de link:", error);
return null;
}
}
/**
* Processar preview de link e atualizar mensagem
*/
export const processarPreviewLink = action({
args: {
mensagemId: v.id("mensagens"),
url: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// 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);
},
});

View File

@@ -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 };
}
},
});

View File

@@ -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<string> {
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}`);
}
}

View File

@@ -2,9 +2,21 @@ import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server"; import { mutation, query, internalMutation } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel"; import { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx, MutationCtx } from "./_generated/server"; import type { QueryCtx, MutationCtx } from "./_generated/server";
import { internal, api } from "./_generated/api";
// ========== HELPERS ========== // ========== 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) * 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()), arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()), arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id("usuarios"))), 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 permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -203,20 +216,78 @@ export const enviarMensagem = mutation({
throw new Error("Você não pertence a esta conversa"); 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 // Criar mensagem
const mensagemId = await ctx.db.insert("mensagens", { const mensagemId = await ctx.db.insert("mensagens", {
conversaId: args.conversaId, conversaId: args.conversaId,
remetenteId: usuarioAtual._id, remetenteId: usuarioAtual._id,
tipo: args.tipo, tipo: args.tipo,
conteudo: args.conteudo, conteudo: args.conteudo,
conteudoBusca,
arquivoId: args.arquivoId, arquivoId: args.arquivoId,
arquivoNome: args.arquivoNome, arquivoNome: args.arquivoNome,
arquivoTamanho: args.arquivoTamanho, arquivoTamanho: args.arquivoTamanho,
arquivoTipo: args.arquivoTipo, arquivoTipo: args.arquivoTipo,
mencoes: args.mencoes, mencoes: args.mencoes,
respostaPara: args.respostaPara,
enviadaEm: Date.now(), 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 // Atualizar última mensagem da conversa
await ctx.db.patch(args.conversaId, { await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100), ultimaMensagem: args.conteudo.substring(0, 100),
@@ -236,20 +307,79 @@ export const enviarMensagem = mutation({
? "mencao" ? "mencao"
: "nova_mensagem"; : "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", { await ctx.db.insert("notificacoes", {
usuarioId: participanteId, usuarioId: participanteId,
tipo: tipoNotificacao, tipo: tipoNotificacao,
conversaId: args.conversaId, conversaId: args.conversaId,
mensagemId, mensagemId,
remetenteId: usuarioAtual._id, remetenteId: usuarioAtual._id,
titulo: titulo,
tipoNotificacao === "mencao" descricao,
? `${usuarioAtual.nome} mencionou você`
: `Nova mensagem de ${usuarioAtual.nome}`,
descricao: args.conteudo.substring(0, 100),
lida: false, lida: false,
criadaEm: Date.now(), 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) { } catch (error) {
@@ -558,6 +688,83 @@ export const marcarTodasNotificacoesLidas = mutation({
/** /**
* Deleta uma mensagem (soft delete) * 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({ export const deletarMensagem = mutation({
args: { args: {
mensagemId: v.id("mensagens"), mensagemId: v.id("mensagens"),
@@ -710,7 +917,7 @@ export const obterMensagens = query({
// Filtrar mensagens agendadas // Filtrar mensagens agendadas
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara); 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( const mensagensEnriquecidas = await Promise.all(
mensagensFiltradas.map(async (mensagem) => { mensagensFiltradas.map(async (mensagem) => {
const remetente = await ctx.db.get(mensagem.remetenteId); const remetente = await ctx.db.get(mensagem.remetenteId);
@@ -718,10 +925,30 @@ export const obterMensagens = query({
if (mensagem.arquivoId) { if (mensagem.arquivoId) {
arquivoUrl = await ctx.storage.getUrl(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 { return {
...mensagem, ...mensagem,
remetente, remetente,
arquivoUrl, 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({ export const buscarMensagens = query({
args: { args: {
query: v.string(), query: v.string(),
conversaId: v.optional(v.id("conversas")), 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) => { handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx); const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return []; if (!usuarioAtual) return [];
// Normalizar query para busca
const queryNormalizada = normalizarTextoParaBusca(args.query);
// Buscar em todas as conversas do usuário // Buscar em todas as conversas do usuário
const todasConversas = await ctx.db.query("conversas").collect(); const todasConversas = await ctx.db.query("conversas").collect();
const conversasDoUsuario = todasConversas.filter((c) => const conversasDoUsuario = todasConversas.filter((c) =>
@@ -980,6 +1215,12 @@ export const buscarMensagens = query({
let mensagens: Doc<"mensagens">[] = []; let mensagens: Doc<"mensagens">[] = [];
if (args.conversaId !== undefined) { 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 // Buscar em conversa específica
const mensagensConversa = await ctx.db const mensagensConversa = await ctx.db
.query("mensagens") .query("mensagens")
@@ -987,7 +1228,7 @@ export const buscarMensagens = query({
.collect(); .collect();
mensagens = mensagensConversa; mensagens = mensagensConversa;
} else { } else {
// Buscar em todas as conversas // Buscar em todas as conversas do usuário
for (const conversa of conversasDoUsuario) { for (const conversa of conversasDoUsuario) {
const mensagensConversa = await ctx.db const mensagensConversa = await ctx.db
.query("mensagens") .query("mensagens")
@@ -997,14 +1238,49 @@ export const buscarMensagens = query({
} }
} }
// Filtrar por query // Aplicar filtros
const queryLower = args.query.toLowerCase(); let mensagensFiltradas = mensagens.filter((m) => {
const mensagensFiltradas = mensagens.filter( // Excluir deletadas e agendadas
(m) => if (m.deletada || m.agendadaPara) {
!m.deletada && return false;
!m.agendadaPara && }
m.conteudo.toLowerCase().includes(queryLower)
); // 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 // Enriquecer com informações
const mensagensEnriquecidas = await Promise.all( const mensagensEnriquecidas = await Promise.all(

View File

@@ -44,6 +44,139 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise<Doc<"
return usuarioAtual; return usuarioAtual;
} }
/**
* Configurações padrão de rate limiting
*/
const RATE_LIMIT_CONFIG = {
emailsPorMinuto: 10,
emailsPorHora: 100,
} as const;
/**
* Verifica rate limiting para um remetente
* Retorna true se pode enviar, false se excedeu limite
*/
async function verificarRateLimit(
ctx: MutationCtx,
remetenteId: Id<"usuarios">
): 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<void> {
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 * Enfileirar email para envio
*/ */
@@ -60,18 +193,27 @@ export const enfileirarEmail = mutation({
returns: v.object({ returns: v.object({
sucesso: v.boolean(), sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")), emailId: v.optional(v.id("notificacoesEmail")),
erro: v.optional(v.string()),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Validar email // Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.destinatario)) { if (!emailRegex.test(args.destinatario)) {
return { sucesso: false }; return { sucesso: false, erro: "Email destinatário inválido" };
} }
// Validar agendamento se fornecido // Validar agendamento se fornecido
if (args.agendadaPara !== undefined) { if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) { 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, agendadaPara: args.agendadaPara,
}); });
// Registrar rate limit apenas para envios imediatos
if (args.agendadaPara === undefined) {
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
}
// Agendar envio // Agendar envio
if (args.agendadaPara !== undefined) { if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado // Agendar para o momento especificado
@@ -122,6 +269,7 @@ export const enviarEmailComTemplate = mutation({
returns: v.object({ returns: v.object({
sucesso: v.boolean(), sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")), emailId: v.optional(v.id("notificacoesEmail")),
erro: v.optional(v.string()),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Buscar template // Buscar template
@@ -132,13 +280,21 @@ export const enviarEmailComTemplate = mutation({
if (!template) { if (!template) {
console.error("Template não encontrado:", args.templateCodigo); 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 // Validar agendamento se fornecido
if (args.agendadaPara !== undefined) { if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) { 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, agendadaPara: args.agendadaPara,
}); });
// Registrar rate limit apenas para envios imediatos
if (args.agendadaPara === undefined) {
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
}
// Agendar envio // Agendar envio
if (args.agendadaPara !== undefined) { if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado // 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 * 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) * Processar fila de emails (cron job - processa emails pendentes)
* Implementa delay exponencial entre envios para evitar bloqueio SMTP
*/ */
export const processarFilaEmails = internalMutation({ export const processarFilaEmails = internalMutation({
args: {}, args: {},
@@ -537,7 +757,22 @@ export const processarFilaEmails = internalMutation({
let processados = 0; let processados = 0;
let falhas = 0; let falhas = 0;
// Agrupar emails por remetente para aplicar rate limiting e delay
const emailsPorRemetente = new Map<Id<"usuarios">, Array<Doc<"notificacoesEmail">>>();
for (const email of emailsPendentes) { for (const email of emailsPendentes) {
if (!emailsPorRemetente.has(email.enviadoPor)) {
emailsPorRemetente.set(email.enviadoPor, []);
}
emailsPorRemetente.get(email.enviadoPor)!.push(email);
}
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) // Verificar se não excedeu tentativas (max 3)
if ((email.tentativas || 0) >= 3) { if ((email.tentativas || 0) >= 3) {
await ctx.db.patch(email._id, { await ctx.db.patch(email._id, {
@@ -548,9 +783,23 @@ export const processarFilaEmails = internalMutation({
continue; continue;
} }
// Agendar envio via action // 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 { try {
await ctx.scheduler.runAfter(0, api.actions.email.enviar, { await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, {
emailId: email._id, emailId: email._id,
}); });
processados++; processados++;
@@ -565,6 +814,7 @@ export const processarFilaEmails = internalMutation({
falhas++; falhas++;
} }
} }
}
if (processados > 0 || falhas > 0) { if (processados > 0 || falhas > 0) {
console.log( console.log(

View File

@@ -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 };
},
});

View File

@@ -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;
},
});

View File

@@ -604,6 +604,19 @@ export default defineSchema({
descricao: v.string(), descricao: v.string(),
}).index("by_chave", ["chave"]), }).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 // Sistema de Chat
conversas: defineTable({ conversas: defineTable({
tipo: v.union(v.literal("individual"), v.literal("grupo")), tipo: v.union(v.literal("individual"), v.literal("grupo")),
@@ -628,10 +641,20 @@ export default defineSchema({
v.literal("imagem") v.literal("imagem")
), ),
conteudo: v.string(), // texto ou nome do arquivo conteudo: v.string(), // texto ou nome do arquivo
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
arquivoId: v.optional(v.id("_storage")), arquivoId: v.optional(v.id("_storage")),
arquivoNome: v.optional(v.string()), arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()), arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()), 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( reagiuPor: v.optional(
v.array( v.array(
v.object({ v.object({
@@ -641,6 +664,7 @@ export default defineSchema({
) )
), ),
mencoes: v.optional(v.array(v.id("usuarios"))), 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 agendadaPara: v.optional(v.number()), // timestamp
enviadaEm: v.number(), enviadaEm: v.number(),
editadaEm: v.optional(v.number()), editadaEm: v.optional(v.number()),
@@ -648,7 +672,8 @@ export default defineSchema({
}) })
.index("by_conversa", ["conversaId", "enviadaEm"]) .index("by_conversa", ["conversaId", "enviadaEm"])
.index("by_remetente", ["remetenteId"]) .index("by_remetente", ["remetenteId"])
.index("by_agendamento", ["agendadaPara"]), .index("by_agendamento", ["agendadaPara"])
.index("by_resposta", ["respostaPara"]),
leituras: defineTable({ leituras: defineTable({
conversaId: v.id("conversas"), conversaId: v.id("conversas"),
@@ -686,6 +711,37 @@ export default defineSchema({
.index("by_conversa", ["conversaId", "iniciouEm"]) .index("by_conversa", ["conversaId", "iniciouEm"])
.index("by_usuario", ["usuarioId"]), .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 // Tabelas de Monitoramento do Sistema
systemMetrics: defineTable({ systemMetrics: defineTable({
timestamp: v.number(), timestamp: v.number(),

View File

@@ -237,6 +237,56 @@ export const criarTemplatesPadrao = mutation({
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", 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"], variaveis: ["nome", "matricula", "senha"],
}, },
{
codigo: "chat_mensagem",
nome: "Nova Mensagem no Chat",
titulo: "Nova mensagem de {{remetente}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #4F46E5;'>Nova mensagem no chat</h2>"
+ "<p><strong>{{remetente}}</strong> enviou uma nova mensagem:</p>"
+ "<div style='background-color: #F3F4F6; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
+ "style='background-color: #4F46E5; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver conversa"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "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."
+ "</p>"
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
codigo: "chat_mencao",
nome: "Menção no Chat",
titulo: "{{remetente}} mencionou você",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #DC2626;'>Você foi mencionado!</h2>"
+ "<p><strong>{{remetente}}</strong> mencionou você em uma mensagem:</p>"
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver mensagem"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Você está recebendo este email porque foi mencionado em uma conversa. "
+ "Você pode desativar essas notificações nas configurações da conversa."
+ "</p>"
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
]; ];
for (const template of templatesPadrao) { for (const template of templatesPadrao) {

View File

@@ -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 ""