import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; /** * 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; } /** * Lista todos os endereços de marcação ativos */ export const listarEnderecos = query({ args: { incluirInativos: v.optional(v.boolean()), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } let enderecos; if (args.incluirInativos) { enderecos = await ctx.db.query('enderecosMarcacao').collect(); } else { enderecos = await ctx.db .query('enderecosMarcacao') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .collect(); } // Ordenar por nome return enderecos.sort((a, b) => a.nome.localeCompare(b.nome)); }, }); /** * Obtém um endereço específico */ export const obterEndereco = query({ args: { enderecoId: v.id('enderecosMarcacao'), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const endereco = await ctx.db.get(args.enderecoId); if (!endereco) { throw new Error('Endereço não encontrado'); } return endereco; }, }); /** * Cria um novo endereço de marcação */ export const criarEndereco = mutation({ args: { nome: v.string(), descricao: v.optional(v.string()), latitude: v.number(), longitude: v.number(), endereco: v.string(), bairro: v.optional(v.string()), cep: v.optional(v.string()), cidade: v.string(), estado: v.string(), pais: v.optional(v.string()), raioMetros: v.number(), tipo: v.union( v.literal('sede'), v.literal('home_office'), v.literal('deslocamento'), v.literal('cliente') ), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissões (apenas TI ou admin) // Validações if (!args.nome || args.nome.trim().length === 0) { throw new Error('Nome é obrigatório'); } if ( isNaN(args.latitude) || args.latitude < -90 || args.latitude > 90 || isNaN(args.longitude) || args.longitude < -180 || args.longitude > 180 ) { throw new Error('Coordenadas inválidas'); } if (args.raioMetros < 0 || args.raioMetros > 50000) { throw new Error('Raio deve estar entre 0 e 50000 metros'); } if (!args.endereco || args.endereco.trim().length === 0) { throw new Error('Endereço é obrigatório'); } if (!args.cidade || args.cidade.trim().length === 0) { throw new Error('Cidade é obrigatória'); } if (!args.estado || args.estado.trim().length === 0) { throw new Error('Estado é obrigatório'); } const enderecoId = await ctx.db.insert('enderecosMarcacao', { nome: args.nome.trim(), descricao: args.descricao?.trim(), latitude: args.latitude, longitude: args.longitude, endereco: args.endereco.trim(), bairro: args.bairro?.trim(), cep: args.cep?.trim(), cidade: args.cidade.trim(), estado: args.estado.trim(), pais: args.pais?.trim() || 'Brasil', raioMetros: args.raioMetros, tipo: args.tipo, ativo: true, criadoPor: usuario._id as Id<'usuarios'>, criadoEm: Date.now(), }); return { enderecoId }; }, }); /** * Atualiza um endereço de marcação */ export const atualizarEndereco = mutation({ args: { enderecoId: v.id('enderecosMarcacao'), nome: v.optional(v.string()), descricao: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), endereco: v.optional(v.string()), bairro: v.optional(v.string()), cep: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), pais: v.optional(v.string()), raioMetros: v.optional(v.number()), ativo: v.optional(v.boolean()), tipo: v.optional( v.union( v.literal('sede'), v.literal('home_office'), v.literal('deslocamento'), v.literal('cliente') ) ), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissões (apenas TI ou admin) const endereco = await ctx.db.get(args.enderecoId); if (!endereco) { throw new Error('Endereço não encontrado'); } const atualizacoes: { nome?: string; descricao?: string; latitude?: number; longitude?: number; endereco?: string; cep?: string; cidade?: string; estado?: string; pais?: string; raioMetros?: number; tipo?: 'sede' | 'home_office' | 'deslocamento' | 'cliente'; ativo?: boolean; atualizadoPor?: Id<'usuarios'>; atualizadoEm?: number; } = {}; if (args.nome !== undefined) { if (args.nome.trim().length === 0) { throw new Error('Nome não pode ser vazio'); } atualizacoes.nome = args.nome.trim(); } if (args.descricao !== undefined) { atualizacoes.descricao = args.descricao?.trim() || undefined; } if (args.latitude !== undefined || args.longitude !== undefined) { const lat = args.latitude ?? endereco.latitude; const lon = args.longitude ?? endereco.longitude; if ( isNaN(lat) || lat < -90 || lat > 90 || isNaN(lon) || lon < -180 || lon > 180 ) { throw new Error('Coordenadas inválidas'); } if (args.latitude !== undefined) atualizacoes.latitude = lat; if (args.longitude !== undefined) atualizacoes.longitude = lon; } if (args.endereco !== undefined) { if (args.endereco.trim().length === 0) { throw new Error('Endereço não pode ser vazio'); } atualizacoes.endereco = args.endereco.trim(); } if (args.bairro !== undefined) { atualizacoes.bairro = args.bairro?.trim() || undefined; } if (args.cep !== undefined) { atualizacoes.cep = args.cep?.trim() || undefined; } if (args.cidade !== undefined) { if (args.cidade.trim().length === 0) { throw new Error('Cidade não pode ser vazia'); } atualizacoes.cidade = args.cidade.trim(); } if (args.estado !== undefined) { if (args.estado.trim().length === 0) { throw new Error('Estado não pode ser vazio'); } atualizacoes.estado = args.estado.trim(); } if (args.pais !== undefined) { atualizacoes.pais = args.pais?.trim() || 'Brasil'; } if (args.raioMetros !== undefined) { if (args.raioMetros < 0 || args.raioMetros > 50000) { throw new Error('Raio deve estar entre 0 e 50000 metros'); } atualizacoes.raioMetros = args.raioMetros; } if (args.tipo !== undefined) { atualizacoes.tipo = args.tipo; } if (args.ativo !== undefined) { atualizacoes.ativo = args.ativo; } atualizacoes.atualizadoPor = usuario._id as Id<'usuarios'>; atualizacoes.atualizadoEm = Date.now(); await ctx.db.patch(args.enderecoId, atualizacoes); return { sucesso: true }; }, }); /** * Desativa um endereço de marcação */ export const desativarEndereco = mutation({ args: { enderecoId: v.id('enderecosMarcacao'), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissões (apenas TI ou admin) const endereco = await ctx.db.get(args.enderecoId); if (!endereco) { throw new Error('Endereço não encontrado'); } await ctx.db.patch(args.enderecoId, { ativo: false, atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoEm: Date.now(), }); return { sucesso: true }; }, }); /** * Obtém endereços permitidos para um funcionário * (leva em conta associações específicas e endereços tipo "sede") */ export const obterEnderecosFuncionario = query({ args: { funcionarioId: v.id('funcionarios'), dataAtual: v.optional(v.string()), // YYYY-MM-DD para validar períodos }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const dataAtual = args.dataAtual || new Date().toISOString().split('T')[0]!; // Buscar associações específicas do funcionário const associacoes = await ctx.db .query('funcionarioEnderecosMarcacao') .withIndex('by_funcionario_ativo', (q) => q.eq('funcionarioId', args.funcionarioId).eq('ativo', true) ) .collect(); const enderecosPermitidos: Array<{ enderecoId: Id<'enderecosMarcacao'>; raioMetros: number; periodoValido: boolean; }> = []; // Processar associações for (const associacao of associacoes) { // Verificar período de validade let periodoValido = true; if (associacao.dataInicio || associacao.dataFim) { if (associacao.dataInicio && dataAtual < associacao.dataInicio) { periodoValido = false; } if (associacao.dataFim && dataAtual > associacao.dataFim) { periodoValido = false; } } if (!periodoValido) continue; const endereco = await ctx.db.get(associacao.enderecoMarcacaoId); if (!endereco || !endereco.ativo) continue; // Usar raio personalizado se disponível, senão usar o raio do endereço const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros; enderecosPermitidos.push({ enderecoId: endereco._id, raioMetros, periodoValido: true, }); } // Se não houver associações específicas, buscar endereços tipo "sede" if (enderecosPermitidos.length === 0) { const enderecosSede = await ctx.db .query('enderecosMarcacao') .withIndex('by_tipo', (q) => q.eq('tipo', 'sede')) .filter((q) => q.eq(q.field('ativo'), true)) .collect(); for (const endereco of enderecosSede) { enderecosPermitidos.push({ enderecoId: endereco._id, raioMetros: endereco.raioMetros, periodoValido: true, }); } } // Buscar dados completos dos endereços const enderecosCompletos = await Promise.all( enderecosPermitidos.map(async (item) => { const endereco = await ctx.db.get(item.enderecoId); if (!endereco) return null; return { ...endereco, raioMetros: item.raioMetros, periodoValido: item.periodoValido, }; }) ); return enderecosCompletos.filter( (endereco): endereco is NonNullable => endereco !== null ); }, }); /** * Função auxiliar interna para validar geofencing * Pode ser chamada diretamente de outras mutations */ async function validarLocalizacaoGeofencingInternal( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, latitude: number, longitude: number, raioPadrao: number = 100 ): Promise<{ dentroRaio: boolean; enderecoMaisProximo?: Id<'enderecosMarcacao'>; distanciaMetros?: number; raioUsado?: number; enderecoEncontrado?: string; avisos: string[]; }> { const avisos: string[] = []; // Validar coordenadas if ( isNaN(latitude) || latitude < -90 || latitude > 90 || isNaN(longitude) || longitude < -180 || longitude > 180 ) { return { dentroRaio: false, avisos: ['Coordenadas inválidas'], }; } // Obter endereços permitidos para o funcionário const dataAtual = new Date().toISOString().split('T')[0]!; const associacoes = await ctx.db .query('funcionarioEnderecosMarcacao') .withIndex('by_funcionario_ativo', (q) => q.eq('funcionarioId', funcionarioId).eq('ativo', true) ) .collect(); const enderecosParaValidar: Array<{ enderecoId: Id<'enderecosMarcacao'>; raioMetros: number; }> = []; // Processar associações específicas for (const associacao of associacoes) { let periodoValido = true; if (associacao.dataInicio || associacao.dataFim) { if (associacao.dataInicio && dataAtual < associacao.dataInicio) { periodoValido = false; } if (associacao.dataFim && dataAtual > associacao.dataFim) { periodoValido = false; } } if (!periodoValido) continue; const endereco = await ctx.db.get(associacao.enderecoMarcacaoId); if (!endereco || !endereco.ativo) continue; const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros; enderecosParaValidar.push({ enderecoId: endereco._id, raioMetros, }); } // Se não houver associações específicas, buscar endereços tipo "sede" if (enderecosParaValidar.length === 0) { const enderecosSede = await ctx.db .query('enderecosMarcacao') .withIndex('by_tipo', (q) => q.eq('tipo', 'sede')) .filter((q) => q.eq(q.field('ativo'), true)) .collect(); for (const endereco of enderecosSede) { enderecosParaValidar.push({ enderecoId: endereco._id, raioMetros: endereco.raioMetros, }); } } // Se ainda não houver endereços, usar raio padrão if (enderecosParaValidar.length === 0) { avisos.push( 'Nenhum endereço de marcação configurado. Usando validação padrão.' ); return { dentroRaio: true, // Não bloquear se não houver configuração raioUsado: raioPadrao, avisos, }; } // Calcular distância para cada endereço e encontrar o mais próximo let enderecoMaisProximo: Id<'enderecosMarcacao'> | undefined = undefined; let distanciaMinima: number | undefined = undefined; let raioUsado: number | undefined = undefined; let enderecoEncontrado: string | undefined = undefined; for (const item of enderecosParaValidar) { const endereco = await ctx.db.get(item.enderecoId); if (!endereco) continue; const distancia = calcularDistancia( latitude, longitude, endereco.latitude, endereco.longitude ); if (distanciaMinima === undefined || distancia < distanciaMinima) { distanciaMinima = distancia; enderecoMaisProximo = endereco._id; raioUsado = item.raioMetros; enderecoEncontrado = `${endereco.endereco}, ${endereco.cidade}/${endereco.estado}`; } } if (enderecoMaisProximo === undefined || distanciaMinima === undefined) { return { dentroRaio: false, avisos: ['Não foi possível validar localização'], }; } const dentroRaio = distanciaMinima <= (raioUsado ?? raioPadrao); if (!dentroRaio) { const distanciaKm = (distanciaMinima / 1000).toFixed(2); const raioKm = ((raioUsado ?? raioPadrao) / 1000).toFixed(2); avisos.push( `Localização fora do raio permitido. Registrado a ${distanciaKm}km do endereço esperado (raio permitido: ${raioKm}km).` ); } return { dentroRaio, enderecoMaisProximo, distanciaMetros: distanciaMinima, raioUsado: raioUsado ?? raioPadrao, enderecoEncontrado, avisos, }; } /** * Mutation pública para validar localização contra endereços permitidos (geofencing) * Retorna o endereço mais próximo e se está dentro do raio */ export const validarLocalizacaoGeofencing = mutation({ args: { funcionarioId: v.id('funcionarios'), latitude: v.number(), longitude: v.number(), raioPadrao: v.optional(v.number()), // metros - usado se não houver endereços configurados }, returns: v.object({ dentroRaio: v.boolean(), enderecoMaisProximo: v.optional(v.id('enderecosMarcacao')), distanciaMetros: v.optional(v.number()), raioUsado: v.optional(v.number()), enderecoEncontrado: v.optional(v.string()), avisos: v.array(v.string()), }), handler: async (ctx, args) => { return await validarLocalizacaoGeofencingInternal( ctx, args.funcionarioId, args.latitude, args.longitude, args.raioPadrao || 100 ); }, }); /** * Exporta a função auxiliar para uso interno em outras mutations */ export { validarLocalizacaoGeofencingInternal };