Compare commits

...

185 commits

Author SHA1 Message Date
rever-tecnologia
2d878f28e8 Evita estado de anexando audio preso
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 14s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m43s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m48s
2025-12-19 16:15:38 -03:00
rever-tecnologia
bc67dc01ef Ajusta player de audio e permite seek
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m9s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m16s
2025-12-19 16:08:00 -03:00
rever-tecnologia
d125930cf6 Libera upload do Convex com CORS
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m3s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m19s
2025-12-19 15:47:48 -03:00
rever-tecnologia
6efbbd49e7 Melhora chat com audio anexado e auto-scroll
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 8s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 5m33s
Quality Checks / Lint, Test and Build (push) Successful in 6m14s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m48s
2025-12-19 15:12:50 -03:00
rever-tecnologia
43017e6fef fix(desktop): fechar hub sem sessoes
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m18s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m45s
2025-12-19 13:06:24 -03:00
rever-tecnologia
394fa9e2a3 fix(desktop): ajustar hub do chat e marca
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m40s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m24s
2025-12-19 11:24:37 -03:00
rever-tecnologia
a43151f84f Ajusta tamanho do logo colapsado
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m46s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m59s
2025-12-19 08:07:11 -03:00
rever-tecnologia
0dd9f10984 Reduz logo colapsado na sidebar 2025-12-19 07:59:12 -03:00
rever-tecnologia
2334e9a9ec Ajusta texto do portal e logo da sidebar 2025-12-19 07:55:45 -03:00
rever-tecnologia
badcb0f502 Ajusta marca na sidebar
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m40s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m2s
2025-12-19 00:17:11 -03:00
rever-tecnologia
0a0f722bd8 Melhora UX do chat no desktop
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m5s
2025-12-18 23:53:24 -03:00
rever-tecnologia
9142446f06 Corrige inicio do chat no desktop
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m21s
Quality Checks / Lint, Test and Build (push) Successful in 3m28s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
2025-12-18 22:50:24 -03:00
rever-tecnologia
9c6e724128 chore(prod): ajustar stack convex/traefik e registrar alteracoes
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m37s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m55s
2025-12-18 21:22:06 -03:00
rever-tecnologia
c030a3ac09 fix: tratar tokens de maquinas e alinhar stack/docs
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m0s
2025-12-18 18:20:35 -03:00
rever-tecnologia
b7e2c4cc98 Exibe status de reprovisionamento quando token é revogado
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m53s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m13s
2025-12-18 16:12:48 -03:00
rever-tecnologia
89f756e088 Corrige redirect do portal após reset do dispositivo
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m54s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m31s
2025-12-18 15:29:08 -03:00
rever-tecnologia
84117e6821 feat(ui): implementa sidebar colapsavel com icones e tooltips
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 9s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m42s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m20s
- Muda collapsible de offcanvas para icon na sidebar
- Adiciona tooltips aos itens de menu quando colapsado
- Itens com submenu mostram mini-menu no tooltip
- Logo mostra apenas icone quando colapsado
- NavUser mostra apenas avatar quando colapsado
- Adiciona separadores entre secoes quando colapsado
- Centraliza icones horizontalmente no modo colapsado
- Persiste estado da sidebar via cookie entre navegacoes
- Corrige hydration mismatch com sincronizacao pos-hidratacao
- Desabilita transicoes durante sincronizacao inicial
- Remove bolinha do tooltip e ajusta espacamento
- Corrige redirecionamento ao resetar dispositivo no Tauri

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:38:35 -03:00
rever-tecnologia
826b376dd3 fix(desktop): corrige navegacao ao resetar dispositivo
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m1s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m53s
Usa window.location.href com URL do Tauri em vez de API inexistente

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:08:56 -03:00
rever-tecnologia
d6531e2a4c fix(desktop): navega para URL do app Tauri ao resetar dispositivo
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
Usa getCurrentWebviewWindow().navigate() para voltar para a pagina
inicial do app Tauri em vez de ir para o servidor web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:06:53 -03:00
rever-tecnologia
649a270416 fix(desktop): remove parametro nao utilizado companyName
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
Quality Checks / Lint, Test and Build (push) Successful in 4m10s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:57:15 -03:00
rever-tecnologia
af37f0b30d style(desktop): padroniza header da tela de registro
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
Usa mesmo layout da sidebar: Raven + badge Plataforma de chamados

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:56:16 -03:00
rever-tecnologia
026772e2f4 fix(desktop): corrige comportamento ao resetar dispositivo
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
- Limpa todos os campos de input ao resetar (codigo, email, nome)
- Forca recarregar pagina inicial para sair de pagina web carregada
- Evita redirect para login web quando dispositivo e resetado

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:54:27 -03:00
rever-tecnologia
06c16ab2a9 fix(desktop): impede scroll na tela de desativacao
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m37s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Usa fixed inset-0 com overflow-hidden para bloquear scroll

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:50:19 -03:00
rever-tecnologia
73c14e2be3 fix(desktop): ajusta layout da tela de desativacao
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Remove exibicao do nome da empresa
- Coloca botoes lado a lado

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:48:59 -03:00
rever-tecnologia
f4a3b22aab fix(desktop): corrige tela de desativacao duplicada no app Tauri
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m38s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m11s
- Adiciona verificacao de isMachineActive antes de redirecionar para handshake
- Remove mensagens de erro antigas com erro gramatical
- Corrige texto do template HTML de desativacao no servidor
- Corrige mensagem de erro na API de sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:32:07 -03:00
rever-tecnologia
1a0574e7f4 style(login): melhora layout da pagina de login
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m7s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m24s
- Adiciona bordas arredondadas e efeito suspenso no painel direito
- Aumenta proporcionalmente o header (Raven, Helpdesk, Por Rever Tecnologia)
- Ajusta posicionamento do header com margem superior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:52:51 -03:00
rever-tecnologia
26b1a65ec4 refactor(devices): substitui badge arquitetura por status ativacao Windows
- Remove identificador machine-xxx do header dos cards
- Substitui badge de arquitetura (X86_64) por status de ativacao do Windows
- Remove badge redundante de tipo de dispositivo
- Ajusta altura da badge na sidebar para nao cortar texto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:48:12 -03:00
rever-tecnologia
d95af184be style(sidebar): transforma subtitulo em badge pill
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m33s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m32s
- Subtitulo agora aparece como badge com fundo escuro
- Alterado texto para "Plataforma de chamados"
- Estilo consistente com badges da plataforma

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:37:45 -03:00
rever-tecnologia
08ae1bd969 style(desktop): transforma titulo em badge estilizada
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- "Sistema de chamados" agora aparece como badge pill
- Estilo consistente com badges usadas na plataforma web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:34:49 -03:00
rever-tecnologia
eb284a7f50 fix(desktop): corrige logo na tela de registro
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Adiciona logo-raven.png local no public do desktop
- Usa logo local como padrao em vez de buscar do servidor
- Fallback continua buscando do servidor se local falhar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:33:55 -03:00
rever-tecnologia
993bd3890a style(devices): substitui spinner por skeleton cards no loading
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
- Cria DeviceCardSkeleton que simula a estrutura do DeviceCard
- Exibe grid de 6 skeletons durante carregamento
- Mantém mesmo layout responsivo dos cards reais

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:31:17 -03:00
rever-tecnologia
a9feea9b78 style(companies): centraliza itens na tabela de empresas
- Adiciona text-center em todos os TableCell
- Centraliza conteudo interno com justify-center
- Mantem texto a esquerda dentro dos cards de contrato/contato

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:29:29 -03:00
rever-tecnologia
d38d5d39eb fix(companies): corrige modal de edicao de empresas
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m37s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m4s
- Corrige erro NaN nos campos numericos (reopenWindowDays, contractedHoursPerMonth)
  usando z.preprocess para converter NaN para null
- Ajusta espacamento nos AccordionContent para melhor legibilidade
- Define tamanho consistente nos botoes Cancelar/Salvar (size="sm")
- Accordions agora iniciam fechados por padrao

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:26:02 -03:00
rever-tecnologia
a5bab2cc33 fix(desktop): corrige tela de desativacao e adiciona botao Verificar novamente
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m30s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m56s
- Corrige erro gramatical: "Dispositivo desativada" -> "Dispositivo desativado"
- Adiciona botao "Verificar novamente" na tela de desativacao
- Adiciona callback onReactivated no MachineStateMonitor
- Corrige fundo escuro para cobrir toda a tela quando desativado
- Corrige acentuacoes faltantes no historico de automacoes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:09:17 -03:00
rever-tecnologia
70cba99424 feat(automations): historico expandivel com detalhes das acoes
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m15s
Quality Checks / Lint, Test and Build (push) Successful in 3m35s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m20s
- Adiciona linhas expandiveis no historico de execucoes
- Mostra detalhes completos de cada acao (destinatarios, assunto, etc.)
- Salva mais informacoes no backend para acoes de e-mail
- Remove log de progresso do dashboard de reports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:51:37 -03:00
rever-tecnologia
c2802b1a4d feat(export): adiciona colunas bootInfo e remove Fleet do inventario
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 8s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m19s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m45s
- Adiciona colunas lastBootTime, uptimeFormatted, bootCount30d
- Remove colunas fleetId, fleetTeam, fleetUpdatedAt (nao utilizadas)
- Adiciona funcao extractBootInfo() para extrair dados de extended.windows.bootInfo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:39:39 -03:00
rever-tecnologia
1a75a69d4a refactor(devices): remove secoes de alertas de postura e historico
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m45s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m45s
2025-12-18 10:08:56 -03:00
rever-tecnologia
ad5e26f211 chore: remove debug logs do heartbeat
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
2025-12-18 10:06:14 -03:00
rever-tecnologia
dc740cd89a fix: remove declaracao duplicada de rawInventory
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
Quality Checks / Lint, Test and Build (push) Successful in 3m12s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m18s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m41s
2025-12-18 09:56:26 -03:00
rever-tecnologia
db23ea1901 fix(heartbeat): extrai inventory de metadata quando args.inventory vazio
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 2m54s
CI/CD Web + Desktop / Deploy Convex functions (push) Failing after 41s
Quality Checks / Lint, Test and Build (push) Has been cancelled
O agente envia o inventory dentro de metadata, não diretamente em args.inventory.
Agora o backend verifica ambos os lugares.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:49:08 -03:00
rever-tecnologia
f39bd46c2b feat: adiciona informacoes de reinicio e melhora SLA global
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m0s
Quality Checks / Lint, Test and Build (push) Successful in 3m29s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m24s
- Agente Rust: captura LastBootTime, uptime e contagem de boots
- Backend: extrai campos do extended (bootInfo, discos, RAM, etc) antes de salvar
- Frontend /devices: exibe secao de ultimo reinicio
- SLA global: adiciona campos de modo, threshold de alerta e status de pausa
- Corrige acento em "destinatario" -> "destinatario" em automations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:38:58 -03:00
rever-tecnologia
d32b94c22d fix(sla): corrige acentuacao no SLA por empresa
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m38s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m5s
- Corrige acentos: Critico -> Crítico, Media -> Média
- Corrige: Horas uteis -> Horas úteis
- Corrige: solucao -> solução, resolucao -> resolução
- Corrige: nao -> não, Nao foi possivel -> Não foi possível
- Corrige: acao ira -> ação irá, passarao -> passarão

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:15:11 -03:00
rever-tecnologia
d6188fd384 fix(checklists): adiciona skeleton de carregamento na lista de templates
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m6s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m22s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:10:47 -03:00
rever-tecnologia
ce52a4393b fix(close-ticket): adiciona segundos na formatacao e ajuste de tempo
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m15s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m24s
- Corrige formatacao de tempo para exibir segundos (ex: 2m 04s)
- Adiciona campo de segundos nos inputs de ajuste de tempo
- Melhora espacamento entre secoes de tempo interno e externo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:59:43 -03:00
rever-tecnologia
f9deb408dc fix: melhora UX de softwares e campos personalizados
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m4s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m20s
Softwares instalados:
- Corrige contador de paginação (mostra 1-30, 31-60, etc.)
- Remove tooltip do botão de limpar pesquisa

Campos personalizados:
- Adiciona mensagem indicando tipo de dispositivo (desktop/celular)
- Melhora mensagem quando não há campos disponíveis
- Adiciona botão X para limpar valor de campos texto/número

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:44:57 -03:00
rever-tecnologia
73de65bbaf fix: corrige acentuacao e melhora UX de softwares
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m19s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m49s
- Adiciona botao de limpar pesquisa em softwares
- Corrige paginacao com historico de cursores
- Corrige acentuacao em politicas de SLA (politica -> política)
- Corrige acentuacao em varios textos do frontend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:23:16 -03:00
rever-tecnologia
cfb72358bc fix: corrige acentuacao em mensagens do usuario
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 4m20s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m23s
- versao -> versão
- nao -> não
- Atualizacao -> Atualização

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:15:45 -03:00
rever-tecnologia
3e63589055 feat(devices): exibe bateria, sensores termicos, rede, monitores e chassis
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Adiciona tipos TypeScript para novos dados do Windows
- Exibe informacoes de bateria com status traduzido
- Exibe sensores termicos em tabela
- Exibe adaptadores de rede com velocidade e status
- Exibe monitores conectados com fabricante e serial
- Exibe info do chassis/gabinete com tipo traduzido

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:13:26 -03:00
rever-tecnologia
f0c2bdc283 feat(agent): adiciona captura de bateria, sensores termicos, rede e monitores
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m11s
Quality Checks / Lint, Test and Build (push) Successful in 4m22s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m28s
- Captura info de bateria (Win32_Battery) com status traduzido
- Captura sensores termicos via WMI ThermalZone e OpenHardwareMonitor
- Captura adaptadores de rede fisicos com status de conexao
- Captura monitores conectados (fabricante, serial, modelo)
- Captura info de chassis/gabinete com tipo traduzido

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:04:43 -03:00
rever-tecnologia
23fe67e7d3 feat(devices): implementa tabela separada para softwares instalados
- Cria tabela machineSoftware no schema com indices otimizados
- Adiciona mutations para sincronizar softwares do heartbeat
- Atualiza heartbeat para processar e salvar softwares
- Cria componente DeviceSoftwareList com pesquisa e paginacao
- Integra lista de softwares no drawer de detalhes do dispositivo

feat(sla): transforma formulario em modal completo

- Substitui formulario inline por modal guiado
- Adiciona badge "Global" para indicar escopo da politica
- Adiciona seletor de unidade de tempo (minutos, horas, dias)
- Melhora textos e adiciona dica sobre hierarquia de SLAs

fix(reports): ajusta altura do SearchableCombobox

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:00:40 -03:00
rever-tecnologia
ef2545221d ui(sla): move SLA por empresa para acima de SLA por categoria
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
Quality Checks / Lint, Test and Build (push) Successful in 3m35s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m34s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:46:15 -03:00
rever-tecnologia
a2fa5d046c fix(sla): adiciona verificacao segura de company.id
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:43:18 -03:00
rever-tecnologia
a55f889689 fix(sla): adiciona viewerId na query companies.list
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m45s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m41s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:35:51 -03:00
rever-tecnologia
4be622c838 fix(sla): adiciona import de Id no companySlas
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 4m2s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m15s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 2m23s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:22:47 -03:00
rever-tecnologia
5db31ba365 fix(sla): corrige tipo de categoryId na insercao de regra
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m0s
Quality Checks / Lint, Test and Build (push) Successful in 4m12s
CI/CD Web + Desktop / Deploy Convex functions (push) Failing after 1m20s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:15:34 -03:00
rever-tecnologia
158fb32b8a fix(email): adiciona tratamento de erros no envio de e-mails de automacao
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m55s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m58s
CI/CD Web + Desktop / Deploy Convex functions (push) Failing after 1m22s
- Adiciona try-catch para cada envio individual
- Registra logs detalhados de sucesso e falha por destinatario
- Retorna informacao sobre quantos e-mails falharam
- Imprime resumo de envios no console para debug

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:05:24 -03:00
rever-tecnologia
8a237a820d feat(reports): adiciona opcao Todas as empresas no relatorio por empresa
- Frontend: usa usePersistentCompanyFilter para persistir selecao
- Frontend: adiciona opcao "Todas as empresas" como primeira opcao
- Backend: torna companyId opcional na query companyOverview
- Backend: usa resolveScopedCompanyId para scoping de gestores

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:01:56 -03:00
rever-tecnologia
14480df9f3 feat(chat): vincula timer automaticamente com inicio/fim do chat ao vivo
- Ao iniciar chat: inicia timer EXTERNAL automaticamente se nao houver sessao ativa
- Ao encerrar chat: pausa timer automaticamente se houver sessao ativa
- Adiciona razao de pausa END_LIVE_CHAT para identificar pausas automaticas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:57:58 -03:00
rever-tecnologia
47ccdc51a7 feat(sla): adiciona interface de administração de SLA por empresa
- Adiciona CompanySlaManager para gerenciar empresas com SLA customizado
- Adiciona CompanySlaDrawer para configurar regras de SLA por empresa
- Integra componentes no SlasManager existente

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:53:32 -03:00
rever-tecnologia
33f0cc2e13 feat: adiciona SLA por empresa e modal de exclusao de automacoes
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 8s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m45s
Quality Checks / Lint, Test and Build (push) Successful in 3m58s
CI/CD Web + Desktop / Deploy Convex functions (push) Failing after 1m17s
## SLA por Empresa
- Adiciona tabela companySlaSettings no schema
- Cria convex/companySlas.ts com queries e mutations
- Modifica resolveTicketSlaSnapshot para verificar SLA da empresa primeiro
- Fallback: empresa > categoria > padrao

## Modal de Exclusao de Automacoes
- Substitui confirm() nativo por Dialog gracioso
- Segue padrao do delete-ticket-dialog

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:44:05 -03:00
rever-tecnologia
b3fcbcc682 fix: adiciona import useCallback no auth-client
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m45s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m53s
2025-12-17 18:03:20 -03:00
rever-tecnologia
ae4fd7f890 feat(portal): adiciona monitoramento em tempo real de desativação de máquina
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
- Cria hook useMachineStateMonitor com subscription Convex
- Cria MachineDeactivationOverlay para bloquear acesso
- Integra no PortalShell para exibir overlay quando máquina é desativada
- Adiciona refreshMachineContext ao AuthProvider para retry manual
- Funciona tanto via Raven quanto via navegador web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:02:23 -03:00
rever-tecnologia
413749d999 fix(desktop): corrige detecção de desativação em tempo real
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m46s
Quality Checks / Lint, Test and Build (push) Successful in 3m54s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
- Renderiza MachineStateMonitor mesmo durante tela de loading
- Adiciona verificação de isMachineActive na condição de early return
- Detecta estado de desativação/reset já na carga inicial
- Adiciona logs para facilitar debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:47:47 -03:00
rever-tecnologia
0bfe4edc6c feat(devices): adiciona modais de confirmacao e deteccao em tempo real
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m31s
Quality Checks / Lint, Test and Build (push) Successful in 4m46s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m39s
- Adiciona modais de confirmacao para resetar e desativar dispositivos
- Cria query getMachineState no Convex para monitoramento em tempo real
- Implementa MachineStateMonitor no desktop para detectar mudancas
- Desktop redireciona para tela de registro apos reset
- Desktop mostra tela de desativacao imediatamente apos bloqueio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:13:37 -03:00
rever-tecnologia
cd3305f1e3 feat(tickets): exclui visitas da listagem principal de tickets
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m49s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m47s
- Adiciona filtro excludeVisits no estado de filtros
- Aplica excludeVisits: true em /tickets e /tickets/resolved
- Visitas agora aparecem apenas em /tickets/visits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:27:22 -03:00
rever-tecnologia
0a36ed049f feat(tickets): adiciona menu dedicado para Visitas na sidebar
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 4m2s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m56s
- Adiciona item "Visitas" no submenu de Tickets com icone MapPin
- Cria pagina /tickets/visits que filtra apenas tickets da fila Visitas
- Corrige teste de automacao para usar emailProps ao inves de html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:20:09 -03:00
rever-tecnologia
b8170d0225 fix(automations): corrige erro de import dinamico no envio de email
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Failing after 2m45s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m44s
- Move renderizacao do React Email para a action Node.js
- Passa props do email em vez do HTML ja renderizado
- Resolve erro "dynamic module import unsupported"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:23:39 -03:00
rever-tecnologia
bddce33217 fix(dashboard): "Em andamento" conta apenas tickets com play ativo
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 3m46s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m39s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 2m13s
- Tickets com status AWAITING_ATTENDANCE mas sem play ativo
  agora contam como "Em aberto"
- "Em andamento" mostra apenas tickets onde working === true

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:39:39 -03:00
rever-tecnologia
6f9cdc8670 style(dashboard): renomeia "Pendentes" para "Em aberto" nas filas
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m20s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m39s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:27:42 -03:00
rever-tecnologia
b5ff8034d2 fix(email): usa TicketCardLegacy no automation-email
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m5s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m16s
Corrige erro de TypeScript ao usar o componente legado que
aceita o formato { ticket: TicketCardData }

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:00:20 -03:00
rever-tecnologia
034f6f47ff feat(company): define prazo de reabertura por empresa
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
Quality Checks / Lint, Test and Build (push) Successful in 3m22s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m25s
CI/CD Web + Desktop / Deploy Convex functions (push) Failing after 1m23s
- Adiciona campo reopenWindowDays no cadastro de empresa (padrao 7 dias)
- Ticket usa automaticamente o prazo da empresa ao ser resolvido
- Remove selecao de prazo do modal de encerramento de ticket
- Valor e gravado no ticket no momento da resolucao

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:53:14 -03:00
rever-tecnologia
d12dcf9512 fix(tickets): filtra templates de checklist por empresa selecionada
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 9s
Quality Checks / Lint, Test and Build (push) Successful in 4m3s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m55s
- Remove templates incompativeis quando empresa do ticket muda
- Limpa selecao de template ao mudar empresa
- Remove texto "(opcional)" do titulo da secao de checklist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:42:19 -03:00
rever-tecnologia
fffc3f553c style(admin): centraliza celulas e melhora modal de exclusao
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m17s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m47s
- Centraliza dados das celulas em todas as tabelas (text-center)
- Melhora layout do modal de exclusao com card destacado
- Exibe nome e email do usuario no card de confirmacao
- Usa cores rose para indicar acao destrutiva

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:56:58 -03:00
rever-tecnologia
30768ea090 style(admin): centraliza dados das celulas nas tabelas
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Adiciona text-center em todas as celulas de dados
- Centraliza botoes de acoes com inline-flex
- Mantem consistencia visual entre headers e dados

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:55:31 -03:00
rever-tecnologia
3e923d5a53 style(admin): ajusta tabelas para consistencia com /admin/companies
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Remove text-[11px] e tracking-wide dos headers
- Remove table-fixed e larguras fixas das colunas
- Usa mesmo estilo de /admin/companies (tamanho padrao)
- Remove bordas laterais desnecessarias dos headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:52:46 -03:00
rever-tecnologia
ec7dc4ce12 style(admin): ajusta titulos das tabelas para sentence case
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 8s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
- Remove uppercase dos headers das tabelas
- Corrige acentuacao em "Acoes" para "Ações"
- Ajusta tamanho da fonte para text-[11px] (consistente com outras tabelas)
- Remove min-width das tabelas para evitar scroll horizontal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:50:20 -03:00
rever-tecnologia
498b9789b5 feat(email): adiciona templates React Email e melhora UI admin
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m33s
Quality Checks / Lint, Test and Build (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been cancelled
- Cria 10 novos templates React Email (invite, password-reset, new-login,
  sla-warning, sla-breached, ticket-created, ticket-resolved,
  ticket-assigned, ticket-status, ticket-comment)
- Adiciona envio de email ao criar convite de usuario
- Adiciona security_invite em COLLABORATOR_VISIBLE_TYPES
- Melhora tabela de equipe com badges de papel e colunas fixas
- Atualiza TicketCard com nova interface de props
- Remove botao de limpeza de dados antigos do admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:46:02 -03:00
rever-tecnologia
8546a1feb1 fix(avatar): sincroniza avatar apos atualizar
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m51s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m14s
- Propaga Set-Cookie do Better Auth no endpoint de avatar\n- Forca refresh da sessao apos upload/remocao\n- Adiciona teste de propagacao e defaults de env para testes
2025-12-17 10:38:07 -03:00
rever-tecnologia
74c06ffa33 fix(auth): inicia machineContextLoading como true para evitar flash
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 6s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m52s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m18s
2025-12-17 10:35:34 -03:00
rever-tecnologia
1e674d5006 style(tickets): aumenta largura do botao Criar
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m0s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m17s
2025-12-17 10:25:42 -03:00
rever-tecnologia
965672e0fa fix(portal): corrige encoding e textos do formulario de ticket
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 9s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m10s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m36s
- Remove texto com encoding quebrado ("informações da máquina")
- Corrige gênero de "dispositivo" (masculino, não feminino)
- Remove lógica redundante - skeleton já cobre o estado de carregamento

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:18:00 -03:00
rever-tecnologia
385a8ee3df feat(ui): melhora UX do formulario de tickets
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m8s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m35s
- Adiciona skeleton loading no formulario de novo chamado do portal
- Remove texto confuso do tipo de solicitacao padrao
- Padroniza estilo dos labels Categoria/Subcategoria com os demais campos
- Move botao "Criar" do header para parte inferior do modal na web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:12:02 -03:00
rever-tecnologia
811ad0641a feat(portal): adiciona skeletons para melhor UX durante carregamento
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m16s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m48s
- Adiciona skeleton no header quando dados do usuario estao carregando
- Remove mensagem "Sem e-mail definido" durante loading
- Substitui spinner por skeleton cards na lista de tickets
- Cria componente PortalTicketCardSkeleton para estado de loading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:52:56 -03:00
rever-tecnologia
aa9c09c30e refactor(tickets): corrige avatar desincronizado e otimiza performance
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m26s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m1s
- Corrige avatar desincronizado na listagem de tickets usando sessao do usuario logado
- Otimiza timer da tickets-table de 1s para 15s (reduz re-renders)
- Remove useEffect desnecessario em status-select (usa prop diretamente)
- Remove useEffects desnecessarios em ticket-custom-fields (usa ticket.customFields diretamente)
- Adiciona React.memo no AssigneeCell para evitar re-renders

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:35:51 -03:00
rever-tecnologia
f617916fe7 perf(fonts): migra para next/font/google e elimina FOUT
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m13s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m45s
- Usa next/font/google para carregar Inter e JetBrains Mono
- Remove @font-face manuais do globals.css
- Corrige variaveis CSS confusas (--font-geist-* com 4 hifens)
- Remove duplicacao de @layer base
- Fontes agora sao otimizadas automaticamente (WOFF2, subset, preload)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:00:22 -03:00
rever-tecnologia
4669be0107 style(portal): usa tamanho padrao da badge de status no detalhe do ticket
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m39s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m12s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 08:50:01 -03:00
rever-tecnologia
028154a7bc style(sidebar): reduz espacamento entre titulo e subtitulo da marca
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m4s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m10s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 08:41:14 -03:00
rever-tecnologia
67433ed5e4 style(portal): aumenta tamanho da badge de status no card de ticket
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m26s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 5m3s
- Altera altura para h-9 (36px) igual ao componente web
- Aumenta fonte de text-xs para text-sm

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 08:33:44 -03:00
esdrasrenan
3f9461a18f fix(desktop-chat): estabiliza janelas e melhora multi-conversas
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 4m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 5m30s
2025-12-17 01:44:28 -03:00
esdrasrenan
380b2e44e9 fix(ci): deploy atomico no Forgejo (symlink) 2025-12-17 01:44:00 -03:00
esdrasrenan
2bdc5ae882 chore: atualiza Bun para versao 1.3.4
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m1s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 23:55:57 -03:00
esdrasrenan
52452f3023 ci: trigger redeploy after permission fix issue
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m15s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
O fix de permissao anterior limpou o diretorio mas o rsync nao
copiou os arquivos corretamente. Este commit dispara um novo deploy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 23:27:23 -03:00
esdrasrenan
9c258b43f1 docs: adiciona instrucoes para repositorio privado
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m40s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Atualiza docs/SETUP.md e scripts/setup-dev.sh com:
- Instrucoes para configurar chave SSH
- Opcao de usar Personal Access Token (PAT)
- Comandos para clonar/configurar via SSH ou HTTPS
- Script setup-dev.sh agora aceita --ssh para repo privado

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 23:18:31 -03:00
esdrasrenan
9e385b664d fix(ci): corrige backup/restore do .env usando Docker
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m29s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 7m0s
O problema anterior: apos limpar o conteudo do diretorio, o proprio
diretorio ainda tinha permissoes de root, fazendo o cp do .env falhar.

Nova abordagem:
- Salvar .env usando Docker (monta origem e /tmp)
- Remover o diretorio COMPLETAMENTE usando Docker (monta diretorio pai)
- Recriar o diretorio com mkdir -p (permissoes do usuario runner)
- Restaurar o .env (agora o diretorio tem permissoes corretas)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 23:03:53 -03:00
esdrasrenan
6943a88e66 fix(ci): solucao definitiva para erro de permissao no rsync
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Failing after 3m0s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m2s
O problema: Docker cria arquivos como root durante o build, e em
deploys subsequentes o rsync falha com "Permission denied" porque
o usuario runner nao consegue sobrescrever arquivos de root.

A solucao anterior (chown do destino) falhava silenciosamente porque
alguns arquivos ja tinham permissoes de root de deploys anteriores.

Nova abordagem:
- Antes do rsync, limpar completamente o destino usando Docker Alpine
- Docker Alpine roda como root e consegue remover qualquer arquivo
- O .env eh preservado (backup/restore)
- rsync copia para diretorio limpo, sem conflitos de permissao

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:57:57 -03:00
esdrasrenan
12a809805e fix(ci): correcao definitiva de permissoes Docker
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Failing after 3m19s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m8s
Adiciona step dedicado para corrigir permissoes apos build Docker:
- Usa container Alpine para fazer chown -R 1000:1000 no build
- Tambem corrige permissoes do destino antes do rsync

Isso resolve o erro "Permission denied" do rsync causado por
arquivos criados pelo Docker como root.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:49:42 -03:00
esdrasrenan
f0a4b9b782 chore: altera subtitulo da sidebar para "Chamados"
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:48:18 -03:00
esdrasrenan
2c95834598 chore: desativa GitHub Actions e adiciona docs de setup
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m34s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Failing after 4m37s
- Move .github/workflows para .github/workflows.disabled
- Adiciona docs/SETUP.md com guia de setup em novo computador
- Adiciona scripts/setup-dev.sh para setup automatizado
- Remove GitHub Actions runner da VPS (agora usa apenas Forgejo)

CI/CD agora e feito exclusivamente via Forgejo Actions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:43:14 -03:00
esdrasrenan
454c3d5c3b fix(ci): corrige permissoes de arquivos gerados por Docker
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 3m56s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 6m34s
Arquivos em src/generated/prisma/ criados por containers Docker
(como root) impediam o rsync de sobrescrever no proximo deploy.

Adiciona correcao de permissoes antes do rsync para garantir que
o usuario runner consiga sobrescrever os arquivos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:32:47 -03:00
esdrasrenan
04226c16cc docs: adiciona troubleshooting para CI/CD Forgejo
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Successful in 4m30s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Failing after 3m56s
Adiciona solucoes para problemas comuns:
- Regeneracao de hooks quando workflows nao disparam
- Correcao de erro de LevelDB lock
- Reinicio do runner apos reinicio do Forgejo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:26:44 -03:00
esdrasrenan
d067bda610 test: verificar CI apos regenerar hooks
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 8s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Quality Checks / Lint, Test and Build (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:24:43 -03:00
esdrasrenan
174b42eaab test: verificar CI apos reinicio do Forgejo
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:22:40 -03:00
esdrasrenan
f748be1931 test: verificar se CI dispara apos habilitar Actions no repo
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:20:49 -03:00
esdrasrenan
415d1c33f2 fix(ci): usa queue channel em vez de leveldb para evitar lock
O Forgejo estava falhando ao iniciar as queues devido ao LevelDB
nao funcionar bem em containers Docker. A mudanca para queue
type=channel resolve o problema de lock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:16:27 -03:00
esdrasrenan
11dd44b54f test: trigger CI after queue cleanup 2025-12-16 22:11:36 -03:00
esdrasrenan
b38d6689ae test: trigger CI after Forgejo restart 2025-12-16 22:04:23 -03:00
esdrasrenan
707306ddf8 refactor(ci): otimiza workflows Forgejo removendo steps desnecessarios
- Remove setup pnpm e Node.js (bun ja inclui Node.js)
- Remove steps Verify Bun e Permissions diagnostic
- Remove Prune workspace (criava pnpm-workspace.yaml)
- Remove smoke test (register + heartbeat) - nao mais necessario
- Atualiza chaves de cache para usar apenas bun.lock
- Atualiza docs para indicar desktop_release comentado

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:56:12 -03:00
esdrasrenan
acb2c35eeb ci: comenta job desktop_release (sem runner Windows)
O job ficava 'aguardando' eternamente porque nao existe runner
com labels [self-hosted, windows, desktop]. Comentado ate que
um runner Windows seja configurado.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:46:46 -03:00
esdrasrenan
c83cbd7e48 docs: atualiza documentacao do Forgejo para push direto
Remove referencias ao mirror (desabilitado) e documenta o novo fluxo
de push para ambos os remotes (GitHub + Forgejo).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:44:46 -03:00
esdrasrenan
a48d98f6c4 fix(ci): usa --no-owner --no-group no rsync
O rsync estava preservando o UID 1000 dos arquivos criados pelo
container Docker, causando erros de permissao para o runner (UID 999).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:21:56 -03:00
esdrasrenan
9a65679ca4 fix(ci): corrige permissoes do rsync no deploy VPS
O runner do Forgejo roda com UID 999, mas o workflow estava
fazendo chown para UID 1000, causando erros de permissao no rsync.

- Usa id -u e id -g para pegar o UID/GID correto do runner
- Remove --chown do rsync para usar permissoes do runner

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:00:39 -03:00
esdrasrenan
1f88880dbd test: trigger CI/CD via webhook 2025-12-16 20:34:45 -03:00
esdrasrenan
98a64f6166 fix(ci): corrige tipo dos inputs do workflow_dispatch
Adiciona type: boolean aos inputs para compatibilidade com Forgejo Actions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:27:40 -03:00
esdrasrenan
e5bf783432 docs: atualiza documentacao do Forgejo para refletir pull mirror
O Forgejo agora esta configurado como pull mirror do GitHub:
- Sincronizacao automatica a cada 10 minutos
- Usuario continua usando apenas git push origin main
- CI/CD dispara automaticamente apos sincronizacao

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:23:13 -03:00
esdrasrenan
4a369ac783 fix(ci): corrige runs-on dos workflows Forgejo para self-hosted
O runner Forgejo esta configurado como self-hosted, entao todos os jobs
precisam usar as labels corretas em vez de ubuntu-latest.

Alteracoes:
- ci-cd-web-desktop.yml: job changes agora usa [self-hosted, linux, vps]
- quality-checks.yml: job lint-test-build agora usa [self-hosted, linux, vps]
- docs/FORGEJO-CI-CD.md: documentacao atualizada com essa diferenca

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:17:19 -03:00
esdrasrenan
aaa64e339c feat(ci): adiciona Forgejo Actions como alternativa ao GitHub Actions
Configura o Forgejo como plataforma de CI/CD self-hosted para evitar
custos futuros do GitHub Actions (a partir de marco/2026).

Arquivos adicionados:
- .forgejo/workflows/ci-cd-web-desktop.yml: workflow principal de deploy
- .forgejo/workflows/quality-checks.yml: lint, test e build
- forgejo/stack.yml: stack Docker do Forgejo para Swarm
- forgejo/setup-runner.sh: script de configuracao do runner
- docs/FORGEJO-CI-CD.md: documentacao completa

Forgejo rodando em: https://git.esdrasrenan.com.br

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:13:29 -03:00
esdrasrenan
771e25798d fix(checklist): corrige exibicao da descricao do template no ticket
O campo templateDescription nao estava sendo exibido porque o schema
Zod em src/lib/mappers/ticket.ts nao incluia esse campo, fazendo com
que ele fosse removido durante a validacao dos dados do servidor.

- Adiciona templateDescription ao schema Zod do checklist
- Remove logs de debug dos arquivos de backend e frontend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:34:25 -03:00
rever-tecnologia
8e5eccfd8e debug: log resultado da funcao applyChecklistTemplateToItems 2025-12-16 16:38:38 -03:00
rever-tecnologia
c0713875b1 debug(checklist): adiciona logs no backend para investigar
Logs adicionados:
- Na criacao do template para ver se description esta sendo salva
- Na aplicacao do template para ver se description existe

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 16:28:05 -03:00
rever-tecnologia
db73e87cdc debug(checklist): adiciona logs para investigar templateDescription
Adiciona:
- Query debugTemplateAndTicketChecklist para verificar dados no backend
- Console.log no frontend para verificar dados recebidos

Esses logs serao removidos apos identificar o problema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 16:19:37 -03:00
rever-tecnologia
58cda4f6ea fix(checklist): inclui templateDescription no retorno do ticket
O campo templateDescription estava sendo salvo no checklist mas nao
era incluido no mapeamento quando o ticket era retornado pela query.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:02:59 -03:00
rever-tecnologia
e844f16b7f fix(lint): ignora arquivos gerados do Convex no desktop
Adiciona apps/desktop/src/convex/_generated/** ao ignore do ESLint
para evitar warnings de diretivas eslint-disable nao utilizadas em
arquivos gerados automaticamente.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:49:40 -03:00
rever-tecnologia
6e8a6fe890 feat(checklist): exibe descricao do template e do item no ticket
- Adiciona campo templateDescription ao schema do checklist
- Copia descricao do template ao aplicar checklist no ticket
- Exibe ambas descricoes na visualizacao do ticket (template em italico)
- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)
- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:45:09 -03:00
esdrasrenan
6430d33c7c fix(desktop): adiciona permissao start-dragging e debug logging no Hub
- Adiciona core🪟allow-start-dragging para corrigir erro ACL do drag-region
- Adiciona logging detalhado no ChatHubWidget para debug de clicks
- Adiciona logging no comando open_chat_window para diagnostico
- Ajusta ordem size/position e set_ignore_cursor_events no Hub
- Remove set_hub_minimized apos build para evitar conflitos de timing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 01:17:42 -03:00
esdrasrenan
0cdbc082ab fix(desktop): evita sobreposicao entre janelas de chat e hub
- Fecha janelas individuais de chat ao abrir o Hub (2+ sessoes)
- Fecha o Hub ao voltar para apenas 1 sessao
- Evita problemas de clique causados por sobreposicao de janelas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:21:43 -03:00
esdrasrenan
2ba5f71580 fix(convex): permite sessoes sem lastAgentMessageAt em listMachineSessions
O filtro anterior excluia sessoes novas onde o agente ainda nao
enviou mensagem, impedindo que o segundo chat aparecesse no desktop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:13:57 -03:00
esdrasrenan
5c5bf0385e fix(desktop): traz janela de chat para frente com novas mensagens
- Adiciona set_focus() apos show() para trazer janela para frente
- Adiciona unminimize() para garantir visibilidade
- Aplica mesma logica para Hub e janelas individuais de chat

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:02:12 -03:00
esdrasrenan
d9d5b495a1 fix(desktop): corrige nomes de parametros nos comandos Tauri
- Usa camelCase (ticketId, ticketRef) em vez de snake_case
- Tauri converte automaticamente snake_case do Rust para camelCase no JS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 23:55:21 -03:00
esdrasrenan
1986bf286a fix(desktop): corrige inicializacao do estado minimizado do chat
- Inicializa isMinimized baseado na altura real da janela
- Usa h-full em vez de h-screen para layout correto
- Evita inconsistencia entre estado React e tamanho da janela

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 23:48:12 -03:00
esdrasrenan
c51b08f127 feat(desktop): implementa Convex React subscriptions para chat em tempo real
- Adiciona ConvexMachineProvider para autenticacao via machine token
- Cria hooks customizados (useMachineSessions, useMachineMessages, etc)
- Refatora ChatWidget e ChatHubWidget para usar useQuery/useMutation
- Remove polling e dependencia de Tauri events para mensagens
- Adiciona copia local dos arquivos _generated do Convex
- Remove componentes obsoletos (ChatSessionItem, ChatSessionList)

Beneficios:
- Tempo real verdadeiro via WebSocket (sem polling)
- Melhor escalabilidade e performance
- Codigo mais simples e maintivel
- Consistencia de estado entre multiplas janelas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 23:40:34 -03:00
esdrasrenan
a6af4aa580 fix(profile): corrige persistencia da remocao de avatar
O Better Auth usa cookie cache de 5 minutos para a sessao.
Quando o avatar era removido via Prisma, o cache ainda tinha
o avatar antigo. Agora usamos auth.api.updateUser para
atualizar o usuario e invalidar o cache da sessao.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:40:06 -03:00
esdrasrenan
92954b45c7 feat(automations): agrupa subcategorias por categoria no select
Melhora UX do select de subcategorias nas condicoes de automacao,
agrupando visualmente as subcategorias dentro de suas respectivas
categorias usando SelectGroup e SelectLabel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:32:39 -03:00
esdrasrenan
022e1f63ba feat: melhorias de UX e redesign de comentários
- Corrige sincronização do avatar no perfil após upload
- Reduz tamanho dos ícones de câmera/lixeira no avatar
- Remove atributos title (tooltips nativos) de toda aplicação
- Adiciona regra no AGENTS.md sobre uso de tooltips
- Permite desmarcar resposta no checklist (toggle)
- Torna campo answer opcional na mutation setChecklistItemAnswer
- Adiciona edição inline dos campos de resumo no painel de detalhes
- Redesenha comentários com layout mais limpo e consistente
- Cria tratamento especial para comentários automáticos de sistema
- Aplica fundo ciano semi-transparente em comentários públicos
- Corrige import do Loader2 no notification-preferences-form

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:05:27 -03:00
esdrasrenan
23ea426c68 style(checklist): padroniza botao Tornar opcional
Remove classes customizadas para usar mesmo estilo do Somente pendentes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:40:46 -03:00
esdrasrenan
95c50d9d62 style(checklist): ajusta icone ? e badges
- Icone ? com fundo preto semitransparente e texto branco
- Badges maiores (text-[11px], px-2.5) e mais espacadas (gap-2)
- Badge Pergunta com estilo slate ao inves de cyan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:39:29 -03:00
esdrasrenan
17fe70ad71 style(checklist): melhora layout do item de pergunta
- Opcoes de resposta como botoes com estilo pill
- Opcao selecionada em preto com texto branco
- Badges menores e mais compactos
- Icone ? com estilo cyan
- Melhor espacamento geral

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:37:51 -03:00
esdrasrenan
358e1256b9 style(checklist): centraliza botoes verticalmente no item
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:35:34 -03:00
esdrasrenan
bf8975df2d style(checklist): ajusta estilo do botao Tornar opcional
Altera para variant outline com estilo similar ao botao Acessar remoto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:34:23 -03:00
esdrasrenan
ab2bcdc755 fix(checklist): inclui description, type, options e answer na query getById
Os campos estavam sendo salvos corretamente no banco, mas a query
getById nao os incluia no mapeamento de resposta, fazendo com que
as opcoes de resposta e descricao nao aparecessem no frontend.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:31:34 -03:00
esdrasrenan
d990450698 fix(email): corrige acentuacoes e adiciona dependencia radio-group
- Corrige todas as acentuacoes em portugues nos templates de e-mail
- Adiciona @radix-ui/react-radio-group como dependencia

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:05:53 -03:00
esdrasrenan
61c36dbb7c feat(email): redesign completo dos templates de e-mail
- Sincroniza cores com globals.css e status-badge.tsx
- Adiciona botoes grandes e destacados (cyan primary, preto secondary)
- Implementa badges pill com border-radius arredondado
- Importa fonte Inter do Google Fonts
- Adiciona icones em circulos grandes (64px) para templates de status
- Cria cards de informacao do ticket bem estruturados
- Aumenta espacamentos e padding para layout mais limpo
- Centraliza estrelas de avaliacao
- Melhora tipografia com pesos bem definidos

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:56:03 -03:00
esdrasrenan
eedd446b36 fix: corrige hydration, notificacoes e melhora visual
- Corrige hydration mismatch no Toaster (sonner) e ChatWidgetProvider
- Corrige API de preferencias de notificacao (typePreferences como string)
- Melhora visual do Switch (estado ativo em preto)
- Adiciona icones em circulos na pagina de notificacoes
- Corrige acentuacao em "Obrigatorio"
- Corrige centralizacao das estrelas de avaliacao nos e-mails
- Aplica Sentence case nos titulos dos templates de e-mail
- Adiciona script de teste de e-mail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:52:46 -03:00
esdrasrenan
7a3791117b fix: corrige cliques nao responsivos e erros silenciosos
- Remove coluna de acoes sem implementacao em data-table
- Corrige loading states travados em new-ticket-dialog, close-ticket-dialog
- Adiciona finally blocks em forgot-password e reset-password
- Adiciona tratamento de erros em invokes do Tauri (ChatWidget, ChatHubWidget)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:15:51 -03:00
esdrasrenan
a285e6f252 fix: corrige indices na mutation updateAvatar
Usa by_tenant_requester e by_tenant_assignee com tenantId

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 18:59:22 -03:00
esdrasrenan
9d1908a5aa fix: remove duplicacao de comentario na troca de responsavel e corrige avatar
- Remove criacao automatica de comentario ao trocar responsavel (ja aparece na timeline)
- Adiciona migration removeAssigneeChangeComments para limpar comentarios antigos
- Adiciona campos description, type, options, answer ao schema de checklist no mapper
- Cria mutation updateAvatar no Convex para sincronizar avatar com snapshots
- Atualiza rota /api/profile/avatar para sincronizar com Convex ao adicionar/remover foto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 18:53:49 -03:00
rever-tecnologia
59e9298d61 fix(chat): filtra sessões problemáticas nas queries
Adiciona filtro para ignorar sessões sem lastAgentMessageAt
(sessões legadas que causam erro de shape no Convex)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:05:53 -03:00
rever-tecnologia
f451ca2e3b fix(chat): corrige sessao problematica diretamente pelo ID
- Usa patch direto sem buscar sessao (evita erro de shape)
- Encerra sessao pd71bvfbxx7th3npdj519hcf3s7xbe2j que estava causando erros

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:48:11 -03:00
rever-tecnologia
129ae70930 feat(api): adiciona endpoint para corrigir sessões de chat antigas
- POST /api/admin/fix-chat-sessions
- Chama mutation fixLegacySessions do Convex
- Requer autenticação de admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:43:34 -03:00
rever-tecnologia
10078c7aa7 fix(checklist): corrige acentuação e adiciona modal de exclusão
- Corrige acentuações: Opções, Não, Descrição, Obrigatório, máx
- Adiciona modal de confirmação para exclusão de itens do checklist
- Remove uso de confirm() nativo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:35:58 -03:00
rever-tecnologia
f1833be1ea fix(chat): melhora fixLegacySessions para buscar todas sessoes
- Busca todas as sessoes, nao apenas por index
- Corrige sessoes encerradas que tambem nao tem o campo
- Adiciona log das sessoes problematicas encontradas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:30:45 -03:00
rever-tecnologia
0f3ba07a5e feat(checklist): adiciona tipo pergunta e descricao nos itens
- Adiciona campo `type` (checkbox/question) nos itens do checklist
- Adiciona campo `description` para descricao do item
- Adiciona campo `options` para opcoes de resposta em perguntas
- Adiciona campo `answer` para resposta selecionada
- Atualiza UI para mostrar descricao e opcoes de pergunta
- Cria componente radio-group para selecao de respostas
- Adiciona mutation setChecklistItemAnswer para salvar respostas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:27:23 -03:00
rever-tecnologia
98b23af4b2 feat(tickets): Play e Chat atribuem responsavel automaticamente
- Botao Play habilitado mesmo sem responsavel
- Ao clicar Play sem responsavel, atribui usuario logado automaticamente
- Ao iniciar chat ao vivo sem responsavel, atribui usuario logado
- Adiciona mutation fixLegacySessions para corrigir sessoes antigas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:10:13 -03:00
rever-tecnologia
3bfc5793f1 fix(auth): sincroniza User e AuthUser com mesmo ID
- Aceitar convite: cria User com mesmo ID do AuthUser
- Criar usuario admin: usa ID do AuthUser no upsert do User
- Garante sincronismo entre tabelas de auth e dominio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:52:28 -03:00
rever-tecnologia
7f63120336 fix(desktop,smtp): corrige chat polling e documenta SMTP
Desktop:
- Adiciona chamada para start_chat_polling no frontend
- Chat agora inicia corretamente quando usuario faz login

SMTP:
- Atualiza documentacao com nomes corretos das variaveis
- Variaveis configuradas no container da VPS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:41:00 -03:00
rever-tecnologia
c776499403 fix(desktop): ajusta tamanho da janela hub expandida
- Reduz tamanho de 380x480 para 340x400 (cabe melhor na tela)
- Reposiciona antes de redimensionar para evitar corte
- Melhora logging de debug

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:06:37 -03:00
rever-tecnologia
8863fffc37 fix(desktop): corrige reinstalacao do RavenService e uso via IPC
- NSIS: remove servico antigo antes de instalar novo (corrige reinstalacoes)
- NSIS: adiciona retry ao iniciar servico
- agent.rs: usa service_client para aplicar politica USB via IPC primeiro
- Fallback para aplicacao direta se servico indisponivel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:01:09 -03:00
rever-tecnologia
f6efc0d678 fix(settings): reverte centralização do Label Alterar senha no formulário
Label no formulário de perfil deve ficar alinhado à esquerda.
O botão na seção Segurança está centralizado corretamente.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:48:32 -03:00
rever-tecnologia
c0e0421369 style(settings): centraliza botão Alterar senha
Remove justify-start para centralizar conteúdo como o botão Encerrar sessão.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:47:22 -03:00
rever-tecnologia
c7b6d78ec2 fix(desktop): corrige travamento com múltiplos chats
Problemas corrigidos:
- Removido always_on_top de todas as janelas (causava competição de Z-order)
- Removido inner_size() síncrono que bloqueava a UI thread
- Simplificado process_chat_update para não fazer múltiplas operações de janela
- Removidos logs de debug

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:46:39 -03:00
rever-tecnologia
4ad0dc5c1e style(settings): centraliza label "Alterar senha"
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:38:58 -03:00
rever-tecnologia
424927573c fix(settings): posiciona background no topo absoluto do card
Background agora usa position absolute para cobrir desde o
topo do card, eliminando a faixa branca acima.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:34:41 -03:00
rever-tecnologia
5f0c9b68c3 fix(settings): aumenta altura do background animado do perfil
Aumenta de h-20 para h-28 e ajusta margin-top do CardHeader
para cobrir toda a área superior do card.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:24:08 -03:00
rever-tecnologia
3b6b9dfeac fix(desktop): corrige parâmetros invoke para snake_case no Tauri 2
Tauri 2 espera parâmetros em snake_case nos comandos Rust.
Corrigido: ticketId -> ticket_id, ticketRef -> ticket_ref, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:24:01 -03:00
rever-tecnologia
915ca6d8ff fix(settings): corrige background animado do perfil preenchendo cantos
Remove rounded-t-2xl redundante que criava gap branco nos cantos
superiores. O card pai já possui overflow-hidden com rounded-2xl.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:13:28 -03:00
rever-tecnologia
05bc1cb7b4 fix(chat-widget): corrige referência a chatData não definida
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:11:14 -03:00
rever-tecnologia
d97e692756 debug(chat): adiciona logs detalhados no invoke do hub
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:03:08 -03:00
rever-tecnologia
c36e18117b fix(desktop): corrige problemas do chat (redimensionamento e cliques)
Correções implementadas:
1. Adiciona .resizable(false) nas janelas de chat e hub para impedir redimensionamento manual
2. Corrige área clicável invisível quando minimizado (janela agora tem tamanho correto)
3. Corrige clique na lista de sessões para expandir janela quando clicado
4. Diferencia abertura automática (minimizada) de abertura manual (expandida)

- Chat agora abre expandido quando clicado na lista do hub
- Chat abre minimizado quando nova mensagem chega (menos intrusivo)
- Janelas não permitem mais redimensionamento manual
- Área clicável agora corresponde ao tamanho visual da janela

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:53:53 -03:00
rever-tecnologia
6b137434fe fix(desktop): corrige permissoes e redimensionamento do chat
- Adiciona chat-hub explicitamente nas capabilities do Tauri
- Adiciona .resizable(false) nas janelas de chat e hub
- Corrige problema de comandos invoke nao funcionando no hub

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:51:34 -03:00
rever-tecnologia
a3b46e5222 debug(chat): adiciona mais logs para debugar clique na lista
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:46:05 -03:00
rever-tecnologia
ca59b6ed92 debug(chat): adiciona logs no clique da lista de sessoes
Logs para debugar problema de clique não funcionando
na lista de sessões do desktop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:22:47 -03:00
rever-tecnologia
973e3496e2 fix(chat): corrige acentuação em "não lida"
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:19:56 -03:00
rever-tecnologia
6c6d53034f style(chat): remove icone do header para mais espaco
Remove o icone circular preto do header do chat
quando esta dentro de uma conversa, mantendo mais
espaco para informacoes do ticket.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:19:08 -03:00
rever-tecnologia
86f818c6f3 feat(chat): adiciona encerramento automatico por inatividade (12h)
- Sessoes de chat sao encerradas apos 12 horas sem atividade
- Criterios de encerramento automatico:
  1. Maquina offline (5 min sem heartbeat)
  2. Chat inativo (12 horas sem atividade) - NOVO
  3. Ticket orfao (sem maquina vinculada)
- Log detalhado com contagem por motivo de encerramento
- Evento no timeline com reason "inatividade_chat"

Isso evita acumular sessoes abertas indefinidamente
quando usuario esquece de encerrar o chat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:06:24 -03:00
rever-tecnologia
29fbbfaa26 feat(desktop): adiciona hub de chats para multiplas sessoes
- Cria ChatSessionList, ChatSessionItem e ChatHubWidget no desktop
- Adiciona comandos Rust para gerenciar hub window
- Quando ha multiplas sessoes, abre hub ao inves de janela individual
- Hub lista todas as sessoes ativas com badge de nao lidos
- Clicar em sessao abre/foca janela de chat especifica
- Menu do tray abre hub quando ha multiplas sessoes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:13:47 -03:00
rever-tecnologia
95ab1b5f0c feat(chat): adiciona interface de lista de chats estilo WhatsApp
- Cria ChatSessionList e ChatSessionItem para listar sessões ativas
- Refatora ChatWidget para usar viewMode (list/chat)
- Ordena por não lidos primeiro, depois por última atividade
- Adiciona botão de voltar quando há múltiplos chats
- Persiste viewMode no localStorage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:03:40 -03:00
rever-tecnologia
bc5ba0c73a feat(checklist): adiciona opção de desmarcar todos os itens
O botão "Concluir todos" agora alterna para "Desmarcar todos"
quando todos os itens do checklist estão marcados como concluídos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:46:13 -03:00
rever-tecnologia
2c21daee79 fix(profile): corrige persistência do avatar e melhora fluxo de salvamento
- Corrige campo de avatar na API (avatarUrl ao invés de image)
- Altera fluxo para salvar foto apenas ao clicar em "Salvar alterações"
- Adiciona preview local antes do upload definitivo
- Ajusta shader para preencher bordas arredondadas do card

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:25:25 -03:00
rever-tecnologia
4e2dd7f77e feat(settings): adiciona opção de remover foto de perfil
- Adiciona endpoint DELETE em /api/profile/avatar
- Mostra dois botões ao hover: câmera (upload) e lixeira (remover)
- Lixeira só aparece quando há uma foto definida

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:17:22 -03:00
rever-tecnologia
4bbd3fda24 feat(settings): adiciona shader animado no header do perfil
Substitui o degradê estático pelo shader animado usado nas páginas de login.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:14:45 -03:00
rever-tecnologia
b614fcd7dc style: melhora layout de login e settings
- Badge Helpdesk preta com texto branco
- Texto maior no painel direito das páginas de auth
- Badge de papel preta em settings
- Adiciona descrição na seção Segurança
- Espaçamento entre título e campos no formulário de login
- Autocomplete nos inputs de senha
- Link de notificações funcional no menu do usuário
- Fallback do avatar com fundo cinza e texto preto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:13:00 -03:00
rever-tecnologia
ab7dfa81ca feat: melhora página de perfil e integra preferências de notificação
- Atualiza cores das badges para padrão cyan do projeto
- Adiciona degradê no header do card de perfil
- Implementa upload de foto de perfil via API Convex
- Integra notificações do Convex com preferências do usuário
- Cria API /api/notifications/send para verificar preferências
- Melhora layout das páginas de login/recuperação com degradê
- Adiciona badge "Helpdesk" e título "Raven" consistente

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:00:02 -03:00
rever-tecnologia
1bc08d3a5f feat: adiciona fluxo de redefinição de senha e melhora página de configurações
- Adiciona página /recuperar para solicitar redefinição de senha
- Adiciona página /redefinir-senha para definir nova senha com token
- Cria APIs /api/auth/forgot-password e /api/auth/reset-password
- Adiciona notificação por e-mail quando ticket é criado
- Repagina página de configurações removendo informações técnicas
- Adiciona script de teste para todos os tipos de e-mail
- Corrige acentuações em templates de e-mail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:42:08 -03:00
rever-tecnologia
300179279a feat(chat): adiciona separador de dias e melhora layout do header
- Adiciona separador de data entre mensagens de dias diferentes (estilo WhatsApp)
- Mostra "Hoje", "Ontem" ou data completa (ex: "segunda-feira, 15 de dezembro")
- Separa hostname da maquina em linha propria no header
- Hostname com truncate e tooltip para nomes longos

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:10:04 -03:00
rever-tecnologia
2293a0275a fix(chat): melhora confiabilidade da deteccao de novas mensagens
- Implementa deteccao dual: timestamp (lastActivityAt) + contador
- Adiciona persistencia de estado em ~/.local/share/Raven/chat-state.json
- Corrige race condition no servidor com refetch antes do patch
- Adiciona campo lastAgentMessageAt no schema do Convex
- Adiciona logs de diagnostico detalhados

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 09:44:03 -03:00
esdrasrenan
c4664ab1c7 feat(desktop): adiciona Raven Service e corrige UAC
- Implementa Windows Service (raven-service) para operacoes privilegiadas
- Comunicacao via Named Pipes sem necessidade de UAC adicional
- Adiciona single-instance para evitar multiplos icones na bandeja
- Corrige todos os warnings do clippy (rustdesk, lib, usb_control, agent)
- Remove fallback de elevacao para evitar UAC desnecessario
- USB Policy e RustDesk provisioning agora usam o servico quando disponivel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 02:30:43 -03:00
200 changed files with 21511 additions and 2949 deletions

View file

@ -29,7 +29,63 @@
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(git push:*)", "Bash(git push:*)",
"Bash(cargo check:*)", "Bash(cargo check:*)",
"Bash(bun run:*)" "Bash(bun run:*)",
"Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")",
"Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")",
"Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")",
"Bash(cmd /c \"echo %TEMP%\")",
"Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")",
"Bash(where:*)",
"Bash(ssh-keygen:*)",
"Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)",
"Bash(npx convex deploy:*)",
"Bash(dir \"%LOCALAPPDATA%\\Raven\")",
"Bash(dir \"%APPDATA%\\Raven\")",
"Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")",
"Bash(dir \"%APPDATA%\\com.raven.app\")",
"Bash(tasklist:*)",
"Bash(dir /s /b %LOCALAPPDATA%*raven*)",
"Bash(cmd /c \"tasklist | findstr /i raven\")",
"Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")",
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")",
"Bash(node:*)",
"Bash(bun scripts/test-all-emails.tsx:*)",
"Bash(bun scripts/send-test-react-email.tsx:*)",
"Bash(dir:*)",
"Bash(git reset:*)",
"Bash(npx convex:*)",
"Bash(bun tsc:*)",
"Bash(scp:*)",
"Bash(docker run:*)",
"Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")",
"Bash(cmd /c \"docker --version && docker ps -a\")",
"Bash(powershell -Command \"docker --version\")",
"Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)",
"Bash(bunx prisma migrate:*)",
"Bash(bunx prisma db push:*)",
"Bash(bun run auth:seed:*)",
"Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)",
"Bash(bun tsx:*)",
"Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
"Bash(timeout 90 git push:*)",
"Bash(docker ps:*)",
"Bash(docker start:*)",
"Bash(docker inspect:*)",
"Bash(docker exec:*)",
"Bash(timeout 90 git push)",
"Bash(bun test:*)",
"Bash(git restore:*)",
"Bash(cd:*)",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\\src\\components\\ui\" /b)",
"Bash(timeout 120 bun:*)",
"Bash(bun run tauri:build:*)",
"Bash(git remote:*)",
"Bash(powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:/Projetos IA/sistema-de-chamados/scripts/test-windows-collection.ps1\")"
] ]
} }
} }

View file

@ -19,8 +19,9 @@ REPORTS_CRON_SECRET=reports-cron-secret
# Diretório para arquivamento local de tickets (JSONL/backup) # Diretório para arquivamento local de tickets (JSONL/backup)
ARCHIVE_DIR=./archives ARCHIVE_DIR=./archives
# PostgreSQL database # PostgreSQL database (versao 18)
# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 # Para desenvolvimento local, use Docker:
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
# SMTP Configuration (production values in docs/SMTP.md) # SMTP Configuration (production values in docs/SMTP.md)

View file

@ -0,0 +1,492 @@
name: CI/CD Web + Desktop
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
force_web_deploy:
description: 'Forcar deploy do Web (ignorar filtro)?'
type: boolean
required: false
default: false
force_convex_deploy:
description: 'Forcar deploy do Convex (ignorar filtro)?'
type: boolean
required: false
default: false
env:
APP_DIR: /srv/apps/sistema
VPS_UPDATES_DIR: /var/www/updates
jobs:
changes:
name: Detect changes
runs-on: [ self-hosted, linux, vps ]
timeout-minutes: 5
outputs:
convex: ${{ steps.filter.outputs.convex }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Paths filter
id: filter
uses: https://github.com/dorny/paths-filter@v3
with:
filters: |
convex:
- 'convex/**'
web:
- 'src/**'
- 'public/**'
- 'prisma/**'
- 'next.config.ts'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
- 'middleware.ts'
- 'stack.yml'
deploy:
name: Deploy (VPS Linux)
needs: changes
timeout-minutes: 30
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/web.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
EXCLUDE_ENV=""
fi
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--filter='protect .env' \
--filter='protect .env*' \
--filter='protect apps/desktop/.env*' \
--filter='protect convex/.env*' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
$EXCLUDE_ENV \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Copy production .env if present
run: |
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
fi
- name: Ensure Next.js cache directory exists and is writable
run: |
cd "$EFFECTIVE_APP_DIR"
mkdir -p .next/cache
chmod -R u+rwX .next || true
- name: Cache Next.js build cache (.next/cache)
uses: https://github.com/actions/cache@v4
with:
path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
${{ runner.os }}-nextjs-
- name: Lint check (fail fast before build)
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint"
- name: Install and build (Next.js)
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \
-e NODE_OPTIONS="--max-old-space-size=4096" \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun"
- name: Fix Docker-created file permissions
run: |
# Docker cria arquivos como root - corrigir para o usuario runner (UID 1000)
docker run --rm -v "$EFFECTIVE_APP_DIR":/target alpine:3 \
chown -R 1000:1000 /target
echo "Permissoes do build corrigidas"
- name: Atualizar symlink do APP_DIR estavel (deploy atomico)
run: |
set -euo pipefail
ROOT="$HOME/apps"
STABLE_LINK="$ROOT/sistema.current"
mkdir -p "$ROOT"
# Sanidade: se esses arquivos nao existirem, o container vai falhar no boot.
test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; }
PREV=""
if [ -L "$STABLE_LINK" ]; then
PREV="$(readlink -f "$STABLE_LINK" || true)"
fi
echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV"
ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK"
# Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta).
if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then
ln -sfn "$STABLE_LINK" "$ROOT/sistema"
fi
echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")"
- name: Swarm deploy (stack.yml)
run: |
APP_DIR_STABLE="$HOME/apps/sistema.current"
if [ ! -d "$APP_DIR_STABLE" ]; then
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
fi
cd "$APP_DIR_STABLE"
set -o allexport
if [ -f .env ]; then
echo "Loading .env from $APP_DIR_STABLE"
. ./.env
else
echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!"
fi
set +o allexport
echo "Using APP_DIR (stable)=$APP_DIR_STABLE"
echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-<not set>}"
echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-<not set>}"
APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
- name: Wait for services to be healthy
run: |
echo "Aguardando servicos ficarem saudaveis..."
for i in $(seq 1 18); do
WEB_STATUS=$(docker service ls --filter "name=sistema_web" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
CONVEX_STATUS=$(docker service ls --filter "name=sistema_convex_backend" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
echo "Tentativa $i/18: web=$WEB_STATUS convex=$CONVEX_STATUS"
if echo "$WEB_STATUS" | grep -q "2/2" && echo "$CONVEX_STATUS" | grep -q "1/1"; then
echo "Todos os servicos estao saudaveis!"
exit 0
fi
sleep 10
done
echo "ERRO: Timeout aguardando servicos. Status atual:"
docker service ls --filter "label=com.docker.stack.namespace=sistema" || true
docker service ps sistema_web --no-trunc || true
docker service logs sistema_web --since 5m --raw 2>/dev/null | tail -n 200 || true
if [ -n "${PREV_APP_DIR:-}" ]; then
echo "Rollback: revertendo APP_DIR estavel para: $PREV_APP_DIR"
ln -sfn "$PREV_APP_DIR" "$HOME/apps/sistema.current"
cd "$HOME/apps/sistema.current"
set -o allexport
if [ -f .env ]; then
. ./.env
fi
set +o allexport
APP_DIR="$HOME/apps/sistema.current" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema || true
fi
exit 1
- name: Cleanup old build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='web.build.*'
ACTIVE="$(readlink -f "$HOME/apps/sistema.current" 2>/dev/null || true)"
echo "Scanning $ROOT for old $PATTERN dirs"
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then
echo "Skipping active dir (in use by APP_DIR): $dir"; continue
fi
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
echo "Disk usage (top 10 under $ROOT):"
du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true
convex_deploy:
name: Deploy Convex functions
needs: changes
timeout-minutes: 20
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }}
runs-on: [ self-hosted, linux, vps ]
env:
APP_DIR: /srv/apps/sistema
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/convex.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--exclude '.env*' \
--exclude 'apps/desktop/.env*' \
--exclude 'convex/.env*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Bring convex.json from live app if present
run: |
if [ -f "$APP_DIR/convex.json" ]; then
echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json"
cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json"
else
echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars"
fi
- name: Set Convex env vars (self-hosted)
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
run: |
set -e
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e MACHINE_PROVISIONING_SECRET \
-e MACHINE_TOKEN_TTL_MS \
-e FLEET_SYNC_SECRET \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
bunx convex env list"
- name: Prepare Convex deploy workspace
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -f .env ]; then
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
mv -f .env .env.bak
fi
mkdir -p .convex-tmp
- name: Deploy functions to Convex self-hosted
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CI=true \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; bunx convex deploy"
- name: Cleanup old convex build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='convex.build.*'
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
# NOTA: Job comentado porque nao ha runner Windows configurado.
# Descomentar quando configurar um runner com labels: [self-hosted, windows, desktop]
#
# desktop_release:
# name: Desktop Release (Windows)
# timeout-minutes: 30
# if: ${{ startsWith(github.ref, 'refs/tags/v') }}
# runs-on: [ self-hosted, windows, desktop ]
# defaults:
# run:
# working-directory: apps/desktop
# steps:
# - name: Checkout
# uses: https://github.com/actions/checkout@v4
#
# - name: Setup pnpm
# uses: https://github.com/pnpm/action-setup@v4
# with:
# version: 10.20.0
#
# - name: Setup Node.js
# uses: https://github.com/actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Install deps (desktop)
# run: pnpm install --frozen-lockfile
#
# - name: Build with Tauri
# uses: https://github.com/tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# with:
# projectPath: apps/desktop
#
# - name: Upload bundles to VPS
# run: |
# # Upload via SCP (configurar chave SSH no runner Windows)
# # scp -r src-tauri/target/release/bundle/* user@vps:/var/www/updates/
# echo "TODO: Configurar upload para VPS"

View file

@ -0,0 +1,54 @@
name: Quality Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint-test-build:
name: Lint, Test and Build
runs-on: [ self-hosted, linux, vps ]
env:
BETTER_AUTH_SECRET: test-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
BETTER_AUTH_URL: http://localhost:3000
NEXT_PUBLIC_CONVEX_URL: http://localhost:3210
DATABASE_URL: file:./prisma/db.dev.sqlite
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Next.js build cache
uses: https://github.com/actions/cache@v4
with:
path: |
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
- name: Generate Prisma client
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: bun run prisma:generate
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build:bun

1
.gitignore vendored
View file

@ -70,3 +70,4 @@ rustdesk/
# Prisma generated files # Prisma generated files
src/generated/ src/generated/
apps/desktop/service/target/

View file

@ -1,4 +1,4 @@
# Runtime image with Node 22 + Bun 1.3.2 and build toolchain preinstalled # Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled
FROM node:22-bullseye-slim FROM node:22-bullseye-slim
ENV BUN_INSTALL=/root/.bun ENV BUN_INSTALL=/root/.bun
@ -17,9 +17,9 @@ RUN apt-get update -y \
git \ git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Bun 1.3.2 # Install Bun 1.3.4
RUN curl -fsSL https://bun.sh/install \ RUN curl -fsSL https://bun.sh/install \
| bash -s -- bun-v1.3.2 \ | bash -s -- bun-v1.3.4 \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx && ln -sf /root/.bun/bin/bun /usr/local/bin/bunx

View file

@ -19,10 +19,10 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
- Seeds de usuários/tickets demo: `convex/seed.ts`. - Seeds de usuários/tickets demo: `convex/seed.ts`.
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas. - Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
## Stack atual (06/11/2025) ## Stack atual (18/12/2025)
- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback). - **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`. - Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
- **React / React DOM**: `19.2.0`. - **React / React DOM**: `19.2.1`.
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`). - **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
- **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS. - **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS.
- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro. - **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro.
@ -38,7 +38,7 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=dev-only-long-random-string BETTER_AUTH_SECRET=dev-only-long-random-string
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
DATABASE_URL=file:./prisma/db.dev.sqlite DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
``` ```
3. `bun run auth:seed` 3. `bun run auth:seed`
4. (Opcional) `bun run queues:ensure` 4. (Opcional) `bun run queues:ensure`
@ -47,8 +47,8 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
7. Acesse `http://localhost:3000` e valide login com os usuários padrão. 7. Acesse `http://localhost:3000` e valide login com os usuários padrão.
### Banco de dados ### Banco de dados
- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`). - Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
- Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`). - Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.). - Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
### Verificações antes de PR/deploy ### Verificações antes de PR/deploy
@ -104,12 +104,12 @@ bun run build:bun
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
docker service update --force sistema_web docker service update --force sistema_web
``` ```
- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`: - Resolver `P3009` (migration falhou) no PostgreSQL ativo:
```bash ```bash
docker service scale sistema_web=0 docker service scale sistema_web=0
docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \ docker run --rm -it --network traefik_public \
--env-file /home/renan/apps/sistema.current/.env \
-v /home/renan/apps/sistema.current:/app \ -v /home/renan/apps/sistema.current:/app \
-v sistema_sistema_db:/app/data -w /app \
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy" oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
docker service scale sistema_web=1 docker service scale sistema_web=1
``` ```
@ -164,8 +164,51 @@ bun run build:bun
- **Docs complementares**: - **Docs complementares**:
- `docs/DEV.md` — guia diário atualizado. - `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog. - `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm. - `docs/OPERATIONS.md` — runbook do Swarm.
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente. - `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
## Regras de Codigo
### Tooltips Nativos do Navegador
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
```tsx
// ERRADO - causa tooltip nativo do navegador
<button title="Remover item">
<Trash2 className="size-4" />
</button>
// CORRETO - sem tooltip nativo
<button>
<Trash2 className="size-4" />
</button>
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
<Tooltip>
<TooltipTrigger asChild>
<button>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Remover item</TooltipContent>
</Tooltip>
```
**Excecoes:**
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
### Acessibilidade
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
```tsx
<button aria-label="Remover item">
<Trash2 className="size-4" />
</button>
```
--- ---
_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._ _Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._

View file

@ -8,7 +8,9 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "node ./scripts/tauri-with-stub.mjs", "tauri": "node ./scripts/tauri-with-stub.mjs",
"gen:icon": "node ./scripts/build-icon.mjs" "gen:icon": "node ./scripts/build-icon.mjs",
"build:service": "cd service && cargo build --release",
"build:all": "bun run build:service && bun run tauri build"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
@ -19,6 +21,7 @@
"@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2", "@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

1931
apps/desktop/service/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
[package]
name = "raven-service"
version = "0.1.0"
description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop"
authors = ["Esdras Renan"]
edition = "2021"
[[bin]]
name = "raven-service"
path = "src/main.rs"
[dependencies]
# Windows Service
windows-service = "0.7"
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] }
# IPC via Named Pipes
interprocess = { version = "2", features = ["tokio"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Windows Registry
winreg = "0.55"
# Error handling
thiserror = "1.0"
# HTTP client (para RustDesk)
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Crypto (para RustDesk ID)
sha2 = "0.10"
# UUID para request IDs
uuid = { version = "1", features = ["v4"] }
# Parking lot para locks
parking_lot = "0.12"
# Once cell para singletons
once_cell = "1.19"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_System_Pipes",
"Win32_System_IO",
"Win32_System_SystemServices",
"Win32_Storage_FileSystem",
] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

View file

@ -0,0 +1,290 @@
//! Modulo IPC - Servidor de Named Pipes
//!
//! Implementa comunicacao entre o Raven UI e o Raven Service
//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado.
use crate::{rustdesk, usb_policy};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Debug, Error)]
pub enum IpcError {
#[error("Erro de IO: {0}")]
Io(#[from] std::io::Error),
#[error("Erro de serializacao: {0}")]
Json(#[from] serde_json::Error),
}
/// Requisicao JSON-RPC simplificada
#[derive(Debug, Deserialize)]
pub struct Request {
pub id: String,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// Resposta JSON-RPC simplificada
#[derive(Debug, Serialize)]
pub struct Response {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorResponse>,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub code: i32,
pub message: String,
}
impl Response {
pub fn success(id: String, result: serde_json::Value) -> Self {
Self {
id,
result: Some(result),
error: None,
}
}
pub fn error(id: String, code: i32, message: String) -> Self {
Self {
id,
result: None,
error: Some(ErrorResponse { code, message }),
}
}
}
/// Inicia o servidor de Named Pipes
pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> {
info!("Iniciando servidor IPC em: {}", pipe_name);
loop {
match accept_connection(pipe_name).await {
Ok(()) => {
debug!("Conexao processada com sucesso");
}
Err(e) => {
warn!("Erro ao processar conexao: {}", e);
}
}
}
}
/// Aceita uma conexao e processa requisicoes
async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> {
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows::Win32::Security::{
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
};
use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
use windows::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe,
PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION;
use windows::core::PCWSTR;
// Cria o named pipe com seguranca que permite acesso a todos os usuarios
let pipe_name_wide: Vec<u16> = pipe_name.encode_utf16().chain(std::iter::once(0)).collect();
// Cria security descriptor com DACL nulo (permite acesso a todos)
let mut sd = SECURITY_DESCRIPTOR::default();
unsafe {
let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION);
// DACL nulo = acesso irrestrito
let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false);
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
bInheritHandle: false.into(),
};
let pipe_handle = unsafe {
CreateNamedPipeW(
PCWSTR::from_raw(pipe_name_wide.as_ptr()),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
4096, // out buffer
4096, // in buffer
0, // default timeout
Some(&sa), // seguranca permissiva
)
};
// Verifica se o handle e valido
if pipe_handle == INVALID_HANDLE_VALUE {
return Err(IpcError::Io(std::io::Error::last_os_error()));
}
// Aguarda conexao de um cliente
info!("Aguardando conexao de cliente...");
let connect_result = unsafe {
ConnectNamedPipe(pipe_handle, None)
};
if let Err(e) = connect_result {
// ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado
// o que e aceitavel
let error_code = e.code().0 as u32;
if error_code != 535 {
warn!("Erro ao aguardar conexao: {:?}", e);
}
}
info!("Cliente conectado");
// Processa requisicoes do cliente
let result = process_client(pipe_handle);
// Desconecta o cliente
unsafe {
let _ = DisconnectNamedPipe(pipe_handle);
}
result
}
/// Processa requisicoes de um cliente conectado
fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> {
use std::os::windows::io::{FromRawHandle, RawHandle};
use std::fs::File;
// Cria File handle a partir do pipe
let raw_handle = pipe_handle.0 as RawHandle;
let file = unsafe { File::from_raw_handle(raw_handle) };
let reader = BufReader::new(file.try_clone()?);
let mut writer = file;
// Le linhas (cada linha e uma requisicao JSON)
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
if e.kind() == std::io::ErrorKind::BrokenPipe {
info!("Cliente desconectou");
break;
}
return Err(e.into());
}
};
if line.is_empty() {
continue;
}
debug!("Requisicao recebida: {}", line);
// Parse da requisicao
let response = match serde_json::from_str::<Request>(&line) {
Ok(request) => handle_request(request),
Err(e) => Response::error(
"unknown".to_string(),
-32700,
format!("Parse error: {}", e),
),
};
// Serializa e envia resposta
let response_json = serde_json::to_string(&response)?;
debug!("Resposta: {}", response_json);
writeln!(writer, "{}", response_json)?;
writer.flush()?;
}
// IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele
std::mem::forget(writer);
Ok(())
}
/// Processa uma requisicao e retorna a resposta
fn handle_request(request: Request) -> Response {
info!("Processando metodo: {}", request.method);
match request.method.as_str() {
"health_check" => handle_health_check(request.id),
"apply_usb_policy" => handle_apply_usb_policy(request.id, request.params),
"get_usb_policy" => handle_get_usb_policy(request.id),
"provision_rustdesk" => handle_provision_rustdesk(request.id, request.params),
"get_rustdesk_status" => handle_get_rustdesk_status(request.id),
_ => Response::error(
request.id,
-32601,
format!("Metodo nao encontrado: {}", request.method),
),
}
}
// =============================================================================
// Handlers de Requisicoes
// =============================================================================
fn handle_health_check(id: String) -> Response {
Response::success(
id,
serde_json::json!({
"status": "ok",
"service": "RavenService",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Utc::now().timestamp_millis()
}),
)
}
fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response {
let policy = match params.get("policy").and_then(|p| p.as_str()) {
Some(p) => p,
None => {
return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string())
}
};
match usb_policy::apply_policy(policy) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)),
}
}
fn handle_get_usb_policy(id: String) -> Response {
match usb_policy::get_current_policy() {
Ok(policy) => Response::success(
id,
serde_json::json!({
"policy": policy
}),
),
Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)),
}
}
fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response {
let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from);
let password = params.get("password").and_then(|p| p.as_str()).map(String::from);
let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from);
match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)),
}
}
fn handle_get_rustdesk_status(id: String) -> Response {
match rustdesk::get_status() {
Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)),
}
}

View file

@ -0,0 +1,268 @@
//! Raven Service - Servico Windows para operacoes privilegiadas
//!
//! Este servico roda como LocalSystem e executa operacoes que requerem
//! privilegios de administrador, como:
//! - Aplicar politicas de USB
//! - Provisionar e configurar RustDesk
//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE
//!
//! O app Raven UI comunica com este servico via Named Pipes.
mod ipc;
mod rustdesk;
mod usb_policy;
use std::ffi::OsString;
use std::time::Duration;
use tracing::{error, info};
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
};
const SERVICE_NAME: &str = "RavenService";
const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service";
const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)";
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
define_windows_service!(ffi_service_main, service_main);
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configura logging
init_logging();
// Verifica argumentos de linha de comando
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
"install" => {
install_service()?;
return Ok(());
}
"uninstall" => {
uninstall_service()?;
return Ok(());
}
"run" => {
// Modo de teste: roda sem registrar como servico
info!("Executando em modo de teste (nao como servico)");
run_standalone()?;
return Ok(());
}
_ => {}
}
}
// Inicia como servico Windows
info!("Iniciando Raven Service...");
service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
Ok(())
}
fn init_logging() {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
// Tenta criar diretorio de logs
let log_dir = std::env::var("PROGRAMDATA")
.map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs"))
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs"));
let _ = std::fs::create_dir_all(&log_dir);
// Arquivo de log
let log_file = log_dir.join("service.log");
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
.ok();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
if let Some(file) = file {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(file).with_ansi(false))
.init();
} else {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
}
fn service_main(arguments: Vec<OsString>) {
if let Err(e) = run_service(arguments) {
error!("Erro ao executar servico: {}", e);
}
}
fn run_service(_arguments: Vec<OsString>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Servico iniciando...");
// Canal para shutdown
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
// Registra handler de controle do servico
let shutdown_tx_clone = shutdown_tx.clone();
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
info!("Recebido comando de parada");
if let Ok(mut guard) = shutdown_tx_clone.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(());
}
}
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
})?;
// Atualiza status para Running
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico em execucao, aguardando conexoes...");
// Cria runtime Tokio
let runtime = tokio::runtime::Runtime::new()?;
// Executa servidor IPC
runtime.block_on(async {
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = async {
let _ = shutdown_rx.await;
} => {
info!("Shutdown solicitado");
}
}
});
// Atualiza status para Stopped
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico parado");
Ok(())
}
fn run_standalone() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async {
info!("Servidor IPC iniciando em modo standalone...");
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = tokio::signal::ctrl_c() => {
info!("Ctrl+C recebido, encerrando...");
}
}
});
Ok(())
}
fn install_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Instalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?;
let exe_path = std::env::current_exe()?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
// Define descricao
service.set_description(SERVICE_DESCRIPTION)?;
info!("Servico instalado com sucesso: {}", SERVICE_NAME);
println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME);
println!("Para iniciar: sc start {}", SERVICE_NAME);
Ok(())
}
fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::ServiceAccess,
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Desinstalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
)?;
// Tenta parar o servico primeiro
let status = service.query_status()?;
if status.current_state != ServiceState::Stopped {
info!("Parando servico...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(2));
}
// Remove o servico
service.delete()?;
info!("Servico desinstalado com sucesso");
println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME);
Ok(())
}

View file

@ -0,0 +1,846 @@
//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk
//!
//! Gerencia a instalacao, configuracao e provisionamento do RustDesk.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use chrono::Utc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::env;
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use thiserror::Error;
use tracing::{error, info, warn};
const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest";
const USER_AGENT: &str = "RavenService/1.0";
const SERVER_HOST: &str = "rust.rever.com.br";
const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI=";
const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI<b*34Vmx_8P";
const SERVICE_NAME: &str = "RustDesk";
const CACHE_DIR_NAME: &str = "Rever\\RustDeskCache";
const LOCAL_SERVICE_CONFIG: &str = r"C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config";
const LOCAL_SYSTEM_CONFIG: &str = r"C:\Windows\System32\config\systemprofile\AppData\Roaming\RustDesk\config";
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
const CREATE_NO_WINDOW: u32 = 0x08000000;
static PROVISION_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[derive(Debug, Error)]
pub enum RustdeskError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Release asset nao encontrado para Windows x86_64")]
AssetMissing,
#[error("Falha ao executar comando {command}: status {status:?}")]
CommandFailed { command: String, status: Option<i32> },
#[error("Falha ao detectar ID do RustDesk")]
MissingId,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskStatus {
pub installed: bool,
pub running: bool,
pub id: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Deserialize)]
struct ReleaseResponse {
tag_name: String,
assets: Vec<ReleaseAsset>,
}
/// Provisiona o RustDesk
pub fn ensure_rustdesk(
config_string: Option<&str>,
password_override: Option<&str>,
machine_id: Option<&str>,
) -> Result<RustdeskResult, RustdeskError> {
let _guard = PROVISION_MUTEX.lock();
info!("Iniciando provisionamento do RustDesk");
// Prepara ACLs dos diretorios de servico
if let Err(e) = ensure_service_profiles_writable() {
warn!("Aviso ao preparar ACL: {}", e);
}
// Le ID existente antes de qualquer limpeza
let preserved_remote_id = read_remote_id_from_profiles();
if let Some(ref id) = preserved_remote_id {
info!("ID existente preservado: {}", id);
}
let exe_path = detect_executable_path();
let (installed_version, freshly_installed) = ensure_installed(&exe_path)?;
info!(
"RustDesk {}: {}",
if freshly_installed { "instalado" } else { "ja presente" },
exe_path.display()
);
// Para processos existentes
let _ = stop_rustdesk_processes();
// Limpa perfis apenas se instalacao fresca
if freshly_installed {
let _ = purge_existing_rustdesk_profiles();
}
// Aplica configuracao
if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) {
if let Err(e) = run_with_args(&exe_path, &["--config", config]) {
warn!("Falha ao aplicar config inline: {}", e);
}
} else {
let config_path = write_config_files()?;
if let Err(e) = apply_config(&exe_path, &config_path) {
warn!("Falha ao aplicar config via CLI: {}", e);
}
}
// Define senha
let password = password_override
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
if let Err(e) = set_password(&exe_path, &password) {
warn!("Falha ao definir senha: {}", e);
} else {
let _ = ensure_password_files(&password);
let _ = propagate_password_profile();
}
// Define ID customizado
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
if !freshly_installed {
Some(existing_id.clone())
} else {
define_custom_id(&exe_path, machine_id)
}
} else {
define_custom_id(&exe_path, machine_id)
};
// Inicia servico
if let Err(e) = ensure_service_running(&exe_path) {
warn!("Falha ao iniciar servico: {}", e);
}
// Obtem ID final
let final_id = match query_id_with_retries(&exe_path, 5) {
Ok(id) => id,
Err(_) => {
read_remote_id_from_profiles()
.or_else(|| custom_id.clone())
.ok_or(RustdeskError::MissingId)?
}
};
// Garante ID em todos os arquivos
ensure_remote_id_files(&final_id);
let version = query_version(&exe_path).ok().or(installed_version);
let last_provisioned_at = Utc::now().timestamp_millis();
info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version);
Ok(RustdeskResult {
id: final_id,
password,
installed_version: version,
updated: freshly_installed,
last_provisioned_at,
})
}
/// Retorna status do RustDesk
pub fn get_status() -> Result<RustdeskStatus, RustdeskError> {
let exe_path = detect_executable_path();
let installed = exe_path.exists();
let running = if installed {
query_service_state().map(|s| s == "running").unwrap_or(false)
} else {
false
};
let id = if installed {
query_id(&exe_path).ok().or_else(read_remote_id_from_profiles)
} else {
None
};
let version = if installed {
query_version(&exe_path).ok()
} else {
None
};
Ok(RustdeskStatus {
installed,
running,
id,
version,
})
}
// =============================================================================
// Funcoes Auxiliares
// =============================================================================
fn detect_executable_path() -> PathBuf {
let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string());
Path::new(&program_files).join("RustDesk").join("rustdesk.exe")
}
fn ensure_installed(exe_path: &Path) -> Result<(Option<String>, bool), RustdeskError> {
if exe_path.exists() {
return Ok((None, false));
}
let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join(CACHE_DIR_NAME);
fs::create_dir_all(&cache_root)?;
let (installer_path, version_tag) = download_latest_installer(&cache_root)?;
run_installer(&installer_path)?;
thread::sleep(Duration::from_secs(20));
Ok((Some(version_tag), true))
}
fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> {
let client = Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(60))
.build()?;
let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?;
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with("x86_64.exe"))
.ok_or(RustdeskError::AssetMissing)?;
let target_path = cache_root.join(&asset.name);
if target_path.exists() {
return Ok((target_path, release.tag_name));
}
info!("Baixando RustDesk: {}", asset.name);
let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?;
let mut output = File::create(&target_path)?;
response.copy_to(&mut output)?;
Ok((target_path, release.tag_name))
}
fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> {
let status = hidden_command(installer_path)
.arg("--silent-install")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --silent-install", installer_path.display()),
status: status.code(),
});
}
Ok(())
}
fn program_data_config_dir() -> PathBuf {
PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join("RustDesk")
.join("config")
}
/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema
/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios
fn all_user_appdata_config_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// Enumera C:\Users\*\AppData\Roaming\RustDesk\config
let users_dir = Path::new("C:\\Users");
if let Ok(entries) = fs::read_dir(users_dir) {
for entry in entries.flatten() {
let path = entry.path();
// Ignora pastas de sistema
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" {
continue;
}
let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config");
// Verifica se o diretorio pai existe (usuario real)
if path.join("AppData").join("Roaming").exists() {
dirs.push(rustdesk_config);
}
}
}
// Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos)
if let Ok(appdata) = env::var("APPDATA") {
let path = Path::new(&appdata).join("RustDesk").join("config");
if !dirs.contains(&path) {
dirs.push(path);
}
}
dirs
}
fn service_profile_dirs() -> Vec<PathBuf> {
vec![
PathBuf::from(LOCAL_SERVICE_CONFIG),
PathBuf::from(LOCAL_SYSTEM_CONFIG),
]
}
fn remote_id_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(program_data_config_dir());
dirs.extend(service_profile_dirs());
dirs.extend(all_user_appdata_config_dirs());
dirs
}
fn write_config_files() -> Result<PathBuf, RustdeskError> {
let config_contents = format!(
r#"[options]
key = "{key}"
relay-server = "{host}"
custom-rendezvous-server = "{host}"
api-server = "https://{host}"
verification-method = "{verification}"
approve-mode = "{approve}"
"#,
host = SERVER_HOST,
key = SERVER_KEY,
verification = SECURITY_VERIFICATION_VALUE,
approve = SECURITY_APPROVE_MODE_VALUE,
);
let main_path = program_data_config_dir().join("RustDesk2.toml");
write_file(&main_path, &config_contents)?;
for service_dir in service_profile_dirs() {
let service_profile = service_dir.join("RustDesk2.toml");
let _ = write_file(&service_profile, &config_contents);
}
Ok(main_path)
}
fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(contents.as_bytes())
}
fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()])
}
fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--password", secret])
}
fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option<String> {
let value = machine_id.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() { None } else { Some(trimmed) }
})?;
let custom_id = derive_numeric_id(value);
if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() {
info!("ID deterministico definido: {}", custom_id);
Some(custom_id)
} else {
None
}
}
fn derive_numeric_id(machine_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(machine_id.as_bytes());
let hash = hasher.finalize();
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&hash[..8]);
let value = u64::from_le_bytes(bytes);
let num = (value % 900_000_000) + 100_000_000;
format!("{:09}", num)
}
fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
ensure_service_installed(exe_path)?;
let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]);
let _ = run_sc(&["start", SERVICE_NAME]);
remove_rustdesk_autorun_artifacts();
Ok(())
}
fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> {
if run_sc(&["query", SERVICE_NAME]).is_ok() {
return Ok(());
}
run_with_args(exe_path, &["--install-service"])
}
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
let _ = run_sc(&["stop", SERVICE_NAME]);
thread::sleep(Duration::from_secs(2));
let status = hidden_command("taskkill")
.args(["/F", "/T", "/IM", "rustdesk.exe"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if status.success() || matches!(status.code(), Some(128)) {
Ok(())
} else {
Err(RustdeskError::CommandFailed {
command: "taskkill".into(),
status: status.code(),
})
}
}
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
let files = [
"RustDesk.toml",
"RustDesk_local.toml",
"RustDesk2.toml",
"password",
"passwd",
"passwd.txt",
];
for dir in remote_id_directories() {
if !dir.exists() {
continue;
}
for name in files {
let path = dir.join(name);
if path.exists() {
let _ = fs::remove_file(&path);
}
}
}
Ok(())
}
fn ensure_password_files(secret: &str) -> Result<(), String> {
for dir in remote_id_directories() {
let password_path = dir.join("RustDesk.toml");
let _ = write_toml_kv(&password_path, "password", secret);
let local_path = dir.join("RustDesk_local.toml");
let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE);
let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE);
}
Ok(())
}
fn propagate_password_profile() -> io::Result<bool> {
// Encontra um diretorio de usuario que tenha arquivos de config
let user_dirs = all_user_appdata_config_dirs();
let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists());
let Some(src_dir) = src_dir else {
// Se nenhum usuario tem config, usa ProgramData como fonte
let pd = program_data_config_dir();
if !pd.join("RustDesk.toml").exists() {
return Ok(false);
}
return propagate_from_dir(&pd);
};
propagate_from_dir(src_dir)
}
fn propagate_from_dir(src_dir: &Path) -> io::Result<bool> {
let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"];
let mut propagated = false;
for filename in propagation_files {
let src_path = src_dir.join(filename);
if !src_path.exists() {
continue;
}
for dest_root in remote_id_directories() {
if dest_root == src_dir {
continue; // Nao copiar para si mesmo
}
let target_path = dest_root.join(filename);
if copy_overwrite(&src_path, &target_path).is_ok() {
propagated = true;
}
}
}
Ok(propagated)
}
fn ensure_remote_id_files(id: &str) {
for dir in remote_id_directories() {
let path = dir.join("RustDesk_local.toml");
let _ = write_remote_id_value(&path, id);
}
}
fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let replacement = format!("remote_id = '{}'\n", id);
if let Ok(existing) = fs::read_to_string(path) {
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
if line.trim_start().starts_with("remote_id") {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
} else {
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(replacement.as_bytes())
}
}
fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let sanitized = value.replace('\\', "\\\\").replace('"', "\\\"");
let replacement = format!("{key} = \"{sanitized}\"\n");
let existing = fs::read_to_string(path).unwrap_or_default();
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
}
fn read_remote_id_from_profiles() -> Option<String> {
for dir in remote_id_directories() {
for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] {
if let Some(id) = read_remote_id_file(&candidate) {
if !id.is_empty() {
return Some(id);
}
}
}
}
None
}
fn read_remote_id_file(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = parse_assignment(line, "remote_id") {
return Some(value);
}
}
None
}
fn parse_assignment(line: &str, key: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with(key) {
return None;
}
let (_, rhs) = trimmed.split_once('=')?;
let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result<String, RustdeskError> {
for attempt in 0..attempts {
match query_id(exe_path) {
Ok(value) if !value.trim().is_empty() => return Ok(value),
_ => {}
}
if attempt + 1 < attempts {
thread::sleep(Duration::from_millis(800));
}
}
Err(RustdeskError::MissingId)
}
fn query_id(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--get-id").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --get-id", exe_path.display()),
status: output.status.code(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return Err(RustdeskError::MissingId);
}
Ok(stdout)
}
fn query_version(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--version").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --version", exe_path.display()),
status: output.status.code(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn query_service_state() -> Option<String> {
let output = hidden_command("sc")
.args(["query", SERVICE_NAME])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let lower = line.to_lowercase();
if lower.contains("running") {
return Some("running".to_string());
}
if lower.contains("stopped") {
return Some("stopped".to_string());
}
}
None
}
fn run_sc(args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command("sc")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("sc {}", args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command(exe_path)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} {}", exe_path.display(), args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn remove_rustdesk_autorun_artifacts() {
// Remove atalhos de inicializacao automatica
let mut startup_paths: Vec<PathBuf> = Vec::new();
if let Ok(appdata) = env::var("APPDATA") {
startup_paths.push(
Path::new(&appdata)
.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"),
);
}
startup_paths.push(PathBuf::from(
r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk",
));
for path in startup_paths {
if path.exists() {
let _ = fs::remove_file(&path);
}
}
// Remove entradas de registro
for hive in ["HKCU", "HKLM"] {
let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive);
let _ = hidden_command("reg")
.args(["delete", &reg_path, "/v", "RustDesk", "/f"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn ensure_service_profiles_writable() -> Result<(), String> {
for dir in service_profile_dirs() {
if !can_write_dir(&dir) {
fix_profile_acl(&dir)?;
}
}
Ok(())
}
fn can_write_dir(dir: &Path) -> bool {
if fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".raven_acl_probe");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&probe)
{
Ok(mut file) => {
if file.write_all(b"ok").is_err() {
let _ = fs::remove_file(&probe);
return false;
}
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn fix_profile_acl(target: &Path) -> Result<(), String> {
let target_str = target.display().to_string();
// Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente
let _ = hidden_command("takeown")
.args(["/F", &target_str, "/R", "/D", "Y"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let status = hidden_command("icacls")
.args([
&target_str,
"/grant",
"*S-1-5-32-544:(OI)(CI)F",
"*S-1-5-19:(OI)(CI)F",
"*S-1-5-32-545:(OI)(CI)M",
"/T",
"/C",
"/Q",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("Erro ao executar icacls: {}", e))?;
if status.success() {
Ok(())
} else {
Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1)))
}
}
fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if dst.is_dir() {
fs::remove_dir_all(dst)?;
} else if dst.exists() {
fs::remove_file(dst)?;
}
fs::copy(src, dst)?;
Ok(())
}
fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut cmd = Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}

View file

@ -0,0 +1,259 @@
//! Modulo USB Policy - Controle de dispositivos USB
//!
//! Implementa o controle de armazenamento USB no Windows.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use serde::{Deserialize, Serialize};
use std::io;
use thiserror::Error;
use tracing::{error, info, warn};
use winreg::enums::*;
use winreg::RegKey;
// GUID para Removable Storage Devices (Disk)
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
// Chaves de registro
const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UsbPolicy {
Allow,
BlockAll,
Readonly,
}
impl UsbPolicy {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"ALLOW" => Some(Self::Allow),
"BLOCK_ALL" => Some(Self::BlockAll),
"READONLY" => Some(Self::Readonly),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Allow => "ALLOW",
Self::BlockAll => "BLOCK_ALL",
Self::Readonly => "READONLY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbPolicyResult {
pub success: bool,
pub policy: String,
pub error: Option<String>,
pub applied_at: Option<i64>,
}
#[derive(Error, Debug)]
pub enum UsbControlError {
#[error("Politica USB invalida: {0}")]
InvalidPolicy(String),
#[error("Erro de registro do Windows: {0}")]
RegistryError(String),
#[error("Permissao negada")]
PermissionDenied,
#[error("Erro de I/O: {0}")]
Io(#[from] io::Error),
}
/// Aplica uma politica de USB
pub fn apply_policy(policy_str: &str) -> Result<UsbPolicyResult, UsbControlError> {
let policy = UsbPolicy::from_str(policy_str)
.ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?;
let now = chrono::Utc::now().timestamp_millis();
info!("Aplicando politica USB: {:?}", policy);
// 1. Aplicar Removable Storage Policy
apply_removable_storage_policy(policy)?;
// 2. Aplicar USBSTOR
apply_usbstor_policy(policy)?;
// 3. Aplicar WriteProtect se necessario
if policy == UsbPolicy::Readonly {
apply_write_protect(true)?;
} else {
apply_write_protect(false)?;
}
// 4. Atualizar Group Policy (opcional)
if let Err(e) = refresh_group_policy() {
warn!("Falha ao atualizar group policy: {}", e);
}
info!("Politica USB aplicada com sucesso: {:?}", policy);
Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
})
}
/// Retorna a politica USB atual
pub fn get_current_policy() -> Result<String, UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
// Verifica Removable Storage Policy primeiro
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
if deny_read == 1 && deny_write == 1 {
return Ok("BLOCK_ALL".to_string());
}
if deny_read == 0 && deny_write == 1 {
return Ok("READONLY".to_string());
}
}
// Verifica USBSTOR como fallback
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
let start: u32 = key.get_value("Start").unwrap_or(3);
if start == 4 {
return Ok("BLOCK_ALL".to_string());
}
}
Ok("ALLOW".to_string())
}
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
match policy {
UsbPolicy::Allow => {
// Tenta remover as restricoes, se existirem
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
let _ = key.delete_value("Deny_Read");
let _ = key.delete_value("Deny_Write");
let _ = key.delete_value("Deny_Execute");
}
// Tenta remover a chave inteira se estiver vazia
let _ = hklm.delete_subkey(&full_path);
}
UsbPolicy::BlockAll => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
key.set_value("Deny_Read", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &1u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
// Permite leitura, bloqueia escrita
key.set_value("Deny_Read", &0u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &0u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
.map_err(map_winreg_error)?;
match policy {
UsbPolicy::Allow => {
// Start = 3 habilita o driver
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::BlockAll => {
// Start = 4 desabilita o driver
key.set_value("Start", &4u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
// Readonly mantem driver ativo
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if enable {
let (key, _) = hklm
.create_subkey(STORAGE_POLICY_PATH)
.map_err(map_winreg_error)?;
key.set_value("WriteProtect", &1u32)
.map_err(map_winreg_error)?;
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
}
Ok(())
}
fn refresh_group_policy() -> Result<(), UsbControlError> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("gpupdate")
.args(["/target:computer", "/force"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(UsbControlError::Io)?;
if !output.status.success() {
warn!(
"gpupdate retornou erro: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn map_winreg_error(error: io::Error) -> UsbControlError {
if let Some(code) = error.raw_os_error() {
if code == 5 {
return UsbControlError::PermissionDenied;
}
}
UsbControlError::RegistryError(error.to_string())
}

View file

@ -63,6 +63,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"convex", "convex",
"dirs 5.0.1",
"futures-util", "futures-util",
"get_if_addrs", "get_if_addrs",
"hostname", "hostname",
@ -80,10 +81,12 @@ dependencies = [
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"uuid",
"winreg", "winreg",
] ]
@ -936,13 +939,34 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ dependencies = [
"dirs-sys", "dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
] ]
[[package]] [[package]]
@ -953,7 +977,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -3627,6 +3651,17 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@ -4514,7 +4549,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 6.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.3", "getrandom 0.3.3",
@ -4564,7 +4599,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 6.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@ -4748,6 +4783,21 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.4.0" version = "2.4.0"
@ -4771,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs 6.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
@ -5307,7 +5357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2 0.6.3", "objc2 0.6.3",
@ -6088,6 +6138,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@ -6139,6 +6198,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "windows_x86_64_msvc 0.42.2",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@ -6196,6 +6270,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -6214,6 +6294,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@ -6232,6 +6318,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@ -6262,6 +6354,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@ -6280,6 +6378,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@ -6298,6 +6402,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -6316,6 +6426,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@ -6378,7 +6494,7 @@ dependencies = [
"block2 0.6.2", "block2 0.6.2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"dpi", "dpi",
"dunce", "dunce",
"gdkx11", "gdkx11",

View file

@ -26,6 +26,7 @@ tauri-plugin-updater = "2.9.0"
tauri-plugin-process = "2.3.0" tauri-plugin-process = "2.3.0"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
@ -41,6 +42,8 @@ hostname = "0.4"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
convex = "0.10.2" convex = "0.10.2"
uuid = { version = "1", features = ["v4"] }
dirs = "5"
# SSE usa reqwest com stream, nao precisa de websocket # SSE usa reqwest com stream, nao precisa de websocket
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]

View file

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for all windows", "description": "Capability for all windows",
"windows": ["main", "chat-*"], "windows": ["main", "chat-*", "chat-hub"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:default", "core:event:default",
@ -14,6 +14,7 @@
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-start-dragging",
"dialog:allow-open", "dialog:allow-open",
"opener:default", "opener:default",
"store:default", "store:default",

View file

@ -1,20 +1,121 @@
; Hooks customizadas do instalador NSIS (Tauri) ; Hooks customizadas do instalador NSIS (Tauri)
; ;
; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo. ; Objetivo:
; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo
; - Instalar o Raven Service para operacoes privilegiadas sem UAC
; ;
; Nota: o bundler do Tauri injeta estes macros no script principal do instalador. ; Nota: o bundler do Tauri injeta estes macros no script principal do instalador.
BrandingText " " BrandingText " "
!macro NSIS_HOOK_PREINSTALL !macro NSIS_HOOK_PREINSTALL
; Para e remove qualquer instancia anterior do servico antes de atualizar
DetailPrint "Parando servicos anteriores..."
; Para o servico
nsExec::ExecToLog 'sc stop RavenService'
; Aguarda o servico parar completamente (ate 10 segundos)
nsExec::ExecToLog 'powershell -Command "$$i=0; while((Get-Service RavenService -ErrorAction SilentlyContinue).Status -eq \"Running\" -and $$i -lt 10){Start-Sleep 1;$$i++}"'
; Remove o servico antigo (IMPORTANTE para reinstalacoes)
DetailPrint "Removendo servico antigo..."
IfFileExists "$INSTDIR\raven-service.exe" 0 +2
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
; Fallback: remove via sc delete se o executavel nao existir
nsExec::ExecToLog 'sc delete RavenService'
; Forca encerramento de processos remanescentes
nsExec::ExecToLog 'taskkill /F /IM raven-service.exe'
nsExec::ExecToLog 'taskkill /F /IM appsdesktop.exe'
; Aguarda liberacao dos arquivos e remocao completa do servico
Sleep 3000
!macroend !macroend
!macro NSIS_HOOK_POSTINSTALL !macro NSIS_HOOK_POSTINSTALL
; =========================================================================
; Instala e inicia o Raven Service
; =========================================================================
DetailPrint "Instalando Raven Service..."
; Garante que nao ha servico residual
nsExec::ExecToLog 'sc delete RavenService'
Sleep 1000
; O servico ja esta em $INSTDIR (copiado como resource pelo Tauri)
; Registra o servico Windows
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
Pop $0
${If} $0 != 0
DetailPrint "Aviso: Falha ao registrar servico (codigo: $0)"
; Tenta remover completamente e reinstalar
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
nsExec::ExecToLog 'sc delete RavenService'
Sleep 1000
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
Pop $0
${EndIf}
; Aguarda registro do servico
Sleep 500
; Inicia o servico
DetailPrint "Iniciando Raven Service..."
nsExec::ExecToLog 'sc start RavenService'
Pop $0
${If} $0 == 0
DetailPrint "Raven Service iniciado com sucesso!"
${Else}
; Tenta novamente apos breve espera
Sleep 1000
nsExec::ExecToLog 'sc start RavenService'
Pop $0
${If} $0 == 0
DetailPrint "Raven Service iniciado com sucesso (segunda tentativa)!"
${Else}
DetailPrint "Aviso: Servico sera iniciado na proxima reinicializacao (codigo: $0)"
${EndIf}
${EndIf}
; =========================================================================
; Verifica se RustDesk esta instalado
; Se nao estiver, o Raven Service instalara automaticamente no primeiro uso
; =========================================================================
IfFileExists "$PROGRAMFILES\RustDesk\rustdesk.exe" rustdesk_found rustdesk_not_found
rustdesk_not_found:
DetailPrint "RustDesk sera instalado automaticamente pelo Raven Service."
Goto rustdesk_done
rustdesk_found:
DetailPrint "RustDesk ja esta instalado."
rustdesk_done:
!macroend !macroend
!macro NSIS_HOOK_PREUNINSTALL !macro NSIS_HOOK_PREUNINSTALL
; =========================================================================
; Para e remove o Raven Service
; =========================================================================
DetailPrint "Parando Raven Service..."
nsExec::ExecToLog 'sc stop RavenService'
Sleep 1000
DetailPrint "Removendo Raven Service..."
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
; Aguarda um pouco para garantir que o servico foi removido
Sleep 500
!macroend !macroend
!macro NSIS_HOOK_POSTUNINSTALL !macro NSIS_HOOK_POSTUNINSTALL
; Nada adicional necessario
!macroend !macroend

View file

@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value {
} }
fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> { fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> {
if bytes.len() % 2 != 0 { if !bytes.len().is_multiple_of(2) {
return None; return None;
} }
let utf16: Vec<u16> = bytes let utf16: Vec<u16> = bytes
@ -971,6 +971,169 @@ fn collect_windows_extended() -> serde_json::Value {
"#).unwrap_or_else(|| json!([])); "#).unwrap_or_else(|| json!([]));
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
// Bateria (notebooks/laptops)
let battery = ps(r#"
$batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime)
if ($batteries.Count -eq 0) {
[PSCustomObject]@{ Present = $false; Batteries = @() }
} else {
# Mapeia status numérico para texto
$statusMap = @{
1 = 'Discharging'
2 = 'AC Power'
3 = 'Fully Charged'
4 = 'Low'
5 = 'Critical'
6 = 'Charging'
7 = 'Charging High'
8 = 'Charging Low'
9 = 'Charging Critical'
10 = 'Undefined'
11 = 'Partially Charged'
}
foreach ($b in $batteries) {
if ($b.BatteryStatus) {
$b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force
}
}
[PSCustomObject]@{ Present = $true; Batteries = $batteries }
}
"#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] }));
// Sensores térmicos (temperatura CPU/GPU quando disponível)
let thermal = ps(r#"
$temps = @()
# Tenta WMI thermal zone (requer admin em alguns sistemas)
try {
$zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
foreach ($z in $zones) {
if ($z.CurrentTemperature) {
$celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1)
$temps += [PSCustomObject]@{
Source = 'ThermalZone'
Name = $z.InstanceName
TemperatureCelsius = $celsius
CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null }
}
}
}
} catch {}
# CPU temp via Open Hardware Monitor WMI (se instalado)
try {
$ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' }
foreach ($s in $ohm) {
$temps += [PSCustomObject]@{
Source = 'OpenHardwareMonitor'
Name = $s.Name
TemperatureCelsius = $s.Value
Parent = $s.Parent
}
}
} catch {}
@($temps)
"#).unwrap_or_else(|| json!([]));
// Adaptadores de rede (físicos e virtuais)
let network_adapters = ps(r#"
@(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object {
$statusMap = @{
0 = 'Disconnected'
1 = 'Connecting'
2 = 'Connected'
3 = 'Disconnecting'
4 = 'Hardware not present'
5 = 'Hardware disabled'
6 = 'Hardware malfunction'
7 = 'Media disconnected'
8 = 'Authenticating'
9 = 'Authentication succeeded'
10 = 'Authentication failed'
11 = 'Invalid address'
12 = 'Credentials required'
}
$_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force
$_
})
"#).unwrap_or_else(|| json!([]));
// Monitores conectados
let monitors = ps(r#"
@(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object {
$decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } }
[PSCustomObject]@{
ManufacturerName = & $decode $_.ManufacturerName
ProductCodeID = & $decode $_.ProductCodeID
SerialNumberID = & $decode $_.SerialNumberID
UserFriendlyName = & $decode $_.UserFriendlyName
YearOfManufacture = $_.YearOfManufacture
WeekOfManufacture = $_.WeekOfManufacture
}
})
"#).unwrap_or_else(|| json!([]));
// Fonte de alimentação / chassis
let power_supply = ps(r#"
$chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag
$chassisTypeMap = @{
1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop'
5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable'
9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station'
13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box'
17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis'
20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis'
23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis'
30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable'
}
$types = @()
if ($chassis.ChassisTypes) {
foreach ($t in $chassis.ChassisTypes) {
$types += $chassisTypeMap[[int]$t] ?? "Type$t"
}
}
[PSCustomObject]@{
ChassisTypes = $chassis.ChassisTypes
ChassisTypesText = $types
Manufacturer = $chassis.Manufacturer
SerialNumber = $chassis.SerialNumber
SMBIOSAssetTag = $chassis.SMBIOSAssetTag
}
"#).unwrap_or_else(|| json!({}));
// Último reinício e contagem de boots
let boot_info = ps(r#"
$os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime
$lastBoot = $os.LastBootUpTime
# Calcula uptime
$uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 }
# Conta eventos de boot (ID 6005) - últimos 30 dias para performance
$startDate = (Get-Date).AddDays(-30)
$bootEvents = @()
$bootCount = 0
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'System'
ID = 6005
StartTime = $startDate
} -MaxEvents 50 -ErrorAction SilentlyContinue
$bootCount = @($events).Count
$bootEvents = @($events | Select-Object -First 10 | ForEach-Object {
@{
TimeCreated = $_.TimeCreated.ToString('o')
Computer = $_.MachineName
}
})
} catch {}
[PSCustomObject]@{
LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null }
UptimeSeconds = [math]::Round($uptime)
BootCountLast30Days = $bootCount
RecentBoots = $bootEvents
}
"#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] }));
json!({ json!({
"windows": { "windows": {
"software": software, "software": software,
@ -992,6 +1155,12 @@ fn collect_windows_extended() -> serde_json::Value {
"windowsUpdate": windows_update, "windowsUpdate": windows_update,
"computerSystem": computer_system, "computerSystem": computer_system,
"azureAdStatus": device_join, "azureAdStatus": device_join,
"battery": battery,
"thermal": thermal,
"networkAdapters": network_adapters,
"monitors": monitors,
"chassis": power_supply,
"bootInfo": boot_info,
} }
}) })
} }
@ -1086,7 +1255,7 @@ pub fn collect_profile() -> Result<MachineProfile, AgentError> {
let system = collect_system(); let system = collect_system();
let os_name = System::name() let os_name = System::name()
.or_else(|| System::long_os_version()) .or_else(System::long_os_version)
.unwrap_or_else(|| "desconhecido".to_string()); .unwrap_or_else(|| "desconhecido".to_string());
let os_version = System::os_version(); let os_version = System::os_version();
let architecture = std::env::consts::ARCH.to_string(); let architecture = std::env::consts::ARCH.to_string();
@ -1146,7 +1315,7 @@ async fn post_heartbeat(
.into_owned(); .into_owned();
let os = MachineOs { let os = MachineOs {
name: System::name() name: System::name()
.or_else(|| System::long_os_version()) .or_else(System::long_os_version)
.unwrap_or_else(|| "desconhecido".to_string()), .unwrap_or_else(|| "desconhecido".to_string()),
version: System::os_version(), version: System::os_version(),
architecture: Some(std::env::consts::ARCH.to_string()), architecture: Some(std::env::consts::ARCH.to_string()),
@ -1225,7 +1394,8 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy}; use crate::usb_control::{get_current_policy, UsbPolicy};
use crate::service_client;
let policy = match UsbPolicy::from_str(&policy_str) { let policy = match UsbPolicy::from_str(&policy_str) {
Some(p) => p, Some(p) => p,
@ -1259,13 +1429,40 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
// Reporta APPLYING para progress bar real no frontend // Reporta APPLYING para progress bar real no frontend
let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await; let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await;
match apply_usb_policy(policy) { // Tenta primeiro via RavenService (privilegiado)
crate::log_info!("Tentando aplicar politica via RavenService...");
match service_client::apply_usb_policy(&policy_str) {
Ok(result) => { Ok(result) => {
crate::log_info!("Politica USB aplicada com sucesso: {:?}", result); if result.success {
crate::log_info!("Politica USB aplicada com sucesso via RavenService: {:?}", result);
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
if !reported {
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::log_info!("Retry agendado: reportando politica USB...");
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
});
}
return;
} else {
let err_msg = result.error.unwrap_or_else(|| "Erro desconhecido".to_string());
crate::log_error!("RavenService retornou erro: {}", err_msg);
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
}
}
Err(service_client::ServiceClientError::ServiceUnavailable(msg)) => {
crate::log_warn!("RavenService nao disponivel: {}", msg);
// Tenta fallback direto (vai falhar se nao tiver privilegio)
crate::log_info!("Tentando aplicar politica diretamente...");
match crate::usb_control::apply_usb_policy(policy) {
Ok(result) => {
crate::log_info!("Politica USB aplicada com sucesso (direto): {:?}", result);
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
if !reported { if !reported {
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
// Agenda retry em background
let base_url = base_url.to_string(); let base_url = base_url.to_string();
let token = token.to_string(); let token = token.to_string();
tokio::spawn(async move { tokio::spawn(async move {
@ -1276,7 +1473,14 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
} }
} }
Err(e) => { Err(e) => {
crate::log_error!("Falha ao aplicar politica USB: {e}"); let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e);
crate::log_error!("{}", err_msg);
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
}
}
}
Err(e) => {
crate::log_error!("Falha ao comunicar com RavenService: {e}");
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await; report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
} }
} }

View file

@ -10,10 +10,12 @@ use once_cell::sync::Lazy;
use parking_lot::Mutex; use parking_lot::Mutex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
@ -100,6 +102,77 @@ pub struct SessionStartedEvent {
pub session: ChatSession, pub session: ChatSession,
} }
// ============================================================================
// PERSISTENCIA DE ESTADO
// ============================================================================
/// Estado persistido do chat para sobreviver a restarts
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChatPersistedState {
last_unread_count: u32,
sessions: Vec<ChatSession>,
saved_at: u64, // Unix timestamp em ms
}
const STATE_FILE_NAME: &str = "chat-state.json";
const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos
fn get_state_file_path() -> Option<PathBuf> {
dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME))
}
fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) {
let Some(path) = get_state_file_path() else {
return;
};
// Criar diretorio se nao existir
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let state = ChatPersistedState {
last_unread_count: last_unread,
sessions: sessions.to_vec(),
saved_at: now,
};
if let Ok(json) = serde_json::to_string_pretty(&state) {
let _ = fs::write(&path, json);
crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len());
}
}
fn load_chat_state() -> Option<ChatPersistedState> {
let path = get_state_file_path()?;
let json = fs::read_to_string(&path).ok()?;
let state: ChatPersistedState = serde_json::from_str(&json).ok()?;
// Verificar se estado nao esta muito antigo
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS {
crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)");
return None;
}
crate::log_info!(
"[CHAT] Estado restaurado: unread={}, sessions={}",
state.last_unread_count, state.sessions.len()
);
Some(state)
}
// ============================================================================ // ============================================================================
// HTTP CLIENT // HTTP CLIENT
// ============================================================================ // ============================================================================
@ -321,6 +394,7 @@ pub struct UploadResult {
// Extensoes permitidas // Extensoes permitidas
const ALLOWED_EXTENSIONS: &[&str] = &[ const ALLOWED_EXTENSIONS: &[&str] = &[
".jpg", ".jpeg", ".png", ".gif", ".webp", ".jpg", ".jpeg", ".png", ".gif", ".webp",
".mp3", ".wav", ".ogg", ".webm", ".m4a",
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
]; ];
@ -361,6 +435,11 @@ pub fn get_mime_type(file_name: &str) -> String {
"png" => "image/png", "png" => "image/png",
"gif" => "image/gif", "gif" => "image/gif",
"webp" => "image/webp", "webp" => "image/webp",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"webm" => "audio/webm",
"m4a" => "audio/mp4",
"pdf" => "application/pdf", "pdf" => "application/pdf",
"txt" => "text/plain", "txt" => "text/plain",
"doc" => "application/msword", "doc" => "application/msword",
@ -462,10 +541,16 @@ pub struct ChatRuntime {
impl ChatRuntime { impl ChatRuntime {
pub fn new() -> Self { pub fn new() -> Self {
// Tentar restaurar estado persistido
let (sessions, unread) = match load_chat_state() {
Some(state) => (state.sessions, state.last_unread_count),
None => (Vec::new(), 0),
};
Self { Self {
inner: Arc::new(Mutex::new(None)), inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())), last_sessions: Arc::new(Mutex::new(sessions)),
last_unread_count: Arc::new(Mutex::new(0)), last_unread_count: Arc::new(Mutex::new(unread)),
is_connected: Arc::new(AtomicBool::new(false)), is_connected: Arc::new(AtomicBool::new(false)),
} }
} }
@ -510,7 +595,9 @@ impl ChatRuntime {
let is_connected = self.is_connected.clone(); let is_connected = self.is_connected.clone();
let join_handle = tauri::async_runtime::spawn(async move { let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat");
crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone);
crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone);
let mut backoff_ms: u64 = 1_000; let mut backoff_ms: u64 = 1_000;
let max_backoff_ms: u64 = 30_000; let max_backoff_ms: u64 = 30_000;
@ -522,12 +609,16 @@ impl ChatRuntime {
break; break;
} }
crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex...");
let client_result = ConvexClient::new(&convex_clone).await; let client_result = ConvexClient::new(&convex_clone).await;
let mut client = match client_result { let mut client = match client_result {
Ok(c) => c, Ok(c) => {
crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso");
c
}
Err(err) => { Err(err) => {
is_connected.store(false, Ordering::Relaxed); is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}");
if last_poll.elapsed() >= poll_interval { if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update( poll_and_process_chat_update(
@ -550,16 +641,18 @@ impl ChatRuntime {
let mut args = BTreeMap::new(); let mut args = BTreeMap::new();
args.insert("machineToken".to_string(), token_clone.clone().into()); args.insert("machineToken".to_string(), token_clone.clone().into());
crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates...");
let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await; let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await;
let mut subscription = match subscribe_result { let mut subscription = match subscribe_result {
Ok(sub) => { Ok(sub) => {
is_connected.store(true, Ordering::Relaxed); is_connected.store(true, Ordering::Relaxed);
backoff_ms = 1_000; backoff_ms = 1_000;
crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!");
sub sub
} }
Err(err) => { Err(err) => {
is_connected.store(false, Ordering::Relaxed); is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}");
if last_poll.elapsed() >= poll_interval { if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update( poll_and_process_chat_update(
@ -579,8 +672,12 @@ impl ChatRuntime {
} }
}; };
crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket...");
let mut update_count: u64 = 0;
while let Some(next) = subscription.next().await { while let Some(next) = subscription.next().await {
update_count += 1;
if stop_clone.load(Ordering::Relaxed) { if stop_clone.load(Ordering::Relaxed) {
crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop");
break; break;
} }
match next { match next {
@ -601,6 +698,11 @@ impl ChatRuntime {
}) })
.unwrap_or(0); .unwrap_or(0);
crate::log_info!(
"[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}",
update_count, has_active, total_unread
);
process_chat_update( process_chat_update(
&base_clone, &base_clone,
&token_clone, &token_clone,
@ -613,13 +715,13 @@ impl ChatRuntime {
.await; .await;
} }
FunctionResult::ConvexError(err) => { FunctionResult::ConvexError(err) => {
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}");
} }
FunctionResult::ErrorMessage(msg) => { FunctionResult::ErrorMessage(msg) => {
crate::log_warn!("Erro em checkMachineUpdates: {msg}"); crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}");
} }
FunctionResult::Value(other) => { FunctionResult::Value(other) => {
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}");
} }
} }
} }
@ -627,10 +729,11 @@ impl ChatRuntime {
is_connected.store(false, Ordering::Relaxed); is_connected.store(false, Ordering::Relaxed);
if stop_clone.load(Ordering::Relaxed) { if stop_clone.load(Ordering::Relaxed) {
crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop");
break; break;
} }
crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar"); crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar...");
if last_poll.elapsed() >= poll_interval { if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update( poll_and_process_chat_update(
&base_clone, &base_clone,
@ -684,8 +787,13 @@ async fn poll_and_process_chat_update(
last_sessions: &Arc<Mutex<Vec<ChatSession>>>, last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>, last_unread_count: &Arc<Mutex<u32>>,
) { ) {
crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling...");
match poll_chat_updates(base_url, token, None).await { match poll_chat_updates(base_url, token, None).await {
Ok(result) => { Ok(result) => {
crate::log_info!(
"[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}",
result.has_active_sessions, result.total_unread
);
process_chat_update( process_chat_update(
base_url, base_url,
token, token,
@ -698,7 +806,7 @@ async fn poll_and_process_chat_update(
.await; .await;
} }
Err(err) => { Err(err) => {
crate::log_warn!("Chat fallback poll falhou: {err}"); crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}");
} }
} }
} }
@ -712,10 +820,18 @@ async fn process_chat_update(
has_active_sessions: bool, has_active_sessions: bool,
total_unread: u32, total_unread: u32,
) { ) {
crate::log_info!(
"[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}",
has_active_sessions, total_unread
);
// Buscar sessoes completas para ter dados corretos // Buscar sessoes completas para ter dados corretos
let mut current_sessions = if has_active_sessions { let mut current_sessions = if has_active_sessions {
fetch_sessions(base_url, token).await.unwrap_or_default() let sessions = fetch_sessions(base_url, token).await.unwrap_or_default();
crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len());
sessions
} else { } else {
crate::log_info!("[CHAT DEBUG] Sem sessoes ativas");
Vec::new() Vec::new()
}; };
@ -776,14 +892,58 @@ async fn process_chat_update(
} }
} }
// Atualizar cache de sessoes // =========================================================================
*last_sessions.lock() = current_sessions.clone(); // DETECCAO ROBUSTA DE NOVAS MENSAGENS
// Usa DUAS estrategias: timestamp E contador (belt and suspenders)
// =========================================================================
// Verificar mensagens nao lidas
let prev_unread = *last_unread_count.lock(); let prev_unread = *last_unread_count.lock();
let new_messages = total_unread > prev_unread;
// Estrategia 1: Detectar por lastActivityAt de cada sessao
// Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem
let mut detected_by_activity = false;
let mut activity_details = String::new();
for session in &current_sessions {
let prev_activity = prev_sessions
.iter()
.find(|s| s.session_id == session.session_id)
.map(|s| s.last_activity_at)
.unwrap_or(0);
// Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente
if session.last_activity_at > prev_activity && session.unread_count > 0 {
detected_by_activity = true;
activity_details = format!(
"sessao={} activity: {} -> {} unread={}",
session.ticket_id, prev_activity, session.last_activity_at, session.unread_count
);
break;
}
}
// Estrategia 2: Fallback por contador total (metodo original)
let detected_by_count = total_unread > prev_unread;
// Nova mensagem se QUALQUER estrategia detectar
let new_messages = detected_by_activity || detected_by_count;
// Log detalhado para diagnostico
crate::log_info!(
"[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}",
detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages
);
if detected_by_activity {
crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details);
}
// Atualizar caches APOS deteccao (importante: manter ordem)
*last_sessions.lock() = current_sessions.clone();
*last_unread_count.lock() = total_unread; *last_unread_count.lock() = total_unread;
// Persistir estado para sobreviver a restarts
save_chat_state(total_unread, &current_sessions);
// Sempre emitir unread-update // Sempre emitir unread-update
let _ = app.emit( let _ = app.emit(
"raven://chat/unread-update", "raven://chat/unread-update",
@ -793,11 +953,25 @@ async fn process_chat_update(
}), }),
); );
if current_sessions.is_empty() {
close_all_chat_windows(app);
let _ = close_hub_window(app);
return;
}
// Notificar novas mensagens - mostrar chat minimizado com badge // Notificar novas mensagens - mostrar chat minimizado com badge
if new_messages && total_unread > 0 { if new_messages && total_unread > 0 {
let new_count = total_unread - prev_unread; let new_count = if total_unread > prev_unread {
total_unread - prev_unread
} else {
1 // Se detectou por activity mas contador nao mudou, assumir 1 nova
};
crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread); crate::log_info!(
"[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}",
new_count, total_unread,
if detected_by_activity { "activity" } else { "count" }
);
let _ = app.emit( let _ = app.emit(
"raven://chat/new-message", "raven://chat/new-message",
@ -838,6 +1012,20 @@ async fn process_chat_update(
} }
} }
// Se ha multiplas sessoes ativas, usar o hub quando nao houver chat expandido.
//
// Importante (UX): nao mostrar hub e chat ao mesmo tempo.
if current_sessions.len() > 1 {
if has_expanded_chat_window() {
let _ = close_hub_window(app);
} else {
close_all_chat_windows(app);
let _ = open_hub_window(app);
}
} else {
// Uma sessao - nao precisa de hub
let _ = close_hub_window(app);
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
let session_to_show = if best_delta > 0 { let session_to_show = if best_delta > 0 {
best_session best_session
@ -849,26 +1037,9 @@ async fn process_chat_update(
}) })
}; };
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) // Mostrar janela de chat (sempre minimizada/nao intrusiva)
if let Some(session) = session_to_show { if let Some(session) = session_to_show {
let label = format!("chat-{}", session.ticket_id); let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true);
if let Some(window) = app.get_webview_window(&label) {
// Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida)
// Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens
let _ = window.show();
// Verificar se esta expandida (altura > 100px significa expandido)
// Se estiver expandida, NAO minimizar - usuario esta usando o chat
if let Ok(size) = window.inner_size() {
let is_expanded = size.height > 100;
if !is_expanded {
// Janela esta minimizada, manter minimizada
let _ = set_chat_minimized(app, &session.ticket_id, true);
}
// Se esta expandida, nao faz nada - deixa o usuario continuar usando
}
} else {
// Criar nova janela ja minimizada (menos intrusivo)
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
} }
} }
@ -885,6 +1056,16 @@ async fn process_chat_update(
.title(notification_title) .title(notification_title)
.body(&notification_body) .body(&notification_body)
.show(); .show();
} else {
// Log para debug quando NAO ha novas mensagens
if total_unread == 0 {
crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)");
} else if !new_messages {
crate::log_info!(
"[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})",
prev_unread, total_unread
);
}
} }
} }
@ -892,6 +1073,53 @@ async fn process_chat_update(
// WINDOW MANAGEMENT // WINDOW MANAGEMENT
// ============================================================================ // ============================================================================
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
static CHAT_WINDOW_STATE: Lazy<Mutex<HashMap<String, bool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
fn set_chat_window_state(label: &str, minimized: bool) {
CHAT_WINDOW_STATE.lock().insert(label.to_string(), minimized);
}
fn clear_chat_window_state(label: &str) {
CHAT_WINDOW_STATE.lock().remove(label);
}
fn has_expanded_chat_window() -> bool {
CHAT_WINDOW_STATE.lock().values().any(|minimized| !*minimized)
}
fn close_all_chat_windows(app: &tauri::AppHandle) {
let labels: Vec<String> = app
.webview_windows()
.keys()
.filter(|label| label.starts_with("chat-") && *label != HUB_WINDOW_LABEL)
.cloned()
.collect();
for label in labels {
if let Some(window) = app.get_webview_window(&label) {
let _ = window.close();
}
clear_chat_window_state(&label);
}
}
fn hide_other_chat_windows(app: &tauri::AppHandle, active_label: &str) {
for (label, window) in app.webview_windows() {
if !label.starts_with("chat-") {
continue;
}
if label == active_label {
continue;
}
let _ = window.hide();
set_chat_window_state(&label, true);
}
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = hub.hide();
}
}
fn resolve_chat_window_position( fn resolve_chat_window_position(
app: &tauri::AppHandle, app: &tauri::AppHandle,
window: Option<&tauri::WebviewWindow>, window: Option<&tauri::WebviewWindow>,
@ -932,18 +1160,44 @@ fn resolve_chat_window_position(
(x, y) (x, y)
} }
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada let _guard = WINDOW_OP_LOCK.lock();
open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
} }
/// Abre janela de chat com estado inicial de minimizacao configuravel /// Abre janela de chat com estado inicial de minimizacao configuravel
fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
let label = format!("chat-{}", ticket_id); let label = format!("chat-{}", ticket_id);
crate::log_info!(
"[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}",
label,
ticket_ref,
start_minimized
);
if !start_minimized {
hide_other_chat_windows(app, &label);
}
// Verificar se ja existe // Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
let _ = window.set_ignore_cursor_events(false);
crate::log_info!("[WINDOW] {}: window existe -> show()", label);
window.show().map_err(|e| e.to_string())?; window.show().map_err(|e| e.to_string())?;
let _ = window.unminimize();
if !start_minimized {
crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label);
window.set_focus().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?;
}
// Expandir a janela se estiver minimizada (quando clicado na lista)
if !start_minimized {
crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label);
let _ = set_chat_minimized_unlocked(app, ticket_id, false);
}
crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label);
if !start_minimized {
set_chat_window_state(&label, false);
}
return Ok(()); return Ok(());
} }
@ -960,7 +1214,17 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
// Usar query param ao inves de path para compatibilidade com SPA // Usar query param ao inves de path para compatibilidade com SPA
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref); let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
WebviewWindowBuilder::new( crate::log_info!(
"[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}",
label,
width,
height,
x,
y,
url_path
);
let window = WebviewWindowBuilder::new(
app, app,
&label, &label,
WebviewUrl::App(url_path.into()), WebviewUrl::App(url_path.into()),
@ -972,46 +1236,64 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
.decorations(false) // Sem decoracoes nativas - usa header customizado .decorations(false) // Sem decoracoes nativas - usa header customizado
.transparent(true) // Permite fundo transparente .transparent(true) // Permite fundo transparente
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
.resizable(false) // Desabilitar redimensionamento manual
// Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
.always_on_top(true) .always_on_top(true)
.skip_taskbar(true) .skip_taskbar(true)
.focused(true) .focused(!start_minimized)
.visible(true) .visible(true)
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: build() OK", label);
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
let _ = window.set_ignore_cursor_events(false);
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized);
// Reaplica layout/posicao logo apos criar a janela. // Reaplica layout/posicao logo apos criar a janela.
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes. // Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
let _ = set_chat_minimized(app, ticket_id, start_minimized); let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized);
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized);
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
Ok(()) Ok(())
} }
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
open_chat_window_internal(app, ticket_id, ticket_ref) // Quando chamado explicitamente (ex: clique no hub), abre expandida
open_chat_window_internal(app, ticket_id, ticket_ref, false)
} }
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let label = format!("chat-{}", ticket_id); let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
window.close().map_err(|e| e.to_string())?; window.close().map_err(|e| e.to_string())?;
} }
clear_chat_window_state(&label);
Ok(()) Ok(())
} }
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let label = format!("chat-{}", ticket_id); let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
window.hide().map_err(|e| e.to_string())?; window.hide().map_err(|e| e.to_string())?;
} }
set_chat_window_state(&label, true);
Ok(()) Ok(())
} }
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido /// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
let label = format!("chat-{}", ticket_id); let label = format!("chat-{}", ticket_id);
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?;
if minimized {
hide_other_chat_windows(app, &label);
}
// Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1) // Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1)
let (width, height) = if minimized { let (width, height) = if minimized {
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge (240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
@ -1023,9 +1305,125 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// Aplicar novo tamanho e posicao // Aplicar novo tamanho e posicao
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized);
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized);
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized);
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized);
set_chat_window_state(&label, minimized);
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
Ok(()) Ok(())
} }
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
set_chat_minimized_unlocked(app, ticket_id, minimized)
}
// ============================================================================
// HUB WINDOW MANAGEMENT (Lista de todas as sessoes)
// ============================================================================
const HUB_WINDOW_LABEL: &str = "chat-hub";
pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
open_hub_window_with_state(app, true) // Por padrao abre minimizada
}
fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> {
// Verificar se ja existe
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = window.set_ignore_cursor_events(false);
window.show().map_err(|e| e.to_string())?;
let _ = window.unminimize();
if !start_minimized {
window.set_focus().map_err(|e| e.to_string())?;
}
return Ok(());
}
// Dimensoes baseadas no estado inicial
let (width, height) = if start_minimized {
(200.0, 52.0) // Tamanho minimizado (chip)
} else {
(400.0, 520.0) // Tamanho expandido (igual ao web)
};
// Posicionar no canto inferior direito
let (x, y) = resolve_chat_window_position(app, None, width, height);
// URL para modo hub
let url_path = "index.html?view=chat&hub=true";
WebviewWindowBuilder::new(
app,
HUB_WINDOW_LABEL,
WebviewUrl::App(url_path.into()),
)
.title("Chats de Suporte")
.inner_size(width, height)
.min_inner_size(200.0, 52.0)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(false)
.resizable(false) // Desabilitar redimensionamento manual
// Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
.always_on_top(true)
.skip_taskbar(true)
.focused(!start_minimized)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = hub.set_ignore_cursor_events(false);
if !start_minimized {
let _ = hub.set_focus();
}
}
// REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar
// "resize em cima do resize" no timing errado do WebView2
// let _ = set_hub_minimized(app, start_minimized);
crate::log_info!("Hub window aberta (minimizada={})", start_minimized);
Ok(())
}
pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
window.close().map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?;
let (width, height) = if minimized {
(200.0, 52.0) // Chip minimizado
} else {
(400.0, 520.0) // Lista expandida (igual ao web)
};
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// IGUAL AO CHAT: primeiro size, depois position (ordem importa para hit-test no Windows)
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
// Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat).
if !minimized {
let _ = window.set_focus();
}
crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y);
Ok(())
}

View file

@ -2,6 +2,8 @@ mod agent;
mod chat; mod chat;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod rustdesk; mod rustdesk;
#[cfg(target_os = "windows")]
mod service_client;
mod usb_control; mod usb_control;
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
@ -68,21 +70,21 @@ pub fn log_agent(level: &str, message: &str) {
#[macro_export] #[macro_export]
macro_rules! log_info { macro_rules! log_info {
($($arg:tt)*) => { ($($arg:tt)*) => {
$crate::log_agent("INFO", &format!($($arg)*)) $crate::log_agent("INFO", format!($($arg)*).as_str())
}; };
} }
#[macro_export] #[macro_export]
macro_rules! log_error { macro_rules! log_error {
($($arg:tt)*) => { ($($arg:tt)*) => {
$crate::log_agent("ERROR", &format!($($arg)*)) $crate::log_agent("ERROR", format!($($arg)*).as_str())
}; };
} }
#[macro_export] #[macro_export]
macro_rules! log_warn { macro_rules! log_warn {
($($arg:tt)*) => { ($($arg:tt)*) => {
$crate::log_agent("WARN", &format!($($arg)*)) $crate::log_agent("WARN", format!($($arg)*).as_str())
}; };
} }
@ -189,6 +191,32 @@ fn run_rustdesk_ensure(
password: Option<String>, password: Option<String>,
machine_id: Option<String>, machine_id: Option<String>,
) -> Result<RustdeskProvisioningResult, String> { ) -> Result<RustdeskProvisioningResult, String> {
// Tenta usar o servico primeiro (sem UAC)
if service_client::is_service_available() {
log_info!("Usando Raven Service para provisionar RustDesk");
match service_client::provision_rustdesk(
config_string.as_deref(),
password.as_deref(),
machine_id.as_deref(),
) {
Ok(result) => {
return Ok(RustdeskProvisioningResult {
id: result.id,
password: result.password,
installed_version: result.installed_version,
updated: result.updated,
last_provisioned_at: result.last_provisioned_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para RustDesk: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)");
rustdesk::ensure_rustdesk( rustdesk::ensure_rustdesk(
config_string.as_deref(), config_string.as_deref(),
password.as_deref(), password.as_deref(),
@ -208,14 +236,50 @@ fn run_rustdesk_ensure(
#[tauri::command] #[tauri::command]
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> { fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
let policy_enum = UsbPolicy::from_str(&policy) // Valida a politica primeiro
let _policy_enum = UsbPolicy::from_str(&policy)
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?; .ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string()) // Tenta usar o servico primeiro (sem UAC)
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
log_info!("Usando Raven Service para aplicar politica USB: {}", policy);
match service_client::apply_usb_policy(&policy) {
Ok(result) => {
return Ok(UsbPolicyResult {
success: result.success,
policy: result.policy,
error: result.error,
applied_at: result.applied_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para USB policy: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)");
usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
fn get_usb_policy() -> Result<String, String> { fn get_usb_policy() -> Result<String, String> {
// Tenta usar o servico primeiro
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
match service_client::get_usb_policy() {
Ok(policy) => return Ok(policy),
Err(e) => {
log_warn!("Falha ao obter USB policy via servico: {e}");
// Continua para fallback
}
}
}
// Fallback: leitura direta (nao precisa elevacao para ler)
usb_control::get_current_policy() usb_control::get_current_policy()
.map(|p| p.as_str().to_string()) .map(|p| p.as_str().to_string())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@ -346,8 +410,17 @@ async fn upload_chat_file(
} }
#[tauri::command] #[tauri::command]
fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> { async fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
chat::open_chat_window(&app, &ticket_id, ticket_ref) log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref);
let app_handle = app.clone();
let ticket_id_for_task = ticket_id.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
chat::open_chat_window(&app_handle, &ticket_id_for_task, ticket_ref)
})
.await
.map_err(|err| format!("Falha ao abrir chat (join): {err}"))?;
log_info!("[CMD] open_chat_window result: {:?}", result);
result
} }
#[tauri::command] #[tauri::command]
@ -365,6 +438,26 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
chat::set_chat_minimized(&app, &ticket_id, minimized) chat::set_chat_minimized(&app, &ticket_id, minimized)
} }
#[tauri::command]
async fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> {
let app_handle = app.clone();
tauri::async_runtime::spawn_blocking(move || {
chat::open_hub_window(&app_handle)
})
.await
.map_err(|err| format!("Falha ao abrir hub (join): {err}"))?
}
#[tauri::command]
fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> {
chat::close_hub_window(&app)
}
#[tauri::command]
fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> {
chat::set_hub_minimized(&app, minimized)
}
// ============================================================================ // ============================================================================
// Handler de Deep Link (raven://) // Handler de Deep Link (raven://)
// ============================================================================ // ============================================================================
@ -452,6 +545,14 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
// Quando uma segunda instância tenta iniciar, foca a janela existente
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
@ -481,7 +582,7 @@ pub fn run() {
{ {
let start_in_background = std::env::args().any(|arg| arg == "--background"); let start_in_background = std::env::args().any(|arg| arg == "--background");
setup_raven_autostart(); setup_raven_autostart();
setup_tray(&app.handle())?; setup_tray(app.handle())?;
if start_in_background { if start_in_background {
if let Some(win) = app.get_webview_window("main") { if let Some(win) = app.get_webview_window("main") {
let _ = win.hide(); let _ = win.hide();
@ -526,7 +627,11 @@ pub fn run() {
open_chat_window, open_chat_window,
close_chat_window, close_chat_window,
minimize_chat_window, minimize_chat_window,
set_chat_minimized set_chat_minimized,
// Hub commands
open_hub_window,
close_hub_window,
set_hub_minimized
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
@ -608,7 +713,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
// Abrir janela de chat se houver sessao ativa // Abrir janela de chat se houver sessao ativa
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() { if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
let sessions = chat_runtime.get_sessions(); let sessions = chat_runtime.get_sessions();
if let Some(session) = sessions.first() { if sessions.len() > 1 {
// Multiplas sessoes - abrir hub
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
log_error!("Falha ao abrir hub de chat: {e}");
}
} else if let Some(session) = sessions.first() {
// Uma sessao - abrir diretamente
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) { if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
log_error!("Falha ao abrir janela de chat: {e}"); log_error!("Falha ao abrir janela de chat: {e}");
} }

View file

@ -1,5 +1,3 @@
#![cfg(target_os = "windows")]
use crate::RustdeskProvisioningResult; use crate::RustdeskProvisioningResult;
use chrono::{Local, Utc}; use chrono::{Local, Utc};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
@ -30,7 +28,9 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\
const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config"; const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config";
const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados"; const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados";
const MACHINE_STORE_FILENAME: &str = "machine-agent.json"; const MACHINE_STORE_FILENAME: &str = "machine-agent.json";
#[allow(dead_code)]
const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag"; const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag";
#[allow(dead_code)]
const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt"; const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt";
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password"; const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
const SECURITY_APPROVE_MODE_VALUE: &str = "password"; const SECURITY_APPROVE_MODE_VALUE: &str = "password";
@ -85,11 +85,11 @@ fn define_custom_id_from_machine(exe_path: &Path, machine_id: Option<&str>) -> O
}) { }) {
match set_custom_id(exe_path, value) { match set_custom_id(exe_path, value) {
Ok(custom) => { Ok(custom) => {
log_event(&format!("ID determinístico definido: {custom}")); log_event(format!("ID determinístico definido: {custom}"));
Some(custom) Some(custom)
} }
Err(error) => { Err(error) => {
log_event(&format!("Falha ao definir ID determinístico: {error}")); log_event(format!("Falha ao definir ID determinístico: {error}"));
None None
} }
} }
@ -107,7 +107,7 @@ pub fn ensure_rustdesk(
log_event("Iniciando preparo do RustDesk"); log_event("Iniciando preparo do RustDesk");
if let Err(error) = ensure_service_profiles_writable_preflight() { if let Err(error) = ensure_service_profiles_writable_preflight() {
log_event(&format!( log_event(format!(
"Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha." "Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha."
)); ));
} }
@ -116,7 +116,7 @@ pub fn ensure_rustdesk(
// Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece // Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece
let preserved_remote_id = read_remote_id_from_profiles(); let preserved_remote_id = read_remote_id_from_profiles();
if let Some(ref id) = preserved_remote_id { if let Some(ref id) = preserved_remote_id {
log_event(&format!("ID existente preservado antes da limpeza: {}", id)); log_event(format!("ID existente preservado antes da limpeza: {}", id));
} }
let exe_path = detect_executable_path(); let exe_path = detect_executable_path();
@ -129,7 +129,7 @@ pub fn ensure_rustdesk(
match stop_rustdesk_processes() { match stop_rustdesk_processes() {
Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"), Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"),
Err(error) => log_event(&format!( Err(error) => log_event(format!(
"Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})" "Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})"
)), )),
} }
@ -139,7 +139,7 @@ pub fn ensure_rustdesk(
if freshly_installed { if freshly_installed {
match purge_existing_rustdesk_profiles() { match purge_existing_rustdesk_profiles() {
Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"), Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"),
Err(error) => log_event(&format!( Err(error) => log_event(format!(
"Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})" "Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})"
)), )),
} }
@ -152,19 +152,19 @@ pub fn ensure_rustdesk(
if trimmed.is_empty() { None } else { Some(trimmed) } if trimmed.is_empty() { None } else { Some(trimmed) }
}) { }) {
if let Err(error) = run_with_args(&exe_path, &["--config", value]) { if let Err(error) = run_with_args(&exe_path, &["--config", value]) {
log_event(&format!("Falha ao aplicar configuração inline: {error}")); log_event(format!("Falha ao aplicar configuração inline: {error}"));
} else { } else {
log_event("Configuração aplicada via --config"); log_event("Configuração aplicada via --config");
} }
} else { } else {
let config_path = write_config_files()?; let config_path = write_config_files()?;
log_event(&format!( log_event(format!(
"Arquivo de configuração atualizado em {}", "Arquivo de configuração atualizado em {}",
config_path.display() config_path.display()
)); ));
if let Err(error) = apply_config(&exe_path, &config_path) { if let Err(error) = apply_config(&exe_path, &config_path) {
log_event(&format!("Falha ao aplicar configuração via CLI: {error}")); log_event(format!("Falha ao aplicar configuração via CLI: {error}"));
} else { } else {
log_event("Configuração aplicada via CLI"); log_event("Configuração aplicada via CLI");
} }
@ -176,7 +176,7 @@ pub fn ensure_rustdesk(
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); .unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
if let Err(error) = set_password(&exe_path, &password) { if let Err(error) = set_password(&exe_path, &password) {
log_event(&format!("Falha ao definir senha padrão: {error}")); log_event(format!("Falha ao definir senha padrão: {error}"));
} else { } else {
log_event("Senha padrão definida com sucesso"); log_event("Senha padrão definida com sucesso");
log_event("Aplicando senha nos perfis do RustDesk"); log_event("Aplicando senha nos perfis do RustDesk");
@ -185,21 +185,21 @@ pub fn ensure_rustdesk(
log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk"); log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk");
log_password_replication(&password); log_password_replication(&password);
} }
Err(error) => log_event(&format!("Falha ao persistir senha nos perfis: {error}")), Err(error) => log_event(format!("Falha ao persistir senha nos perfis: {error}")),
} }
match propagate_password_profile() { match propagate_password_profile() {
Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"), Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"),
Err(error) => log_event(&format!("Falha ao copiar perfil de senha: {error}")), Err(error) => log_event(format!("Falha ao copiar perfil de senha: {error}")),
} }
match replicate_password_artifacts() { match replicate_password_artifacts() {
Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"), Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"),
Err(error) => log_event(&format!("Falha ao replicar artefatos de senha: {error}")), Err(error) => log_event(format!("Falha ao replicar artefatos de senha: {error}")),
} }
if let Err(error) = enforce_security_flags() { if let Err(error) = enforce_security_flags() {
log_event(&format!("Falha ao reforçar configuração de senha permanente: {error}")); log_event(format!("Falha ao reforçar configuração de senha permanente: {error}"));
} }
} }
@ -207,7 +207,7 @@ pub fn ensure_rustdesk(
// Isso garante que reinstalar o Raven nao muda o ID do RustDesk // Isso garante que reinstalar o Raven nao muda o ID do RustDesk
let custom_id = if let Some(ref existing_id) = preserved_remote_id { let custom_id = if let Some(ref existing_id) = preserved_remote_id {
if !freshly_installed { if !freshly_installed {
log_event(&format!("Reutilizando ID existente do RustDesk: {}", existing_id)); log_event(format!("Reutilizando ID existente do RustDesk: {}", existing_id));
Some(existing_id.clone()) Some(existing_id.clone())
} else { } else {
// Instalacao fresca - define novo ID baseado no machine_id // Instalacao fresca - define novo ID baseado no machine_id
@ -219,7 +219,7 @@ pub fn ensure_rustdesk(
}; };
if let Err(error) = ensure_service_running(&exe_path) { if let Err(error) = ensure_service_running(&exe_path) {
log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}")); log_event(format!("Falha ao reiniciar serviço do RustDesk: {error}"));
} else { } else {
log_event("Serviço RustDesk reiniciado/run ativo"); log_event("Serviço RustDesk reiniciado/run ativo");
} }
@ -227,10 +227,10 @@ pub fn ensure_rustdesk(
let reported_id = match query_id_with_retries(&exe_path, 5) { let reported_id = match query_id_with_retries(&exe_path, 5) {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}")); log_event(format!("Falha ao obter ID após múltiplas tentativas: {error}"));
match read_remote_id_from_profiles().or_else(|| custom_id.clone()) { match read_remote_id_from_profiles().or_else(|| custom_id.clone()) {
Some(value) => { Some(value) => {
log_event(&format!("ID obtido via arquivos de perfil: {value}")); log_event(format!("ID obtido via arquivos de perfil: {value}"));
value value
} }
None => return Err(error), None => return Err(error),
@ -242,7 +242,7 @@ pub fn ensure_rustdesk(
if let Some(expected) = custom_id.as_ref() { if let Some(expected) = custom_id.as_ref() {
if expected != &reported_id { if expected != &reported_id {
log_event(&format!( log_event(format!(
"ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico" "ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico"
)); ));
@ -252,25 +252,25 @@ pub fn ensure_rustdesk(
Ok(_) => match query_id_with_retries(&exe_path, 3) { Ok(_) => match query_id_with_retries(&exe_path, 3) {
Ok(rechecked) => { Ok(rechecked) => {
if &rechecked == expected { if &rechecked == expected {
log_event(&format!("ID determinístico aplicado com sucesso: {rechecked}")); log_event(format!("ID determinístico aplicado com sucesso: {rechecked}"));
final_id = rechecked; final_id = rechecked;
enforced = true; enforced = true;
} else { } else {
log_event(&format!( log_event(format!(
"ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado" "ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado"
)); ));
final_id = rechecked; final_id = rechecked;
} }
} }
Err(error) => { Err(error) => {
log_event(&format!( log_event(format!(
"Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})" "Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})"
)); ));
final_id = reported_id.clone(); final_id = reported_id.clone();
} }
}, },
Err(error) => { Err(error) => {
log_event(&format!( log_event(format!(
"Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})" "Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})"
)); ));
final_id = reported_id.clone(); final_id = reported_id.clone();
@ -308,7 +308,7 @@ pub fn ensure_rustdesk(
"lastError": serde_json::Value::Null "lastError": serde_json::Value::Null
}); });
if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) { if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) {
log_event(&format!("Aviso: falha ao salvar dados do RustDesk no store: {error}")); log_event(format!("Aviso: falha ao salvar dados do RustDesk no store: {error}"));
} else { } else {
log_event("Dados do RustDesk salvos no machine-agent.json"); log_event("Dados do RustDesk salvos no machine-agent.json");
} }
@ -316,7 +316,7 @@ pub fn ensure_rustdesk(
// Sincroniza com o backend imediatamente apos provisionar // Sincroniza com o backend imediatamente apos provisionar
// O Rust faz o HTTP direto, sem passar pelo CSP do webview // O Rust faz o HTTP direto, sem passar pelo CSP do webview
if let Err(error) = sync_remote_access_with_backend(&result) { if let Err(error) = sync_remote_access_with_backend(&result) {
log_event(&format!("Aviso: falha ao sincronizar com backend: {error}")); log_event(format!("Aviso: falha ao sincronizar com backend: {error}"));
} else { } else {
log_event("Acesso remoto sincronizado com backend"); log_event("Acesso remoto sincronizado com backend");
// Atualiza lastSyncedAt no store // Atualiza lastSyncedAt no store
@ -330,13 +330,13 @@ pub fn ensure_rustdesk(
"lastError": serde_json::Value::Null "lastError": serde_json::Value::Null
}); });
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) { if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}")); log_event(format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
} else { } else {
log_event("lastSyncedAt atualizado com sucesso"); log_event("lastSyncedAt atualizado com sucesso");
} }
} }
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version)); log_event(format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
Ok(result) Ok(result)
} }
@ -403,7 +403,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
let config_contents = build_config_contents(); let config_contents = build_config_contents();
let main_path = program_data_config_dir().join("RustDesk2.toml"); let main_path = program_data_config_dir().join("RustDesk2.toml");
write_file(&main_path, &config_contents)?; write_file(&main_path, &config_contents)?;
log_event(&format!( log_event(format!(
"Config principal gravada em {}", "Config principal gravada em {}",
main_path.display() main_path.display()
)); ));
@ -412,7 +412,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
for service_dir in service_profile_dirs() { for service_dir in service_profile_dirs() {
let service_profile = service_dir.join("RustDesk2.toml"); let service_profile = service_dir.join("RustDesk2.toml");
if let Err(error) = write_file(&service_profile, &config_contents) { if let Err(error) = write_file(&service_profile, &config_contents) {
log_event(&format!( log_event(format!(
"Falha ao gravar config no perfil do serviço ({}): {error}", "Falha ao gravar config no perfil do serviço ({}): {error}",
service_profile.display() service_profile.display()
)); ));
@ -421,7 +421,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") { if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") {
if let Err(error) = write_file(&appdata_path, &config_contents) { if let Err(error) = write_file(&appdata_path, &config_contents) {
log_event(&format!( log_event(format!(
"Falha ao atualizar config no AppData do usuário: {error}" "Falha ao atualizar config no AppData do usuário: {error}"
)); ));
} }
@ -516,7 +516,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
ensure_service_installed(exe_path)?; ensure_service_installed(exe_path)?;
if let Err(error) = configure_service_startup() { if let Err(error) = configure_service_startup() {
log_event(&format!( log_event(format!(
"Aviso: não foi possível reforçar autostart/recuperação do serviço RustDesk: {error}" "Aviso: não foi possível reforçar autostart/recuperação do serviço RustDesk: {error}"
)); ));
} }
@ -553,7 +553,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
let _ = run_with_args(exe_path, &["--install-service"]); let _ = run_with_args(exe_path, &["--install-service"]);
let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]); let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]);
if let Err(error) = start_sequence() { if let Err(error) = start_sequence() {
log_event(&format!( log_event(format!(
"Falha ao subir o serviço RustDesk mesmo após reinstalação: {error}" "Falha ao subir o serviço RustDesk mesmo após reinstalação: {error}"
)); ));
} }
@ -631,8 +631,8 @@ fn remove_rustdesk_autorun_artifacts() {
for path in startup_paths { for path in startup_paths {
if path.exists() { if path.exists() {
match fs::remove_file(&path) { match fs::remove_file(&path) {
Ok(_) => log_event(&format!("Atalho de inicialização do RustDesk removido: {}", path.display())), Ok(_) => log_event(format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
Err(error) => log_event(&format!( Err(error) => log_event(format!(
"Falha ao remover atalho de inicialização do RustDesk ({}): {}", "Falha ao remover atalho de inicialização do RustDesk ({}): {}",
path.display(), path.display(),
error error
@ -650,7 +650,7 @@ fn remove_rustdesk_autorun_artifacts() {
.status(); .status();
if let Ok(code) = status { if let Ok(code) = status {
if code.success() { if code.success() {
log_event(&format!("Entrada de auto-run RustDesk removida de {}", reg_path)); log_event(format!("Entrada de auto-run RustDesk removida de {}", reg_path));
} }
} }
} }
@ -658,7 +658,7 @@ fn remove_rustdesk_autorun_artifacts() {
fn stop_rustdesk_processes() -> Result<(), RustdeskError> { fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
if let Err(error) = try_stop_service() { if let Err(error) = try_stop_service() {
log_event(&format!( log_event(format!(
"Não foi possível parar o serviço RustDesk antes da sincronização: {error}" "Não foi possível parar o serviço RustDesk antes da sincronização: {error}"
)); ));
} }
@ -774,12 +774,12 @@ fn ensure_remote_id_files(id: &str) {
for dir in remote_id_directories() { for dir in remote_id_directories() {
let path = dir.join("RustDesk_local.toml"); let path = dir.join("RustDesk_local.toml");
match write_remote_id_value(&path, id) { match write_remote_id_value(&path, id) {
Ok(_) => log_event(&format!( Ok(_) => log_event(format!(
"remote_id atualizado para {} em {}", "remote_id atualizado para {} em {}",
id, id,
path.display() path.display()
)), )),
Err(error) => log_event(&format!( Err(error) => log_event(format!(
"Falha ao atualizar remote_id em {}: {error}", "Falha ao atualizar remote_id em {}: {error}",
path.display() path.display()
)), )),
@ -821,7 +821,7 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
if let Err(error) = write_toml_kv(&password_path, "password", secret) { if let Err(error) = write_toml_kv(&password_path, "password", secret) {
errors.push(format!("{} -> {}", password_path.display(), error)); errors.push(format!("{} -> {}", password_path.display(), error));
} else { } else {
log_event(&format!( log_event(format!(
"Senha escrita via fallback em {}", "Senha escrita via fallback em {}",
password_path.display() password_path.display()
)); ));
@ -829,12 +829,12 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
let local_path = dir.join("RustDesk_local.toml"); let local_path = dir.join("RustDesk_local.toml");
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) { if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
log_event(&format!( log_event(format!(
"Falha ao ajustar verification-method em {}: {error}", "Falha ao ajustar verification-method em {}: {error}",
local_path.display() local_path.display()
)); ));
} else { } else {
log_event(&format!( log_event(format!(
"verification-method atualizado para {} em {}", "verification-method atualizado para {} em {}",
SECURITY_VERIFICATION_VALUE, SECURITY_VERIFICATION_VALUE,
local_path.display() local_path.display()
@ -843,19 +843,19 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
let rustdesk2_path = dir.join("RustDesk2.toml"); let rustdesk2_path = dir.join("RustDesk2.toml");
if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) { if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) {
log_event(&format!( log_event(format!(
"Falha ao ajustar flags no RustDesk2.toml em {}: {error}", "Falha ao ajustar flags no RustDesk2.toml em {}: {error}",
rustdesk2_path.display() rustdesk2_path.display()
)); ));
} }
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) { if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
log_event(&format!( log_event(format!(
"Falha ao ajustar approve-mode em {}: {error}", "Falha ao ajustar approve-mode em {}: {error}",
local_path.display() local_path.display()
)); ));
} else { } else {
log_event(&format!( log_event(format!(
"approve-mode atualizado para {} em {}", "approve-mode atualizado para {} em {}",
SECURITY_APPROVE_MODE_VALUE, SECURITY_APPROVE_MODE_VALUE,
local_path.display() local_path.display()
@ -877,7 +877,7 @@ fn enforce_security_flags() -> Result<(), String> {
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) { if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
errors.push(format!("{} -> {}", local_path.display(), error)); errors.push(format!("{} -> {}", local_path.display(), error));
} else { } else {
log_event(&format!( log_event(format!(
"verification-method atualizado para {} em {}", "verification-method atualizado para {} em {}",
SECURITY_VERIFICATION_VALUE, SECURITY_VERIFICATION_VALUE,
local_path.display() local_path.display()
@ -887,7 +887,7 @@ fn enforce_security_flags() -> Result<(), String> {
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) { if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
errors.push(format!("{} -> {}", local_path.display(), error)); errors.push(format!("{} -> {}", local_path.display(), error));
} else { } else {
log_event(&format!( log_event(format!(
"approve-mode atualizado para {} em {}", "approve-mode atualizado para {} em {}",
SECURITY_APPROVE_MODE_VALUE, SECURITY_APPROVE_MODE_VALUE,
local_path.display() local_path.display()
@ -921,7 +921,7 @@ fn propagate_password_profile() -> io::Result<bool> {
if !src_path.exists() { if !src_path.exists() {
continue; continue;
} }
log_event(&format!( log_event(format!(
"Copiando {} para ProgramData/serviços", "Copiando {} para ProgramData/serviços",
src_path.display() src_path.display()
)); ));
@ -929,7 +929,7 @@ fn propagate_password_profile() -> io::Result<bool> {
for dest_root in propagation_destinations() { for dest_root in propagation_destinations() {
let target_path = dest_root.join(filename); let target_path = dest_root.join(filename);
copy_overwrite(&src_path, &target_path)?; copy_overwrite(&src_path, &target_path)?;
log_event(&format!( log_event(format!(
"{} propagado para {}", "{} propagado para {}",
filename, filename,
target_path.display() target_path.display()
@ -969,7 +969,7 @@ fn replicate_password_artifacts() -> io::Result<()> {
let target_path = dest.join(name); let target_path = dest.join(name);
copy_overwrite(&source_path, &target_path)?; copy_overwrite(&source_path, &target_path)?;
log_event(&format!( log_event(format!(
"Artefato de senha {name} replicado para {}", "Artefato de senha {name} replicado para {}",
target_path.display() target_path.display()
)); ));
@ -981,13 +981,11 @@ fn replicate_password_artifacts() -> io::Result<()> {
fn purge_existing_rustdesk_profiles() -> Result<(), String> { fn purge_existing_rustdesk_profiles() -> Result<(), String> {
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut cleaned_any = false;
for dir in remote_id_directories() { for dir in remote_id_directories() {
match purge_config_dir(&dir) { match purge_config_dir(&dir) {
Ok(true) => { Ok(true) => {
cleaned_any = true; log_event(format!(
log_event(&format!(
"Perfis antigos removidos em {}", "Perfis antigos removidos em {}",
dir.display() dir.display()
)); ));
@ -997,9 +995,7 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> {
} }
} }
if cleaned_any { if errors.is_empty() {
Ok(())
} else if errors.is_empty() {
Ok(()) Ok(())
} else { } else {
Err(errors.join(" | ")) Err(errors.join(" | "))
@ -1030,6 +1026,7 @@ fn purge_config_dir(dir: &Path) -> Result<bool, io::Error> {
Ok(removed) Ok(removed)
} }
#[allow(dead_code)]
fn run_powershell_elevated(script: &str) -> Result<(), String> { fn run_powershell_elevated(script: &str) -> Result<(), String> {
let temp_dir = env::temp_dir(); let temp_dir = env::temp_dir();
let payload = temp_dir.join("raven_payload.ps1"); let payload = temp_dir.join("raven_payload.ps1");
@ -1077,6 +1074,7 @@ exit $process.ExitCode
Err(format!("elevated ps exit {:?}", status.code())) Err(format!("elevated ps exit {:?}", status.code()))
} }
#[allow(dead_code)]
fn fix_profile_acl(target: &Path) -> Result<(), String> { fn fix_profile_acl(target: &Path) -> Result<(), String> {
let target_str = target.display().to_string(); let target_str = target.display().to_string();
let transcript = env::temp_dir().join("raven_acl_ps.log"); let transcript = env::temp_dir().join("raven_acl_ps.log");
@ -1111,7 +1109,7 @@ try {{
let result = run_powershell_elevated(&script); let result = run_powershell_elevated(&script);
if result.is_err() { if result.is_err() {
if let Ok(content) = fs::read_to_string(&transcript) { if let Ok(content) = fs::read_to_string(&transcript) {
log_event(&format!( log_event(format!(
"ACL transcript para {}:\n{}", "ACL transcript para {}:\n{}",
target.display(), content target.display(), content
)); ));
@ -1122,6 +1120,9 @@ try {{
} }
fn ensure_service_profiles_writable_preflight() -> Result<(), String> { fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
// Verificamos se os diretorios de perfil sao graváveis
// Se nao forem, apenas logamos aviso - o Raven Service deve lidar com isso
// Nao usamos elevacao para evitar UAC adicional
let mut blocked_dirs = Vec::new(); let mut blocked_dirs = Vec::new();
for dir in service_profile_dirs() { for dir in service_profile_dirs() {
if !can_write_dir(&dir) { if !can_write_dir(&dir) {
@ -1133,53 +1134,46 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
return Ok(()); return Ok(());
} }
if has_acl_unlock_flag() { // Apenas logamos aviso - o serviço RavenService deve lidar com permissões
log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL"); log_event(format!(
} else { "Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.",
log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)"); blocked_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()
} ));
let mut last_error: Option<String> = None; // Retornamos Ok para não bloquear o fluxo
for dir in blocked_dirs.iter() { // O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios
log_event(&format!(
"Tentando corrigir ACL via UAC (preflight) em {}...",
dir.display()
));
if let Err(error) = fix_profile_acl(dir) {
last_error = Some(error);
continue;
}
if can_write_dir(dir) {
log_event(&format!(
"ACL ajustada com sucesso em {}",
dir.display()
));
} else {
last_error = Some(format!(
"continua sem permissão para {} mesmo após preflight",
dir.display()
));
}
}
if blocked_dirs.iter().all(|dir| can_write_dir(dir)) {
mark_acl_unlock_flag();
Ok(()) Ok(())
} else {
Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into()))
}
} }
fn stop_service_elevated() -> Result<(), String> { fn stop_service_elevated() -> Result<(), String> {
let script = r#" // Tentamos parar o serviço RustDesk sem elevação
$ErrorActionPreference='Stop' // Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso
$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue // Não usamos elevação para evitar UAC adicional
if ($service -and $service.Status -ne 'Stopped') { let output = Command::new("sc")
Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop .args(["stop", "RustDesk"])
$service.WaitForStatus('Stopped','00:00:10') .output();
}
"#; match output {
run_powershell_elevated(script) Ok(result) => {
if result.status.success() {
// Aguarda um pouco para o serviço parar
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
log_event(format!(
"Aviso: não foi possível parar o serviço RustDesk sem elevação: {}",
stderr.trim()
));
// Retornamos Ok para não bloquear - o serviço pode estar já parado
Ok(())
}
}
Err(e) => {
log_event(format!("Aviso: falha ao executar sc stop RustDesk: {e}"));
Ok(())
}
}
} }
fn can_write_dir(dir: &Path) -> bool { fn can_write_dir(dir: &Path) -> bool {
@ -1339,21 +1333,21 @@ fn log_password_replication(secret: &str) {
fn log_password_match(path: &Path, secret: &str) { fn log_password_match(path: &Path, secret: &str) {
match read_password_from_file(path) { match read_password_from_file(path) {
Some(value) if value == secret => { Some(value) if value == secret => {
log_event(&format!( log_event(format!(
"Senha confirmada em {} ({})", "Senha confirmada em {} ({})",
path.display(), path.display(),
mask_secret(&value) mask_secret(&value)
)); ));
} }
Some(value) => { Some(value) => {
log_event(&format!( log_event(format!(
"Aviso: senha divergente ({}) em {}", "Aviso: senha divergente ({}) em {}",
mask_secret(&value), mask_secret(&value),
path.display() path.display()
)); ));
} }
None => { None => {
log_event(&format!( log_event(format!(
"Aviso: chave 'password' não encontrada em {}", "Aviso: chave 'password' não encontrada em {}",
path.display() path.display()
)); ));
@ -1469,21 +1463,24 @@ fn write_machine_store_object(map: JsonMap<String, JsonValue>) -> Result<(), Str
} }
fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> { fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> {
let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new); let mut map = read_machine_store_object().unwrap_or_default();
map.insert(key.to_string(), value); map.insert(key.to_string(), value);
write_machine_store_object(map) write_machine_store_object(map)
} }
#[allow(dead_code)]
fn machine_store_key_exists(key: &str) -> bool { fn machine_store_key_exists(key: &str) -> bool {
read_machine_store_object() read_machine_store_object()
.map(|map| map.contains_key(key)) .map(|map| map.contains_key(key))
.unwrap_or(false) .unwrap_or(false)
} }
#[allow(dead_code)]
fn acl_flag_file_path() -> Option<PathBuf> { fn acl_flag_file_path() -> Option<PathBuf> {
raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME)) raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME))
} }
#[allow(dead_code)]
fn has_acl_unlock_flag() -> bool { fn has_acl_unlock_flag() -> bool {
if let Some(flag) = acl_flag_file_path() { if let Some(flag) = acl_flag_file_path() {
if flag.exists() { if flag.exists() {
@ -1493,6 +1490,7 @@ fn has_acl_unlock_flag() -> bool {
machine_store_key_exists(RUSTDESK_ACL_STORE_KEY) machine_store_key_exists(RUSTDESK_ACL_STORE_KEY)
} }
#[allow(dead_code)]
fn mark_acl_unlock_flag() { fn mark_acl_unlock_flag() {
let timestamp = Utc::now().timestamp_millis(); let timestamp = Utc::now().timestamp_millis();
if let Some(flag_path) = acl_flag_file_path() { if let Some(flag_path) = acl_flag_file_path() {
@ -1500,7 +1498,7 @@ fn mark_acl_unlock_flag() {
let _ = fs::create_dir_all(parent); let _ = fs::create_dir_all(parent);
} }
if let Err(error) = fs::write(&flag_path, timestamp.to_string()) { if let Err(error) = fs::write(&flag_path, timestamp.to_string()) {
log_event(&format!( log_event(format!(
"Falha ao gravar flag de ACL em {}: {error}", "Falha ao gravar flag de ACL em {}: {error}",
flag_path.display() flag_path.display()
)); ));
@ -1508,7 +1506,7 @@ fn mark_acl_unlock_flag() {
} }
if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) { if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) {
log_event(&format!( log_event(format!(
"Falha ao registrar flag de ACL no machine-agent: {error}" "Falha ao registrar flag de ACL no machine-agent: {error}"
)); ));
} }
@ -1547,7 +1545,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("https://tickets.esdrasrenan.com.br"); .unwrap_or("https://tickets.esdrasrenan.com.br");
log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id)); log_event(format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
// Monta payload conforme schema esperado pelo backend // Monta payload conforme schema esperado pelo backend
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? } // Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
@ -1575,13 +1573,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
.send()?; .send()?;
if response.status().is_success() { if response.status().is_success() {
log_event(&format!("Sync com backend OK: status {}", response.status())); log_event(format!("Sync com backend OK: status {}", response.status()));
Ok(()) Ok(())
} else { } else {
let status = response.status(); let status = response.status();
let body = response.text().unwrap_or_default(); let body = response.text().unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body }; let body_preview = if body.len() > 200 { &body[..200] } else { &body };
log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview)); log_event(format!("Sync com backend falhou: {} - {}", status, body_preview));
Err(RustdeskError::CommandFailed { Err(RustdeskError::CommandFailed {
command: "sync_remote_access".to_string(), command: "sync_remote_access".to_string(),
status: Some(status.as_u16() as i32) status: Some(status.as_u16() as i32)

View file

@ -0,0 +1,244 @@
//! Cliente IPC para comunicacao com o Raven Service
//!
//! Este modulo permite que o app Tauri se comunique com o Raven Service
//! via Named Pipes para executar operacoes privilegiadas.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use thiserror::Error;
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
#[derive(Debug, Error)]
pub enum ServiceClientError {
#[error("Servico nao disponivel: {0}")]
ServiceUnavailable(String),
#[error("Erro de comunicacao: {0}")]
CommunicationError(String),
#[error("Erro de serializacao: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Erro do servico: {message} (code: {code})")]
ServiceError { code: i32, message: String },
#[error("Timeout aguardando resposta")]
Timeout,
}
#[derive(Debug, Serialize)]
struct Request {
id: String,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct Response {
id: String,
result: Option<serde_json::Value>,
error: Option<ErrorResponse>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
code: i32,
message: String,
}
// =============================================================================
// Tipos de Resultado
// =============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbPolicyResult {
pub success: bool,
pub policy: String,
pub error: Option<String>,
pub applied_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskStatus {
pub installed: bool,
pub running: bool,
pub id: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckResult {
pub status: String,
pub service: String,
pub version: String,
pub timestamp: i64,
}
// =============================================================================
// Cliente
// =============================================================================
/// Verifica se o servico esta disponivel
pub fn is_service_available() -> bool {
health_check().is_ok()
}
/// Verifica saude do servico
pub fn health_check() -> Result<HealthCheckResult, ServiceClientError> {
let response = call_service("health_check", serde_json::json!({}))?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Aplica politica de USB
pub fn apply_usb_policy(policy: &str) -> Result<UsbPolicyResult, ServiceClientError> {
let response = call_service(
"apply_usb_policy",
serde_json::json!({ "policy": policy }),
)?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Obtem politica de USB atual
pub fn get_usb_policy() -> Result<String, ServiceClientError> {
let response = call_service("get_usb_policy", serde_json::json!({}))?;
response
.get("policy")
.and_then(|p| p.as_str())
.map(String::from)
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta invalida".into()))
}
/// Provisiona RustDesk
pub fn provision_rustdesk(
config: Option<&str>,
password: Option<&str>,
machine_id: Option<&str>,
) -> Result<RustdeskResult, ServiceClientError> {
let params = serde_json::json!({
"config": config,
"password": password,
"machineId": machine_id,
});
let response = call_service("provision_rustdesk", params)?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Obtem status do RustDesk
pub fn get_rustdesk_status() -> Result<RustdeskStatus, ServiceClientError> {
let response = call_service("get_rustdesk_status", serde_json::json!({}))?;
serde_json::from_value(response).map_err(|e| e.into())
}
// =============================================================================
// Comunicacao IPC
// =============================================================================
fn call_service(
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, ServiceClientError> {
// Gera ID unico para a requisicao
let id = uuid::Uuid::new_v4().to_string();
let request = Request {
id: id.clone(),
method: method.to_string(),
params,
};
// Serializa requisicao
let request_json = serde_json::to_string(&request)?;
// Conecta ao pipe
let mut pipe = connect_to_pipe()?;
// Envia requisicao
writeln!(pipe, "{}", request_json).map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao enviar requisicao: {}", e))
})?;
pipe.flush().map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao flush: {}", e))
})?;
// Le resposta
let mut reader = BufReader::new(pipe);
let mut response_line = String::new();
reader.read_line(&mut response_line).map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao ler resposta: {}", e))
})?;
// Parse da resposta
let response: Response = serde_json::from_str(&response_line)?;
// Verifica se o ID bate
if response.id != id {
return Err(ServiceClientError::CommunicationError(
"ID de resposta nao corresponde".into(),
));
}
// Verifica erro
if let Some(error) = response.error {
return Err(ServiceClientError::ServiceError {
code: error.code,
message: error.message,
});
}
// Retorna resultado
response
.result
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta sem resultado".into()))
}
#[cfg(target_os = "windows")]
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
// Tenta conectar ao pipe com retry
let mut attempts = 0;
let max_attempts = 3;
loop {
match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(PIPE_NAME)
{
Ok(file) => return Ok(file),
Err(e) => {
attempts += 1;
if attempts >= max_attempts {
return Err(ServiceClientError::ServiceUnavailable(format!(
"Nao foi possivel conectar ao servico apos {} tentativas: {}",
max_attempts, e
)));
}
std::thread::sleep(Duration::from_millis(500));
}
}
}
}
#[cfg(not(target_os = "windows"))]
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
Err(ServiceClientError::ServiceUnavailable(
"Named Pipes so estao disponiveis no Windows".into(),
))
}

View file

@ -93,23 +93,11 @@ mod windows_impl {
applied_at: Some(now), applied_at: Some(now),
}), }),
Err(err) => { Err(err) => {
// Tenta elevação se faltou permissão // Se faltou permissão, retorna erro - o serviço deve ser usado
// Não fazemos elevação aqui para evitar UAC adicional
if is_permission_error(&err) { if is_permission_error(&err) {
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
return Err(elevated_err);
}
// Revalida a policy após elevação
let current = get_current_policy()?;
if current != policy {
return Err(UsbControlError::PermissionDenied); return Err(UsbControlError::PermissionDenied);
} }
return Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
});
}
Err(err) Err(err)
} }
} }
@ -219,11 +207,9 @@ mod windows_impl {
key.set_value("WriteProtect", &1u32) key.set_value("WriteProtect", &1u32)
.map_err(map_winreg_error)?; .map_err(map_winreg_error)?;
} else { } else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32); let _ = key.set_value("WriteProtect", &0u32);
} }
}
Ok(()) Ok(())
} }
@ -269,6 +255,7 @@ mod windows_impl {
} }
} }
#[allow(dead_code)]
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> { fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
// Cria script temporário para aplicar as chaves via PowerShell elevado // Cria script temporário para aplicar as chaves via PowerShell elevado
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
@ -321,7 +308,7 @@ try {{
policy = policy_str policy = policy_str
); );
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?; fs::write(&script_path, script).map_err(UsbControlError::Io)?;
// Start-Process com RunAs para acionar UAC // Start-Process com RunAs para acionar UAC
let arg = format!( let arg = format!(
@ -333,7 +320,7 @@ try {{
.arg("-Command") .arg("-Command")
.arg(arg) .arg(arg)
.status() .status()
.map_err(|e| UsbControlError::Io(e))?; .map_err(UsbControlError::Io)?;
if !status.success() { if !status.success() {
return Err(UsbControlError::PermissionDenied); return Err(UsbControlError::PermissionDenied);
@ -362,7 +349,7 @@ try {{
.args(["/target:computer", "/force"]) .args(["/target:computer", "/force"])
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.output() .output()
.map_err(|e| UsbControlError::Io(e))?; .map_err(UsbControlError::Io)?;
if !output.status.success() { if !output.status.success() {
// Nao e critico se falhar, apenas log // Nao e critico se falhar, apenas log

View file

@ -50,6 +50,9 @@
"icons/icon.png", "icons/icon.png",
"icons/Raven.png" "icons/Raven.png"
], ],
"resources": {
"../service/target/release/raven-service.exe": "raven-service.exe"
},
"windows": { "windows": {
"webviewInstallMode": { "webviewInstallMode": {
"type": "skip" "type": "skip"

View file

@ -0,0 +1,256 @@
/**
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
*
* Arquitetura:
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
* - Tauri usado apenas para gerenciamento de janelas
*/
import { useEffect, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
/**
* Hub Widget - Lista todas as sessoes de chat ativas
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
*/
export function ChatHubWidget() {
// Inicializa baseado na altura real da janela (< 100px = minimizado)
const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100)
// Convex subscription reativa
const { sessions = [], isLoading, hasToken } = useMachineSessions()
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
const mountTime = Date.now()
const STABILIZATION_DELAY = 500
const handler = () => {
if (Date.now() - mountTime < STABILIZATION_DELAY) {
return
}
const h = window.innerHeight
setIsMinimized(h < 100)
}
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
try {
// Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS)
await invoke("open_chat_window", { ticketId, ticketRef })
await invoke("close_hub_window")
} catch (err) {
console.error("open_chat_window FAILED:", err)
}
}
const handleMinimize = async () => {
setIsMinimized(true)
try {
await invoke("set_hub_minimized", { minimized: true })
} catch (err) {
console.error("Erro ao minimizar hub:", err)
}
}
const handleExpand = async () => {
try {
await invoke("set_hub_minimized", { minimized: false })
setTimeout(() => setIsMinimized(false), 100)
} catch (err) {
console.error("set_hub_minimized FAILED:", err)
setIsMinimized(false)
}
}
const handleClose = () => {
invoke("close_hub_window").catch((err) => {
console.error("Erro ao fechar janela do hub:", err)
})
}
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
// Sem token
if (!hasToken) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Token nao configurado</span>
</div>
</div>
)
}
// Loading
if (isLoading) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Carregando...</span>
</div>
</div>
)
}
// Sem sessoes ativas
if (sessions.length === 0) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<MessageCircle className="size-4" />
<span className="text-sm font-medium">Sem chats</span>
</div>
</div>
)
}
// Minimizado
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
<button
onClick={(e) => {
e.stopPropagation()
handleExpand()
}}
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
{sessions.length} chat{sessions.length !== 1 ? "s" : ""}
</span>
<span className="size-2 rounded-full bg-emerald-400" />
<ChevronUp className="size-4" />
{totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread > 9 ? "9+" : totalUnread}
</span>
)}
</button>
</div>
)
}
// Expandido
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Header */}
<div
data-tauri-drag-region
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
aria-label="Minimizar lista de chats"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={handleClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
aria-label="Fechar lista de chats"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{sessions.map((session) => (
<SessionItem
key={session.sessionId}
session={session}
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
/>
))}
</div>
</div>
</div>
)
}
function SessionItem({
session,
onClick,
}: {
session: MachineSession
onClick: () => void
}) {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
onClick()
}
return (
<button
onClick={handleClick}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
>
{/* Avatar */}
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
{/* Indicador online */}
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium text-slate-900">
Ticket #{session.ticketRef}
</p>
<span className="shrink-0 text-xs text-slate-400">
{formatRelativeTime(session.lastActivityAt)}
</span>
</div>
<p className="truncate text-xs text-slate-500">
{session.agentName}
</p>
</div>
{/* Badge nao lidas */}
{session.unreadCount > 0 && (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
)}
</button>
)
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return "agora"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
/**
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
*
* Este provider inicializa o ConvexReactClient usando o token da maquina
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
*
* Arquitetura:
* - Carrega o token do Tauri Store na montagem
* - Inicializa o ConvexReactClient com a URL do Convex
* - Disponibiliza o cliente para componentes filhos via Context
* - Reconecta automaticamente quando o token muda
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { ConvexReactClient } from "convex/react"
import { getMachineStoreConfig } from "./machineStore"
// URL do Convex - em producao, usa o dominio personalizado
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineAuthState = {
token: string | null
apiBaseUrl: string | null
isLoading: boolean
error: string | null
}
type ConvexMachineContextValue = {
client: ConvexReactClient | null
machineToken: string | null
apiBaseUrl: string | null
isReady: boolean
error: string | null
reload: () => Promise<void>
}
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
export function useConvexMachine() {
const ctx = useContext(ConvexMachineContext)
if (!ctx) {
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
}
return ctx
}
export function useMachineToken() {
const { machineToken } = useConvexMachine()
return machineToken
}
interface ConvexMachineProviderProps {
children: ReactNode
}
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
const [authState, setAuthState] = useState<MachineAuthState>({
token: null,
apiBaseUrl: null,
isLoading: true,
error: null,
})
const [client, setClient] = useState<ConvexReactClient | null>(null)
// Funcao para carregar configuracao do Tauri Store
const loadConfig = async () => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
try {
const config = await getMachineStoreConfig()
if (!config.token) {
setAuthState({
token: null,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: "Token da maquina nao encontrado",
})
return
}
setAuthState({
token: config.token,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: null,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setAuthState({
token: null,
apiBaseUrl: null,
isLoading: false,
error: message || "Erro ao carregar configuracao",
})
}
}
// Carregar configuracao na montagem
useEffect(() => {
loadConfig()
}, [])
// Inicializar/reinicializar cliente Convex quando token muda
useEffect(() => {
if (!authState.token) {
// Limpar cliente se nao tem token
if (client) {
client.close()
setClient(null)
}
return
}
// Criar novo cliente Convex
const newClient = new ConvexReactClient(CONVEX_URL, {
// Desabilitar retry agressivo para evitar loops infinitos
unsavedChangesWarning: false,
})
setClient(newClient)
// Cleanup ao desmontar ou trocar token
return () => {
newClient.close()
}
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
const contextValue: ConvexMachineContextValue = {
client,
machineToken: authState.token,
apiBaseUrl: authState.apiBaseUrl,
isReady: !authState.isLoading && !!client && !!authState.token,
error: authState.error,
reload: loadConfig,
}
return (
<ConvexMachineContext.Provider value={contextValue}>
{children}
</ConvexMachineContext.Provider>
)
}

View file

@ -0,0 +1,41 @@
const AUDIO_MIME_CANDIDATES = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
]
const AUDIO_MIME_EXTENSION_MAP: Record<string, string> = {
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/mp4": "m4a",
"audio/mpeg": "mp3",
"audio/wav": "wav",
}
export function normalizeMimeType(mimeType: string) {
return mimeType.split(";")[0].trim().toLowerCase()
}
export function pickSupportedMimeType(isTypeSupported?: (mimeType: string) => boolean) {
const checker = isTypeSupported ?? (
typeof MediaRecorder === "undefined" ? undefined : MediaRecorder.isTypeSupported.bind(MediaRecorder)
)
if (!checker) return ""
for (const candidate of AUDIO_MIME_CANDIDATES) {
if (checker(candidate)) return candidate
}
return ""
}
export function buildAudioFileName(mimeType: string, now: Date = new Date()) {
const normalized = normalizeMimeType(mimeType)
const ext = AUDIO_MIME_EXTENSION_MAP[normalized] ?? "webm"
const timestamp = now.toISOString().replace(/[:.]/g, "-")
return `audio-${timestamp}.${ext}`
}

View file

@ -1,21 +1,65 @@
import { ConvexProvider } from "convex/react"
import { ChatWidget } from "./ChatWidget" import { ChatWidget } from "./ChatWidget"
import { ChatHubWidget } from "./ChatHubWidget"
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
import { Loader2 } from "lucide-react"
function ChatAppContent() {
const { client, isReady, error } = useConvexMachine()
export function ChatApp() {
// Obter ticketId e ticketRef da URL // Obter ticketId e ticketRef da URL
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const ticketId = params.get("ticketId") const ticketId = params.get("ticketId")
const ticketRef = params.get("ticketRef") const ticketRef = params.get("ticketRef")
const isHub = params.get("hub") === "true"
if (!ticketId) { // Aguardar cliente Convex estar pronto
if (!isReady || !client) {
if (error) {
return ( return (
<div className="flex h-screen flex-col items-center justify-center bg-white p-4"> <div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p> <div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Erro: {error}</span>
</div>
</div> </div>
) )
} }
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} /> return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Conectando...</span>
</div>
</div>
)
}
// Modo hub - lista de todas as sessoes
if (isHub || !ticketId) {
return (
<ConvexProvider client={client}>
<ChatHubWidget />
</ConvexProvider>
)
}
// Modo chat - conversa de um ticket especifico
return (
<ConvexProvider client={client}>
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
</ConvexProvider>
)
}
export function ChatApp() {
return (
<ConvexMachineProvider>
<ChatAppContent />
</ConvexMachineProvider>
)
} }
export { ChatWidget } export { ChatWidget }
export { ChatHubWidget }
export * from "./types" export * from "./types"

View file

@ -0,0 +1,253 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { buildAudioFileName, pickSupportedMimeType } from "./audio-recorder-utils"
type AudioRecorderPayload = {
file: File
durationSeconds: number
}
type AudioRecorderOptions = {
onAudioReady: (payload: AudioRecorderPayload) => Promise<void>
onError?: (message: string) => void
maxDurationSeconds?: number
maxFileSizeBytes?: number
audioBitsPerSecond?: number
levelBars?: number
}
type AudioRecorderState = {
isRecording: boolean
isProcessing: boolean
durationSeconds: number
levels: number[]
startRecording: () => Promise<void>
stopRecording: () => void
cancelRecording: () => void
}
export function useAudioRecorder(options: AudioRecorderOptions): AudioRecorderState {
const {
onAudioReady,
onError,
maxDurationSeconds = 300,
maxFileSizeBytes = 5 * 1024 * 1024,
audioBitsPerSecond = 64000,
levelBars = 32,
} = options
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [durationSeconds, setDurationSeconds] = useState(0)
const [levels, setLevels] = useState<number[]>(() => Array.from({ length: levelBars }, () => 0))
const durationRef = useRef(0)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const chunksRef = useRef<BlobPart[]>([])
const timerRef = useRef<number | null>(null)
const stopTimeoutRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const cancelRef = useRef(false)
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (stopTimeoutRef.current) {
clearTimeout(stopTimeoutRef.current)
stopTimeoutRef.current = null
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
if (audioContextRef.current) {
void audioContextRef.current.close()
audioContextRef.current = null
}
analyserRef.current = null
recorderRef.current = null
chunksRef.current = []
}, [])
const updateLevels = useCallback(() => {
const analyser = analyserRef.current
if (!analyser) return
const bufferLength = analyser.fftSize
const dataArray = new Uint8Array(bufferLength)
analyser.getByteTimeDomainData(dataArray)
const step = Math.floor(bufferLength / levelBars)
const nextLevels = Array.from({ length: levelBars }, (_, index) => {
let sum = 0
const start = index * step
const end = Math.min(start + step, bufferLength)
for (let i = start; i < end; i += 1) {
sum += Math.abs(dataArray[i] - 128)
}
const avg = sum / Math.max(1, end - start)
return Math.min(1, avg / 128)
})
if (mountedRef.current) {
setLevels(nextLevels)
rafRef.current = requestAnimationFrame(updateLevels)
}
}, [levelBars])
const stopRecording = useCallback(() => {
if (!recorderRef.current || !isRecording) return
setIsRecording(false)
try {
recorderRef.current.stop()
} catch (error) {
console.error("Falha ao parar gravação:", error)
cleanup()
}
}, [cleanup, isRecording])
const cancelRecording = useCallback(() => {
cancelRef.current = true
stopRecording()
}, [stopRecording])
const startRecording = useCallback(async () => {
if (isRecording || isProcessing) return
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
onError?.("Gravação de áudio indisponível neste dispositivo.")
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
analyser.fftSize = 256
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
audioContextRef.current = audioContext
analyserRef.current = analyser
const mimeType = pickSupportedMimeType()
const recorderOptions: MediaRecorderOptions = mimeType
? { mimeType, audioBitsPerSecond }
: { audioBitsPerSecond }
const recorder = new MediaRecorder(stream, recorderOptions)
recorderRef.current = recorder
chunksRef.current = []
cancelRef.current = false
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
recorder.onstop = async () => {
const blobType = recorder.mimeType || mimeType || "audio/webm"
const blob = new Blob(chunksRef.current, { type: blobType })
chunksRef.current = []
cleanup()
if (cancelRef.current) {
if (mountedRef.current) {
setLevels(Array.from({ length: levelBars }, () => 0))
}
return
}
if (blob.size > maxFileSizeBytes) {
onError?.("Áudio excede o limite de 5MB. Tente gravar por menos tempo.")
if (mountedRef.current) {
setLevels(Array.from({ length: levelBars }, () => 0))
}
return
}
const fileName = buildAudioFileName(blobType)
const file = new File([blob], fileName, { type: blobType })
setIsProcessing(true)
try {
await onAudioReady({ file, durationSeconds: durationRef.current })
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao enviar áudio."
onError?.(message)
} finally {
if (mountedRef.current) {
setIsProcessing(false)
setLevels(Array.from({ length: levelBars }, () => 0))
}
}
}
recorder.start()
durationRef.current = 0
setDurationSeconds(0)
setIsRecording(true)
updateLevels()
timerRef.current = window.setInterval(() => {
setDurationSeconds((prev) => {
const next = prev + 1
durationRef.current = next
if (next >= maxDurationSeconds) {
stopRecording()
return next
}
return next
})
}, 1000)
stopTimeoutRef.current = window.setTimeout(() => {
stopRecording()
}, maxDurationSeconds * 1000)
} catch (error) {
console.error("Falha ao iniciar gravação:", error)
onError?.("Não foi possível iniciar a gravação de áudio.")
cleanup()
}
}, [
audioBitsPerSecond,
cleanup,
isProcessing,
isRecording,
levelBars,
maxDurationSeconds,
maxFileSizeBytes,
onAudioReady,
onError,
stopRecording,
updateLevels,
])
return {
isRecording,
isProcessing,
durationSeconds,
levels,
startRecording,
stopRecording,
cancelRecording,
}
}

View file

@ -0,0 +1,206 @@
/**
* Hooks customizados para queries/mutations do Convex com token de maquina
*
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
* para as queries e mutations do Convex, proporcionando uma API simples
* e reativa para os componentes de chat.
*/
import { useQuery, useMutation, useAction } from "convex/react"
import { api } from "@convex/_generated/api"
import type { Id } from "@convex/_generated/dataModel"
import { useMachineToken } from "./ConvexMachineProvider"
// ============================================
// TIPOS
// ============================================
export type MachineSession = {
sessionId: Id<"liveChatSessions">
ticketId: Id<"tickets">
ticketRef: number
ticketSubject: string
agentName: string
agentEmail?: string
agentAvatarUrl?: string
unreadCount: number
lastActivityAt: number
startedAt: number
}
export type MachineMessage = {
id: Id<"ticketChatMessages">
body: string
authorName: string
authorAvatarUrl?: string
isFromMachine: boolean
createdAt: number
attachments: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}
export type MachineMessagesResult = {
messages: MachineMessage[]
hasSession: boolean
unreadCount: number
}
export type MachineUpdatesResult = {
hasActiveSessions: boolean
sessions: Array<{
ticketId: Id<"tickets">
ticketRef: number
unreadCount: number
lastActivityAt: number
}>
totalUnread: number
}
// ============================================
// HOOKS
// ============================================
/**
* Hook para listar sessoes ativas da maquina
* Subscription reativa - atualiza automaticamente quando ha mudancas
*/
export function useMachineSessions() {
const machineToken = useMachineToken()
const sessions = useQuery(
api.liveChat.listMachineSessions,
machineToken ? { machineToken } : "skip"
)
return {
sessions: sessions as MachineSession[] | undefined,
isLoading: sessions === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para listar mensagens de um ticket especifico
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
*/
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.listMachineMessages,
machineToken && ticketId
? { machineToken, ticketId, limit: options?.limit }
: "skip"
)
return {
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
isLoading: result === undefined && !!machineToken && !!ticketId,
hasToken: !!machineToken,
}
}
/**
* Hook para verificar updates (polling leve)
* Usado como fallback ou para verificar status rapidamente
*/
export function useMachineUpdates() {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.checkMachineUpdates,
machineToken ? { machineToken } : "skip"
)
return {
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
isLoading: result === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para enviar mensagem
*/
export function usePostMachineMessage() {
const machineToken = useMachineToken()
const postMessage = useMutation(api.liveChat.postMachineMessage)
return async (args: {
ticketId: Id<"tickets">
body: string
attachments?: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return postMessage({
machineToken,
ticketId: args.ticketId,
body: args.body,
attachments: args.attachments,
})
}
}
/**
* Hook para marcar mensagens como lidas
*/
export function useMarkMachineMessagesRead() {
const machineToken = useMachineToken()
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
return async (args: {
ticketId: Id<"tickets">
messageIds: Id<"ticketChatMessages">[]
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return markRead({
machineToken,
ticketId: args.ticketId,
messageIds: args.messageIds,
})
}
}
/**
* Hook para gerar URL de upload
*/
export function useGenerateMachineUploadUrl() {
const machineToken = useMachineToken()
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
return async (args: {
fileName: string
fileType: string
fileSize: number
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return generateUrl({
machineToken,
fileName: args.fileName,
fileType: args.fileType,
fileSize: args.fileSize,
})
}
}

View file

@ -1,23 +1,36 @@
import { ShieldAlert, Mail } from "lucide-react" import { ShieldAlert, Mail, RefreshCw } from "lucide-react"
import { useState } from "react"
type DeactivationScreenProps = {
companyName?: string | null
onRetry?: () => Promise<void> | void
}
export function DeactivationScreen({ onRetry }: DeactivationScreenProps) {
const [isRetrying, setIsRetrying] = useState(false)
const handleRetry = async () => {
if (isRetrying || !onRetry) return
setIsRetrying(true)
try {
await onRetry()
} finally {
setIsRetrying(false)
}
}
export function DeactivationScreen({ companyName }: { companyName?: string | null }) {
return ( return (
<div className="min-h-screen grid place-items-center bg-neutral-950 p-6"> <div className="fixed inset-0 z-50 grid place-items-center overflow-hidden bg-neutral-950 p-6">
<div className="flex w-full max-w-[720px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm"> <div className="flex w-full max-w-[720px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700"> <span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
<ShieldAlert className="size-4" /> Acesso bloqueado <ShieldAlert className="size-4" /> Acesso bloqueado
</span> </span>
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1> <h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativado</h1>
<p className="max-w-md text-sm text-neutral-600"> <p className="max-w-md text-sm text-neutral-600">
Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o Este dispositivo foi desativado temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
envio de informações ficam indisponíveis. envio de informações ficam indisponíveis.
</p> </p>
{companyName ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700">
{companyName}
</span>
) : null}
</div> </div>
<div className="w-full max-w-[520px] space-y-4"> <div className="w-full max-w-[520px] space-y-4">
@ -29,12 +42,25 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
</ul> </ul>
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3">
<a <a
href="mailto:suporte@rever.com.br" href="mailto:suporte@rever.com.br"
className="mx-auto inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90" className="inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
> >
<Mail className="size-4" /> Falar com o suporte <Mail className="size-4" /> Falar com o suporte
</a> </a>
{onRetry && (
<button
type="button"
onClick={handleRetry}
disabled={isRetrying}
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 transition hover:bg-slate-50 disabled:opacity-50"
>
<RefreshCw className={`size-4 ${isRetrying ? "animate-spin" : ""}`} />
{isRetrying ? "Verificando..." : "Verificar novamente"}
</button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,103 @@
/**
* MachineStateMonitor - Componente para monitorar o estado da máquina em tempo real
*
* Este componente usa uma subscription Convex para detectar mudanças no estado da máquina:
* - Quando isActive muda para false: máquina foi desativada
* - Quando hasValidToken muda para false: máquina foi resetada (tokens revogados)
*
* O componente não renderiza nada, apenas monitora e chama callbacks quando detecta mudanças.
*/
import { useEffect, useRef } from "react"
import { useQuery, ConvexProvider } from "convex/react"
import type { ConvexReactClient } from "convex/react"
import { api } from "../convex/_generated/api"
import type { Id } from "../convex/_generated/dataModel"
type MachineStateMonitorProps = {
machineId: string
onDeactivated?: () => void
onTokenRevoked?: () => void
onReactivated?: () => void
}
function MachineStateMonitorInner({ machineId, onDeactivated, onTokenRevoked, onReactivated }: MachineStateMonitorProps) {
const machineState = useQuery(api.machines.getMachineState, {
machineId: machineId as Id<"machines">,
})
// Refs para rastrear o estado anterior e evitar chamadas duplicadas
const previousIsActive = useRef<boolean | null>(null)
const previousHasValidToken = useRef<boolean | null>(null)
const initialLoadDone = useRef(false)
useEffect(() => {
if (!machineState) return
// Na primeira carga, verifica estado inicial E armazena valores
if (!initialLoadDone.current) {
console.log("[MachineStateMonitor] Carga inicial", {
isActive: machineState.isActive,
hasValidToken: machineState.hasValidToken,
found: machineState.found,
})
// Se já estiver desativado na carga inicial, chama callback
if (machineState.isActive === false) {
console.log("[MachineStateMonitor] Máquina já estava desativada")
onDeactivated?.()
}
// Se token já estiver inválido na carga inicial, chama callback
if (machineState.hasValidToken === false) {
console.log("[MachineStateMonitor] Token já estava revogado")
onTokenRevoked?.()
}
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
initialLoadDone.current = true
return
}
// Detecta mudança de ativo para inativo
if (previousIsActive.current === true && machineState.isActive === false) {
console.log("[MachineStateMonitor] Máquina foi desativada")
onDeactivated?.()
}
// Detecta mudança de inativo para ativo (reativação)
if (previousIsActive.current === false && machineState.isActive === true) {
console.log("[MachineStateMonitor] Máquina foi reativada")
onReactivated?.()
}
// Detecta mudança de token válido para inválido
if (previousHasValidToken.current === true && machineState.hasValidToken === false) {
console.log("[MachineStateMonitor] Token foi revogado (reset)")
onTokenRevoked?.()
}
// Atualiza refs
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
}, [machineState, onDeactivated, onTokenRevoked, onReactivated])
// Este componente nao renderiza nada
return null
}
type MachineStateMonitorWithClientProps = MachineStateMonitorProps & {
client: ConvexReactClient
}
/**
* Wrapper que recebe o cliente Convex e envolve o monitor com o provider
*/
export function MachineStateMonitor({ client, ...props }: MachineStateMonitorWithClientProps) {
return (
<ConvexProvider client={client}>
<MachineStateMonitorInner {...props} />
</ConvexProvider>
)
}

View file

@ -0,0 +1,121 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as alerts from "../alerts.js";
import type * as automations from "../automations.js";
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as categorySlas from "../categorySlas.js";
import type * as checklistTemplates from "../checklistTemplates.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js";
import type * as crons from "../crons.js";
import type * as dashboards from "../dashboards.js";
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js";
import type * as emprestimos from "../emprestimos.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as incidents from "../incidents.js";
import type * as invites from "../invites.js";
import type * as liveChat from "../liveChat.js";
import type * as machines from "../machines.js";
import type * as metrics from "../metrics.js";
import type * as migrations from "../migrations.js";
import type * as ops from "../ops.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
import type * as revision from "../revision.js";
import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as ticketFormSettings from "../ticketFormSettings.js";
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
import type * as ticketNotifications from "../ticketNotifications.js";
import type * as tickets from "../tickets.js";
import type * as usbPolicy from "../usbPolicy.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
alerts: typeof alerts;
automations: typeof automations;
bootstrap: typeof bootstrap;
categories: typeof categories;
categorySlas: typeof categorySlas;
checklistTemplates: typeof checklistTemplates;
commentTemplates: typeof commentTemplates;
companies: typeof companies;
crons: typeof crons;
dashboards: typeof dashboards;
deviceExportTemplates: typeof deviceExportTemplates;
deviceFieldDefaults: typeof deviceFieldDefaults;
deviceFields: typeof deviceFields;
devices: typeof devices;
emprestimos: typeof emprestimos;
fields: typeof fields;
files: typeof files;
incidents: typeof incidents;
invites: typeof invites;
liveChat: typeof liveChat;
machines: typeof machines;
metrics: typeof metrics;
migrations: typeof migrations;
ops: typeof ops;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
revision: typeof revision;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
ticketFormTemplates: typeof ticketFormTemplates;
ticketNotifications: typeof ticketNotifications;
tickets: typeof tickets;
usbPolicy: typeof usbPolicy;
users: typeof users;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

View file

@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View file

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View file

@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

View file

@ -6,12 +6,21 @@ import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store" import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path" import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react" import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
import { ConvexReactClient } from "convex/react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils" import { cn } from "./lib/utils"
import { ChatApp } from "./chat" import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen" import { DeactivationScreen } from "./components/DeactivationScreen"
import { MachineStateMonitor } from "./components/MachineStateMonitor"
import { api } from "./convex/_generated/api"
import type { Id } from "./convex/_generated/dataModel"
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types" import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
// URL do Convex para subscription em tempo real
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineOs = { type MachineOs = {
name: string name: string
version?: string | null version?: string | null
@ -304,7 +313,7 @@ function App() {
const [token, setToken] = useState<string | null>(null) const [token, setToken] = useState<string | null>(null)
const [config, setConfig] = useState<AgentConfig | null>(null) const [config, setConfig] = useState<AgentConfig | null>(null)
const [profile, setProfile] = useState<MachineProfile | null>(null) const [profile, setProfile] = useState<MachineProfile | null>(null)
const [logoSrc, setLogoSrc] = useState<string>(() => `${appUrl}/logo-raven.png`) const [logoSrc, setLogoSrc] = useState<string>("/logo-raven.png")
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
@ -321,6 +330,9 @@ function App() {
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null) const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
const lastHealAtRef = useRef(0) const lastHealAtRef = useRef(0)
// Cliente Convex para monitoramento em tempo real do estado da maquina
const [convexClient, setConvexClient] = useState<ConvexReactClient | null>(null)
const [provisioningCode, setProvisioningCode] = useState("") const [provisioningCode, setProvisioningCode] = useState("")
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
const [companyName, setCompanyName] = useState("") const [companyName, setCompanyName] = useState("")
@ -410,8 +422,15 @@ function App() {
status: "online", status: "online",
intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300, intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300,
}) })
// Iniciar sistema de chat apos o agente
await invoke("start_chat_polling", {
baseUrl: apiBaseUrl,
convexUrl: "https://convex.esdrasrenan.com.br",
token: data.machineToken,
})
logDesktop("chat:started")
} catch (err) { } catch (err) {
console.error("Falha ao reiniciar heartbeat", err) console.error("Falha ao reiniciar heartbeat/chat", err)
} }
return nextConfig return nextConfig
@ -586,8 +605,15 @@ function App() {
status: "online", status: "online",
intervalSeconds: 300, intervalSeconds: 300,
}) })
// Iniciar sistema de chat apos o agente
await invoke("start_chat_polling", {
baseUrl: apiBaseUrl,
convexUrl: "https://convex.esdrasrenan.com.br",
token,
})
logDesktop("chat:started:validation")
} catch (err) { } catch (err) {
console.error("Falha ao iniciar heartbeat em segundo plano", err) console.error("Falha ao iniciar heartbeat/chat em segundo plano", err)
} }
const payload = await res.clone().json().catch(() => null) const payload = await res.clone().json().catch(() => null)
if (payload && typeof payload === "object" && "machine" in payload) { if (payload && typeof payload === "object" && "machine" in payload) {
@ -679,6 +705,88 @@ useEffect(() => {
rustdeskInfoRef.current = rustdeskInfo rustdeskInfoRef.current = rustdeskInfo
}, [rustdeskInfo]) }, [rustdeskInfo])
// Cria/destrói cliente Convex quando o token muda
useEffect(() => {
if (!token) {
if (convexClient) {
convexClient.close()
setConvexClient(null)
}
return
}
// Cria novo cliente Convex para monitoramento em tempo real
const client = new ConvexReactClient(CONVEX_URL, {
unsavedChangesWarning: false,
})
setConvexClient(client)
return () => {
client.close()
}
}, [token]) // eslint-disable-line react-hooks/exhaustive-deps
// Callbacks para quando a máquina for desativada, resetada ou reativada
const handleMachineDeactivated = useCallback(() => {
console.log("[App] Máquina foi desativada - mostrando tela de bloqueio")
setIsMachineActive(false)
}, [])
const handleMachineReactivated = useCallback(() => {
console.log("[App] Máquina foi reativada - liberando acesso")
setIsMachineActive(true)
}, [])
// Callback para o botão "Verificar novamente" na tela de desativação
// Usa o convexClient diretamente para fazer uma query manual
const handleRetryCheck = useCallback(async () => {
if (!convexClient || !config?.machineId) return
console.log("[App] Verificando estado da máquina manualmente...")
try {
const state = await convexClient.query(api.machines.getMachineState, {
machineId: config.machineId as Id<"machines">,
})
console.log("[App] Estado da máquina:", state)
if (state?.isActive) {
console.log("[App] Máquina ativa - liberando acesso")
setIsMachineActive(true)
}
} catch (err) {
console.error("[App] Erro ao verificar estado:", err)
}
}, [convexClient, config?.machineId])
const handleTokenRevoked = useCallback(async () => {
console.log("[App] Token foi revogado - voltando para tela de registro")
if (store) {
try {
await store.delete("token")
await store.delete("config")
await store.save()
} catch (err) {
console.error("Falha ao limpar store", err)
}
}
tokenVerifiedRef.current = false
autoLaunchRef.current = false
setToken(null)
setConfig(null)
setStatus(null)
setIsMachineActive(true)
setIsLaunchingSystem(false)
// Limpa campos de input para novo registro
setProvisioningCode("")
setCollabEmail("")
setCollabName("")
setValidatedCompany(null)
setCodeStatus(null)
setCompanyName("")
setError("Este dispositivo foi resetado. Informe o código de provisionamento para reconectar.")
// Força navegar de volta para a página inicial do app Tauri (não do servidor web)
// URL do app Tauri em produção é http://tauri.localhost/, em dev é http://localhost:1420/
const appUrl = import.meta.env.MODE === "production" ? "http://tauri.localhost/" : "http://localhost:1420/"
window.location.href = appUrl
}, [store])
useEffect(() => { useEffect(() => {
if (!store || !config) return if (!store || !config) return
@ -1249,6 +1357,10 @@ const resolvedAppUrl = useMemo(() => {
const openSystem = useCallback(async () => { const openSystem = useCallback(async () => {
if (!token) return if (!token) return
if (!isMachineActive) {
setIsLaunchingSystem(false)
return
}
setIsLaunchingSystem(true) setIsLaunchingSystem(true)
// Recarrega store do disco para pegar dados que o Rust salvou diretamente // Recarrega store do disco para pegar dados que o Rust salvou diretamente
@ -1308,7 +1420,6 @@ const resolvedAppUrl = useMemo(() => {
setError(null) setError(null)
} }
if (!currentActive) { if (!currentActive) {
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
setIsLaunchingSystem(false) setIsLaunchingSystem(false)
return return
} }
@ -1316,14 +1427,8 @@ const resolvedAppUrl = useMemo(() => {
} }
} else { } else {
if (res.status === 423) { if (res.status === 423) {
const payload = await res.clone().json().catch(() => null)
const message =
payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string"
? ((payload as { error?: string }).error ?? "").trim()
: ""
setIsMachineActive(false) setIsMachineActive(false)
setIsLaunchingSystem(false) setIsLaunchingSystem(false)
setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
return return
} }
// Se sessão falhar, tenta identificar token inválido/expirado // Se sessão falhar, tenta identificar token inválido/expirado
@ -1373,7 +1478,7 @@ const resolvedAppUrl = useMemo(() => {
const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") }) logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") })
window.location.href = url window.location.href = url
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store]) }, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive])
async function reprovision() { async function reprovision() {
if (!store) return if (!store) return
@ -1478,17 +1583,28 @@ const resolvedAppUrl = useMemo(() => {
if (!token) return if (!token) return
if (autoLaunchRef.current) return if (autoLaunchRef.current) return
if (!tokenVerifiedRef.current) return if (!tokenVerifiedRef.current) return
if (!isMachineActive) return // Não redireciona se a máquina estiver desativada
autoLaunchRef.current = true autoLaunchRef.current = true
setIsLaunchingSystem(true) setIsLaunchingSystem(true)
openSystem() openSystem()
}, [token, status, config?.accessRole, openSystem, tokenValidationTick]) }, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive])
// Quando há token persistido (dispositivo já provisionado) e ainda não // Quando há token persistido (dispositivo já provisionado) e ainda não
// disparamos o auto-launch, exibimos diretamente a tela de loading da // disparamos o auto-launch, exibimos diretamente a tela de loading da
// plataforma para evitar piscar o card de resumo/inventário. // plataforma para evitar piscar o card de resumo/inventário.
if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) { // IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real
if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) {
return ( return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6"> <div className="min-h-screen grid place-items-center bg-slate-50 p-6">
{/* Monitor de estado da máquina - deve rodar mesmo durante loading */}
{token && config?.machineId && convexClient && (
<MachineStateMonitor
client={convexClient}
machineId={config.machineId}
onDeactivated={handleMachineDeactivated}
onTokenRevoked={handleTokenRevoked}
/>
)}
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm"> <div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
<Loader2 className="size-6 animate-spin text-neutral-700" /> <Loader2 className="size-6 animate-spin text-neutral-700" />
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever</p> <p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever</p>
@ -1498,11 +1614,31 @@ const resolvedAppUrl = useMemo(() => {
) )
} }
// Monitor sempre ativo quando há token e machineId
const machineMonitor = token && config?.machineId && convexClient ? (
<MachineStateMonitor
client={convexClient}
machineId={config.machineId}
onDeactivated={handleMachineDeactivated}
onTokenRevoked={handleTokenRevoked}
onReactivated={handleMachineReactivated}
/>
) : null
// Tela de desativação (renderizada separadamente para evitar container com fundo claro)
if (token && !isMachineActive) {
return (
<>
{machineMonitor}
<DeactivationScreen companyName={companyName} onRetry={handleRetryCheck} />
</>
)
}
return ( return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6"> <div className="min-h-screen grid place-items-center bg-slate-50 p-6">
{token && !isMachineActive ? ( {/* Monitor de estado da maquina em tempo real via Convex */}
<DeactivationScreen companyName={companyName} /> {machineMonitor}
) : (
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> <div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="mb-6 flex flex-col items-center gap-4 text-center"> <div className="mb-6 flex flex-col items-center gap-4 text-center">
<img <img
@ -1510,16 +1646,23 @@ const resolvedAppUrl = useMemo(() => {
alt="Logotipo Raven" alt="Logotipo Raven"
width={160} width={160}
height={160} height={160}
className="h-14 w-auto md:h-16" className="h-16 w-auto md:h-20"
onError={() => { onError={() => {
if (logoFallbackRef.current) return if (logoFallbackRef.current) return
logoFallbackRef.current = true logoFallbackRef.current = true
setLogoSrc(`${appUrl}/raven.png`) setLogoSrc(`${appUrl}/logo-raven.png`)
}} }}
/> />
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="text-xs text-neutral-500">Raven</span> <span className="text-lg font-semibold text-neutral-900">Raven</span>
<span className="text-2xl font-semibold text-neutral-900">Sistema de chamados</span> <div className="flex flex-col items-center gap-1">
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
Plataforma de
</span>
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
Chamados
</span>
</div>
<StatusBadge status={status} /> <StatusBadge status={status} />
</div> </div>
</div> </div>
@ -1723,8 +1866,6 @@ const resolvedAppUrl = useMemo(() => {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
) )
} }

View file

@ -19,7 +19,13 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["vite/client"] "types": ["vite/client"],
/* Paths */
"baseUrl": ".",
"paths": {
"@convex/_generated/*": ["./src/convex/_generated/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View file

@ -1,5 +1,6 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { resolve } from "path";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
// Usar arquivos _generated locais para evitar problemas de type-check
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors

View file

@ -21,6 +21,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -114,6 +115,7 @@
"@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2", "@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -512,6 +514,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@ -2332,6 +2336,8 @@
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], "appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
@ -2466,6 +2472,8 @@
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -2597,5 +2605,55 @@
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
} }
} }

View file

@ -14,7 +14,7 @@ import {
} from "./automationsEngine" } from "./automationsEngine"
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates" import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
import { TICKET_FORM_CONFIG } from "./ticketForms.config" import { TICKET_FORM_CONFIG } from "./ticketForms.config"
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail" import type { AutomationEmailProps } from "./reactEmail"
import { buildBaseUrl } from "./url" import { buildBaseUrl } from "./url"
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist" import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
@ -988,19 +988,38 @@ async function applyActions(
ctaLabel, ctaLabel,
ctaUrl, ctaUrl,
} }
const html = await renderAutomationEmailHtml(emailProps)
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, { await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
to, to,
subject, subject,
html, emailProps: {
title: emailProps.title,
message: emailProps.message,
ticket: {
reference: emailProps.ticket.reference,
subject: emailProps.ticket.subject,
status: emailProps.ticket.status ?? null,
priority: emailProps.ticket.priority ?? null,
companyName: emailProps.ticket.companyName ?? null,
requesterName: emailProps.ticket.requesterName ?? null,
assigneeName: emailProps.ticket.assigneeName ?? null,
},
ctaLabel: emailProps.ctaLabel,
ctaUrl: emailProps.ctaUrl,
},
}) })
applied.push({ applied.push({
type: "SEND_EMAIL", type: "SEND_EMAIL",
details: { details: {
recipients: to,
toCount: to.length, toCount: to.length,
subject,
messagePreview: message.length > 100 ? `${message.slice(0, 100)}...` : message,
ctaTarget: effectiveTarget, ctaTarget: effectiveTarget,
ctaLabel,
ctaUrl,
scheduledAt: Date.now(),
}, },
}) })
} }

View file

@ -14,17 +14,37 @@ function normalizeTemplateDescription(input: string | null | undefined) {
return text.length > 0 ? text : null return text.length > 0 ? text : null
} }
type ChecklistItemType = "checkbox" | "question"
type RawTemplateItem = {
id?: string
text: string
description?: string
type?: string
options?: string[]
required?: boolean
}
type NormalizedTemplateItem = {
id: string
text: string
description?: string
type?: ChecklistItemType
options?: string[]
required?: boolean
}
function normalizeTemplateItems( function normalizeTemplateItems(
raw: Array<{ id?: string; text: string; required?: boolean }>, raw: RawTemplateItem[],
options: { generateId?: () => string } options: { generateId?: () => string }
) { ): NormalizedTemplateItem[] {
if (!Array.isArray(raw) || raw.length === 0) { if (!Array.isArray(raw) || raw.length === 0) {
throw new ConvexError("Adicione pelo menos um item no checklist.") throw new ConvexError("Adicione pelo menos um item no checklist.")
} }
const generateId = options.generateId ?? (() => crypto.randomUUID()) const generateId = options.generateId ?? (() => crypto.randomUUID())
const seen = new Set<string>() const seen = new Set<string>()
const items: Array<{ id: string; text: string; required?: boolean }> = [] const items: NormalizedTemplateItem[] = []
for (const entry of raw) { for (const entry of raw) {
const id = String(entry.id ?? "").trim() || generateId() const id = String(entry.id ?? "").trim() || generateId()
@ -41,8 +61,25 @@ function normalizeTemplateItems(
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).") throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
} }
const description = entry.description?.trim() || undefined
const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox"
const itemOptions = itemType === "question" && Array.isArray(entry.options)
? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0)
: undefined
if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) {
throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opções.`)
}
const required = typeof entry.required === "boolean" ? entry.required : true const required = typeof entry.required === "boolean" ? entry.required : true
items.push({ id, text, required }) items.push({
id,
text,
description,
type: itemType,
options: itemOptions,
required,
})
} }
return items return items
@ -57,6 +94,9 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co
items: (template.items ?? []).map((item) => ({ items: (template.items ?? []).map((item) => ({
id: item.id, id: item.id,
text: item.text, text: item.text,
description: item.description,
type: item.type ?? "checkbox",
options: item.options,
required: typeof item.required === "boolean" ? item.required : true, required: typeof item.required === "boolean" ? item.required : true,
})), })),
isArchived: Boolean(template.isArchived), isArchived: Boolean(template.isArchived),
@ -164,6 +204,9 @@ export const create = mutation({
v.object({ v.object({
id: v.optional(v.string()), id: v.optional(v.string()),
text: v.string(), text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()),
options: v.optional(v.array(v.string())),
required: v.optional(v.boolean()), required: v.optional(v.boolean()),
}), }),
), ),
@ -216,6 +259,9 @@ export const update = mutation({
v.object({ v.object({
id: v.optional(v.string()), id: v.optional(v.string()),
text: v.string(), text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()),
options: v.optional(v.array(v.string())),
required: v.optional(v.boolean()), required: v.optional(v.boolean()),
}), }),
), ),
@ -279,3 +325,52 @@ export const remove = mutation({
return { ok: true } return { ok: true }
}, },
}) })
// DEBUG: Query para verificar dados do template e checklist de um ticket
export const debugTemplateAndTicketChecklist = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
templateId: v.id("ticketChecklistTemplates"),
ticketId: v.optional(v.id("tickets")),
},
handler: async (ctx, { tenantId, viewerId, templateId, ticketId }) => {
await requireStaff(ctx, viewerId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
return { error: "Template nao encontrado" }
}
const templateData = {
id: String(template._id),
name: template.name,
description: template.description,
hasDescription: Boolean(template.description),
descriptionType: typeof template.description,
itemsCount: template.items?.length ?? 0,
}
let ticketData = null
if (ticketId) {
const ticket = await ctx.db.get(ticketId)
if (ticket && ticket.tenantId === tenantId) {
ticketData = {
id: String(ticket._id),
checklistCount: ticket.checklist?.length ?? 0,
checklistItems: (ticket.checklist ?? []).map((item) => ({
id: item.id,
text: item.text.substring(0, 50),
templateId: item.templateId ? String(item.templateId) : null,
templateDescription: item.templateDescription,
hasTemplateDescription: Boolean(item.templateDescription),
description: item.description,
hasDescription: Boolean(item.description),
})),
}
}
}
return { template: templateData, ticket: ticketData }
},
})

273
convex/companySlas.ts Normal file
View file

@ -0,0 +1,273 @@
import { mutation, query } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { requireAdmin } from "./rbac"
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
const VALID_TIME_MODES = ["business", "calendar"] as const
type CompanySlaRuleInput = {
priority: string
categoryId?: string | null
responseTargetMinutes?: number | null
responseMode?: string | null
solutionTargetMinutes?: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
calendarType?: string | null
}
const ruleInput = v.object({
priority: v.string(),
categoryId: v.optional(v.union(v.id("ticketCategories"), v.null())),
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()),
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
})
function normalizePriority(value: string) {
const upper = value.trim().toUpperCase()
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
}
function sanitizeTime(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function normalizeMode(value?: string | null) {
if (!value) return "calendar"
const normalized = value.toLowerCase()
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
}
function normalizeThreshold(value?: number | null) {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0.8
}
const clamped = Math.min(Math.max(value, 0.1), 0.95)
return Math.round(clamped * 100) / 100
}
function normalizePauseStatuses(value?: string[] | null) {
if (!Array.isArray(value)) return ["PAUSED"]
const normalized = new Set<string>()
for (const status of value) {
if (typeof status !== "string") continue
const upper = status.trim().toUpperCase()
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
normalized.add(upper)
}
}
if (normalized.size === 0) {
normalized.add("PAUSED")
}
return Array.from(normalized)
}
// Lista todas as empresas que possuem SLA customizado
export const listCompaniesWithCustomSla = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId)
// Busca todas as configurações de SLA por empresa
const allSettings = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId))
.take(1000)
// Agrupa por companyId para evitar duplicatas
const companyIds = [...new Set(allSettings.map((s) => s.companyId))]
// Busca dados das empresas
const companies = await Promise.all(
companyIds.map(async (companyId) => {
const company = await ctx.db.get(companyId)
if (!company) return null
const rulesCount = allSettings.filter((s) => s.companyId === companyId).length
return {
companyId,
companyName: company.name,
companySlug: company.slug,
rulesCount,
}
})
)
return companies.filter(Boolean)
},
})
// Busca as regras de SLA de uma empresa específica
export const get = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.id("companies"),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
await requireAdmin(ctx, viewerId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
const records = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
// Busca nomes das categorias referenciadas
const categoryIds = [...new Set(records.filter((r) => r.categoryId).map((r) => r.categoryId!))]
const categories = await Promise.all(categoryIds.map((id) => ctx.db.get(id)))
const categoryNames = new Map(
categories.filter(Boolean).map((c) => [c!._id, c!.name])
)
return {
companyId,
companyName: company.name,
rules: records.map((record) => ({
priority: record.priority,
categoryId: record.categoryId ?? null,
categoryName: record.categoryId ? categoryNames.get(record.categoryId) ?? null : null,
responseTargetMinutes: record.responseTargetMinutes ?? null,
responseMode: record.responseMode ?? "calendar",
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
solutionMode: record.solutionMode ?? "calendar",
alertThreshold: record.alertThreshold ?? 0.8,
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
})),
}
},
})
// Salva as regras de SLA de uma empresa
export const save = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
companyId: v.id("companies"),
rules: v.array(ruleInput),
},
handler: async (ctx, { tenantId, actorId, companyId, rules }) => {
await requireAdmin(ctx, actorId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
// Valida categorias referenciadas
for (const rule of rules) {
if (rule.categoryId) {
const category = await ctx.db.get(rule.categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError(`Categoria inválida: ${rule.categoryId}`)
}
}
}
const sanitized = sanitizeRules(rules)
// Remove regras existentes da empresa
const existing = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
// Insere novas regras
const now = Date.now()
for (const rule of sanitized) {
await ctx.db.insert("companySlaSettings", {
tenantId,
companyId,
categoryId: rule.categoryId ? (rule.categoryId as Id<"ticketCategories">) : undefined,
priority: rule.priority,
responseTargetMinutes: rule.responseTargetMinutes,
responseMode: rule.responseMode,
solutionTargetMinutes: rule.solutionTargetMinutes,
solutionMode: rule.solutionMode,
alertThreshold: rule.alertThreshold,
pauseStatuses: rule.pauseStatuses,
calendarType: rule.calendarType ?? undefined,
createdAt: now,
updatedAt: now,
actorId,
})
}
return { ok: true }
},
})
// Remove todas as regras de SLA de uma empresa
export const remove = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
companyId: v.id("companies"),
},
handler: async (ctx, { tenantId, actorId, companyId }) => {
await requireAdmin(ctx, actorId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
const existing = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
return { ok: true }
},
})
function sanitizeRules(rules: CompanySlaRuleInput[]) {
// Chave única: categoryId + priority
const normalized: Map<string, ReturnType<typeof buildRule>> = new Map()
for (const rule of rules) {
const built = buildRule(rule)
const key = `${built.categoryId ?? "ALL"}-${built.priority}`
normalized.set(key, built)
}
return Array.from(normalized.values())
}
function buildRule(rule: CompanySlaRuleInput) {
const priority = normalizePriority(rule.priority)
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
return {
priority,
categoryId: rule.categoryId ?? null,
responseTargetMinutes,
responseMode: normalizeMode(rule.responseMode),
solutionTargetMinutes,
solutionMode: normalizeMode(rule.solutionMode),
alertThreshold: normalizeThreshold(rule.alertThreshold),
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
calendarType: rule.calendarType ?? null,
}
}

View file

@ -168,7 +168,40 @@ export const startSession = mutation({
createdAt: now, createdAt: now,
}) })
return { sessionId, isNew: true } // Iniciar timer automaticamente se nao houver sessao de trabalho ativa
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
let workSessionId: Id<"ticketWorkSessions"> | null = null
if (!ticket.activeSessionId && ticket.assigneeId) {
workSessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: ticket.assigneeId,
workType: "EXTERNAL",
startedAt: now,
})
await ctx.db.patch(ticketId, {
working: true,
activeSessionId: workSessionId,
status: "AWAITING_ATTENDANCE",
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
payload: {
actorId,
actorName: agent.name,
actorAvatar: agent.avatarUrl,
sessionId: workSessionId,
workType: "EXTERNAL",
source: "live_chat_auto",
},
createdAt: now,
})
}
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
}, },
}) })
@ -225,7 +258,60 @@ export const endSession = mutation({
createdAt: now, createdAt: now,
}) })
return { ok: true } // Pausar timer automaticamente se houver sessao de trabalho ativa
let workSessionPaused = false
const ticket = await ctx.db.get(session.ticketId)
if (ticket?.activeSessionId) {
const workSession = await ctx.db.get(ticket.activeSessionId)
if (workSession && !workSession.stoppedAt) {
const workDurationMs = now - workSession.startedAt
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
// Encerrar sessao de trabalho
await ctx.db.patch(ticket.activeSessionId, {
stoppedAt: now,
durationMs: workDurationMs,
pauseReason: "END_LIVE_CHAT",
pauseNote: "Pausa automática ao encerrar chat ao vivo",
})
// Atualizar ticket
await ctx.db.patch(session.ticketId, {
working: false,
activeSessionId: undefined,
status: "PAUSED",
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now,
})
// Registrar evento de pausa
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "WORK_PAUSED",
payload: {
actorId,
actorName: actor.name,
actorAvatar: actor.avatarUrl,
sessionId: workSession._id,
sessionDurationMs: workDurationMs,
workType: sessionType,
pauseReason: "END_LIVE_CHAT",
pauseReasonLabel: "Chat ao vivo encerrado",
pauseNote: "Pausa automática ao encerrar chat ao vivo",
source: "live_chat_auto",
},
createdAt: now,
})
workSessionPaused = true
}
}
return { ok: true, workSessionPaused }
}, },
}) })
@ -417,8 +503,14 @@ export const listMachineSessions = query({
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak) // Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
.take(50) .take(50)
// Filtrar apenas sessão problemática legada (ID hardcoded)
// Nota: lastAgentMessageAt pode ser undefined em sessões novas onde o agente ainda não enviou mensagem
const validSessions = sessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j"
)
const result = await Promise.all( const result = await Promise.all(
sessions.map(async (session) => { validSessions.map(async (session) => {
const ticket = await ctx.db.get(session.ticketId) const ticket = await ctx.db.get(session.ticketId)
return { return {
sessionId: session._id, sessionId: session._id,
@ -520,13 +612,18 @@ export const checkMachineUpdates = query({
const { machine } = await validateMachineToken(ctx, args.machineToken) const { machine } = await validateMachineToken(ctx, args.machineToken)
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak) // Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
const sessions = await ctx.db const rawSessions = await ctx.db
.query("liveChatSessions") .query("liveChatSessions")
.withIndex("by_machine_status", (q) => .withIndex("by_machine_status", (q) =>
q.eq("machineId", machine._id).eq("status", "ACTIVE") q.eq("machineId", machine._id).eq("status", "ACTIVE")
) )
.take(50) .take(50)
// Filtrar sessões problemáticas (sem campos obrigatórios)
const sessions = rawSessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
)
if (sessions.length === 0) { if (sessions.length === 0) {
return { return {
hasActiveSessions: false, hasActiveSessions: false,
@ -763,27 +860,40 @@ export const getTicketChatHistory = query({
// Timeout de maquina offline: 5 minutos sem heartbeat // Timeout de maquina offline: 5 minutos sem heartbeat
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron) // Timeout de inatividade do chat: 12 horas sem atividade
// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat // Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar
// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
// Critérios de encerramento:
// 1. Máquina offline (5 min sem heartbeat)
// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online
// 3. Ticket órfão (sem máquina vinculada)
export const autoEndInactiveSessions = mutation({ export const autoEndInactiveSessions = mutation({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") console.log("cron: autoEndInactiveSessions iniciado")
const now = Date.now() const now = Date.now()
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS
// Limitar a 50 sessões por execução para evitar timeout do cron (30s) // Limitar a 50 sessões por execução para evitar timeout do cron (30s)
const maxSessionsPerRun = 50 const maxSessionsPerRun = 50
// Buscar todas as sessões ativas // Buscar todas as sessões ativas
const activeSessions = await ctx.db const rawActiveSessions = await ctx.db
.query("liveChatSessions") .query("liveChatSessions")
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE")) .withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
.take(maxSessionsPerRun) .take(maxSessionsPerRun)
// Filtrar sessões problemáticas (sem campos obrigatórios)
const activeSessions = rawActiveSessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
)
let endedCount = 0 let endedCount = 0
let checkedCount = 0 let checkedCount = 0
const reasons: Record<string, number> = {}
for (const session of activeSessions) { for (const session of activeSessions) {
checkedCount++ checkedCount++
@ -812,6 +922,36 @@ export const autoEndInactiveSessions = mutation({
createdAt: now, createdAt: now,
}) })
endedCount++ endedCount++
reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1
continue
}
// Verificar inatividade do chat (12 horas sem atividade)
// Isso tem prioridade sobre o status da máquina
const chatIsInactive = session.lastActivityAt < inactivityCutoff
if (chatIsInactive) {
await ctx.db.patch(session._id, {
status: "ENDED",
endedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "LIVE_CHAT_ENDED",
payload: {
sessionId: session._id,
agentId: session.agentId,
agentName: session.agentSnapshot?.name ?? "Sistema",
durationMs: now - session.startedAt,
startedAt: session.startedAt,
endedAt: now,
autoEnded: true,
reason: "inatividade_chat",
inactiveForMs: now - session.lastActivityAt,
},
createdAt: now,
})
endedCount++
reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1
continue continue
} }
@ -819,7 +959,7 @@ export const autoEndInactiveSessions = mutation({
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
// Se máquina está online, manter sessão ativa // Se máquina está online e chat está ativo, manter sessão
if (machineIsOnline) { if (machineIsOnline) {
continue continue
} }
@ -849,10 +989,40 @@ export const autoEndInactiveSessions = mutation({
}) })
endedCount++ endedCount++
reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1
} }
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ")
return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`)
return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun }
},
})
// Mutation para corrigir sessoes antigas sem campos obrigatorios
export const fixLegacySessions = mutation({
args: {},
handler: async (ctx) => {
// IDs problematicos conhecidos - sessoes sem lastAgentMessageAt
const knownProblematicIds = [
"pd71bvfbxx7th3npdj519hcf3s7xbe2j",
]
let deleted = 0
const results: string[] = []
for (const sessionId of knownProblematicIds) {
try {
// Deletar a sessao problematica diretamente (evita erro de shape ao ler)
await ctx.db.delete(sessionId as Id<"liveChatSessions">)
deleted++
results.push(`${sessionId}: deleted`)
} catch (error) {
results.push(`${sessionId}: error - ${error}`)
}
}
console.log(`fixLegacySessions: deleted=${deleted}, results=${results.join(", ")}`)
return { deleted, results }
}, },
}) })
@ -867,6 +1037,13 @@ const ALLOWED_MIME_TYPES = [
"image/png", "image/png",
"image/gif", "image/gif",
"image/webp", "image/webp",
// Audio
"audio/webm",
"audio/ogg",
"audio/wav",
"audio/mpeg",
"audio/mp4",
"audio/x-m4a",
// Documentos // Documentos
"application/pdf", "application/pdf",
"text/plain", "text/plain",
@ -878,6 +1055,7 @@ const ALLOWED_MIME_TYPES = [
const ALLOWED_EXTENSIONS = [ const ALLOWED_EXTENSIONS = [
".jpg", ".jpeg", ".png", ".gif", ".webp", ".jpg", ".jpeg", ".png", ".gif", ".webp",
".webm", ".ogg", ".wav", ".mp3", ".m4a",
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
] ]
@ -931,7 +1109,8 @@ export const generateMachineUploadUrl = action({
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`) throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
} }
if (!ALLOWED_MIME_TYPES.includes(args.fileType)) { const normalizedType = args.fileType.split(";")[0].trim().toLowerCase()
if (!ALLOWED_MIME_TYPES.includes(normalizedType)) {
throw new ConvexError("Tipo MIME não permitido") throw new ConvexError("Tipo MIME não permitido")
} }

276
convex/machineSoftware.ts Normal file
View file

@ -0,0 +1,276 @@
import { mutation, query, internalMutation } from "./_generated/server"
import { v } from "convex/values"
import type { Id } from "./_generated/dataModel"
// Tipo para software recebido do agente
type SoftwareInput = {
name: string
version?: string
publisher?: string
source?: string
}
// Upsert de softwares de uma maquina (chamado pelo heartbeat)
export const syncFromHeartbeat = internalMutation({
args: {
tenantId: v.string(),
machineId: v.id("machines"),
software: v.array(
v.object({
name: v.string(),
version: v.optional(v.string()),
publisher: v.optional(v.string()),
source: v.optional(v.string()),
})
),
},
handler: async (ctx, { tenantId, machineId, software }) => {
const now = Date.now()
// Busca softwares existentes da maquina
const existing = await ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.collect()
const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s]))
// Processa cada software recebido
const seenKeys = new Set<string>()
for (const item of software) {
if (!item.name || item.name.trim().length === 0) continue
const nameLower = item.name.toLowerCase().trim()
const key = `${nameLower}|${item.version ?? ""}`
seenKeys.add(key)
const existingDoc = existingMap.get(key)
if (existingDoc) {
// Atualiza lastSeenAt se ja existe
await ctx.db.patch(existingDoc._id, {
lastSeenAt: now,
publisher: item.publisher || existingDoc.publisher,
source: item.source || existingDoc.source,
})
} else {
// Cria novo registro
await ctx.db.insert("machineSoftware", {
tenantId,
machineId,
name: item.name.trim(),
nameLower,
version: item.version?.trim() || undefined,
publisher: item.publisher?.trim() || undefined,
source: item.source?.trim() || undefined,
detectedAt: now,
lastSeenAt: now,
})
}
}
// Remove softwares que nao foram vistos (desinstalados)
// So remove se o software nao foi visto nas ultimas 24 horas
const staleThreshold = now - 24 * 60 * 60 * 1000
for (const doc of existing) {
const key = `${doc.nameLower}|${doc.version ?? ""}`
if (!seenKeys.has(key) && doc.lastSeenAt < staleThreshold) {
await ctx.db.delete(doc._id)
}
}
return { processed: software.length }
},
})
// Lista softwares de uma maquina com paginacao e filtros
export const listByMachine = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
machineId: v.id("machines"),
search: v.optional(v.string()),
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, { machineId, search, limit = 50, cursor }) => {
const pageLimit = Math.min(limit, 100)
let query = ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
// Coleta todos e filtra em memoria (Convex nao suporta LIKE)
const all = await query.collect()
// Filtra por search se fornecido
let filtered = all
if (search && search.trim().length > 0) {
const searchLower = search.toLowerCase().trim()
filtered = all.filter(
(s) =>
s.nameLower.includes(searchLower) ||
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
(s.version && s.version.toLowerCase().includes(searchLower))
)
}
// Ordena por nome
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
// Paginacao manual
let startIndex = 0
if (cursor) {
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
if (cursorIndex >= 0) {
startIndex = cursorIndex + 1
}
}
const page = filtered.slice(startIndex, startIndex + pageLimit)
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
return {
items: page.map((s) => ({
id: s._id,
name: s.name,
version: s.version ?? null,
publisher: s.publisher ?? null,
source: s.source ?? null,
detectedAt: s.detectedAt,
lastSeenAt: s.lastSeenAt,
})),
total: filtered.length,
nextCursor,
}
},
})
// Lista softwares de todas as maquinas de um tenant (para admin)
export const listByTenant = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
search: v.optional(v.string()),
machineId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, { tenantId, search, machineId, limit = 50, cursor }) => {
const pageLimit = Math.min(limit, 100)
// Busca por tenant ou por maquina especifica
let all: Array<{
_id: Id<"machineSoftware">
tenantId: string
machineId: Id<"machines">
name: string
nameLower: string
version?: string
publisher?: string
source?: string
detectedAt: number
lastSeenAt: number
}>
if (machineId) {
all = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machineId))
.collect()
} else {
// Busca por tenant - pode ser grande, limita
all = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(5000)
}
// Filtra por search
let filtered = all
if (search && search.trim().length > 0) {
const searchLower = search.toLowerCase().trim()
filtered = all.filter(
(s) =>
s.nameLower.includes(searchLower) ||
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
(s.version && s.version.toLowerCase().includes(searchLower))
)
}
// Ordena por nome
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
// Paginacao
let startIndex = 0
if (cursor) {
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
if (cursorIndex >= 0) {
startIndex = cursorIndex + 1
}
}
const page = filtered.slice(startIndex, startIndex + pageLimit)
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
// Busca nomes das maquinas
const machineIds = [...new Set(page.map((s) => s.machineId))]
const machines = await Promise.all(machineIds.map((id) => ctx.db.get(id)))
const machineNames = new Map(
machines.filter(Boolean).map((m) => [m!._id, m!.displayName || m!.hostname])
)
return {
items: page.map((s) => ({
id: s._id,
machineId: s.machineId,
machineName: machineNames.get(s.machineId) ?? "Desconhecido",
name: s.name,
version: s.version ?? null,
publisher: s.publisher ?? null,
source: s.source ?? null,
detectedAt: s.detectedAt,
lastSeenAt: s.lastSeenAt,
})),
total: filtered.length,
nextCursor,
}
},
})
// Conta softwares de uma maquina
export const countByMachine = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const software = await ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.collect()
return { count: software.length }
},
})
// Conta softwares unicos por tenant (para relatorios)
export const stats = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId }) => {
const software = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(10000)
const uniqueNames = new Set(software.map((s) => s.nameLower))
const machineIds = new Set(software.map((s) => s.machineId))
return {
totalInstances: software.length,
uniqueSoftware: uniqueNames.size,
machinesWithSoftware: machineIds.size,
}
},
})

View file

@ -1,6 +1,6 @@
// ci: trigger convex functions deploy (no-op) // ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
import { api } from "./_generated/api" import { internal, api } from "./_generated/api"
import { paginationOptsValidator } from "convex/server" import { paginationOptsValidator } from "convex/server"
import { ConvexError, v, Infer } from "convex/values" import { ConvexError, v, Infer } from "convex/values"
import { sha256 } from "@noble/hashes/sha2.js" import { sha256 } from "@noble/hashes/sha2.js"
@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
return hb?.lastHeartbeatAt ?? fallback ?? null return hb?.lastHeartbeatAt ?? fallback ?? null
} }
// Campos do inventory que sao muito grandes e nao devem ser persistidos // Campo software é muito grande e é tratado separadamente via machineSoftware
// para evitar OOM no Convex (documentos de ~100KB cada)
const INVENTORY_BLOCKLIST = new Set(["software", "extended"]) // Extrai campos importantes do extended antes de bloqueá-lo
function extractFromExtended(extended: unknown): JsonRecord {
const result: JsonRecord = {}
const sanitizedExtended = sanitizeRecord(extended)
if (!sanitizedExtended) return result
// Extrair dados do Windows
const windows = sanitizeRecord(sanitizedExtended["windows"])
if (windows) {
const windowsFields: JsonRecord = {}
// bootInfo - informacoes de reinicio
if (windows["bootInfo"]) {
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
}
// osInfo - informacoes do sistema operacional
if (windows["osInfo"]) {
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
}
// cpu, baseboard, bios, memoryModules, videoControllers, disks
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
if (windows[key]) {
windowsFields[key] = windows[key] as JsonValue
}
}
if (Object.keys(windowsFields).length > 0) {
result["windows"] = windowsFields
}
}
// Extrair dados do Linux
const linux = sanitizeRecord(sanitizedExtended["linux"])
if (linux) {
const linuxFields: JsonRecord = {}
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
if (linux[key]) {
linuxFields[key] = linux[key] as JsonValue
}
}
if (Object.keys(linuxFields).length > 0) {
result["linux"] = linuxFields
}
}
// Extrair dados do macOS
const macos = sanitizeRecord(sanitizedExtended["macos"])
if (macos) {
result["macos"] = macos as JsonValue
}
return result
}
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord { function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
const sanitizedPatch = sanitizeRecord(patch) const sanitizedPatch = sanitizeRecord(patch)
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
return current ? { ...current } : {} return current ? { ...current } : {}
} }
const base: JsonRecord = current ? { ...current } : {} const base: JsonRecord = current ? { ...current } : {}
for (const [key, value] of Object.entries(sanitizedPatch)) { for (const [key, value] of Object.entries(sanitizedPatch)) {
// Filtrar campos volumosos que causam OOM // Filtrar software (extended já foi processado em sanitizeInventoryPayload)
if (INVENTORY_BLOCKLIST.has(key)) continue if (key === "software") continue
if (value === undefined) continue if (value === undefined) continue
if (isObject(value) && isObject(base[key])) { if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>) base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
@ -393,9 +444,20 @@ function ensureString(value: unknown): string | null {
function sanitizeInventoryPayload(value: unknown): JsonRecord | null { function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
const record = sanitizeRecord(value) const record = sanitizeRecord(value)
if (!record) return null if (!record) return null
for (const blocked of INVENTORY_BLOCKLIST) {
delete record[blocked] // Extrair campos importantes do extended antes de deletá-lo
if (record["extended"]) {
const extractedExtended = extractFromExtended(record["extended"])
if (Object.keys(extractedExtended).length > 0) {
record["extended"] = extractedExtended
} else {
delete record["extended"]
} }
}
// Deletar apenas software (extended já foi processado acima)
delete record["software"]
return record return record
} }
@ -956,10 +1018,13 @@ export const heartbeat = mutation({
} }
} }
const sanitizedInventory = sanitizeInventoryPayload(args.inventory) // Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata)
const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record<string, unknown> | undefined)
const sanitizedInventory = sanitizeInventoryPayload(rawInventory)
const currentInventory = ensureRecord(currentMetadata.inventory) const currentInventory = ensureRecord(currentMetadata.inventory)
const incomingInventoryHash = hashJson(sanitizedInventory) const incomingInventoryHash = hashJson(sanitizedInventory)
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) { if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory) metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
metadataPatch.inventoryHash = incomingInventoryHash metadataPatch.inventoryHash = incomingInventoryHash
@ -1010,6 +1075,34 @@ export const heartbeat = mutation({
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
} }
// Processar softwares instalados (armazenados em tabela separada)
// Os dados de software sao extraidos ANTES de sanitizar o inventory
// Usa rawInventory ja extraido anteriormente (linha ~1022)
if (rawInventory && typeof rawInventory === "object") {
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
const validSoftware = softwareArray
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
.map((item) => ({
name: typeof item.name === "string" ? item.name : "",
version: typeof item.version === "string" ? item.version : undefined,
publisher: typeof item.publisher === "string" || typeof item.source === "string"
? (item.publisher as string) || (item.source as string)
: undefined,
source: typeof item.source === "string" ? item.source : undefined,
}))
.filter((item) => item.name.length > 0)
if (validSoftware.length > 0) {
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
tenantId: machine.tenantId,
machineId: machine._id,
software: validSoftware,
})
}
}
}
await ctx.db.patch(token._id, { await ctx.db.patch(token._id, {
lastUsedAt: now, lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1, usageCount: (token.usageCount ?? 0) + 1,
@ -2317,6 +2410,44 @@ export const resetAgent = mutation({
}, },
}) })
/**
* Query para o desktop monitorar o estado da máquina em tempo real.
* O desktop faz subscribe nessa query e reage imediatamente quando:
* - isActive muda para false (desativação)
* - hasValidToken muda para false (reset/revogação de tokens)
*/
export const getMachineState = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
}
// Verifica se existe algum token válido (não revogado e não expirado)
const now = Date.now()
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(10)
const hasValidToken = tokens.some((token) => {
if (token.revoked) return false
if (token.expiresAt && token.expiresAt < now) return false
return true
})
return {
found: true,
isActive: machine.isActive ?? true,
hasValidToken,
status: machine.status ?? "unknown",
}
},
})
type RemoteAccessEntry = { type RemoteAccessEntry = {
id: string id: string
provider: string provider: string

View file

@ -1043,3 +1043,81 @@ export const backfillTicketSnapshots = mutation({
return { processed } return { processed }
}, },
}) })
/**
* Migration para remover comentarios duplicados de troca de responsavel.
* Esses comentarios eram criados automaticamente ao trocar o responsavel,
* mas essa informacao ja aparece na linha do tempo (ticketEvents).
* O comentario segue o padrao: "<p><strong>Responsável atualizado:</strong>..."
*/
export const removeAssigneeChangeComments = mutation({
args: {
tenantId: v.optional(v.string()),
limit: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, limit, dryRun }) => {
const effectiveDryRun = Boolean(dryRun)
const effectiveLimit = limit && limit > 0 ? Math.min(limit, 500) : 500
// Busca comentarios internos que contenham o padrao de troca de responsavel
const comments = tenantId && tenantId.trim().length > 0
? await ctx.db.query("ticketComments").take(5000)
: await ctx.db.query("ticketComments").take(5000)
// Filtrar comentarios que sao de troca de responsavel
const assigneeChangePattern = "<p><strong>Responsável atualizado:</strong>"
const toDelete = comments.filter((comment) => {
if (comment.visibility !== "INTERNAL") return false
if (typeof comment.body !== "string") return false
return comment.body.includes(assigneeChangePattern)
})
// Filtrar por tenant se especificado
let filtered = toDelete
if (tenantId && tenantId.trim().length > 0) {
const ticketIds = new Set<string>()
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(10000)
for (const t of tickets) {
ticketIds.add(t._id)
}
filtered = toDelete.filter((c) => ticketIds.has(c.ticketId))
}
const limitedComments = filtered.slice(0, effectiveLimit)
let deleted = 0
let eventsDeleted = 0
for (const comment of limitedComments) {
if (!effectiveDryRun) {
// Deletar o evento COMMENT_ADDED correspondente
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
.take(500)
const matchingEvent = events.find(
(event) =>
event.type === "COMMENT_ADDED" &&
Math.abs(event.createdAt - comment.createdAt) < 1000, // mesmo timestamp (tolerancia de 1s)
)
if (matchingEvent) {
await ctx.db.delete(matchingEvent._id)
eventsDeleted += 1
}
await ctx.db.delete(comment._id)
}
deleted += 1
}
return {
dryRun: effectiveDryRun,
totalFound: filtered.length,
deleted,
eventsDeleted,
remaining: filtered.length - deleted,
}
},
})

View file

@ -154,10 +154,17 @@ export const summary = query({
const now = Date.now(); const now = Date.now();
for (const ticket of tickets) { for (const ticket of tickets) {
const status = normalizeStatus(ticket.status); const status = normalizeStatus(ticket.status);
const isWorking = ticket.working === true;
if (status === "PENDING") { if (status === "PENDING") {
pending += 1; pending += 1;
} else if (status === "AWAITING_ATTENDANCE") { } else if (status === "AWAITING_ATTENDANCE") {
// "Em andamento" conta apenas tickets com play ativo
if (isWorking) {
inProgress += 1; inProgress += 1;
} else {
// Tickets em atendimento sem play ativo contam como "Em aberto"
pending += 1;
}
} else if (status === "PAUSED") { } else if (status === "PAUSED") {
paused += 1; paused += 1;
} }

View file

@ -3,8 +3,31 @@ import { render } from "@react-email/render"
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email" import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email" import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
import InviteEmail, { type InviteEmailProps } from "../emails/invite-email"
import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email"
import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email"
import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email"
import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email"
import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email"
import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email"
import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email"
import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email"
import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email"
export type { AutomationEmailProps, SimpleNotificationEmailProps } export type {
AutomationEmailProps,
SimpleNotificationEmailProps,
InviteEmailProps,
PasswordResetEmailProps,
NewLoginEmailProps,
SlaWarningEmailProps,
SlaBreachedEmailProps,
TicketCreatedEmailProps,
TicketResolvedEmailProps,
TicketAssignedEmailProps,
TicketStatusEmailProps,
TicketCommentEmailProps,
}
export async function renderAutomationEmailHtml(props: AutomationEmailProps) { export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
return render(<AutomationEmail {...props} />, { pretty: false }) return render(<AutomationEmail {...props} />, { pretty: false })
@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) { export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
return render(<SimpleNotificationEmail {...props} />, { pretty: false }) return render(<SimpleNotificationEmail {...props} />, { pretty: false })
} }
export async function renderInviteEmailHtml(props: InviteEmailProps) {
return render(<InviteEmail {...props} />, { pretty: false })
}
export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) {
return render(<PasswordResetEmail {...props} />, { pretty: false })
}
export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) {
return render(<NewLoginEmail {...props} />, { pretty: false })
}
export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) {
return render(<SlaWarningEmail {...props} />, { pretty: false })
}
export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) {
return render(<SlaBreachedEmail {...props} />, { pretty: false })
}
export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) {
return render(<TicketCreatedEmail {...props} />, { pretty: false })
}
export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) {
return render(<TicketResolvedEmail {...props} />, { pretty: false })
}
export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) {
return render(<TicketAssignedEmail {...props} />, { pretty: false })
}
export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) {
return render(<TicketStatusEmail {...props} />, { pretty: false })
}
export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) {
return render(<TicketCommentEmail {...props} />, { pretty: false })
}

View file

@ -161,11 +161,8 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks
} }
} }
function logDashboardProgress(processed: number, tenantId: string) { function logDashboardProgress(_processed: number, _tenantId: string) {
const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024)); // Log de progresso removido para reduzir ruido no console
console.log(
`[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`,
);
} }
function mapToChronologicalSeries(map: Map<string, number>) { function mapToChronologicalSeries(map: Map<string, number>) {
@ -2406,19 +2403,21 @@ export const companyOverview = query({
args: { args: {
tenantId: v.string(), tenantId: v.string(),
viewerId: v.id("users"), viewerId: v.id("users"),
companyId: v.id("companies"), companyId: v.optional(v.id("companies")),
range: v.optional(v.string()), range: v.optional(v.string()),
}, },
handler: async (ctx, { tenantId, viewerId, companyId, range }) => { handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
}
const company = await ctx.db.get(companyId); // Buscar dados da empresa selecionada (se houver)
let company: Doc<"companies"> | null = null;
if (scopedCompanyId) {
company = await ctx.db.get(scopedCompanyId);
if (!company || company.tenantId !== tenantId) { if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada"); throw new ConvexError("Empresa não encontrada");
} }
}
const normalizedRange = (range ?? "30d").toLowerCase(); const normalizedRange = (range ?? "30d").toLowerCase();
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30; const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
@ -2426,19 +2425,34 @@ export const companyOverview = query({
const startMs = now - rangeDays * ONE_DAY_MS; const startMs = now - rangeDays * ONE_DAY_MS;
// Limita consultas para evitar OOM em empresas muito grandes // Limita consultas para evitar OOM em empresas muito grandes
const tickets = await ctx.db const tickets = scopedCompanyId
? await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(2000)
: await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(2000); .take(2000);
const machines = await ctx.db const machines = scopedCompanyId
? await ctx.db
.query("machines") .query("machines")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(1000)
: await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(1000); .take(1000);
const users = await ctx.db const users = scopedCompanyId
? await ctx.db
.query("users") .query("users")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(500)
: await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500); .take(500);
const statusCounts = {} as Record<string, number>; const statusCounts = {} as Record<string, number>;
@ -2534,11 +2548,13 @@ export const companyOverview = query({
}); });
return { return {
company: { company: company
? {
id: company._id, id: company._id,
name: company.name, name: company.name,
isAvulso: company.isAvulso ?? false, isAvulso: company.isAvulso ?? false,
}, }
: null,
rangeDays, rangeDays,
generatedAt: now, generatedAt: now,
tickets: { tickets: {

View file

@ -82,6 +82,7 @@ export default defineSchema({
contacts: v.optional(v.any()), contacts: v.optional(v.any()),
locations: v.optional(v.any()), locations: v.optional(v.any()),
sla: v.optional(v.any()), sla: v.optional(v.any()),
reopenWindowDays: v.optional(v.number()),
tags: v.optional(v.array(v.string())), tags: v.optional(v.array(v.string())),
customFields: v.optional(v.any()), customFields: v.optional(v.any()),
notes: v.optional(v.string()), notes: v.optional(v.string()),
@ -199,7 +200,11 @@ export default defineSchema({
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), // minutes timeToFirstResponse: v.optional(v.number()), // minutes
responseMode: v.optional(v.string()), // "business" | "calendar"
timeToResolution: v.optional(v.number()), // minutes timeToResolution: v.optional(v.number()), // minutes
solutionMode: v.optional(v.string()), // "business" | "calendar"
alertThreshold: v.optional(v.number()), // 0.1 a 0.95
pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA
}).index("by_tenant_name", ["tenantId", "name"]), }).index("by_tenant_name", ["tenantId", "name"]),
tickets: defineTable({ tickets: defineTable({
@ -314,10 +319,15 @@ export default defineSchema({
v.object({ v.object({
id: v.string(), id: v.string(),
text: v.string(), text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()), // "checkbox" | "question"
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
answer: v.optional(v.string()), // Resposta selecionada para tipo "question"
done: v.boolean(), done: v.boolean(),
required: v.optional(v.boolean()), required: v.optional(v.boolean()),
templateId: v.optional(v.id("ticketChecklistTemplates")), templateId: v.optional(v.id("ticketChecklistTemplates")),
templateItemId: v.optional(v.string()), templateItemId: v.optional(v.string()),
templateDescription: v.optional(v.string()), // Descricao do template (copiada ao aplicar)
createdAt: v.optional(v.number()), createdAt: v.optional(v.number()),
createdBy: v.optional(v.id("users")), createdBy: v.optional(v.id("users")),
doneAt: v.optional(v.number()), doneAt: v.optional(v.number()),
@ -478,6 +488,7 @@ export default defineSchema({
startedAt: v.number(), startedAt: v.number(),
endedAt: v.optional(v.number()), endedAt: v.optional(v.number()),
lastActivityAt: v.number(), lastActivityAt: v.number(),
lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel)
unreadByMachine: v.optional(v.number()), unreadByMachine: v.optional(v.number()),
unreadByAgent: v.optional(v.number()), unreadByAgent: v.optional(v.number()),
}) })
@ -587,6 +598,29 @@ export default defineSchema({
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"]) .index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
.index("by_tenant_category", ["tenantId", "categoryId"]), .index("by_tenant_category", ["tenantId", "categoryId"]),
// SLA por empresa - permite configurar políticas de SLA específicas por cliente
// Quando um ticket é criado, o sistema busca primeiro aqui antes de usar categorySlaSettings
companySlaSettings: defineTable({
tenantId: v.string(),
companyId: v.id("companies"),
// Se categoryId for null, aplica-se a todas as categorias da empresa
categoryId: v.optional(v.id("ticketCategories")),
priority: v.string(), // URGENT, HIGH, MEDIUM, LOW, DEFAULT
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()), // "business" | "calendar"
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()), // "business" | "calendar"
alertThreshold: v.optional(v.number()), // 0.1 a 0.95 (ex: 0.8 = 80%)
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
actorId: v.optional(v.id("users")),
})
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_company_category", ["tenantId", "companyId", "categoryId"])
.index("by_tenant_company_category_priority", ["tenantId", "companyId", "categoryId", "priority"]),
ticketFields: defineTable({ ticketFields: defineTable({
tenantId: v.string(), tenantId: v.string(),
key: v.string(), key: v.string(),
@ -658,6 +692,9 @@ export default defineSchema({
v.object({ v.object({
id: v.string(), id: v.string(),
text: v.string(), text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()), // "checkbox" | "question"
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
required: v.optional(v.boolean()), required: v.optional(v.boolean()),
}) })
), ),
@ -788,6 +825,25 @@ export default defineSchema({
}) })
.index("by_machine", ["machineId"]), .index("by_machine", ["machineId"]),
// Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao
// Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada
machineSoftware: defineTable({
tenantId: v.string(),
machineId: v.id("machines"),
name: v.string(),
nameLower: v.string(), // Para busca case-insensitive
version: v.optional(v.string()),
publisher: v.optional(v.string()),
source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc
installedAt: v.optional(v.number()), // Data de instalacao (se disponivel)
detectedAt: v.number(), // Quando foi detectado pelo agente
lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat
})
.index("by_machine", ["machineId"])
.index("by_machine_name", ["machineId", "nameLower"])
.index("by_tenant_name", ["tenantId", "nameLower"])
.index("by_tenant_machine", ["tenantId", "machineId"]),
machineTokens: defineTable({ machineTokens: defineTable({
tenantId: v.string(), tenantId: v.string(),
machineId: v.id("machines"), machineId: v.id("machines"),

View file

@ -9,6 +9,26 @@ function normalizeName(value: string) {
return value.trim(); return value.trim();
} }
function normalizeMode(value?: string): "business" | "calendar" {
if (value === "business") return "business";
return "calendar";
}
function normalizeThreshold(value?: number): number {
if (value === undefined || value === null) return 0.8;
if (value < 0.1) return 0.1;
if (value > 0.95) return 0.95;
return value;
}
const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const;
function normalizePauseStatuses(statuses?: string[]): string[] {
if (!statuses || statuses.length === 0) return ["PAUSED"];
const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number]));
return filtered.length > 0 ? filtered : ["PAUSED"];
}
type AnyCtx = QueryCtx | MutationCtx; type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) { async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
@ -35,7 +55,11 @@ export const list = query({
name: policy.name, name: policy.name,
description: policy.description ?? "", description: policy.description ?? "",
timeToFirstResponse: policy.timeToFirstResponse ?? null, timeToFirstResponse: policy.timeToFirstResponse ?? null,
responseMode: policy.responseMode ?? "calendar",
timeToResolution: policy.timeToResolution ?? null, timeToResolution: policy.timeToResolution ?? null,
solutionMode: policy.solutionMode ?? "calendar",
alertThreshold: policy.alertThreshold ?? 0.8,
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
})); }));
}, },
}); });
@ -47,9 +71,14 @@ export const create = mutation({
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()), timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
}, },
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { handler: async (ctx, args) => {
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
await requireAdmin(ctx, actorId, tenantId); await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name); const trimmed = normalizeName(name);
if (trimmed.length < 2) { if (trimmed.length < 2) {
@ -68,7 +97,11 @@ export const create = mutation({
name: trimmed, name: trimmed,
description, description,
timeToFirstResponse, timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution, timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
}); });
return id; return id;
}, },
@ -82,9 +115,14 @@ export const update = mutation({
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()), timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
}, },
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { handler: async (ctx, args) => {
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
await requireAdmin(ctx, actorId, tenantId); await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId); const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) { if (!policy || policy.tenantId !== tenantId) {
@ -106,7 +144,11 @@ export const update = mutation({
name: trimmed, name: trimmed,
description, description,
timeToFirstResponse, timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution, timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
}); });
}, },
}); });

View file

@ -1,21 +1,38 @@
import type { Id } from "./_generated/dataModel" import type { Id } from "./_generated/dataModel"
export type ChecklistItemType = "checkbox" | "question"
export type TicketChecklistItem = { export type TicketChecklistItem = {
id: string id: string
text: string text: string
description?: string
type?: ChecklistItemType
options?: string[] // Para tipo "question": ["Sim", "Nao", ...]
answer?: string // Resposta selecionada para tipo "question"
done: boolean done: boolean
required?: boolean required?: boolean
templateId?: Id<"ticketChecklistTemplates"> templateId?: Id<"ticketChecklistTemplates">
templateItemId?: string templateItemId?: string
templateDescription?: string // Descricao do template (copiada ao aplicar)
createdAt?: number createdAt?: number
createdBy?: Id<"users"> createdBy?: Id<"users">
doneAt?: number doneAt?: number
doneBy?: Id<"users"> doneBy?: Id<"users">
} }
export type TicketChecklistTemplateItem = {
id: string
text: string
description?: string
type?: string // "checkbox" | "question" - string para compatibilidade com schema
options?: string[]
required?: boolean
}
export type TicketChecklistTemplateLike = { export type TicketChecklistTemplateLike = {
_id: Id<"ticketChecklistTemplates"> _id: Id<"ticketChecklistTemplates">
items: Array<{ id: string; text: string; required?: boolean }> description?: string
items: TicketChecklistTemplateItem[]
} }
export function normalizeChecklistText(input: string) { export function normalizeChecklistText(input: string) {
@ -53,13 +70,18 @@ export function applyChecklistTemplateToItems(
const key = `${String(template._id)}:${templateItemId}` const key = `${String(template._id)}:${templateItemId}`
if (existingKeys.has(key)) continue if (existingKeys.has(key)) continue
existingKeys.add(key) existingKeys.add(key)
const itemType = tplItem.type ?? "checkbox"
next.push({ next.push({
id: generateId(), id: generateId(),
text, text,
description: tplItem.description,
type: itemType as ChecklistItemType,
options: itemType === "question" ? tplItem.options : undefined,
done: false, done: false,
required: typeof tplItem.required === "boolean" ? tplItem.required : true, required: typeof tplItem.required === "boolean" ? tplItem.required : true,
templateId: template._id, templateId: template._id,
templateItemId, templateItemId,
templateDescription: template.description,
createdAt: now, createdAt: now,
createdBy: options.actorId, createdBy: options.actorId,
}) })

View file

@ -8,6 +8,45 @@ import { v } from "convex/values"
import { renderSimpleNotificationEmailHtml } from "./reactEmail" import { renderSimpleNotificationEmailHtml } from "./reactEmail"
import { buildBaseUrl } from "./url" import { buildBaseUrl } from "./url"
// API do Next.js para verificar preferências
async function sendViaNextApi(params: {
type: string
to: { email: string; name?: string; userId?: string }
subject: string
data: Record<string, unknown>
tenantId?: string
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
const baseUrl = buildBaseUrl()
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
if (!token) {
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
return { success: false, reason: "no_token" }
}
try {
const response = await fetch(`${baseUrl}/api/notifications/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
console.error("[ticketNotifications] Erro na API:", error)
return { success: false, reason: "api_error" }
}
return await response.json()
} catch (error) {
console.error("[ticketNotifications] Erro ao chamar API:", error)
return { success: false, reason: "fetch_error" }
}
}
function b64(input: string) { function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64") return Buffer.from(input, "utf8").toString("base64")
} }
@ -281,25 +320,109 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
} }
} }
export const sendPublicCommentEmail = action({ export const sendTicketCreatedEmail = action({
args: { args: {
to: v.string(), to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(), ticketId: v.string(),
reference: v.number(), reference: v.number(),
subject: v.string(), subject: v.string(),
priority: v.string(),
tenantId: v.optional(v.string()),
}, },
handler: async (_ctx, { to, ticketId, reference, subject }) => { handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityLabel = priorityLabels[priority] ?? priority
const mailSubject = `Novo chamado #${reference} aberto`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_created",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
status: "Pendente",
priority: priorityLabel,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
const html = await renderSimpleNotificationEmailHtml({
title: `Novo chamado #${reference} aberto`,
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
ctaLabel: "Ver chamado",
ctaUrl: url,
})
await sendSmtpMail(smtp, to, mailSubject, html)
return { ok: true }
},
})
export const sendPublicCommentEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "comment_public",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig() const smtp = buildSmtpConfig()
if (!smtp) { if (!smtp) {
console.warn("SMTP not configured; skipping ticket comment email") console.warn("SMTP not configured; skipping ticket comment email")
return { skipped: true } return { skipped: true }
} }
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
const html = await renderSimpleNotificationEmailHtml({ const html = await renderSimpleNotificationEmailHtml({
title: `Nova atualização no seu chamado #${reference}`, title: `Nova atualização no seu chamado #${reference}`,
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`, message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
ctaLabel: "Abrir e responder", ctaLabel: "Abrir e responder",
ctaUrl: url, ctaUrl: url,
}) })
@ -311,22 +434,45 @@ export const sendPublicCommentEmail = action({
export const sendResolvedEmail = action({ export const sendResolvedEmail = action({
args: { args: {
to: v.string(), to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(), ticketId: v.string(),
reference: v.number(), reference: v.number(),
subject: v.string(), subject: v.string(),
tenantId: v.optional(v.string()),
}, },
handler: async (_ctx, { to, ticketId, reference, subject }) => { handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_resolved",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig() const smtp = buildSmtpConfig()
if (!smtp) { if (!smtp) {
console.warn("SMTP not configured; skipping ticket resolution email") console.warn("SMTP not configured; skipping ticket resolution email")
return { skipped: true } return { skipped: true }
} }
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
const html = await renderSimpleNotificationEmailHtml({ const html = await renderSimpleNotificationEmailHtml({
title: `Chamado #${reference} encerrado`, title: `Chamado #${reference} encerrado`,
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
ctaLabel: "Ver detalhes", ctaLabel: "Ver detalhes",
ctaUrl: url, ctaUrl: url,
}) })
@ -339,9 +485,23 @@ export const sendAutomationEmail = action({
args: { args: {
to: v.array(v.string()), to: v.array(v.string()),
subject: v.string(), subject: v.string(),
html: v.string(), emailProps: v.object({
title: v.string(),
message: v.string(),
ticket: v.object({
reference: v.number(),
subject: v.string(),
status: v.optional(v.union(v.string(), v.null())),
priority: v.optional(v.union(v.string(), v.null())),
companyName: v.optional(v.union(v.string(), v.null())),
requesterName: v.optional(v.union(v.string(), v.null())),
assigneeName: v.optional(v.union(v.string(), v.null())),
}),
ctaLabel: v.string(),
ctaUrl: v.string(),
}),
}, },
handler: async (_ctx, { to, subject, html }) => { handler: async (_ctx, { to, subject, emailProps }) => {
const smtp = buildSmtpConfig() const smtp = buildSmtpConfig()
if (!smtp) { if (!smtp) {
console.warn("SMTP not configured; skipping automation email") console.warn("SMTP not configured; skipping automation email")
@ -357,10 +517,45 @@ export const sendAutomationEmail = action({
return { skipped: true, reason: "no_recipients" } return { skipped: true, reason: "no_recipients" }
} }
// Renderiza o HTML aqui (ambiente Node.js suporta imports dinâmicos)
const { renderAutomationEmailHtml } = await import("./reactEmail")
const html = await renderAutomationEmailHtml({
title: emailProps.title,
message: emailProps.message,
ticket: {
reference: emailProps.ticket.reference,
subject: emailProps.ticket.subject,
status: emailProps.ticket.status ?? null,
priority: emailProps.ticket.priority ?? null,
companyName: emailProps.ticket.companyName ?? null,
requesterName: emailProps.ticket.requesterName ?? null,
assigneeName: emailProps.ticket.assigneeName ?? null,
},
ctaLabel: emailProps.ctaLabel,
ctaUrl: emailProps.ctaUrl,
})
const results: Array<{ recipient: string; sent: boolean; error?: string }> = []
for (const recipient of recipients) { for (const recipient of recipients) {
try {
await sendSmtpMail(smtp, recipient, subject, html) await sendSmtpMail(smtp, recipient, subject, html)
results.push({ recipient, sent: true })
console.log(`[automation-email] Enviado para ${recipient}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
results.push({ recipient, sent: false, error: errorMessage })
console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`)
}
} }
return { ok: true, sent: recipients.length } const sent = results.filter((r) => r.sent).length
const failed = results.filter((r) => !r.sent).length
if (failed > 0) {
console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`)
}
return { ok: sent > 0, sent, failed, results }
}, },
}) })

View file

@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato", NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro", WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento", IN_PROCEDURE: "Em procedimento",
END_LIVE_CHAT: "Chat ao vivo encerrado",
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL, [LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
}; };
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
@ -272,13 +273,60 @@ async function resolveTicketSlaSnapshot(
ctx: AnyCtx, ctx: AnyCtx,
tenantId: string, tenantId: string,
category: Doc<"ticketCategories"> | null, category: Doc<"ticketCategories"> | null,
priority: string priority: string,
companyId?: Id<"companies"> | null
): Promise<TicketSlaSnapshot | null> { ): Promise<TicketSlaSnapshot | null> {
if (!category) { if (!category) {
return null; return null;
} }
const normalizedPriority = priority.trim().toUpperCase(); const normalizedPriority = priority.trim().toUpperCase();
const rule =
// 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado)
let rule: {
responseTargetMinutes?: number;
responseMode?: string;
solutionTargetMinutes?: number;
solutionMode?: string;
alertThreshold?: number;
pauseStatuses?: string[];
} | null = null;
if (companyId) {
// Tenta: empresa + categoria + prioridade
rule = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority)
)
.first();
// Fallback: empresa + categoria + DEFAULT
if (!rule) {
rule = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first();
}
// Fallback: empresa + todas categorias (categoryId null) + prioridade
if (!rule) {
const allCategoriesRules = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.filter((q) => q.eq(q.field("categoryId"), undefined))
.take(10);
rule = allCategoriesRules.find((r) => r.priority === normalizedPriority) ??
allCategoriesRules.find((r) => r.priority === "DEFAULT") ??
null;
}
}
// 2. Se não encontrou SLA da empresa, usa SLA da categoria (comportamento padrão)
if (!rule) {
rule =
(await ctx.db (await ctx.db
.query("categorySlaSettings") .query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) => .withIndex("by_tenant_category_priority", (q) =>
@ -291,6 +339,8 @@ async function resolveTicketSlaSnapshot(
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
) )
.first()); .first());
}
if (!rule) { if (!rule) {
return null; return null;
} }
@ -872,23 +922,6 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
} }
} }
export function buildAssigneeChangeComment(
reason: string,
context: { previousName: string; nextName: string },
): string {
const normalized = reason.replace(/\r\n/g, "\n").trim();
const lines = normalized
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const previous = escapeHtml(context.previousName || "Não atribuído");
const next = escapeHtml(context.nextName || "Não atribuído");
const reasonHtml = lines.length
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
: `<p>—</p>`;
return `<p><strong>Responsável atualizado:</strong> ${previous}${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
}
function truncateSubject(subject: string) { function truncateSubject(subject: string) {
if (subject.length <= 60) return subject if (subject.length <= 60) return subject
return `${subject.slice(0, 57)}` return `${subject.slice(0, 57)}`
@ -2098,10 +2131,15 @@ export const getById = query({
? t.checklist.map((item) => ({ ? t.checklist.map((item) => ({
id: item.id, id: item.id,
text: item.text, text: item.text,
description: item.description ?? undefined,
type: item.type ?? "checkbox",
options: item.options ?? undefined,
answer: item.answer ?? undefined,
done: item.done, done: item.done,
required: typeof item.required === "boolean" ? item.required : true, required: typeof item.required === "boolean" ? item.required : true,
templateId: item.templateId ? String(item.templateId) : undefined, templateId: item.templateId ? String(item.templateId) : undefined,
templateItemId: item.templateItemId ?? undefined, templateItemId: item.templateItemId ?? undefined,
templateDescription: item.templateDescription ?? undefined,
createdAt: item.createdAt ?? undefined, createdAt: item.createdAt ?? undefined,
createdBy: item.createdBy ? String(item.createdBy) : undefined, createdBy: item.createdBy ? String(item.createdBy) : undefined,
doneAt: item.doneAt ?? undefined, doneAt: item.doneAt ?? undefined,
@ -2337,7 +2375,7 @@ export const create = mutation({
avatarUrl: requester.avatarUrl ?? undefined, avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined, teams: requester.teams ?? undefined,
} }
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) // Resolve a empresa primeiro para poder verificar SLA específico
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
if (!companyDoc && machineDoc?.companyId) { if (!companyDoc && machineDoc?.companyId) {
const candidateCompany = await ctx.db.get(machineDoc.companyId) const candidateCompany = await ctx.db.get(machineDoc.companyId)
@ -2349,6 +2387,8 @@ export const create = mutation({
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined : undefined
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined
// Resolve SLA passando companyId para verificar regras específicas da empresa
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority, resolvedCompanyId)
let checklist = manualChecklist let checklist = manualChecklist
for (const templateId of args.checklistTemplateIds ?? []) { for (const templateId of args.checklistTemplateIds ?? []) {
@ -2456,6 +2496,28 @@ export const create = mutation({
createdAt: now, createdAt: now,
}); });
// Notificação por e-mail: ticket criado para o solicitante
try {
const requesterEmail = requester?.email
if (requesterEmail) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
to: requesterEmail,
userId: String(requester._id),
userName: requester.name ?? undefined,
ticketId: String(id),
reference: nextRef,
subject,
priority: args.priority,
tenantId: args.tenantId,
})
}
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e)
}
if (initialAssigneeId && initialAssignee) { if (initialAssigneeId && initialAssignee) {
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: id, ticketId: id,
@ -2647,6 +2709,49 @@ export const setChecklistItemRequired = mutation({
}, },
}); });
export const setChecklistItemAnswer = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
answer: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
await requireTicketStaff(ctx, actorId, ticketDoc);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const item = checklist[index]!;
if (item.type !== "question") {
throw new ConvexError("Este item não é uma pergunta.");
}
const now = Date.now();
const normalizedAnswer = answer?.trim() ?? "";
const isDone = normalizedAnswer.length > 0;
const nextChecklist = checklist.map((it) => {
if (it.id !== itemId) return it;
if (isDone) {
return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
}
return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const removeChecklistItem = mutation({ export const removeChecklistItem = mutation({
args: { args: {
ticketId: v.id("tickets"), ticketId: v.id("tickets"),
@ -2700,6 +2805,34 @@ export const completeAllChecklistItems = mutation({
}, },
}); });
export const uncompleteAllChecklistItems = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, actorId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
if (checklist.length === 0) return { ok: true };
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.done === false) return item;
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const applyChecklistTemplate = mutation({ export const applyChecklistTemplate = mutation({
args: { args: {
ticketId: v.id("tickets"), ticketId: v.id("tickets"),
@ -2851,15 +2984,19 @@ export const addComment = mutation({
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch }); await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
// Notificação por e-mail: comentário público para o solicitante // Notificação por e-mail: comentário público para o solicitante
try { try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const snapshotEmail = requesterSnapshot?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
const schedulerRunAfter = ctx.scheduler?.runAfter const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") { if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail, to: snapshotEmail,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName: requesterSnapshot?.name ?? undefined,
ticketId: String(ticketDoc._id), ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0, reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "", subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
}) })
} }
} }
@ -3090,7 +3227,18 @@ export async function resolveTicketHandler(
throw new ConvexError("Chamado vinculado não encontrado") throw new ConvexError("Chamado vinculado não encontrado")
} }
const reopenDays = resolveReopenWindowDays(reopenWindowDays) // Buscar prazo de reabertura da empresa do ticket (se existir)
let companyReopenDays: number | null = null
if (ticketDoc.companyId) {
const company = await ctx.db.get(ticketDoc.companyId)
if (company && typeof company.reopenWindowDays === "number") {
companyReopenDays = company.reopenWindowDays
}
}
// Prioridade: 1) valor passado explicitamente, 2) valor da empresa, 3) padrão
const effectiveReopenDays = reopenWindowDays ?? companyReopenDays
const reopenDays = resolveReopenWindowDays(effectiveReopenDays)
const reopenDeadline = computeReopenDeadline(now, reopenDays) const reopenDeadline = computeReopenDeadline(now, reopenDays)
const normalizedStatus = "RESOLVED" const normalizedStatus = "RESOLVED"
const relatedIdList = Array.from( const relatedIdList = Array.from(
@ -3127,16 +3275,21 @@ export async function resolveTicketHandler(
// Notificação por e-mail: encerramento do chamado // Notificação por e-mail: encerramento do chamado
try { try {
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const email = requesterDoc?.email || requesterSnapshot?.email || null
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
if (email) { if (email) {
const schedulerRunAfter = ctx.scheduler?.runAfter const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") { if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email, to: email,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName,
ticketId: String(ticketId), ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0, reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "", subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
}) })
} }
} }
@ -3373,38 +3526,6 @@ export const changeAssignee = mutation({
createdAt: now, createdAt: now,
}); });
if (normalizedReason.length > 0) {
const commentBody = buildAssigneeChangeComment(normalizedReason, {
previousName: previousAssigneeName,
nextName: nextAssigneeName,
})
const commentPlainLength = plainTextLength(commentBody)
if (commentPlainLength > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const authorSnapshot: CommentAuthorSnapshot = {
name: viewerUser.name,
email: viewerUser.email,
avatarUrl: viewerUser.avatarUrl ?? undefined,
teams: viewerUser.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId,
authorId: actorId,
visibility: "INTERNAL",
body: commentBody,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "COMMENT_ADDED",
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
createdAt: now,
})
}
}, },
}); });
@ -3734,6 +3855,8 @@ export const postChatMessage = mutation({
await ctx.db.patch(ticketId, { updatedAt: now }) await ctx.db.patch(ticketId, { updatedAt: now })
// Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa // Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
// IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions
// O Convex faz retry automatico em caso de OCC conflict
const actorRole = participant.role?.toUpperCase() ?? "" const actorRole = participant.role?.toUpperCase() ?? ""
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) { if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
const activeSession = await ctx.db const activeSession = await ctx.db
@ -3743,12 +3866,17 @@ export const postChatMessage = mutation({
.first() .first()
if (activeSession) { if (activeSession) {
// Refetch para garantir valor mais recente (OCC protection)
const freshSession = await ctx.db.get(activeSession._id)
if (freshSession) {
await ctx.db.patch(activeSession._id, { await ctx.db.patch(activeSession._id, {
unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1, unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1,
lastActivityAt: now, lastActivityAt: now,
lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente
}) })
} }
} }
}
return { ok: true, messageId } return { ok: true, messageId }
}, },

View file

@ -279,6 +279,86 @@ export const deleteUser = mutation({
}, },
}); });
/**
* Atualiza o avatar de um usuário.
* Passa avatarUrl como null para remover o avatar.
* Também atualiza os snapshots em comentários e tickets.
*/
export const updateAvatar = mutation({
args: {
tenantId: v.string(),
email: v.string(),
avatarUrl: v.union(v.string(), v.null()),
},
handler: async (ctx, { tenantId, email, avatarUrl }) => {
const user = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
.first()
if (!user) {
return { status: "not_found" }
}
// Atualiza o avatar do usuário - usa undefined para remover o campo
const normalizedAvatarUrl = avatarUrl ?? undefined
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
// Cria snapshot base sem avatarUrl se for undefined
// Isso garante que o campo seja realmente removido do snapshot
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
name: user.name,
email: user.email,
}
if (normalizedAvatarUrl !== undefined) {
baseSnapshot.avatarUrl = normalizedAvatarUrl
}
if (user.teams && user.teams.length > 0) {
baseSnapshot.teams = user.teams
}
// Atualiza snapshots em comentários
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_author", (q) => q.eq("authorId", user._id))
.take(10000)
if (comments.length > 0) {
await Promise.all(
comments.map(async (comment) => {
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
}),
)
}
// Atualiza snapshots de requester em tickets
const requesterTickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", user._id))
.take(10000)
if (requesterTickets.length > 0) {
for (const t of requesterTickets) {
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
}
}
// Atualiza snapshots de assignee em tickets
const assigneeTickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", user._id))
.take(10000)
if (assigneeTickets.length > 0) {
for (const t of assigneeTickets) {
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
}
}
return { status: "updated", avatarUrl: normalizedAvatarUrl }
},
})
export const assignCompany = mutation({ export const assignCompany = mutation({
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") }, args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
handler: async (ctx, { tenantId, email, companyId, actorId }) => { handler: async (ctx, { tenantId, email, companyId, actorId }) => {

View file

@ -1,11 +1,11 @@
# Deploy Manual via VPS # Deploy Manual via VPS
## Acesso rápido ## Acesso rápido
- Host: 31.220.78.20 - Host: 154.12.253.40
- Usuário: root - Usuário: root
- Caminho do projeto: /srv/apps/sistema - Caminho do projeto: /srv/apps/sistema
- Chave SSH (local): ./codex_ed25519 (chmod 600) - Chave SSH (local): ./codex_ed25519 (chmod 600)
- Login: `ssh -i ./codex_ed25519 root@31.220.78.20` - Login: `ssh -i ./codex_ed25519 root@154.12.253.40`
## Passo a passo resumido ## Passo a passo resumido
1. Conectar na VPS usando o comando acima. 1. Conectar na VPS usando o comando acima.

View file

@ -1,4 +1,4 @@
# Guia de Desenvolvimento — 18/10/2025 # Guia de Desenvolvimento — 18/12/2025
Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções. Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções.
@ -6,7 +6,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
- **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback). - **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback).
- **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun. - **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun.
- **Next.js 16**: Projeto roda em `next@16.0.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback. - **Next.js 16**: Projeto roda em `next@16.0.10` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
- **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente. - **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente.
- **Banco DEV**: PostgreSQL local (Docker recomendado). Defina `DATABASE_URL` apontando para seu PostgreSQL. - **Banco DEV**: PostgreSQL local (Docker recomendado). Defina `DATABASE_URL` apontando para seu PostgreSQL.
- **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases. - **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases.
@ -47,7 +47,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
## Next.js 16 (estável) ## Next.js 16 (estável)
- Mantemos o projeto em `next@16.0.8`, com React 19 e o App Router completo. - Mantemos o projeto em `next@16.0.10`, com React 19 e o App Router completo.
- **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas. - **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas.
- **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`. - **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`.
@ -200,8 +200,8 @@ PY
## Referências úteis ## Referências úteis
- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`. - **Deploy (Swarm)**: veja `docs/OPERATIONS.md`.
- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. - **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.md`.
- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`. - **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`.
> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. > Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem.

296
docs/FORGEJO-CI-CD.md Normal file
View file

@ -0,0 +1,296 @@
# Forgejo CI/CD - Documentacao
Este documento descreve a configuracao do Forgejo como alternativa ao GitHub Actions para CI/CD self-hosted.
## Por que Forgejo?
A partir de marco de 2026, o GitHub passara a cobrar $0.002 por minuto de execucao em self-hosted runners. O Forgejo Actions oferece a mesma experiencia visual e funcionalidade sem custo adicional.
## Arquitetura
```
Claude Code / VS Code
|
Git local
|
git push origin main (GitHub - backup)
git push forgejo main (Forgejo - CI/CD)
|
Forgejo (git.esdrasrenan.com.br)
|
Forgejo Actions (dispara automaticamente)
|
Forgejo Runner (VPS)
|
Docker Swarm deploy
```
**Fluxo:** Push para ambos os remotes. O push para `forgejo` dispara o CI/CD.
```bash
# Push para ambos (recomendado)
git push origin main && git push forgejo main
# Ou use o alias configurado
git push-all
```
## URLs e Credenciais
| Servico | URL | Usuario |
|---------|-----|---------|
| Forgejo UI | https://git.esdrasrenan.com.br | esdras |
| Forgejo SSH | git@git.esdrasrenan.com.br:2222 | - |
| Actions | https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions | - |
**Senha inicial:** `ForgejoAdmin2025!` (altere apos primeiro acesso)
## Estrutura de Arquivos
```
projeto/
├── .forgejo/
│ └── workflows/
│ ├── ci-cd-web-desktop.yml # Deploy principal (VPS + Convex)
│ └── quality-checks.yml # Lint, test, build
├── .github/
│ └── workflows/ # Workflows originais do GitHub
│ └── ...
└── forgejo/
├── stack.yml # Stack Docker do Forgejo
└── setup-runner.sh # Script de setup do runner
```
## Configuracao na VPS
### Forgejo Server
Rodando como servico Docker Swarm:
```bash
# Localização do stack
/srv/forgejo/stack.yml
# Comandos uteis
docker service ls --filter "name=forgejo"
docker service logs forgejo_forgejo --tail 100
docker stack deploy -c /srv/forgejo/stack.yml forgejo
```
### Forgejo Runner
Rodando como servico systemd:
```bash
# Localização
/srv/forgejo-runner/
# Arquivos
/srv/forgejo-runner/forgejo-runner # Binario
/srv/forgejo-runner/config.yaml # Configuracao
/srv/forgejo-runner/.runner # Registro
# Comandos uteis
systemctl status forgejo-runner
systemctl restart forgejo-runner
journalctl -u forgejo-runner -f
# Labels do runner
- ubuntu-latest:docker://node:20-bookworm
- self-hosted:host
- linux:host
- vps:host
```
## Fluxo de Trabalho
O repositorio no Forgejo recebe pushes diretos (nao e mais um mirror).
### Uso diario
```bash
# Trabalhe normalmente
git add .
git commit -m "sua mensagem"
# Push para GitHub (backup) e Forgejo (CI/CD)
git push origin main && git push forgejo main
# Acompanhe o CI/CD em:
# https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions
```
### Configurar alias (opcional)
```bash
# Adicionar alias para push em ambos
git config alias.push-all '!git push origin main && git push forgejo main'
# Usar:
git push-all
```
## Workflows Disponiveis
### ci-cd-web-desktop.yml
Triggers:
- Push na branch `main`
- Tags `v*.*.*`
- workflow_dispatch (manual)
Jobs:
1. **changes** - Detecta arquivos alterados
2. **deploy** - Deploy na VPS (Next.js + Docker Swarm, usando Bun)
3. **convex_deploy** - Deploy das functions Convex
4. ~~**desktop_release**~~ - Build do app desktop (comentado - sem runner Windows)
### quality-checks.yml
Triggers:
- Push na branch `main`
- Pull requests para `main`
Jobs:
1. **lint-test-build** - Lint, testes e build
## Diferenca do GitHub Actions
Os workflows do Forgejo sao quase identicos aos do GitHub Actions. Principais diferencas:
1. **Localizacao:** `.forgejo/workflows/` em vez de `.github/workflows/`
2. **Actions URL:** Usar `https://github.com/` prefixo nas actions
```yaml
# GitHub Actions
uses: actions/checkout@v4
# Forgejo Actions
uses: https://github.com/actions/checkout@v4
```
3. **runs-on:** Usar labels do self-hosted runner em vez de `ubuntu-latest`
```yaml
# GitHub Actions (hosted runner)
runs-on: ubuntu-latest
# Forgejo Actions (self-hosted)
runs-on: [ self-hosted, linux, vps ]
```
4. **Secrets:** Configurar em Settings > Actions > Secrets no Forgejo
## Manutencao
### Atualizar Forgejo
```bash
ssh root@154.12.253.40
cd /srv/forgejo
# Editar stack.yml para nova versao da imagem
docker stack deploy -c stack.yml forgejo
```
### Atualizar Runner
```bash
ssh root@154.12.253.40
cd /srv/forgejo-runner
systemctl stop forgejo-runner
# Baixar nova versao
RUNNER_VERSION="6.2.2" # ajustar versao
curl -sL -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
chmod +x forgejo-runner
systemctl start forgejo-runner
```
### Re-registrar Runner
Se o runner perder a conexao:
```bash
ssh root@154.12.253.40
cd /srv/forgejo-runner
# Gerar novo token no Forgejo
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
/usr/local/bin/gitea --config /data/gitea/conf/app.ini actions generate-runner-token
# Re-registrar
systemctl stop forgejo-runner
rm .runner
./forgejo-runner register \
--instance https://git.esdrasrenan.com.br \
--token "NOVO_TOKEN" \
--name "vps-runner" \
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
--no-interactive
systemctl start forgejo-runner
```
### Backup
```bash
# Backup do volume do Forgejo
docker run --rm -v forgejo_forgejo_data:/data -v /backup:/backup alpine \
tar czf /backup/forgejo-backup-$(date +%Y%m%d).tar.gz /data
```
## Troubleshooting
### Runner nao aparece online
```bash
# Verificar status
systemctl status forgejo-runner
journalctl -u forgejo-runner --no-pager -n 50
# Verificar conectividade
curl -s https://git.esdrasrenan.com.br/api/healthz
# Se o runner mostrar erro "404 Not Found" apos reinicio do Forgejo:
systemctl restart forgejo-runner
```
### Workflow nao dispara apos push
1. Verificar se o arquivo esta em `.forgejo/workflows/`
2. Verificar se Actions esta habilitado no repositorio (Settings > Actions)
3. Verificar se o runner esta online (Settings > Actions > Runners)
4. **Regenerar hooks do repositorio:**
```bash
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
/usr/local/bin/gitea admin regenerate hooks --config /data/gitea/conf/app.ini
```
### Erro de LevelDB Lock (queue nao inicia)
Se o Forgejo mostrar erro `unable to lock level db at /data/gitea/queues/common`:
1. O stack.yml ja usa `FORGEJO__queue__TYPE=channel` para evitar esse problema
2. Se o erro persistir, limpe o diretorio de queues:
```bash
docker exec $(docker ps -q --filter "name=forgejo_forgejo") \
rm -rf /data/gitea/queues/*
docker service update --force forgejo_forgejo
```
### Erro de permissao no deploy
O runner precisa de acesso ao Docker:
```bash
# Verificar grupo docker
groups runner
# Adicionar se necessario
usermod -aG docker runner
systemctl restart forgejo-runner
```
## Referencias
- [Forgejo Documentation](https://forgejo.org/docs/)
- [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/)
- [Forgejo Runner](https://code.forgejo.org/forgejo/runner)

166
docs/LOCAL-DEV.md Normal file
View file

@ -0,0 +1,166 @@
# Desenvolvimento Local
Guia para rodar o projeto localmente conectando aos dados de producao.
## Pre-requisitos
- [Bun](https://bun.sh/) 1.3+
- [Docker](https://www.docker.com/) (para PostgreSQL)
- Node.js 20+ (opcional, usado pelo tsx)
## 1. Subir o PostgreSQL
O sistema usa PostgreSQL para autenticacao (Better Auth). Os dados de tickets ficam no Convex.
```bash
docker run -d \
--name postgres-chamados \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:18
```
Para verificar se esta rodando:
```bash
docker ps | grep postgres-chamados
```
Para parar/iniciar posteriormente:
```bash
docker stop postgres-chamados
docker start postgres-chamados
```
## 2. Configurar variaveis de ambiente
O arquivo `.env.local` ja vem configurado para desenvolvimento local apontando para o Convex de producao:
```env
NODE_ENV=development
# URLs locais
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
# Convex de producao (dados reais)
NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br
CONVEX_INTERNAL_URL=https://convex.esdrasrenan.com.br
# PostgreSQL local (apenas autenticacao)
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
```
## 3. Instalar dependencias
```bash
bun install
```
## 4. Gerar cliente Prisma e aplicar schema
```bash
bun run prisma:generate
bunx prisma db push
```
## 5. Criar usuarios de desenvolvimento
O seed cria usuarios locais para autenticacao:
```bash
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
### Credenciais padrao
| Usuario | Email | Senha | Role |
|---------------|----------------------|------------|-------|
| Administrador | `admin@sistema.dev` | `admin123` | admin |
| Agentes | `*@rever.com.br` | `agent123` | agent |
## 6. Iniciar o servidor de desenvolvimento
```bash
bun run dev:bun
```
Acesse: http://localhost:3000
## Arquitetura Local vs Producao
```
┌─────────────────────────────────────────────────────────────┐
│ DESENVOLVIMENTO LOCAL │
├─────────────────────────────────────────────────────────────┤
│ │
│ localhost:3000 (Next.js) │
│ │ │
│ ├──► PostgreSQL local (porta 5432) │
│ │ └── Autenticacao (Better Auth) │
│ │ └── Usuarios, sessoes, contas │
│ │ │
│ └──► convex.esdrasrenan.com.br (remoto) │
│ └── Dados de producao │
│ └── Tickets, empresas, filas, etc. │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Comandos uteis
| Comando | Descricao |
|---------|-----------|
| `bun run dev:bun` | Inicia servidor de desenvolvimento com Turbopack |
| `bun run build:bun` | Build de producao |
| `bun run lint` | Verificar codigo com ESLint |
| `bun test` | Rodar testes |
| `bunx prisma studio` | Interface visual do banco de dados |
## Solucao de problemas
### Erro de conexao com PostgreSQL
```
Error: P1001: Can't reach database server at localhost:5432
```
**Solucao:** Verifique se o container Docker esta rodando:
```bash
docker start postgres-chamados
```
### Erro de migracao (tipo DATETIME)
Se aparecer erro sobre tipo `DATETIME` ao rodar migrations, use `db push` em vez de `migrate`:
```bash
bunx prisma db push --accept-data-loss
```
### Usuario nao consegue logar
Os usuarios de autenticacao ficam no PostgreSQL local, nao no Convex. Rode o seed novamente:
```bash
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
### Limpar banco e recriar
```bash
docker stop postgres-chamados
docker rm postgres-chamados
docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
bunx prisma db push
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
## Proximos passos
- Para deploy em producao, consulte `docs/OPERACAO-PRODUCAO.md`
- Para configuracao de SMTP, consulte `docs/SMTP.md`
- Para testes automatizados, consulte `docs/testes-vitest.md`

View file

@ -3,6 +3,7 @@
Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação. Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação.
## Visão Geral ## Visão Geral
- **Desenvolvimento local**: `docs/LOCAL-DEV.md` (setup rapido para rodar localmente)
- Operações (produção): `docs/operations.md` - Operações (produção): `docs/operations.md`
- Guia de desenvolvimento: `docs/DEV.md` - Guia de desenvolvimento: `docs/DEV.md`
- Desktop (Tauri): - Desktop (Tauri):

View file

@ -18,7 +18,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
- Export/backup local de tickets: endpoint `POST /api/admin/tickets/archive-local` (staff) grava tickets resolvidos mais antigos que N dias em JSONL dentro de `ARCHIVE_DIR` (padrão `./archives`). Usa `exportResolvedTicketsToDisk` com segredo interno (`INTERNAL_HEALTH_TOKEN`/`REPORTS_CRON_SECRET`). - Export/backup local de tickets: endpoint `POST /api/admin/tickets/archive-local` (staff) grava tickets resolvidos mais antigos que N dias em JSONL dentro de `ARCHIVE_DIR` (padrão `./archives`). Usa `exportResolvedTicketsToDisk` com segredo interno (`INTERNAL_HEALTH_TOKEN`/`REPORTS_CRON_SECRET`).
## Como acessar tickets antigos sem perda ## Como acessar tickets antigos sem perda
- Base quente: Prisma (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets. - Base quente: Prisma (PostgreSQL) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
- Se um dia for preciso offload (ex.: >50k tickets): - Se um dia for preciso offload (ex.: >50k tickets):
- Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat). - Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat).
- Gravar um marcador de offload no DB quente (ex.: `ticket_archived_at`, `archive_key`). - Gravar um marcador de offload no DB quente (ex.: `ticket_archived_at`, `archive_key`).
@ -33,7 +33,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
## Checks operacionais sugeridos (manuais) ## Checks operacionais sugeridos (manuais)
- Tamanho do banco do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "ls -lh /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3"` - Tamanho do banco do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "ls -lh /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3"`
- Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"` - Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"`
- Alvos: <100-200 MB para o SQLite e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual. - Alvos: <100-200 MB para o SQLite do Convex e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
## Estado atual e proximos passos ## Estado atual e proximos passos
- Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes. - Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes.

252
docs/SETUP.md Normal file
View file

@ -0,0 +1,252 @@
# Setup em Novo Computador
Guia rapido para configurar o ambiente de desenvolvimento em uma nova maquina.
## Pre-requisitos
- **Git** instalado
- **Bun** 1.3+ ([bun.sh](https://bun.sh))
- **Docker** (para PostgreSQL local)
- **Node.js** 20+ (opcional, para algumas ferramentas)
### Instalar Bun (se ainda nao tiver)
```bash
# Linux/macOS/WSL
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
## Configurar Autenticacao (Repositorio Privado)
Se o repositorio for privado, configure autenticacao SSH antes de clonar.
### Opcao 1: SSH Key (Recomendado)
```bash
# 1. Gerar chave SSH (se nao tiver)
ssh-keygen -t ed25519 -C "seu-email@exemplo.com"
# Pressione Enter para aceitar o local padrao
# Defina uma senha ou deixe em branco
# 2. Copiar a chave publica
# Linux/macOS/WSL:
cat ~/.ssh/id_ed25519.pub
# Windows (PowerShell):
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub
# Windows (CMD):
type %USERPROFILE%\.ssh\id_ed25519.pub
```
**Adicionar a chave nos servicos:**
- **GitHub:** Settings > SSH and GPG keys > New SSH key
- **Forgejo:** Settings > SSH / GPG Keys > Add Key
### Opcao 2: Personal Access Token (PAT)
1. **GitHub:** Settings > Developer settings > Personal access tokens > Tokens (classic)
2. Gerar token com permissao `repo`
3. Usar o token como senha quando o git pedir
Para salvar o token (nao precisar digitar toda vez):
```bash
git config --global credential.helper store
# Proximo push/pull vai pedir usuario e token, e salvar
```
## Setup Rapido
### 1. Clonar o repositorio
**Repositorio publico (HTTPS):**
```bash
git clone https://github.com/esdrasrenan/sistema-de-chamados.git
cd sistema-de-chamados
```
**Repositorio privado (SSH):**
```bash
git clone git@github.com:esdrasrenan/sistema-de-chamados.git
cd sistema-de-chamados
```
Ou se ja tiver o repositorio:
```bash
cd sistema-de-chamados
git pull origin main
```
### 2. Configurar remotes (para CI/CD)
**Repositorio publico (HTTPS):**
```bash
git remote add forgejo https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git
```
**Repositorio privado (SSH):**
```bash
# Mudar origin para SSH (se clonou via HTTPS)
git remote set-url origin git@github.com:esdrasrenan/sistema-de-chamados.git
# Adicionar forgejo via SSH (porta 2222)
git remote add forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git
```
**Verificar remotes:**
```bash
git remote -v
# Deve mostrar (exemplo com SSH):
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (fetch)
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (push)
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (fetch)
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (push)
```
### 3. Instalar dependencias
```bash
bun install
```
### 4. Configurar banco de dados
```bash
# Subir PostgreSQL via Docker
docker run -d \
--name postgres-dev \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:18
# Criar arquivo .env
cp .env.example .env
```
Edite o `.env` e configure:
```env
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
BETTER_AUTH_SECRET=sua-chave-secreta-aqui
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
```
### 5. Inicializar o banco
```bash
# Gerar cliente Prisma
bun run prisma:generate
# Criar tabelas no banco
bunx prisma db push
# Popular dados iniciais
bun run auth:seed
```
### 6. Rodar o projeto
```bash
bun run dev:bun
```
Acesse: http://localhost:3000
**Credenciais padrao:** `admin@sistema.dev` / `admin123`
## Comandos Uteis
| Comando | Descricao |
|---------|-----------|
| `bun run dev:bun` | Iniciar servidor de desenvolvimento |
| `bun run build:bun` | Build de producao |
| `bun run lint` | Verificar codigo (ESLint) |
| `bun test` | Rodar testes |
| `bun run prisma:generate` | Gerar cliente Prisma |
| `bunx prisma studio` | Interface visual do banco |
## Fluxo de Trabalho com Git
### Push para ambos os remotes (recomendado)
```bash
# Fazer alteracoes
git add .
git commit -m "sua mensagem"
# Push para GitHub (backup) e Forgejo (CI/CD)
git push origin main && git push forgejo main
```
### Configurar alias para push duplo (opcional)
```bash
# Criar alias
git config alias.push-all '!git push origin main && git push forgejo main'
# Usar
git push-all
```
## Troubleshooting
### Erro: "bun: command not found"
```bash
# Adicionar Bun ao PATH
export PATH="$HOME/.bun/bin:$PATH"
# Adicionar permanentemente ao ~/.bashrc ou ~/.zshrc
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
### Erro: Prisma "P2021" / tabelas nao existem
```bash
bunx prisma db push
bun run auth:seed
```
### Erro: Lockfile desatualizado
```bash
bun install
```
### PostgreSQL nao conecta
```bash
# Verificar se o container esta rodando
docker ps
# Se nao estiver, iniciar
docker start postgres-dev
# Ou recriar
docker rm -f postgres-dev
docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
```
## Convex (Backend de Tempo Real)
Para desenvolvimento com Convex local:
```bash
# Terminal 1: Convex dev server
bun run convex:dev:bun
# Terminal 2: Next.js
bun run dev:bun
```
## Mais Informacoes
- **Desenvolvimento detalhado:** `docs/DEV.md`
- **Deploy e operacoes:** `docs/OPERATIONS.md`
- **CI/CD Forgejo:** `docs/FORGEJO-CI-CD.md`

View file

@ -15,14 +15,17 @@ Configuracao do servidor de email para envio de notificacoes do sistema.
## Variaveis de Ambiente ## Variaveis de Ambiente
Nomes usados pelo sistema (conforme `src/lib/env.ts`):
```bash ```bash
SMTP_HOST=smtp.c.inova.com.br SMTP_ADDRESS=smtp.c.inova.com.br
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_TLS=false
SMTP_USER=envio@rever.com.br SMTP_ENABLE_STARTTLS_AUTO=true
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu SMTP_USERNAME=envio@rever.com.br
SMTP_FROM_NAME=Sistema de Chamados SMTP_PASSWORD=CAAJQm6ZT6AUdhXRTDYu
SMTP_FROM_EMAIL=envio@rever.com.br SMTP_DOMAIN=rever.com.br
MAILER_SENDER_EMAIL=Sistema de Chamados <envio@rever.com.br>
``` ```
## Exemplo de Uso (Nodemailer) ## Exemplo de Uso (Nodemailer)

View file

@ -0,0 +1,54 @@
# Alteracoes de producao - 2025-12-18
Este documento registra as mudancas aplicadas na VPS para estabilizar o ambiente e padronizar o uso do PostgreSQL 18.
## Resumo
- Migracao do banco principal do sistema para o servico `postgres18`.
- Desativacao do servico `postgres` (pg16) no Swarm.
- Convex backend fixado na tag `ghcr.io/get-convex/convex-backend:6690a911bced1e5e516eafc0409a7239fb6541bb`.
- `CONVEX_INTERNAL_URL` ajustado para o endpoint publico, evitando falhas de DNS interno (`ENOTFOUND sistema_convex_backend`).
- Tratamento explicito para tokens revogados/expirados/invalidos nas rotas `/api/machines/*` e chat.
- Limpeza de documento legado no Convex (`liveChatSessions` id `pd71bvfbxx7th3npdj519hcf3s7xbe2j`).
## Backups gerados
- `/root/pg-backups/sistema_chamados_pg16_20251218215925.dump`
- `/root/pg-backups/sistema_chamados_pg18_20251218215925.dump`
- Convex: `/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717`
- Observacao: foi gerado um arquivo extra `db.sqlite3.backup-` (sem timestamp) por comando incorreto.
## Procedimento (principais comandos)
```
# 1) Backup dos bancos
docker exec -u postgres <pg16> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg16_20251218215925.dump
docker exec -u postgres <pg18> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg18_20251218215925.dump
# 2) Parar o web durante a migracao
docker service scale sistema_web=0
# 3) Restaurar dump do pg16 no pg18
docker exec -u postgres <pg18> psql -c "DROP DATABASE IF EXISTS sistema_chamados;"
docker exec -u postgres <pg18> psql -c "CREATE DATABASE sistema_chamados OWNER sistema;"
docker cp /root/pg-backups/sistema_chamados_pg16_20251218215925.dump <pg18>:/tmp/sistema_chamados_restore.dump
docker exec -u postgres <pg18> pg_restore -d sistema_chamados -Fc /tmp/sistema_chamados_restore.dump
# 4) Atualizar stack (com variaveis exportadas)
set -a; . /srv/apps/sistema/.env; set +a
docker stack deploy --with-registry-auth -c /srv/apps/sistema/stack.yml sistema
# 5) Desativar pg16
docker service scale postgres=0
```
## Ajustes em stack.yml
- `DATABASE_URL` apontando para `postgres18:5432`.
- `CONVEX_INTERNAL_URL` apontando para `https://convex.esdrasrenan.com.br`.
- Imagem do Convex ajustada para a tag acima.
## Resultado
- `sistema_web` voltou com 2 replicas saudaveis.
- `sistema_convex_backend` rodando na tag informada.
- `postgres` (pg16) desativado no Swarm.
- Healthcheck OK: `GET /api/health` e `GET /version`.
## Observacoes operacionais
- O deploy do stack precisa de variaveis exportadas do `.env`. Sem isso, `NEXT_PUBLIC_*` fica vazio e o `POSTGRES_PASSWORD` nao e propagado, causando `P1000` no Prisma.

View file

@ -0,0 +1,34 @@
# Alteracoes de producao - 2025-12-19
Registro das correcoes aplicadas na VPS para reduzir erros em logs e estabilizar certificados e Convex.
## Traefik / TLS
- ACME alterado de HTTP-01 para TLS-ALPN no servico `traefik_traefik`.
- Reinicio do servico Traefik para aplicar a nova configuracao.
## Certificados ACME
- Remocao de certificados obsoletos no `acme.json`:
- `pgadmin.rever.com.br`
- `supa.rever.com.br`
- `compressor.esdrasrenan.com.br`
- Backups gerados:
- `/var/lib/docker/volumes/certificados/_data/acme.json.backup-20251219011425`
- `/var/lib/docker/volumes/certificados/_data/acme.json.backup-` (gerado sem timestamp por comando anterior)
## Convex
- Adicionado `convex_proxy` (tinyproxy) e configurado `--convex-http-proxy` para remover warning de proxy ausente.
- Adicionado `convex_block` (http-echo) para bloquear `POST /api/*` com `Content-Type` nao JSON (415).
- Adicionada excecao de roteamento para `POST /api/storage/upload` (uploads do Storage) e `CONVEX_SITE_ORIGIN` ajustado para o app (`NEXT_PUBLIC_APP_URL`) para liberar CORS no frontend.
- Prioridades de roteamento ajustadas:
- `sistema_convex_api_json` (priority 100)
- `sistema_convex_api_upload` (priority 90)
- `sistema_convex_api_block` (priority 50)
- `sistema_convex` (priority 1)
- `RUST_LOG` ajustado para `info,common::errors=error` a fim de reduzir ruido de warnings nao criticos.
## Stack / Rede
- Criada rede `convex_internal` (overlay, internal) para trafego interno do Convex com o proxy.
- Arquivo atualizado: `/srv/apps/sistema/stack.yml` (stack `sistema`).
## Observacoes
- A alteracao do ACME foi feita via `docker service update --args` no Traefik (nao ha stack file versionado).

View file

@ -112,7 +112,39 @@ Critérios de sucesso:
--- ---
## 6. Referências rápidas ## 6. Registro de alterações manuais
### 2025-12-18 — liveChatSessions com versão legada (shape_inference)
Motivo: logs do Convex mostravam `shape_inference` recorrente apontando para o documento
`pd71bvfbxx7th3npdj519hcf3s7xbe2j` (sessão de chat antiga com status `ACTIVE` em versão histórica).
Comandos executados:
```bash
# 1) Parar Convex
docker service scale sistema_convex_backend=0
# 2) Backup
cp /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3 \
/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717
# 3) Remover versões antigas do documento (mantendo a mais recente)
docker run --rm -v sistema_convex_data:/convex/data nouchka/sqlite3 /convex/data/db.sqlite3 \
"DELETE FROM documents \
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j' \
AND ts < (SELECT MAX(ts) FROM documents \
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j');"
# 4) Subir Convex
docker service scale sistema_convex_backend=1
```
Resultado: versões antigas do documento foram removidas e os erros de `shape_inference` pararam após o restart.
---
## 7. Referências rápidas
- Volume Convex: `sistema_convex_data` - Volume Convex: `sistema_convex_data`
- Banco: `/convex/data/db.sqlite3` - Banco: `/convex/data/db.sqlite3`
@ -122,4 +154,4 @@ Critérios de sucesso:
--- ---
Última revisão: **18/11/2025** — sanado por remoção dos registros incompatíveis e rerun bem-sucedido do export `gg20vw5b479d9a2jprjpe3pxg57vk9wa`. Última revisão: **18/12/2025** — limpeza da versão legada de `liveChatSessions` (`pd71bvfbxx7th3npdj519hcf3s7xbe2j`) e restart do Convex.

View file

@ -0,0 +1,51 @@
# Diagnostico — Chat do desktop (2025-12-19)
## Contexto
Relato de instabilidade no chat do desktop (Raven): mensagens enviadas pela web nao chegavam ao app, e com multiplas sessoes a janela travava/nao abria.
## Evidencias coletadas
- `tickets:getById` confirmou ticket #41048 vinculado a maquina `jn7fc2d5dd8f1qw340ya092k6d7xjrps`, chat habilitado e maquina online.
- `liveChat:getTicketSession` nao tinha sessao ativa antes do teste.
- Teste ponta a ponta via Convex:
- `liveChat:startSession` + `tickets:postChatMessage` criaram sessao e mensagem.
- `liveChat:checkMachineUpdates` retornou `hasActiveSessions=true` e `unreadCount=1`.
- `liveChat:listMachineMessages` retornou a nova mensagem.
- `POST /api/machines/chat/poll` confirmou o mesmo unread.
- Traefik (VPS): nao ha chamadas do desktop para `/api/machines/chat/*` nem `raven-chat/1.0` nas ultimas horas.
- Logs locais do desktop:
- `raven-agent.log` sem entradas `[CHAT DEBUG]`.
- `app.log` sem `chat:started`.
- Com duas sessoes ativas, o log parou em:
- `[CMD] open_chat_window called...`
- `[WINDOW] ... build() inicio`
- sem `build() OK` / `open_chat_window result`, indicando travamento na criacao da janela quando chamada via comando.
## Causa raiz
O desktop nao estava iniciando o runtime de chat.
Em `apps/desktop/src/main.tsx`, o `invoke("start_chat_polling", ...)` enviava `base_url` e `convex_url` em snake_case. No Tauri v2, o mapeamento esperado e camelCase (`baseUrl`, `convexUrl`). Com isso, o comando falha na desserializacao dos args e o chat nao inicia (sem polling/WebSocket), resultando em nenhuma mensagem chegando ao app.
Em cenarios com multiplas sessoes, a abertura do segundo chat via hub usa o comando `open_chat_window` (JS). Esse comando era sincrono e rodava no thread principal; ao criar uma nova janela (`WebviewWindowBuilder::build`), a execucao travava e a janela nao concluia o build, congelando o chat no desktop.
## Correcoes aplicadas
- Ajustado `invoke("start_chat_polling")` para usar `baseUrl` e `convexUrl` (camelCase).
- Tornado `open_chat_window` e `open_hub_window` assíncronos, executando em `spawn_blocking` para evitar bloqueio do thread principal ao criar novas janelas de chat.
- Quando o chat esta aberto e no fim da conversa, o desktop marca automaticamente mensagens como lidas (evita badge preso).
- Ao abrir um chat (foco), outras janelas de chat sao ocultadas e o hub e escondido para evitar sobreposicao.
- Ao minimizar um chat, outras janelas de chat abertas sao ocultadas automaticamente.
## Arquivos alterados
- `apps/desktop/src/main.tsx`
- `apps/desktop/src-tauri/src/lib.rs`
- `apps/desktop/src-tauri/src/chat.rs`
- `apps/desktop/src/chat/ChatWidget.tsx`
## Testes recomendados
- `bun run lint`
- `bun test`
- `bun run build:bun`
## Validacao operativa (pos-build)
1. Abrir o Raven com a maquina online.
2. Enviar mensagem no ticket #41048.
3. Confirmar em `raven-agent.log` a sequencia `[CHAT DEBUG] Iniciando sistema de chat` e eventos `chat:started` em `app.log`.
4. Verificar no Traefik chamadas `/api/machines/chat/poll` ou conexoes WS do Convex com origin `http://tauri.localhost`.

View file

@ -14,6 +14,18 @@ export type TicketCardData = {
assigneeName?: string | null assigneeName?: string | null
} }
export type TicketCardProps = {
ticketNumber: string
ticketTitle: string
status?: string | null
priority?: string | null
category?: string | null
subcategory?: string | null
companyName?: string | null
requesterName?: string | null
assigneeName?: string | null
}
function badge(label: string, bg: string, color: string) { function badge(label: string, bg: string, color: string) {
return ( return (
<span <span
@ -76,7 +88,8 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
) )
} }
export function TicketCard({ ticket }: { ticket: TicketCardData }) { /** @deprecated Use TicketCard with props instead */
export function TicketCardLegacy({ ticket }: { ticket: TicketCardData }) {
return ( return (
<Section <Section
style={{ style={{
@ -100,3 +113,90 @@ export function TicketCard({ ticket }: { ticket: TicketCardData }) {
</Section> </Section>
) )
} }
export function TicketCard(props: TicketCardProps) {
const { ticketNumber, ticketTitle, status, priority, category, subcategory, companyName, requesterName, assigneeName } = props
const categoryLabel = category && subcategory ? `${category} / ${subcategory}` : category ?? subcategory ?? null
return (
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #f1f5f9" }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Chamado #{ticketNumber}
</Text>
<Text style={{ margin: "4px 0 0 0", fontSize: "16px", fontWeight: 700, color: EMAIL_COLORS.textPrimary }}>
{ticketTitle}
</Text>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
{status ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Status
</td>
<td style={{ paddingBottom: "10px" }}>{statusBadge(status)}</td>
</tr>
) : null}
{priority ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Prioridade
</td>
<td style={{ paddingBottom: "10px" }}>{priorityBadge(priority)}</td>
</tr>
) : null}
{categoryLabel ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Categoria
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{categoryLabel}</td>
</tr>
) : null}
{companyName ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Empresa
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{companyName}</td>
</tr>
) : null}
{requesterName ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Solicitante
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{requesterName}</td>
</tr>
) : null}
{assigneeName ? (
<tr>
<td style={{ width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Responsavel
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{assigneeName}</td>
</tr>
) : null}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
)
}

View file

@ -3,7 +3,7 @@ import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout" import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens" import { EMAIL_COLORS } from "./_components/tokens"
import { TicketCard, type TicketCardData } from "./_components/ticket-card" import { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card"
import { normalizeTextToParagraphs } from "./_components/utils" import { normalizeTextToParagraphs } from "./_components/utils"
export type AutomationEmailProps = { export type AutomationEmailProps = {
@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) {
</Text> </Text>
)} )}
<TicketCard ticket={props.ticket} /> <TicketCardLegacy ticket={props.ticket} />
<Section style={{ marginTop: "18px" }}> <Section style={{ marginTop: "18px" }}>
<Button <Button

132
emails/invite-email.tsx Normal file
View file

@ -0,0 +1,132 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type InviteEmailProps = {
inviterName: string
roleName: string
companyName?: string | null
inviteUrl: string
}
export default function InviteEmail(props: InviteEmailProps) {
const { inviterName, roleName, companyName, inviteUrl } = props
return (
<RavenEmailLayout title="Convite para o Sistema de Chamados" preview={`${inviterName} convidou voce para acessar o Sistema de Chamados Raven`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: EMAIL_COLORS.primary,
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
}}
>
&#127881;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Voce foi convidado!
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
<strong>{inviterName}</strong> convidou voce para acessar o Sistema de Chamados Raven.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
Funcao
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontWeight: 600 }}>
{roleName}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{companyName ? (
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
Empresa
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{companyName}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
) : null}
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={inviteUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Aceitar convite
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Se o botao nao funcionar, copie e cole esta URL no navegador:
<br />
<a href={inviteUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
{inviteUrl}
</a>
</Text>
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca.
</Text>
</RavenEmailLayout>
)
}
InviteEmail.PreviewProps = {
inviterName: "Renan Oliveira",
roleName: "Agente",
companyName: "Paulicon",
inviteUrl: "https://raven.rever.com.br/invite/abc123def456",
} satisfies InviteEmailProps

150
emails/new-login-email.tsx Normal file
View file

@ -0,0 +1,150 @@
import * as React from "react"
import { Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type NewLoginEmailProps = {
loginAt: string
ipAddress: string
userAgent: string
location?: string | null
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(date)
} catch {
return dateStr
}
}
export default function NewLoginEmail(props: NewLoginEmailProps) {
const { loginAt, ipAddress, userAgent, location } = props
return (
<RavenEmailLayout title="Novo acesso detectado" preview="Detectamos um novo acesso a sua conta">
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#128274;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Novo acesso detectado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Data/Hora
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{formatDate(loginAt)}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Endereco IP
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontFamily: "monospace" }}>
{ipAddress}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{location ? (
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Localizacao
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{location}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
) : null}
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Dispositivo
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "13px" }}>
{userAgent}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: "0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente.
</Text>
</RavenEmailLayout>
)
}
NewLoginEmail.PreviewProps = {
loginAt: new Date().toISOString(),
ipAddress: "192.168.1.100",
userAgent: "Chrome 120.0 / Windows 11",
location: "Sao Paulo, SP, Brasil",
} satisfies NewLoginEmailProps

View file

@ -0,0 +1,81 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type PasswordResetEmailProps = {
resetUrl: string
expiresIn?: string
}
export default function PasswordResetEmail(props: PasswordResetEmailProps) {
const { resetUrl, expiresIn = "1 hora" } = props
return (
<RavenEmailLayout title="Redefinicao de senha" preview="Voce solicitou a redefinicao de sua senha">
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#128274;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Redefinir senha
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha.
</Text>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={resetUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Redefinir senha
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Se o botao nao funcionar, copie e cole esta URL no navegador:
<br />
<a href={resetUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
{resetUrl}
</a>
</Text>
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca.
</Text>
</RavenEmailLayout>
)
}
PasswordResetEmail.PreviewProps = {
resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456",
expiresIn: "1 hora",
} satisfies PasswordResetEmailProps

View file

@ -0,0 +1,151 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type SlaBreachedEmailProps = {
ticketNumber: string
ticketTitle: string
breachedAt: string
ticketUrl: string
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(date)
} catch {
return dateStr
}
}
export default function SlaBreachedEmail(props: SlaBreachedEmailProps) {
const { ticketNumber, ticketTitle, breachedAt, ticketUrl } = props
return (
<RavenEmailLayout title="SLA estourado" preview={`Chamado #${ticketNumber} estourou o SLA`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fee2e2",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #ef4444",
}}
>
&#128680;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
SLA estourado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata.
</Text>
<Section
style={{
backgroundColor: "#fef2f2",
borderRadius: "12px",
border: "1px solid #fca5a5",
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Chamado
</td>
<td style={{ color: "#7f1d1d", fontSize: "14px", fontWeight: 600 }}>
#{ticketNumber}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Titulo
</td>
<td style={{ color: "#7f1d1d", fontSize: "14px" }}>
{ticketTitle}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Estourado em
</td>
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
{formatDate(breachedAt)}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: "#dc2626",
color: "#ffffff",
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: "1px solid #b91c1c",
}}
>
Atender agora
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Este chamado deve ser tratado com prioridade maxima.
</Text>
</RavenEmailLayout>
)
}
SlaBreachedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
breachedAt: new Date().toISOString(),
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies SlaBreachedEmailProps

View file

@ -0,0 +1,139 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type SlaWarningEmailProps = {
ticketNumber: string
ticketTitle: string
timeRemaining: string
ticketUrl: string
}
export default function SlaWarningEmail(props: SlaWarningEmailProps) {
const { ticketNumber, ticketTitle, timeRemaining, ticketUrl } = props
return (
<RavenEmailLayout title="Alerta de SLA" preview={`Chamado #${ticketNumber} esta proximo de estourar o SLA`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#9888;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Alerta de SLA
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado abaixo esta proximo de estourar o tempo de atendimento acordado.
</Text>
<Section
style={{
backgroundColor: "#fffbeb",
borderRadius: "12px",
border: "1px solid #fcd34d",
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Chamado
</td>
<td style={{ color: "#78350f", fontSize: "14px", fontWeight: 600 }}>
#{ticketNumber}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Titulo
</td>
<td style={{ color: "#78350f", fontSize: "14px" }}>
{ticketTitle}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Tempo restante
</td>
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
{timeRemaining}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Acesse o sistema para mais detalhes e acompanhe o status do chamado.
</Text>
</RavenEmailLayout>
)
}
SlaWarningEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
timeRemaining: "45 minutos",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies SlaWarningEmailProps

View file

@ -0,0 +1,82 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketAssignedEmailProps = TicketCardProps & {
ticketUrl: string
assigneeName: string
}
export default function TicketAssignedEmail(props: TicketAssignedEmailProps) {
const { ticketUrl, assigneeName, ...ticketProps } = props
return (
<RavenEmailLayout title="Chamado atribuido" preview={`Chamado #${ticketProps.ticketNumber} foi atribuido a ${assigneeName}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128100;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado atribuido
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado foi atribuido a <strong>{assigneeName}</strong>.
</Text>
<TicketCard {...ticketProps} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketAssignedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
assigneeName: "Weslei Magalhaes",
} satisfies TicketAssignedEmailProps

View file

@ -0,0 +1,113 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketCommentEmailProps = {
ticketNumber: string
ticketTitle: string
commenterName: string
commentPreview: string
ticketUrl: string
}
export default function TicketCommentEmail(props: TicketCommentEmailProps) {
const { ticketNumber, ticketTitle, commenterName, commentPreview, ticketUrl } = props
return (
<RavenEmailLayout title="Novo comentario" preview={`${commenterName} comentou no chamado #${ticketNumber}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128172;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Novo comentario
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
<strong>{commenterName}</strong> comentou no chamado <strong>#{ticketNumber}</strong>.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Chamado #{ticketNumber}
</Text>
<Text style={{ margin: "4px 0 0 0", fontSize: "14px", fontWeight: 600, color: EMAIL_COLORS.textPrimary }}>
{ticketTitle}
</Text>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Comentario
</Text>
<Text style={{ margin: "8px 0 0 0", fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
{commentPreview}
</Text>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver e responder
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Clique no botao acima para ver o comentario completo e responder.
</Text>
</RavenEmailLayout>
)
}
TicketCommentEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
commenterName: "Weslei Magalhaes",
commentPreview: "Ola! Ja verificamos o problema e parece ser relacionado ao driver da placa de video. Vou precisar de acesso remoto para fazer a correcao...",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies TicketCommentEmailProps

View file

@ -0,0 +1,80 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketCreatedEmailProps = TicketCardProps & {
ticketUrl: string
}
export default function TicketCreatedEmail(props: TicketCreatedEmailProps) {
const { ticketUrl, ...ticketProps } = props
return (
<RavenEmailLayout title="Novo chamado criado" preview={`Chamado #${ticketProps.ticketNumber} foi criado com sucesso`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#ecfdf5",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #10b981",
}}
>
&#9989;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado criado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe.
</Text>
<TicketCard {...ticketProps} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Acompanhar chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketCreatedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "PENDING",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies TicketCreatedEmailProps

View file

@ -0,0 +1,121 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketResolvedEmailProps = TicketCardProps & {
ticketUrl: string
ratingUrl?: string | null
resolution?: string | null
}
export default function TicketResolvedEmail(props: TicketResolvedEmailProps) {
const { ticketUrl, ratingUrl, resolution, ...ticketProps } = props
return (
<RavenEmailLayout title="Chamado resolvido" preview={`Chamado #${ticketProps.ticketNumber} foi resolvido`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#ecfdf5",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #10b981",
}}
>
&#127881;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado resolvido
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Seu chamado foi marcado como resolvido. Confira os detalhes abaixo.
</Text>
<TicketCard {...ticketProps} status="RESOLVED" />
{resolution ? (
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
padding: "16px 20px",
}}
>
<Text style={{ margin: "0 0 8px 0", fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Resolucao
</Text>
<Text style={{ margin: 0, fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
{resolution}
</Text>
</Section>
) : null}
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver detalhes
</Button>
{ratingUrl ? (
<Button
href={ratingUrl}
style={{
display: "inline-block",
backgroundColor: "#0f172a",
color: "#f8fafc",
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
marginLeft: "12px",
}}
>
Avaliar atendimento
</Button>
) : null}
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar.
</Text>
</RavenEmailLayout>
)
}
TicketResolvedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "RESOLVED",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
ratingUrl: "https://raven.rever.com.br/rate/abc123",
resolution: "Problema resolvido apos atualizacao do driver da placa de video e reinicializacao do sistema.",
} satisfies TicketResolvedEmailProps

View file

@ -0,0 +1,85 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
import { formatStatus } from "./_components/utils"
export type TicketStatusEmailProps = TicketCardProps & {
ticketUrl: string
previousStatus: string
newStatus: string
}
export default function TicketStatusEmail(props: TicketStatusEmailProps) {
const { ticketUrl, previousStatus, newStatus, ...ticketProps } = props
return (
<RavenEmailLayout title="Status atualizado" preview={`Chamado #${ticketProps.ticketNumber} mudou de ${formatStatus(previousStatus)} para ${formatStatus(newStatus)}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128260;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Status atualizado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O status do seu chamado foi alterado de <strong>{formatStatus(previousStatus)}</strong> para <strong>{formatStatus(newStatus)}</strong>.
</Text>
<TicketCard {...ticketProps} status={newStatus} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketStatusEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
previousStatus: "PENDING",
newStatus: "AWAITING_ATTENDANCE",
} satisfies TicketStatusEmailProps

View file

@ -15,6 +15,7 @@ const eslintConfig = [
"referência/**", "referência/**",
"next-env.d.ts", "next-env.d.ts",
"convex/_generated/**", "convex/_generated/**",
"apps/desktop/src/convex/_generated/**",
], ],
}, },
{ {

113
forgejo/setup-runner.sh Normal file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# Script para configurar o Forgejo Runner
# Execute na VPS apos o Forgejo estar rodando
set -e
FORGEJO_URL="${FORGEJO_URL:-https://git.esdrasrenan.com.br}"
RUNNER_NAME="${RUNNER_NAME:-vps-runner}"
RUNNER_DIR="/srv/forgejo-runner"
CONFIG_FILE="$RUNNER_DIR/config.yml"
echo "=== Configuracao do Forgejo Runner ==="
echo ""
echo "1. Acesse o Forgejo: $FORGEJO_URL"
echo "2. Va em: Site Administration > Actions > Runners"
echo "3. Clique em 'Create new Runner'"
echo "4. Copie o token de registro"
echo ""
read -p "Cole o token de registro aqui: " REGISTRATION_TOKEN
if [ -z "$REGISTRATION_TOKEN" ]; then
echo "ERRO: Token nao pode ser vazio"
exit 1
fi
# Criar diretorio do runner
mkdir -p "$RUNNER_DIR"
cd "$RUNNER_DIR"
# Baixar o runner se nao existir
if [ ! -f "./forgejo-runner" ]; then
echo "Baixando Forgejo Runner..."
RUNNER_VERSION="6.2.2"
curl -L -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
chmod +x forgejo-runner
fi
# Registrar o runner
echo "Registrando runner..."
./forgejo-runner register \
--instance "$FORGEJO_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$RUNNER_NAME" \
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
--no-interactive
# Criar config.yml customizado
cat > "$CONFIG_FILE" << 'EOF'
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "ubuntu-latest:docker://node:20-bookworm"
- "self-hosted:host"
- "linux:host"
- "vps:host"
cache:
enabled: true
dir: /tmp/forgejo-runner-cache
host: ""
port: 0
external_server: ""
container:
network: "host"
privileged: false
options: ""
workdir_parent: /tmp/forgejo-runner-workdir
valid_volumes:
- /var/run/docker.sock
- /home/runner/apps
- /srv/apps
- /tmp
docker_host: ""
force_pull: false
host:
workdir_parent: /tmp/forgejo-runner-workdir
EOF
echo ""
echo "=== Runner registrado com sucesso! ==="
echo ""
echo "Para iniciar o runner como servico systemd, execute:"
echo ""
echo "sudo tee /etc/systemd/system/forgejo-runner.service << 'SYSTEMD'
[Unit]
Description=Forgejo Runner
After=docker.service network.target
[Service]
Type=simple
User=runner
WorkingDirectory=$RUNNER_DIR
ExecStart=$RUNNER_DIR/forgejo-runner daemon --config $CONFIG_FILE
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SYSTEMD"
echo ""
echo "sudo systemctl daemon-reload"
echo "sudo systemctl enable forgejo-runner"
echo "sudo systemctl start forgejo-runner"

89
forgejo/stack.yml Normal file
View file

@ -0,0 +1,89 @@
version: "3.8"
# Forgejo para CI/CD self-hosted
# Substitui o GitHub Actions sem perder a experiencia visual
# NOTA: O runner roda como servico systemd, nao como container no Swarm
services:
forgejo:
image: codeberg.org/forgejo/forgejo:11
environment:
- USER_UID=1000
- USER_GID=1000
# Configuracoes do Forgejo
- FORGEJO__database__DB_TYPE=sqlite3
- FORGEJO__database__PATH=/data/gitea/forgejo.db
- FORGEJO__server__DOMAIN=git.esdrasrenan.com.br
- FORGEJO__server__ROOT_URL=https://git.esdrasrenan.com.br/
- FORGEJO__server__SSH_DOMAIN=git.esdrasrenan.com.br
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__server__HTTP_PORT=3000
- FORGEJO__server__OFFLINE_MODE=false
# Actions habilitado
- FORGEJO__actions__ENABLED=true
- FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
# Seguranca - INSTALL_LOCK=true apos instalacao inicial
- FORGEJO__security__INSTALL_LOCK=true
- FORGEJO__service__DISABLE_REGISTRATION=true
# Queue - usar channel em vez de leveldb para evitar problemas de lock
- FORGEJO__queue__TYPE=channel
- FORGEJO__queue__DATADIR=queues/
# Logs
- FORGEJO__log__MODE=console
- FORGEJO__log__LEVEL=Info
volumes:
- forgejo_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- traefik_public
- forgejo_internal
ports:
# SSH para git clone via SSH (exposto diretamente)
- "2222:2222"
deploy:
mode: replicated
replicas: 1
update_config:
parallelism: 1
order: start-first
failure_action: rollback
delay: 10s
monitor: 30s
resources:
limits:
memory: "1G"
reservations:
memory: "256M"
restart_policy:
condition: any
delay: 5s
max_attempts: 3
window: 120s
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
# Web UI
- traefik.http.routers.forgejo.rule=Host(`git.esdrasrenan.com.br`)
- traefik.http.routers.forgejo.entrypoints=websecure
- traefik.http.routers.forgejo.tls=true
- traefik.http.routers.forgejo.tls.certresolver=le
- traefik.http.services.forgejo.loadbalancer.server.port=3000
healthcheck:
test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
forgejo_data:
networks:
traefik_public:
external: true
forgejo_internal:
driver: overlay

View file

@ -46,6 +46,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",

252
scripts/setup-dev.sh Normal file
View file

@ -0,0 +1,252 @@
#!/bin/bash
# Script de setup para ambiente de desenvolvimento
# Uso: ./scripts/setup-dev.sh [--ssh]
#
# Opcoes:
# --ssh Configurar remotes usando SSH (para repositorio privado)
set -e
# Verificar se deve usar SSH
USE_SSH=false
if [ "$1" = "--ssh" ]; then
USE_SSH=true
fi
echo "=== Setup do Ambiente de Desenvolvimento ==="
echo ""
# Cores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Funcao para printar status
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[AVISO]${NC} $1"; }
err() { echo -e "${RED}[ERRO]${NC} $1"; }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
# 1. Verificar pre-requisitos
echo "1. Verificando pre-requisitos..."
# Verificar Bun
if command -v bun &> /dev/null; then
BUN_VERSION=$(bun --version)
ok "Bun instalado: v$BUN_VERSION"
else
err "Bun nao encontrado!"
echo " Instale com: curl -fsSL https://bun.sh/install | bash"
exit 1
fi
# Verificar Docker
if command -v docker &> /dev/null; then
ok "Docker instalado"
else
warn "Docker nao encontrado. Voce precisara configurar o PostgreSQL manualmente."
fi
# Verificar Git
if command -v git &> /dev/null; then
ok "Git instalado"
else
err "Git nao encontrado!"
exit 1
fi
# Verificar SSH key (se usando SSH)
if [ "$USE_SSH" = true ]; then
echo ""
echo "1.1. Verificando chave SSH..."
if [ -f "$HOME/.ssh/id_ed25519.pub" ] || [ -f "$HOME/.ssh/id_rsa.pub" ]; then
ok "Chave SSH encontrada"
echo " Certifique-se de que a chave esta adicionada no GitHub e Forgejo"
else
warn "Chave SSH nao encontrada!"
echo ""
echo " Para criar uma chave SSH:"
echo " ssh-keygen -t ed25519 -C \"seu-email@exemplo.com\""
echo ""
echo " Depois adicione a chave publica em:"
echo " - GitHub: Settings > SSH and GPG keys > New SSH key"
echo " - Forgejo: Settings > SSH / GPG Keys > Add Key"
echo ""
read -p " Deseja continuar mesmo assim? (s/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
exit 1
fi
fi
fi
echo ""
# 2. Configurar remotes do Git
echo "2. Configurando remotes do Git..."
# Verificar se estamos em um repositorio git
if [ ! -d ".git" ]; then
err "Este diretorio nao e um repositorio Git!"
exit 1
fi
# URLs dos remotes
if [ "$USE_SSH" = true ]; then
ORIGIN_URL="git@github.com:esdrasrenan/sistema-de-chamados.git"
FORGEJO_URL="ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git"
info "Usando SSH para os remotes (repositorio privado)"
else
ORIGIN_URL="https://github.com/esdrasrenan/sistema-de-chamados.git"
FORGEJO_URL="https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git"
info "Usando HTTPS para os remotes (repositorio publico)"
fi
# Configurar/atualizar origin
CURRENT_ORIGIN=$(git remote get-url origin 2>/dev/null || echo "")
if [ "$CURRENT_ORIGIN" != "$ORIGIN_URL" ]; then
if [ -n "$CURRENT_ORIGIN" ]; then
git remote set-url origin "$ORIGIN_URL"
ok "Remote 'origin' atualizado para $ORIGIN_URL"
fi
else
ok "Remote 'origin' ja configurado corretamente"
fi
# Verificar/adicionar remote forgejo
if git remote get-url forgejo &> /dev/null; then
CURRENT_FORGEJO=$(git remote get-url forgejo)
if [ "$CURRENT_FORGEJO" != "$FORGEJO_URL" ]; then
git remote set-url forgejo "$FORGEJO_URL"
ok "Remote 'forgejo' atualizado para $FORGEJO_URL"
else
ok "Remote 'forgejo' ja configurado corretamente"
fi
else
git remote add forgejo "$FORGEJO_URL"
ok "Remote 'forgejo' adicionado"
fi
# Mostrar remotes
echo " Remotes configurados:"
git remote -v | sed 's/^/ /'
echo ""
# 3. Instalar dependencias
echo "3. Instalando dependencias..."
bun install
ok "Dependencias instaladas"
echo ""
# 4. Configurar arquivo .env
echo "4. Configurando arquivo .env..."
if [ -f ".env" ]; then
warn "Arquivo .env ja existe. Pulando..."
else
if [ -f ".env.example" ]; then
cp .env.example .env
ok "Arquivo .env criado a partir do .env.example"
warn "IMPORTANTE: Edite o arquivo .env com suas configuracoes!"
else
# Criar .env basico
cat > .env << 'EOF'
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
BETTER_AUTH_SECRET=dev-secret-change-in-production
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210
EOF
ok "Arquivo .env criado com valores padrao para desenvolvimento"
warn "IMPORTANTE: Ajuste as configuracoes conforme necessario!"
fi
fi
echo ""
# 5. Configurar PostgreSQL via Docker
echo "5. Configurando PostgreSQL..."
if command -v docker &> /dev/null; then
if docker ps -a --format '{{.Names}}' | grep -q '^postgres-dev$'; then
# Container existe, verificar se esta rodando
if docker ps --format '{{.Names}}' | grep -q '^postgres-dev$'; then
ok "PostgreSQL ja esta rodando"
else
docker start postgres-dev
ok "PostgreSQL iniciado"
fi
else
# Criar container
docker run -d \
--name postgres-dev \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:16
ok "PostgreSQL criado e iniciado"
echo " Aguardando PostgreSQL inicializar..."
sleep 3
fi
else
warn "Docker nao disponivel. Configure o PostgreSQL manualmente."
echo " DATABASE_URL deve apontar para seu servidor PostgreSQL"
fi
echo ""
# 6. Gerar cliente Prisma
echo "6. Gerando cliente Prisma..."
bun run prisma:generate
ok "Cliente Prisma gerado"
echo ""
# 7. Inicializar banco de dados
echo "7. Inicializando banco de dados..."
# Verificar se o banco esta acessivel
if bunx prisma db push --skip-generate 2>/dev/null; then
ok "Schema do banco atualizado"
# Seed inicial
echo " Populando dados iniciais..."
if bun run auth:seed 2>/dev/null; then
ok "Dados iniciais criados"
else
warn "Seed falhou ou ja foi executado anteriormente"
fi
else
warn "Nao foi possivel conectar ao banco de dados"
echo " Verifique se o PostgreSQL esta rodando e as credenciais no .env"
fi
echo ""
# 8. Configurar alias do Git (opcional)
echo "8. Configurando alias do Git..."
if git config --get alias.push-all &> /dev/null; then
ok "Alias 'push-all' ja configurado"
else
git config alias.push-all '!git push origin main && git push forgejo main'
ok "Alias 'push-all' criado (use: git push-all)"
fi
echo ""
echo "=== Setup Concluido! ==="
echo ""
echo "Proximos passos:"
echo " 1. Verifique/edite o arquivo .env"
echo " 2. Execute: bun run dev:bun"
echo " 3. Acesse: http://localhost:3000"
echo " 4. Login: admin@sistema.dev / admin123"
echo ""
echo "Para fazer deploy:"
echo " git push origin main && git push forgejo main"
echo " ou: git push-all"
echo ""

188
scripts/test-all-emails.tsx Normal file
View file

@ -0,0 +1,188 @@
import * as React from "react"
import dotenv from "dotenv"
import { render } from "@react-email/render"
import { sendSmtpMail } from "@/server/email-smtp"
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
dotenv.config({ path: ".env.local" })
dotenv.config({ path: ".env" })
function getSmtpConfig() {
const host = process.env.SMTP_HOST
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER
const password = process.env.SMTP_PASS
const fromEmail = process.env.SMTP_FROM_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
type EmailScenario = {
name: string
subject: string
render: () => Promise<string>
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tickets.esdrasrenan.com.br"
const scenarios: EmailScenario[] = [
{
name: "Ticket Criado",
subject: "[TESTE] Novo chamado #41025 aberto",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Novo chamado #41025 aberto",
message: "Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: Computador reiniciando sozinho\nPrioridade: Alta\nStatus: Pendente",
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Ticket Resolvido",
subject: "[TESTE] Chamado #41025 foi encerrado",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Chamado #41025 encerrado",
message: "O chamado 'Computador reiniciando sozinho' foi marcado como concluído.\n\nCaso necessário, você pode responder pelo portal para reabrir dentro do prazo.",
ctaLabel: "Ver detalhes",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Novo Comentário",
subject: "[TESTE] Atualização no chamado #41025",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Nova atualização no seu chamado #41025",
message: "Um novo comentário foi adicionado ao chamado 'Computador reiniciando sozinho'.\n\nClique abaixo para visualizar e responder pelo portal.",
ctaLabel: "Abrir e responder",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Mudança de Prioridade",
subject: "[TESTE] Prioridade alterada no chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Prioridade alterada para Urgente",
message: "A prioridade do seu chamado foi alterada automaticamente pelo sistema.\n\nIsso pode ter ocorrido devido a regras de SLA ou categorização automática.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "URGENT",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Atribuição de Agente",
subject: "[TESTE] Agente atribuído ao chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Agente atribuído ao seu chamado",
message: "O agente Administrador foi automaticamente atribuído ao seu chamado e entrará em contato em breve.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Redefinição de Senha",
subject: "[TESTE] Redefinição de senha - Raven",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Redefinição de Senha",
message: "Recebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail.\n\nEste link expira em 1 hora.",
ctaLabel: "Redefinir Senha",
ctaUrl: `${baseUrl}/redefinir-senha?token=abc123def456`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
]
async function main() {
const targetEmail = process.argv[2] ?? "renan.pac@paulicon.com.br"
const smtp = getSmtpConfig()
if (!smtp) {
console.error("SMTP não configurado. Defina as variáveis SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL")
process.exit(1)
}
console.log("=".repeat(60))
console.log("Teste de E-mails - Sistema de Chamados Raven")
console.log("=".repeat(60))
console.log(`\nDestinatario: ${targetEmail}`)
console.log(`SMTP: ${smtp.host}:${smtp.port}`)
console.log(`De: ${smtp.from}`)
console.log(`\nEnviando ${scenarios.length} e-mails de teste...\n`)
let success = 0
let failed = 0
for (const scenario of scenarios) {
try {
process.stdout.write(` ${scenario.name}... `)
const html = await scenario.render()
await sendSmtpMail(smtp, targetEmail, scenario.subject, html)
console.log("OK")
success++
// Pequeno delay entre envios para evitar rate limit
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
console.log(`ERRO: ${error instanceof Error ? error.message : error}`)
failed++
}
}
console.log("\n" + "=".repeat(60))
console.log(`Resultado: ${success} enviados, ${failed} falharam`)
console.log("=".repeat(60))
if (failed > 0) {
process.exit(1)
}
}
main().catch((error) => {
console.error("Erro fatal:", error)
process.exit(1)
})

209
scripts/test-email.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* Script para testar envio de e-mail
* Uso: bun scripts/test-email.ts [destinatario]
*/
import { sendSmtpMail } from "../src/server/email-smtp"
import { renderTemplate } from "../src/server/email/email-templates"
const DESTINATARIO = process.argv[2] || "renan.pac@paulicon.com.br"
// Credenciais do SMTP (usando as da documentacao)
const smtpConfig = {
host: "smtp.c.inova.com.br",
port: 587,
username: "envio@rever.com.br",
password: "CAAJQm6ZT6AUdhXRTDYu",
from: '"Sistema de Chamados" <envio@rever.com.br>',
starttls: true,
tls: false,
rejectUnauthorized: false,
timeoutMs: 15000,
}
async function testEmail() {
console.log("=".repeat(50))
console.log("TESTE DE ENVIO DE E-MAIL")
console.log("=".repeat(50))
console.log(`Destinatario: ${DESTINATARIO}`)
console.log(`SMTP: ${smtpConfig.host}:${smtpConfig.port}`)
console.log("")
// 1. Teste basico
console.log("[1/10] Enviando e-mail de teste basico...")
try {
const html = renderTemplate("test", {
title: "Teste do Sistema de E-mail",
message: "Este e-mail confirma que o sistema de notificacoes esta funcionando corretamente.",
timestamp: new Date().toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" }),
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Teste - Sistema de Chamados Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 2. Teste de abertura de chamado
console.log("\n[2/10] Enviando notificacao de abertura de chamado...")
try {
const html = renderTemplate("ticket_created", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "PENDING",
priority: "HIGH",
createdAt: new Date().toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Aberto - Problema no sistema de vendas", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 3. Teste de resolucao de chamado
console.log("\n[3/10] Enviando notificacao de resolucao...")
try {
const html = renderTemplate("ticket_resolved", {
reference: 12345,
subject: "Problema no sistema de vendas",
assigneeName: "Joao Silva",
resolutionSummary: "O problema foi identificado como uma configuracao incorreta no modulo de pagamentos. A configuracao foi corrigida e o sistema esta funcionando normalmente.",
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
rateUrl: "https://tickets.esdrasrenan.com.br/rate/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Resolvido - Problema no sistema de vendas", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 4. Teste de comentario
console.log("\n[4/10] Enviando notificacao de comentario...")
try {
const html = renderTemplate("ticket_comment", {
reference: 12345,
subject: "Problema no sistema de vendas",
authorName: "Joao Silva",
commentBody: "Estou analisando o problema e em breve envio uma atualizacao. Por favor, verifique se o erro persiste apos limpar o cache do navegador.",
commentedAt: new Date().toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Nova atualizacao no Chamado #12345", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 5. Teste de atribuicao de chamado
console.log("\n[5/10] Enviando notificacao de atribuicao...")
try {
const html = renderTemplate("ticket_assigned", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Maria Santos",
assigneeName: "Joao Silva",
isForRequester: false,
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Atribuido", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 6. Teste de mudanca de status
console.log("\n[6/10] Enviando notificacao de mudanca de status...")
try {
const html = renderTemplate("ticket_status", {
reference: 12345,
subject: "Problema no sistema de vendas",
oldStatus: "PENDING",
newStatus: "AWAITING_ATTENDANCE",
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Status do Chamado #12345 Alterado", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 7. Teste de reset de senha
console.log("\n[7/10] Enviando notificacao de reset de senha...")
try {
const html = renderTemplate("password_reset", {
resetUrl: "https://tickets.esdrasrenan.com.br/reset-password?token=abc123",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Redefinicao de Senha - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 8. Teste de convite
console.log("\n[8/10] Enviando notificacao de convite...")
try {
const html = renderTemplate("invite", {
inviterName: "Admin Sistema",
roleName: "Agente",
companyName: "Empresa Teste",
inviteUrl: "https://tickets.esdrasrenan.com.br/invite?token=xyz789",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Voce foi convidado - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 9. Teste de novo login
console.log("\n[9/10] Enviando notificacao de novo login...")
try {
const html = renderTemplate("new_login", {
loginAt: new Date().toISOString(),
userAgent: "Chrome 120 no Windows 11",
ipAddress: "189.45.123.78",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Novo Acesso Detectado - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 10. Teste de SLA em risco
console.log("\n[10/10] Enviando notificacao de SLA em risco...")
try {
const html = renderTemplate("sla_warning", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Maria Santos",
assigneeName: "Joao Silva",
timeRemaining: "2 horas",
dueAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "ALERTA: SLA em Risco - Chamado #12345", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
console.log("\n" + "=".repeat(50))
console.log("TESTE CONCLUIDO - 10 TIPOS DE NOTIFICACAO")
console.log("=".repeat(50))
console.log(`Verifique a caixa de entrada de: ${DESTINATARIO}`)
}
testEmail().catch(console.error)

View file

@ -1,63 +1,24 @@
import path from "node:path" import pg from "pg"
// NOTE: This helper imports the generated Prisma client from TypeScript files. // NOTE: This helper imports the generated Prisma client from TypeScript files.
// Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun). // Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun).
import { PrismaClient } from "../../src/generated/prisma/client.ts" import { PrismaClient } from "../../src/generated/prisma/client.ts"
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3" import { PrismaPg } from "@prisma/adapter-pg"
const PROJECT_ROOT = process.cwd() const { Pool } = pg
const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma")
function resolveFileUrl(url) {
if (!url.startsWith("file:")) {
return url
}
const filePath = url.slice("file:".length)
if (filePath.startsWith("//")) {
return url
}
if (path.isAbsolute(filePath)) {
return `file:${path.normalize(filePath)}`
}
const normalized = path.normalize(filePath)
const prismaPrefix = `prisma${path.sep}`
const relativeToPrisma = normalized.startsWith(prismaPrefix)
? normalized.slice(prismaPrefix.length)
: normalized
const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma)
if (!absolutePath.startsWith(PROJECT_ROOT)) {
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
}
return `file:${absolutePath}`
}
function normalizeDatasourceUrl(envUrl) {
const trimmed = envUrl?.trim()
if (trimmed) {
return resolveFileUrl(trimmed)
}
if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite"
}
return resolveFileUrl("file:./db.dev.sqlite")
}
export function createPrismaClient() { export function createPrismaClient() {
const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL) const databaseUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = resolvedDatabaseUrl
const adapter = new PrismaBetterSqlite3({ if (!databaseUrl) {
url: resolvedDatabaseUrl, throw new Error("DATABASE_URL environment variable is required")
}
const pool = new Pool({
connectionString: databaseUrl,
}) })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter }) return new PrismaClient({ adapter })
} }

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function POST() {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "CONVEX_URL não configurada" }, { status: 500 })
}
try {
const convex = new ConvexHttpClient(convexUrl)
const result = await convex.mutation(api.liveChat.fixLegacySessions, {})
return NextResponse.json({ success: true, result })
} catch (error) {
console.error("[fix-chat-sessions] Erro:", error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Falha ao corrigir sessões" },
{ status: 500 }
)
}
}

View file

@ -10,7 +10,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
import { env } from "@/lib/env" import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
import { notifyUserInvite } from "@/server/notification/notification-service"
const DEFAULT_EXPIRATION_DAYS = 7 const DEFAULT_EXPIRATION_DAYS = 7
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
@ -27,6 +28,17 @@ function normalizeRole(input: string | null | undefined): RoleOption {
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
} }
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
}
function formatRoleName(role: string): string {
return ROLE_LABELS[role.toLowerCase()] ?? role
}
function generateToken() { function generateToken() {
return randomBytes(32).toString("hex") return randomBytes(32).toString("hex")
} }
@ -213,5 +225,24 @@ export async function POST(request: Request) {
const normalized = buildInvitePayload(inviteWithEvents, now) const normalized = buildInvitePayload(inviteWithEvents, now)
await syncInviteWithConvex(normalized) await syncInviteWithConvex(normalized)
// Envia email de convite
const inviteUrl = buildInviteUrl(token)
const inviterName = session.user.name ?? session.user.email
const roleName = formatRoleName(role)
try {
await notifyUserInvite(
email,
name ?? null,
inviterName,
roleName,
null, // companyName - não temos essa informação no convite
inviteUrl
)
} catch (error) {
// Log do erro mas não falha a criação do convite
console.error("[invites] Falha ao enviar email de convite:", error)
}
return NextResponse.json({ invite: normalized }) return NextResponse.json({ invite: normalized })
} }

View file

@ -204,7 +204,7 @@ export async function POST(request: Request) {
}) })
const createdDomainUser = await tx.user.upsert({ const createdDomainUser = await tx.user.upsert({
where: { email }, where: { id: createdAuthUser.id },
update: { update: {
name, name,
role: userRole, role: userRole,
@ -213,6 +213,7 @@ export async function POST(request: Request) {
managerId: managerRecord?.id ?? null, managerId: managerRecord?.id ?? null,
}, },
create: { create: {
id: createdAuthUser.id,
name, name,
email, email,
role: userRole, role: userRole,

View file

@ -0,0 +1,101 @@
import crypto from "crypto"
import { render } from "@react-email/render"
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { sendSmtpMail } from "@/server/email-smtp"
import SimpleNotificationEmail from "../../../../../emails/simple-notification-email"
function getSmtpConfig() {
const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME
const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD
const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
starttls: process.env.SMTP_SECURE !== "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { email } = body
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 })
}
const normalizedEmail = email.toLowerCase().trim()
// Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança)
const user = await prisma.authUser.findFirst({
where: { email: normalizedEmail },
})
// Sempre retorna sucesso para não revelar se o e-mail existe
if (!user) {
return NextResponse.json({ success: true })
}
// Gera um token seguro
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora
// Remove tokens anteriores do mesmo usuário
await prisma.authVerification.deleteMany({
where: {
identifier: `password-reset:${user.id}`,
},
})
// Salva o novo token
await prisma.authVerification.create({
data: {
identifier: `password-reset:${user.id}`,
value: token,
expiresAt,
},
})
// Envia o e-mail
const smtp = getSmtpConfig()
if (!smtp) {
console.error("[FORGOT_PASSWORD] SMTP não configurado")
return NextResponse.json({ success: true }) // Não revela erro de configuração
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`
const html = await render(
SimpleNotificationEmail({
title: "Redefinição de Senha",
message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`,
ctaLabel: "Redefinir Senha",
ctaUrl: resetUrl,
}),
{ pretty: true }
)
await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html)
return NextResponse.json({ success: true })
} catch (error) {
console.error("[FORGOT_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 })
}
}

View file

@ -0,0 +1,97 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { prisma } from "@/lib/prisma"
export async function POST(request: Request) {
try {
const body = await request.json()
const { token, password } = body
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token inválido" }, { status: 400 })
}
if (!password || typeof password !== "string" || password.length < 6) {
return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 })
}
// Busca o token de verificação
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 })
}
// Extrai o userId do identifier
const userId = verification.identifier.replace("password-reset:", "")
// Busca o usuário
const user = await prisma.authUser.findUnique({
where: { id: userId },
})
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 })
}
// Hash da nova senha
const hashedPassword = await hashPassword(password)
// Atualiza a conta do usuário com a nova senha
await prisma.authAccount.updateMany({
where: {
userId: user.id,
providerId: "credential",
},
data: {
password: hashedPassword,
},
})
// Remove o token usado
await prisma.authVerification.delete({
where: { id: verification.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 })
}
}
// GET para validar se o token é válido (usado pela página)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get("token")
if (!token) {
return NextResponse.json({ valid: false, error: "Token não fornecido" })
}
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ valid: false, error: "Token inválido ou expirado" })
}
return NextResponse.json({ valid: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro ao validar token:", error)
return NextResponse.json({ valid: false, error: "Erro ao validar token" })
}
}

Some files were not shown because too many files have changed in this diff Show more