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:
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