feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours. - Updated RelogioSincronizado to include GMT offset adjustments for accurate time display. - Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas. - Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks. - Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
This commit is contained in:
@@ -3,6 +3,225 @@ import { mutation, query } from './_generated/server';
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
||||
|
||||
/**
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
* Retorna distância em metros
|
||||
*/
|
||||
function calcularDistancia(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Raio da Terra em metros
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém geolocalização aproximada por IP usando serviço externo
|
||||
*/
|
||||
async function obterGeoPorIP(ipAddress: string): Promise<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
} | null> {
|
||||
try {
|
||||
// Usar ipapi.co (gratuito, sem chave para uso limitado)
|
||||
const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country_name?: string;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
if (!data.error && data.latitude && data.longitude) {
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
cidade: data.city,
|
||||
estado: data.region,
|
||||
pais: data.country_name
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter geolocalização por IP:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida localização contra IP geolocation e histórico
|
||||
* Retorna informações detalhadas para salvar no registro
|
||||
*/
|
||||
async function validarLocalizacao(
|
||||
ctx: MutationCtx,
|
||||
funcionarioId: Id<'funcionarios'>,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
ipAddress?: string,
|
||||
confiabilidadeGPS?: number
|
||||
): Promise<{
|
||||
valida: boolean;
|
||||
motivo?: string;
|
||||
scoreConfianca: number; // 0-1
|
||||
avisos: string[];
|
||||
distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS
|
||||
velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h
|
||||
distanciaUltimoRegistro?: number; // Distância em metros do último registro
|
||||
tempoDecorridoHoras?: number; // Tempo em horas desde último registro
|
||||
}> {
|
||||
const avisos: string[] = [];
|
||||
let scoreConfianca = confiabilidadeGPS || 0.5;
|
||||
let valida = true;
|
||||
let distanciaIPvsGPS: number | undefined = undefined;
|
||||
let velocidadeUltimoRegistro: number | undefined = undefined;
|
||||
let distanciaUltimoRegistro: number | undefined = undefined;
|
||||
let tempoDecorridoHoras: number | undefined = undefined;
|
||||
|
||||
// 1. Validar coordenadas básicas
|
||||
if (
|
||||
isNaN(latitude) ||
|
||||
isNaN(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
return {
|
||||
valida: false,
|
||||
motivo: 'Coordenadas inválidas',
|
||||
scoreConfianca: 0,
|
||||
avisos: [],
|
||||
distanciaIPvsGPS,
|
||||
velocidadeUltimoRegistro,
|
||||
distanciaUltimoRegistro,
|
||||
tempoDecorridoHoras
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Comparar com geolocalização do IP
|
||||
if (ipAddress) {
|
||||
const ipGeo = await obterGeoPorIP(ipAddress);
|
||||
if (ipGeo) {
|
||||
distanciaIPvsGPS = calcularDistancia(
|
||||
latitude,
|
||||
longitude,
|
||||
ipGeo.latitude,
|
||||
ipGeo.longitude
|
||||
);
|
||||
|
||||
// Se diferença > 50km, muito suspeito
|
||||
if (distanciaIPvsGPS > 50000) {
|
||||
valida = false;
|
||||
scoreConfianca = Math.min(scoreConfianca, 0.2);
|
||||
avisos.push(
|
||||
`Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.`
|
||||
);
|
||||
} else if (distanciaIPvsGPS > 10000) {
|
||||
// Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile)
|
||||
scoreConfianca *= 0.7;
|
||||
avisos.push(
|
||||
`Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.`
|
||||
);
|
||||
} else if (distanciaIPvsGPS < 5000) {
|
||||
// Se diferença < 5km, aumenta confiança
|
||||
scoreConfianca = Math.min(scoreConfianca + 0.2, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validar histórico de localizações do funcionário
|
||||
const ultimosRegistros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
|
||||
.order('desc')
|
||||
.take(5);
|
||||
|
||||
if (ultimosRegistros.length > 0) {
|
||||
// Verificar movimento impossível
|
||||
for (const registro of ultimosRegistros) {
|
||||
if (registro.latitude && registro.longitude && registro.timestamp) {
|
||||
distanciaUltimoRegistro = calcularDistancia(
|
||||
latitude,
|
||||
longitude,
|
||||
registro.latitude,
|
||||
registro.longitude
|
||||
);
|
||||
const tempoDecorrido = Date.now() - registro.timestamp;
|
||||
tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60);
|
||||
|
||||
// Calcular velocidade (km/h) se tempo decorrido > 0
|
||||
if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) {
|
||||
velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h
|
||||
|
||||
// Se velocidade > 1000 km/h, impossível (mais rápido que avião)
|
||||
if (velocidadeUltimoRegistro > 1000) {
|
||||
valida = false;
|
||||
scoreConfianca = 0;
|
||||
avisos.push(
|
||||
`Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Se velocidade > 200 km/h, suspeito (mas possível em avião)
|
||||
if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) {
|
||||
scoreConfianca *= 0.6;
|
||||
avisos.push(
|
||||
`Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break; // Usar apenas o último registro
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validar confiabilidade GPS do frontend
|
||||
if (confiabilidadeGPS !== undefined) {
|
||||
if (confiabilidadeGPS < 0.3) {
|
||||
scoreConfianca *= 0.5;
|
||||
avisos.push(
|
||||
`Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valida,
|
||||
motivo: avisos.length > 0 ? avisos[0] : undefined,
|
||||
scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)),
|
||||
avisos,
|
||||
distanciaIPvsGPS,
|
||||
velocidadeUltimoRegistro,
|
||||
distanciaUltimoRegistro,
|
||||
tempoDecorridoHoras
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera URL para upload de imagem do ponto
|
||||
@@ -96,6 +315,13 @@ export const registrarPonto = mutation({
|
||||
latitude: v.optional(v.number()),
|
||||
longitude: v.optional(v.number()),
|
||||
precisao: v.optional(v.number()),
|
||||
altitude: v.optional(v.union(v.number(), v.null())),
|
||||
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
|
||||
heading: v.optional(v.union(v.number(), v.null())),
|
||||
speed: v.optional(v.union(v.number(), v.null())),
|
||||
confiabilidadeGPS: v.optional(v.number()),
|
||||
suspeitaSpoofing: v.optional(v.boolean()),
|
||||
motivoSuspeita: v.optional(v.string()),
|
||||
endereco: v.optional(v.string()),
|
||||
cidade: v.optional(v.string()),
|
||||
estado: v.optional(v.string()),
|
||||
@@ -150,13 +376,31 @@ export const registrarPonto = mutation({
|
||||
.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;
|
||||
const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
|
||||
const dataObj = new Date(timestampAjustado);
|
||||
const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD
|
||||
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
|
||||
|
||||
// Verificar se já existe registro no mesmo minuto
|
||||
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
||||
@@ -244,6 +488,97 @@ export const registrarPonto = mutation({
|
||||
|
||||
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
|
||||
|
||||
// Validar localização se fornecida e salvar informações detalhadas
|
||||
let validacaoLocalizacao: {
|
||||
valida: boolean;
|
||||
motivo?: string;
|
||||
scoreConfianca: number;
|
||||
avisos: string[];
|
||||
distanciaIPvsGPS?: number;
|
||||
velocidadeUltimoRegistro?: number;
|
||||
distanciaUltimoRegistro?: number;
|
||||
tempoDecorridoHoras?: number;
|
||||
} | null = null;
|
||||
|
||||
if (
|
||||
args.informacoesDispositivo?.latitude &&
|
||||
args.informacoesDispositivo?.longitude
|
||||
) {
|
||||
validacaoLocalizacao = await validarLocalizacao(
|
||||
ctx,
|
||||
usuario.funcionarioId,
|
||||
args.informacoesDispositivo.latitude,
|
||||
args.informacoesDispositivo.longitude,
|
||||
args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress,
|
||||
args.informacoesDispositivo.confiabilidadeGPS
|
||||
);
|
||||
|
||||
// Sempre registrar, mesmo com baixa confiabilidade
|
||||
// Mas salvar todas as informações detalhadas para análise posterior
|
||||
const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing;
|
||||
const suspeitaBackend = !validacaoLocalizacao.valida;
|
||||
const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5;
|
||||
|
||||
if (suspeitaFrontend || suspeitaBackend || baixaConfianca) {
|
||||
console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', {
|
||||
funcionarioId: usuario.funcionarioId,
|
||||
latitude: args.informacoesDispositivo.latitude,
|
||||
longitude: args.informacoesDispositivo.longitude,
|
||||
confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
|
||||
scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
|
||||
suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
|
||||
suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
|
||||
avisos: validacaoLocalizacao.avisos
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validar geofencing (localização permitida) se habilitado
|
||||
let validacaoGeofencing: {
|
||||
dentroRaio: boolean;
|
||||
enderecoMaisProximo?: Id<'enderecosMarcacao'>;
|
||||
distanciaMetros?: number;
|
||||
raioUsado?: number;
|
||||
enderecoEncontrado?: string;
|
||||
avisos: string[];
|
||||
} | null = null;
|
||||
|
||||
if (
|
||||
configPonto?.validarLocalizacao !== false &&
|
||||
args.informacoesDispositivo?.latitude &&
|
||||
args.informacoesDispositivo?.longitude
|
||||
) {
|
||||
const geofencing = await validarLocalizacaoGeofencingInternal(
|
||||
ctx,
|
||||
usuario.funcionarioId,
|
||||
args.informacoesDispositivo.latitude,
|
||||
args.informacoesDispositivo.longitude,
|
||||
configPonto?.toleranciaDistanciaMetros ?? 100
|
||||
);
|
||||
|
||||
validacaoGeofencing = geofencing;
|
||||
|
||||
// Adicionar avisos de geofencing aos avisos de validação
|
||||
if (geofencing.avisos.length > 0) {
|
||||
if (!validacaoLocalizacao) {
|
||||
validacaoLocalizacao = {
|
||||
valida: true,
|
||||
scoreConfianca: 1,
|
||||
avisos: [],
|
||||
};
|
||||
}
|
||||
validacaoLocalizacao.avisos.push(...geofencing.avisos);
|
||||
|
||||
// Reduzir score de confiança se estiver fora do raio
|
||||
if (!geofencing.dentroRaio) {
|
||||
validacaoLocalizacao.scoreConfianca = Math.min(
|
||||
validacaoLocalizacao.scoreConfianca,
|
||||
0.7
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Criar registro
|
||||
const registroId = await ctx.db.insert('registrosPonto', {
|
||||
funcionarioId: usuario.funcionarioId,
|
||||
@@ -272,6 +607,22 @@ export const registrarPonto = mutation({
|
||||
latitude: args.informacoesDispositivo?.latitude,
|
||||
longitude: args.informacoesDispositivo?.longitude,
|
||||
precisao: args.informacoesDispositivo?.precisao,
|
||||
altitude: args.informacoesDispositivo?.altitude,
|
||||
altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy,
|
||||
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),
|
||||
// Informações detalhadas de validação (sempre salvar quando houver validação)
|
||||
avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined,
|
||||
// Informações de Geofencing
|
||||
enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo,
|
||||
distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros,
|
||||
dentroRaioPermitido: validacaoGeofencing?.dentroRaio,
|
||||
enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo,
|
||||
raioToleranciaUsado: validacaoGeofencing?.raioUsado,
|
||||
endereco: args.informacoesDispositivo?.endereco,
|
||||
cidade: args.informacoesDispositivo?.cidade,
|
||||
estado: args.informacoesDispositivo?.estado,
|
||||
|
||||
Reference in New Issue
Block a user