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 3da53bd..8ebe538 100644 --- a/apps/web/src/lib/components/call/CallWindow.svelte +++ b/apps/web/src/lib/components/call/CallWindow.svelte @@ -153,12 +153,13 @@ const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId }); const chamada = $derived(chamadaQuery?.data); const meuPerfil = useQuery(api.auth.getCurrentUser, {}); + const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {}); // Estado derivado do store const estadoChamada = $derived(get(callState)); - // Configuração Jitsi - const configJitsi = $derived.by(() => obterConfiguracaoJitsi()); + // Configuração Jitsi (busca do backend primeiro, depois fallback para env vars) + const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null)); // Handler de erro function handleError(message: string, details?: string): void { @@ -175,8 +176,68 @@ if (!browser || JitsiMeetJS) return; try { + console.log('🔄 Tentando carregar lib-jitsi-meet...'); + + // 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 const module = await import('lib-jitsi-meet'); - JitsiMeetJS = module.default as unknown as JitsiMeetJSLib; + + console.log('📦 Módulo carregado, verificando exportações...', { + hasDefault: !!module.default, + hasJitsiMeetJS: !!module.JitsiMeetJS, + keys: Object.keys(module) + }); + + // Tentar múltiplas formas de acessar o JitsiMeetJS + // A biblioteca pode exportar de diferentes formas dependendo da versão + let jitsiModule: unknown = null; + + // Tentativa 1: export default + if (module.default) { + if (typeof module.default === 'object' && 'init' in module.default) { + jitsiModule = module.default; + console.log('✅ Encontrado em module.default'); + } + } + + // Tentativa 2: export nomeado JitsiMeetJS + if (!jitsiModule && module.JitsiMeetJS) { + jitsiModule = module.JitsiMeetJS; + console.log('✅ Encontrado em module.JitsiMeetJS'); + } + + // Tentativa 3: o próprio módulo pode ser o JitsiMeetJS + if (!jitsiModule && typeof module === 'object' && 'init' in module) { + jitsiModule = module; + console.log('✅ Encontrado no próprio módulo'); + } + + if (!jitsiModule) { + throw new Error( + 'Não foi possível encontrar JitsiMeetJS no módulo. ' + + 'Verifique se lib-jitsi-meet está instalado corretamente.' + ); + } + + JitsiMeetJS = jitsiModule as unknown as JitsiMeetJSLib; + + // Verificar se JitsiMeetJS foi inicializado corretamente + if (!JitsiMeetJS || !JitsiMeetJS.init || typeof JitsiMeetJS.init !== 'function') { + throw new Error('JitsiMeetJS não possui método init válido'); + } + + // Verificar se JitsiConnection existe + if (!JitsiMeetJS.JitsiConnection) { + throw new Error('JitsiConnection não está disponível no módulo carregado'); + } + + console.log('🔧 Inicializando Jitsi Meet JS...'); // Inicializar Jitsi JitsiMeetJS.init({ @@ -188,16 +249,35 @@ disableThirdPartyRequests: false }); - // Configurar nível de log para DEBUG em desenvolvimento - JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO); + // Configurar nível de log + if (JitsiMeetJS.setLogLevel && typeof JitsiMeetJS.setLogLevel === 'function') { + if (JitsiMeetJS.constants && JitsiMeetJS.constants.logLevels) { + JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO); + } + } - console.log('✅ Jitsi Meet JS carregado e inicializado'); - } catch (error) { - console.error('Erro ao carregar lib-jitsi-meet:', error); - handleError( - 'Erro ao carregar biblioteca de vídeo', - 'Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Por favor, recarregue a página.' - ); + console.log('✅ Jitsi Meet JS carregado e inicializado com sucesso'); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('❌ Erro ao carregar lib-jitsi-meet:', error); + console.error('Detalhes do erro:', { + message: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + error + }); + + // Verificar se é um erro de módulo não encontrado + if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Cannot find module')) { + handleError( + 'Biblioteca de vídeo não encontrada', + 'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.' + ); + } else { + handleError( + 'Erro ao carregar biblioteca de vídeo', + `Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Erro: ${errorMessage}. Por favor, recarregue a página e tente novamente.` + ); + } } } @@ -855,6 +935,12 @@ onMount(async () => { if (!browser) return; + // 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/jitsi.ts b/apps/web/src/lib/utils/jitsi.ts index 0cd47b8..486a690 100644 --- a/apps/web/src/lib/utils/jitsi.ts +++ b/apps/web/src/lib/utils/jitsi.ts @@ -7,6 +7,7 @@ export interface ConfiguracaoJitsi { appId: string; roomPrefix: string; useHttps: boolean; + acceptSelfSignedCert?: boolean; } export interface DispositivoMedia { @@ -22,9 +23,50 @@ export interface DispositivosDisponiveis { } /** - * Obter configuração do Jitsi baseada em variáveis de ambiente + * Obter configuração do Jitsi do backend ou variáveis de ambiente (fallback) + * + * @param configBackend - Configuração do backend (opcional). Se fornecida, será usada. + * @returns Configuração do Jitsi */ -export function obterConfiguracaoJitsi(): ConfiguracaoJitsi { +export function obterConfiguracaoJitsi(configBackend?: { + domain: string; + appId: string; + roomPrefix: string; + useHttps: boolean; + acceptSelfSignedCert?: boolean; +} | null): ConfiguracaoJitsi { + // Se há configuração do backend e está ativa, usar ela + if (configBackend) { + return { + domain: configBackend.domain, + appId: configBackend.appId, + roomPrefix: configBackend.roomPrefix, + useHttps: configBackend.useHttps, + acceptSelfSignedCert: configBackend.acceptSelfSignedCert || false + }; + } + + // Fallback para variáveis de ambiente + const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443'; + const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app'; + const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse'; + const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true' || domain.includes(':8443'); + const acceptSelfSignedCert = import.meta.env.VITE_JITSI_ACCEPT_SELF_SIGNED === 'true'; + + return { + domain, + appId, + roomPrefix, + useHttps, + acceptSelfSignedCert + }; +} + +/** + * Obter configuração do Jitsi de forma síncrona (apenas variáveis de ambiente) + * Use esta função quando não houver acesso ao Convex client + */ +export function obterConfiguracaoJitsiSync(): ConfiguracaoJitsi { const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443'; const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app'; const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse'; @@ -49,9 +91,19 @@ export function obterHostEPorta(domain: string): { host: string; porta: number } /** * Gerar nome único para a sala Jitsi + * + * @param conversaId - ID da conversa + * @param tipo - Tipo de chamada ('audio' ou 'video') + * @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback. */ -export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): string { - const config = obterConfiguracaoJitsi(); +export function gerarRoomName( + conversaId: string, + tipo: 'audio' | 'video', + configBackend?: { + roomPrefix: string; + } | null +): string { + const config = obterConfiguracaoJitsi(configBackend || undefined); const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 9); const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10); @@ -61,9 +113,18 @@ export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): stri /** * Obter URL completa da sala Jitsi + * + * @param roomName - Nome da sala Jitsi + * @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback. */ -export function obterUrlSala(roomName: string): string { - const config = obterConfiguracaoJitsi(); +export function obterUrlSala( + roomName: string, + configBackend?: { + domain: string; + useHttps: boolean; + } | null +): string { + const config = obterConfiguracaoJitsi(configBackend || undefined); const protocol = config.useHttps ? 'https' : 'http'; return `${protocol}://${config.domain}/${roomName}`; } 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/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index e0f92f2..b3c78d0 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -12,7 +12,8 @@ | 'document' | 'teams' | 'userPlus' - | 'clock'; + | 'clock' + | 'video'; type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning'; type TiRouteId = @@ -28,7 +29,8 @@ | '/(dashboard)/ti/notificacoes' | '/(dashboard)/ti/monitoramento' | '/(dashboard)/ti/configuracoes-ponto' - | '/(dashboard)/ti/configuracoes-relogio'; + | '/(dashboard)/ti/configuracoes-relogio' + | '/(dashboard)/ti/configuracoes-jitsi'; type FeatureCard = { title: string; @@ -202,6 +204,13 @@ strokeLinecap: 'round', strokeLinejoin: 'round' } + ], + video: [ + { + d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z', + strokeLinecap: 'round', + strokeLinejoin: 'round' + } ] }; @@ -259,6 +268,15 @@ palette: 'secondary', icon: 'envelope' }, + { + title: 'Configurações do Jitsi', + description: + 'Configure o servidor Jitsi Meet para chamadas de vídeo e áudio no chat. Ajuste domínio, App ID e prefixo de salas.', + ctaLabel: 'Configurar Jitsi', + href: '/(dashboard)/ti/configuracoes-jitsi', + palette: 'primary', + icon: 'video' + }, { title: 'Configurações de Ponto', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte new file mode 100644 index 0000000..7ed103d --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte @@ -0,0 +1,876 @@ + + +
+ +
+
+
+ + + +
+
+

