feat: enhance chat functionality with new conversation and meeting room features

- Added support for creating and managing group conversations and meeting rooms, allowing users to initiate discussions with multiple participants.
- Implemented a modal for creating new conversations, including options for individual, group, and meeting room types.
- Enhanced the chat list component to filter and display conversations based on type, improving user navigation.
- Introduced admin functionalities for meeting rooms, enabling user management and role assignments within the chat interface.
- Updated backend schema and API to accommodate new conversation types and related operations, ensuring robust data handling.
This commit is contained in:
2025-11-05 07:20:37 -03:00
parent aa3e3470cd
commit 8ca737c62f
9 changed files with 1665 additions and 212 deletions

View File

@@ -48,6 +48,30 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
return usuarioAtual;
}
/**
* Helper function para verificar se usuário é administrador de uma sala de reunião
*/
async function verificarPermissaoAdmin(
ctx: QueryCtx | MutationCtx,
conversaId: Id<"conversas">,
usuarioId: Id<"usuarios">
): Promise<boolean> {
const conversa = await ctx.db.get(conversaId);
if (!conversa) return false;
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") return false;
// Verificar se tem array de administradores
if (!conversa.administradores || conversa.administradores.length === 0) {
// Se não tem administradores definidos, o criador é admin por padrão
return conversa.criadoPor === usuarioId;
}
// Verificar se está na lista de administradores
return conversa.administradores.includes(usuarioId);
}
// ========== MUTATIONS ==========
/**
@@ -55,7 +79,7 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
*/
export const criarConversa = mutation({
args: {
tipo: v.union(v.literal("individual"), v.literal("grupo")),
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()),
@@ -86,27 +110,38 @@ export const criarConversa = mutation({
}
}
// Criar nova conversa
const conversaId = await ctx.db.insert("conversas", {
// Preparar dados da conversa
const dadosConversa: any = {
tipo: args.tipo,
nome: args.nome,
avatar: args.avatar,
participantes: args.participantes,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
});
};
// Se for sala de reunião, adicionar administradores (criador sempre é admin)
if (args.tipo === "sala_reuniao") {
dadosConversa.administradores = [usuarioAtual._id];
}
// Criar nova conversa
const conversaId = await ctx.db.insert("conversas", dadosConversa);
// Criar notificações para outros participantes
if (args.tipo === "grupo") {
if (args.tipo === "grupo" || args.tipo === "sala_reuniao") {
const tipoNotificacao = args.tipo === "sala_reuniao" ? "adicionado_grupo" : "adicionado_grupo";
const tipoTexto = args.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
for (const participanteId of args.participantes) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: "adicionado_grupo",
tipo: tipoNotificacao,
conversaId,
remetenteId: usuarioAtual._id,
titulo: "Adicionado a grupo",
descricao: `Você foi adicionado ao grupo "${
titulo: args.tipo === "sala_reuniao" ? "Adicionado a sala de reunião" : "Adicionado a grupo",
descricao: `Você foi adicionado à ${tipoTexto} "${
args.nome || "Sem nome"
}" por ${usuarioAtual.nome}`,
lida: false,
@@ -120,6 +155,69 @@ export const criarConversa = mutation({
},
});
/**
* Cria uma nova sala de reunião (wrapper específico para facilitar uso)
*/
export const criarSalaReuniao = mutation({
args: {
nome: v.string(),
participantes: v.array(v.id("usuarios")),
avatar: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
// Validar nome
if (!args.nome || args.nome.trim().length === 0) {
throw new Error("O nome da sala de reunião é obrigatório");
}
// Validar participantes
const participantesUnicos = [...new Set(args.participantes)];
if (!participantesUnicos.includes(usuarioAtual._id)) {
participantesUnicos.push(usuarioAtual._id);
}
// Preparar dados da conversa
const dadosConversa: any = {
tipo: "sala_reuniao" as const,
nome: args.nome.trim(),
avatar: args.avatar,
participantes: participantesUnicos,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
administradores: [usuarioAtual._id], // Criador sempre é admin
};
// Criar nova conversa
const conversaId = await ctx.db.insert("conversas", dadosConversa);
// Criar notificações para outros participantes
const tipoNotificacao = "adicionado_grupo";
const tipoTexto = "sala de reunião";
for (const participanteId of participantesUnicos) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: tipoNotificacao,
conversaId,
remetenteId: usuarioAtual._id,
titulo: "Adicionado a sala de reunião",
descricao: `Você foi adicionado à ${tipoTexto} "${
args.nome || "Sem nome"
}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
}
return conversaId;
},
});
/**
* Cria ou busca uma conversa individual com outro usuário
*/
@@ -840,7 +938,10 @@ export const deletarMensagem = mutation({
throw new Error("Mensagem inválida");
}
if (mensagem.remetenteId !== usuarioAtual._id) {
// Verificar se é admin de sala de reunião ou se é o próprio remetente
const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id);
if (mensagem.remetenteId !== usuarioAtual._id && !isAdmin) {
throw new Error("Você só pode deletar suas próprias mensagens");
}
@@ -853,8 +954,370 @@ export const deletarMensagem = mutation({
},
});
/**
* Deleta uma mensagem como administrador (com notificação ao remetente)
*/
export const deletarMensagemComoAdmin = mutation({
args: {
mensagemId: v.id("mensagens"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const mensagem = await ctx.db.get(args.mensagemId);
if (!mensagem) {
return { sucesso: false, erro: "Mensagem não encontrada" };
}
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
const conversa = await ctx.db.get(mensagem.conversaId);
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
return { sucesso: false, erro: "Você não tem acesso a esta mensagem" };
}
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
if (!conversa.participantes.includes(mensagem.remetenteId)) {
return { sucesso: false, erro: "Mensagem inválida" };
}
// Verificar se usuário é administrador da sala
const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem deletar mensagens de outros usuários" };
}
// Não permitir deletar mensagem já deletada
if (mensagem.deletada) {
return { sucesso: false, erro: "Mensagem já foi deletada" };
}
// Deletar mensagem
await ctx.db.patch(args.mensagemId, {
deletada: true,
conteudo: "Mensagem deletada por administrador",
});
// Criar notificação para o remetente original (se não for o próprio admin)
if (mensagem.remetenteId !== usuarioAtual._id) {
const remetente = await ctx.db.get(mensagem.remetenteId);
if (remetente) {
await ctx.db.insert("notificacoes", {
usuarioId: mensagem.remetenteId,
tipo: "nova_mensagem",
conversaId: mensagem.conversaId,
mensagemId: args.mensagemId,
remetenteId: usuarioAtual._id,
titulo: "Mensagem deletada",
descricao: `Sua mensagem foi deletada por um administrador da sala "${conversa.nome || "Sem nome"}"`,
lida: false,
criadaEm: Date.now(),
});
}
}
return { sucesso: true };
},
});
/**
* Adiciona um participante à sala de reunião (apenas administradores)
*/
export const adicionarParticipanteSala = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem adicionar participantes" };
}
// Verificar se participante já está na sala
if (conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário já é participante desta sala" };
}
// Verificar se participante existe
const participante = await ctx.db.get(args.participanteId);
if (!participante) {
return { sucesso: false, erro: "Usuário não encontrado" };
}
// Adicionar participante
const novosParticipantes = [...conversa.participantes, args.participanteId];
await ctx.db.patch(args.conversaId, {
participantes: novosParticipantes,
});
// Criar notificação para o novo participante
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}`,
lida: false,
criadaEm: Date.now(),
});
return { sucesso: true };
},
});
/**
* Remove um participante da sala de reunião (apenas administradores, não pode remover outros admins)
*/
export const removerParticipanteSala = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem remover participantes" };
}
// Verificar se participante está na sala
if (!conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário não é participante desta sala" };
}
// Verificar se está tentando remover outro administrador
const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (isParticipanteAdmin) {
return { sucesso: false, erro: "Não é possível remover outros administradores" };
}
// Remover participante
const novosParticipantes = conversa.participantes.filter((p) => p !== args.participanteId);
await ctx.db.patch(args.conversaId, {
participantes: novosParticipantes,
});
// Criar notificação para o participante removido
const participanteRemovido = await ctx.db.get(args.participanteId);
if (participanteRemovido) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Removido da sala de reunião",
descricao: `Você foi removido da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
return { sucesso: true };
},
});
/**
* Promove um participante a administrador (apenas administradores)
*/
export const promoverAdministrador = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem promover outros administradores" };
}
// Verificar se participante está na sala
if (!conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário não é participante desta sala" };
}
// Verificar se já é administrador
const jaEhAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (jaEhAdmin) {
return { sucesso: false, erro: "Usuário já é administrador desta sala" };
}
// Obter lista atual de administradores ou criar nova
const administradoresAtuais = conversa.administradores || [];
// Se não está na lista, adicionar
if (!administradoresAtuais.includes(args.participanteId)) {
const novosAdministradores = [...administradoresAtuais, args.participanteId];
await ctx.db.patch(args.conversaId, {
administradores: novosAdministradores,
});
// Criar notificação para o novo administrador
const novoAdmin = await ctx.db.get(args.participanteId);
if (novoAdmin) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Promovido a administrador",
descricao: `Você foi promovido a administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
}
return { sucesso: true };
},
});
/**
* Rebaixa um administrador a participante (apenas administradores, não pode rebaixar a si mesmo)
*/
export const rebaixarAdministrador = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem rebaixar outros administradores" };
}
// Não permitir rebaixar a si mesmo
if (args.participanteId === usuarioAtual._id) {
return { sucesso: false, erro: "Você não pode rebaixar a si mesmo" };
}
// Verificar se é administrador
const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (!isParticipanteAdmin) {
return { sucesso: false, erro: "Usuário não é administrador desta sala" };
}
// Não permitir rebaixar o criador da sala
if (conversa.criadoPor === args.participanteId) {
return { sucesso: false, erro: "Não é possível rebaixar o criador da sala" };
}
// Remover da lista de administradores
const administradoresAtuais = conversa.administradores || [];
const novosAdministradores = administradoresAtuais.filter((adminId) => adminId !== args.participanteId);
await ctx.db.patch(args.conversaId, {
administradores: novosAdministradores.length > 0 ? novosAdministradores : undefined,
});
// Criar notificação para o administrador rebaixado
const adminRebaixado = await ctx.db.get(args.participanteId);
if (adminRebaixado) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Rebaixado de administrador",
descricao: `Você foi rebaixado de administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
return { sucesso: true };
},
});
// ========== QUERIES ==========
/**
* Verifica se o usuário atual é administrador de uma sala de reunião
*/
export const verificarSeEhAdmin = query({
args: {
conversaId: v.id("conversas"),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return false;
return await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
},
});
/**
* Lista todas as conversas do usuário logado
* SEGURANÇA: Usuário só vê conversas onde é participante
@@ -947,11 +1410,36 @@ export const listarConversas = query({
).length;
}
// Verificar se usuário é administrador (apenas para salas de reunião)
const isAdmin = conversa.tipo === "sala_reuniao"
? await verificarPermissaoAdmin(ctx, conversa._id, usuarioAtual._id)
: false;
// Enriquecer participantes com fotoPerfilUrl (para grupos e salas)
const participantesInfo = await Promise.all(
participantes
.filter((p) => p !== null)
.map(async (participante) => {
if (!participante) return null;
let fotoPerfilUrl = null;
if (participante.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(participante.fotoPerfil);
}
return {
...participante,
fotoPerfilUrl,
};
})
);
return {
...conversa,
outroUsuario,
participantesInfo: participantes.filter((p) => p !== null),
participantesInfo: participantesInfo.filter((p) => p !== null),
naoLidas,
isAdmin, // Adicionar flag de admin
};
})
);

View File

@@ -619,10 +619,11 @@ export default defineSchema({
// Sistema de Chat
conversas: defineTable({
tipo: v.union(v.literal("individual"), v.literal("grupo")),
nome: v.optional(v.string()), // nome do grupo
avatar: v.optional(v.string()), // avatar do grupo
tipo: v.union(v.literal("individual"), v.literal("grupo"), 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()),
ultimaMensagemTimestamp: v.optional(v.number()),
criadoPor: v.id("usuarios"),