From 27924244546b79175065f4d492393e9175ad97bc Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 21 Nov 2025 13:17:44 -0300 Subject: [PATCH 01/34] feat: implement audio/video call functionality in chat - Added a new schema for managing audio/video calls, including fields for call type, room name, and participant management. - Enhanced ChatWindow component to support initiating audio and video calls with dynamic loading of the CallWindow component. - Updated package dependencies to include 'lib-jitsi-meet' for call handling. - Refactored existing code to accommodate new call features and improve user experience. --- PLANO_IMPLEMENTACAO_JITSI.md | 701 ++++++++++++++++++ apps/web/package.json | 3 +- .../lib/components/call/CallControls.svelte | 132 ++++ .../lib/components/call/CallSettings.svelte | 327 ++++++++ .../src/lib/components/call/CallWindow.svelte | 670 +++++++++++++++++ .../lib/components/call/HostControls.svelte | 112 +++ .../components/call/RecordingIndicator.svelte | 23 + .../src/lib/components/chat/ChatWindow.svelte | 117 ++- apps/web/src/lib/stores/callStore.ts | 322 ++++++++ apps/web/src/lib/utils/floatingWindow.ts | 366 +++++++++ apps/web/src/lib/utils/jitsi.ts | 265 +++++++ apps/web/src/lib/utils/mediaRecorder.ts | 331 +++++++++ bun.lock | 4 +- packages/backend/convex/chamadas.ts | 578 +++++++++++++++ packages/backend/convex/schema.ts | 38 + 15 files changed, 3986 insertions(+), 3 deletions(-) create mode 100644 PLANO_IMPLEMENTACAO_JITSI.md create mode 100644 apps/web/src/lib/components/call/CallControls.svelte create mode 100644 apps/web/src/lib/components/call/CallSettings.svelte create mode 100644 apps/web/src/lib/components/call/CallWindow.svelte create mode 100644 apps/web/src/lib/components/call/HostControls.svelte create mode 100644 apps/web/src/lib/components/call/RecordingIndicator.svelte create mode 100644 apps/web/src/lib/stores/callStore.ts create mode 100644 apps/web/src/lib/utils/floatingWindow.ts create mode 100644 apps/web/src/lib/utils/jitsi.ts create mode 100644 apps/web/src/lib/utils/mediaRecorder.ts create mode 100644 packages/backend/convex/chamadas.ts diff --git a/PLANO_IMPLEMENTACAO_JITSI.md b/PLANO_IMPLEMENTACAO_JITSI.md new file mode 100644 index 0000000..4b05f03 --- /dev/null +++ b/PLANO_IMPLEMENTACAO_JITSI.md @@ -0,0 +1,701 @@ +# Plano de Implementação - Chamadas de Áudio e Vídeo com Jitsi Meet + +## Opção Escolhida: Docker Local (Desenvolvimento) + +--- + +## 📋 Etapas Fora do Código - Configuração Docker + +### Etapa 1: Preparar Ambiente Docker + +**Requisitos:** + +- Docker Desktop instalado e rodando +- Mínimo 4GB RAM disponível +- Portas livres: 8000, 8443, 10000-20000/udp + +**Passos:** + +1. **Criar diretório para configuração Docker Jitsi:** + +```bash +mkdir -p ~/jitsi-docker +cd ~/jitsi-docker +``` + +2. **Clonar repositório oficial:** + +```bash +git clone https://github.com/jitsi/docker-jitsi-meet.git +cd docker-jitsi-meet +``` + +3. **Configurar variáveis de ambiente:** + +```bash +cp env.example .env +``` + +4. **Editar arquivo `.env` com as seguintes configurações:** + +```env +# Configuração básica para desenvolvimento local +CONFIG=~/.jitsi-meet-cfg +TZ=America/Recife + +# Desabilitar Let's Encrypt (não necessário para localhost) +ENABLE_LETSENCRYPT=0 + +# Portas HTTP/HTTPS +HTTP_PORT=8000 +HTTPS_PORT=8443 + +# Domínio local +PUBLIC_URL=http://localhost:8000 +DOMAIN=localhost + +# Desabilitar autenticação para facilitar testes +ENABLE_AUTH=0 +ENABLE_GUESTS=1 + +# Desabilitar transcrissão (não necessário para desenvolvimento) +ENABLE_TRANSCRIPTION=0 + +# Desabilitar gravação no servidor (usaremos gravação local) +ENABLE_RECORDING=0 + +# Configurações de vídeo (ajustar conforme necessidade) +ENABLE_PREJOIN_PAGE=0 +START_AUDIO_MUTED=0 +START_VIDEO_MUTED=0 + +# Configurações de segurança +ENABLE_XMPP_WEBSOCKET=0 +ENABLE_P2P=1 + +# Limites +MAX_NUMBER_OF_PARTICIPANTS=10 +RESOLUTION_WIDTH=1280 +RESOLUTION_HEIGHT=720 +``` + +5. **Criar diretórios necessários:** + +```bash +mkdir -p ~/.jitsi-meet-cfg/{web/letsencrypt,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb} +``` + +6. **Iniciar containers:** + +```bash +docker-compose up -d +``` + +7. **Verificar status:** + +```bash +docker-compose ps +``` + +8. **Ver logs se necessário:** + +```bash +docker-compose logs -f +``` + +9. **Testar acesso:** + +- Acessar: http://localhost:8000 +- Criar uma sala de teste e verificar se funciona + +**Troubleshooting:** + +- Se houver erro de permissão nos diretórios: `sudo chown -R $USER:$USER ~/.jitsi-meet-cfg` +- Se portas estiverem em uso, alterar HTTP_PORT e HTTPS_PORT no .env +- Para parar: `docker-compose down` +- Para reiniciar: `docker-compose restart` + +--- + +## 📦 Etapas no Código - Backend Convex + +### Etapa 2: Atualizar Schema + +**Arquivo:** `packages/backend/convex/schema.ts` + +**Adicionar nova tabela `chamadas`:** + +```typescript +chamadas: defineTable({ + conversaId: v.id('conversas'), + tipo: v.union(v.literal('audio'), v.literal('video')), + roomName: v.string(), // Nome único da sala Jitsi + criadoPor: v.id('usuarios'), // Anfitrião/criador + participantes: v.array(v.id('usuarios')), + status: v.union( + v.literal('aguardando'), + v.literal('em_andamento'), + v.literal('finalizada'), + v.literal('cancelada') + ), + iniciadaEm: v.optional(v.number()), + finalizadaEm: v.optional(v.number()), + duracaoSegundos: v.optional(v.number()), + gravando: v.boolean(), + gravacaoIniciadaPor: v.optional(v.id('usuarios')), + gravacaoIniciadaEm: v.optional(v.number()), + gravacaoFinalizadaEm: v.optional(v.number()), + configuracoes: v.optional( + v.object({ + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + participantesConfig: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião + }) + ) + ) + }) + ), + criadoEm: v.number() +}) + .index('by_conversa', ['conversaId', 'status']) + .index('by_conversa_ativa', ['conversaId', 'status']) + .index('by_criado_por', ['criadoPor']) + .index('by_status', ['status']) + .index('by_room_name', ['roomName']); +``` + +### Etapa 3: Criar Backend de Chamadas + +**Arquivo:** `packages/backend/convex/chamadas.ts` + +**Funções a implementar:** + +#### Mutations: + +1. `criarChamada` - Criar nova chamada +2. `iniciarChamada` - Marcar como em andamento +3. `finalizarChamada` - Finalizar e calcular duração +4. `adicionarParticipante` - Adicionar participante +5. `removerParticipante` - Remover participante +6. `toggleAudioVideo` - Anfitrião controla áudio/vídeo de participante +7. `atualizarConfiguracaoParticipante` - Atualizar configuração individual +8. `iniciarGravacao` - Marcar início de gravação +9. `finalizarGravacao` - Marcar fim de gravação + +#### Queries: + +1. `obterChamadaAtiva` - Buscar chamada ativa de uma conversa +2. `listarChamadas` - Listar histórico +3. `verificarAnfitriao` - Verificar se usuário é anfitrião +4. `obterParticipantesChamada` - Listar participantes + +**Tipos TypeScript (sem usar `any`):** + +```typescript +import type { Id } from './_generated/dataModel'; +import type { QueryCtx, MutationCtx } from './_generated/server'; + +type ChamadaTipo = 'audio' | 'video'; +type ChamadaStatus = 'aguardando' | 'em_andamento' | 'finalizada' | 'cancelada'; + +interface ParticipanteConfig { + usuarioId: Id<'usuarios'>; + audioHabilitado: boolean; + videoHabilitado: boolean; + forcadoPeloAnfitriao?: boolean; +} + +interface ConfiguracoesChamada { + audioHabilitado: boolean; + videoHabilitado: boolean; + participantesConfig?: ParticipanteConfig[]; +} +``` + +--- + +## 🎨 Etapas no Código - Frontend Svelte + +### Etapa 4: Instalar Dependências + +**Arquivo:** `apps/web/package.json` + +```bash +cd apps/web +bun add lib-jitsi-meet +``` + +**Dependências adicionais necessárias:** + +- `lib-jitsi-meet` - Biblioteca oficial Jitsi +- (Possivelmente tipos) `@types/lib-jitsi-meet` se disponível + +### Etapa 5: Configurar Variáveis de Ambiente + +**Arquivo:** `apps/web/.env` + +```env +# Jitsi Meet Configuration (Docker Local) +VITE_JITSI_DOMAIN=localhost:8443 +VITE_JITSI_APP_ID=sgse-app +VITE_JITSI_ROOM_PREFIX=sgse +VITE_JITSI_USE_HTTPS=false +``` + +### Etapa 6: Criar Utilitários Jitsi + +**Arquivo:** `apps/web/src/lib/utils/jitsi.ts` + +**Funções:** + +- `gerarRoomName(conversaId: string, tipo: "audio" | "video"): string` - Gerar nome único da sala +- `obterConfiguracaoJitsi()` - Retornar configuração do Jitsi baseada em .env +- `validarDispositivos()` - Validar disponibilidade de microfone/webcam +- `obterDispositivosDisponiveis()` - Listar dispositivos de mídia + +**Tipos (sem `any`):** + +```typescript +interface ConfiguracaoJitsi { + domain: string; + appId: string; + roomPrefix: string; + useHttps: boolean; +} + +interface DispositivoMedia { + deviceId: string; + label: string; + kind: 'audioinput' | 'audiooutput' | 'videoinput'; +} + +interface DispositivosDisponiveis { + microphones: DispositivoMedia[]; + speakers: DispositivoMedia[]; + cameras: DispositivoMedia[]; +} +``` + +### Etapa 7: Criar Store de Chamadas + +**Arquivo:** `apps/web/src/lib/stores/callStore.ts` + +**Estado gerenciado:** + +- Chamada ativa (se houver) +- Estado de mídia (áudio/vídeo ligado/desligado) +- Dispositivos selecionados +- Status de gravação +- Lista de participantes +- Duração da chamada +- É anfitrião ou não + +**Tipos:** + +```typescript +interface EstadoChamada { + chamadaId: Id<'chamadas'> | null; + conversaId: Id<'conversas'> | null; + tipo: 'audio' | 'video' | null; + roomName: string | null; + estaConectado: boolean; + audioHabilitado: boolean; + videoHabilitado: boolean; + gravando: boolean; + ehAnfitriao: boolean; + participantes: Array<{ + usuarioId: Id<'usuarios'>; + nome: string; + avatar?: string; + audioHabilitado: boolean; + videoHabilitado: boolean; + }>; + duracaoSegundos: number; + dispositivos: { + microphoneId: string | null; + cameraId: string | null; + speakerId: string | null; + }; +} + +interface EventosChamada { + 'participant-joined': (participant: ParticipanteJitsi) => void; + 'participant-left': (participantId: string) => void; + 'audio-mute-status-changed': (isMuted: boolean) => void; + 'video-mute-status-changed': (isMuted: boolean) => void; + 'connection-failed': (error: Error) => void; + 'connection-disconnected': () => void; +} +``` + +**Métodos principais:** + +- `iniciarChamada(conversaId, tipo)` +- `finalizarChamada()` +- `toggleAudio()` +- `toggleVideo()` +- `iniciarGravacao()` +- `finalizarGravacao()` +- `atualizarDispositivos()` + +### Etapa 8: Criar Utilitários de Gravação + +**Arquivo:** `apps/web/src/lib/utils/mediaRecorder.ts` + +**Funções:** + +- `iniciarGravacaoAudio(stream: MediaStream): MediaRecorder` - Gravar apenas áudio +- `iniciarGravacaoVideo(stream: MediaStream): MediaRecorder` - Gravar áudio + vídeo +- `pararGravacao(recorder: MediaRecorder): Promise` - Parar e retornar blob +- `salvarGravacao(blob: Blob, nomeArquivo: string): void` - Salvar localmente +- `obterDuracaoGravacao(recorder: MediaRecorder): number` - Obter duração + +**Tipos:** + +```typescript +interface OpcoesGravacao { + audioBitsPerSecond?: number; + videoBitsPerSecond?: number; + mimeType?: string; +} + +interface ResultadoGravacao { + blob: Blob; + duracaoSegundos: number; + nomeArquivo: string; +} +``` + +### Etapa 9: Criar Componente CallWindow + +**Arquivo:** `apps/web/src/lib/components/call/CallWindow.svelte` + +**Características:** + +- Janela flutuante redimensionável e arrastável +- Integração com lib-jitsi-meet +- Container para vídeo dos participantes +- Barra de controles +- Indicador de gravação +- Contador de duração + +**Props (TypeScript estrito):** + +```typescript +interface Props { + chamadaId: Id<'chamadas'>; + conversaId: Id<'conversas'>; + tipo: 'audio' | 'video'; + roomName: string; + ehAnfitriao: boolean; + onClose: () => void; +} +``` + +**Estrutura:** + +- ` + +
+ +
+ + {duracaoFormatada} +
+ + +
+ + + + + + + + {#if ehAnfitriao} + + {/if} + + + + + + +
+
+ diff --git a/apps/web/src/lib/components/call/CallSettings.svelte b/apps/web/src/lib/components/call/CallSettings.svelte new file mode 100644 index 0000000..0245540 --- /dev/null +++ b/apps/web/src/lib/components/call/CallSettings.svelte @@ -0,0 +1,327 @@ + + +{#if open} + e.target === e.currentTarget && handleFechar()} + role="dialog" + aria-labelledby="modal-title" + > + + + + +{/if} + diff --git a/apps/web/src/lib/components/call/CallWindow.svelte b/apps/web/src/lib/components/call/CallWindow.svelte new file mode 100644 index 0000000..8997d0e --- /dev/null +++ b/apps/web/src/lib/components/call/CallWindow.svelte @@ -0,0 +1,670 @@ + + + + + + diff --git a/apps/web/src/lib/components/call/HostControls.svelte b/apps/web/src/lib/components/call/HostControls.svelte new file mode 100644 index 0000000..914243a --- /dev/null +++ b/apps/web/src/lib/components/call/HostControls.svelte @@ -0,0 +1,112 @@ + + +
+
+ + Controles do Anfitrião +
+ +
+ {#if participantes.length === 0} +
+ Nenhum participante na chamada +
+ {:else} + {#each participantes as participante} +
+ +
+ +
+ + {participante.nome} + + {#if participante.forcadoPeloAnfitriao} + + Controlado pelo anfitrião + + {/if} +
+
+ + +
+ + + + + +
+
+ {/each} + {/if} +
+
+ diff --git a/apps/web/src/lib/components/call/RecordingIndicator.svelte b/apps/web/src/lib/components/call/RecordingIndicator.svelte new file mode 100644 index 0000000..da25a23 --- /dev/null +++ b/apps/web/src/lib/components/call/RecordingIndicator.svelte @@ -0,0 +1,23 @@ + + +{#if gravando} + +{/if} diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index f3eb486..94cf83c 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -10,7 +10,20 @@ import ScheduleMessageModal from './ScheduleMessageModal.svelte'; import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import { getAvatarUrl } from '$lib/utils/avatarGenerator'; - import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte'; + import { browser } from '$app/environment'; + import { onMount } from 'svelte'; + import { + Bell, + X, + ArrowLeft, + LogOut, + MoreVertical, + Users, + Clock, + XCircle, + Phone, + Video + } from 'lucide-svelte'; interface Props { conversaId: string; @@ -26,11 +39,20 @@ let showSalaManager = $state(false); let showAdminMenu = $state(false); let showNotificacaoModal = $state(false); + let iniciandoChamada = $state(false); + + // Importação dinâmica do CallWindow apenas no cliente + let CallWindowComponent: any = $state(null); + + const chamadaAtual = $derived(chamadaAtivaQuery?.data); const conversas = useQuery(api.chat.listarConversas, {}); const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as Id<'conversas'> }); + const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, { + conversaId: conversaId as Id<'conversas'> + }); const conversa = $derived(() => { console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); @@ -115,6 +137,54 @@ alert(errorMessage); } } + + // Funções para chamadas + async function iniciarChamada(tipo: 'audio' | 'video'): Promise { + if (chamadaAtual) { + alert('Já existe uma chamada ativa nesta conversa'); + return; + } + + try { + iniciandoChamada = true; + const chamadaId = await client.mutation(api.chamadas.criarChamada, { + conversaId: conversaId as Id<'conversas'>, + tipo, + audioHabilitado: true, + videoHabilitado: tipo === 'video' + }); + + chamadaAtiva = chamadaId; + } catch (error) { + console.error('Erro ao iniciar chamada:', error); + const errorMessage = error instanceof Error ? error.message : 'Erro ao iniciar chamada'; + alert(errorMessage); + } finally { + iniciandoChamada = false; + } + } + + function fecharChamada(): void { + chamadaAtiva = null; + } + + // Carregar CallWindow dinamicamente apenas no cliente + onMount(async () => { + if (browser && !CallWindowComponent) { + try { + const module = await import('../call/CallWindow.svelte'); + CallWindowComponent = module.default; + } catch (error) { + console.error('Erro ao carregar CallWindow:', error); + } + } + }); + + // Verificar se usuário é anfitrião da chamada atual + const meuPerfil = useQuery(api.auth.getCurrentUser, {}); + const souAnfitriao = $derived( + chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false + );
(showAdminMenu = false)}> @@ -233,6 +303,36 @@
+ + {#if !chamadaAtual} + + + {/if} + {#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')} + + +
+
+ + {/if} + + +
+
+

Exemplos de Configuração

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AmbienteDomínioApp IDPrefixo SalaHTTPS
Docker Locallocalhost:8443sgse-appsgseSim
Produçãomeet.example.comsgse-appsgseSim
Desenvolvimentolocalhost:8000sgse-appsgse-devNão
+
+
+
+ + +
+ + + +
+

+ Dica: Para servidor Jitsi Docker local, use + localhost:8443 com HTTPS habilitado. Para servidor em + produção, use o domínio completo do seu servidor Jitsi. +

+

+ A configuração será aplicada imediatamente após salvar. Usuários precisarão + recarregar a página para usar a nova configuração. +

+
+
+ + + {#if acceptSelfSignedCert} +
+ + + +
+

Certificados Autoassinados Ativados

+

+ Para certificados autoassinados (desenvolvimento local), os usuários precisarão + aceitar o certificado no navegador na primeira conexão. Em produção, use + certificados válidos (Let's Encrypt, etc.). +

+
+
+ {/if} + + + {#if !useHttps} +
+ + + +
+

HTTP Ativado (Não Seguro)

+

+ O uso de HTTP não é recomendado para produção. Use HTTPS com certificado válido + para garantir segurança nas chamadas. +

+
+
+ {/if} + + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e43a543..c5e3bd5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,4 +7,10 @@ export default defineConfig({ resolve: { dedupe: ["lucide-svelte"], }, + optimizeDeps: { + exclude: ["lib-jitsi-meet"], // Excluir para permitir carregamento dinâmico no browser + }, + ssr: { + noExternal: [], // lib-jitsi-meet não funciona no SSR, deve ser carregada apenas no browser + }, }); diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f79ac9a..5046710 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -22,6 +22,7 @@ import type * as chamadas from "../chamadas.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; +import type * as configuracaoJitsi from "../configuracaoJitsi.js"; import type * as configuracaoPonto from "../configuracaoPonto.js"; import type * as configuracaoRelogio from "../configuracaoRelogio.js"; import type * as contratos from "../contratos.js"; @@ -78,6 +79,7 @@ declare const fullApi: ApiFromModules<{ chamados: typeof chamados; chat: typeof chat; configuracaoEmail: typeof configuracaoEmail; + configuracaoJitsi: typeof configuracaoJitsi; configuracaoPonto: typeof configuracaoPonto; configuracaoRelogio: typeof configuracaoRelogio; contratos: typeof contratos; diff --git a/packages/backend/convex/chamadas.ts b/packages/backend/convex/chamadas.ts index 7c5c725..17d3afc 100644 --- a/packages/backend/convex/chamadas.ts +++ b/packages/backend/convex/chamadas.ts @@ -19,11 +19,25 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { /** * Gerar nome único para a sala Jitsi + * Usa configuração do backend se disponível, senão usa padrão 'sgse' */ -function gerarRoomName(conversaId: Id<'conversas'>, tipo: 'audio' | 'video'): string { +async function gerarRoomName( + ctx: QueryCtx | MutationCtx, + conversaId: Id<'conversas'>, + tipo: 'audio' | 'video' +): Promise { + // Buscar configuração Jitsi ativa + const configJitsi = await ctx.db + .query('configuracaoJitsi') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + const roomPrefix = configJitsi?.roomPrefix || 'sgse'; const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 9); - return `sgse-${tipo}-${conversaId.replace('conversas|', '')}-${timestamp}-${random}`; + const conversaHash = conversaId.replace('conversas|', '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 10); + + return `${roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`; } /** @@ -96,7 +110,7 @@ export const criarChamada = mutation({ if (!conversa) throw new Error('Conversa não encontrada'); // Gerar nome único da sala - const roomName = gerarRoomName(args.conversaId, args.tipo); + const roomName = await gerarRoomName(ctx, args.conversaId, args.tipo); // Criar chamada const chamadaId = await ctx.db.insert('chamadas', { diff --git a/packages/backend/convex/configuracaoJitsi.ts b/packages/backend/convex/configuracaoJitsi.ts new file mode 100644 index 0000000..51a9aff --- /dev/null +++ b/packages/backend/convex/configuracaoJitsi.ts @@ -0,0 +1,282 @@ +import { v } from "convex/values"; +import { mutation, query, action, internalMutation } from "./_generated/server"; +import { registrarAtividade } from "./logsAtividades"; +import { api, internal } from "./_generated/api"; + +/** + * Obter configuração de Jitsi ativa + */ +export const obterConfigJitsi = query({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query("configuracaoJitsi") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + if (!config) { + return null; + } + + return { + _id: config._id, + domain: config.domain, + appId: config.appId, + roomPrefix: config.roomPrefix, + useHttps: config.useHttps, + acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir + ativo: config.ativo, + testadoEm: config.testadoEm, + atualizadoEm: config.atualizadoEm, + }; + }, +}); + +/** + * Salvar configuração de Jitsi (apenas TI_MASTER) + */ +export const salvarConfigJitsi = mutation({ + args: { + domain: v.string(), + appId: v.string(), + roomPrefix: v.string(), + useHttps: v.boolean(), + acceptSelfSignedCert: v.boolean(), + configuradoPorId: v.id("usuarios"), + }, + returns: v.union( + v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + // Validar domínio (deve ser não vazio) + if (!args.domain || args.domain.trim().length === 0) { + return { sucesso: false as const, erro: "Domínio não pode estar vazio" }; + } + + // Validar appId (deve ser não vazio) + if (!args.appId || args.appId.trim().length === 0) { + return { sucesso: false as const, erro: "App ID não pode estar vazio" }; + } + + // Validar roomPrefix (deve ser não vazio e alfanumérico) + if (!args.roomPrefix || args.roomPrefix.trim().length === 0) { + return { sucesso: false as const, erro: "Prefixo de sala não pode estar vazio" }; + } + + // Validar formato do roomPrefix (apenas letras, números e hífens) + const roomPrefixRegex = /^[a-zA-Z0-9-]+$/; + if (!roomPrefixRegex.test(args.roomPrefix.trim())) { + return { + sucesso: false as const, + erro: "Prefixo de sala deve conter apenas letras, números e hífens", + }; + } + + // Desativar config anterior + const configsAntigas = await ctx.db + .query("configuracaoJitsi") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .collect(); + + for (const config of configsAntigas) { + await ctx.db.patch(config._id, { ativo: false }); + } + + // Criar nova config + const configId = await ctx.db.insert("configuracaoJitsi", { + domain: args.domain.trim(), + appId: args.appId.trim(), + roomPrefix: args.roomPrefix.trim(), + useHttps: args.useHttps, + acceptSelfSignedCert: args.acceptSelfSignedCert ?? false, // Default para false se não fornecido + ativo: true, + configuradoPor: args.configuradoPorId, + atualizadoEm: Date.now(), + }); + + // Log de atividade + await registrarAtividade( + ctx, + args.configuradoPorId, + "configurar", + "jitsi", + JSON.stringify({ domain: args.domain, appId: args.appId }), + configId + ); + + return { sucesso: true as const, configId }; + }, +}); + +/** + * Mutation interna para atualizar testadoEm + */ +export const atualizarTestadoEm = internalMutation({ + args: { + configId: v.id("configuracaoJitsi"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + testadoEm: Date.now(), + }); + return null; + }, +}); + +/** + * Testar conexão com servidor Jitsi + */ +export const testarConexaoJitsi = action({ + args: { + domain: v.string(), + useHttps: v.boolean(), + acceptSelfSignedCert: v.optional(v.boolean()), + }, + returns: v.union( + v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => { + // Validações básicas + if (!args.domain || args.domain.trim().length === 0) { + return { sucesso: false as const, erro: "Domínio não pode estar vazio" }; + } + + try { + const protocol = args.useHttps ? "https" : "http"; + // Extrair host e porta do domain + const [host, portStr] = args.domain.split(":"); + const port = portStr ? parseInt(portStr, 10) : args.useHttps ? 443 : 80; + const url = `${protocol}://${host}:${port}/http-bind`; + + // Tentar fazer uma requisição HTTP para verificar se o servidor está acessível + // Nota: No ambiente Node.js do Convex, podemos usar fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 segundos de timeout + + try { + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + headers: { + "Content-Type": "application/xml", + }, + }); + + clearTimeout(timeoutId); + + // Qualquer resposta indica que o servidor está acessível + // Não precisamos verificar o status code exato, apenas se há resposta + if (response.status >= 200 && response.status < 600) { + // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm + const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); + + if (configAtiva) { + await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { + configId: configAtiva._id, + }); + } + + return { sucesso: true as const, aviso: undefined }; + } else { + return { + sucesso: false as const, + erro: `Servidor retornou status ${response.status}`, + }; + } + } catch (fetchError: unknown) { + clearTimeout(timeoutId); + const errorMessage = + fetchError instanceof Error ? fetchError.message : String(fetchError); + + // Se for erro de timeout + if (errorMessage.includes("aborted") || errorMessage.includes("timeout")) { + return { + sucesso: false as const, + erro: "Timeout: Servidor não respondeu em 5 segundos", + }; + } + + // Verificar se é erro de certificado SSL autoassinado + const isSSLError = + errorMessage.includes("CERTIFICATE_VERIFY_FAILED") || + errorMessage.includes("self signed certificate") || + errorMessage.includes("self-signed certificate") || + errorMessage.includes("certificate") || + errorMessage.includes("SSL") || + errorMessage.includes("certificate verify failed"); + + // Se for erro de certificado e aceitar autoassinado está configurado + if (isSSLError && args.acceptSelfSignedCert) { + // Aceitar como sucesso se configurado para aceitar certificados autoassinados + // (o servidor está acessível, apenas o certificado não é confiável) + // Nota: No cliente (navegador), o usuário ainda precisará aceitar o certificado manualmente + const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); + + if (configAtiva) { + await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { + configId: configAtiva._id, + }); + } + + return { + sucesso: true as const, + aviso: "Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão." + }; + } + + // Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed) + // para GET em /http-bind, pois esse endpoint espera POST (BOSH) + // Isso indica que o servidor está acessível, apenas não aceita GET + if (errorMessage.includes("405") || errorMessage.includes("Method Not Allowed")) { + // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm + const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); + + if (configAtiva) { + await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { + configId: configAtiva._id, + }); + } + + return { sucesso: true as const, aviso: undefined }; + } + + // Se for erro de certificado SSL e não está configurado para aceitar + if (isSSLError) { + return { + sucesso: false as const, + erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).`, + }; + } + + return { + sucesso: false as const, + erro: `Erro ao conectar: ${errorMessage}`, + }; + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + sucesso: false as const, + erro: errorMessage || "Erro ao conectar com o servidor Jitsi", + }; + } + }, +}); + +/** + * Marcar que a configuração foi testada com sucesso + */ +export const marcarConfigTestada = mutation({ + args: { + configId: v.id("configuracaoJitsi"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + testadoEm: Date.now(), + }); + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index fa3e753..8442af4 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -708,6 +708,19 @@ export default defineSchema({ atualizadoEm: v.number(), }).index("by_ativo", ["ativo"]), + // Configuração de Jitsi Meet + configuracaoJitsi: defineTable({ + domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com") + appId: v.string(), // ID da aplicação Jitsi + roomPrefix: v.string(), // Prefixo para nomes de salas + useHttps: v.boolean(), // Usar HTTPS + acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento) + ativo: v.boolean(), // Configuração ativa + testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão + configuradoPor: v.id("usuarios"), // Usuário que configurou + atualizadoEm: v.number(), // Timestamp de atualização + }).index("by_ativo", ["ativo"]), + // Fila de Emails notificacoesEmail: defineTable({ destinatario: v.string(), // email From 54089f5ecaa16d836c466494f47a5f921b510aba Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 21 Nov 2025 22:09:30 -0300 Subject: [PATCH 11/34] fix: update Jitsi configuration handling for default values - Refactored the Jitsi configuration logic to use nullish coalescing for default values in the frontend. - Added a condition to reset configuration values to defaults when no configuration is available. - Adjusted backend mutation to ensure consistent handling of the acceptSelfSignedCert parameter. --- .../(dashboard)/ti/configuracoes-jitsi/+page.svelte | 11 +++++++++-- packages/backend/convex/configuracaoJitsi.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte index 364b5ac..5a483c9 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte @@ -29,8 +29,15 @@ domain = configAtual.data.domain || ""; appId = configAtual.data.appId || "sgse-app"; roomPrefix = configAtual.data.roomPrefix || "sgse"; - useHttps = configAtual.data.useHttps || false; - acceptSelfSignedCert = configAtual.data.acceptSelfSignedCert || false; + useHttps = configAtual.data.useHttps ?? false; + acceptSelfSignedCert = configAtual.data.acceptSelfSignedCert ?? false; + } else if (configAtual === null) { + // Se não há configuração, resetar para valores padrão + domain = ""; + appId = "sgse-app"; + roomPrefix = "sgse"; + useHttps = false; + acceptSelfSignedCert = false; } }); diff --git a/packages/backend/convex/configuracaoJitsi.ts b/packages/backend/convex/configuracaoJitsi.ts index 51a9aff..af714c4 100644 --- a/packages/backend/convex/configuracaoJitsi.ts +++ b/packages/backend/convex/configuracaoJitsi.ts @@ -89,7 +89,7 @@ export const salvarConfigJitsi = mutation({ appId: args.appId.trim(), roomPrefix: args.roomPrefix.trim(), useHttps: args.useHttps, - acceptSelfSignedCert: args.acceptSelfSignedCert ?? false, // Default para false se não fornecido + acceptSelfSignedCert: args.acceptSelfSignedCert, ativo: true, configuradoPor: args.configuradoPorId, atualizadoEm: Date.now(), From c056506ce562f96064f03040ad67b7f8145f2072 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 22 Nov 2025 18:18:16 -0300 Subject: [PATCH 12/34] feat: enhance time synchronization and Jitsi configuration handling - Implemented a comprehensive time synchronization mechanism that applies GMT offsets based on user configuration, ensuring accurate timestamps across the application. - Updated the Jitsi configuration to include SSH settings, allowing for better integration with Docker setups. - Refactored the backend queries and mutations to handle the new SSH configuration fields, ensuring secure and flexible server management. - Enhanced error handling and logging for time synchronization processes, providing clearer feedback for users and developers. --- apps/web/src/app.html | 43 ++ .../src/lib/components/call/CallWindow.svelte | 83 +-- .../lib/components/ponto/RegistroPonto.svelte | 90 ++- .../ponto/RelogioSincronizado.svelte | 25 +- apps/web/src/lib/utils/jitsiPolyfill.ts | 82 +++ .../registro-pontos/+page.svelte | 45 +- .../ti/configuracoes-jitsi/+page.svelte | 523 ++++++++++++++---- .../ti/configuracoes-relogio/+page.svelte | 379 ++++++++++++- apps/web/src/routes/+layout.svelte | 2 + bun.lock | 24 + packages/backend/convex/_generated/api.d.ts | 2 + .../backend/convex/actions/jitsiServer.ts | 424 ++++++++++++++ packages/backend/convex/configuracaoJitsi.ts | 90 +++ .../backend/convex/configuracaoRelogio.ts | 136 +++-- packages/backend/convex/pontos.ts | 59 +- packages/backend/convex/schema.ts | 11 + packages/backend/package.json | 4 +- 17 files changed, 1765 insertions(+), 257 deletions(-) create mode 100644 apps/web/src/lib/utils/jitsiPolyfill.ts create mode 100644 packages/backend/convex/actions/jitsiServer.ts diff --git a/apps/web/src/app.html b/apps/web/src/app.html index bd3affa..b66466c 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -5,6 +5,49 @@ %sveltekit.head% + + +
%sveltekit.body%
diff --git a/apps/web/src/lib/components/call/CallWindow.svelte b/apps/web/src/lib/components/call/CallWindow.svelte index 6734301..8ebe538 100644 --- a/apps/web/src/lib/components/call/CallWindow.svelte +++ b/apps/web/src/lib/components/call/CallWindow.svelte @@ -172,82 +172,17 @@ } // Carregar Jitsi dinamicamente - // Polyfill para BlobBuilder (API antiga que lib-jitsi-meet pode usar) - // Deve ser executado antes de qualquer import da biblioteca - function adicionarBlobBuilderPolyfill(): void { - if (!browser || typeof window === 'undefined') return; - - // Verificar se já foi adicionado (evitar múltiplas execuções) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((window as any).__blobBuilderPolyfillAdded) { - return; - } - - // Implementar BlobBuilder usando Blob moderno - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const BlobBuilderClass = class BlobBuilder { - private parts: BlobPart[] = []; - - append(data: BlobPart): void { - this.parts.push(data); - } - - getBlob(contentType?: string): Blob { - return new Blob(this.parts, contentType ? { type: contentType } : undefined); - } - }; - - // Adicionar em todos os possíveis locais onde a biblioteca pode procurar - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const win = window as any; - - if (typeof win.BlobBuilder === 'undefined') { - win.BlobBuilder = BlobBuilderClass; - } - - if (typeof win.WebKitBlobBuilder === 'undefined') { - win.WebKitBlobBuilder = BlobBuilderClass; - } - - if (typeof win.MozBlobBuilder === 'undefined') { - win.MozBlobBuilder = BlobBuilderClass; - } - - if (typeof win.MSBlobBuilder === 'undefined') { - win.MSBlobBuilder = BlobBuilderClass; - } - - // Também adicionar no global scope caso a biblioteca procure lá - if (typeof globalThis !== 'undefined') { - if (typeof (globalThis as any).BlobBuilder === 'undefined') { - (globalThis as any).BlobBuilder = BlobBuilderClass; - } - if (typeof (globalThis as any).WebKitBlobBuilder === 'undefined') { - (globalThis as any).WebKitBlobBuilder = BlobBuilderClass; - } - } - - // Marcar que o polyfill foi adicionado - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).__blobBuilderPolyfillAdded = true; - - console.log('✅ Polyfill BlobBuilder adicionado para todos os navegadores'); - } - - // Executar polyfill imediatamente se estiver no browser - // Isso garante que esteja disponível antes de qualquer import - if (browser && typeof window !== 'undefined') { - adicionarBlobBuilderPolyfill(); - } - async function carregarJitsi(): Promise { if (!browser || JitsiMeetJS) return; try { console.log('🔄 Tentando carregar lib-jitsi-meet...'); - // Adicionar polyfill antes de carregar a biblioteca - adicionarBlobBuilderPolyfill(); + // Polyfill BlobBuilder já deve estar disponível via app.html + // Verificar se está disponível antes de carregar a biblioteca + if (typeof (window as any).BlobBuilder === 'undefined') { + console.warn('⚠️ Polyfill BlobBuilder não encontrado, pode causar erros'); + } // Tentar carregar o módulo lib-jitsi-meet dinamicamente // Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser @@ -1000,9 +935,11 @@ onMount(async () => { if (!browser) return; - // Adicionar polyfill BlobBuilder o mais cedo possível - // Isso deve ser feito antes de qualquer tentativa de carregar lib-jitsi-meet - adicionarBlobBuilderPolyfill(); + // Polyfill BlobBuilder já deve estar disponível via app.html + // Verificar se está disponível + if (typeof (window as any).BlobBuilder === 'undefined') { + console.warn('⚠️ Polyfill BlobBuilder não encontrado no onMount'); + } // Inicializar store primeiro inicializarStore(); diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index ed3bfb4..8a16bc1 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -190,9 +190,51 @@ const informacoesDispositivo = await obterInformacoesDispositivo(); coletandoInfo = false; - // Obter tempo sincronizado - const timestamp = await obterTempoServidor(client); - const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor + // Obter tempo sincronizado e aplicar GMT offset (igual ao relógio) + const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido + const gmtOffset = configRelogio.gmtOffset ?? 0; + + let timestampBase: number; + + if (configRelogio.usarServidorExterno) { + try { + const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + if (resultado.sucesso && resultado.timestamp) { + timestampBase = resultado.timestamp; + } else { + timestampBase = await obterTempoServidor(client); + } + } catch (error) { + console.warn('Erro ao sincronizar com servidor externo:', error); + if (configRelogio.fallbackParaPC) { + timestampBase = Date.now(); + } else { + timestampBase = await obterTempoServidor(client); + } + } + } else { + // Usar relógio do PC (sem sincronização com servidor) + timestampBase = Date.now(); + } + + // Aplicar GMT offset ao timestamp + // Quando GMT é 0, compensar o timezone local do navegador para que o timestamp + // represente o horário local (não UTC), evitando que apareça 3 horas a mais + let timestamp: number; + if (gmtOffset !== 0) { + // Aplicar offset configurado + timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000); + } else { + // Quando GMT = 0, ajustar para horário local do navegador + // getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC + // Exemplo: Brasil (UTC-3) retorna 180 minutos + // Subtrair esses minutos para que o timestamp represente o horário local + const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos + timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos + } + // Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida + const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now(); // Upload da imagem (obrigatória agora) let imagemId: Id<'_storage'> | undefined = undefined; @@ -275,9 +317,47 @@ // Se capturou a foto, mostrar modal de confirmação if (blob && capturandoAutomaticamente) { capturandoAutomaticamente = false; - // Obter data e hora sincronizada do servidor + // Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio) try { - const timestamp = await obterTempoServidor(client); + const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido + const gmtOffset = configRelogio.gmtOffset ?? 0; + + let timestampBase: number; + + if (configRelogio.usarServidorExterno) { + try { + const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + if (resultado.sucesso && resultado.timestamp) { + timestampBase = resultado.timestamp; + } else { + timestampBase = await obterTempoServidor(client); + } + } catch (error) { + console.warn('Erro ao sincronizar com servidor externo:', error); + if (configRelogio.fallbackParaPC) { + timestampBase = Date.now(); + } else { + timestampBase = await obterTempoServidor(client); + } + } + } else { + // Usar relógio do PC (sem sincronização com servidor) + timestampBase = Date.now(); + } + + // Aplicar GMT offset ao timestamp + // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática + // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + let timestamp: number; + if (gmtOffset !== 0) { + // Aplicar offset configurado + timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + timestamp = timestampBase; + } const dataObj = new Date(timestamp); const data = dataObj.toLocaleDateString('pt-BR'); const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index 1bb6e23..1f07ee7 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -17,6 +17,8 @@ async function atualizarTempo() { try { const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido + // Se não estiver configurado, usar null e tratar como 0 const gmtOffset = config.gmtOffset ?? 0; let timestampBase: number; @@ -45,16 +47,25 @@ } } } else { - // Usar tempo do servidor Convex - timestampBase = await obterTempoServidor(client); - sincronizado = true; + // Usar relógio do PC (sem sincronização com servidor) + timestampBase = obterTempoPC(); + sincronizado = false; usandoServidorExterno = false; - erro = null; + erro = 'Usando relógio do PC'; } // Aplicar GMT offset ao timestamp - // O timestamp está em UTC, adicionar o offset em horas - const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000); + // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática + // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + let timestampAjustado: number; + if (gmtOffset !== 0) { + // Aplicar offset configurado + timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + timestampAjustado = timestampBase; + } tempoAtual = new Date(timestampAjustado); } catch (error) { console.error('Erro ao obter tempo:', error); @@ -120,7 +131,7 @@ {erro} {:else} - Sincronizando... + Usando relógio do PC {/if} diff --git a/apps/web/src/lib/utils/jitsiPolyfill.ts b/apps/web/src/lib/utils/jitsiPolyfill.ts new file mode 100644 index 0000000..b083317 --- /dev/null +++ b/apps/web/src/lib/utils/jitsiPolyfill.ts @@ -0,0 +1,82 @@ +/** + * Polyfill global para BlobBuilder + * Deve ser executado ANTES de qualquer import de lib-jitsi-meet + * + * BlobBuilder é uma API antiga dos navegadores que foi substituída pelo construtor Blob + * A biblioteca lib-jitsi-meet pode tentar usar BlobBuilder em navegadores modernos + */ + +export function adicionarBlobBuilderPolyfill(): void { + if (typeof window === 'undefined') return; + + // Verificar se já foi adicionado (evitar múltiplas execuções) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((window as any).__blobBuilderPolyfillAdded) { + return; + } + + // Implementar BlobBuilder usando Blob moderno + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const BlobBuilderClass = class BlobBuilder { + private parts: BlobPart[] = []; + + append(data: BlobPart): void { + this.parts.push(data); + } + + getBlob(contentType?: string): Blob { + return new Blob(this.parts, contentType ? { type: contentType } : undefined); + } + }; + + // Adicionar em todos os possíveis locais onde a biblioteca pode procurar + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = window as any; + + // Definir BlobBuilder se não existir + if (typeof win.BlobBuilder === 'undefined') { + win.BlobBuilder = BlobBuilderClass; + } + + // Variantes de navegadores antigos + if (typeof win.WebKitBlobBuilder === 'undefined') { + win.WebKitBlobBuilder = BlobBuilderClass; + } + + if (typeof win.MozBlobBuilder === 'undefined') { + win.MozBlobBuilder = BlobBuilderClass; + } + + if (typeof win.MSBlobBuilder === 'undefined') { + win.MSBlobBuilder = BlobBuilderClass; + } + + // Adicionar no global scope + if (typeof globalThis !== 'undefined') { + if (typeof (globalThis as any).BlobBuilder === 'undefined') { + (globalThis as any).BlobBuilder = BlobBuilderClass; + } + if (typeof (globalThis as any).WebKitBlobBuilder === 'undefined') { + (globalThis as any).WebKitBlobBuilder = BlobBuilderClass; + } + if (typeof (globalThis as any).MozBlobBuilder === 'undefined') { + (globalThis as any).MozBlobBuilder = BlobBuilderClass; + } + } + + // Marcar que o polyfill foi adicionado + win.__blobBuilderPolyfillAdded = true; + + console.log('✅ Polyfill BlobBuilder adicionado globalmente'); +} + +// Executar imediatamente se estiver no browser +if (typeof window !== 'undefined') { + adicionarBlobBuilderPolyfill(); +} + + + + + + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index 04c223e..5a9cbca 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -28,13 +28,14 @@ // Parâmetros reativos para queries const registrosParams = $derived({ - funcionarioId: funcionarioIdFiltro || undefined, + funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, dataInicio, dataFim, }); const estatisticasParams = $derived({ dataInicio, dataFim, + funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, }); // Queries @@ -176,6 +177,12 @@ // Inicializar gráfico quando canvas e dados estiverem disponíveis $effect(() => { if (chartCanvas && estatisticas && chartData) { + // Destruir gráfico anterior se existir + if (chartInstance) { + chartInstance.destroy(); + chartInstance = null; + } + // Aguardar um pouco para garantir que o canvas está renderizado const timeoutId = setTimeout(() => { criarGrafico(); @@ -1748,7 +1755,6 @@ {/if} - {#if estatisticas}
@@ -1760,16 +1766,41 @@
+ {#if estatisticasQuery === undefined || estatisticasQuery?.isLoading} +
+
+ + Carregando estatísticas... +
+
+ {:else if estatisticasQuery?.error} +
+
+ +
+

Erro ao carregar estatísticas

+
{estatisticasQuery.error?.message || String(estatisticasQuery.error) || 'Erro desconhecido'}
+
+
+
+ {:else if !estatisticas || !chartData} +
+
+ +

Nenhuma estatística disponível

+
+
+ {:else} - {#if !chartInstance && estatisticas} -
+ {#if !chartInstance && estatisticas && chartData} +
{/if} + {/if}
- {/if}
@@ -1860,7 +1891,7 @@ {/if}
- {#if registrosQuery?.status === 'Loading'} + {#if registrosQuery === undefined || registrosQuery?.isLoading}
Carregando registros... @@ -1871,7 +1902,7 @@

Erro ao carregar registros

-
{registrosQuery.error.message || 'Erro desconhecido'}
+
{registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'}
{:else if !registrosQuery?.data} diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte index 5a483c9..7ed103d 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte @@ -1,52 +1,90 @@ -
+
-
+
-
+
-

Configurações do Jitsi Meet

+

Configurações do Jitsi Meet

Configurar servidor Jitsi para chamadas de vídeo e áudio

@@ -174,16 +300,16 @@ {#if mensagem}
- {#if mensagem.tipo === "success"} + {#if mensagem.tipo === 'success'} {/if} - {mensagem.texto} +
+ {mensagem.texto} + {#if mensagem.detalhes} +
+ Detalhes +
{mensagem.detalhes}
+
+ {/if} +
{/if} @@ -213,16 +348,12 @@ {#if !isLoading} -
+
{#if configAtual?.data?.ativo} Status: {statusConfig} {#if configAtual?.data?.testadoEm} - - Última conexão testada em {new Date( - configAtual.data.testadoEm - ).toLocaleString("pt-BR")} + - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')} {/if}
@@ -258,7 +387,7 @@

Dados do Servidor Jitsi

-
+
-

Configurações de Segurança

+

Configurações de Segurança

Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não recomendado para produção)Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não + recomendado para produção)
@@ -347,14 +471,228 @@
Habilitar apenas para desenvolvimento local com certificados autoassinados. Em produção, use certificados válidos.Habilitar apenas para desenvolvimento local com certificados autoassinados. Em + produção, use certificados válidos.
+ +
+
+

Configuração SSH/Docker (Opcional)

+ +
+ + {#if mostrarConfigSSH} +
+ +
+ + +
+ Endereço do servidor Docker +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ Ou use caminho da chave SSH abaixo +
+
+ + +
+ + +
+ Caminho no servidor SSH para a chave privada +
+
+ + +
+ + +
+ Diretório com docker-compose.yml +
+
+ + +
+ + +
+ Diretório de configurações do Jitsi +
+
+
+ + + {#if configuradoNoServidor} +
+ + + + + Configuração aplicada no servidor + {#if configCompleta?.data?.configuradoNoServidorEm} + em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')} + {/if} + +
+ {/if} + + +
+ + + +
+ {/if} + -
+