refactor: enhance chat components with type safety and response functionality
- Updated type definitions in ChatWindow and MessageList components for better type safety. - Improved MessageInput to handle message responses, including a preview feature for replying to messages. - Enhanced the chat message handling logic to support message references and improve user interaction. - Refactored notification utility functions to support push notifications and rate limiting for email sending. - Updated backend schema to accommodate new features related to message responses and notifications.
This commit is contained in:
117
CONFIGURACAO_PUSH_NOTIFICATIONS.md
Normal file
117
CONFIGURACAO_PUSH_NOTIFICATIONS.md
Normal 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)
|
||||||
|
|
||||||
214
GUIA_TESTE_PUSH_NOTIFICATIONS.md
Normal file
214
GUIA_TESTE_PUSH_NOTIFICATIONS.md
Normal 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**:
|
||||||
|
_______________________________________
|
||||||
|
_______________________________________
|
||||||
|
_______________________________________
|
||||||
|
|
||||||
163
PASSO_A_PASSO_CONFIGURACAO.md
Normal file
163
PASSO_A_PASSO_CONFIGURACAO.md
Normal 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!
|
||||||
|
|
||||||
68
RESUMO_CONFIGURACAO_COMPLETA.md
Normal file
68
RESUMO_CONFIGURACAO_COMPLETA.md
Normal 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!** 🎉
|
||||||
|
|
||||||
75
apps/web/src/lib/components/PushNotificationManager.svelte
Normal file
75
apps/web/src/lib/components/PushNotificationManager.svelte
Normal 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 -->
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,33 +19,43 @@
|
|||||||
|
|
||||||
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) {
|
||||||
tick().then(() => {
|
const currentCount = mensagens.data.length;
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
const isNewMessage = currentCount > lastMessageCount;
|
||||||
});
|
|
||||||
|
if (isNewMessage || shouldScrollToBottom) {
|
||||||
|
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tick().then(() => {
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessageCount = currentCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Marcar como lida quando mensagens carregam
|
// 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];
|
||||||
client.mutation(api.chat.marcarComoLida, {
|
// Só marcar como lida se não for minha mensagem
|
||||||
conversaId,
|
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
|
||||||
mensagemId: ultimaMensagem._id as any,
|
client.mutation(api.chat.marcarComoLida, {
|
||||||
});
|
conversaId,
|
||||||
|
mensagemId: ultimaMensagem._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,66 +1,207 @@
|
|||||||
/**
|
/**
|
||||||
* Solicita permissão para notificações desktop
|
* Solicita permissão para notificações desktop
|
||||||
*/
|
*/
|
||||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||||
if (!("Notification" in window)) {
|
if (!("Notification" in window)) {
|
||||||
console.warn("Este navegador não suporta notificações desktop");
|
console.warn("Este navegador não suporta notificações desktop");
|
||||||
return "denied";
|
return "denied";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Notification.permission === "granted") {
|
if (Notification.permission === "granted") {
|
||||||
return "granted";
|
return "granted";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Notification.permission !== "denied") {
|
if (Notification.permission !== "denied") {
|
||||||
return await Notification.requestPermission();
|
return await Notification.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Notification.permission;
|
return Notification.permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mostra uma notificação desktop
|
* Mostra uma notificação desktop
|
||||||
*/
|
*/
|
||||||
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
|
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
|
||||||
if (!("Notification" in window)) {
|
if (!("Notification" in window)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Notification.permission !== "granted") {
|
if (Notification.permission !== "granted") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Notification(title, {
|
return new Notification(title, {
|
||||||
icon: "/favicon.png",
|
icon: "/favicon.png",
|
||||||
badge: "/favicon.png",
|
badge: "/favicon.png",
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao exibir notificação:", error);
|
console.error("Erro ao exibir notificação:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toca o som de notificação
|
* Toca o som de notificação
|
||||||
*/
|
*/
|
||||||
export function playNotificationSound() {
|
export function playNotificationSound() {
|
||||||
try {
|
try {
|
||||||
const audio = new Audio("/sounds/notification.mp3");
|
const audio = new Audio("/sounds/notification.mp3");
|
||||||
audio.volume = 0.5;
|
audio.volume = 0.5;
|
||||||
audio.play().catch((err) => {
|
audio.play().catch((err) => {
|
||||||
console.warn("Não foi possível reproduzir o som de notificação:", err);
|
console.warn("Não foi possível reproduzir o som de notificação:", err);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao tocar som de notificação:", error);
|
console.error("Erro ao tocar som de notificação:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica se o usuário está na aba ativa
|
* Verifica se o usuário está na aba ativa
|
||||||
*/
|
*/
|
||||||
export function isTabActive(): boolean {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +1,73 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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";
|
||||||
const { children } = $props();
|
import PushNotificationManager from "$lib/components/PushNotificationManager.svelte";
|
||||||
|
const { children } = $props();
|
||||||
// Resolver recurso/ação a partir da rota
|
|
||||||
const routeAction = $derived.by(() => {
|
// Resolver recurso/ação a partir da rota
|
||||||
const p = page.url.pathname;
|
const routeAction = $derived.by(() => {
|
||||||
if (p === "/" || p === "/solicitar-acesso") return null;
|
const p = page.url.pathname;
|
||||||
|
if (p === "/" || p === "/solicitar-acesso") return null;
|
||||||
// Funcionários
|
|
||||||
if (p.startsWith("/recursos-humanos/funcionarios")) {
|
// Funcionários
|
||||||
if (p.includes("/cadastro"))
|
if (p.startsWith("/recursos-humanos/funcionarios")) {
|
||||||
return { recurso: "funcionarios", acao: "criar" };
|
if (p.includes("/cadastro"))
|
||||||
if (p.includes("/excluir"))
|
return { recurso: "funcionarios", acao: "criar" };
|
||||||
return { recurso: "funcionarios", acao: "excluir" };
|
if (p.includes("/excluir"))
|
||||||
if (p.includes("/editar") || p.includes("/funcionarioId"))
|
return { recurso: "funcionarios", acao: "excluir" };
|
||||||
return { recurso: "funcionarios", acao: "editar" };
|
if (p.includes("/editar") || p.includes("/funcionarioId"))
|
||||||
return { recurso: "funcionarios", acao: "listar" };
|
return { recurso: "funcionarios", acao: "editar" };
|
||||||
}
|
return { recurso: "funcionarios", acao: "listar" };
|
||||||
|
}
|
||||||
// Símbolos
|
|
||||||
if (p.startsWith("/recursos-humanos/simbolos")) {
|
// Símbolos
|
||||||
if (p.includes("/cadastro"))
|
if (p.startsWith("/recursos-humanos/simbolos")) {
|
||||||
return { recurso: "simbolos", acao: "criar" };
|
if (p.includes("/cadastro"))
|
||||||
if (p.includes("/excluir"))
|
return { recurso: "simbolos", acao: "criar" };
|
||||||
return { recurso: "simbolos", acao: "excluir" };
|
if (p.includes("/excluir"))
|
||||||
if (p.includes("/editar") || p.includes("/simboloId"))
|
return { recurso: "simbolos", acao: "excluir" };
|
||||||
return { recurso: "simbolos", acao: "editar" };
|
if (p.includes("/editar") || p.includes("/simboloId"))
|
||||||
return { recurso: "simbolos", acao: "listar" };
|
return { recurso: "simbolos", acao: "editar" };
|
||||||
}
|
return { recurso: "simbolos", acao: "listar" };
|
||||||
|
}
|
||||||
// Outras áreas (uso genérico: ver)
|
|
||||||
if (p.startsWith("/financeiro"))
|
// Outras áreas (uso genérico: ver)
|
||||||
return { recurso: "financeiro", acao: "ver" };
|
if (p.startsWith("/financeiro"))
|
||||||
if (p.startsWith("/controladoria"))
|
return { recurso: "financeiro", acao: "ver" };
|
||||||
return { recurso: "controladoria", acao: "ver" };
|
if (p.startsWith("/controladoria"))
|
||||||
if (p.startsWith("/licitacoes"))
|
return { recurso: "controladoria", acao: "ver" };
|
||||||
return { recurso: "licitacoes", acao: "ver" };
|
if (p.startsWith("/licitacoes"))
|
||||||
if (p.startsWith("/compras")) return { recurso: "compras", acao: "ver" };
|
return { recurso: "licitacoes", acao: "ver" };
|
||||||
if (p.startsWith("/juridico")) return { recurso: "juridico", acao: "ver" };
|
if (p.startsWith("/compras")) return { recurso: "compras", acao: "ver" };
|
||||||
if (p.startsWith("/comunicacao"))
|
if (p.startsWith("/juridico")) return { recurso: "juridico", acao: "ver" };
|
||||||
return { recurso: "comunicacao", acao: "ver" };
|
if (p.startsWith("/comunicacao"))
|
||||||
if (p.startsWith("/programas-esportivos"))
|
return { recurso: "comunicacao", acao: "ver" };
|
||||||
return { recurso: "programas_esportivos", acao: "ver" };
|
if (p.startsWith("/programas-esportivos"))
|
||||||
if (p.startsWith("/secretaria-executiva"))
|
return { recurso: "programas_esportivos", acao: "ver" };
|
||||||
return { recurso: "secretaria_executiva", acao: "ver" };
|
if (p.startsWith("/secretaria-executiva"))
|
||||||
if (p.startsWith("/gestao-pessoas"))
|
return { recurso: "secretaria_executiva", acao: "ver" };
|
||||||
return { recurso: "gestao_pessoas", acao: "ver" };
|
if (p.startsWith("/gestao-pessoas"))
|
||||||
|
return { recurso: "gestao_pessoas", acao: "ver" };
|
||||||
return null;
|
|
||||||
});
|
return null;
|
||||||
</script>
|
});
|
||||||
|
</script>
|
||||||
{#if routeAction}
|
|
||||||
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
|
{#if routeAction}
|
||||||
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
|
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
|
||||||
{@render children()}
|
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
|
||||||
</main>
|
{@render children()}
|
||||||
</ActionGuard>
|
</main>
|
||||||
{:else}
|
</ActionGuard>
|
||||||
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
|
{:else}
|
||||||
{@render children()}
|
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
|
||||||
</main>
|
{@render children()}
|
||||||
{/if}
|
</main>
|
||||||
|
{/if}
|
||||||
<!-- Toast Notifications (Sonner) -->
|
|
||||||
<Toaster position="top-right" richColors closeButton expand={true} />
|
<!-- Toast Notifications (Sonner) -->
|
||||||
|
<Toaster position="top-right" richColors closeButton expand={true} />
|
||||||
|
|
||||||
|
<!-- Push Notification Manager (registra subscription automaticamente) -->
|
||||||
|
<PushNotificationManager />
|
||||||
|
|||||||
70
apps/web/static/sw.js
Normal file
70
apps/web/static/sw.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
77
configurar-variaveis-ambiente.md
Normal file
77
configurar-variaveis-ambiente.md
Normal 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`
|
||||||
|
|
||||||
@@ -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(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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 };
|
||||||
|
|||||||
138
packages/backend/convex/actions/linkPreview.ts
Normal file
138
packages/backend/convex/actions/linkPreview.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
93
packages/backend/convex/actions/pushNotifications.ts
Normal file
93
packages/backend/convex/actions/pushNotifications.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
72
packages/backend/convex/actions/utils/nodeCrypto.ts
Normal file
72
packages/backend/convex/actions/utils/nodeCrypto.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,32 +757,62 @@ 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) {
|
||||||
// Verificar se não excedeu tentativas (max 3)
|
if (!emailsPorRemetente.has(email.enviadoPor)) {
|
||||||
if ((email.tentativas || 0) >= 3) {
|
emailsPorRemetente.set(email.enviadoPor, []);
|
||||||
await ctx.db.patch(email._id, {
|
|
||||||
status: "falha",
|
|
||||||
erroDetalhes: "Número máximo de tentativas excedido",
|
|
||||||
});
|
|
||||||
falhas++;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
emailsPorRemetente.get(email.enviadoPor)!.push(email);
|
||||||
|
}
|
||||||
|
|
||||||
// Agendar envio via action
|
for (const [remetenteId, emails] of emailsPorRemetente.entries()) {
|
||||||
try {
|
// Verificar rate limit do remetente
|
||||||
await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
|
const rateLimitCheck = await verificarRateLimit(ctx, remetenteId);
|
||||||
emailId: email._id,
|
|
||||||
});
|
for (let i = 0; i < emails.length; i++) {
|
||||||
processados++;
|
const email = emails[i];
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
// Verificar se não excedeu tentativas (max 3)
|
||||||
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
|
if ((email.tentativas || 0) >= 3) {
|
||||||
await ctx.db.patch(email._id, {
|
await ctx.db.patch(email._id, {
|
||||||
status: "falha",
|
status: "falha",
|
||||||
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
|
erroDetalhes: "Número máximo de tentativas excedido",
|
||||||
tentativas: (email.tentativas || 0) + 1,
|
});
|
||||||
});
|
falhas++;
|
||||||
falhas++;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se rate limit excedido, pular este lote
|
||||||
|
if (!rateLimitCheck.permitido && i === 0) {
|
||||||
|
console.log(`⏸️ Rate limit excedido para remetente ${remetenteId}, aguardando...`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay exponencial baseado na tentativa (primeira: 0ms, segunda: 2s, terceira: 4s)
|
||||||
|
const delayExponencial = email.tentativas
|
||||||
|
? Math.min(2000 * Math.pow(2, email.tentativas - 1), 10000) // Máximo 10s
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Delay adicional entre emails do mesmo remetente (1 segundo)
|
||||||
|
const delayEntreEmails = i * 1000;
|
||||||
|
|
||||||
|
// Agendar envio via action com delay
|
||||||
|
try {
|
||||||
|
await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, {
|
||||||
|
emailId: email._id,
|
||||||
|
});
|
||||||
|
processados++;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
|
||||||
|
await ctx.db.patch(email._id, {
|
||||||
|
status: "falha",
|
||||||
|
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
|
||||||
|
tentativas: (email.tentativas || 0) + 1,
|
||||||
|
});
|
||||||
|
falhas++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
packages/backend/convex/preferenciasNotificacao.ts
Normal file
136
packages/backend/convex/preferenciasNotificacao.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
266
packages/backend/convex/pushNotifications.ts
Normal file
266
packages/backend/convex/pushNotifications.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,262 +1,312 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import { registrarAtividade } from "./logsAtividades";
|
import { registrarAtividade } from "./logsAtividades";
|
||||||
import { Doc } from "./_generated/dataModel";
|
import { Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listar todos os templates
|
* Listar todos os templates
|
||||||
*/
|
*/
|
||||||
export const listarTemplates = query({
|
export const listarTemplates = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const templates = await ctx.db.query("templatesMensagens").collect();
|
const templates = await ctx.db.query("templatesMensagens").collect();
|
||||||
return templates;
|
return templates;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obter template por código
|
* Obter template por código
|
||||||
*/
|
*/
|
||||||
export const obterTemplatePorCodigo = query({
|
export const obterTemplatePorCodigo = query({
|
||||||
args: {
|
args: {
|
||||||
codigo: v.string(),
|
codigo: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const template = await ctx.db
|
const template = await ctx.db
|
||||||
.query("templatesMensagens")
|
.query("templatesMensagens")
|
||||||
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
return template;
|
return template;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Criar template customizado (apenas TI_MASTER)
|
* Criar template customizado (apenas TI_MASTER)
|
||||||
*/
|
*/
|
||||||
export const criarTemplate = mutation({
|
export const criarTemplate = mutation({
|
||||||
args: {
|
args: {
|
||||||
codigo: v.string(),
|
codigo: v.string(),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
titulo: v.string(),
|
titulo: v.string(),
|
||||||
corpo: v.string(),
|
corpo: v.string(),
|
||||||
variaveis: v.optional(v.array(v.string())),
|
variaveis: v.optional(v.array(v.string())),
|
||||||
criadoPorId: v.id("usuarios"),
|
criadoPorId: v.id("usuarios"),
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
|
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
|
||||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Verificar se código já existe
|
// Verificar se código já existe
|
||||||
const existente = await ctx.db
|
const existente = await ctx.db
|
||||||
.query("templatesMensagens")
|
.query("templatesMensagens")
|
||||||
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (existente) {
|
if (existente) {
|
||||||
return { sucesso: false as const, erro: "Código de template já existe" };
|
return { sucesso: false as const, erro: "Código de template já existe" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Criar template
|
// Criar template
|
||||||
const templateId = await ctx.db.insert("templatesMensagens", {
|
const templateId = await ctx.db.insert("templatesMensagens", {
|
||||||
codigo: args.codigo,
|
codigo: args.codigo,
|
||||||
nome: args.nome,
|
nome: args.nome,
|
||||||
tipo: "customizado",
|
tipo: "customizado",
|
||||||
titulo: args.titulo,
|
titulo: args.titulo,
|
||||||
corpo: args.corpo,
|
corpo: args.corpo,
|
||||||
variaveis: args.variaveis,
|
variaveis: args.variaveis,
|
||||||
criadoPor: args.criadoPorId,
|
criadoPor: args.criadoPorId,
|
||||||
criadoEm: Date.now(),
|
criadoEm: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log de atividade
|
// Log de atividade
|
||||||
await registrarAtividade(
|
await registrarAtividade(
|
||||||
ctx,
|
ctx,
|
||||||
args.criadoPorId,
|
args.criadoPorId,
|
||||||
"criar",
|
"criar",
|
||||||
"templates",
|
"templates",
|
||||||
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
|
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
|
||||||
templateId
|
templateId
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sucesso: true as const, templateId };
|
return { sucesso: true as const, templateId };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
|
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
|
||||||
*/
|
*/
|
||||||
export const editarTemplate = mutation({
|
export const editarTemplate = mutation({
|
||||||
args: {
|
args: {
|
||||||
templateId: v.id("templatesMensagens"),
|
templateId: v.id("templatesMensagens"),
|
||||||
nome: v.optional(v.string()),
|
nome: v.optional(v.string()),
|
||||||
titulo: v.optional(v.string()),
|
titulo: v.optional(v.string()),
|
||||||
corpo: v.optional(v.string()),
|
corpo: v.optional(v.string()),
|
||||||
variaveis: v.optional(v.array(v.string())),
|
variaveis: v.optional(v.array(v.string())),
|
||||||
editadoPorId: v.id("usuarios"),
|
editadoPorId: v.id("usuarios"),
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
v.object({ sucesso: v.literal(true) }),
|
v.object({ sucesso: v.literal(true) }),
|
||||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const template = await ctx.db.get(args.templateId);
|
const template = await ctx.db.get(args.templateId);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return { sucesso: false as const, erro: "Template não encontrado" };
|
return { sucesso: false as const, erro: "Template não encontrado" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Não permite editar templates do sistema
|
// Não permite editar templates do sistema
|
||||||
if (template.tipo === "sistema") {
|
if (template.tipo === "sistema") {
|
||||||
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
|
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar template
|
// Atualizar template
|
||||||
const updates: Partial<Doc<"templatesMensagens">> = {};
|
const updates: Partial<Doc<"templatesMensagens">> = {};
|
||||||
if (args.nome !== undefined) updates.nome = args.nome;
|
if (args.nome !== undefined) updates.nome = args.nome;
|
||||||
if (args.titulo !== undefined) updates.titulo = args.titulo;
|
if (args.titulo !== undefined) updates.titulo = args.titulo;
|
||||||
if (args.corpo !== undefined) updates.corpo = args.corpo;
|
if (args.corpo !== undefined) updates.corpo = args.corpo;
|
||||||
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
|
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
|
||||||
|
|
||||||
await ctx.db.patch(args.templateId, updates);
|
await ctx.db.patch(args.templateId, updates);
|
||||||
|
|
||||||
// Log de atividade
|
// Log de atividade
|
||||||
await registrarAtividade(
|
await registrarAtividade(
|
||||||
ctx,
|
ctx,
|
||||||
args.editadoPorId,
|
args.editadoPorId,
|
||||||
"editar",
|
"editar",
|
||||||
"templates",
|
"templates",
|
||||||
JSON.stringify(updates),
|
JSON.stringify(updates),
|
||||||
args.templateId
|
args.templateId
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sucesso: true as const };
|
return { sucesso: true as const };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
|
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
|
||||||
*/
|
*/
|
||||||
export const excluirTemplate = mutation({
|
export const excluirTemplate = mutation({
|
||||||
args: {
|
args: {
|
||||||
templateId: v.id("templatesMensagens"),
|
templateId: v.id("templatesMensagens"),
|
||||||
excluidoPorId: v.id("usuarios"),
|
excluidoPorId: v.id("usuarios"),
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
v.object({ sucesso: v.literal(true) }),
|
v.object({ sucesso: v.literal(true) }),
|
||||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const template = await ctx.db.get(args.templateId);
|
const template = await ctx.db.get(args.templateId);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return { sucesso: false as const, erro: "Template não encontrado" };
|
return { sucesso: false as const, erro: "Template não encontrado" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Não permite excluir templates do sistema
|
// Não permite excluir templates do sistema
|
||||||
if (template.tipo === "sistema") {
|
if (template.tipo === "sistema") {
|
||||||
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
|
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Excluir template
|
// Excluir template
|
||||||
await ctx.db.delete(args.templateId);
|
await ctx.db.delete(args.templateId);
|
||||||
|
|
||||||
// Log de atividade
|
// Log de atividade
|
||||||
await registrarAtividade(
|
await registrarAtividade(
|
||||||
ctx,
|
ctx,
|
||||||
args.excluidoPorId,
|
args.excluidoPorId,
|
||||||
"excluir",
|
"excluir",
|
||||||
"templates",
|
"templates",
|
||||||
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
|
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
|
||||||
args.templateId
|
args.templateId
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sucesso: true as const };
|
return { sucesso: true as const };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renderizar template com variáveis
|
* Renderizar template com variáveis
|
||||||
*/
|
*/
|
||||||
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
|
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
|
||||||
let resultado = template;
|
let resultado = template;
|
||||||
|
|
||||||
for (const [chave, valor] of Object.entries(variaveis)) {
|
for (const [chave, valor] of Object.entries(variaveis)) {
|
||||||
const placeholder = `{{${chave}}}`;
|
const placeholder = `{{${chave}}}`;
|
||||||
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
|
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultado;
|
return resultado;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Criar templates padrão do sistema (chamado no seed)
|
* Criar templates padrão do sistema (chamado no seed)
|
||||||
*/
|
*/
|
||||||
export const criarTemplatesPadrao = mutation({
|
export const criarTemplatesPadrao = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const templatesPadrao = [
|
const templatesPadrao = [
|
||||||
{
|
{
|
||||||
codigo: "USUARIO_BLOQUEADO",
|
codigo: "USUARIO_BLOQUEADO",
|
||||||
nome: "Usuário Bloqueado",
|
nome: "Usuário Bloqueado",
|
||||||
titulo: "Sua conta foi bloqueada",
|
titulo: "Sua conta foi bloqueada",
|
||||||
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
|
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
|
||||||
variaveis: ["motivo"],
|
variaveis: ["motivo"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: "USUARIO_DESBLOQUEADO",
|
codigo: "USUARIO_DESBLOQUEADO",
|
||||||
nome: "Usuário Desbloqueado",
|
nome: "Usuário Desbloqueado",
|
||||||
titulo: "Sua conta foi desbloqueada",
|
titulo: "Sua conta foi desbloqueada",
|
||||||
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
|
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
|
||||||
variaveis: [],
|
variaveis: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: "SENHA_RESETADA",
|
codigo: "SENHA_RESETADA",
|
||||||
nome: "Senha Resetada",
|
nome: "Senha Resetada",
|
||||||
titulo: "Sua senha foi resetada",
|
titulo: "Sua senha foi resetada",
|
||||||
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
|
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
|
||||||
variaveis: ["senha"],
|
variaveis: ["senha"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: "PERMISSAO_ALTERADA",
|
codigo: "PERMISSAO_ALTERADA",
|
||||||
nome: "Permissão Alterada",
|
nome: "Permissão Alterada",
|
||||||
titulo: "Suas permissões foram atualizadas",
|
titulo: "Suas permissões foram atualizadas",
|
||||||
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
|
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
|
||||||
variaveis: [],
|
variaveis: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: "AVISO_GERAL",
|
codigo: "AVISO_GERAL",
|
||||||
nome: "Aviso Geral",
|
nome: "Aviso Geral",
|
||||||
titulo: "{{titulo}}",
|
titulo: "{{titulo}}",
|
||||||
corpo: "{{mensagem}}",
|
corpo: "{{mensagem}}",
|
||||||
variaveis: ["titulo", "mensagem"],
|
variaveis: ["titulo", "mensagem"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: "BEM_VINDO",
|
codigo: "BEM_VINDO",
|
||||||
nome: "Boas-vindas",
|
nome: "Boas-vindas",
|
||||||
titulo: "Bem-vindo ao SGSE",
|
titulo: "Bem-vindo ao SGSE",
|
||||||
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
|
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",
|
||||||
for (const template of templatesPadrao) {
|
nome: "Nova Mensagem no Chat",
|
||||||
// Verificar se já existe
|
titulo: "Nova mensagem de {{remetente}}",
|
||||||
const existente = await ctx.db
|
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||||
.query("templatesMensagens")
|
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||||
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
|
+ "<h2 style='color: #4F46E5;'>Nova mensagem no chat</h2>"
|
||||||
.first();
|
+ "<p><strong>{{remetente}}</strong> enviou uma nova mensagem:</p>"
|
||||||
|
+ "<div style='background-color: #F3F4F6; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||||
if (!existente) {
|
+ "<p style='margin: 0;'>{{mensagem}}</p>"
|
||||||
await ctx.db.insert("templatesMensagens", {
|
+ "</div>"
|
||||||
...template,
|
+ "<p style='margin-top: 30px;'>"
|
||||||
tipo: "sistema",
|
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
|
||||||
criadoEm: Date.now(),
|
+ "style='background-color: #4F46E5; color: white; padding: 12px 24px; "
|
||||||
});
|
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||||
}
|
+ "Ver conversa"
|
||||||
}
|
+ "</a>"
|
||||||
|
+ "</p>"
|
||||||
return { sucesso: true };
|
+ "<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) {
|
||||||
|
// Verificar se já existe
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query("templatesMensagens")
|
||||||
|
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existente) {
|
||||||
|
await ctx.db.insert("templatesMensagens", {
|
||||||
|
...template,
|
||||||
|
tipo: "sistema",
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
71
scripts/configurar-push-notifications.ps1
Normal file
71
scripts/configurar-push-notifications.ps1
Normal 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 ""
|
||||||
|
|
||||||
Reference in New Issue
Block a user