Configurações do Jitsi Meet

+

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

+
+
+
+ + + {#if mensagem} +
+ + {#if mensagem.tipo === 'success'} + + {:else} + + {/if} + +
+ {mensagem.texto} + {#if mensagem.detalhes} +
+ Detalhes +
{mensagem.detalhes}
+
+ {/if} +
+
+ {/if} + + + {#if isLoading} +
+ + Carregando configurações... +
+ {/if} + + + {#if !isLoading} +
+ + {#if configAtual?.data?.ativo} + + {:else} + + {/if} + + + Status: + {statusConfig} + {#if configAtual?.data?.testadoEm} + - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')} + {/if} + +
+ {/if} + + + {#if !isLoading} +
+
+

Dados do Servidor Jitsi

+ +
+ +
+ + +
+ Ex: localhost:8443 (local), meet.example.com (produção) +
+
+ + +
+ + +
+ Identificador da aplicação Jitsi +
+
+ + +
+ + +
+ Apenas letras, números e hífens +
+
+
+ + +
+

Configurações de Segurança

+ +
+
+ +
+ Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não + recomendado para produção) +
+
+ +
+ +
+ 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} + + +
+ + + +
+
+
+ {/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/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte index 2e4dcec..4eb25fa 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte @@ -10,10 +10,20 @@ let portaNTP = $state(123); let usarServidorExterno = $state(false); let fallbackParaPC = $state(true); - let gmtOffset = $state(0); + let gmtOffset = $state(-3); // Padrão GMT-3 para Brasília let processando = $state(false); let testando = $state(false); let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null); + let statusSincronizacao = $state<{ + ultimaSincronizacao: number | null; + offsetSegundos: number | null; + usandoServidorExterno: boolean; + } | null>(null); + + // Estados para os relógios + let timestampOriginal = $state(null); + let timestampUTC = $state(null); + let intervaloRelogio: ReturnType | null = null; $effect(() => { if (configQuery?.data) { @@ -21,10 +31,159 @@ portaNTP = configQuery.data.portaNTP || 123; usarServidorExterno = configQuery.data.usarServidorExterno || false; fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true; - gmtOffset = configQuery.data.gmtOffset ?? 0; + gmtOffset = configQuery.data.gmtOffset ?? -3; // Padrão GMT-3 para Brasília + + // Atualizar status de sincronização + statusSincronizacao = { + ultimaSincronizacao: configQuery.data.ultimaSincronizacao ?? null, + offsetSegundos: configQuery.data.offsetSegundos ?? null, + usandoServidorExterno: configQuery.data.usarServidorExterno || false, + }; } }); + // Função para obter tempo sincronizado + async function obterTempoSincronizado() { + try { + if (usarServidorExterno) { + // Se usar servidor externo, sincronizar com NTP + const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + if (resultado.sucesso) { + timestampUTC = resultado.timestamp; // Timestamp UTC da fonte + // Calcular o timestamp original (antes do ajuste GMT) + timestampOriginal = timestampUTC; + } + } else { + // Se não usar servidor externo, usar tempo local do PC (igual ao relógio de ponto) + timestampUTC = Date.now(); + timestampOriginal = timestampUTC; + } + } catch (error) { + console.error('Erro ao obter tempo sincronizado:', error); + // Fallback: usar tempo local + timestampUTC = Date.now(); + timestampOriginal = timestampUTC; + } + } + + // Inicializar e atualizar relógios periodicamente + $effect(() => { + // Obter tempo inicial quando configuração mudar + if (configQuery?.data) { + obterTempoSincronizado(); + } + + // Atualizar a cada segundo + intervaloRelogio = setInterval(() => { + if (timestampUTC !== null) { + // Incrementar em 1 segundo + timestampUTC += 1000; + timestampOriginal = timestampUTC; + } else { + // Se ainda não temos timestamp, tentar obter novamente + obterTempoSincronizado(); + } + }, 1000); + + // Limpar intervalo quando componente for destruído + return () => { + if (intervaloRelogio) { + clearInterval(intervaloRelogio); + } + }; + }); + + // Funções para formatar os relógios + function formatarRelogio(timestamp: number | null, ajusteGMT: number = 0): string { + if (!timestamp) return '--:--:--'; + // 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 timestampAjustado: number; + if (ajusteGMT !== 0) { + // Aplicar offset configurado + timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + timestampAjustado = timestamp; + } + const data = new Date(timestampAjustado); + return data.toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } + + function formatarDataRelogio(timestamp: number | null, ajusteGMT: number = 0): string { + if (!timestamp) return '--/--/----'; + // Aplicar GMT offset ao timestamp + // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleDateString() fazer a conversão automática + // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + let timestampAjustado: number; + if (ajusteGMT !== 0) { + // Aplicar offset configurado + timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleDateString() converterá automaticamente para o timezone local do navegador + timestampAjustado = timestamp; + } + const data = new Date(timestampAjustado); + return data.toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } + + // Função para formatar timestamp ISO + function formatarTimestampISO(timestamp: number | null): string { + if (!timestamp) return '--'; + return new Date(timestamp).toISOString(); + } + + // Função para obter nome do fuso horário baseado no offset + function obterNomeFusoHorario(offset: number): string { + const fusos: Record = { + '-12': 'IDLW (Linha Internacional de Data Oeste)', + '-11': 'HST (Hawaii-Aleutian Standard Time)', + '-10': 'HST (Hawaii Standard Time)', + '-9': 'AKST (Alaska Standard Time)', + '-8': 'PST (Pacific Standard Time)', + '-7': 'MST (Mountain Standard Time)', + '-6': 'CST (Central Standard Time)', + '-5': 'EST (Eastern Standard Time)', + '-4': 'AST (Atlantic Standard Time)', + '-3': 'BRT (Brasília Time) / ART (Argentina Time)', + '-2': 'GST (South Georgia Standard Time)', + '-1': 'AZOT (Azores Standard Time)', + '0': 'UTC / GMT (Greenwich Mean Time)', + '1': 'CET (Central European Time)', + '2': 'EET (Eastern European Time)', + '3': 'MSK (Moscow Standard Time)', + '4': 'GST (Gulf Standard Time)', + '5': 'PKT (Pakistan Standard Time)', + '6': 'BST (Bangladesh Standard Time)', + '7': 'ICT (Indochina Time)', + '8': 'CST (China Standard Time)', + '9': 'JST (Japan Standard Time)', + '10': 'AEST (Australian Eastern Standard Time)', + '11': 'SBT (Solomon Islands Time)', + '12': 'NZST (New Zealand Standard Time)', + }; + return fusos[offset.toString()] || `UTC${offset >= 0 ? '+' : ''}${offset}`; + } + + // Função para calcular diferença em horas e minutos + function calcularDiferencaFuso(offset: number): string { + const horas = Math.abs(offset); + const minutos = 0; // Offset em horas completas + return `${horas}h ${minutos}m ${offset < 0 ? 'atrás' : 'à frente'} de UTC`; + } + function mostrarMensagem(tipo: 'success' | 'error', texto: string) { mensagem = { tipo, texto }; setTimeout(() => { @@ -55,6 +214,9 @@ }); mostrarMensagem('success', 'Configuração salva com sucesso!'); + + // Recarregar configuração para atualizar status + // A query será atualizada automaticamente pelo useQuery } catch (error) { console.error('Erro ao salvar configuração:', error); mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração'); @@ -69,10 +231,37 @@ try { const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); if (resultado.sucesso) { + // Atualizar status de sincronização + statusSincronizacao = { + ultimaSincronizacao: Date.now(), + offsetSegundos: resultado.offsetSegundos, + usandoServidorExterno: resultado.usandoServidorExterno, + }; + + // Atualizar timestamps dos relógios + timestampUTC = resultado.timestamp; + timestampOriginal = resultado.timestamp; + + // Calcular horário atual com GMT offset + // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática + let timestampAjustado: number; + if (gmtOffset !== 0) { + timestampAjustado = timestampUTC + (gmtOffset * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + timestampAjustado = timestampUTC; + } + const horarioAtual = new Date(timestampAjustado).toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + mostrarMensagem( 'success', resultado.usandoServidorExterno - ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s` + ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}` : 'Usando relógio do PC (servidor externo não disponível)' ); } else { @@ -85,6 +274,24 @@ testando = false; } } + + // Função para atualizar relógios manualmente + async function atualizarRelogios() { + await obterTempoSincronizado(); + mostrarMensagem('success', 'Relógios atualizados!'); + } + + function formatarDataHora(timestamp: number | null): string { + if (!timestamp) return 'Nunca'; + return new Date(timestamp).toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }
@@ -117,6 +324,134 @@
{/if} + +
+
+
+

Relógios Sincronizados

+ +
+

+ Visualização em tempo real dos horários sincronizados. O primeiro relógio mostra o horário original (UTC) da fonte de sincronismo escolhida, e o segundo mostra o horário ajustado conforme o GMT configurado. +

+ +
+ +
+
+
+ Horário Original (UTC) +
+
+ {formatarRelogio(timestampOriginal, 0)} +
+
+ {formatarDataRelogio(timestampOriginal, 0)} +
+ + +
+ + +
+
+ Fuso Horário: + UTC / GMT (Greenwich Mean Time) +
+
+ Offset UTC: + ±00:00 (Coordenado Universal) +
+
+ Fonte: + + {statusSincronizacao?.usandoServidorExterno ? `Servidor NTP (${servidorNTP})` : 'Relógio do PC'} + +
+ {#if statusSincronizacao?.offsetSegundos !== null && statusSincronizacao?.offsetSegundos !== undefined} +
+ Offset Calculado: + + {statusSincronizacao.offsetSegundos > 0 ? '+' : ''}{statusSincronizacao.offsetSegundos}s + +
+ {/if} + {#if timestampOriginal} +
+ Timestamp UTC: + + {formatarTimestampISO(timestampOriginal)} + +
+ {/if} +
+ Formato Recebido: + ISO 8601 (UTC) +
+
+
+
+ + +
+
+
+ Horário com GMT {gmtOffset >= 0 ? '+' : ''}{gmtOffset} +
+
+ {formatarRelogio(timestampUTC, gmtOffset)} +
+
+ {formatarDataRelogio(timestampUTC, gmtOffset)} +
+ + +
+ + +
+
+ Fuso Horário: + {obterNomeFusoHorario(gmtOffset)} +
+
+ Offset Configurado: + + GMT{gmtOffset >= 0 ? '+' : ''}{gmtOffset} ({calcularDiferencaFuso(gmtOffset)}) + +
+
+ Ajuste Aplicado: + + {gmtOffset >= 0 ? '+' : ''}{gmtOffset}:00 UTC + +
+ {#if timestampUTC} +
+ Timestamp Local: + + {formatarTimestampISO(gmtOffset !== 0 ? timestampUTC + (gmtOffset * 60 * 60 * 1000) : timestampUTC)} + +
+ {/if} +
+ Status: + Ajuste aplicado em tempo real +
+
+
+
+
+
+
+
@@ -226,6 +561,34 @@
+ + {#if statusSincronizacao} +
+

Status de Sincronização

+
+
+
Última Sincronização
+
+ {formatarDataHora(statusSincronizacao.ultimaSincronizacao)} +
+
+
+
Offset Calculado
+
+ {statusSincronizacao.offsetSegundos !== null + ? `${statusSincronizacao.offsetSegundos > 0 ? '+' : ''}${statusSincronizacao.offsetSegundos}s` + : 'N/A'} +
+
+
+
Fonte de Tempo
+
+ {statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Relógio do PC'} +
+
+
+ {/if} +
{#if usarServidorExterno} @@ -272,7 +635,15 @@ específica.

- Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com + Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com, ntp.br +

+

+ Como funciona: O servidor NTP configurado é mapeado para uma API HTTP que retorna UTC. + O GMT offset configurado é então aplicado no frontend para exibir o horário correto. +

+

+ Importante: Todos os servidores NTP retornam tempo em UTC. O GMT offset é aplicado + apenas uma vez no frontend para ajustar ao fuso horário local (ex: GMT-3 para Brasília).

diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 86935d1..6bf3c46 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -3,6 +3,8 @@ import Sidebar from "$lib/components/Sidebar.svelte"; import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte"; import { authClient } from "$lib/auth"; + // Importar polyfill ANTES de qualquer outro código que possa usar Jitsi + import "$lib/utils/jitsiPolyfill"; const { children } = $props(); 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/bun.lock b/bun.lock index 9f97a1d..0f9a7b8 100644 --- a/bun.lock +++ b/bun.lock @@ -78,9 +78,11 @@ "@convex-dev/better-auth": "^0.9.7", "@convex-dev/rate-limiter": "^0.3.0", "@dicebear/avataaars": "^9.2.4", + "@types/ssh2": "^1.15.5", "better-auth": "catalog:", "convex": "catalog:", "nodemailer": "^7.0.10", + "ssh2": "^1.17.0", }, "devDependencies": { "@sgse-app/eslint-config": "*", @@ -616,6 +618,8 @@ "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/type-utils": "8.46.3", "@typescript-eslint/utils": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw=="], @@ -664,6 +668,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -680,6 +686,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="], "better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="], @@ -692,6 +700,8 @@ "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="], + "buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -730,6 +740,8 @@ "core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="], + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], @@ -1060,6 +1072,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="], @@ -1190,6 +1204,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1218,6 +1234,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1284,6 +1302,8 @@ "turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -1370,6 +1390,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1398,6 +1420,8 @@ "@convex-dev/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f79ac9a..0914ef0 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as actions_email from "../actions/email.js"; +import type * as actions_jitsiServer from "../actions/jitsiServer.js"; import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_smtp from "../actions/smtp.js"; @@ -22,6 +23,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"; @@ -65,6 +67,7 @@ import type { declare const fullApi: ApiFromModules<{ "actions/email": typeof actions_email; + "actions/jitsiServer": typeof actions_jitsiServer; "actions/linkPreview": typeof actions_linkPreview; "actions/pushNotifications": typeof actions_pushNotifications; "actions/smtp": typeof actions_smtp; @@ -78,6 +81,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/actions/jitsiServer.ts b/packages/backend/convex/actions/jitsiServer.ts new file mode 100644 index 0000000..3159eb8 --- /dev/null +++ b/packages/backend/convex/actions/jitsiServer.ts @@ -0,0 +1,424 @@ +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; +import { api, internal } from "../_generated/api"; +import { Client } from "ssh2"; +import { readFileSync } from "fs"; +import { decryptSMTPPasswordNode } from "./utils/nodeCrypto"; + +/** + * Interface para configuração SSH + */ +interface SSHConfig { + host: string; + port: number; + username: string; + password?: string; + keyPath?: string; +} + +/** + * Executar comando via SSH + */ +async function executarComandoSSH( + config: SSHConfig, + comando: string +): Promise<{ sucesso: boolean; output: string; erro?: string }> { + return new Promise((resolve) => { + const conn = new Client(); + let output = ""; + let errorOutput = ""; + + conn.on("ready", () => { + conn.exec(comando, (err, stream) => { + if (err) { + conn.end(); + resolve({ sucesso: false, output: "", erro: err.message }); + return; + } + + stream + .on("close", (code: number | null, signal: string | null) => { + conn.end(); + if (code === 0) { + resolve({ sucesso: true, output: output.trim() }); + } else { + resolve({ + sucesso: false, + output: output.trim(), + erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`, + }); + } + }) + .on("data", (data: Buffer) => { + output += data.toString(); + }) + .stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + }); + }).on("error", (err) => { + resolve({ sucesso: false, output: "", erro: err.message }); + }).connect({ + host: config.host, + port: config.port, + username: config.username, + password: config.password, + privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined, + readyTimeout: 10000, + }); + }); +} + +/** + * Ler arquivo via SSH + */ +async function lerArquivoSSH( + config: SSHConfig, + caminho: string +): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> { + const comando = `cat "${caminho}" 2>&1`; + const resultado = await executarComandoSSH(config, comando); + + if (!resultado.sucesso) { + return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" }; + } + + return { sucesso: true, conteudo: resultado.output }; +} + +/** + * Escrever arquivo via SSH + */ +async function escreverArquivoSSH( + config: SSHConfig, + caminho: string, + conteudo: string +): Promise<{ sucesso: boolean; erro?: string }> { + // Escapar conteúdo para shell + const conteudoEscapado = conteudo + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\$/g, "\\$") + .replace(/`/g, "\\`"); + + const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF' +${conteudo} +JITSI_CONFIG_EOF`; + + const resultado = await executarComandoSSH(config, comando); + + if (!resultado.sucesso) { + return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" }; + } + + return { sucesso: true }; +} + +/** + * Aplicar configurações do Jitsi no servidor Docker via SSH + */ +export const aplicarConfiguracaoServidor = action({ + args: { + configId: v.id("configuracaoJitsi"), + sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave) + }, + returns: v.union( + v.object({ + sucesso: v.literal(true), + mensagem: v.string(), + detalhes: v.optional(v.string()), + }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args): Promise< + | { sucesso: true; mensagem: string; detalhes?: string } + | { sucesso: false; erro: string } + > => { + try { + // Buscar configuração + const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); + + if (!config || config._id !== args.configId) { + return { sucesso: false as const, erro: "Configuração não encontrada" }; + } + + // Verificar se tem configurações SSH + const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, { + configId: args.configId, + }); + + if (!configFull || !configFull.sshHost) { + return { + sucesso: false as const, + erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.", + }; + } + + // Configurar SSH + let sshPasswordDecrypted: string | undefined = undefined; + + // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada + if (args.sshPassword) { + sshPasswordDecrypted = args.sshPassword; + } else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") { + // Tentar descriptografar senha armazenada + try { + sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash); + } catch (error) { + return { + sucesso: false as const, + erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.", + }; + } + } + + const sshConfig: SSHConfig = { + host: configFull.sshHost, + port: configFull.sshPort || 22, + username: configFull.sshUsername || "", + password: sshPasswordDecrypted, + keyPath: configFull.sshKeyPath || undefined, + }; + + if (!sshConfig.username) { + return { sucesso: false as const, erro: "Usuário SSH não configurado" }; + } + + if (!sshConfig.password && !sshConfig.keyPath) { + return { + sucesso: false as const, + erro: "Senha SSH ou caminho da chave deve ser fornecido", + }; + } + + const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg"; + const dockerComposePath = configFull.dockerComposePath || "."; + + // Extrair host e porta do domain + const [host, portStr] = configFull.domain.split(":"); + const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80; + const protocol = configFull.useHttps ? "https" : "http"; + + const detalhes: string[] = []; + + // 1. Atualizar arquivo .env do docker-compose + if (dockerComposePath) { + const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE +CONFIG=${basePath} +TZ=America/Recife +ENABLE_LETSENCRYPT=0 +HTTP_PORT=${protocol === "https" ? 8000 : port} +HTTPS_PORT=${configFull.useHttps ? port : 8443} +PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""} +DOMAIN=${host} +ENABLE_AUTH=0 +ENABLE_GUESTS=1 +ENABLE_TRANSCRIPTION=0 +ENABLE_RECORDING=0 +ENABLE_PREJOIN_PAGE=0 +START_AUDIO_MUTED=0 +START_VIDEO_MUTED=0 +ENABLE_XMPP_WEBSOCKET=0 +ENABLE_P2P=1 +MAX_NUMBER_OF_PARTICIPANTS=10 +RESOLUTION_WIDTH=1280 +RESOLUTION_HEIGHT=720 +JWT_APP_ID=${configFull.appId} +JWT_APP_SECRET= +`; + + const envPath = `${dockerComposePath}/.env`; + const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent); + + if (!resultadoEnv.sucesso) { + return { + sucesso: false as const, + erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`, + }; + } + + detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`); + } + + // 2. Atualizar configuração do Prosody + const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`; + const prosodyContent = `-- Configuração Prosody para ${host} +-- Gerada automaticamente pelo SGSE + +VirtualHost "${host}" + authentication = "anonymous" + modules_enabled = { + "bosh"; + "ping"; + "speakerstats"; + "turncredentials"; + "presence"; + "conference_duration"; + } + c2s_require_encryption = false + allow_anonymous_s2s = false + +Component "conference.${host}" "muc" + storage = "memory" + muc_room_locking = false + muc_room_default_public_jids = true + +Component "jitsi-videobridge.${host}" + component_secret = "" + +Component "focus.${host}" + component_secret = "" +`; + + const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent); + + if (!resultadoProsody.sucesso) { + return { + sucesso: false as const, + erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`, + }; + } + + detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`); + + // 3. Atualizar configuração do Jicofo + const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`; + const jicofoContent = `# Configuração Jicofo +# Gerada automaticamente pelo SGSE +org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host} +org.jitsi.jicofo.jid=XMPP_USER@${host} +org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host} +org.jitsi.jicofo.app.ID=${configFull.appId} +`; + + const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent); + + if (!resultadoJicofo.sucesso) { + return { + sucesso: false as const, + erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`, + }; + } + + detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`); + + // 4. Atualizar configuração do JVB + const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`; + const jvbContent = `# Configuração JVB (Jitsi Video Bridge) +# Gerada automaticamente pelo SGSE +org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.* +org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host} +org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host} +org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb +org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host} +`; + + const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent); + + if (!resultadoJvb.sucesso) { + return { + sucesso: false as const, + erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`, + }; + } + + detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`); + + // 5. Reiniciar containers Docker + if (dockerComposePath) { + const resultadoRestart = await executarComandoSSH( + sshConfig, + `cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1` + ); + + if (!resultadoRestart.sucesso) { + return { + sucesso: false as const, + erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`, + }; + } + + detalhes.push(`✓ Containers Docker reiniciados`); + } + + // Atualizar timestamp de configuração no servidor + await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, { + configId: args.configId, + }); + + return { + sucesso: true as const, + mensagem: "Configurações aplicadas com sucesso no servidor Jitsi", + detalhes: detalhes.join("\n"), + }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + sucesso: false as const, + erro: `Erro ao aplicar configurações: ${errorMessage}`, + }; + } + }, +}); + +/** + * Testar conexão SSH + */ +export const testarConexaoSSH = action({ + args: { + sshHost: v.string(), + sshPort: v.optional(v.number()), + sshUsername: v.string(), + sshPassword: v.optional(v.string()), + sshKeyPath: v.optional(v.string()), + }, + returns: v.union( + v.object({ sucesso: v.literal(true), mensagem: v.string() }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args): Promise< + | { sucesso: true; mensagem: string } + | { sucesso: false; erro: string } + > => { + try { + if (!args.sshPassword && !args.sshKeyPath) { + return { + sucesso: false as const, + erro: "Senha SSH ou caminho da chave deve ser fornecido", + }; + } + + const sshConfig: SSHConfig = { + host: args.sshHost, + port: args.sshPort || 22, + username: args.sshUsername, + password: args.sshPassword || undefined, + keyPath: args.sshKeyPath || undefined, + }; + + // Tentar executar um comando simples + const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'"); + + if (resultado.sucesso && resultado.output.includes("SSH_OK")) { + return { + sucesso: true as const, + mensagem: "Conexão SSH estabelecida com sucesso", + }; + } + + return { + sucesso: false as const, + erro: resultado.erro || "Falha ao estabelecer conexão SSH", + }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + sucesso: false as const, + erro: `Erro ao testar SSH: ${errorMessage}`, + }; + } + }, +}); + 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..c04ede5 --- /dev/null +++ b/packages/backend/convex/configuracaoJitsi.ts @@ -0,0 +1,372 @@ +import { v } from "convex/values"; +import { mutation, query, action, internalMutation } from "./_generated/server"; +import { registrarAtividade } from "./logsAtividades"; +import { api, internal } from "./_generated/api"; +import { encryptSMTPPassword } from "./auth/utils"; + +/** + * 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, + }; + }, +}); + +/** + * Obter configuração completa de Jitsi (incluindo SSH, mas sem senha) + */ +export const obterConfigJitsiCompleta = query({ + args: { + configId: v.id("configuracaoJitsi"), + }, + handler: async (ctx, args) => { + const config = await ctx.db.get(args.configId); + + if (!config) { + return null; + } + + return { + _id: config._id, + domain: config.domain, + appId: config.appId, + roomPrefix: config.roomPrefix, + useHttps: config.useHttps, + acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, + ativo: config.ativo, + testadoEm: config.testadoEm, + atualizadoEm: config.atualizadoEm, + configuradoEm: config.configuradoEm, + // Configurações SSH (sem senha) + sshHost: config.sshHost, + sshPort: config.sshPort, + sshUsername: config.sshUsername, + sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar + sshKeyPath: config.sshKeyPath, + dockerComposePath: config.dockerComposePath, + jitsiConfigPath: config.jitsiConfigPath, + configuradoNoServidor: config.configuradoNoServidor ?? false, + configuradoNoServidorEm: config.configuradoNoServidorEm, + }; + }, +}); + +/** + * 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"), + // Opcionais: configurações SSH/Docker + sshHost: v.optional(v.string()), + sshPort: v.optional(v.number()), + sshUsername: v.optional(v.string()), + sshPassword: v.optional(v.string()), // Senha nova (será criptografada) + sshKeyPath: v.optional(v.string()), + dockerComposePath: v.optional(v.string()), + jitsiConfigPath: v.optional(v.string()), + }, + 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", + }; + } + + // Buscar config ativa anterior para manter senha SSH se não fornecida + const configAtiva = await ctx.db + .query("configuracaoJitsi") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + // 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 }); + } + + // Determinar senha SSH: usar nova senha se fornecida, senão manter a atual + let sshPasswordHash: string | undefined = undefined; + if (args.sshPassword && args.sshPassword.trim().length > 0) { + // Nova senha fornecida, criptografar + sshPasswordHash = await encryptSMTPPassword(args.sshPassword); + } else if (configAtiva && configAtiva.sshPasswordHash) { + // Senha não fornecida, manter a atual (já criptografada) + sshPasswordHash = configAtiva.sshPasswordHash; + } + + // 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, + ativo: true, + configuradoPor: args.configuradoPorId, + atualizadoEm: Date.now(), + // Configurações SSH/Docker + sshHost: args.sshHost?.trim() || undefined, + sshPort: args.sshPort || undefined, + sshUsername: args.sshUsername?.trim() || undefined, + sshPasswordHash: sshPasswordHash, + sshKeyPath: args.sshKeyPath?.trim() || undefined, + dockerComposePath: args.dockerComposePath?.trim() || undefined, + jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined, + }); + + // 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(), + }); + }, +}); + +/** + * Mutation interna para marcar que a configuração foi aplicada no servidor + */ +export const marcarConfiguradoNoServidor = internalMutation({ + args: { + configId: v.id("configuracaoJitsi"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + configuradoNoServidor: true, + configuradoNoServidorEm: Date.now(), + configuradoEm: Date.now(), + }); + return null; + }, +}); + diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts index b932127..41c9640 100644 --- a/packages/backend/convex/configuracaoRelogio.ts +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -10,13 +10,18 @@ import { api, internal } from './_generated/api'; export const obterConfiguracao = query({ args: {}, handler: async (ctx) => { - const config = await ctx.db + // Buscar todas as configurações e pegar a mais recente (por atualizadoEm) + const configs = await ctx.db .query('configuracaoRelogio') - .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true)) - .first(); + .collect(); + + // Pegar a configuração mais recente (ordenar por atualizadoEm desc) + const config = configs.length > 0 + ? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0] + : null; if (!config) { - // Retornar configuração padrão + // Retornar configuração padrão (GMT-3 para Brasília) return { servidorNTP: 'pool.ntp.org', portaNTP: 123, @@ -24,13 +29,13 @@ export const obterConfiguracao = query({ fallbackParaPC: true, ultimaSincronizacao: null, offsetSegundos: null, - gmtOffset: 0, + gmtOffset: -3, // GMT-3 para Brasília }; } return { ...config, - gmtOffset: config.gmtOffset ?? 0, + gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado }; }, }); @@ -64,11 +69,14 @@ export const salvarConfiguracao = mutation({ } } - // Buscar configuração existente - const configExistente = await ctx.db + // Buscar configuração existente (pegar a mais recente) + const configs = await ctx.db .query('configuracaoRelogio') - .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno)) - .first(); + .collect(); + + const configExistente = configs.length > 0 + ? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0] + : null; if (configExistente) { // Atualizar configuração existente @@ -77,7 +85,7 @@ export const salvarConfiguracao = mutation({ portaNTP: args.portaNTP, usarServidorExterno: args.usarServidorExterno, fallbackParaPC: args.fallbackParaPC, - gmtOffset: args.gmtOffset ?? 0, + gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoEm: Date.now(), }); @@ -89,7 +97,7 @@ export const salvarConfiguracao = mutation({ portaNTP: args.portaNTP, usarServidorExterno: args.usarServidorExterno, fallbackParaPC: args.fallbackParaPC, - gmtOffset: args.gmtOffset ?? 0, + gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoEm: Date.now(), }); @@ -131,16 +139,75 @@ export const sincronizarTempo = action({ } // Tentar obter tempo de um servidor NTP público via HTTP - // Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica + // Nota: NTP real requer protocolo UDP na porta 123, aqui usamos APIs HTTP que retornam UTC + // O GMT offset será aplicado no frontend try { - // Usar API pública de tempo como fallback - const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife'); - if (!response.ok) { - throw new Error('Falha ao obter tempo do servidor'); + const servidorNTP = config.servidorNTP || 'pool.ntp.org'; + let serverTime: number; + + // Mapear servidores NTP conhecidos para APIs HTTP que retornam UTC + // Todos os servidores NTP retornam UTC, então usamos APIs que retornam UTC + if (servidorNTP.includes('pool.ntp.org') || servidorNTP.includes('ntp.org') || servidorNTP.includes('ntp.br')) { + // pool.ntp.org e servidores .org/.br - usar API que retorna UTC + const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo do servidor'); + } + const data = (await response.json()) as { unixtime: number; datetime: string }; + // unixtime está em segundos, converter para milissegundos + serverTime = data.unixtime * 1000; + } else if (servidorNTP.includes('time.google.com') || servidorNTP.includes('google')) { + // Google NTP - usar API que retorna UTC + try { + const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo'); + } + const data = (await response.json()) as { unixtime: number }; + serverTime = data.unixtime * 1000; + } catch { + // Fallback para outra API UTC + const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo do servidor'); + } + const data = (await response.json()) as { unixTime: number }; + serverTime = data.unixTime * 1000; + } + } else if (servidorNTP.includes('time.windows.com') || servidorNTP.includes('windows')) { + // Windows NTP - usar API que retorna UTC + const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo do servidor'); + } + const data = (await response.json()) as { unixtime: number }; + serverTime = data.unixtime * 1000; + } else { + // Para outros servidores NTP, usar API genérica que retorna UTC + // Tentar worldtimeapi primeiro + try { + const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo'); + } + const data = (await response.json()) as { unixtime: number }; + serverTime = data.unixtime * 1000; + } catch { + // Fallback para timeapi.io + try { + const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC'); + if (!response.ok) { + throw new Error('Falha ao obter tempo'); + } + const data = (await response.json()) as { unixTime: number }; + serverTime = data.unixTime * 1000; + } catch { + // Último fallback: usar tempo do servidor Convex (já está em UTC) + serverTime = Date.now(); + } + } } - const data = (await response.json()) as { datetime: string }; - const serverTime = new Date(data.datetime).getTime(); const localTime = Date.now(); const offsetSegundos = Math.floor((serverTime - localTime) / 1000); @@ -160,23 +227,26 @@ export const sincronizarTempo = action({ return { sucesso: true, - timestamp: serverTime, + timestamp: serverTime, // Retorna UTC (sem GMT offset aplicado) usandoServidorExterno: true, offsetSegundos, }; - } catch { - // Se falhar e fallbackParaPC estiver ativo, usar tempo local - if (config.fallbackParaPC) { - return { - sucesso: true, - timestamp: Date.now(), - usandoServidorExterno: false, - offsetSegundos: 0, - aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC', - }; - } - - throw new Error('Falha ao sincronizar tempo e fallback desabilitado'); + } catch (error) { + // Sempre usar fallback como última opção, mesmo se desabilitado + // Isso evita que o sistema trave completamente se o servidor externo não estiver disponível + const aviso = config.fallbackParaPC + ? 'Falha ao sincronizar com servidor externo, usando relógio do PC' + : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.'; + + console.warn('Erro ao sincronizar tempo com servidor externo:', error); + + return { + sucesso: true, + timestamp: Date.now(), + usandoServidorExterno: false, + offsetSegundos: 0, + aviso, + }; } }, }); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 5d1060a..36299b6 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -369,38 +369,23 @@ export const registrarPonto = mutation({ throw new Error('Configuração de ponto não encontrada'); } - // Obter configuração de ponto para GMT offset (buscar configuração ativa) - const configPonto = await ctx.db - .query('configuracaoPonto') - .withIndex('by_ativo', (q) => q.eq('ativo', true)) - .first(); - - // Converter timestamp para data/hora com ajuste de GMT - // O timestamp está em UTC, precisamos aplicar o GMT offset - const gmtOffset = configPonto?.gmtOffset ?? 0; + // Converter timestamp para data/hora + // O timestamp pode vir ajustado com GMT offset do frontend (se GMT !== 0) + // ou em UTC puro (se GMT === 0). Usamos UTC methods para extrair os valores + // diretamente do timestamp recebido, seja ele ajustado ou não + const dataObj = new Date(args.timestamp); + // Usar UTC methods porque: + // - Se GMT === 0: timestamp está em UTC puro, métodos UTC extraem corretamente + // - Se GMT !== 0: timestamp já vem ajustado do frontend, métodos UTC extraem o horário ajustado + const hora = dataObj.getUTCHours(); + const minuto = dataObj.getUTCMinutes(); + const segundo = dataObj.getUTCSeconds(); - // Calcular horário ajustado manualmente a partir de UTC - const dataUTC = new Date(args.timestamp); - let hora = dataUTC.getUTCHours() + gmtOffset; - const minuto = dataUTC.getUTCMinutes(); - const segundo = dataUTC.getUTCSeconds(); - - // Ajustar hora se ultrapassar os limites do dia - let diasOffset = 0; - if (hora >= 24) { - hora = hora - 24; - diasOffset = 1; - } else if (hora < 0) { - hora = hora + 24; - diasOffset = -1; - } - - // Calcular data ajustada - const dataAjustada = new Date(args.timestamp); - if (diasOffset !== 0) { - dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset); - } - const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD + // Obter data no formato YYYY-MM-DD usando UTC + const ano = dataObj.getUTCFullYear(); + const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0'); + const dia = String(dataObj.getUTCDate()).padStart(2, '0'); + const data = `${ano}-${mes}-${dia}`; // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined @@ -544,7 +529,7 @@ export const registrarPonto = mutation({ } | null = null; if ( - configPonto?.validarLocalizacao !== false && + config.validarLocalizacao !== false && args.informacoesDispositivo?.latitude && args.informacoesDispositivo?.longitude ) { @@ -553,7 +538,7 @@ export const registrarPonto = mutation({ usuario.funcionarioId, args.informacoesDispositivo.latitude, args.informacoesDispositivo.longitude, - configPonto?.toleranciaDistanciaMetros ?? 100 + config.toleranciaDistanciaMetros ?? 100 ); validacaoGeofencing = geofencing; @@ -822,6 +807,7 @@ export const obterEstatisticas = query({ args: { dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string(), // YYYY-MM-DD + funcionarioId: v.optional(v.id('funcionarios')), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); @@ -831,11 +817,16 @@ export const obterEstatisticas = query({ // TODO: Verificar permissão (RH ou TI) - const registros = await ctx.db + let registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); + // Filtrar por funcionário se fornecido + if (args.funcionarioId) { + registros = registros.filter((r) => r.funcionarioId === args.funcionarioId); + } + const totalRegistros = registros.length; const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length; const foraDoPrazo = totalRegistros - dentroDoPrazo; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index fa3e753..9e02da5 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -708,6 +708,30 @@ 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) + // Configurações SSH/Docker para configuração automática do servidor + sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local") + sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) + sshUsername: v.optional(v.string()), // Usuário SSH + sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) + sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha) + dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker") + jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg") + ativo: v.boolean(), // Configuração ativa + testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão + configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker + configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor + configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor + 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 diff --git a/packages/backend/package.json b/packages/backend/package.json index fcb7a6e..8b2c439 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -25,8 +25,10 @@ "@convex-dev/better-auth": "^0.9.7", "@convex-dev/rate-limiter": "^0.3.0", "@dicebear/avataaars": "^9.2.4", + "@types/ssh2": "^1.15.5", "better-auth": "catalog:", "convex": "catalog:", - "nodemailer": "^7.0.10" + "nodemailer": "^7.0.10", + "ssh2": "^1.17.0" } } \ No newline at end of file