Merge branch 'master' into call-audio-video-jitsi
This commit is contained in:
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -30,8 +30,10 @@ import type * as cursos from "../cursos.js";
|
||||
import type * as dashboard from "../dashboard.js";
|
||||
import type * as documentos from "../documentos.js";
|
||||
import type * as email from "../email.js";
|
||||
import type * as enderecosMarcacao from "../enderecosMarcacao.js";
|
||||
import type * as empresas from "../empresas.js";
|
||||
import type * as ferias from "../ferias.js";
|
||||
import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
|
||||
import type * as funcionarios from "../funcionarios.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
@@ -84,8 +86,10 @@ declare const fullApi: ApiFromModules<{
|
||||
dashboard: typeof dashboard;
|
||||
documentos: typeof documentos;
|
||||
email: typeof email;
|
||||
enderecosMarcacao: typeof enderecosMarcacao;
|
||||
empresas: typeof empresas;
|
||||
ferias: typeof ferias;
|
||||
funcionarioEnderecos: typeof funcionarioEnderecos;
|
||||
funcionarios: typeof funcionarios;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
|
||||
@@ -70,8 +70,7 @@ export const criarConversa = mutation({
|
||||
args: {
|
||||
tipo: v.union(v.literal('individual'), v.literal('grupo'), v.literal('sala_reuniao')),
|
||||
participantes: v.array(v.id('usuarios')),
|
||||
nome: v.optional(v.string()),
|
||||
avatar: v.optional(v.string())
|
||||
nome: v.optional(v.string())
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
@@ -103,7 +102,6 @@ export const criarConversa = mutation({
|
||||
const dadosConversa: Omit<Doc<'conversas'>, '_id' | '_creationTime'> = {
|
||||
tipo: args.tipo,
|
||||
nome: args.nome,
|
||||
avatar: args.avatar,
|
||||
participantes: args.participantes,
|
||||
criadoPor: usuarioAtual._id,
|
||||
criadoEm: Date.now()
|
||||
@@ -152,8 +150,7 @@ export const criarConversa = mutation({
|
||||
export const criarSalaReuniao = mutation({
|
||||
args: {
|
||||
nome: v.string(),
|
||||
participantes: v.array(v.id('usuarios')),
|
||||
avatar: v.optional(v.string())
|
||||
participantes: v.array(v.id('usuarios'))
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
@@ -174,7 +171,6 @@ export const criarSalaReuniao = mutation({
|
||||
const dadosConversa: Omit<Doc<'conversas'>, '_id' | '_creationTime'> = {
|
||||
tipo: 'sala_reuniao' as const,
|
||||
nome: args.nome.trim(),
|
||||
avatar: args.avatar,
|
||||
participantes: participantesUnicos,
|
||||
criadoPor: usuarioAtual._id,
|
||||
criadoEm: Date.now(),
|
||||
@@ -2010,7 +2006,6 @@ export const obterUsuariosOnline = query({
|
||||
_id: u._id,
|
||||
nome: u.nome,
|
||||
email: u.email,
|
||||
avatar: u.avatar,
|
||||
fotoPerfil: u.fotoPerfil,
|
||||
statusPresenca: u.statusPresenca,
|
||||
statusMensagem: u.statusMensagem,
|
||||
@@ -2055,7 +2050,6 @@ export const listarTodosUsuarios = query({
|
||||
nome: u.nome,
|
||||
email: u.email,
|
||||
matricula,
|
||||
avatar: u.avatar,
|
||||
fotoPerfil: u.fotoPerfil,
|
||||
fotoPerfilUrl,
|
||||
statusPresenca: u.statusPresenca,
|
||||
|
||||
@@ -34,17 +34,21 @@ export const obterConfiguracao = query({
|
||||
nomeSaidaAlmoco: 'Saída 1',
|
||||
nomeRetornoAlmoco: 'Entrada 2',
|
||||
nomeSaida: 'Saída 2',
|
||||
validarLocalizacao: true,
|
||||
toleranciaDistanciaMetros: 100,
|
||||
ativo: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Garantir que os nomes padrão estejam definidos
|
||||
// Garantir que os nomes padrão e valores padrão estejam definidos
|
||||
return {
|
||||
...config,
|
||||
nomeEntrada: config.nomeEntrada || 'Entrada 1',
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
||||
nomeSaida: config.nomeSaida || 'Saída 2',
|
||||
validarLocalizacao: config.validarLocalizacao ?? true,
|
||||
toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -63,6 +67,8 @@ export const salvarConfiguracao = mutation({
|
||||
nomeSaidaAlmoco: v.optional(v.string()),
|
||||
nomeRetornoAlmoco: v.optional(v.string()),
|
||||
nomeSaida: v.optional(v.string()),
|
||||
validarLocalizacao: v.optional(v.boolean()),
|
||||
toleranciaDistanciaMetros: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
@@ -113,6 +119,13 @@ export const salvarConfiguracao = mutation({
|
||||
throw new Error('Horário de retorno do almoço deve ser anterior à saída');
|
||||
}
|
||||
|
||||
// Validar tolerância de distância se fornecida
|
||||
if (args.toleranciaDistanciaMetros !== undefined) {
|
||||
if (args.toleranciaDistanciaMetros < 0 || args.toleranciaDistanciaMetros > 50000) {
|
||||
throw new Error('Tolerância de distância deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
}
|
||||
|
||||
// Desativar configurações antigas
|
||||
const configsAntigas = await ctx.db
|
||||
.query('configuracaoPonto')
|
||||
@@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({
|
||||
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
|
||||
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
|
||||
nomeSaida: args.nomeSaida || 'Saída 2',
|
||||
validarLocalizacao: args.validarLocalizacao ?? true,
|
||||
toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
|
||||
ativo: true,
|
||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||
atualizadoEm: Date.now(),
|
||||
|
||||
@@ -162,6 +162,29 @@ export const enfileirarEmail = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Cancelar agendamento de email
|
||||
*/
|
||||
export const cancelarAgendamentoEmail = mutation({
|
||||
args: {
|
||||
emailId: v.id("notificacoesEmail"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const email = await ctx.db.get(args.emailId);
|
||||
if (!email) {
|
||||
return { sucesso: false, erro: "Email não encontrado" };
|
||||
}
|
||||
|
||||
if (email.status !== "pendente") {
|
||||
return { sucesso: false, erro: "Apenas emails pendentes podem ser cancelados" };
|
||||
}
|
||||
|
||||
// Remove o email da fila
|
||||
await ctx.db.delete(args.emailId);
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Enviar email usando template
|
||||
*/
|
||||
|
||||
627
packages/backend/convex/enderecosMarcacao.ts
Normal file
627
packages/backend/convex/enderecosMarcacao.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
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;
|
||||
bairro?: 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<typeof endereco> => 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 };
|
||||
|
||||
|
||||
246
packages/backend/convex/funcionarioEnderecos.ts
Normal file
246
packages/backend/convex/funcionarioEnderecos.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { v } from 'convex/values';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
|
||||
/**
|
||||
* Lista todas as associações de endereços para um funcionário
|
||||
*/
|
||||
export const listarAssociacoesFuncionario = query({
|
||||
args: {
|
||||
funcionarioId: v.id('funcionarios'),
|
||||
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 associacoes;
|
||||
if (args.incluirInativos) {
|
||||
associacoes = await ctx.db
|
||||
.query('funcionarioEnderecosMarcacao')
|
||||
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||
.collect();
|
||||
} else {
|
||||
associacoes = await ctx.db
|
||||
.query('funcionarioEnderecosMarcacao')
|
||||
.withIndex('by_funcionario_ativo', (q) =>
|
||||
q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Buscar dados completos dos endereços
|
||||
const associacoesCompletas = await Promise.all(
|
||||
associacoes.map(async (associacao) => {
|
||||
const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
|
||||
if (!endereco) return null;
|
||||
|
||||
// Usar raio personalizado se disponível, senão usar o raio padrão do endereço
|
||||
const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
|
||||
|
||||
return {
|
||||
...associacao,
|
||||
raioMetros, // Raio usado (personalizado ou padrão)
|
||||
endereco: {
|
||||
_id: endereco._id,
|
||||
nome: endereco.nome,
|
||||
descricao: endereco.descricao,
|
||||
latitude: endereco.latitude,
|
||||
longitude: endereco.longitude,
|
||||
endereco: endereco.endereco,
|
||||
cep: endereco.cep,
|
||||
cidade: endereco.cidade,
|
||||
estado: endereco.estado,
|
||||
pais: endereco.pais,
|
||||
raioMetros: endereco.raioMetros, // Raio padrão do endereço
|
||||
tipo: endereco.tipo,
|
||||
ativo: endereco.ativo,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return associacoesCompletas.filter(
|
||||
(item): item is NonNullable<typeof item> => item !== null
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Associa um endereço a um funcionário
|
||||
*/
|
||||
export const associarEnderecoFuncionario = mutation({
|
||||
args: {
|
||||
funcionarioId: v.id('funcionarios'),
|
||||
enderecoMarcacaoId: v.id('enderecosMarcacao'),
|
||||
raioMetrosPersonalizado: v.optional(v.number()),
|
||||
dataInicio: v.optional(v.string()), // YYYY-MM-DD
|
||||
dataFim: v.optional(v.string()), // YYYY-MM-DD
|
||||
},
|
||||
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)
|
||||
|
||||
// Validar que o funcionário existe
|
||||
const funcionario = await ctx.db.get(args.funcionarioId);
|
||||
if (!funcionario) {
|
||||
throw new Error('Funcionário não encontrado');
|
||||
}
|
||||
|
||||
// Validar que o endereço existe e está ativo
|
||||
const endereco = await ctx.db.get(args.enderecoMarcacaoId);
|
||||
if (!endereco) {
|
||||
throw new Error('Endereço não encontrado');
|
||||
}
|
||||
if (!endereco.ativo) {
|
||||
throw new Error('Endereço não está ativo');
|
||||
}
|
||||
|
||||
// Validar raio personalizado se fornecido
|
||||
if (args.raioMetrosPersonalizado !== undefined) {
|
||||
if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) {
|
||||
throw new Error('Raio deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
}
|
||||
|
||||
// Validar datas se fornecidas
|
||||
if (args.dataInicio && args.dataFim) {
|
||||
if (args.dataInicio > args.dataFim) {
|
||||
throw new Error('Data de início deve ser anterior à data de fim');
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se já existe uma associação ativa
|
||||
const associacaoExistente = await ctx.db
|
||||
.query('funcionarioEnderecosMarcacao')
|
||||
.withIndex('by_funcionario_ativo', (q) =>
|
||||
q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
|
||||
)
|
||||
.filter((q) => q.eq(q.field('enderecoMarcacaoId'), args.enderecoMarcacaoId))
|
||||
.first();
|
||||
|
||||
if (associacaoExistente) {
|
||||
// Atualizar associação existente
|
||||
await ctx.db.patch(associacaoExistente._id, {
|
||||
raioMetrosPersonalizado: args.raioMetrosPersonalizado,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
ativo: true,
|
||||
});
|
||||
|
||||
return { associacaoId: associacaoExistente._id, atualizado: true };
|
||||
}
|
||||
|
||||
// Criar nova associação
|
||||
const associacaoId = await ctx.db.insert('funcionarioEnderecosMarcacao', {
|
||||
funcionarioId: args.funcionarioId,
|
||||
enderecoMarcacaoId: args.enderecoMarcacaoId,
|
||||
raioMetrosPersonalizado: args.raioMetrosPersonalizado,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
ativo: true,
|
||||
criadoPor: usuario._id as Id<'usuarios'>,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
return { associacaoId, atualizado: false };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualiza uma associação de endereço com funcionário
|
||||
*/
|
||||
export const atualizarAssociacao = mutation({
|
||||
args: {
|
||||
associacaoId: v.id('funcionarioEnderecosMarcacao'),
|
||||
raioMetrosPersonalizado: v.optional(v.number()),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
ativo: v.optional(v.boolean()),
|
||||
},
|
||||
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 associacao = await ctx.db.get(args.associacaoId);
|
||||
if (!associacao) {
|
||||
throw new Error('Associação não encontrada');
|
||||
}
|
||||
|
||||
const atualizacoes: {
|
||||
raioMetrosPersonalizado?: number;
|
||||
dataInicio?: string;
|
||||
dataFim?: string;
|
||||
ativo?: boolean;
|
||||
} = {};
|
||||
|
||||
if (args.raioMetrosPersonalizado !== undefined) {
|
||||
if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) {
|
||||
throw new Error('Raio deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
atualizacoes.raioMetrosPersonalizado = args.raioMetrosPersonalizado;
|
||||
}
|
||||
|
||||
if (args.dataInicio !== undefined) {
|
||||
atualizacoes.dataInicio = args.dataInicio || undefined;
|
||||
}
|
||||
|
||||
if (args.dataFim !== undefined) {
|
||||
atualizacoes.dataFim = args.dataFim || undefined;
|
||||
}
|
||||
|
||||
if (args.dataInicio && args.dataFim) {
|
||||
if (args.dataInicio > args.dataFim) {
|
||||
throw new Error('Data de início deve ser anterior à data de fim');
|
||||
}
|
||||
}
|
||||
|
||||
if (args.ativo !== undefined) {
|
||||
atualizacoes.ativo = args.ativo;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.associacaoId, atualizacoes);
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove uma associação de endereço com funcionário (desativa)
|
||||
*/
|
||||
export const removerAssociacao = mutation({
|
||||
args: {
|
||||
associacaoId: v.id('funcionarioEnderecosMarcacao'),
|
||||
},
|
||||
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 associacao = await ctx.db.get(args.associacaoId);
|
||||
if (!associacao) {
|
||||
throw new Error('Associação não encontrada');
|
||||
}
|
||||
|
||||
// Desativar ao invés de deletar
|
||||
await ctx.db.patch(args.associacaoId, {
|
||||
ativo: false,
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
@@ -384,15 +735,32 @@ export const listarRegistrosPeriodo = query({
|
||||
const dataFim = new Date(args.dataFim);
|
||||
dataFim.setHours(23, 59, 59, 999);
|
||||
|
||||
const 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 especificado
|
||||
let registrosFiltrados = registros;
|
||||
let registrosFiltrados;
|
||||
|
||||
// Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente)
|
||||
if (args.funcionarioId) {
|
||||
registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId);
|
||||
// Garantir que funcionarioId não é undefined para TypeScript
|
||||
const funcionarioId = args.funcionarioId;
|
||||
|
||||
// Buscar todos os registros do funcionário
|
||||
const todosRegistrosFuncionario = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
|
||||
.collect();
|
||||
|
||||
// Filtrar por período de data
|
||||
registrosFiltrados = todosRegistrosFuncionario.filter((r) => {
|
||||
const dataRegistro = new Date(r.data);
|
||||
return dataRegistro >= new Date(args.dataInicio) && dataRegistro <= dataFim;
|
||||
});
|
||||
} else {
|
||||
// Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário)
|
||||
const registros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||
.collect();
|
||||
|
||||
registrosFiltrados = registros;
|
||||
}
|
||||
|
||||
// Buscar informações dos funcionários
|
||||
@@ -1052,6 +1420,48 @@ export const listarHomologacoes = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Exclui uma homologação (apenas para gestores)
|
||||
*/
|
||||
export const excluirHomologacao = mutation({
|
||||
args: {
|
||||
homologacaoId: v.id('homologacoesPonto'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const homologacao = await ctx.db.get(args.homologacaoId);
|
||||
if (!homologacao) {
|
||||
throw new Error('Homologação não encontrada');
|
||||
}
|
||||
|
||||
// Verificar se é gestor do funcionário
|
||||
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId);
|
||||
if (!isGestor && homologacao.gestorId !== usuario._id) {
|
||||
throw new Error('Você não tem permissão para excluir esta homologação');
|
||||
}
|
||||
|
||||
// Se a homologação estiver vinculada a um registro, remover a referência
|
||||
if (homologacao.registroId) {
|
||||
const registro = await ctx.db.get(homologacao.registroId);
|
||||
if (registro && registro.homologacaoId === args.homologacaoId) {
|
||||
await ctx.db.patch(homologacao.registroId, {
|
||||
homologacaoId: undefined,
|
||||
editadoPorGestor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Excluir homologação
|
||||
await ctx.db.delete(args.homologacaoId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém opções de motivos de atestados/declarações
|
||||
*/
|
||||
|
||||
@@ -535,7 +535,7 @@ export default defineSchema({
|
||||
ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa
|
||||
|
||||
// Campos de Chat e Perfil
|
||||
avatar: v.optional(v.string()), // "avatar-1" até "avatar-15" ou storageId
|
||||
|
||||
fotoPerfil: v.optional(v.id("_storage")),
|
||||
setor: v.optional(v.string()),
|
||||
statusMensagem: v.optional(v.string()), // max 100 chars
|
||||
@@ -761,7 +761,7 @@ export default defineSchema({
|
||||
v.literal("sala_reuniao")
|
||||
),
|
||||
nome: v.optional(v.string()), // nome do grupo/sala
|
||||
avatar: v.optional(v.string()), // avatar do grupo/sala
|
||||
|
||||
participantes: v.array(v.id("usuarios")), // IDs dos participantes
|
||||
administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao)
|
||||
ultimaMensagem: v.optional(v.string()),
|
||||
@@ -1453,6 +1453,25 @@ export default defineSchema({
|
||||
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()), // 0-1 (frontend)
|
||||
scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend)
|
||||
suspeitaSpoofing: v.optional(v.boolean()),
|
||||
motivoSuspeita: v.optional(v.string()),
|
||||
avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação
|
||||
distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS
|
||||
velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro
|
||||
distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro
|
||||
tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro
|
||||
// Informações de Geofencing
|
||||
enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado
|
||||
distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado
|
||||
dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido
|
||||
enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação
|
||||
raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros
|
||||
endereco: v.optional(v.string()),
|
||||
cidade: v.optional(v.string()),
|
||||
estado: v.optional(v.string()),
|
||||
@@ -1487,6 +1506,60 @@ export default defineSchema({
|
||||
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
|
||||
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
|
||||
|
||||
// Endereços de Marcação - Locais permitidos para registro de ponto
|
||||
enderecosMarcacao: defineTable({
|
||||
nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC"
|
||||
descricao: v.optional(v.string()), // Descrição opcional
|
||||
// Coordenadas (obrigatórias)
|
||||
latitude: v.number(),
|
||||
longitude: v.number(),
|
||||
// Endereço físico (para exibição)
|
||||
endereco: v.string(), // Ex: "Rua Exemplo, 123"
|
||||
bairro: v.optional(v.string()), // Bairro do endereço
|
||||
cep: v.optional(v.string()),
|
||||
cidade: v.string(),
|
||||
estado: v.string(),
|
||||
pais: v.optional(v.string()), // Padrão: "Brasil"
|
||||
// Configurações
|
||||
raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m)
|
||||
ativo: v.boolean(),
|
||||
// Tipos de uso
|
||||
tipo: v.union(
|
||||
v.literal("sede"), // Sede principal (para todos)
|
||||
v.literal("home_office"), // Home office específico
|
||||
v.literal("deslocamento"), // Deslocamento temporário
|
||||
v.literal("cliente") // Local de cliente
|
||||
),
|
||||
// Metadados
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
atualizadoPor: v.optional(v.id("usuarios")),
|
||||
atualizadoEm: v.optional(v.number()),
|
||||
})
|
||||
.index("by_ativo", ["ativo"])
|
||||
.index("by_tipo", ["tipo"])
|
||||
.index("by_cidade", ["cidade"]),
|
||||
|
||||
// Associação Funcionário ↔ Endereço de Marcação
|
||||
funcionarioEnderecosMarcacao: defineTable({
|
||||
funcionarioId: v.id("funcionarios"),
|
||||
enderecoMarcacaoId: v.id("enderecosMarcacao"),
|
||||
// Configurações específicas do funcionário
|
||||
raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão
|
||||
// Período de validade (para deslocamentos temporários)
|
||||
dataInicio: v.optional(v.string()), // YYYY-MM-DD
|
||||
dataFim: v.optional(v.string()), // YYYY-MM-DD
|
||||
// Status
|
||||
ativo: v.boolean(),
|
||||
// Metadados
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
})
|
||||
.index("by_funcionario", ["funcionarioId"])
|
||||
.index("by_endereco", ["enderecoMarcacaoId"])
|
||||
.index("by_funcionario_ativo", ["funcionarioId", "ativo"])
|
||||
.index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]),
|
||||
|
||||
configuracaoPonto: defineTable({
|
||||
horarioEntrada: v.string(), // HH:mm
|
||||
horarioSaidaAlmoco: v.string(), // HH:mm
|
||||
@@ -1500,6 +1573,9 @@ export default defineSchema({
|
||||
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
|
||||
// Ajuste de fuso horário (GMT offset em horas)
|
||||
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
||||
// Configurações de geofencing
|
||||
validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização
|
||||
toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros
|
||||
ativo: v.boolean(),
|
||||
atualizadoPor: v.id("usuarios"),
|
||||
atualizadoEm: v.number(),
|
||||
|
||||
@@ -476,11 +476,10 @@ export const alterarRole = mutation({
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualizar perfil do usuário (foto, avatar, setor, status, preferências)
|
||||
* Atualizar perfil do usuário (foto, setor, status, preferências)
|
||||
*/
|
||||
export const atualizarPerfil = mutation({
|
||||
args: {
|
||||
avatar: v.optional(v.string()),
|
||||
fotoPerfil: v.optional(v.id('_storage')),
|
||||
setor: v.optional(v.string()),
|
||||
statusMensagem: v.optional(v.string()),
|
||||
@@ -511,7 +510,6 @@ export const atualizarPerfil = mutation({
|
||||
atualizadoEm: Date.now()
|
||||
};
|
||||
|
||||
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
||||
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
|
||||
if (args.setor !== undefined) updates.setor = args.setor;
|
||||
if (args.statusMensagem !== undefined) updates.statusMensagem = args.statusMensagem;
|
||||
@@ -541,7 +539,6 @@ export const obterPerfil = query({
|
||||
email: v.string(),
|
||||
matricula: v.optional(v.string()),
|
||||
funcionarioId: v.optional(v.id('funcionarios')),
|
||||
avatar: v.optional(v.string()),
|
||||
fotoPerfil: v.optional(v.id('_storage')),
|
||||
fotoPerfilUrl: v.union(v.string(), v.null()),
|
||||
setor: v.optional(v.string()),
|
||||
@@ -582,7 +579,6 @@ export const obterPerfil = query({
|
||||
email: usuarioAtual.email,
|
||||
matricula: matricula || undefined,
|
||||
funcionarioId: usuarioAtual.funcionarioId,
|
||||
avatar: usuarioAtual.avatar,
|
||||
fotoPerfil: usuarioAtual.fotoPerfil,
|
||||
fotoPerfilUrl,
|
||||
setor: usuarioAtual.setor,
|
||||
@@ -595,7 +591,7 @@ export const obterPerfil = query({
|
||||
});
|
||||
|
||||
/**
|
||||
* Listar todos usuários para o chat (com avatar, foto e status)
|
||||
* Listar todos usuários para o chat (com foto e status)
|
||||
*/
|
||||
export const listarParaChat = query({
|
||||
args: {},
|
||||
@@ -605,7 +601,6 @@ export const listarParaChat = query({
|
||||
nome: v.string(),
|
||||
email: v.string(),
|
||||
matricula: v.optional(v.string()),
|
||||
avatar: v.optional(v.string()),
|
||||
fotoPerfil: v.optional(v.id('_storage')),
|
||||
fotoPerfilUrl: v.union(v.string(), v.null()),
|
||||
statusPresenca: v.optional(
|
||||
@@ -656,7 +651,6 @@ export const listarParaChat = query({
|
||||
nome: usuario.nome,
|
||||
email: usuario.email,
|
||||
matricula: matricula || undefined,
|
||||
avatar: usuario.avatar,
|
||||
fotoPerfil: usuario.fotoPerfil,
|
||||
fotoPerfilUrl,
|
||||
statusPresenca: usuario.statusPresenca || 'offline',
|
||||
|
||||
Reference in New Issue
Block a user