+ Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não
+ recomendado para produção)
+
+
+
+
+
+
+ Habilitar apenas para desenvolvimento local com certificados autoassinados. Em
+ produção, use certificados válidos.
+
+
+
+
+
+
+
+
Configuração SSH/Docker (Opcional)
+
+
+
+ {#if mostrarConfigSSH}
+
+
+
+
+
+
+ Endereço do servidor Docker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ou use caminho da chave SSH abaixo
+
+
+
+
+
+
+
+
+ Caminho no servidor SSH para a chave privada
+
+
+
+
+
+
+
+
+ Diretório com docker-compose.yml
+
+
+
+
+
+
+
+
+ Diretório de configurações do Jitsi
+
+
+
+
+
+ {#if configuradoNoServidor}
+
+
+
+ Configuração aplicada no servidor
+ {#if configCompleta?.data?.configuradoNoServidorEm}
+ em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')}
+ {/if}
+
+
+ {/if}
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
Exemplos de Configuração
+
+
+
+
+
+
Ambiente
+
Domínio
+
App ID
+
Prefixo Sala
+
HTTPS
+
+
+
+
+
Docker Local
+
localhost:8443
+
sgse-app
+
sgse
+
Sim
+
+
+
Produção
+
meet.example.com
+
sgse-app
+
sgse
+
Sim
+
+
+
Desenvolvimento
+
localhost:8000
+
sgse-app
+
sgse-dev
+
Não
+
+
+
+
+
+
+
+
+
+
+
+
+ Dica: Para servidor Jitsi Docker local, use
+ localhost:8443 com HTTPS habilitado. Para servidor em produção, use o domínio completo
+ do seu servidor Jitsi.
+
+
+ A configuração será aplicada imediatamente após salvar. Usuários precisarão recarregar a
+ página para usar a nova configuração.
+
+
+
+
+
+ {#if acceptSelfSignedCert}
+
+
+
+
Certificados Autoassinados Ativados
+
+ Para certificados autoassinados (desenvolvimento local), os usuários precisarão aceitar o
+ certificado no navegador na primeira conexão. Em produção, use certificados válidos (Let's
+ Encrypt, etc.).
+
+
+
+ {/if}
+
+
+ {#if !useHttps}
+
+
+
+
HTTP Ativado (Não Seguro)
+
+ O uso de HTTP não é recomendado para produção. Use HTTPS com certificado válido para
+ garantir segurança nas chamadas.
+
+
+
+ {/if}
+
diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte
index 2e4dcec..4eb25fa 100644
--- a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte
@@ -10,10 +10,20 @@
let portaNTP = $state(123);
let usarServidorExterno = $state(false);
let fallbackParaPC = $state(true);
- let gmtOffset = $state(0);
+ let gmtOffset = $state(-3); // Padrão GMT-3 para Brasília
let processando = $state(false);
let testando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
+ let statusSincronizacao = $state<{
+ ultimaSincronizacao: number | null;
+ offsetSegundos: number | null;
+ usandoServidorExterno: boolean;
+ } | null>(null);
+
+ // Estados para os relógios
+ let timestampOriginal = $state(null);
+ let timestampUTC = $state(null);
+ let intervaloRelogio: ReturnType | null = null;
$effect(() => {
if (configQuery?.data) {
@@ -21,10 +31,159 @@
portaNTP = configQuery.data.portaNTP || 123;
usarServidorExterno = configQuery.data.usarServidorExterno || false;
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
- gmtOffset = configQuery.data.gmtOffset ?? 0;
+ gmtOffset = configQuery.data.gmtOffset ?? -3; // Padrão GMT-3 para Brasília
+
+ // Atualizar status de sincronização
+ statusSincronizacao = {
+ ultimaSincronizacao: configQuery.data.ultimaSincronizacao ?? null,
+ offsetSegundos: configQuery.data.offsetSegundos ?? null,
+ usandoServidorExterno: configQuery.data.usarServidorExterno || false,
+ };
}
});
+ // Função para obter tempo sincronizado
+ async function obterTempoSincronizado() {
+ try {
+ if (usarServidorExterno) {
+ // Se usar servidor externo, sincronizar com NTP
+ const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
+ if (resultado.sucesso) {
+ timestampUTC = resultado.timestamp; // Timestamp UTC da fonte
+ // Calcular o timestamp original (antes do ajuste GMT)
+ timestampOriginal = timestampUTC;
+ }
+ } else {
+ // Se não usar servidor externo, usar tempo local do PC (igual ao relógio de ponto)
+ timestampUTC = Date.now();
+ timestampOriginal = timestampUTC;
+ }
+ } catch (error) {
+ console.error('Erro ao obter tempo sincronizado:', error);
+ // Fallback: usar tempo local
+ timestampUTC = Date.now();
+ timestampOriginal = timestampUTC;
+ }
+ }
+
+ // Inicializar e atualizar relógios periodicamente
+ $effect(() => {
+ // Obter tempo inicial quando configuração mudar
+ if (configQuery?.data) {
+ obterTempoSincronizado();
+ }
+
+ // Atualizar a cada segundo
+ intervaloRelogio = setInterval(() => {
+ if (timestampUTC !== null) {
+ // Incrementar em 1 segundo
+ timestampUTC += 1000;
+ timestampOriginal = timestampUTC;
+ } else {
+ // Se ainda não temos timestamp, tentar obter novamente
+ obterTempoSincronizado();
+ }
+ }, 1000);
+
+ // Limpar intervalo quando componente for destruído
+ return () => {
+ if (intervaloRelogio) {
+ clearInterval(intervaloRelogio);
+ }
+ };
+ });
+
+ // Funções para formatar os relógios
+ function formatarRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
+ if (!timestamp) return '--:--:--';
+ // Aplicar GMT offset ao timestamp
+ // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
+ // Quando GMT ≠ 0, aplicar offset configurado ao timestamp
+ let timestampAjustado: number;
+ if (ajusteGMT !== 0) {
+ // Aplicar offset configurado
+ timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
+ } else {
+ // Quando GMT = 0, manter timestamp UTC puro
+ // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
+ timestampAjustado = timestamp;
+ }
+ const data = new Date(timestampAjustado);
+ return data.toLocaleTimeString('pt-BR', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ });
+ }
+
+ function formatarDataRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
+ if (!timestamp) return '--/--/----';
+ // Aplicar GMT offset ao timestamp
+ // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleDateString() fazer a conversão automática
+ // Quando GMT ≠ 0, aplicar offset configurado ao timestamp
+ let timestampAjustado: number;
+ if (ajusteGMT !== 0) {
+ // Aplicar offset configurado
+ timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
+ } else {
+ // Quando GMT = 0, manter timestamp UTC puro
+ // O toLocaleDateString() converterá automaticamente para o timezone local do navegador
+ timestampAjustado = timestamp;
+ }
+ const data = new Date(timestampAjustado);
+ return data.toLocaleDateString('pt-BR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+ }
+
+ // Função para formatar timestamp ISO
+ function formatarTimestampISO(timestamp: number | null): string {
+ if (!timestamp) return '--';
+ return new Date(timestamp).toISOString();
+ }
+
+ // Função para obter nome do fuso horário baseado no offset
+ function obterNomeFusoHorario(offset: number): string {
+ const fusos: Record = {
+ '-12': 'IDLW (Linha Internacional de Data Oeste)',
+ '-11': 'HST (Hawaii-Aleutian Standard Time)',
+ '-10': 'HST (Hawaii Standard Time)',
+ '-9': 'AKST (Alaska Standard Time)',
+ '-8': 'PST (Pacific Standard Time)',
+ '-7': 'MST (Mountain Standard Time)',
+ '-6': 'CST (Central Standard Time)',
+ '-5': 'EST (Eastern Standard Time)',
+ '-4': 'AST (Atlantic Standard Time)',
+ '-3': 'BRT (Brasília Time) / ART (Argentina Time)',
+ '-2': 'GST (South Georgia Standard Time)',
+ '-1': 'AZOT (Azores Standard Time)',
+ '0': 'UTC / GMT (Greenwich Mean Time)',
+ '1': 'CET (Central European Time)',
+ '2': 'EET (Eastern European Time)',
+ '3': 'MSK (Moscow Standard Time)',
+ '4': 'GST (Gulf Standard Time)',
+ '5': 'PKT (Pakistan Standard Time)',
+ '6': 'BST (Bangladesh Standard Time)',
+ '7': 'ICT (Indochina Time)',
+ '8': 'CST (China Standard Time)',
+ '9': 'JST (Japan Standard Time)',
+ '10': 'AEST (Australian Eastern Standard Time)',
+ '11': 'SBT (Solomon Islands Time)',
+ '12': 'NZST (New Zealand Standard Time)',
+ };
+ return fusos[offset.toString()] || `UTC${offset >= 0 ? '+' : ''}${offset}`;
+ }
+
+ // Função para calcular diferença em horas e minutos
+ function calcularDiferencaFuso(offset: number): string {
+ const horas = Math.abs(offset);
+ const minutos = 0; // Offset em horas completas
+ return `${horas}h ${minutos}m ${offset < 0 ? 'atrás' : 'à frente'} de UTC`;
+ }
+
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
@@ -55,6 +214,9 @@
});
mostrarMensagem('success', 'Configuração salva com sucesso!');
+
+ // Recarregar configuração para atualizar status
+ // A query será atualizada automaticamente pelo useQuery
} catch (error) {
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
@@ -69,10 +231,37 @@
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso) {
+ // Atualizar status de sincronização
+ statusSincronizacao = {
+ ultimaSincronizacao: Date.now(),
+ offsetSegundos: resultado.offsetSegundos,
+ usandoServidorExterno: resultado.usandoServidorExterno,
+ };
+
+ // Atualizar timestamps dos relógios
+ timestampUTC = resultado.timestamp;
+ timestampOriginal = resultado.timestamp;
+
+ // Calcular horário atual com GMT offset
+ // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
+ let timestampAjustado: number;
+ if (gmtOffset !== 0) {
+ timestampAjustado = timestampUTC + (gmtOffset * 60 * 60 * 1000);
+ } else {
+ // Quando GMT = 0, manter timestamp UTC puro
+ // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
+ timestampAjustado = timestampUTC;
+ }
+ const horarioAtual = new Date(timestampAjustado).toLocaleTimeString('pt-BR', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+
mostrarMensagem(
'success',
resultado.usandoServidorExterno
- ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s`
+ ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}`
: 'Usando relógio do PC (servidor externo não disponível)'
);
} else {
@@ -85,6 +274,24 @@
testando = false;
}
}
+
+ // Função para atualizar relógios manualmente
+ async function atualizarRelogios() {
+ await obterTempoSincronizado();
+ mostrarMensagem('success', 'Relógios atualizados!');
+ }
+
+ function formatarDataHora(timestamp: number | null): string {
+ if (!timestamp) return 'Nunca';
+ return new Date(timestamp).toLocaleString('pt-BR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ }
@@ -117,6 +324,134 @@
{/if}
+
+
+
+
+
Relógios Sincronizados
+
+
+
+ Visualização em tempo real dos horários sincronizados. O primeiro relógio mostra o horário original (UTC) da fonte de sincronismo escolhida, e o segundo mostra o horário ajustado conforme o GMT configurado.
+
+
+
+
+
+
+
+ Horário Original (UTC)
+
+
+ {formatarRelogio(timestampOriginal, 0)}
+
+
+ {formatarDataRelogio(timestampOriginal, 0)}
+
+
+
+
+
+
+
+
+ Fuso Horário:
+ UTC / GMT (Greenwich Mean Time)
+
+ Como funciona: O servidor NTP configurado é mapeado para uma API HTTP que retorna UTC.
+ O GMT offset configurado é então aplicado no frontend para exibir o horário correto.
+
+
+ Importante: Todos os servidores NTP retornam tempo em UTC. O GMT offset é aplicado
+ apenas uma vez no frontend para ajustar ao fuso horário local (ex: GMT-3 para Brasília).
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
index 86935d1..6bf3c46 100644
--- a/apps/web/src/routes/+layout.svelte
+++ b/apps/web/src/routes/+layout.svelte
@@ -3,6 +3,8 @@
import Sidebar from "$lib/components/Sidebar.svelte";
import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
import { authClient } from "$lib/auth";
+ // Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
+ import "$lib/utils/jitsiPolyfill";
const { children } = $props();
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index e43a543..c5e3bd5 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -7,4 +7,10 @@ export default defineConfig({
resolve: {
dedupe: ["lucide-svelte"],
},
+ optimizeDeps: {
+ exclude: ["lib-jitsi-meet"], // Excluir para permitir carregamento dinâmico no browser
+ },
+ ssr: {
+ noExternal: [], // lib-jitsi-meet não funciona no SSR, deve ser carregada apenas no browser
+ },
});
diff --git a/bun.lock b/bun.lock
index 9f97a1d..0f9a7b8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -78,9 +78,11 @@
"@convex-dev/better-auth": "^0.9.7",
"@convex-dev/rate-limiter": "^0.3.0",
"@dicebear/avataaars": "^9.2.4",
+ "@types/ssh2": "^1.15.5",
"better-auth": "catalog:",
"convex": "catalog:",
"nodemailer": "^7.0.10",
+ "ssh2": "^1.17.0",
},
"devDependencies": {
"@sgse-app/eslint-config": "*",
@@ -616,6 +618,8 @@
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
+ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
+
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/type-utils": "8.46.3", "@typescript-eslint/utils": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw=="],
@@ -664,6 +668,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
+
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -680,6 +686,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
+ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
+
"better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="],
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
@@ -692,6 +700,8 @@
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
+ "buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
+
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -730,6 +740,8 @@
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
+ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
@@ -1060,6 +1072,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
+
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
@@ -1190,6 +1204,8 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1218,6 +1234,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
+
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -1284,6 +1302,8 @@
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="],
+ "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
+
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -1370,6 +1390,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1398,6 +1420,8 @@
"@convex-dev/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
+ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index f79ac9a..0914ef0 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -9,6 +9,7 @@
*/
import type * as actions_email from "../actions/email.js";
+import type * as actions_jitsiServer from "../actions/jitsiServer.js";
import type * as actions_linkPreview from "../actions/linkPreview.js";
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
import type * as actions_smtp from "../actions/smtp.js";
@@ -22,6 +23,7 @@ import type * as chamadas from "../chamadas.js";
import type * as chamados from "../chamados.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
+import type * as configuracaoJitsi from "../configuracaoJitsi.js";
import type * as configuracaoPonto from "../configuracaoPonto.js";
import type * as configuracaoRelogio from "../configuracaoRelogio.js";
import type * as contratos from "../contratos.js";
@@ -65,6 +67,7 @@ import type {
declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email;
+ "actions/jitsiServer": typeof actions_jitsiServer;
"actions/linkPreview": typeof actions_linkPreview;
"actions/pushNotifications": typeof actions_pushNotifications;
"actions/smtp": typeof actions_smtp;
@@ -78,6 +81,7 @@ declare const fullApi: ApiFromModules<{
chamados: typeof chamados;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
+ configuracaoJitsi: typeof configuracaoJitsi;
configuracaoPonto: typeof configuracaoPonto;
configuracaoRelogio: typeof configuracaoRelogio;
contratos: typeof contratos;
diff --git a/packages/backend/convex/actions/jitsiServer.ts b/packages/backend/convex/actions/jitsiServer.ts
new file mode 100644
index 0000000..3159eb8
--- /dev/null
+++ b/packages/backend/convex/actions/jitsiServer.ts
@@ -0,0 +1,424 @@
+"use node";
+
+import { action } from "../_generated/server";
+import { v } from "convex/values";
+import { api, internal } from "../_generated/api";
+import { Client } from "ssh2";
+import { readFileSync } from "fs";
+import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
+
+/**
+ * Interface para configuração SSH
+ */
+interface SSHConfig {
+ host: string;
+ port: number;
+ username: string;
+ password?: string;
+ keyPath?: string;
+}
+
+/**
+ * Executar comando via SSH
+ */
+async function executarComandoSSH(
+ config: SSHConfig,
+ comando: string
+): Promise<{ sucesso: boolean; output: string; erro?: string }> {
+ return new Promise((resolve) => {
+ const conn = new Client();
+ let output = "";
+ let errorOutput = "";
+
+ conn.on("ready", () => {
+ conn.exec(comando, (err, stream) => {
+ if (err) {
+ conn.end();
+ resolve({ sucesso: false, output: "", erro: err.message });
+ return;
+ }
+
+ stream
+ .on("close", (code: number | null, signal: string | null) => {
+ conn.end();
+ if (code === 0) {
+ resolve({ sucesso: true, output: output.trim() });
+ } else {
+ resolve({
+ sucesso: false,
+ output: output.trim(),
+ erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`,
+ });
+ }
+ })
+ .on("data", (data: Buffer) => {
+ output += data.toString();
+ })
+ .stderr.on("data", (data: Buffer) => {
+ errorOutput += data.toString();
+ });
+ });
+ }).on("error", (err) => {
+ resolve({ sucesso: false, output: "", erro: err.message });
+ }).connect({
+ host: config.host,
+ port: config.port,
+ username: config.username,
+ password: config.password,
+ privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined,
+ readyTimeout: 10000,
+ });
+ });
+}
+
+/**
+ * Ler arquivo via SSH
+ */
+async function lerArquivoSSH(
+ config: SSHConfig,
+ caminho: string
+): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> {
+ const comando = `cat "${caminho}" 2>&1`;
+ const resultado = await executarComandoSSH(config, comando);
+
+ if (!resultado.sucesso) {
+ return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" };
+ }
+
+ return { sucesso: true, conteudo: resultado.output };
+}
+
+/**
+ * Escrever arquivo via SSH
+ */
+async function escreverArquivoSSH(
+ config: SSHConfig,
+ caminho: string,
+ conteudo: string
+): Promise<{ sucesso: boolean; erro?: string }> {
+ // Escapar conteúdo para shell
+ const conteudoEscapado = conteudo
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/\$/g, "\\$")
+ .replace(/`/g, "\\`");
+
+ const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF'
+${conteudo}
+JITSI_CONFIG_EOF`;
+
+ const resultado = await executarComandoSSH(config, comando);
+
+ if (!resultado.sucesso) {
+ return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" };
+ }
+
+ return { sucesso: true };
+}
+
+/**
+ * Aplicar configurações do Jitsi no servidor Docker via SSH
+ */
+export const aplicarConfiguracaoServidor = action({
+ args: {
+ configId: v.id("configuracaoJitsi"),
+ sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave)
+ },
+ returns: v.union(
+ v.object({
+ sucesso: v.literal(true),
+ mensagem: v.string(),
+ detalhes: v.optional(v.string()),
+ }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args): Promise<
+ | { sucesso: true; mensagem: string; detalhes?: string }
+ | { sucesso: false; erro: string }
+ > => {
+ try {
+ // Buscar configuração
+ const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
+
+ if (!config || config._id !== args.configId) {
+ return { sucesso: false as const, erro: "Configuração não encontrada" };
+ }
+
+ // Verificar se tem configurações SSH
+ const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, {
+ configId: args.configId,
+ });
+
+ if (!configFull || !configFull.sshHost) {
+ return {
+ sucesso: false as const,
+ erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.",
+ };
+ }
+
+ // Configurar SSH
+ let sshPasswordDecrypted: string | undefined = undefined;
+
+ // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
+ if (args.sshPassword) {
+ sshPasswordDecrypted = args.sshPassword;
+ } else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") {
+ // Tentar descriptografar senha armazenada
+ try {
+ sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash);
+ } catch (error) {
+ return {
+ sucesso: false as const,
+ erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.",
+ };
+ }
+ }
+
+ const sshConfig: SSHConfig = {
+ host: configFull.sshHost,
+ port: configFull.sshPort || 22,
+ username: configFull.sshUsername || "",
+ password: sshPasswordDecrypted,
+ keyPath: configFull.sshKeyPath || undefined,
+ };
+
+ if (!sshConfig.username) {
+ return { sucesso: false as const, erro: "Usuário SSH não configurado" };
+ }
+
+ if (!sshConfig.password && !sshConfig.keyPath) {
+ return {
+ sucesso: false as const,
+ erro: "Senha SSH ou caminho da chave deve ser fornecido",
+ };
+ }
+
+ const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg";
+ const dockerComposePath = configFull.dockerComposePath || ".";
+
+ // Extrair host e porta do domain
+ const [host, portStr] = configFull.domain.split(":");
+ const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80;
+ const protocol = configFull.useHttps ? "https" : "http";
+
+ const detalhes: string[] = [];
+
+ // 1. Atualizar arquivo .env do docker-compose
+ if (dockerComposePath) {
+ const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE
+CONFIG=${basePath}
+TZ=America/Recife
+ENABLE_LETSENCRYPT=0
+HTTP_PORT=${protocol === "https" ? 8000 : port}
+HTTPS_PORT=${configFull.useHttps ? port : 8443}
+PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""}
+DOMAIN=${host}
+ENABLE_AUTH=0
+ENABLE_GUESTS=1
+ENABLE_TRANSCRIPTION=0
+ENABLE_RECORDING=0
+ENABLE_PREJOIN_PAGE=0
+START_AUDIO_MUTED=0
+START_VIDEO_MUTED=0
+ENABLE_XMPP_WEBSOCKET=0
+ENABLE_P2P=1
+MAX_NUMBER_OF_PARTICIPANTS=10
+RESOLUTION_WIDTH=1280
+RESOLUTION_HEIGHT=720
+JWT_APP_ID=${configFull.appId}
+JWT_APP_SECRET=
+`;
+
+ const envPath = `${dockerComposePath}/.env`;
+ const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent);
+
+ if (!resultadoEnv.sucesso) {
+ return {
+ sucesso: false as const,
+ erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`,
+ };
+ }
+
+ detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`);
+ }
+
+ // 2. Atualizar configuração do Prosody
+ const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`;
+ const prosodyContent = `-- Configuração Prosody para ${host}
+-- Gerada automaticamente pelo SGSE
+
+VirtualHost "${host}"
+ authentication = "anonymous"
+ modules_enabled = {
+ "bosh";
+ "ping";
+ "speakerstats";
+ "turncredentials";
+ "presence";
+ "conference_duration";
+ }
+ c2s_require_encryption = false
+ allow_anonymous_s2s = false
+
+Component "conference.${host}" "muc"
+ storage = "memory"
+ muc_room_locking = false
+ muc_room_default_public_jids = true
+
+Component "jitsi-videobridge.${host}"
+ component_secret = ""
+
+Component "focus.${host}"
+ component_secret = ""
+`;
+
+ const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent);
+
+ if (!resultadoProsody.sucesso) {
+ return {
+ sucesso: false as const,
+ erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`,
+ };
+ }
+
+ detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`);
+
+ // 3. Atualizar configuração do Jicofo
+ const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`;
+ const jicofoContent = `# Configuração Jicofo
+# Gerada automaticamente pelo SGSE
+org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host}
+org.jitsi.jicofo.jid=XMPP_USER@${host}
+org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host}
+org.jitsi.jicofo.app.ID=${configFull.appId}
+`;
+
+ const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent);
+
+ if (!resultadoJicofo.sucesso) {
+ return {
+ sucesso: false as const,
+ erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`,
+ };
+ }
+
+ detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`);
+
+ // 4. Atualizar configuração do JVB
+ const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`;
+ const jvbContent = `# Configuração JVB (Jitsi Video Bridge)
+# Gerada automaticamente pelo SGSE
+org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.*
+org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host}
+org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host}
+org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb
+org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host}
+`;
+
+ const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent);
+
+ if (!resultadoJvb.sucesso) {
+ return {
+ sucesso: false as const,
+ erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`,
+ };
+ }
+
+ detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`);
+
+ // 5. Reiniciar containers Docker
+ if (dockerComposePath) {
+ const resultadoRestart = await executarComandoSSH(
+ sshConfig,
+ `cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1`
+ );
+
+ if (!resultadoRestart.sucesso) {
+ return {
+ sucesso: false as const,
+ erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`,
+ };
+ }
+
+ detalhes.push(`✓ Containers Docker reiniciados`);
+ }
+
+ // Atualizar timestamp de configuração no servidor
+ await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, {
+ configId: args.configId,
+ });
+
+ return {
+ sucesso: true as const,
+ mensagem: "Configurações aplicadas com sucesso no servidor Jitsi",
+ detalhes: detalhes.join("\n"),
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ sucesso: false as const,
+ erro: `Erro ao aplicar configurações: ${errorMessage}`,
+ };
+ }
+ },
+});
+
+/**
+ * Testar conexão SSH
+ */
+export const testarConexaoSSH = action({
+ args: {
+ sshHost: v.string(),
+ sshPort: v.optional(v.number()),
+ sshUsername: v.string(),
+ sshPassword: v.optional(v.string()),
+ sshKeyPath: v.optional(v.string()),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true), mensagem: v.string() }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args): Promise<
+ | { sucesso: true; mensagem: string }
+ | { sucesso: false; erro: string }
+ > => {
+ try {
+ if (!args.sshPassword && !args.sshKeyPath) {
+ return {
+ sucesso: false as const,
+ erro: "Senha SSH ou caminho da chave deve ser fornecido",
+ };
+ }
+
+ const sshConfig: SSHConfig = {
+ host: args.sshHost,
+ port: args.sshPort || 22,
+ username: args.sshUsername,
+ password: args.sshPassword || undefined,
+ keyPath: args.sshKeyPath || undefined,
+ };
+
+ // Tentar executar um comando simples
+ const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'");
+
+ if (resultado.sucesso && resultado.output.includes("SSH_OK")) {
+ return {
+ sucesso: true as const,
+ mensagem: "Conexão SSH estabelecida com sucesso",
+ };
+ }
+
+ return {
+ sucesso: false as const,
+ erro: resultado.erro || "Falha ao estabelecer conexão SSH",
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ sucesso: false as const,
+ erro: `Erro ao testar SSH: ${errorMessage}`,
+ };
+ }
+ },
+});
+
diff --git a/packages/backend/convex/chamadas.ts b/packages/backend/convex/chamadas.ts
index 7c5c725..17d3afc 100644
--- a/packages/backend/convex/chamadas.ts
+++ b/packages/backend/convex/chamadas.ts
@@ -19,11 +19,25 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
/**
* Gerar nome único para a sala Jitsi
+ * Usa configuração do backend se disponível, senão usa padrão 'sgse'
*/
-function gerarRoomName(conversaId: Id<'conversas'>, tipo: 'audio' | 'video'): string {
+async function gerarRoomName(
+ ctx: QueryCtx | MutationCtx,
+ conversaId: Id<'conversas'>,
+ tipo: 'audio' | 'video'
+): Promise {
+ // Buscar configuração Jitsi ativa
+ const configJitsi = await ctx.db
+ .query('configuracaoJitsi')
+ .withIndex('by_ativo', (q) => q.eq('ativo', true))
+ .first();
+
+ const roomPrefix = configJitsi?.roomPrefix || 'sgse';
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
- return `sgse-${tipo}-${conversaId.replace('conversas|', '')}-${timestamp}-${random}`;
+ const conversaHash = conversaId.replace('conversas|', '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
+
+ return `${roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
}
/**
@@ -96,7 +110,7 @@ export const criarChamada = mutation({
if (!conversa) throw new Error('Conversa não encontrada');
// Gerar nome único da sala
- const roomName = gerarRoomName(args.conversaId, args.tipo);
+ const roomName = await gerarRoomName(ctx, args.conversaId, args.tipo);
// Criar chamada
const chamadaId = await ctx.db.insert('chamadas', {
diff --git a/packages/backend/convex/configuracaoJitsi.ts b/packages/backend/convex/configuracaoJitsi.ts
new file mode 100644
index 0000000..c04ede5
--- /dev/null
+++ b/packages/backend/convex/configuracaoJitsi.ts
@@ -0,0 +1,372 @@
+import { v } from "convex/values";
+import { mutation, query, action, internalMutation } from "./_generated/server";
+import { registrarAtividade } from "./logsAtividades";
+import { api, internal } from "./_generated/api";
+import { encryptSMTPPassword } from "./auth/utils";
+
+/**
+ * Obter configuração de Jitsi ativa
+ */
+export const obterConfigJitsi = query({
+ args: {},
+ handler: async (ctx) => {
+ const config = await ctx.db
+ .query("configuracaoJitsi")
+ .withIndex("by_ativo", (q) => q.eq("ativo", true))
+ .first();
+
+ if (!config) {
+ return null;
+ }
+
+ return {
+ _id: config._id,
+ domain: config.domain,
+ appId: config.appId,
+ roomPrefix: config.roomPrefix,
+ useHttps: config.useHttps,
+ acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir
+ ativo: config.ativo,
+ testadoEm: config.testadoEm,
+ atualizadoEm: config.atualizadoEm,
+ };
+ },
+});
+
+/**
+ * Obter configuração completa de Jitsi (incluindo SSH, mas sem senha)
+ */
+export const obterConfigJitsiCompleta = query({
+ args: {
+ configId: v.id("configuracaoJitsi"),
+ },
+ handler: async (ctx, args) => {
+ const config = await ctx.db.get(args.configId);
+
+ if (!config) {
+ return null;
+ }
+
+ return {
+ _id: config._id,
+ domain: config.domain,
+ appId: config.appId,
+ roomPrefix: config.roomPrefix,
+ useHttps: config.useHttps,
+ acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
+ ativo: config.ativo,
+ testadoEm: config.testadoEm,
+ atualizadoEm: config.atualizadoEm,
+ configuradoEm: config.configuradoEm,
+ // Configurações SSH (sem senha)
+ sshHost: config.sshHost,
+ sshPort: config.sshPort,
+ sshUsername: config.sshUsername,
+ sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar
+ sshKeyPath: config.sshKeyPath,
+ dockerComposePath: config.dockerComposePath,
+ jitsiConfigPath: config.jitsiConfigPath,
+ configuradoNoServidor: config.configuradoNoServidor ?? false,
+ configuradoNoServidorEm: config.configuradoNoServidorEm,
+ };
+ },
+});
+
+/**
+ * Salvar configuração de Jitsi (apenas TI_MASTER)
+ */
+export const salvarConfigJitsi = mutation({
+ args: {
+ domain: v.string(),
+ appId: v.string(),
+ roomPrefix: v.string(),
+ useHttps: v.boolean(),
+ acceptSelfSignedCert: v.boolean(),
+ configuradoPorId: v.id("usuarios"),
+ // Opcionais: configurações SSH/Docker
+ sshHost: v.optional(v.string()),
+ sshPort: v.optional(v.number()),
+ sshUsername: v.optional(v.string()),
+ sshPassword: v.optional(v.string()), // Senha nova (será criptografada)
+ sshKeyPath: v.optional(v.string()),
+ dockerComposePath: v.optional(v.string()),
+ jitsiConfigPath: v.optional(v.string()),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ // Validar domínio (deve ser não vazio)
+ if (!args.domain || args.domain.trim().length === 0) {
+ return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
+ }
+
+ // Validar appId (deve ser não vazio)
+ if (!args.appId || args.appId.trim().length === 0) {
+ return { sucesso: false as const, erro: "App ID não pode estar vazio" };
+ }
+
+ // Validar roomPrefix (deve ser não vazio e alfanumérico)
+ if (!args.roomPrefix || args.roomPrefix.trim().length === 0) {
+ return { sucesso: false as const, erro: "Prefixo de sala não pode estar vazio" };
+ }
+
+ // Validar formato do roomPrefix (apenas letras, números e hífens)
+ const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
+ if (!roomPrefixRegex.test(args.roomPrefix.trim())) {
+ return {
+ sucesso: false as const,
+ erro: "Prefixo de sala deve conter apenas letras, números e hífens",
+ };
+ }
+
+ // Buscar config ativa anterior para manter senha SSH se não fornecida
+ const configAtiva = await ctx.db
+ .query("configuracaoJitsi")
+ .withIndex("by_ativo", (q) => q.eq("ativo", true))
+ .first();
+
+ // Desativar config anterior
+ const configsAntigas = await ctx.db
+ .query("configuracaoJitsi")
+ .withIndex("by_ativo", (q) => q.eq("ativo", true))
+ .collect();
+
+ for (const config of configsAntigas) {
+ await ctx.db.patch(config._id, { ativo: false });
+ }
+
+ // Determinar senha SSH: usar nova senha se fornecida, senão manter a atual
+ let sshPasswordHash: string | undefined = undefined;
+ if (args.sshPassword && args.sshPassword.trim().length > 0) {
+ // Nova senha fornecida, criptografar
+ sshPasswordHash = await encryptSMTPPassword(args.sshPassword);
+ } else if (configAtiva && configAtiva.sshPasswordHash) {
+ // Senha não fornecida, manter a atual (já criptografada)
+ sshPasswordHash = configAtiva.sshPasswordHash;
+ }
+
+ // Criar nova config
+ const configId = await ctx.db.insert("configuracaoJitsi", {
+ domain: args.domain.trim(),
+ appId: args.appId.trim(),
+ roomPrefix: args.roomPrefix.trim(),
+ useHttps: args.useHttps,
+ acceptSelfSignedCert: args.acceptSelfSignedCert,
+ ativo: true,
+ configuradoPor: args.configuradoPorId,
+ atualizadoEm: Date.now(),
+ // Configurações SSH/Docker
+ sshHost: args.sshHost?.trim() || undefined,
+ sshPort: args.sshPort || undefined,
+ sshUsername: args.sshUsername?.trim() || undefined,
+ sshPasswordHash: sshPasswordHash,
+ sshKeyPath: args.sshKeyPath?.trim() || undefined,
+ dockerComposePath: args.dockerComposePath?.trim() || undefined,
+ jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined,
+ });
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.configuradoPorId,
+ "configurar",
+ "jitsi",
+ JSON.stringify({ domain: args.domain, appId: args.appId }),
+ configId
+ );
+
+ return { sucesso: true as const, configId };
+ },
+});
+
+/**
+ * Mutation interna para atualizar testadoEm
+ */
+export const atualizarTestadoEm = internalMutation({
+ args: {
+ configId: v.id("configuracaoJitsi"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.configId, {
+ testadoEm: Date.now(),
+ });
+ return null;
+ },
+});
+
+/**
+ * Testar conexão com servidor Jitsi
+ */
+export const testarConexaoJitsi = action({
+ args: {
+ domain: v.string(),
+ useHttps: v.boolean(),
+ acceptSelfSignedCert: v.optional(v.boolean()),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => {
+ // Validações básicas
+ if (!args.domain || args.domain.trim().length === 0) {
+ return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
+ }
+
+ try {
+ const protocol = args.useHttps ? "https" : "http";
+ // Extrair host e porta do domain
+ const [host, portStr] = args.domain.split(":");
+ const port = portStr ? parseInt(portStr, 10) : args.useHttps ? 443 : 80;
+ const url = `${protocol}://${host}:${port}/http-bind`;
+
+ // Tentar fazer uma requisição HTTP para verificar se o servidor está acessível
+ // Nota: No ambiente Node.js do Convex, podemos usar fetch
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 segundos de timeout
+
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ signal: controller.signal,
+ headers: {
+ "Content-Type": "application/xml",
+ },
+ });
+
+ clearTimeout(timeoutId);
+
+ // Qualquer resposta indica que o servidor está acessível
+ // Não precisamos verificar o status code exato, apenas se há resposta
+ if (response.status >= 200 && response.status < 600) {
+ // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
+ const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
+
+ if (configAtiva) {
+ await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
+ configId: configAtiva._id,
+ });
+ }
+
+ return { sucesso: true as const, aviso: undefined };
+ } else {
+ return {
+ sucesso: false as const,
+ erro: `Servidor retornou status ${response.status}`,
+ };
+ }
+ } catch (fetchError: unknown) {
+ clearTimeout(timeoutId);
+ const errorMessage =
+ fetchError instanceof Error ? fetchError.message : String(fetchError);
+
+ // Se for erro de timeout
+ if (errorMessage.includes("aborted") || errorMessage.includes("timeout")) {
+ return {
+ sucesso: false as const,
+ erro: "Timeout: Servidor não respondeu em 5 segundos",
+ };
+ }
+
+ // Verificar se é erro de certificado SSL autoassinado
+ const isSSLError =
+ errorMessage.includes("CERTIFICATE_VERIFY_FAILED") ||
+ errorMessage.includes("self signed certificate") ||
+ errorMessage.includes("self-signed certificate") ||
+ errorMessage.includes("certificate") ||
+ errorMessage.includes("SSL") ||
+ errorMessage.includes("certificate verify failed");
+
+ // Se for erro de certificado e aceitar autoassinado está configurado
+ if (isSSLError && args.acceptSelfSignedCert) {
+ // Aceitar como sucesso se configurado para aceitar certificados autoassinados
+ // (o servidor está acessível, apenas o certificado não é confiável)
+ // Nota: No cliente (navegador), o usuário ainda precisará aceitar o certificado manualmente
+ const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
+
+ if (configAtiva) {
+ await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
+ configId: configAtiva._id,
+ });
+ }
+
+ return {
+ sucesso: true as const,
+ aviso: "Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão."
+ };
+ }
+
+ // Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed)
+ // para GET em /http-bind, pois esse endpoint espera POST (BOSH)
+ // Isso indica que o servidor está acessível, apenas não aceita GET
+ if (errorMessage.includes("405") || errorMessage.includes("Method Not Allowed")) {
+ // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
+ const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
+
+ if (configAtiva) {
+ await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
+ configId: configAtiva._id,
+ });
+ }
+
+ return { sucesso: true as const, aviso: undefined };
+ }
+
+ // Se for erro de certificado SSL e não está configurado para aceitar
+ if (isSSLError) {
+ return {
+ sucesso: false as const,
+ erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).`,
+ };
+ }
+
+ return {
+ sucesso: false as const,
+ erro: `Erro ao conectar: ${errorMessage}`,
+ };
+ }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ sucesso: false as const,
+ erro: errorMessage || "Erro ao conectar com o servidor Jitsi",
+ };
+ }
+ },
+});
+
+/**
+ * Marcar que a configuração foi testada com sucesso
+ */
+export const marcarConfigTestada = mutation({
+ args: {
+ configId: v.id("configuracaoJitsi"),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.configId, {
+ testadoEm: Date.now(),
+ });
+ },
+});
+
+/**
+ * Mutation interna para marcar que a configuração foi aplicada no servidor
+ */
+export const marcarConfiguradoNoServidor = internalMutation({
+ args: {
+ configId: v.id("configuracaoJitsi"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.configId, {
+ configuradoNoServidor: true,
+ configuradoNoServidorEm: Date.now(),
+ configuradoEm: Date.now(),
+ });
+ return null;
+ },
+});
+
diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts
index b932127..41c9640 100644
--- a/packages/backend/convex/configuracaoRelogio.ts
+++ b/packages/backend/convex/configuracaoRelogio.ts
@@ -10,13 +10,18 @@ import { api, internal } from './_generated/api';
export const obterConfiguracao = query({
args: {},
handler: async (ctx) => {
- const config = await ctx.db
+ // Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
+ const configs = await ctx.db
.query('configuracaoRelogio')
- .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true))
- .first();
+ .collect();
+
+ // Pegar a configuração mais recente (ordenar por atualizadoEm desc)
+ const config = configs.length > 0
+ ? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
+ : null;
if (!config) {
- // Retornar configuração padrão
+ // Retornar configuração padrão (GMT-3 para Brasília)
return {
servidorNTP: 'pool.ntp.org',
portaNTP: 123,
@@ -24,13 +29,13 @@ export const obterConfiguracao = query({
fallbackParaPC: true,
ultimaSincronizacao: null,
offsetSegundos: null,
- gmtOffset: 0,
+ gmtOffset: -3, // GMT-3 para Brasília
};
}
return {
...config,
- gmtOffset: config.gmtOffset ?? 0,
+ gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
};
},
});
@@ -64,11 +69,14 @@ export const salvarConfiguracao = mutation({
}
}
- // Buscar configuração existente
- const configExistente = await ctx.db
+ // Buscar configuração existente (pegar a mais recente)
+ const configs = await ctx.db
.query('configuracaoRelogio')
- .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno))
- .first();
+ .collect();
+
+ const configExistente = configs.length > 0
+ ? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
+ : null;
if (configExistente) {
// Atualizar configuração existente
@@ -77,7 +85,7 @@ export const salvarConfiguracao = mutation({
portaNTP: args.portaNTP,
usarServidorExterno: args.usarServidorExterno,
fallbackParaPC: args.fallbackParaPC,
- gmtOffset: args.gmtOffset ?? 0,
+ gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
});
@@ -89,7 +97,7 @@ export const salvarConfiguracao = mutation({
portaNTP: args.portaNTP,
usarServidorExterno: args.usarServidorExterno,
fallbackParaPC: args.fallbackParaPC,
- gmtOffset: args.gmtOffset ?? 0,
+ gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
});
@@ -131,16 +139,75 @@ export const sincronizarTempo = action({
}
// Tentar obter tempo de um servidor NTP público via HTTP
- // Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica
+ // Nota: NTP real requer protocolo UDP na porta 123, aqui usamos APIs HTTP que retornam UTC
+ // O GMT offset será aplicado no frontend
try {
- // Usar API pública de tempo como fallback
- const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife');
- if (!response.ok) {
- throw new Error('Falha ao obter tempo do servidor');
+ const servidorNTP = config.servidorNTP || 'pool.ntp.org';
+ let serverTime: number;
+
+ // Mapear servidores NTP conhecidos para APIs HTTP que retornam UTC
+ // Todos os servidores NTP retornam UTC, então usamos APIs que retornam UTC
+ if (servidorNTP.includes('pool.ntp.org') || servidorNTP.includes('ntp.org') || servidorNTP.includes('ntp.br')) {
+ // pool.ntp.org e servidores .org/.br - usar API que retorna UTC
+ const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo do servidor');
+ }
+ const data = (await response.json()) as { unixtime: number; datetime: string };
+ // unixtime está em segundos, converter para milissegundos
+ serverTime = data.unixtime * 1000;
+ } else if (servidorNTP.includes('time.google.com') || servidorNTP.includes('google')) {
+ // Google NTP - usar API que retorna UTC
+ try {
+ const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo');
+ }
+ const data = (await response.json()) as { unixtime: number };
+ serverTime = data.unixtime * 1000;
+ } catch {
+ // Fallback para outra API UTC
+ const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo do servidor');
+ }
+ const data = (await response.json()) as { unixTime: number };
+ serverTime = data.unixTime * 1000;
+ }
+ } else if (servidorNTP.includes('time.windows.com') || servidorNTP.includes('windows')) {
+ // Windows NTP - usar API que retorna UTC
+ const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo do servidor');
+ }
+ const data = (await response.json()) as { unixtime: number };
+ serverTime = data.unixtime * 1000;
+ } else {
+ // Para outros servidores NTP, usar API genérica que retorna UTC
+ // Tentar worldtimeapi primeiro
+ try {
+ const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo');
+ }
+ const data = (await response.json()) as { unixtime: number };
+ serverTime = data.unixtime * 1000;
+ } catch {
+ // Fallback para timeapi.io
+ try {
+ const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
+ if (!response.ok) {
+ throw new Error('Falha ao obter tempo');
+ }
+ const data = (await response.json()) as { unixTime: number };
+ serverTime = data.unixTime * 1000;
+ } catch {
+ // Último fallback: usar tempo do servidor Convex (já está em UTC)
+ serverTime = Date.now();
+ }
+ }
}
- const data = (await response.json()) as { datetime: string };
- const serverTime = new Date(data.datetime).getTime();
const localTime = Date.now();
const offsetSegundos = Math.floor((serverTime - localTime) / 1000);
@@ -160,23 +227,26 @@ export const sincronizarTempo = action({
return {
sucesso: true,
- timestamp: serverTime,
+ timestamp: serverTime, // Retorna UTC (sem GMT offset aplicado)
usandoServidorExterno: true,
offsetSegundos,
};
- } catch {
- // Se falhar e fallbackParaPC estiver ativo, usar tempo local
- if (config.fallbackParaPC) {
- return {
- sucesso: true,
- timestamp: Date.now(),
- usandoServidorExterno: false,
- offsetSegundos: 0,
- aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC',
- };
- }
-
- throw new Error('Falha ao sincronizar tempo e fallback desabilitado');
+ } catch (error) {
+ // Sempre usar fallback como última opção, mesmo se desabilitado
+ // Isso evita que o sistema trave completamente se o servidor externo não estiver disponível
+ const aviso = config.fallbackParaPC
+ ? 'Falha ao sincronizar com servidor externo, usando relógio do PC'
+ : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.';
+
+ console.warn('Erro ao sincronizar tempo com servidor externo:', error);
+
+ return {
+ sucesso: true,
+ timestamp: Date.now(),
+ usandoServidorExterno: false,
+ offsetSegundos: 0,
+ aviso,
+ };
}
},
});
diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts
index 5d1060a..36299b6 100644
--- a/packages/backend/convex/pontos.ts
+++ b/packages/backend/convex/pontos.ts
@@ -369,38 +369,23 @@ export const registrarPonto = mutation({
throw new Error('Configuração de ponto não encontrada');
}
- // Obter configuração de ponto para GMT offset (buscar configuração ativa)
- const configPonto = await ctx.db
- .query('configuracaoPonto')
- .withIndex('by_ativo', (q) => q.eq('ativo', true))
- .first();
-
- // Converter timestamp para data/hora com ajuste de GMT
- // O timestamp está em UTC, precisamos aplicar o GMT offset
- const gmtOffset = configPonto?.gmtOffset ?? 0;
+ // Converter timestamp para data/hora
+ // O timestamp pode vir ajustado com GMT offset do frontend (se GMT !== 0)
+ // ou em UTC puro (se GMT === 0). Usamos UTC methods para extrair os valores
+ // diretamente do timestamp recebido, seja ele ajustado ou não
+ const dataObj = new Date(args.timestamp);
+ // Usar UTC methods porque:
+ // - Se GMT === 0: timestamp está em UTC puro, métodos UTC extraem corretamente
+ // - Se GMT !== 0: timestamp já vem ajustado do frontend, métodos UTC extraem o horário ajustado
+ const hora = dataObj.getUTCHours();
+ const minuto = dataObj.getUTCMinutes();
+ const segundo = dataObj.getUTCSeconds();
- // Calcular horário ajustado manualmente a partir de UTC
- const dataUTC = new Date(args.timestamp);
- let hora = dataUTC.getUTCHours() + gmtOffset;
- const minuto = dataUTC.getUTCMinutes();
- const segundo = dataUTC.getUTCSeconds();
-
- // Ajustar hora se ultrapassar os limites do dia
- let diasOffset = 0;
- if (hora >= 24) {
- hora = hora - 24;
- diasOffset = 1;
- } else if (hora < 0) {
- hora = hora + 24;
- diasOffset = -1;
- }
-
- // Calcular data ajustada
- const dataAjustada = new Date(args.timestamp);
- if (diasOffset !== 0) {
- dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
- }
- const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
+ // Obter data no formato YYYY-MM-DD usando UTC
+ const ano = dataObj.getUTCFullYear();
+ const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0');
+ const dia = String(dataObj.getUTCDate()).padStart(2, '0');
+ const data = `${ano}-${mes}-${dia}`;
// Verificar se já existe registro no mesmo minuto
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
@@ -544,7 +529,7 @@ export const registrarPonto = mutation({
} | null = null;
if (
- configPonto?.validarLocalizacao !== false &&
+ config.validarLocalizacao !== false &&
args.informacoesDispositivo?.latitude &&
args.informacoesDispositivo?.longitude
) {
@@ -553,7 +538,7 @@ export const registrarPonto = mutation({
usuario.funcionarioId,
args.informacoesDispositivo.latitude,
args.informacoesDispositivo.longitude,
- configPonto?.toleranciaDistanciaMetros ?? 100
+ config.toleranciaDistanciaMetros ?? 100
);
validacaoGeofencing = geofencing;
@@ -822,6 +807,7 @@ export const obterEstatisticas = query({
args: {
dataInicio: v.string(), // YYYY-MM-DD
dataFim: v.string(), // YYYY-MM-DD
+ funcionarioId: v.optional(v.id('funcionarios')),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -831,11 +817,16 @@ export const obterEstatisticas = query({
// TODO: Verificar permissão (RH ou TI)
- const registros = await ctx.db
+ let registros = await ctx.db
.query('registrosPonto')
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
.collect();
+ // Filtrar por funcionário se fornecido
+ if (args.funcionarioId) {
+ registros = registros.filter((r) => r.funcionarioId === args.funcionarioId);
+ }
+
const totalRegistros = registros.length;
const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length;
const foraDoPrazo = totalRegistros - dentroDoPrazo;
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index fa3e753..9e02da5 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -708,6 +708,30 @@ export default defineSchema({
atualizadoEm: v.number(),
}).index("by_ativo", ["ativo"]),
+ // Configuração de Jitsi Meet
+ configuracaoJitsi: defineTable({
+ domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com")
+ appId: v.string(), // ID da aplicação Jitsi
+ roomPrefix: v.string(), // Prefixo para nomes de salas
+ useHttps: v.boolean(), // Usar HTTPS
+ acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
+ // Configurações SSH/Docker para configuração automática do servidor
+ sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local")
+ sshPort: v.optional(v.number()), // Porta SSH (padrão: 22)
+ sshUsername: v.optional(v.string()), // Usuário SSH
+ sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
+ sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha)
+ dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker")
+ jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg")
+ ativo: v.boolean(), // Configuração ativa
+ testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
+ configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
+ configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
+ configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
+ configuradoPor: v.id("usuarios"), // Usuário que configurou
+ atualizadoEm: v.number(), // Timestamp de atualização
+ }).index("by_ativo", ["ativo"]),
+
// Fila de Emails
notificacoesEmail: defineTable({
destinatario: v.string(), // email
diff --git a/packages/backend/package.json b/packages/backend/package.json
index fcb7a6e..8b2c439 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -25,8 +25,10 @@
"@convex-dev/better-auth": "^0.9.7",
"@convex-dev/rate-limiter": "^0.3.0",
"@dicebear/avataaars": "^9.2.4",
+ "@types/ssh2": "^1.15.5",
"better-auth": "catalog:",
"convex": "catalog:",
- "nodemailer": "^7.0.10"
+ "nodemailer": "^7.0.10",
+ "ssh2": "^1.17.0"
}
}
\ No newline at end of file