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.
This commit is contained in:
@@ -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<void> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const configAtual = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
let domain = $state("");
|
||||
let appId = $state("sgse-app");
|
||||
let roomPrefix = $state("sgse");
|
||||
let useHttps = $state(false);
|
||||
let acceptSelfSignedCert = $state(false);
|
||||
let processando = $state(false);
|
||||
let testando = $state(false);
|
||||
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||
|
||||
function mostrarMensagem(tipo: "success" | "error", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Carregar config existente
|
||||
$effect(() => {
|
||||
if (configAtual?.data) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Ativar HTTPS automaticamente se domínio contém porta 8443
|
||||
$effect(() => {
|
||||
if (domain.includes(":8443")) {
|
||||
useHttps = true;
|
||||
// Para localhost com porta 8443, geralmente é certificado autoassinado
|
||||
if (domain.includes("localhost")) {
|
||||
acceptSelfSignedCert = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function salvarConfiguracao() {
|
||||
// Validação de campos obrigatórios
|
||||
if (!domain?.trim() || !appId?.trim() || !roomPrefix?.trim()) {
|
||||
mostrarMensagem("error", "Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de roomPrefix (apenas letras, números e hífens)
|
||||
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
|
||||
if (!roomPrefixRegex.test(roomPrefix.trim())) {
|
||||
mostrarMensagem(
|
||||
"error",
|
||||
"Prefixo de sala deve conter apenas letras, números e hífens"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser?.data) {
|
||||
mostrarMensagem("error", "Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
const resultado = await client.mutation(api.configuracaoJitsi.salvarConfigJitsi, {
|
||||
domain: domain.trim(),
|
||||
appId: appId.trim(),
|
||||
roomPrefix: roomPrefix.trim(),
|
||||
useHttps,
|
||||
acceptSelfSignedCert,
|
||||
configuradoPorId: currentUser.data._id as Id<"usuarios">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Configuração salva com sucesso!");
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Erro ao salvar configuração:", error);
|
||||
mostrarMensagem("error", errorMessage || "Erro ao salvar configuração");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testarConexao() {
|
||||
if (!domain?.trim()) {
|
||||
mostrarMensagem("error", "Preencha o domínio antes de testar");
|
||||
return;
|
||||
}
|
||||
|
||||
testando = true;
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoJitsi.testarConexaoJitsi, {
|
||||
domain: domain.trim(),
|
||||
useHttps,
|
||||
acceptSelfSignedCert,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
const mensagemSucesso = resultado.aviso
|
||||
? `Conexão testada com sucesso! ${resultado.aviso}`
|
||||
: "Conexão testada com sucesso! Servidor Jitsi está acessível.";
|
||||
mostrarMensagem("success", mensagemSucesso);
|
||||
} else {
|
||||
mostrarMensagem("error", `Erro ao testar conexão: ${resultado.erro}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Erro ao testar conexão:", error);
|
||||
mostrarMensagem(
|
||||
"error",
|
||||
errorMessage || "Erro ao conectar com o servidor Jitsi"
|
||||
);
|
||||
} finally {
|
||||
testando = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusConfig = $derived(
|
||||
configAtual?.data?.ativo ? "Configurado" : "Não configurado"
|
||||
);
|
||||
|
||||
const isLoading = $derived(configAtual === undefined);
|
||||
const hasError = $derived(configAtual === null && !isLoading);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary/10 rounded-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Configurações do Jitsi Meet</h1>
|
||||
<p class="text-base-content/60 mt-1">
|
||||
Configurar servidor Jitsi para chamadas de vídeo e áudio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-6"
|
||||
class:alert-success={mensagem.tipo === "success"}
|
||||
class:alert-error={mensagem.tipo === "error"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if mensagem.tipo === "success"}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{mensagem.texto}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if isLoading}
|
||||
<div class="alert alert-info mb-6">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Carregando configurações...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status -->
|
||||
{#if !isLoading}
|
||||
<div
|
||||
class="alert {configAtual?.data?.ativo
|
||||
? 'alert-success'
|
||||
: 'alert-warning'} mb-6"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
{#if configAtual?.data?.ativo}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Status:</strong>
|
||||
{statusConfig}
|
||||
{#if configAtual?.data?.testadoEm}
|
||||
- Última conexão testada em {new Date(
|
||||
configAtual.data.testadoEm
|
||||
).toLocaleString("pt-BR")}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário -->
|
||||
{#if !isLoading}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Dados do Servidor Jitsi</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Domínio -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="jitsi-domain">
|
||||
<span class="label-text font-medium">Domínio do Servidor *</span>
|
||||
</label>
|
||||
<input
|
||||
id="jitsi-domain"
|
||||
type="text"
|
||||
bind:value={domain}
|
||||
placeholder="localhost:8443 ou meet.example.com"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt"
|
||||
>Ex: localhost:8443 (local), meet.example.com (produção)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App ID -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="jitsi-app-id">
|
||||
<span class="label-text font-medium">App ID *</span>
|
||||
</label>
|
||||
<input
|
||||
id="jitsi-app-id"
|
||||
type="text"
|
||||
bind:value={appId}
|
||||
placeholder="sgse-app"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Identificador da aplicação Jitsi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Prefix -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="jitsi-room-prefix">
|
||||
<span class="label-text font-medium">Prefixo de Sala *</span>
|
||||
</label>
|
||||
<input
|
||||
id="jitsi-room-prefix"
|
||||
type="text"
|
||||
bind:value={roomPrefix}
|
||||
placeholder="sgse"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt"
|
||||
>Apenas letras, números e hífens</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opções de Segurança -->
|
||||
<div class="divider"></div>
|
||||
<h3 class="font-bold mb-2">Configurações de Segurança</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={useHttps}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span class="label-text font-medium">Usar HTTPS</span>
|
||||
</label>
|
||||
<div class="label">
|
||||
<span class="label-text-alt"
|
||||
>Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não recomendado para produção)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={acceptSelfSignedCert}
|
||||
class="checkbox checkbox-warning"
|
||||
/>
|
||||
<span class="label-text font-medium">Aceitar Certificados Autoassinados</span>
|
||||
</label>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-warning"
|
||||
>Habilitar apenas para desenvolvimento local com certificados autoassinados. Em produção, use certificados válidos.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions justify-end mt-6 gap-3">
|
||||
<button
|
||||
class="btn btn-outline btn-info"
|
||||
onclick={testarConexao}
|
||||
disabled={testando || processando}
|
||||
>
|
||||
{#if testando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Testar Conexão
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={salvarConfiguracao}
|
||||
disabled={processando || testando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Salvar Configuração
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Exemplos Comuns -->
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Exemplos de Configuração</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ambiente</th>
|
||||
<th>Domínio</th>
|
||||
<th>App ID</th>
|
||||
<th>Prefixo Sala</th>
|
||||
<th>HTTPS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Docker Local</strong></td>
|
||||
<td>localhost:8443</td>
|
||||
<td>sgse-app</td>
|
||||
<td>sgse</td>
|
||||
<td>Sim</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Produção</strong></td>
|
||||
<td>meet.example.com</td>
|
||||
<td>sgse-app</td>
|
||||
<td>sgse</td>
|
||||
<td>Sim</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Desenvolvimento</strong></td>
|
||||
<td>localhost:8000</td>
|
||||
<td>sgse-app</td>
|
||||
<td>sgse-dev</td>
|
||||
<td>Não</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avisos -->
|
||||
<div class="alert alert-info mt-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Dica:</strong> Para servidor Jitsi Docker local, use
|
||||
<code>localhost:8443</code> com HTTPS habilitado. Para servidor em
|
||||
produção, use o domínio completo do seu servidor Jitsi.
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
A configuração será aplicada imediatamente após salvar. Usuários precisarão
|
||||
recarregar a página para usar a nova configuração.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aviso sobre Certificados Autoassinados -->
|
||||
{#if acceptSelfSignedCert}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-bold">Certificados Autoassinados Ativados</p>
|
||||
<p class="text-sm mt-1">
|
||||
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.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aviso sobre HTTP -->
|
||||
{#if !useHttps}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-bold">HTTP Ativado (Não Seguro)</p>
|
||||
<p class="text-sm mt-1">
|
||||
O uso de HTTP não é recomendado para produção. Use HTTPS com certificado válido
|
||||
para garantir segurança nas chamadas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
|
||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
// 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', {
|
||||
|
||||
282
packages/backend/convex/configuracaoJitsi.ts
Normal file
282
packages/backend/convex/configuracaoJitsi.ts
Normal file
@@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user