From 52823a9fac1898b6788ea9d9aa47b52d3bde25ba Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 21 Nov 2025 22:03:01 -0300 Subject: [PATCH 1/3] feat: integrate Jitsi configuration and dynamic loading in CallWindow - Added support for Jitsi configuration retrieval from the backend, allowing for dynamic room name generation based on the active configuration. - Implemented a polyfill for BlobBuilder to ensure compatibility with the lib-jitsi-meet library across different browsers. - Enhanced error handling during the loading of the Jitsi library, providing clearer feedback for missing modules and connection issues. - Updated Vite configuration to exclude lib-jitsi-meet from SSR and allow dynamic loading in the browser. - Introduced a new route for Jitsi settings in the dashboard for user configuration of Jitsi Meet parameters. --- .../src/lib/components/call/CallWindow.svelte | 173 +++++- apps/web/src/lib/utils/jitsi.ts | 73 ++- .../src/routes/(dashboard)/ti/+page.svelte | 22 +- .../ti/configuracoes-jitsi/+page.svelte | 532 ++++++++++++++++++ apps/web/vite.config.ts | 6 + packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/chamadas.ts | 20 +- packages/backend/convex/configuracaoJitsi.ts | 282 ++++++++++ packages/backend/convex/schema.ts | 13 + 9 files changed, 1100 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte create mode 100644 packages/backend/convex/configuracaoJitsi.ts diff --git a/apps/web/src/lib/components/call/CallWindow.svelte b/apps/web/src/lib/components/call/CallWindow.svelte index 3da53bd..6734301 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 { @@ -171,12 +172,137 @@ } // 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(); + + // 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 +314,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 +1000,10 @@ 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(); + // Inicializar store primeiro inicializarStore(); 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/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..364b5ac --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte @@ -0,0 +1,532 @@ + + +
+ +
+
+
+ + + +
+
+

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} + + + {#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. +
+
+
+ + +
+ + + +
+
+
+ {/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 2/3] 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 3/3] 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} + -
+