feat: enhance call and point registration features with sensor data integration
- Updated the CallWindow component to include connection quality states and reconnection attempts, improving user experience during calls. - Enhanced the ChatWindow to allow starting audio and video calls in a new window, providing users with more flexibility. - Integrated accelerometer and gyroscope data collection in the RegistroPonto component, enabling validation of point registration authenticity. - Improved error handling and user feedback for sensor permissions and data validation, ensuring a smoother registration process. - Updated backend logic to validate sensor data and adjust confidence scores for point registration, enhancing security against spoofing.
This commit is contained in:
@@ -242,28 +242,36 @@ JWT_APP_SECRET=
|
||||
detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`);
|
||||
}
|
||||
|
||||
// 2. Atualizar configuração do Prosody
|
||||
// 2. Atualizar configuração do Prosody (conforme documentação oficial)
|
||||
const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`;
|
||||
const prosodyContent = `-- Configuração Prosody para ${host}
|
||||
-- Gerada automaticamente pelo SGSE
|
||||
-- Baseado na documentação oficial do Jitsi Meet
|
||||
|
||||
VirtualHost "${host}"
|
||||
authentication = "anonymous"
|
||||
modules_enabled = {
|
||||
"bosh";
|
||||
"websocket";
|
||||
"ping";
|
||||
"speakerstats";
|
||||
"turncredentials";
|
||||
"presence";
|
||||
"conference_duration";
|
||||
"stats";
|
||||
}
|
||||
c2s_require_encryption = false
|
||||
allow_anonymous_s2s = false
|
||||
bosh_max_inactivity = 60
|
||||
bosh_max_polling = 5
|
||||
bosh_max_stanzas = 5
|
||||
|
||||
Component "conference.${host}" "muc"
|
||||
storage = "memory"
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
muc_room_cache_size = 1000
|
||||
muc_log_presences = true
|
||||
|
||||
Component "jitsi-videobridge.${host}"
|
||||
component_secret = ""
|
||||
|
||||
@@ -4,12 +4,25 @@ import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { api, internal } from './_generated/api';
|
||||
|
||||
/**
|
||||
* Tipo de retorno da configuração do relógio
|
||||
*/
|
||||
type ConfiguracaoRelogioRetorno = {
|
||||
servidorNTP?: string | undefined;
|
||||
portaNTP?: number | undefined;
|
||||
usarServidorExterno: boolean;
|
||||
fallbackParaPC: boolean;
|
||||
ultimaSincronizacao: number | null;
|
||||
offsetSegundos: number | null;
|
||||
gmtOffset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtém a configuração do relógio
|
||||
*/
|
||||
export const obterConfiguracao = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
handler: async (ctx): Promise<ConfiguracaoRelogioRetorno> => {
|
||||
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
|
||||
const configs = await ctx.db
|
||||
.query('configuracaoRelogio')
|
||||
@@ -35,6 +48,46 @@ export const obterConfiguracao = query({
|
||||
|
||||
return {
|
||||
...config,
|
||||
ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null
|
||||
offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null
|
||||
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém a configuração do relógio (internal) - usado por actions para evitar referência circular
|
||||
*/
|
||||
export const obterConfiguracaoInternal = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ConfiguracaoRelogioRetorno> => {
|
||||
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
|
||||
const configs = await ctx.db
|
||||
.query('configuracaoRelogio')
|
||||
.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 (GMT-3 para Brasília)
|
||||
return {
|
||||
servidorNTP: 'pool.ntp.org',
|
||||
portaNTP: 123,
|
||||
usarServidorExterno: false,
|
||||
fallbackParaPC: true,
|
||||
ultimaSincronizacao: null,
|
||||
offsetSegundos: null,
|
||||
gmtOffset: -3, // GMT-3 para Brasília
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null
|
||||
offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null
|
||||
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
|
||||
};
|
||||
},
|
||||
@@ -119,15 +172,26 @@ export const obterTempoServidor = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tipo de retorno da sincronização
|
||||
*/
|
||||
type SincronizacaoRetorno = {
|
||||
sucesso: boolean;
|
||||
timestamp: number;
|
||||
usandoServidorExterno: boolean;
|
||||
offsetSegundos: number;
|
||||
aviso?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sincroniza tempo com servidor NTP (via action)
|
||||
* Nota: NTP real requer biblioteca específica, aqui fazemos uma aproximação
|
||||
*/
|
||||
export const sincronizarTempo = action({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Buscar configuração diretamente do banco usando query pública
|
||||
const config = await ctx.runQuery(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
handler: async (ctx): Promise<SincronizacaoRetorno> => {
|
||||
// Buscar configuração usando query interna para evitar referência circular
|
||||
const config: ConfiguracaoRelogioRetorno = await ctx.runQuery(internal.configuracaoRelogio.obterConfiguracaoInternal, {});
|
||||
|
||||
if (!config.usarServidorExterno) {
|
||||
return {
|
||||
@@ -145,66 +209,42 @@ export const sincronizarTempo = action({
|
||||
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
|
||||
// Se o servidor configurado for uma URL HTTP/HTTPS, tentar usar diretamente
|
||||
if (servidorNTP.startsWith('http://') || servidorNTP.startsWith('https://')) {
|
||||
try {
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
const response = await fetch(servidorNTP);
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo');
|
||||
throw new Error('Falha ao obter tempo do servidor configurado');
|
||||
}
|
||||
const data = (await response.json()) as { unixtime: number };
|
||||
serverTime = data.unixtime * 1000;
|
||||
} catch {
|
||||
// Fallback para outra API UTC
|
||||
const data = (await response.json()) as { unixtime?: number; unixTime?: number; unixtimestamp?: number };
|
||||
// Tentar diferentes formatos de resposta
|
||||
if (data.unixtime) {
|
||||
serverTime = data.unixtime * 1000; // Converter segundos para milissegundos
|
||||
} else if (data.unixTime) {
|
||||
serverTime = data.unixTime * 1000;
|
||||
} else if (data.unixtimestamp) {
|
||||
serverTime = data.unixtimestamp * 1000;
|
||||
} else {
|
||||
throw new Error('Formato de resposta não reconhecido');
|
||||
}
|
||||
} catch (error) {
|
||||
// Se falhar, tentar APIs genéricas como fallback
|
||||
throw new Error(`Falha ao usar servidor configurado: ${error}`);
|
||||
}
|
||||
} else {
|
||||
// Para servidores NTP tradicionais (sem HTTP), usar APIs genéricas que retornam UTC
|
||||
// Não usar worldtimeapi.org hardcoded - usar timeapi.io como primeira opção
|
||||
try {
|
||||
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');
|
||||
throw new Error('Falha ao obter tempo');
|
||||
}
|
||||
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();
|
||||
}
|
||||
// Fallback: usar tempo do servidor Convex (já está em UTC)
|
||||
// Não usar worldtimeapi.org como fallback automático
|
||||
serverTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +274,7 @@ export const sincronizarTempo = action({
|
||||
} 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
|
||||
const aviso: string = 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.';
|
||||
|
||||
|
||||
@@ -223,6 +223,92 @@ async function validarLocalizacao(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida dados de acelerômetro para detectar autenticidade do registro
|
||||
* Retorna informações de validação sem bloquear o registro
|
||||
*/
|
||||
function validarAcelerometro(
|
||||
isDesktop: boolean | undefined,
|
||||
sensorDisponivel: boolean | undefined,
|
||||
permissaoSensorNegada: boolean | undefined,
|
||||
acelerometroX: number | undefined,
|
||||
acelerometroY: number | undefined,
|
||||
acelerometroZ: number | undefined,
|
||||
movimentoDetectado: boolean | undefined,
|
||||
magnitudeMovimento: number | undefined,
|
||||
variacaoAcelerometro: number | undefined
|
||||
): {
|
||||
valida: boolean;
|
||||
motivo?: string;
|
||||
scoreConfianca: number; // 0-1
|
||||
avisos: string[];
|
||||
} {
|
||||
const avisos: string[] = [];
|
||||
let scoreConfianca = 1.0;
|
||||
|
||||
// Se for desktop, ausência de sensor não é suspeito
|
||||
if (isDesktop === true) {
|
||||
if (sensorDisponivel === false || !acelerometroX) {
|
||||
// Desktop não tem sensor - isso é normal, não reduzir confiança
|
||||
return {
|
||||
valida: true,
|
||||
scoreConfianca: 1.0,
|
||||
avisos: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Se permissão foi negada, apenas reduzir score de confiança (não bloqueia registro)
|
||||
if (permissaoSensorNegada === true) {
|
||||
scoreConfianca *= 0.9;
|
||||
avisos.push('Permissão de sensor negada pelo usuário (não bloqueia registro)');
|
||||
// Continuar validação normalmente
|
||||
}
|
||||
|
||||
// Se sensor não está disponível e não é desktop, pode ser suspeito (mas não bloqueia)
|
||||
if (sensorDisponivel === false && isDesktop !== true) {
|
||||
scoreConfianca *= 0.8;
|
||||
avisos.push('Sensor de movimento não disponível no dispositivo móvel');
|
||||
}
|
||||
|
||||
// Se sensor está disponível mas não há dados, pode ser suspeito
|
||||
if (sensorDisponivel === true && (!acelerometroX || !acelerometroY || !acelerometroZ)) {
|
||||
scoreConfianca *= 0.7;
|
||||
avisos.push('Sensor disponível mas dados de acelerômetro não coletados');
|
||||
}
|
||||
|
||||
// Se há dados de acelerômetro, validar
|
||||
if (acelerometroX !== undefined && acelerometroY !== undefined && acelerometroZ !== undefined) {
|
||||
// Verificar se valores são realistas (aceleração geralmente entre -20 e +20 m/s² em uso normal)
|
||||
const magnitude = magnitudeMovimento || Math.sqrt(acelerometroX * acelerometroX + acelerometroY * acelerometroY + acelerometroZ * acelerometroZ);
|
||||
|
||||
if (magnitude > 50) {
|
||||
// Aceleração muito alta pode indicar leitura errada ou emulador
|
||||
scoreConfianca *= 0.6;
|
||||
avisos.push(`Magnitude de movimento muito alta (${magnitude.toFixed(2)} m/s²). Pode indicar leitura incorreta.`);
|
||||
}
|
||||
|
||||
// Se não há movimento detectado quando deveria haver (em móvel), pode ser suspeito
|
||||
if (isDesktop !== true && movimentoDetectado === false && variacaoAcelerometro !== undefined && variacaoAcelerometro < 0.001) {
|
||||
// Variância muito baixa pode indicar que o dispositivo está parado ou emulador
|
||||
scoreConfianca *= 0.9;
|
||||
avisos.push('Nenhum movimento detectado durante o registro. Pode ser normal se o dispositivo estava parado.');
|
||||
}
|
||||
|
||||
// Se há movimento, aumenta confiança
|
||||
if (movimentoDetectado === true) {
|
||||
scoreConfianca = Math.min(scoreConfianca * 1.1, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valida: true, // Sempre retorna true - não bloqueia registro, apenas informa através do score
|
||||
motivo: avisos.length > 0 ? avisos[0] : undefined,
|
||||
scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)),
|
||||
avisos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera URL para upload de imagem do ponto
|
||||
*/
|
||||
@@ -337,6 +423,27 @@ export const registrarPonto = mutation({
|
||||
isDesktop: v.optional(v.boolean()),
|
||||
connectionType: v.optional(v.string()),
|
||||
memoryInfo: v.optional(v.string()),
|
||||
// Campos de sensores (acelerômetro e giroscópio)
|
||||
sensorDisponivel: v.optional(v.boolean()),
|
||||
permissaoNegada: v.optional(v.boolean()),
|
||||
acelerometro: v.optional(
|
||||
v.object({
|
||||
x: v.number(),
|
||||
y: v.number(),
|
||||
z: v.number(),
|
||||
movimentoDetectado: v.boolean(),
|
||||
magnitude: v.number(),
|
||||
variacao: v.number(),
|
||||
timestamp: v.number(),
|
||||
})
|
||||
),
|
||||
giroscopio: v.optional(
|
||||
v.object({
|
||||
alpha: v.number(),
|
||||
beta: v.number(),
|
||||
gamma: v.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
timestamp: v.number(),
|
||||
@@ -564,6 +671,39 @@ export const registrarPonto = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Validar dados de acelerômetro (não bloqueia registro - apenas informa)
|
||||
const validacaoAcelerometro = validarAcelerometro(
|
||||
args.informacoesDispositivo?.isDesktop,
|
||||
args.informacoesDispositivo?.sensorDisponivel,
|
||||
args.informacoesDispositivo?.permissaoNegada,
|
||||
args.informacoesDispositivo?.acelerometro?.x,
|
||||
args.informacoesDispositivo?.acelerometro?.y,
|
||||
args.informacoesDispositivo?.acelerometro?.z,
|
||||
args.informacoesDispositivo?.acelerometro?.movimentoDetectado,
|
||||
args.informacoesDispositivo?.acelerometro?.magnitude,
|
||||
args.informacoesDispositivo?.acelerometro?.variacao
|
||||
);
|
||||
|
||||
// Nota: A validação de acelerômetro não bloqueia o registro - apenas reduz o score de confiança
|
||||
// Apenas câmera e localização são obrigatórias para registrar ponto
|
||||
|
||||
// Combinar avisos de validação de localização e acelerômetro
|
||||
const todosAvisos = [
|
||||
...(validacaoLocalizacao?.avisos || []),
|
||||
...(validacaoAcelerometro.avisos || [])
|
||||
];
|
||||
|
||||
// Combinar scores de confiança (média ponderada)
|
||||
let scoreFinalConfianca = 1.0;
|
||||
if (validacaoLocalizacao && validacaoAcelerometro) {
|
||||
// GPS tem peso 0.7, acelerômetro tem peso 0.3
|
||||
scoreFinalConfianca = (validacaoLocalizacao.scoreConfianca * 0.7) + (validacaoAcelerometro.scoreConfianca * 0.3);
|
||||
} else if (validacaoLocalizacao) {
|
||||
scoreFinalConfianca = validacaoLocalizacao.scoreConfianca;
|
||||
} else if (validacaoAcelerometro) {
|
||||
scoreFinalConfianca = validacaoAcelerometro.scoreConfianca;
|
||||
}
|
||||
|
||||
// Criar registro
|
||||
const registroId = await ctx.db.insert('registrosPonto', {
|
||||
funcionarioId: usuario.funcionarioId,
|
||||
@@ -597,11 +737,11 @@ export const registrarPonto = mutation({
|
||||
heading: args.informacoesDispositivo?.heading,
|
||||
speed: args.informacoesDispositivo?.speed,
|
||||
confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS,
|
||||
scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca,
|
||||
suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined),
|
||||
motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined),
|
||||
scoreConfiancaBackend: scoreFinalConfianca,
|
||||
suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined) || (validacaoAcelerometro ? validacaoAcelerometro.scoreConfianca < 0.5 || !validacaoAcelerometro.valida : undefined),
|
||||
motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || validacaoAcelerometro?.motivo || (todosAvisos.length > 0 ? todosAvisos.join('; ') : undefined),
|
||||
// Informações detalhadas de validação (sempre salvar quando houver validação)
|
||||
avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined,
|
||||
avisosValidacao: todosAvisos.length > 0 ? todosAvisos : undefined,
|
||||
// Informações de Geofencing
|
||||
enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo,
|
||||
distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros,
|
||||
@@ -623,6 +763,18 @@ export const registrarPonto = mutation({
|
||||
isDesktop: args.informacoesDispositivo?.isDesktop,
|
||||
connectionType: args.informacoesDispositivo?.connectionType,
|
||||
memoryInfo: args.informacoesDispositivo?.memoryInfo,
|
||||
// Dados de sensores (Acelerômetro e Giroscópio)
|
||||
acelerometroX: args.informacoesDispositivo?.acelerometro?.x,
|
||||
acelerometroY: args.informacoesDispositivo?.acelerometro?.y,
|
||||
acelerometroZ: args.informacoesDispositivo?.acelerometro?.z,
|
||||
movimentoDetectado: args.informacoesDispositivo?.acelerometro?.movimentoDetectado,
|
||||
magnitudeMovimento: args.informacoesDispositivo?.acelerometro?.magnitude,
|
||||
variacaoAcelerometro: args.informacoesDispositivo?.acelerometro?.variacao,
|
||||
giroscopioAlpha: args.informacoesDispositivo?.giroscopio?.alpha,
|
||||
giroscopioBeta: args.informacoesDispositivo?.giroscopio?.beta,
|
||||
giroscopioGamma: args.informacoesDispositivo?.giroscopio?.gamma,
|
||||
sensorDisponivel: args.informacoesDispositivo?.sensorDisponivel,
|
||||
permissaoSensorNegada: args.informacoesDispositivo?.permissaoNegada,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1517,6 +1517,19 @@ export default defineSchema({
|
||||
connectionType: v.optional(v.string()),
|
||||
memoryInfo: v.optional(v.string()),
|
||||
|
||||
// Informações de Sensores (Acelerômetro e Giroscópio)
|
||||
acelerometroX: v.optional(v.number()),
|
||||
acelerometroY: v.optional(v.number()),
|
||||
acelerometroZ: v.optional(v.number()),
|
||||
movimentoDetectado: v.optional(v.boolean()),
|
||||
magnitudeMovimento: v.optional(v.number()),
|
||||
variacaoAcelerometro: v.optional(v.number()),
|
||||
giroscopioAlpha: v.optional(v.number()),
|
||||
giroscopioBeta: v.optional(v.number()),
|
||||
giroscopioGamma: v.optional(v.number()),
|
||||
sensorDisponivel: v.optional(v.boolean()),
|
||||
permissaoSensorNegada: v.optional(v.boolean()),
|
||||
|
||||
// Justificativa opcional para o registro
|
||||
justificativa: v.optional(v.string()),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user