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;
|
||||
}
|
||||
|
||||
const encontrada = conversas.data.find((c: any) => c._id === conversaId);
|
||||
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
|
||||
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
|
||||
return encontrada;
|
||||
});
|
||||
@@ -54,10 +54,10 @@
|
||||
return "👤";
|
||||
}
|
||||
|
||||
function getStatusConversa(): any {
|
||||
function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusPresenca || "offline";
|
||||
return (c.outroUsuario.statusPresenca as "online" | "offline" | "ausente" | "externo" | "em_reuniao") || "offline";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -169,20 +169,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as any} />
|
||||
<div class="flex-1 overflow-hidden min-h-0">
|
||||
<MessageList conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300">
|
||||
<MessageInput conversaId={conversaId as any} />
|
||||
<div class="border-t border-base-300 flex-shrink-0">
|
||||
<MessageInput conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Agendamento -->
|
||||
{#if showScheduleModal}
|
||||
<ScheduleMessageModal
|
||||
conversaId={conversaId as any}
|
||||
conversaId={conversaId as Id<"conversas">}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
let uploadingFile = $state(false);
|
||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showEmojiPicker = $state(false);
|
||||
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
|
||||
|
||||
// Emojis mais usados
|
||||
const emojis = [
|
||||
@@ -62,6 +63,7 @@
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -70,11 +72,13 @@
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
});
|
||||
|
||||
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
|
||||
|
||||
mensagem = "";
|
||||
mensagemRespondendo = null;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
@@ -86,6 +90,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cancelarResposta() {
|
||||
mensagemRespondendo = null;
|
||||
}
|
||||
|
||||
// Escutar evento de resposta
|
||||
onMount(() => {
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
|
||||
// Buscar informações da mensagem para exibir preview
|
||||
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
||||
const msg = mensagens.find((m: any) => m._id === customEvent.detail.mensagemId);
|
||||
if (msg) {
|
||||
mensagemRespondendo = {
|
||||
id: msg._id,
|
||||
conteudo: msg.conteudo.substring(0, 100),
|
||||
remetente: msg.remetente?.nome || "Usuário",
|
||||
};
|
||||
textarea?.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("responderMensagem", handler);
|
||||
return () => {
|
||||
window.removeEventListener("responderMensagem", handler);
|
||||
};
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter sem Shift = enviar
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -154,6 +186,24 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<!-- Botão de anexar arquivo MODERNO -->
|
||||
<label
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
@@ -18,33 +19,43 @@
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
let lastMessageCount = 0;
|
||||
|
||||
// DEBUG: Log quando mensagens mudam
|
||||
$effect(() => {
|
||||
console.log("💬 [MessageList] Mensagens atualizadas:", {
|
||||
conversaId,
|
||||
count: mensagens?.data?.length || 0,
|
||||
mensagens: mensagens?.data,
|
||||
});
|
||||
});
|
||||
// Obter ID do usuário atual
|
||||
const usuarioAtualId = $derived(authStore.usuario?._id);
|
||||
|
||||
// Auto-scroll para a última mensagem
|
||||
// Auto-scroll para a última mensagem quando novas mensagens chegam
|
||||
$effect(() => {
|
||||
if (mensagens?.data && shouldScrollToBottom && messagesContainer) {
|
||||
tick().then(() => {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
if (mensagens?.data && messagesContainer) {
|
||||
const currentCount = mensagens.data.length;
|
||||
const isNewMessage = currentCount > lastMessageCount;
|
||||
|
||||
if (isNewMessage || shouldScrollToBottom) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||
requestAnimationFrame(() => {
|
||||
tick().then(() => {
|
||||
if (messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
lastMessageCount = currentCount;
|
||||
}
|
||||
});
|
||||
|
||||
// Marcar como lida quando mensagens carregam
|
||||
$effect(() => {
|
||||
if (mensagens?.data && mensagens.data.length > 0) {
|
||||
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id as any,
|
||||
});
|
||||
// Só marcar como lida se não for minha mensagem
|
||||
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,8 +75,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
|
||||
const grupos: Record<string, any[]> = {};
|
||||
interface Mensagem {
|
||||
_id: Id<"mensagens">;
|
||||
remetente?: {
|
||||
_id: Id<"usuarios">;
|
||||
nome: string;
|
||||
} | null;
|
||||
conteudo: string;
|
||||
tipo: "texto" | "arquivo" | "imagem";
|
||||
enviadaEm: number;
|
||||
editadaEm?: number;
|
||||
deletada?: boolean;
|
||||
agendadaPara?: number;
|
||||
respostaPara?: Id<"mensagens">;
|
||||
mensagemOriginal?: {
|
||||
_id: Id<"mensagens">;
|
||||
conteudo: string;
|
||||
remetente: {
|
||||
_id: Id<"usuarios">;
|
||||
nome: string;
|
||||
} | null;
|
||||
deletada: boolean;
|
||||
} | null;
|
||||
reagiuPor?: Array<{
|
||||
usuarioId: Id<"usuarios">;
|
||||
emoji: string;
|
||||
}>;
|
||||
arquivoUrl?: string | null;
|
||||
arquivoNome?: string;
|
||||
arquivoTamanho?: number;
|
||||
linkPreview?: {
|
||||
url: string;
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
imagem?: string;
|
||||
site?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||
const grupos: Record<string, Mensagem[]> = {};
|
||||
for (const msg of msgs) {
|
||||
const dia = formatarDiaMensagem(msg.enviadaEm);
|
||||
if (!grupos[dia]) {
|
||||
@@ -83,14 +132,14 @@
|
||||
shouldScrollToBottom = isAtBottom;
|
||||
}
|
||||
|
||||
async function handleReagir(mensagemId: string, emoji: string) {
|
||||
async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
|
||||
await client.mutation(api.chat.reagirMensagem, {
|
||||
mensagemId: mensagemId as any,
|
||||
mensagemId,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
|
||||
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
|
||||
function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
|
||||
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
||||
|
||||
const emojiMap: Record<string, number> = {};
|
||||
@@ -100,6 +149,64 @@
|
||||
|
||||
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
|
||||
}
|
||||
|
||||
let mensagemEditando: Mensagem | null = $state(null);
|
||||
let novoConteudoEditado = $state("");
|
||||
|
||||
async function editarMensagem(mensagem: Mensagem) {
|
||||
mensagemEditando = mensagem;
|
||||
novoConteudoEditado = mensagem.conteudo;
|
||||
}
|
||||
|
||||
async function salvarEdicao() {
|
||||
if (!mensagemEditando || !novoConteudoEditado.trim()) return;
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.editarMensagem, {
|
||||
mensagemId: mensagemEditando._id,
|
||||
novoConteudo: novoConteudoEditado.trim(),
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mensagemEditando = null;
|
||||
novoConteudoEditado = "";
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao editar mensagem");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao editar mensagem:", error);
|
||||
alert("Erro ao editar mensagem");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelarEdicao() {
|
||||
mensagemEditando = null;
|
||||
novoConteudoEditado = "";
|
||||
}
|
||||
|
||||
async function deletarMensagem(mensagemId: Id<"mensagens">) {
|
||||
if (!confirm("Tem certeza que deseja deletar esta mensagem?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar mensagem:", error);
|
||||
alert("Erro ao deletar mensagem");
|
||||
}
|
||||
}
|
||||
|
||||
// Função para responder mensagem (será passada via props ou event)
|
||||
function responderMensagem(mensagem: Mensagem) {
|
||||
// Disparar evento customizado para o componente pai
|
||||
const event = new CustomEvent("responderMensagem", {
|
||||
detail: { mensagemId: mensagem._id },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -119,9 +226,9 @@
|
||||
|
||||
<!-- Mensagens do dia -->
|
||||
{#each mensagensDia as mensagem (mensagem._id)}
|
||||
{@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id}
|
||||
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
{@const isMinha = mensagem.remetente?._id === usuarioAtualId}
|
||||
<div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
<!-- Nome do remetente (apenas se não for minha) -->
|
||||
{#if !isMinha}
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
{#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>
|
||||
{: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"}
|
||||
<div class="mb-2">
|
||||
<img
|
||||
@@ -198,14 +387,45 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p
|
||||
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
|
||||
>
|
||||
<!-- Timestamp e ações -->
|
||||
<div
|
||||
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)}
|
||||
</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>
|
||||
{/each}
|
||||
@@ -226,7 +446,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<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ão digitando"}...
|
||||
</p>
|
||||
|
||||
@@ -64,3 +64,144 @@ export function isTabActive(): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page } from "$app/state";
|
||||
import ActionGuard from "$lib/components/ActionGuard.svelte";
|
||||
import { Toaster } from "svelte-sonner";
|
||||
import PushNotificationManager from "$lib/components/PushNotificationManager.svelte";
|
||||
const { children } = $props();
|
||||
|
||||
// Resolver recurso/ação a partir da rota
|
||||
@@ -67,3 +68,6 @@
|
||||
|
||||
<!-- 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 { v } from "convex/values";
|
||||
import { internal } from "../_generated/api";
|
||||
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
|
||||
|
||||
export const enviar = action({
|
||||
args: {
|
||||
@@ -23,47 +24,105 @@ export const enviar = action({
|
||||
return { sucesso: false, erro: "Email não encontrado" };
|
||||
}
|
||||
|
||||
// Buscar configuração SMTP ativa com senha descriptografada
|
||||
const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {});
|
||||
// Buscar configuração SMTP ativa
|
||||
const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
|
||||
|
||||
if (!config) {
|
||||
if (!configRaw) {
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: "Configuração de email não encontrada ou inativa",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.testadoEm) {
|
||||
// Descriptografar senha usando função compatível com Node.js
|
||||
let senhaDescriptografada: string;
|
||||
try {
|
||||
senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
|
||||
} catch (decryptError) {
|
||||
const decryptErrorMessage = decryptError instanceof Error ? decryptError.message : String(decryptError);
|
||||
console.error("Erro ao descriptografar senha SMTP:", decryptErrorMessage);
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!",
|
||||
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
const config = {
|
||||
...configRaw,
|
||||
senha: senhaDescriptografada,
|
||||
};
|
||||
|
||||
// Config já foi validado acima
|
||||
|
||||
// Avisar mas não bloquear se não foi testado
|
||||
if (!config.testadoEm) {
|
||||
console.warn("⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...");
|
||||
}
|
||||
|
||||
// Marcar como enviando
|
||||
await ctx.runMutation(internal.email.markEmailEnviando, {
|
||||
emailId: args.emailId,
|
||||
});
|
||||
|
||||
// Criar transporter do nodemailer
|
||||
const transporter = nodemailer.createTransport({
|
||||
// Criar transporter do nodemailer com configuração melhorada
|
||||
const transporterOptions: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
requireTLS?: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
tls?: {
|
||||
rejectUnauthorized: boolean;
|
||||
ciphers?: string;
|
||||
};
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
socketTimeout: number;
|
||||
pool?: boolean;
|
||||
maxConnections?: number;
|
||||
maxMessages?: number;
|
||||
} = {
|
||||
host: config.servidor,
|
||||
port: config.porta,
|
||||
secure: config.usarSSL,
|
||||
requireTLS: config.usarTLS,
|
||||
auth: {
|
||||
user: config.usuario,
|
||||
pass: config.senha, // Senha já descriptografada
|
||||
},
|
||||
tls: {
|
||||
// Permitir certificados autoassinados apenas se necessário
|
||||
connectionTimeout: 15000, // 15 segundos
|
||||
greetingTimeout: 15000,
|
||||
socketTimeout: 15000,
|
||||
pool: true, // Usar pool de conexões
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
};
|
||||
|
||||
// Adicionar TLS apenas se necessário
|
||||
if (config.usarTLS) {
|
||||
transporterOptions.requireTLS = true;
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false, // Permitir certificados autoassinados
|
||||
};
|
||||
} else if (config.usarSSL) {
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false,
|
||||
ciphers: "SSLv3",
|
||||
},
|
||||
connectionTimeout: 10000, // 10 segundos
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
// Verificar conexão antes de enviar
|
||||
try {
|
||||
await transporter.verify();
|
||||
console.log("✅ Conexão SMTP verificada com sucesso");
|
||||
} catch (verifyError) {
|
||||
const verifyErrorMessage = verifyError instanceof Error ? verifyError.message : String(verifyError);
|
||||
console.warn("⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", verifyErrorMessage);
|
||||
// Não bloquear envio por falha na verificação, apenas avisar
|
||||
}
|
||||
|
||||
// Validar email destinatário antes de enviar
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -71,13 +130,28 @@ export const enviar = action({
|
||||
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
|
||||
}
|
||||
|
||||
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
|
||||
const textoPlano = email.corpo
|
||||
.replace(/<[^>]*>/g, "") // Remover tags HTML
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
// Enviar email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
|
||||
to: email.destinatario,
|
||||
subject: email.assunto,
|
||||
html: email.corpo,
|
||||
text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML
|
||||
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
|
||||
headers: {
|
||||
"X-Mailer": "SGSE-Sistema",
|
||||
"X-Priority": "3",
|
||||
},
|
||||
});
|
||||
|
||||
interface MessageInfo {
|
||||
@@ -102,12 +176,23 @@ export const enviar = action({
|
||||
return { sucesso: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("❌ Erro ao enviar email:", errorMessage);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
console.error("❌ Erro ao enviar email:", {
|
||||
emailId: args.emailId,
|
||||
destinatario: email?.destinatario,
|
||||
erro: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
// Marcar como falha com detalhes completos
|
||||
const erroCompleto = errorStack
|
||||
? `${errorMessage}\n\nStack: ${errorStack}`
|
||||
: errorMessage;
|
||||
|
||||
// Marcar como falha
|
||||
await ctx.runMutation(internal.email.markEmailFalha, {
|
||||
emailId: args.emailId,
|
||||
erro: errorMessage,
|
||||
erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
|
||||
});
|
||||
|
||||
return { sucesso: false, erro: errorMessage };
|
||||
|
||||
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 { Doc, Id } from "./_generated/dataModel";
|
||||
import type { QueryCtx, MutationCtx } from "./_generated/server";
|
||||
import { internal, api } from "./_generated/api";
|
||||
|
||||
// ========== HELPERS ==========
|
||||
|
||||
/**
|
||||
* Normaliza texto para busca (remove acentos, converte para lowercase)
|
||||
*/
|
||||
function normalizarTextoParaBusca(texto: string): string {
|
||||
return texto
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // Remove diacríticos
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function para obter usuário autenticado (Better Auth ou Sessão)
|
||||
*/
|
||||
@@ -190,6 +202,7 @@ export const enviarMensagem = mutation({
|
||||
arquivoTamanho: v.optional(v.number()),
|
||||
arquivoTipo: v.optional(v.string()),
|
||||
mencoes: v.optional(v.array(v.id("usuarios"))),
|
||||
respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
|
||||
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -203,20 +216,78 @@ export const enviarMensagem = mutation({
|
||||
throw new Error("Você não pertence a esta conversa");
|
||||
}
|
||||
|
||||
// Normalizar conteúdo para busca (remover acentos, lowercase)
|
||||
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
|
||||
|
||||
// Verificar se é resposta a outra mensagem
|
||||
if (args.respostaPara) {
|
||||
const mensagemOriginal = await ctx.db.get(args.respostaPara);
|
||||
if (!mensagemOriginal || mensagemOriginal.conversaId !== args.conversaId) {
|
||||
throw new Error("Mensagem original não encontrada ou não pertence à mesma conversa");
|
||||
}
|
||||
if (mensagemOriginal.deletada) {
|
||||
throw new Error("Não é possível responder a uma mensagem deletada");
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto)
|
||||
let linkPreview = undefined;
|
||||
if (args.tipo === "texto") {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const urls = args.conteudo.match(urlRegex);
|
||||
if (urls && urls.length > 0) {
|
||||
// Pegar primeira URL encontrada
|
||||
const primeiraUrl = urls[0];
|
||||
// Agendar extração de preview (assíncrono, não bloqueia envio)
|
||||
ctx.scheduler.runAfter(1000, api.actions.linkPreview.extrairPreviewLink, {
|
||||
url: primeiraUrl,
|
||||
}).then((preview) => {
|
||||
if (preview) {
|
||||
// Atualizar mensagem com preview via mutation interna
|
||||
return ctx.runMutation(internal.chat.atualizarLinkPreview, {
|
||||
mensagemId,
|
||||
linkPreview: preview,
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("Erro ao agendar/processar preview de link:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Criar mensagem
|
||||
const mensagemId = await ctx.db.insert("mensagens", {
|
||||
conversaId: args.conversaId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
tipo: args.tipo,
|
||||
conteudo: args.conteudo,
|
||||
conteudoBusca,
|
||||
arquivoId: args.arquivoId,
|
||||
arquivoNome: args.arquivoNome,
|
||||
arquivoTamanho: args.arquivoTamanho,
|
||||
arquivoTipo: args.arquivoTipo,
|
||||
mencoes: args.mencoes,
|
||||
respostaPara: args.respostaPara,
|
||||
enviadaEm: Date.now(),
|
||||
});
|
||||
|
||||
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
|
||||
if (args.tipo === "texto") {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const urls = args.conteudo.match(urlRegex);
|
||||
if (urls && urls.length > 0) {
|
||||
// Pegar primeira URL encontrada
|
||||
const primeiraUrl = urls[0];
|
||||
// Agendar processamento de preview via action wrapper
|
||||
ctx.scheduler.runAfter(1000, api.actions.linkPreview.processarPreviewLink, {
|
||||
mensagemId,
|
||||
url: primeiraUrl,
|
||||
}).catch((error) => {
|
||||
console.error("Erro ao agendar processamento de preview de link:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar última mensagem da conversa
|
||||
await ctx.db.patch(args.conversaId, {
|
||||
ultimaMensagem: args.conteudo.substring(0, 100),
|
||||
@@ -236,20 +307,79 @@ export const enviarMensagem = mutation({
|
||||
? "mencao"
|
||||
: "nova_mensagem";
|
||||
|
||||
const titulo =
|
||||
tipoNotificacao === "mencao"
|
||||
? `${usuarioAtual.nome} mencionou você`
|
||||
: `Nova mensagem de ${usuarioAtual.nome}`;
|
||||
const descricao = args.conteudo.substring(0, 100);
|
||||
|
||||
// Criar notificação no banco
|
||||
await ctx.db.insert("notificacoes", {
|
||||
usuarioId: participanteId,
|
||||
tipo: tipoNotificacao,
|
||||
conversaId: args.conversaId,
|
||||
mensagemId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
titulo:
|
||||
tipoNotificacao === "mencao"
|
||||
? `${usuarioAtual.nome} mencionou você`
|
||||
: `Nova mensagem de ${usuarioAtual.nome}`,
|
||||
descricao: args.conteudo.substring(0, 100),
|
||||
titulo,
|
||||
descricao,
|
||||
lida: false,
|
||||
criadaEm: Date.now(),
|
||||
});
|
||||
|
||||
// Enviar push notification (assíncrono, não bloqueia)
|
||||
ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
|
||||
usuarioId: participanteId,
|
||||
titulo,
|
||||
corpo: descricao,
|
||||
data: {
|
||||
conversaId: args.conversaId,
|
||||
mensagemId,
|
||||
tipo: tipoNotificacao,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(`Erro ao agendar push para usuário ${participanteId}:`, error);
|
||||
});
|
||||
|
||||
// Se usuário offline, enviar email (assíncrono)
|
||||
const usuarioOnline = await ctx.runQuery(internal.pushNotifications.verificarUsuarioOnline, {
|
||||
usuarioId: participanteId,
|
||||
});
|
||||
|
||||
if (!usuarioOnline) {
|
||||
// Verificar preferências de email para esta conversa
|
||||
const preferencias = await ctx.db
|
||||
.query("preferenciasNotificacaoConversa")
|
||||
.withIndex("by_usuario_conversa", (q) =>
|
||||
q.eq("usuarioId", participanteId).eq("conversaId", args.conversaId)
|
||||
)
|
||||
.first();
|
||||
|
||||
const deveEnviarEmail = !preferencias || preferencias.emailAtivado !== false;
|
||||
|
||||
if (deveEnviarEmail) {
|
||||
// Buscar email do usuário
|
||||
const usuarioParticipante = await ctx.db.get(participanteId);
|
||||
if (usuarioParticipante?.email) {
|
||||
// Obter URL do sistema (padrão: localhost para dev)
|
||||
const urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||
|
||||
ctx.scheduler.runAfter(1000, api.email.enviarEmailComTemplate, {
|
||||
destinatario: usuarioParticipante.email,
|
||||
destinatarioId: participanteId,
|
||||
templateCodigo: tipoNotificacao === "mencao" ? "chat_mencao" : "chat_mensagem",
|
||||
variaveis: {
|
||||
remetente: usuarioAtual.nome,
|
||||
mensagem: descricao,
|
||||
conversaId: args.conversaId,
|
||||
urlSistema,
|
||||
},
|
||||
enviadoPorId: usuarioAtual._id,
|
||||
}).catch((error) => {
|
||||
console.error(`Erro ao agendar email para usuário ${participanteId}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -558,6 +688,83 @@ export const marcarTodasNotificacoesLidas = mutation({
|
||||
/**
|
||||
* Deleta uma mensagem (soft delete)
|
||||
*/
|
||||
/**
|
||||
* Editar mensagem enviada
|
||||
*/
|
||||
export const editarMensagem = mutation({
|
||||
args: {
|
||||
mensagemId: v.id("mensagens"),
|
||||
novoConteudo: v.string(),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) {
|
||||
return { sucesso: false, erro: "Não autenticado" };
|
||||
}
|
||||
|
||||
const mensagem = await ctx.db.get(args.mensagemId);
|
||||
if (!mensagem) {
|
||||
return { sucesso: false, erro: "Mensagem não encontrada" };
|
||||
}
|
||||
|
||||
// Verificar se usuário é o remetente
|
||||
if (mensagem.remetenteId !== usuarioAtual._id) {
|
||||
return { sucesso: false, erro: "Você só pode editar suas próprias mensagens" };
|
||||
}
|
||||
|
||||
// Verificar se mensagem não foi deletada
|
||||
if (mensagem.deletada) {
|
||||
return { sucesso: false, erro: "Não é possível editar uma mensagem deletada" };
|
||||
}
|
||||
|
||||
// Verificar se não é mensagem agendada
|
||||
if (mensagem.agendadaPara) {
|
||||
return { sucesso: false, erro: "Não é possível editar mensagens agendadas" };
|
||||
}
|
||||
|
||||
// Validar novo conteúdo
|
||||
if (!args.novoConteudo || args.novoConteudo.trim().length === 0) {
|
||||
return { sucesso: false, erro: "O conteúdo da mensagem não pode estar vazio" };
|
||||
}
|
||||
|
||||
// Normalizar conteúdo para busca
|
||||
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
|
||||
|
||||
// Atualizar mensagem
|
||||
await ctx.db.patch(args.mensagemId, {
|
||||
conteudo: args.novoConteudo.trim(),
|
||||
conteudoBusca,
|
||||
editadaEm: Date.now(),
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation interna para atualizar link preview
|
||||
*/
|
||||
export const atualizarLinkPreview = internalMutation({
|
||||
args: {
|
||||
mensagemId: v.id("mensagens"),
|
||||
linkPreview: v.object({
|
||||
url: v.string(),
|
||||
titulo: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
imagem: v.optional(v.string()),
|
||||
site: v.optional(v.string()),
|
||||
}),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.mensagemId, {
|
||||
linkPreview: args.linkPreview,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const deletarMensagem = mutation({
|
||||
args: {
|
||||
mensagemId: v.id("mensagens"),
|
||||
@@ -710,7 +917,7 @@ export const obterMensagens = query({
|
||||
// Filtrar mensagens agendadas
|
||||
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
|
||||
|
||||
// Enriquecer com informações do remetente
|
||||
// Enriquecer com informações do remetente e mensagem respondida
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
mensagensFiltradas.map(async (mensagem) => {
|
||||
const remetente = await ctx.db.get(mensagem.remetenteId);
|
||||
@@ -718,10 +925,30 @@ export const obterMensagens = query({
|
||||
if (mensagem.arquivoId) {
|
||||
arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
|
||||
}
|
||||
|
||||
// Buscar mensagem original se for resposta
|
||||
let mensagemOriginal = null;
|
||||
if (mensagem.respostaPara) {
|
||||
const original = await ctx.db.get(mensagem.respostaPara);
|
||||
if (original) {
|
||||
const remetenteOriginal = await ctx.db.get(original.remetenteId);
|
||||
mensagemOriginal = {
|
||||
_id: original._id,
|
||||
conteudo: original.conteudo.substring(0, 100), // Limitar tamanho
|
||||
remetente: remetenteOriginal ? {
|
||||
_id: remetenteOriginal._id,
|
||||
nome: remetenteOriginal.nome,
|
||||
} : null,
|
||||
deletada: original.deletada || false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...mensagem,
|
||||
remetente,
|
||||
arquivoUrl,
|
||||
mensagemOriginal,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -960,17 +1187,25 @@ export const listarTodosUsuarios = query({
|
||||
});
|
||||
|
||||
/**
|
||||
* Busca mensagens em conversas
|
||||
* Busca mensagens em conversas com filtros avançados
|
||||
*/
|
||||
export const buscarMensagens = query({
|
||||
args: {
|
||||
query: v.string(),
|
||||
conversaId: v.optional(v.id("conversas")),
|
||||
remetenteId: v.optional(v.id("usuarios")),
|
||||
tipo: v.optional(v.union(v.literal("texto"), v.literal("arquivo"), v.literal("imagem"))),
|
||||
dataInicio: v.optional(v.number()),
|
||||
dataFim: v.optional(v.number()),
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
|
||||
// Normalizar query para busca
|
||||
const queryNormalizada = normalizarTextoParaBusca(args.query);
|
||||
|
||||
// Buscar em todas as conversas do usuário
|
||||
const todasConversas = await ctx.db.query("conversas").collect();
|
||||
const conversasDoUsuario = todasConversas.filter((c) =>
|
||||
@@ -980,6 +1215,12 @@ export const buscarMensagens = query({
|
||||
let mensagens: Doc<"mensagens">[] = [];
|
||||
|
||||
if (args.conversaId !== undefined) {
|
||||
// Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Buscar em conversa específica
|
||||
const mensagensConversa = await ctx.db
|
||||
.query("mensagens")
|
||||
@@ -987,7 +1228,7 @@ export const buscarMensagens = query({
|
||||
.collect();
|
||||
mensagens = mensagensConversa;
|
||||
} else {
|
||||
// Buscar em todas as conversas
|
||||
// Buscar em todas as conversas do usuário
|
||||
for (const conversa of conversasDoUsuario) {
|
||||
const mensagensConversa = await ctx.db
|
||||
.query("mensagens")
|
||||
@@ -997,14 +1238,49 @@ export const buscarMensagens = query({
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrar por query
|
||||
const queryLower = args.query.toLowerCase();
|
||||
const mensagensFiltradas = mensagens.filter(
|
||||
(m) =>
|
||||
!m.deletada &&
|
||||
!m.agendadaPara &&
|
||||
m.conteudo.toLowerCase().includes(queryLower)
|
||||
);
|
||||
// Aplicar filtros
|
||||
let mensagensFiltradas = mensagens.filter((m) => {
|
||||
// Excluir deletadas e agendadas
|
||||
if (m.deletada || m.agendadaPara) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrar por query (busca no conteúdo normalizado)
|
||||
if (queryNormalizada && queryNormalizada.length > 0) {
|
||||
const conteudoBusca = m.conteudoBusca || normalizarTextoParaBusca(m.conteudo);
|
||||
if (!conteudoBusca.includes(queryNormalizada)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrar por remetente
|
||||
if (args.remetenteId && m.remetenteId !== args.remetenteId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrar por tipo
|
||||
if (args.tipo && m.tipo !== args.tipo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrar por data
|
||||
if (args.dataInicio && m.enviadaEm < args.dataInicio) {
|
||||
return false;
|
||||
}
|
||||
if (args.dataFim && m.enviadaEm > args.dataFim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ordenar por data (mais recentes primeiro)
|
||||
mensagensFiltradas.sort((a, b) => b.enviadaEm - a.enviadaEm);
|
||||
|
||||
// Limitar resultados
|
||||
if (args.limite) {
|
||||
mensagensFiltradas = mensagensFiltradas.slice(0, args.limite);
|
||||
}
|
||||
|
||||
// Enriquecer com informações
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
|
||||
@@ -44,6 +44,139 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise<Doc<"
|
||||
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
|
||||
*/
|
||||
@@ -60,18 +193,27 @@ export const enfileirarEmail = mutation({
|
||||
returns: v.object({
|
||||
sucesso: v.boolean(),
|
||||
emailId: v.optional(v.id("notificacoesEmail")),
|
||||
erro: v.optional(v.string()),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(args.destinatario)) {
|
||||
return { sucesso: false };
|
||||
return { sucesso: false, erro: "Email destinatário inválido" };
|
||||
}
|
||||
|
||||
// Validar agendamento se fornecido
|
||||
if (args.agendadaPara !== undefined) {
|
||||
if (args.agendadaPara <= Date.now()) {
|
||||
return { sucesso: false };
|
||||
return { sucesso: false, erro: "Data de agendamento deve ser futura" };
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar rate limiting (apenas para envios imediatos, não agendados)
|
||||
if (args.agendadaPara === undefined) {
|
||||
const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
|
||||
if (!rateLimitCheck.permitido) {
|
||||
return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +231,11 @@ export const enfileirarEmail = mutation({
|
||||
agendadaPara: args.agendadaPara,
|
||||
});
|
||||
|
||||
// Registrar rate limit apenas para envios imediatos
|
||||
if (args.agendadaPara === undefined) {
|
||||
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
|
||||
}
|
||||
|
||||
// Agendar envio
|
||||
if (args.agendadaPara !== undefined) {
|
||||
// Agendar para o momento especificado
|
||||
@@ -122,6 +269,7 @@ export const enviarEmailComTemplate = mutation({
|
||||
returns: v.object({
|
||||
sucesso: v.boolean(),
|
||||
emailId: v.optional(v.id("notificacoesEmail")),
|
||||
erro: v.optional(v.string()),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Buscar template
|
||||
@@ -132,13 +280,21 @@ export const enviarEmailComTemplate = mutation({
|
||||
|
||||
if (!template) {
|
||||
console.error("Template não encontrado:", args.templateCodigo);
|
||||
return { sucesso: false };
|
||||
return { sucesso: false, erro: `Template "${args.templateCodigo}" não encontrado` };
|
||||
}
|
||||
|
||||
// Validar agendamento se fornecido
|
||||
if (args.agendadaPara !== undefined) {
|
||||
if (args.agendadaPara <= Date.now()) {
|
||||
return { sucesso: false };
|
||||
return { sucesso: false, erro: "Data de agendamento deve ser futura" };
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar rate limiting (apenas para envios imediatos, não agendados)
|
||||
if (args.agendadaPara === undefined) {
|
||||
const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
|
||||
if (!rateLimitCheck.permitido) {
|
||||
return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +316,11 @@ export const enviarEmailComTemplate = mutation({
|
||||
agendadaPara: args.agendadaPara,
|
||||
});
|
||||
|
||||
// Registrar rate limit apenas para envios imediatos
|
||||
if (args.agendadaPara === undefined) {
|
||||
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
|
||||
}
|
||||
|
||||
// Agendar envio
|
||||
if (args.agendadaPara !== undefined) {
|
||||
// Agendar para o momento especificado
|
||||
@@ -384,6 +545,64 @@ export const obterEstatisticasFilaEmails = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter estatísticas de rate limiting para um usuário
|
||||
*/
|
||||
export const obterEstatisticasRateLimit = query({
|
||||
args: {
|
||||
remetenteId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.object({
|
||||
emailsUltimoMinuto: v.number(),
|
||||
emailsUltimaHora: v.number(),
|
||||
limiteMinuto: v.number(),
|
||||
limiteHora: v.number(),
|
||||
podeEnviar: v.boolean(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const agora = Date.now();
|
||||
const umMinutoAtras = agora - 60 * 1000;
|
||||
const umaHoraAtras = agora - 60 * 60 * 1000;
|
||||
|
||||
// Contar emails do último minuto
|
||||
const emailsUltimoMinuto = await ctx.db
|
||||
.query("rateLimitEmails")
|
||||
.withIndex("by_remetente_periodo", (q) =>
|
||||
q.eq("remetenteId", args.remetenteId).eq("periodo", "minuto")
|
||||
)
|
||||
.filter((q) => q.gte(q.field("timestamp"), umMinutoAtras))
|
||||
.collect();
|
||||
|
||||
const totalUltimoMinuto = emailsUltimoMinuto.reduce(
|
||||
(sum, rl) => sum + rl.contador,
|
||||
0
|
||||
);
|
||||
|
||||
// Contar emails da última hora
|
||||
const emailsUltimaHora = await ctx.db
|
||||
.query("rateLimitEmails")
|
||||
.withIndex("by_remetente_periodo", (q) =>
|
||||
q.eq("remetenteId", args.remetenteId).eq("periodo", "hora")
|
||||
)
|
||||
.filter((q) => q.gte(q.field("timestamp"), umaHoraAtras))
|
||||
.collect();
|
||||
|
||||
const totalUltimaHora = emailsUltimaHora.reduce(
|
||||
(sum, rl) => sum + rl.contador,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
emailsUltimoMinuto: totalUltimoMinuto,
|
||||
emailsUltimaHora: totalUltimaHora,
|
||||
limiteMinuto: RATE_LIMIT_CONFIG.emailsPorMinuto,
|
||||
limiteHora: RATE_LIMIT_CONFIG.emailsPorHora,
|
||||
podeEnviar: totalUltimoMinuto < RATE_LIMIT_CONFIG.emailsPorMinuto &&
|
||||
totalUltimaHora < RATE_LIMIT_CONFIG.emailsPorHora,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Listar agendamentos de email do usuário atual
|
||||
*/
|
||||
@@ -516,6 +735,7 @@ export const markEmailFalha = internalMutation({
|
||||
|
||||
/**
|
||||
* Processar fila de emails (cron job - processa emails pendentes)
|
||||
* Implementa delay exponencial entre envios para evitar bloqueio SMTP
|
||||
*/
|
||||
export const processarFilaEmails = internalMutation({
|
||||
args: {},
|
||||
@@ -537,32 +757,62 @@ export const processarFilaEmails = internalMutation({
|
||||
let processados = 0;
|
||||
let falhas = 0;
|
||||
|
||||
// Agrupar emails por remetente para aplicar rate limiting e delay
|
||||
const emailsPorRemetente = new Map<Id<"usuarios">, Array<Doc<"notificacoesEmail">>>();
|
||||
for (const email of emailsPendentes) {
|
||||
// Verificar se não excedeu tentativas (max 3)
|
||||
if ((email.tentativas || 0) >= 3) {
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "falha",
|
||||
erroDetalhes: "Número máximo de tentativas excedido",
|
||||
});
|
||||
falhas++;
|
||||
continue;
|
||||
if (!emailsPorRemetente.has(email.enviadoPor)) {
|
||||
emailsPorRemetente.set(email.enviadoPor, []);
|
||||
}
|
||||
emailsPorRemetente.get(email.enviadoPor)!.push(email);
|
||||
}
|
||||
|
||||
// Agendar envio via action
|
||||
try {
|
||||
await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
|
||||
emailId: email._id,
|
||||
});
|
||||
processados++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "falha",
|
||||
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
|
||||
tentativas: (email.tentativas || 0) + 1,
|
||||
});
|
||||
falhas++;
|
||||
for (const [remetenteId, emails] of emailsPorRemetente.entries()) {
|
||||
// Verificar rate limit do remetente
|
||||
const rateLimitCheck = await verificarRateLimit(ctx, remetenteId);
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const email = emails[i];
|
||||
|
||||
// Verificar se não excedeu tentativas (max 3)
|
||||
if ((email.tentativas || 0) >= 3) {
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "falha",
|
||||
erroDetalhes: "Número máximo de tentativas excedido",
|
||||
});
|
||||
falhas++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Se rate limit excedido, pular este lote
|
||||
if (!rateLimitCheck.permitido && i === 0) {
|
||||
console.log(`⏸️ Rate limit excedido para remetente ${remetenteId}, aguardando...`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Delay exponencial baseado na tentativa (primeira: 0ms, segunda: 2s, terceira: 4s)
|
||||
const delayExponencial = email.tentativas
|
||||
? Math.min(2000 * Math.pow(2, email.tentativas - 1), 10000) // Máximo 10s
|
||||
: 0;
|
||||
|
||||
// Delay adicional entre emails do mesmo remetente (1 segundo)
|
||||
const delayEntreEmails = i * 1000;
|
||||
|
||||
// Agendar envio via action com delay
|
||||
try {
|
||||
await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, {
|
||||
emailId: email._id,
|
||||
});
|
||||
processados++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "falha",
|
||||
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
|
||||
tentativas: (email.tentativas || 0) + 1,
|
||||
});
|
||||
falhas++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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(),
|
||||
}).index("by_chave", ["chave"]),
|
||||
|
||||
// Rate Limiting de Emails
|
||||
rateLimitEmails: defineTable({
|
||||
remetenteId: v.id("usuarios"),
|
||||
timestamp: v.number(),
|
||||
contador: v.number(), // quantidade de emails enviados neste período
|
||||
periodo: v.union(
|
||||
v.literal("minuto"), // último minuto
|
||||
v.literal("hora") // última hora
|
||||
),
|
||||
})
|
||||
.index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"])
|
||||
.index("by_timestamp", ["timestamp"]),
|
||||
|
||||
// Sistema de Chat
|
||||
conversas: defineTable({
|
||||
tipo: v.union(v.literal("individual"), v.literal("grupo")),
|
||||
@@ -628,10 +641,20 @@ export default defineSchema({
|
||||
v.literal("imagem")
|
||||
),
|
||||
conteudo: v.string(), // texto ou nome do arquivo
|
||||
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
|
||||
arquivoId: v.optional(v.id("_storage")),
|
||||
arquivoNome: v.optional(v.string()),
|
||||
arquivoTamanho: v.optional(v.number()),
|
||||
arquivoTipo: v.optional(v.string()),
|
||||
linkPreview: v.optional(
|
||||
v.object({
|
||||
url: v.string(),
|
||||
titulo: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
imagem: v.optional(v.string()),
|
||||
site: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
reagiuPor: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
@@ -641,6 +664,7 @@ export default defineSchema({
|
||||
)
|
||||
),
|
||||
mencoes: v.optional(v.array(v.id("usuarios"))),
|
||||
respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
|
||||
agendadaPara: v.optional(v.number()), // timestamp
|
||||
enviadaEm: v.number(),
|
||||
editadaEm: v.optional(v.number()),
|
||||
@@ -648,7 +672,8 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_conversa", ["conversaId", "enviadaEm"])
|
||||
.index("by_remetente", ["remetenteId"])
|
||||
.index("by_agendamento", ["agendadaPara"]),
|
||||
.index("by_agendamento", ["agendadaPara"])
|
||||
.index("by_resposta", ["respostaPara"]),
|
||||
|
||||
leituras: defineTable({
|
||||
conversaId: v.id("conversas"),
|
||||
@@ -686,6 +711,37 @@ export default defineSchema({
|
||||
.index("by_conversa", ["conversaId", "iniciouEm"])
|
||||
.index("by_usuario", ["usuarioId"]),
|
||||
|
||||
// Push Notifications
|
||||
pushSubscriptions: defineTable({
|
||||
usuarioId: v.id("usuarios"),
|
||||
endpoint: v.string(), // URL do serviço de push
|
||||
keys: v.object({
|
||||
p256dh: v.string(), // Chave pública
|
||||
auth: v.string(), // Chave de autenticação
|
||||
}),
|
||||
userAgent: v.optional(v.string()),
|
||||
criadoEm: v.number(),
|
||||
ultimaAtividade: v.number(),
|
||||
ativo: v.boolean(),
|
||||
})
|
||||
.index("by_usuario", ["usuarioId", "ativo"])
|
||||
.index("by_endpoint", ["endpoint"]),
|
||||
|
||||
// Preferências de Notificação por Conversa
|
||||
preferenciasNotificacaoConversa: defineTable({
|
||||
usuarioId: v.id("usuarios"),
|
||||
conversaId: v.id("conversas"),
|
||||
pushAtivado: v.boolean(), // Receber push notifications
|
||||
emailAtivado: v.boolean(), // Receber emails quando offline
|
||||
somAtivado: v.boolean(), // Tocar som
|
||||
silenciado: v.boolean(), // Silenciar completamente
|
||||
apenasMencoes: v.boolean(), // Notificar apenas quando mencionado
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number(),
|
||||
})
|
||||
.index("by_usuario_conversa", ["usuarioId", "conversaId"])
|
||||
.index("by_conversa", ["conversaId"]),
|
||||
|
||||
// Tabelas de Monitoramento do Sistema
|
||||
systemMetrics: defineTable({
|
||||
timestamp: v.number(),
|
||||
|
||||
@@ -237,6 +237,56 @@ export const criarTemplatesPadrao = mutation({
|
||||
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
|
||||
variaveis: ["nome", "matricula", "senha"],
|
||||
},
|
||||
{
|
||||
codigo: "chat_mensagem",
|
||||
nome: "Nova Mensagem no Chat",
|
||||
titulo: "Nova mensagem de {{remetente}}",
|
||||
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||
+ "<h2 style='color: #4F46E5;'>Nova mensagem no chat</h2>"
|
||||
+ "<p><strong>{{remetente}}</strong> enviou uma nova mensagem:</p>"
|
||||
+ "<div style='background-color: #F3F4F6; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||
+ "<p style='margin: 0;'>{{mensagem}}</p>"
|
||||
+ "</div>"
|
||||
+ "<p style='margin-top: 30px;'>"
|
||||
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
|
||||
+ "style='background-color: #4F46E5; color: white; padding: 12px 24px; "
|
||||
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||
+ "Ver conversa"
|
||||
+ "</a>"
|
||||
+ "</p>"
|
||||
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||
+ "Você está recebendo este email porque não estava online quando a mensagem foi enviada. "
|
||||
+ "Você pode desativar essas notificações nas configurações da conversa."
|
||||
+ "</p>"
|
||||
+ "</div></body></html>",
|
||||
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
|
||||
},
|
||||
{
|
||||
codigo: "chat_mencao",
|
||||
nome: "Menção no Chat",
|
||||
titulo: "{{remetente}} mencionou você",
|
||||
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||
+ "<h2 style='color: #DC2626;'>Você foi mencionado!</h2>"
|
||||
+ "<p><strong>{{remetente}}</strong> mencionou você em uma mensagem:</p>"
|
||||
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||
+ "<p style='margin: 0;'>{{mensagem}}</p>"
|
||||
+ "</div>"
|
||||
+ "<p style='margin-top: 30px;'>"
|
||||
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
|
||||
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
|
||||
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||
+ "Ver mensagem"
|
||||
+ "</a>"
|
||||
+ "</p>"
|
||||
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||
+ "Você está recebendo este email porque foi mencionado em uma conversa. "
|
||||
+ "Você pode desativar essas notificações nas configurações da conversa."
|
||||
+ "</p>"
|
||||
+ "</div></body></html>",
|
||||
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const template of templatesPadrao) {
|
||||
|
||||
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