Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -261,7 +261,11 @@ export const enviarMensagem = mutation({
arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id('usuarios'))),
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()) // ✅ NOVO: Permite criar notificação para si mesmo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
// Campos para criptografia E2E
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
@@ -281,6 +285,59 @@ export const enviarMensagem = mutation({
});
}
// Validar tamanho da mensagem
const MAX_MENSAGEM_LENGTH = 5000;
if (args.conteudo.length > MAX_MENSAGEM_LENGTH) {
throw new Error(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
}
// Validação de tipo de arquivo (se houver arquivo)
if (args.arquivoTipo) {
const TIPOS_PERMITIDOS = [
// Imagens
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documentos
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv',
// Arquivos
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
];
if (!TIPOS_PERMITIDOS.includes(args.arquivoTipo)) {
throw new Error('Tipo de arquivo não permitido');
}
// Validar tamanho de arquivo (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
if (args.arquivoTamanho && args.arquivoTamanho > MAX_FILE_SIZE) {
throw new Error(`Arquivo muito grande. O tamanho máximo é ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
}
// Validar nome do arquivo (sanitizar)
if (args.arquivoNome) {
const nomeSanitizado = args.arquivoNome.replace(/[^a-zA-Z0-9._-]/g, '_');
if (nomeSanitizado !== args.arquivoNome) {
// Se o nome foi alterado, usar o sanitizado
args.arquivoNome = nomeSanitizado;
}
}
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) throw new Error('Conversa não encontrada');
@@ -289,7 +346,8 @@ export const enviarMensagem = mutation({
}
// Normalizar conteúdo para busca (remover acentos, lowercase)
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
// Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined)
const conteudoBusca = args.criptografado ? undefined : normalizarTextoParaBusca(args.conteudo);
// Verificar se é resposta a outra mensagem
if (args.respostaPara) {
@@ -320,7 +378,11 @@ export const enviarMensagem = mutation({
mencoes: args.mencoes,
respostaPara: args.respostaPara,
enviadaEm: Date.now(),
lidaPor: [] // Inicializar como array vazio
lidaPor: [], // Inicializar como array vazio
// Campos de criptografia E2E
criptografado: args.criptografado ?? false,
iv: args.iv,
keyId: args.keyId
});
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
@@ -343,8 +405,12 @@ export const enviarMensagem = mutation({
}
// Atualizar última mensagem da conversa
// Se a mensagem estiver criptografada, usar placeholder
const ultimaMensagemTexto = args.criptografado
? '🔒 Mensagem criptografada'
: args.conteudo.substring(0, 100);
await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100),
ultimaMensagem: ultimaMensagemTexto,
ultimaMensagemTimestamp: Date.now(),
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
});
@@ -793,7 +859,11 @@ export const marcarNotificacaoLida = mutation({
if (!usuarioAtual) throw new Error('Não autenticado');
const notificacao = await ctx.db.get(args.notificacaoId);
if (!notificacao) throw new Error('Notificação não encontrada');
// Se a notificação não existe (já foi deletada), retornar sucesso silenciosamente
// Isso evita erros quando múltiplas tentativas são feitas ou quando a notificação já foi removida
if (!notificacao) {
return true;
}
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
if (notificacao.usuarioId !== usuarioAtual._id) {
@@ -808,6 +878,11 @@ export const marcarNotificacaoLida = mutation({
}
}
// Se já está marcada como lida, retornar sucesso sem fazer nada
if (notificacao.lida) {
return true;
}
await ctx.db.patch(args.notificacaoId, { lida: true });
return true;
}
@@ -890,7 +965,10 @@ export const limparNotificacoesNaoLidas = mutation({
export const editarMensagem = mutation({
args: {
mensagemId: v.id('mensagens'),
novoConteudo: v.string()
novoConteudo: v.string(),
// Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada)
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
@@ -947,15 +1025,32 @@ export const editarMensagem = mutation({
};
}
// Normalizar conteúdo para busca
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
// Normalizar conteúdo para busca (se não estiver criptografado)
// Se a mensagem original estava criptografada, manter criptografado
const estaCriptografada = mensagem.criptografado ?? false;
const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo);
// Atualizar mensagem
await ctx.db.patch(args.mensagemId, {
// Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar
const updateData: {
conteudo: string;
conteudoBusca?: string;
editadaEm: number;
iv?: string;
keyId?: string;
} = {
conteudo: args.novoConteudo.trim(),
conteudoBusca,
editadaEm: Date.now()
});
};
// Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar
if (estaCriptografada && (args.iv || args.keyId)) {
if (args.iv) updateData.iv = args.iv;
if (args.keyId) updateData.keyId = args.keyId;
}
await ctx.db.patch(args.mensagemId, updateData);
return { sucesso: true };
}
@@ -1148,18 +1243,39 @@ export const adicionarParticipanteSala = mutation({
participantes: novosParticipantes
});
// Verificar se a conversa tem criptografia E2E ativa
const chaveE2E = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
// Criar notificação para o novo participante
const agora = Date.now();
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'adicionado_grupo',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Adicionado a sala de reunião',
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}`,
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`,
lida: false,
criadaEm: Date.now()
criadaEm: agora
});
// Se a conversa tem E2E ativo, notificar sobre a chave
if (chaveE2E) {
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Chave de criptografia E2E disponível',
descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`,
lida: false,
criadaEm: agora
});
}
return { sucesso: true };
}
});
@@ -1764,28 +1880,42 @@ export const listarConversas = query({
export const obterMensagens = query({
args: {
conversaId: v.id('conversas'),
limit: v.optional(v.number())
limit: v.optional(v.number()),
cursor: v.optional(v.id('mensagens')) // ID da última mensagem carregada (para paginação)
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
if (!usuarioAtual) return { mensagens: [], hasMore: false };
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
const conversa = await ctx.db.get(args.conversaId);
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
return [];
return { mensagens: [], hasMore: false };
}
// Buscar mensagens (excluir agendadas)
const mensagens = await ctx.db
const limit = args.limit || 50;
let query = ctx.db
.query('mensagens')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.order('desc')
.take(args.limit || 50);
.order('desc');
// Se há cursor, buscar mensagens anteriores a ele
if (args.cursor) {
const cursorMsg = await ctx.db.get(args.cursor);
if (cursorMsg && cursorMsg.conversaId === args.conversaId) {
// Buscar mensagens anteriores à mensagem do cursor
query = query.filter((q) => q.lt(q.field('_creationTime'), cursorMsg._creationTime));
}
}
// Buscar uma mensagem a mais para verificar se há mais mensagens
const mensagens = await query.take(limit + 1);
const hasMore = mensagens.length > limit;
const mensagensParaRetornar = hasMore ? mensagens.slice(0, limit) : mensagens;
// Filtrar mensagens agendadas e garantir que são da conversa correta
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
const mensagensFiltradas = mensagens.filter((m) => {
const mensagensFiltradas = mensagensParaRetornar.filter((m) => {
// Excluir agendadas
if (m.agendadaPara) return false;
@@ -1842,7 +1972,15 @@ export const obterMensagens = query({
);
// Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
return mensagensEnriquecidas.filter((m) => m !== null).reverse();
const mensagensFinais = mensagensEnriquecidas.filter((m) => m !== null).reverse();
return {
mensagens: mensagensFinais,
hasMore,
nextCursor: hasMore && mensagensFinais.length > 0
? mensagensFinais[mensagensFinais.length - 1]._id
: null
};
}
});
@@ -2480,3 +2618,144 @@ export const limparIndicadoresDigitacao = internalMutation({
return indicadoresAntigos.length;
}
});
// ========== CRIPTOGRAFIA E2E ==========
/**
* Compartilha uma chave de criptografia E2E para uma conversa
* A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método)
*/
export const compartilharChaveCriptografia = mutation({
args: {
conversaId: v.id('conversas'),
chaveCompartilhada: v.string(), // Chave criptografada (base64)
keyId: v.string() // Identificador único da chave
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
throw new Error('Não autenticado');
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
throw new Error('Conversa não encontrada');
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
throw new Error('Você não pertence a esta conversa');
}
// Desativar chaves antigas da conversa
const chavesAntigas = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.collect();
for (const chaveAntiga of chavesAntigas) {
await ctx.db.patch(chaveAntiga._id, { ativo: false });
}
// Criar nova chave
await ctx.db.insert('chavesCriptografia', {
conversaId: args.conversaId,
chaveCompartilhada: args.chaveCompartilhada,
keyId: args.keyId,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
ativo: true
});
// Criar notificações para outros participantes sobre a ativação/regeneração da chave
const agora = Date.now();
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert('notificacoes', {
usuarioId: participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Criptografia E2E atualizada',
descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`,
lida: false,
criadaEm: agora
});
}
}
return { sucesso: true };
}
});
/**
* Obtém a chave de criptografia ativa para uma conversa
*/
export const obterChaveCriptografia = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return null;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return null;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return null;
}
// Buscar chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
if (!chave) {
return null;
}
return {
chaveCompartilhada: chave.chaveCompartilhada,
keyId: chave.keyId,
criadoPor: chave.criadoPor,
criadoEm: chave.criadoEm
};
}
});
/**
* Verifica se uma conversa tem criptografia E2E habilitada
*/
export const verificarCriptografiaE2E = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return false;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return false;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return false;
}
// Verificar se existe chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
return chave !== null;
}
});