- O módulo da Secretaria Executiva está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão executiva e administrativa.
-
-
-
-
- Em Desenvolvimento
+
+
+ {#each menuItems as categoria}
+
+
+
+
+
+
+ {@html categoria.icon}
+
+
+
+
+ {categoria.categoria}
+
+
{categoria.descricao}
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
Precisa de ajuda?
+
+ Entre em contato com o suporte técnico ou consulte a documentação do sistema para mais informações sobre as funcionalidades da Secretaria Executiva.
+
+
diff --git a/apps/web/src/routes/(dashboard)/secretaria-executiva/gestao-ausencias/+page.svelte b/apps/web/src/routes/(dashboard)/secretaria-executiva/gestao-ausencias/+page.svelte
new file mode 100644
index 0000000..c259570
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/secretaria-executiva/gestao-ausencias/+page.svelte
@@ -0,0 +1,419 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Gestão de Ausências
+
+ Visão geral de todas as solicitações de ausências
+
+
+
+
goto("/secretaria-executiva")}
+ >
+
+
+
+ Voltar
+
+
+
+
+
+
+
+
+
Total
+
{stats.total}
+
Solicitações
+
+
+
+
+
Pendentes
+
{stats.aguardando}
+
Aguardando
+
+
+
+
+
Aprovadas
+
{stats.aprovadas}
+
Deferidas
+
+
+
+
+
Reprovadas
+
{stats.reprovadas}
+
Indeferidas
+
+
+
+
+
+
+
Filtros
+
+
+
+ Status
+
+
+ Todos
+ Aguardando Aprovação
+ Aprovado
+ Reprovado
+
+
+
+
+
+
+
+
+
+
+ Todas as Solicitações ({ausenciasFiltradas.length})
+
+
+ {#if ausenciasFiltradas.length === 0}
+
+
+
+
+
Nenhuma solicitação encontrada com os filtros aplicados.
+
+ {:else}
+
+
+
+
+ Funcionário
+ Time
+ Período
+ Dias
+ Motivo
+ Status
+ Solicitado em
+ Ações
+
+
+
+ {#each ausenciasFiltradas as ausencia}
+
+
+ {ausencia.funcionario?.nome || "N/A"}
+
+
+ {#if ausencia.time}
+
+ {ausencia.time.nome}
+
+ {:else}
+ Sem time
+ {/if}
+
+
+ {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
+ {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
+
+
+ {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
+
+
+ {ausencia.motivo}
+
+
+
+ {getStatusTexto(ausencia.status)}
+
+
+
+ {new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")}
+
+
+ {#if ausencia.status === "aguardando_aprovacao"}
+ selecionarSolicitacao(ausencia._id)}
+ >
+
+
+
+
+ Ver Detalhes
+
+ {:else}
+ selecionarSolicitacao(ausencia._id)}
+ >
+
+
+
+
+ Ver Detalhes
+
+ {/if}
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+
+{#if solicitacaoSelecionada && authStore.usuario}
+ {#await client.query(api.ausencias.obterDetalhes, {
+ solicitacaoId: solicitacaoSelecionada,
+ }) then detalhes}
+ {#if detalhes}
+
+
+
(solicitacaoSelecionada = null)}
+ />
+
+
+
+ {/if}
+ {/await}
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte
index b59a15e..185e28e 100644
--- a/apps/web/src/routes/(dashboard)/ti/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte
@@ -218,6 +218,19 @@
palette: "secondary",
icon: "envelope",
},
+ {
+ title: "Monitoramento de Emails",
+ description:
+ "Acompanhe o status da fila de emails, identifique problemas de envio e processe manualmente quando necessário.",
+ ctaLabel: "Monitorar Emails",
+ href: "/ti/monitoramento-emails",
+ palette: "info",
+ icon: "envelope",
+ highlightBadges: [
+ { label: "Tempo Real", variant: "solid" },
+ { label: "Debug", variant: "outline" },
+ ],
+ },
{
title: "Gerenciar Usuários",
description:
diff --git a/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte b/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte
new file mode 100644
index 0000000..214332c
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte
@@ -0,0 +1,463 @@
+
+
+
+
+
📧 Monitoramento de Emails
+
+ Acompanhe o status da fila de emails e identifique problemas de envio
+
+
+
+
+ {#if estatisticas?.data}
+
+
+
Total
+
{estatisticas.data.total}
+
Emails na fila
+
+
+
+
Pendentes
+
{estatisticas.data.pendentes}
+
Aguardando envio
+
+
+
+
Enviando
+
{estatisticas.data.enviando}
+
Em processamento
+
+
+
+
Enviados
+
{estatisticas.data.enviados}
+
Concluídos
+
+
+
+
Falhas
+
{estatisticas.data.falhas}
+
Com erro
+
+
+ {:else if estatisticas === undefined}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ Atualização automática
+
+
+
+
+
+ {#if processando}
+
+ Processando...
+ {:else}
+ 🔄 Processar Fila Manualmente
+ {/if}
+
+
+
+
+
+
+
+
+
Fila de Emails
+
+ {#if filaEmails?.data && filaEmails.data.length > 0}
+
+
+
+
+ Destinatário
+ Assunto
+ Status
+ Tentativas
+ Criado em
+ Última tentativa
+ Erro
+ Ações
+
+
+
+ {#each filaEmails.data as email}
+
+
+ {email.destinatario}
+
+
+
+ {email.assunto}
+
+
+
+
+ {getStatusLabel(email.status)}
+
+
+ {email.tentativas || 0}
+ {formatarData(email.criadoEm)}
+
+ {formatarData(email.ultimaTentativa)}
+
+
+ {#if email.erroDetalhes}
+ abrirModalDetalhes(email)}
+ >
+
+
+
+ Ver erro
+
+ {:else}
+ -
+ {/if}
+
+
+ abrirModalDetalhes(email)}
+ title="Ver detalhes"
+ >
+
+
+
+
+
+
+
+ {/each}
+
+
+
+ {:else if filaEmails?.data !== undefined}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
+
+
+
🔍 Troubleshooting
+
+
+ Emails pendentes não estão sendo enviados?
+
+
+ Verifique se a configuração SMTP está ativa em Configurações de Email
+ Confirme se o cron job está rodando (verifique logs do Convex)
+ Clique em "Processar Fila Manualmente" para forçar o processamento
+
+
+
+ Emails com status "Falha"?
+
+
+ Verifique as credenciais SMTP em Configurações de Email
+ Confirme se o servidor SMTP está acessível
+ Verifique os logs do Convex para detalhes do erro
+ Teste a conexão SMTP na página de configurações
+
+
+
+
+
+
+
+{#if modalDetalhesAberto && emailSelecionado}
+
+
+
+
+
+
+
+ Detalhes do Email
+
+
+
+
+
+
+
+
+
+
+
+ Status:
+
+ {getStatusLabel(emailSelecionado.status)}
+
+
+
+
+
+
+
+
+
+ Assunto
+
+
+ {emailSelecionado.assunto}
+
+
+
+
+
+
+
+ {#if emailSelecionado.ultimaTentativa}
+
+
+ Última tentativa
+
+
+
+ {/if}
+
+ {#if emailSelecionado.enviadoEm}
+
+ {/if}
+
+ {#if emailSelecionado.agendadaPara}
+
+ {/if}
+
+
+
+ {#if emailSelecionado.erroDetalhes}
+
+
+ Detalhes do Erro
+
+
+
{emailSelecionado.erroDetalhes}
+
+
+ {/if}
+
+
+ {#if emailSelecionado.corpo}
+
+
+ Preview do Email
+
+
+ {@html emailSelecionado.corpo}
+
+
+ {/if}
+
+
+
+
+ Fechar
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
index e2ffd97..ff7e182 100644
--- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
@@ -727,12 +727,12 @@
"enviando",
"Criando/buscando conversa...",
);
- const conversaResult = await client.mutation(
+ const conversaId = await client.mutation(
api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as Id<"usuarios"> },
);
- if (conversaResult.conversaId) {
+ if (conversaId) {
const mensagem = usarTemplate
? templateSelecionado?.corpo || ""
: mensagemPersonalizada;
@@ -748,7 +748,7 @@
resultadoChat = await client.mutation(
api.chat.agendarMensagem,
{
- conversaId: conversaResult.conversaId,
+ conversaId: conversaId,
conteudo: mensagem,
agendadaPara: agendadaPara,
},
@@ -773,7 +773,7 @@
"Enviando mensagem...",
);
resultadoChat = await client.mutation(api.chat.enviarMensagem, {
- conversaId: conversaResult.conversaId,
+ conversaId: conversaId,
conteudo: mensagem,
tipo: "texto",
permitirNotificacaoParaSiMesmo: true,
@@ -814,7 +814,7 @@
if (usarTemplate && templateId) {
const template = templateSelecionado;
if (template) {
- resultadoEmail = await client.mutation(
+ const emailId = await client.action(
api.email.enviarEmailComTemplate,
{
destinatario: destinatario.email,
@@ -824,11 +824,11 @@
nome: destinatario.nome,
matricula: destinatario.matricula,
},
- enviadoPorId: authStore.usuario._id as Id<"usuarios">,
+ enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
- if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
+ if (emailId) {
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -840,7 +840,7 @@
destinatario.nome,
"fila",
`Email agendado para ${dataFormatada}`,
- resultadoEmail.emailId,
+ emailId,
);
} else {
adicionarLog(
@@ -848,7 +848,7 @@
destinatario.nome,
"fila",
"Email enfileirado para envio",
- resultadoEmail.emailId,
+ emailId,
);
}
} else {
@@ -868,18 +868,19 @@
);
}
} else {
- resultadoEmail = await client.mutation(
+ const emailId = await client.mutation(
api.email.enfileirarEmail,
{
destinatario: destinatario.email,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
- enviadoPorId: authStore.usuario._id as Id<"usuarios">,
+ enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
- if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
+ if (emailId) {
+ resultadoEmail = { sucesso: true, emailId };
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -891,7 +892,7 @@
destinatario.nome,
"fila",
`Email agendado para ${dataFormatada}`,
- resultadoEmail.emailId,
+ emailId,
);
} else {
adicionarLog(
@@ -899,7 +900,7 @@
destinatario.nome,
"fila",
"Email enfileirado para envio",
- resultadoEmail.emailId,
+ emailId,
);
}
} else {
@@ -981,12 +982,12 @@
"enviando",
"Processando...",
);
- const conversaResult = await client.mutation(
+ const conversaId = await client.mutation(
api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as Id<"usuarios"> },
);
- if (conversaResult.conversaId) {
+ if (conversaId) {
// Para templates, usar corpo direto (o backend já faz substituição via email)
// Para mensagem personalizada, usar diretamente
const mensagem = usarTemplate
@@ -995,7 +996,7 @@
if (agendadaPara) {
await client.mutation(api.chat.agendarMensagem, {
- conversaId: conversaResult.conversaId,
+ conversaId: conversaId,
conteudo: mensagem,
agendadaPara: agendadaPara,
});
@@ -1012,7 +1013,7 @@
);
} else {
await client.mutation(api.chat.enviarMensagem, {
- conversaId: conversaResult.conversaId,
+ conversaId: conversaId,
conteudo: mensagem,
tipo: "texto",
permitirNotificacaoParaSiMesmo: true,
@@ -1064,7 +1065,7 @@
if (usarTemplate && templateId) {
const template = templateSelecionado;
if (template) {
- const resultadoEmail = await client.mutation(
+ const emailId = await client.action(
api.email.enviarEmailComTemplate,
{
destinatario: destinatario.email,
@@ -1074,11 +1075,11 @@
nome: destinatario.nome,
matricula: destinatario.matricula || "",
},
- enviadoPorId: authStore.usuario._id as Id<"usuarios">,
+ enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
- if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
+ if (emailId) {
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -1090,7 +1091,7 @@
destinatario.nome,
"fila",
`Agendado para ${dataFormatada}`,
- resultadoEmail.emailId,
+ emailId,
);
} else {
adicionarLog(
@@ -1098,7 +1099,7 @@
destinatario.nome,
"fila",
"Enfileirado para envio",
- resultadoEmail.emailId,
+ emailId,
);
}
sucessosEmail++;
@@ -1121,18 +1122,19 @@
falhasEmail++;
}
} else {
- const resultadoEmail = await client.mutation(
+ const emailId = await client.mutation(
api.email.enfileirarEmail,
{
destinatario: destinatario.email,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
- enviadoPorId: authStore.usuario._id as Id<"usuarios">,
+ enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
- if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
+ if (emailId) {
+ resultadoEmail = { sucesso: true, emailId };
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -1144,7 +1146,7 @@
destinatario.nome,
"fila",
`Agendado para ${dataFormatada}`,
- resultadoEmail.emailId,
+ emailId,
);
} else {
adicionarLog(
@@ -1152,7 +1154,7 @@
destinatario.nome,
"fila",
"Enfileirado para envio",
- resultadoEmail.emailId,
+ emailId,
);
}
sucessosEmail++;
diff --git a/apps/web/static/sw.js b/apps/web/static/sw.js
new file mode 100644
index 0000000..d07439d
--- /dev/null
+++ b/apps/web/static/sw.js
@@ -0,0 +1,70 @@
+// Service Worker para Push Notifications
+self.addEventListener("install", (event) => {
+ console.log("Service Worker instalado");
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ console.log("Service Worker ativado");
+ event.waitUntil(self.clients.claim());
+});
+
+// Escutar push notifications
+self.addEventListener("push", (event) => {
+ console.log("Push notification recebida:", event);
+
+ let data = {};
+ if (event.data) {
+ try {
+ data = event.data.json();
+ } catch (e) {
+ data = { title: "Nova notificação", body: event.data.text() };
+ }
+ }
+
+ const title = data.title || "SGSE";
+ const options = {
+ body: data.body || "Você tem uma nova notificação",
+ icon: data.icon || "/favicon.png",
+ badge: data.badge || "/favicon.png",
+ tag: data.tag || "default",
+ requireInteraction: data.requireInteraction || false,
+ data: data.data || {},
+ };
+
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+// Escutar cliques em notificações
+self.addEventListener("notificationclick", (event) => {
+ console.log("Notificação clicada:", event);
+
+ event.notification.close();
+
+ // Abrir/focar na aplicação
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: "window", includeUncontrolled: true })
+ .then((clientList) => {
+ // Se há um cliente aberto, focar nele
+ for (const client of clientList) {
+ if (client.url && "focus" in client) {
+ return client.focus();
+ }
+ }
+
+ // Se não há cliente aberto, abrir nova janela
+ if (self.clients.openWindow) {
+ const data = event.notification.data;
+ let url = "/";
+
+ if (data?.conversaId) {
+ url = `/chat?conversa=${data.conversaId}`;
+ }
+
+ return self.clients.openWindow(url);
+ }
+ })
+ );
+});
+
diff --git a/configurar-variaveis-ambiente.md b/configurar-variaveis-ambiente.md
new file mode 100644
index 0000000..c31c8d4
--- /dev/null
+++ b/configurar-variaveis-ambiente.md
@@ -0,0 +1,77 @@
+# ⚙️ Configuração de Variáveis de Ambiente
+
+## Passo 1: Configurar VAPID Keys no Convex
+
+### Opção A: Via Dashboard Convex (Recomendado)
+
+1. Acesse https://dashboard.convex.dev
+2. Selecione seu projeto SGSE
+3. Vá em **Settings** > **Environment Variables**
+4. Clique em **Add Variable** e adicione uma por vez:
+
+**Variável 1:**
+- **Name**: `VAPID_PUBLIC_KEY`
+- **Value**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+
+**Variável 2:**
+- **Name**: `VAPID_PRIVATE_KEY`
+- **Value**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4`
+
+**Variável 3:**
+- **Name**: `FRONTEND_URL`
+- **Value**: `http://localhost:5173` (ou sua URL de produção)
+
+### Opção B: Via CLI Convex
+
+Execute os seguintes comandos no diretório `packages/backend`:
+
+```bash
+cd packages/backend
+
+# Configurar VAPID Public Key
+npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+
+# Configurar VAPID Private Key
+npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+
+# Configurar URL do Frontend
+npx convex env set FRONTEND_URL "http://localhost:5173"
+```
+
+## Passo 2: Configurar VAPID Public Key no Frontend
+
+Crie um arquivo `.env` no diretório `apps/web/` com o seguinte conteúdo:
+
+```env
+VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks
+```
+
+**Importante**:
+- Após criar/modificar o `.env`, reinicie o servidor de desenvolvimento do frontend
+- O arquivo `.env` já está no `.gitignore`, então não será commitado
+
+## Passo 3: Verificar Configuração
+
+### Verificar no Convex Dashboard:
+1. Acesse https://dashboard.convex.dev
+2. Vá em **Settings** > **Environment Variables**
+3. Verifique se as 3 variáveis estão listadas
+
+### Verificar no Frontend:
+1. Execute o frontend: `cd apps/web && bun run dev`
+2. Abra o DevTools (F12)
+3. Vá em **Console** e execute: `console.log(import.meta.env.VITE_VAPID_PUBLIC_KEY)`
+4. Deve exibir a chave pública (ou `undefined` se não estiver configurado)
+
+## Chaves Geradas
+
+As seguintes VAPID keys foram geradas para este projeto:
+
+- **Public Key**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks`
+- **Private Key**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4`
+
+⚠️ **IMPORTANTE**:
+- A Private Key deve ser mantida em segredo
+- Nunca exponha a Private Key no frontend ou em código público
+- Se suspeitar de comprometimento, regenere as keys com `bunx web-push generate-vapid-keys`
+
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index a0b2e5f..4f3f6f4 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -9,8 +9,12 @@
*/
import type * as actions_email from "../actions/email.js";
+import type * as actions_linkPreview from "../actions/linkPreview.js";
+import type * as actions_pushNotifications from "../actions/pushNotifications.js";
import type * as actions_smtp from "../actions/smtp.js";
+import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js";
import type * as atestadosLicencas from "../atestadosLicencas.js";
+import type * as ausencias from "../ausencias.js";
import type * as autenticacao from "../autenticacao.js";
import type * as auth_utils from "../auth/utils.js";
import type * as chat from "../chat.js";
@@ -30,6 +34,8 @@ import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js";
import type * as monitoramento from "../monitoramento.js";
import type * as permissoesAcoes from "../permissoesAcoes.js";
+import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
+import type * as pushNotifications from "../pushNotifications.js";
import type * as roles from "../roles.js";
import type * as saldoFerias from "../saldoFerias.js";
import type * as seed from "../seed.js";
@@ -58,8 +64,12 @@ import type {
*/
declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email;
+ "actions/linkPreview": typeof actions_linkPreview;
+ "actions/pushNotifications": typeof actions_pushNotifications;
"actions/smtp": typeof actions_smtp;
+ "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto;
atestadosLicencas: typeof atestadosLicencas;
+ ausencias: typeof ausencias;
autenticacao: typeof autenticacao;
"auth/utils": typeof auth_utils;
chat: typeof chat;
@@ -79,6 +89,8 @@ declare const fullApi: ApiFromModules<{
logsLogin: typeof logsLogin;
monitoramento: typeof monitoramento;
permissoesAcoes: typeof permissoesAcoes;
+ preferenciasNotificacao: typeof preferenciasNotificacao;
+ pushNotifications: typeof pushNotifications;
roles: typeof roles;
saldoFerias: typeof saldoFerias;
seed: typeof seed;
diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts
index 3ae0249..e97ff59 100644
--- a/packages/backend/convex/actions/email.ts
+++ b/packages/backend/convex/actions/email.ts
@@ -3,6 +3,7 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
+import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
export const enviar = action({
args: {
@@ -13,9 +14,10 @@ export const enviar = action({
"use node";
const nodemailer = await import("nodemailer");
+ let email;
try {
// Buscar email da fila
- const email = await ctx.runQuery(internal.email.getEmailById, {
+ email = await ctx.runQuery(internal.email.getEmailById, {
emailId: args.emailId,
});
@@ -23,21 +25,47 @@ export const enviar = action({
return { sucesso: false, erro: "Email não encontrado" };
}
- // Buscar configuração SMTP ativa com senha descriptografada
- const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {});
-
- if (!config) {
+ // Buscar configuração SMTP ativa
+ const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
+
+ if (!configRaw) {
+ console.error("❌ Configuração SMTP não encontrada ou inativa para email:", email.destinatario);
return {
sucesso: false,
- erro: "Configuração de email não encontrada ou inativa",
+ erro: "Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.",
+ };
+ }
+
+ console.log("📧 Tentando enviar email:", {
+ para: email.destinatario,
+ assunto: email.assunto,
+ servidor: configRaw.servidor,
+ porta: configRaw.porta,
+ });
+
+ // Descriptografar senha usando função compatível com Node.js
+ let senhaDescriptografada: string;
+ try {
+ senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
+ } catch (decryptError) {
+ const decryptErrorMessage = decryptError instanceof Error ? decryptError.message : String(decryptError);
+ console.error("Erro ao descriptografar senha SMTP:", decryptErrorMessage);
+ return {
+ sucesso: false,
+ erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
};
}
+ const config = {
+ ...configRaw,
+ senha: senhaDescriptografada,
+ };
+
+ // Config já foi validado acima
+
+ // Avisar mas não bloquear se não foi testado
if (!config.testadoEm) {
- return {
- sucesso: false,
- erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!",
- };
+ console.warn("⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...");
}
// Marcar como enviando
@@ -45,25 +73,65 @@ export const enviar = action({
emailId: args.emailId,
});
- // Criar transporter do nodemailer
- const transporter = nodemailer.createTransport({
+ // Criar transporter do nodemailer com configuração melhorada
+ const transporterOptions: {
+ host: string;
+ port: number;
+ secure: boolean;
+ requireTLS?: boolean;
+ auth: {
+ user: string;
+ pass: string;
+ };
+ tls?: {
+ rejectUnauthorized: boolean;
+ ciphers?: string;
+ };
+ connectionTimeout: number;
+ greetingTimeout: number;
+ socketTimeout: number;
+ pool?: boolean;
+ maxConnections?: number;
+ maxMessages?: number;
+ } = {
host: config.servidor,
port: config.porta,
secure: config.usarSSL,
- requireTLS: config.usarTLS,
auth: {
user: config.usuario,
pass: config.senha, // Senha já descriptografada
},
- tls: {
- // Permitir certificados autoassinados apenas se necessário
+ connectionTimeout: 15000, // 15 segundos
+ greetingTimeout: 15000,
+ socketTimeout: 15000,
+ pool: true, // Usar pool de conexões
+ maxConnections: 5,
+ maxMessages: 100,
+ };
+
+ // Adicionar TLS apenas se necessário
+ if (config.usarTLS) {
+ transporterOptions.requireTLS = true;
+ transporterOptions.tls = {
+ rejectUnauthorized: false, // Permitir certificados autoassinados
+ };
+ } else if (config.usarSSL) {
+ transporterOptions.tls = {
rejectUnauthorized: false,
- ciphers: "SSLv3",
- },
- connectionTimeout: 10000, // 10 segundos
- greetingTimeout: 10000,
- socketTimeout: 10000,
- });
+ };
+ }
+
+ const transporter = nodemailer.createTransport(transporterOptions);
+
+ // Verificar conexão antes de enviar
+ try {
+ await transporter.verify();
+ console.log("✅ Conexão SMTP verificada com sucesso");
+ } catch (verifyError) {
+ const verifyErrorMessage = verifyError instanceof Error ? verifyError.message : String(verifyError);
+ console.warn("⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", verifyErrorMessage);
+ // Não bloquear envio por falha na verificação, apenas avisar
+ }
// Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -71,13 +139,28 @@ export const enviar = action({
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
}
+ // Criar versão texto do HTML (remover tags e decodificar entidades básicas)
+ const textoPlano = email.corpo
+ .replace(/<[^>]*>/g, "") // Remover tags HTML
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .trim();
+
// Enviar email
const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
- text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML
+ text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
+ headers: {
+ "X-Mailer": "SGSE-Sistema",
+ "X-Priority": "3",
+ },
});
interface MessageInfo {
@@ -102,12 +185,23 @@ export const enviar = action({
return { sucesso: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- console.error("❌ Erro ao enviar email:", errorMessage);
+ const errorStack = error instanceof Error ? error.stack : undefined;
+
+ console.error("❌ Erro ao enviar email:", {
+ emailId: args.emailId,
+ destinatario: email?.destinatario,
+ erro: errorMessage,
+ stack: errorStack,
+ });
+
+ // Marcar como falha com detalhes completos
+ const erroCompleto = errorStack
+ ? `${errorMessage}\n\nStack: ${errorStack}`
+ : errorMessage;
- // Marcar como falha
await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId,
- erro: errorMessage,
+ erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
});
return { sucesso: false, erro: errorMessage };
diff --git a/packages/backend/convex/actions/linkPreview.ts b/packages/backend/convex/actions/linkPreview.ts
new file mode 100644
index 0000000..8a77c53
--- /dev/null
+++ b/packages/backend/convex/actions/linkPreview.ts
@@ -0,0 +1,138 @@
+"use node";
+
+import { action } from "../_generated/server";
+import { v } from "convex/values";
+import { internal } from "../_generated/api";
+
+/**
+ * Extrair preview de link (metadados Open Graph) - função auxiliar
+ */
+async function extrairPreviewLinkHelper(url: string) {
+ try {
+ // Validar URL
+ let urlObj: URL;
+ try {
+ urlObj = new URL(url);
+ } catch {
+ return null;
+ }
+
+ // Buscar HTML da página
+ const response = await fetch(url, {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (compatible; SGSE-Bot/1.0)",
+ },
+ signal: AbortSignal.timeout(5000), // Timeout de 5 segundos
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const html = await response.text();
+
+ // Extrair metadados Open Graph e Twitter Cards
+ const metadata: {
+ titulo?: string;
+ descricao?: string;
+ imagem?: string;
+ site?: string;
+ } = {};
+
+ // Título (og:title ou twitter:title ou
)
+ const ogTitleMatch = html.match(/ ([^<]+)<\/title>/i);
+
+ metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
+ if (metadata.titulo) {
+ metadata.titulo = metadata.titulo.trim().substring(0, 200);
+ }
+
+ // Descrição (og:description ou twitter:description ou meta description)
+ const ogDescMatch = html.match(/ {
+ // Extrair preview
+ const preview = await extrairPreviewLinkHelper(args.url);
+
+ if (preview) {
+ // Atualizar mensagem com preview
+ await ctx.runMutation(internal.chat.atualizarLinkPreview, {
+ mensagemId: args.mensagemId,
+ linkPreview: preview,
+ });
+ }
+
+ return null;
+ },
+});
+
+/**
+ * Extrair preview de link (metadados Open Graph) - versão pública
+ */
+export const extrairPreviewLink = action({
+ args: {
+ url: v.string(),
+ },
+ returns: v.union(
+ v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ return await extrairPreviewLinkHelper(args.url);
+ },
+});
diff --git a/packages/backend/convex/actions/pushNotifications.ts b/packages/backend/convex/actions/pushNotifications.ts
new file mode 100644
index 0000000..7071d03
--- /dev/null
+++ b/packages/backend/convex/actions/pushNotifications.ts
@@ -0,0 +1,103 @@
+"use node";
+
+import { action } from "../_generated/server";
+import { v } from "convex/values";
+import { internal } from "../_generated/api";
+
+/**
+ * Enviar push notification usando Web Push API
+ */
+export const enviarPush = action({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ titulo: v.string(),
+ corpo: v.string(),
+ data: v.optional(
+ v.object({
+ conversaId: v.optional(v.string()),
+ mensagemId: v.optional(v.string()),
+ tipo: v.optional(v.string()),
+ })
+ ),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args) => {
+ try {
+ // Buscar subscription
+ const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
+ subscriptionId: args.subscriptionId,
+ });
+
+ if (!subscription || !subscription.ativo) {
+ return { sucesso: false, erro: "Subscription não encontrada ou inativa" };
+ }
+
+ // Web Push requer VAPID keys (deve estar em variáveis de ambiente)
+ // Por enquanto, vamos usar uma implementação básica
+ // Em produção, você precisará configurar VAPID keys
+
+ const webpushModule = await import("web-push");
+ // web-push pode exportar como default ou named exports
+ // Usar a declaração de tipo do módulo web-push
+ interface WebPushType {
+ setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
+ sendNotification: (
+ subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
+ payload: string | Buffer
+ ) => Promise;
+ }
+ const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
+
+ // VAPID keys devem vir de variáveis de ambiente
+ const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
+ const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
+
+ if (!publicKey || !privateKey) {
+ console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
+ // Em desenvolvimento, podemos retornar sucesso sem enviar
+ return { sucesso: true };
+ }
+
+ webpush.setVapidDetails("mailto:suporte@sgse.app", publicKey, privateKey);
+
+ // Preparar payload da notificação
+ const payload = JSON.stringify({
+ title: args.titulo,
+ body: args.corpo,
+ icon: "/favicon.png",
+ badge: "/favicon.png",
+ data: args.data || {},
+ tag: args.data?.conversaId || "default",
+ requireInteraction: args.data?.tipo === "mencao", // Menções requerem interação
+ });
+
+ // Enviar push notification
+ await webpush.sendNotification(
+ {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: subscription.keys.p256dh,
+ auth: subscription.keys.auth,
+ },
+ },
+ payload
+ );
+
+ console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
+ return { sucesso: true };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error("❌ Erro ao enviar push notification:", errorMessage);
+
+ // Se subscription inválida, marcar como inativa
+ if (errorMessage.includes("410") || errorMessage.includes("expired")) {
+ await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
+ subscriptionId: args.subscriptionId,
+ });
+ }
+
+ return { sucesso: false, erro: errorMessage };
+ }
+ },
+});
+
diff --git a/packages/backend/convex/actions/smtp.ts b/packages/backend/convex/actions/smtp.ts
index c68285a..1bfb3ef 100644
--- a/packages/backend/convex/actions/smtp.ts
+++ b/packages/backend/convex/actions/smtp.ts
@@ -3,6 +3,9 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
+// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
+import nodemailer from "nodemailer";
+
export const testarConexao = action({
args: {
servidor: v.string(),
@@ -17,8 +20,6 @@ export const testarConexao = action({
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
- "use node";
- const nodemailer = await import("nodemailer");
try {
// Validações básicas
diff --git a/packages/backend/convex/actions/utils/nodeCrypto.ts b/packages/backend/convex/actions/utils/nodeCrypto.ts
new file mode 100644
index 0000000..34c9551
--- /dev/null
+++ b/packages/backend/convex/actions/utils/nodeCrypto.ts
@@ -0,0 +1,74 @@
+"use node";
+
+/**
+ * Utilitários de criptografia compatíveis com Node.js
+ * Para uso em actions que rodam em ambiente Node.js
+ */
+
+/**
+ * Descriptografa senha SMTP usando Web Crypto API compatível com Node.js
+ * Esta versão funciona em ambiente Node.js (actions)
+ */
+export async function decryptSMTPPasswordNode(encryptedPassword: string): Promise {
+ try {
+ // Em Node.js, crypto.subtle está disponível globalmente
+ const crypto = globalThis.crypto;
+
+ if (!crypto || !crypto.subtle) {
+ throw new Error("Web Crypto API não disponível");
+ }
+
+ // Chave base - mesma usada em auth/utils.ts
+ const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
+
+ // Importar chave material
+ const keyMaterialKey = await crypto.subtle.importKey(
+ "raw",
+ keyMaterial,
+ { name: "PBKDF2" },
+ false,
+ ["deriveBits", "deriveKey"]
+ );
+
+ // Derivar chave de 256 bits usando PBKDF2
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: new TextEncoder().encode("SGSE-SALT"),
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ keyMaterialKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ );
+
+ // Decodificar base64 manualmente (compatível com Node.js e browser)
+ const binaryString = atob(encryptedPassword);
+ const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
+
+ // Extrair IV e dados criptografados
+ const iv = combined.slice(0, 12);
+ const encrypted = combined.slice(12);
+
+ // Descriptografar
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: iv,
+ },
+ key,
+ encrypted
+ );
+
+ // Converter para string
+ const decoder = new TextDecoder();
+ return decoder.decode(decrypted);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error("Erro ao descriptografar senha SMTP (Node.js):", errorMessage);
+ throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
+ }
+}
+
diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts
new file mode 100644
index 0000000..c48a5d0
--- /dev/null
+++ b/packages/backend/convex/ausencias.ts
@@ -0,0 +1,666 @@
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import type { QueryCtx, MutationCtx } from "./_generated/server";
+import { internal, api } from "./_generated/api";
+import { Id, Doc } from "./_generated/dataModel";
+
+// Query: Listar todas as solicitações (para RH)
+export const listarTodas = query({
+ args: {},
+ handler: async (ctx) => {
+ const solicitacoes = await ctx.db.query("solicitacoesAusencias").collect();
+
+ const solicitacoesComDetalhes = await Promise.all(
+ solicitacoes.map(async (s) => {
+ const funcionario = await ctx.db.get(s.funcionarioId);
+
+ // Buscar time do funcionário
+ const membroTime = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", s.funcionarioId)
+ )
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .first();
+
+ let time = null;
+ if (membroTime) {
+ time = await ctx.db.get(membroTime.timeId);
+ }
+
+ return {
+ ...s,
+ funcionario,
+ time,
+ };
+ })
+ );
+
+ return solicitacoesComDetalhes.sort(
+ (a, b) => b.criadoEm - a.criadoEm
+ );
+ },
+});
+
+// Query: Listar solicitações do funcionário
+export const listarMinhasSolicitacoes = query({
+ args: { funcionarioId: v.id("funcionarios") },
+ handler: async (ctx, args) => {
+ const solicitacoes = await ctx.db
+ .query("solicitacoesAusencias")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", args.funcionarioId)
+ )
+ .order("desc")
+ .collect();
+
+ // Enriquecer com dados do funcionário e time
+ const solicitacoesComDetalhes = await Promise.all(
+ solicitacoes.map(async (s) => {
+ const funcionario = await ctx.db.get(s.funcionarioId);
+
+ // Buscar time do funcionário
+ const membroTime = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", s.funcionarioId)
+ )
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .first();
+
+ let time = null;
+ if (membroTime) {
+ time = await ctx.db.get(membroTime.timeId);
+ }
+
+ return {
+ ...s,
+ funcionario,
+ time,
+ };
+ })
+ );
+
+ return solicitacoesComDetalhes;
+ },
+});
+
+// Query: Listar solicitações dos subordinados (para gestores)
+export const listarSolicitacoesSubordinados = query({
+ args: { gestorId: v.id("usuarios") },
+ handler: async (ctx, args) => {
+ // Buscar times onde o usuário é gestor
+ const timesGestor = await ctx.db
+ .query("times")
+ .withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .collect();
+
+ const solicitacoes: Array & {
+ funcionario: Doc<"funcionarios"> | null;
+ time: Doc<"times"> | null;
+ }> = [];
+
+ for (const time of timesGestor) {
+ // Buscar membros do time
+ const membros = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_time_and_ativo", (q) =>
+ q.eq("timeId", time._id).eq("ativo", true)
+ )
+ .collect();
+
+ // Buscar solicitações de cada membro
+ for (const membro of membros) {
+ const solic = await ctx.db
+ .query("solicitacoesAusencias")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", membro.funcionarioId)
+ )
+ .collect();
+
+ // Adicionar info do funcionário
+ for (const s of solic) {
+ const funcionario = await ctx.db.get(s.funcionarioId);
+ solicitacoes.push({
+ ...s,
+ funcionario,
+ time,
+ });
+ }
+ }
+ }
+
+ return solicitacoes.sort((a, b) => b.criadoEm - a.criadoEm);
+ },
+});
+
+// Query: Obter detalhes completos de uma solicitação
+export const obterDetalhes = query({
+ args: { solicitacaoId: v.id("solicitacoesAusencias") },
+ handler: async (ctx, args) => {
+ const solicitacao = await ctx.db.get(args.solicitacaoId);
+ if (!solicitacao) return null;
+
+ const funcionario = await ctx.db.get(solicitacao.funcionarioId);
+ let gestor = null;
+ if (solicitacao.gestorId) {
+ gestor = await ctx.db.get(solicitacao.gestorId);
+ }
+
+ // Buscar time do funcionário
+ const membroTime = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", solicitacao.funcionarioId)
+ )
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .first();
+
+ let time = null;
+ if (membroTime) {
+ time = await ctx.db.get(membroTime.timeId);
+ }
+
+ return {
+ ...solicitacao,
+ funcionario,
+ gestor,
+ time,
+ };
+ },
+});
+
+// Query: Obter notificações não lidas
+export const obterNotificacoesNaoLidas = query({
+ args: { usuarioId: v.id("usuarios") },
+ handler: async (ctx, args) => {
+ const notificacoes = await ctx.db
+ .query("notificacoesAusencias")
+ .withIndex("by_destinatario_and_lida", (q) =>
+ q.eq("destinatarioId", args.usuarioId).eq("lida", false)
+ )
+ .order("desc")
+ .collect();
+
+ return notificacoes;
+ },
+});
+
+// Query: Contar solicitações pendentes para gestor
+export const contarPendentesGestor = query({
+ args: { gestorId: v.id("usuarios") },
+ handler: async (ctx, args) => {
+ // Buscar times onde o usuário é gestor
+ const timesGestor = await ctx.db
+ .query("times")
+ .withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .collect();
+
+ let totalPendentes = 0;
+
+ for (const time of timesGestor) {
+ // Buscar membros do time
+ const membros = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_time_and_ativo", (q) =>
+ q.eq("timeId", time._id).eq("ativo", true)
+ )
+ .collect();
+
+ // Contar solicitações pendentes de cada membro
+ for (const membro of membros) {
+ const pendentes = await ctx.db
+ .query("solicitacoesAusencias")
+ .withIndex("by_funcionario_and_status", (q) =>
+ q
+ .eq("funcionarioId", membro.funcionarioId)
+ .eq("status", "aguardando_aprovacao")
+ )
+ .collect();
+ totalPendentes += pendentes.length;
+ }
+ }
+
+ return totalPendentes;
+ },
+});
+
+// Helper: Verificar se há sobreposição de datas
+function verificarSobreposicao(
+ inicio1: string,
+ fim1: string,
+ inicio2: string,
+ fim2: string
+): boolean {
+ const d1Inicio = new Date(inicio1);
+ const d1Fim = new Date(fim1);
+ const d2Inicio = new Date(inicio2);
+ const d2Fim = new Date(fim2);
+
+ return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
+}
+
+// Helper: Encontrar gestor do funcionário
+async function encontrarGestorDoFuncionario(
+ ctx: QueryCtx | MutationCtx,
+ funcionarioId: Id<"funcionarios">
+): Promise | null> {
+ const membroTime = await ctx.db
+ .query("timesMembros")
+ .withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionarioId))
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .first();
+
+ if (!membroTime) return null;
+
+ const time = await ctx.db.get(membroTime.timeId);
+ if (!time) return null;
+
+ return time.gestorId;
+}
+
+// Mutation: Criar solicitação de ausência
+export const criarSolicitacao = mutation({
+ args: {
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ motivo: v.string(),
+ },
+ returns: v.id("solicitacoesAusencias"),
+ handler: async (ctx, args) => {
+ // Validações
+ if (args.motivo.trim().length < 10) {
+ throw new Error("O motivo deve ter no mínimo 10 caracteres");
+ }
+
+ const dataInicio = new Date(args.dataInicio);
+ const dataFim = new Date(args.dataFim);
+ const hoje = new Date();
+ hoje.setHours(0, 0, 0, 0);
+
+ if (dataInicio < hoje) {
+ throw new Error("A data de início não pode ser no passado");
+ }
+
+ if (dataFim < dataInicio) {
+ throw new Error("A data de fim deve ser maior ou igual à data de início");
+ }
+
+ const funcionario = await ctx.db.get(args.funcionarioId);
+ if (!funcionario) {
+ throw new Error("Funcionário não encontrado");
+ }
+
+ // Verificar sobreposição com outras solicitações aprovadas ou pendentes
+ const solicitacoesExistentes = await ctx.db
+ .query("solicitacoesAusencias")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", args.funcionarioId)
+ )
+ .collect();
+
+ for (const solic of solicitacoesExistentes) {
+ if (
+ solic.status === "aprovado" ||
+ solic.status === "aguardando_aprovacao"
+ ) {
+ if (
+ verificarSobreposicao(
+ args.dataInicio,
+ args.dataFim,
+ solic.dataInicio,
+ solic.dataFim
+ )
+ ) {
+ throw new Error(
+ "Já existe uma solicitação aprovada ou pendente para este período"
+ );
+ }
+ }
+ }
+
+ // Criar solicitação
+ const solicitacaoId = await ctx.db.insert("solicitacoesAusencias", {
+ funcionarioId: args.funcionarioId,
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ motivo: args.motivo.trim(),
+ status: "aguardando_aprovacao",
+ criadoEm: Date.now(),
+ });
+
+ // Encontrar gestor do funcionário
+ const gestorId = await encontrarGestorDoFuncionario(
+ ctx,
+ args.funcionarioId
+ );
+
+ if (gestorId) {
+ // Criar notificação in-app para gestor
+ await ctx.db.insert("notificacoesAusencias", {
+ destinatarioId: gestorId,
+ solicitacaoAusenciaId: solicitacaoId,
+ tipo: "nova_solicitacao",
+ lida: false,
+ mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}`,
+ });
+
+ // Buscar usuário do gestor para enviar email e chat
+ const gestorUsuario = await ctx.db.get(gestorId);
+ const funcionarioUsuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_funcionarioId", (q) =>
+ q.eq("funcionarioId", args.funcionarioId)
+ )
+ .first();
+
+ if (gestorUsuario && funcionarioUsuario) {
+ // Enviar email ao gestor
+ await ctx.runMutation(api.email.enfileirarEmail, {
+ destinatario: gestorUsuario.email,
+ destinatarioId: gestorId,
+ assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
+ corpo: `Olá ${gestorUsuario.nome},
+ O funcionário ${funcionario.nome} solicitou uma ausência:
+
+ Período: ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}
+ Motivo: ${args.motivo}
+
+ Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.
`,
+ enviadoPor: funcionarioUsuario._id,
+ });
+
+ // Criar ou obter conversa entre gestor e funcionário
+ const conversasExistentes = await ctx.db
+ .query("conversas")
+ .filter((q) => q.eq(q.field("tipo"), "individual"))
+ .collect();
+
+ let conversaId: Id<"conversas"> | null = null;
+ for (const conversa of conversasExistentes) {
+ if (
+ conversa.participantes.length === 2 &&
+ conversa.participantes.includes(gestorId) &&
+ conversa.participantes.includes(funcionarioUsuario._id)
+ ) {
+ conversaId = conversa._id;
+ break;
+ }
+ }
+
+ if (!conversaId) {
+ conversaId = await ctx.db.insert("conversas", {
+ tipo: "individual",
+ participantes: [gestorId, funcionarioUsuario._id],
+ criadoPor: funcionarioUsuario._id,
+ criadoEm: Date.now(),
+ });
+ }
+
+ // Criar mensagem de chat
+ await ctx.db.insert("mensagens", {
+ conversaId,
+ remetenteId: funcionarioUsuario._id,
+ tipo: "texto",
+ conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivo}`,
+ enviadaEm: Date.now(),
+ });
+ }
+ }
+
+ return solicitacaoId;
+ },
+});
+
+// Mutation: Aprovar ausência
+export const aprovar = mutation({
+ args: {
+ solicitacaoId: v.id("solicitacoesAusencias"),
+ gestorId: v.id("usuarios"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const solicitacao = await ctx.db.get(args.solicitacaoId);
+ if (!solicitacao) {
+ throw new Error("Solicitação não encontrada");
+ }
+
+ if (solicitacao.status !== "aguardando_aprovacao") {
+ throw new Error("Esta solicitação já foi processada");
+ }
+
+ // Verificar se o gestor tem permissão (é gestor do time do funcionário)
+ const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
+ ctx,
+ solicitacao.funcionarioId
+ );
+
+ if (gestorIdDoFuncionario !== args.gestorId) {
+ throw new Error("Você não tem permissão para aprovar esta solicitação");
+ }
+
+ const funcionario = await ctx.db.get(solicitacao.funcionarioId);
+ if (!funcionario) {
+ throw new Error("Funcionário não encontrado");
+ }
+
+ // Atualizar solicitação
+ await ctx.db.patch(args.solicitacaoId, {
+ status: "aprovado",
+ gestorId: args.gestorId,
+ dataAprovacao: Date.now(),
+ });
+
+ // Buscar usuário do funcionário
+ const funcionarioUsuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_funcionarioId", (q) =>
+ q.eq("funcionarioId", solicitacao.funcionarioId)
+ )
+ .first();
+
+ if (funcionarioUsuario) {
+ // Criar notificação in-app para funcionário
+ await ctx.db.insert("notificacoesAusencias", {
+ destinatarioId: funcionarioUsuario._id,
+ solicitacaoAusenciaId: args.solicitacaoId,
+ tipo: "aprovado",
+ lida: false,
+ mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi aprovada!`,
+ });
+
+ const gestorUsuario = await ctx.db.get(args.gestorId);
+
+ if (gestorUsuario) {
+ // Enviar email ao funcionário
+ await ctx.runMutation(api.email.enfileirarEmail, {
+ destinatario: funcionarioUsuario.email,
+ destinatarioId: funcionarioUsuario._id,
+ assunto: "Solicitação de Ausência Aprovada",
+ corpo: `Olá ${funcionarioUsuario.nome},
+ Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:
+
+ Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
+ Motivo: ${solicitacao.motivo}
+ `,
+ enviadoPor: args.gestorId,
+ });
+
+ // Criar ou obter conversa
+ const conversasExistentes = await ctx.db
+ .query("conversas")
+ .filter((q) => q.eq(q.field("tipo"), "individual"))
+ .collect();
+
+ let conversaId: Id<"conversas"> | null = null;
+ for (const conversa of conversasExistentes) {
+ if (
+ conversa.participantes.length === 2 &&
+ conversa.participantes.includes(args.gestorId) &&
+ conversa.participantes.includes(funcionarioUsuario._id)
+ ) {
+ conversaId = conversa._id;
+ break;
+ }
+ }
+
+ if (!conversaId) {
+ conversaId = await ctx.db.insert("conversas", {
+ tipo: "individual",
+ participantes: [args.gestorId, funcionarioUsuario._id],
+ criadoPor: args.gestorId,
+ criadoEm: Date.now(),
+ });
+ }
+
+ // Criar mensagem de chat
+ await ctx.db.insert("mensagens", {
+ conversaId,
+ remetenteId: args.gestorId,
+ tipo: "texto",
+ conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}.`,
+ enviadaEm: Date.now(),
+ });
+ }
+ }
+
+ return null;
+ },
+});
+
+// Mutation: Reprovar ausência
+export const reprovar = mutation({
+ args: {
+ solicitacaoId: v.id("solicitacoesAusencias"),
+ gestorId: v.id("usuarios"),
+ motivoReprovacao: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const solicitacao = await ctx.db.get(args.solicitacaoId);
+ if (!solicitacao) {
+ throw new Error("Solicitação não encontrada");
+ }
+
+ if (solicitacao.status !== "aguardando_aprovacao") {
+ throw new Error("Esta solicitação já foi processada");
+ }
+
+ // Verificar se o gestor tem permissão
+ const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
+ ctx,
+ solicitacao.funcionarioId
+ );
+
+ if (gestorIdDoFuncionario !== args.gestorId) {
+ throw new Error("Você não tem permissão para reprovar esta solicitação");
+ }
+
+ const funcionario = await ctx.db.get(solicitacao.funcionarioId);
+ if (!funcionario) {
+ throw new Error("Funcionário não encontrado");
+ }
+
+ // Atualizar solicitação
+ await ctx.db.patch(args.solicitacaoId, {
+ status: "reprovado",
+ gestorId: args.gestorId,
+ dataReprovacao: Date.now(),
+ motivoReprovacao: args.motivoReprovacao.trim(),
+ });
+
+ // Buscar usuário do funcionário
+ const funcionarioUsuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_funcionarioId", (q) =>
+ q.eq("funcionarioId", solicitacao.funcionarioId)
+ )
+ .first();
+
+ if (funcionarioUsuario) {
+ // Criar notificação in-app para funcionário
+ await ctx.db.insert("notificacoesAusencias", {
+ destinatarioId: funcionarioUsuario._id,
+ solicitacaoAusenciaId: args.solicitacaoId,
+ tipo: "reprovado",
+ lida: false,
+ mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi reprovada. Motivo: ${args.motivoReprovacao}`,
+ });
+
+ const gestorUsuario = await ctx.db.get(args.gestorId);
+
+ if (gestorUsuario) {
+ // Enviar email ao funcionário
+ await ctx.runMutation(api.email.enfileirarEmail, {
+ destinatario: funcionarioUsuario.email,
+ destinatarioId: funcionarioUsuario._id,
+ assunto: "Solicitação de Ausência Reprovada",
+ corpo: `Olá ${funcionarioUsuario.nome},
+ Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:
+
+ Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
+ Motivo: ${solicitacao.motivo}
+ Motivo da Reprovação: ${args.motivoReprovacao}
+ `,
+ enviadoPor: args.gestorId,
+ });
+
+ // Criar ou obter conversa
+ const conversasExistentes = await ctx.db
+ .query("conversas")
+ .filter((q) => q.eq(q.field("tipo"), "individual"))
+ .collect();
+
+ let conversaId: Id<"conversas"> | null = null;
+ for (const conversa of conversasExistentes) {
+ if (
+ conversa.participantes.length === 2 &&
+ conversa.participantes.includes(args.gestorId) &&
+ conversa.participantes.includes(funcionarioUsuario._id)
+ ) {
+ conversaId = conversa._id;
+ break;
+ }
+ }
+
+ if (!conversaId) {
+ conversaId = await ctx.db.insert("conversas", {
+ tipo: "individual",
+ participantes: [args.gestorId, funcionarioUsuario._id],
+ criadoPor: args.gestorId,
+ criadoEm: Date.now(),
+ });
+ }
+
+ // Criar mensagem de chat
+ await ctx.db.insert("mensagens", {
+ conversaId,
+ remetenteId: args.gestorId,
+ tipo: "texto",
+ conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivoReprovacao}`,
+ enviadaEm: Date.now(),
+ });
+ }
+ }
+
+ return null;
+ },
+});
+
+// Mutation: Marcar notificação como lida
+export const marcarComoLida = mutation({
+ args: {
+ notificacaoId: v.id("notificacoesAusencias"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.notificacaoId, {
+ lida: true,
+ });
+ return null;
+ },
+});
+
diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts
index 56bb63d..b36b449 100644
--- a/packages/backend/convex/chat.ts
+++ b/packages/backend/convex/chat.ts
@@ -1,1179 +1,2147 @@
-import { v } from "convex/values";
-import { mutation, query, internalMutation } from "./_generated/server";
-import { Doc, Id } from "./_generated/dataModel";
-import type { QueryCtx, MutationCtx } from "./_generated/server";
-
-// ========== HELPERS ==========
-
-/**
- * Helper function para obter usuário autenticado (Better Auth ou Sessão)
- */
-async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
- // Tentar autenticação via Better Auth primeiro
- const identity = await ctx.auth.getUserIdentity();
- let usuarioAtual = null;
-
- if (identity && identity.email) {
- usuarioAtual = await ctx.db
- .query("usuarios")
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
- .first();
- }
-
- // Se não encontrou via Better Auth, tentar via sessão mais recente
- if (!usuarioAtual) {
- const sessaoAtiva = await ctx.db
- .query("sessoes")
- .filter((q) => q.eq(q.field("ativo"), true))
- .order("desc")
- .first();
-
- if (sessaoAtiva) {
- usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
- }
- }
-
- return usuarioAtual;
-}
-
-// ========== MUTATIONS ==========
-
-/**
- * Cria uma nova conversa (individual ou grupo)
- */
-export const criarConversa = mutation({
- args: {
- tipo: v.union(v.literal("individual"), v.literal("grupo")),
- participantes: v.array(v.id("usuarios")),
- nome: v.optional(v.string()),
- avatar: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- // Validar participantes
- if (!args.participantes.includes(usuarioAtual._id)) {
- args.participantes.push(usuarioAtual._id);
- }
-
- // Se for conversa individual, verificar se já existe
- if (args.tipo === "individual" && args.participantes.length === 2) {
- const conversaExistente = await ctx.db
- .query("conversas")
- .filter((q) => q.eq(q.field("tipo"), "individual"))
- .collect();
-
- for (const conversa of conversaExistente) {
- if (
- conversa.participantes.length === 2 &&
- conversa.participantes.every((p) => args.participantes.includes(p))
- ) {
- return conversa._id;
- }
- }
- }
-
- // Criar nova conversa
- const conversaId = await ctx.db.insert("conversas", {
- tipo: args.tipo,
- nome: args.nome,
- avatar: args.avatar,
- participantes: args.participantes,
- criadoPor: usuarioAtual._id,
- criadoEm: Date.now(),
- });
-
- // Criar notificações para outros participantes
- if (args.tipo === "grupo") {
- for (const participanteId of args.participantes) {
- if (participanteId !== usuarioAtual._id) {
- await ctx.db.insert("notificacoes", {
- usuarioId: participanteId,
- tipo: "adicionado_grupo",
- conversaId,
- remetenteId: usuarioAtual._id,
- titulo: "Adicionado a grupo",
- descricao: `Você foi adicionado ao grupo "${
- args.nome || "Sem nome"
- }" por ${usuarioAtual.nome}`,
- lida: false,
- criadaEm: Date.now(),
- });
- }
- }
- }
-
- return conversaId;
- },
-});
-
-/**
- * Cria ou busca uma conversa individual com outro usuário
- */
-export const criarOuBuscarConversaIndividual = mutation({
- args: {
- outroUsuarioId: v.id("usuarios"),
- },
- returns: v.id("conversas"),
- handler: async (ctx, args) => {
- // TENTAR BETTER AUTH PRIMEIRO
- const identity = await ctx.auth.getUserIdentity();
-
- let usuarioAtual = null;
-
- if (identity && identity.email) {
- // Buscar por email (Better Auth)
- usuarioAtual = await ctx.db
- .query("usuarios")
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
- .first();
- }
-
- // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado)
- if (!usuarioAtual) {
- const sessaoAtiva = await ctx.db
- .query("sessoes")
- .filter((q) => q.eq(q.field("ativo"), true))
- .order("desc")
- .first();
-
- if (sessaoAtiva) {
- usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
- }
- }
-
- if (!usuarioAtual) throw new Error("Usuário não autenticado");
-
- // Buscar conversa individual existente entre os dois usuários
- const conversasExistentes = await ctx.db
- .query("conversas")
- .filter((q) => q.eq(q.field("tipo"), "individual"))
- .collect();
-
- for (const conversa of conversasExistentes) {
- if (
- conversa.participantes.length === 2 &&
- conversa.participantes.includes(usuarioAtual._id) &&
- conversa.participantes.includes(args.outroUsuarioId)
- ) {
- return conversa._id;
- }
- }
-
- // Se não existe, criar nova conversa individual
- const conversaId = await ctx.db.insert("conversas", {
- tipo: "individual",
- participantes: [usuarioAtual._id, args.outroUsuarioId],
- criadoPor: usuarioAtual._id,
- criadoEm: Date.now(),
- });
-
- return conversaId;
- },
-});
-
-/**
- * Envia uma mensagem em uma conversa
- */
-export const enviarMensagem = mutation({
- args: {
- conversaId: v.id("conversas"),
- conteudo: v.string(),
- tipo: v.union(
- v.literal("texto"),
- v.literal("arquivo"),
- v.literal("imagem")
- ),
- arquivoId: v.optional(v.id("_storage")),
- arquivoNome: v.optional(v.string()),
- arquivoTamanho: v.optional(v.number()),
- arquivoTipo: v.optional(v.string()),
- mencoes: v.optional(v.array(v.id("usuarios"))),
- permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
- },
- 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");
- }
-
- // Criar mensagem
- const mensagemId = await ctx.db.insert("mensagens", {
- conversaId: args.conversaId,
- remetenteId: usuarioAtual._id,
- tipo: args.tipo,
- conteudo: args.conteudo,
- arquivoId: args.arquivoId,
- arquivoNome: args.arquivoNome,
- arquivoTamanho: args.arquivoTamanho,
- arquivoTipo: args.arquivoTipo,
- mencoes: args.mencoes,
- enviadaEm: Date.now(),
- });
-
- // Atualizar última mensagem da conversa
- await ctx.db.patch(args.conversaId, {
- ultimaMensagem: args.conteudo.substring(0, 100),
- ultimaMensagemTimestamp: Date.now(),
- });
-
- // Criar notificações para participantes (com tratamento de erro)
- try {
- for (const participanteId of conversa.participantes) {
- // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa
- const ehOMesmoUsuario = participanteId === usuarioAtual._id;
- const deveCriarNotificacao =
- !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo;
-
- if (deveCriarNotificacao) {
- const tipoNotificacao = args.mencoes?.includes(participanteId)
- ? "mencao"
- : "nova_mensagem";
-
- await ctx.db.insert("notificacoes", {
- usuarioId: participanteId,
- tipo: tipoNotificacao,
- conversaId: args.conversaId,
- mensagemId,
- remetenteId: usuarioAtual._id,
- titulo:
- tipoNotificacao === "mencao"
- ? `${usuarioAtual.nome} mencionou você`
- : `Nova mensagem de ${usuarioAtual.nome}`,
- descricao: args.conteudo.substring(0, 100),
- lida: false,
- criadaEm: Date.now(),
- });
- }
- }
- } catch (error) {
- // Log do erro mas não falhar o envio da mensagem
- console.error("Erro ao criar notificações:", error);
- // A mensagem já foi criada, então retornamos o ID normalmente
- }
-
- return mensagemId;
- },
-});
-
-/**
- * Agenda uma mensagem para envio futuro
- */
-export const agendarMensagem = mutation({
- args: {
- conversaId: v.id("conversas"),
- conteudo: v.string(),
- agendadaPara: v.number(), // timestamp
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- // Validar data futura
- if (args.agendadaPara <= Date.now()) {
- throw new Error("Data de agendamento deve ser futura");
- }
-
- // 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");
- }
-
- // Criar mensagem agendada
- const mensagemId = await ctx.db.insert("mensagens", {
- conversaId: args.conversaId,
- remetenteId: usuarioAtual._id,
- tipo: "texto",
- conteudo: args.conteudo,
- agendadaPara: args.agendadaPara,
- enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada
- });
-
- return mensagemId;
- },
-});
-
-/**
- * Cancela uma mensagem agendada
- */
-export const cancelarMensagemAgendada = mutation({
- args: {
- mensagemId: v.id("mensagens"),
- },
- returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
- handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) {
- return { sucesso: false, erro: "Usuário não autenticado" };
- }
-
- const mensagem = await ctx.db.get(args.mensagemId);
- if (!mensagem) {
- return { sucesso: false, erro: "Mensagem não encontrada" };
- }
-
- if (mensagem.remetenteId !== usuarioAtual._id) {
- return {
- sucesso: false,
- erro: "Você só pode cancelar suas próprias mensagens",
- };
- }
-
- if (!mensagem.agendadaPara) {
- return { sucesso: false, erro: "Esta mensagem não está agendada" };
- }
-
- if (mensagem.agendadaPara <= Date.now()) {
- return { sucesso: false, erro: "A data de agendamento já passou" };
- }
-
- await ctx.db.delete(args.mensagemId);
- return { sucesso: true };
- },
-});
-
-/**
- * Adiciona uma reação (emoji) a uma mensagem
- */
-export const reagirMensagem = mutation({
- args: {
- mensagemId: v.id("mensagens"),
- emoji: v.string(),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- const mensagem = await ctx.db.get(args.mensagemId);
- if (!mensagem) throw new Error("Mensagem não encontrada");
-
- const reacoes = mensagem.reagiuPor || [];
- const reacaoExistente = reacoes.find(
- (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji
- );
-
- if (reacaoExistente) {
- // Remover reação
- await ctx.db.patch(args.mensagemId, {
- reagiuPor: reacoes.filter(
- (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji)
- ),
- });
- } else {
- // Adicionar reação
- await ctx.db.patch(args.mensagemId, {
- reagiuPor: [
- ...reacoes,
- { usuarioId: usuarioAtual._id, emoji: args.emoji },
- ],
- });
- }
-
- return true;
- },
-});
-
-/**
- * Marca mensagens de uma conversa como lidas
- */
-export const marcarComoLida = mutation({
- args: {
- conversaId: v.id("conversas"),
- mensagemId: v.id("mensagens"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- // Buscar registro de leitura existente
- const leituraExistente = await ctx.db
- .query("leituras")
- .withIndex("by_conversa_usuario", (q) =>
- q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id)
- )
- .first();
-
- if (leituraExistente) {
- await ctx.db.patch(leituraExistente._id, {
- ultimaMensagemLida: args.mensagemId,
- lidaEm: Date.now(),
- });
- } else {
- await ctx.db.insert("leituras", {
- conversaId: args.conversaId,
- usuarioId: usuarioAtual._id,
- ultimaMensagemLida: args.mensagemId,
- lidaEm: Date.now(),
- });
- }
-
- // Marcar notificações desta conversa como lidas
- const notificacoes = await ctx.db
- .query("notificacoes")
- .withIndex("by_usuario_lida", (q) =>
- q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
- )
- .filter((q) => q.eq(q.field("conversaId"), args.conversaId))
- .collect();
-
- for (const notificacao of notificacoes) {
- await ctx.db.patch(notificacao._id, { lida: true });
- }
-
- return true;
- },
-});
-
-/**
- * Atualiza o status de presença do usuário
- */
-export const atualizarStatusPresenca = mutation({
- args: {
- status: v.union(
- v.literal("online"),
- v.literal("offline"),
- v.literal("ausente"),
- v.literal("externo"),
- v.literal("em_reuniao")
- ),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- await ctx.db.patch(usuarioAtual._id, {
- statusPresenca: args.status,
- ultimaAtividade: Date.now(),
- });
-
- return true;
- },
-});
-
-/**
- * Indica que o usuário está digitando em uma conversa
- */
-export const indicarDigitacao = mutation({
- args: {
- conversaId: v.id("conversas"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- // Buscar indicador existente
- const indicadorExistente = await ctx.db
- .query("digitando")
- .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id))
- .filter((q) => q.eq(q.field("conversaId"), args.conversaId))
- .first();
-
- if (indicadorExistente) {
- await ctx.db.patch(indicadorExistente._id, {
- iniciouEm: Date.now(),
- });
- } else {
- await ctx.db.insert("digitando", {
- conversaId: args.conversaId,
- usuarioId: usuarioAtual._id,
- iniciouEm: Date.now(),
- });
- }
-
- return true;
- },
-});
-
-/**
- * Gera URL para upload de arquivo no chat
- */
-export const uploadArquivoChat = mutation({
- args: {
- conversaId: v.id("conversas"),
- },
- 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");
- }
-
- return await ctx.storage.generateUploadUrl();
- },
-});
-
-/**
- * Marca uma notificação como lida
- */
-export const marcarNotificacaoLida = mutation({
- args: {
- notificacaoId: v.id("notificacoes"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- await ctx.db.patch(args.notificacaoId, { lida: true });
- return true;
- },
-});
-
-/**
- * Marca todas as notificações como lidas
- */
-export const marcarTodasNotificacoesLidas = mutation({
- args: {},
- handler: async (ctx) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- const notificacoes = await ctx.db
- .query("notificacoes")
- .withIndex("by_usuario_lida", (q) =>
- q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
- )
- .collect();
-
- for (const notificacao of notificacoes) {
- await ctx.db.patch(notificacao._id, { lida: true });
- }
-
- return true;
- },
-});
-
-/**
- * Deleta uma mensagem (soft delete)
- */
-export const deletarMensagem = mutation({
- args: {
- mensagemId: v.id("mensagens"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) throw new Error("Não autenticado");
-
- const mensagem = await ctx.db.get(args.mensagemId);
- if (!mensagem) throw new Error("Mensagem não encontrada");
-
- if (mensagem.remetenteId !== usuarioAtual._id) {
- throw new Error("Você só pode deletar suas próprias mensagens");
- }
-
- await ctx.db.patch(args.mensagemId, {
- deletada: true,
- conteudo: "Mensagem deletada",
- });
-
- return true;
- },
-});
-
-// ========== QUERIES ==========
-
-/**
- * Lista todas as conversas do usuário logado
- */
-export const listarConversas = query({
- args: {},
- handler: async (ctx) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- // Buscar todas as conversas do usuário
- const todasConversas = await ctx.db.query("conversas").collect();
- const conversasDoUsuario = todasConversas.filter((c) =>
- c.participantes.includes(usuarioAtual._id)
- );
-
- // Ordenar por última mensagem
- conversasDoUsuario.sort((a, b) => {
- const timestampA = a.ultimaMensagemTimestamp || a.criadoEm;
- const timestampB = b.ultimaMensagemTimestamp || b.criadoEm;
- return timestampB - timestampA;
- });
-
- // Enriquecer com informações dos participantes
- const conversasEnriquecidas = await Promise.all(
- conversasDoUsuario.map(async (conversa) => {
- // Buscar participantes
- const participantes = await Promise.all(
- conversa.participantes.map((id) => ctx.db.get(id))
- );
-
- // Para conversas individuais, pegar o outro usuário
- let outroUsuario = null;
- if (conversa.tipo === "individual") {
- const outroUsuarioRaw = participantes.find(
- (p) => p?._id !== usuarioAtual._id
- );
- if (outroUsuarioRaw) {
- // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot)
- const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id);
-
- if (usuarioAtualizado) {
- // Adicionar URL da foto de perfil
- let fotoPerfilUrl = null;
- if (usuarioAtualizado.fotoPerfil) {
- fotoPerfilUrl = await ctx.storage.getUrl(
- usuarioAtualizado.fotoPerfil
- );
- }
- outroUsuario = {
- ...usuarioAtualizado,
- fotoPerfilUrl,
- };
- }
- }
- }
-
- // Contar mensagens não lidas (apenas mensagens NÃO agendadas)
- const leitura = await ctx.db
- .query("leituras")
- .withIndex("by_conversa_usuario", (q) =>
- q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id)
- )
- .first();
-
- // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined)
- const todasMensagens = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
- .collect();
-
- const mensagens = todasMensagens.filter((m) => !m.agendadaPara);
-
- let naoLidas = 0;
- if (leitura) {
- naoLidas = mensagens.filter(
- (m) =>
- m.enviadaEm > (leitura.lidaEm || 0) &&
- m.remetenteId !== usuarioAtual._id
- ).length;
- } else {
- naoLidas = mensagens.filter(
- (m) => m.remetenteId !== usuarioAtual._id
- ).length;
- }
-
- return {
- ...conversa,
- outroUsuario,
- participantesInfo: participantes.filter((p) => p !== null),
- naoLidas,
- };
- })
- );
-
- return conversasEnriquecidas;
- },
-});
-
-/**
- * Obtém as mensagens de uma conversa com paginação
- */
-export const obterMensagens = query({
- args: {
- conversaId: v.id("conversas"),
- limit: v.optional(v.number()),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- // Verificar se usuário pertence à conversa
- const conversa = await ctx.db.get(args.conversaId);
- if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
- return [];
- }
-
- // Buscar mensagens (excluir agendadas)
- const mensagens = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
- .order("desc")
- .take(args.limit || 50);
-
- // Filtrar mensagens agendadas
- const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
-
- // Enriquecer com informações do remetente
- const mensagensEnriquecidas = await Promise.all(
- mensagensFiltradas.map(async (mensagem) => {
- const remetente = await ctx.db.get(mensagem.remetenteId);
- let arquivoUrl = null;
- if (mensagem.arquivoId) {
- arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
- }
- return {
- ...mensagem,
- remetente,
- arquivoUrl,
- };
- })
- );
-
- return mensagensEnriquecidas.reverse();
- },
-});
-
-/**
- * Obtém mensagens agendadas de uma conversa
- */
-export const obterMensagensAgendadas = query({
- args: {
- conversaId: v.id("conversas"),
- },
- handler: async (ctx, args): Promise[]> => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- // Buscar mensagens agendadas
- const todasMensagens = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
- .collect();
-
- // Filtrar apenas as agendadas do usuário atual
- const minhasMensagensAgendadas = todasMensagens.filter(
- (m) =>
- m.remetenteId === usuarioAtual._id &&
- m.agendadaPara !== undefined &&
- m.agendadaPara > Date.now()
- );
-
- return minhasMensagensAgendadas.sort(
- (a, b) => (a.agendadaPara ?? 0) - (b.agendadaPara ?? 0)
- );
- },
-});
-
-/**
- * Listar todas as mensagens agendadas do usuário atual (para página de notificações)
- */
-export const listarAgendamentosChat = query({
- args: {},
- handler: async (
- ctx
- ): Promise<
- Array<
- Doc<"mensagens"> & {
- conversaInfo: Doc<"conversas"> | null;
- destinatarioInfo: Doc<"usuarios"> | null;
- }
- >
- > => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) {
- return [];
- }
-
- // Buscar todas as mensagens agendadas do usuário
- const todasMensagens = await ctx.db
- .query("mensagens")
- .withIndex("by_remetente", (q) => q.eq("remetenteId", usuarioAtual._id))
- .collect();
-
- // Filtrar apenas as que têm agendamento (passadas ou futuras)
- const mensagensAgendadas = todasMensagens.filter(
- (m) => m.agendadaPara !== undefined
- );
-
- // Enriquecer com informações da conversa e destinatário
- const mensagensEnriquecidas = await Promise.all(
- mensagensAgendadas.map(async (mensagem) => {
- let conversaInfo: Doc<"conversas"> | null = null;
- let destinatarioInfo: Doc<"usuarios"> | null = null;
-
- conversaInfo = await ctx.db.get(mensagem.conversaId);
-
- // Se for conversa individual, encontrar o outro participante
- if (conversaInfo && conversaInfo.tipo === "individual") {
- const outroParticipanteId = conversaInfo.participantes.find(
- (p) => p !== usuarioAtual._id
- );
- if (outroParticipanteId) {
- destinatarioInfo = await ctx.db.get(outroParticipanteId);
- }
- }
-
- return {
- ...mensagem,
- conversaInfo,
- destinatarioInfo,
- };
- })
- );
-
- // Ordenar por data de agendamento (mais próximos primeiro)
- return mensagensEnriquecidas.sort((a, b) => {
- const dataA = a.agendadaPara ?? 0;
- const dataB = b.agendadaPara ?? 0;
- return dataA - dataB;
- });
- },
-});
-
-/**
- * Obtém as notificações do usuário
- */
-export const obterNotificacoes = query({
- args: {
- apenasPendentes: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- let query = ctx.db
- .query("notificacoes")
- .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id));
-
- if (args.apenasPendentes) {
- query = ctx.db
- .query("notificacoes")
- .withIndex("by_usuario_lida", (q) =>
- q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
- );
- }
-
- const notificacoes = await query.order("desc").take(50);
-
- // Enriquecer com informações do remetente
- const notificacoesEnriquecidas = await Promise.all(
- notificacoes.map(async (notificacao) => {
- let remetente = null;
- if (notificacao.remetenteId) {
- remetente = await ctx.db.get(notificacao.remetenteId);
- }
- return {
- ...notificacao,
- remetente,
- };
- })
- );
-
- return notificacoesEnriquecidas;
- },
-});
-
-/**
- * Conta o número de notificações não lidas
- */
-export const contarNotificacoesNaoLidas = query({
- args: {},
- handler: async (ctx) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return 0;
-
- const notificacoes = await ctx.db
- .query("notificacoes")
- .withIndex("by_usuario_lida", (q) =>
- q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
- )
- .collect();
-
- return notificacoes.length;
- },
-});
-
-/**
- * Obtém usuários online
- */
-export const obterUsuariosOnline = query({
- args: {},
- handler: async (ctx) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- const usuarios = await ctx.db
- .query("usuarios")
- .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online"))
- .collect();
-
- return usuarios.map((u) => ({
- _id: u._id,
- nome: u.nome,
- email: u.email,
- avatar: u.avatar,
- fotoPerfil: u.fotoPerfil,
- statusPresenca: u.statusPresenca,
- statusMensagem: u.statusMensagem,
- setor: u.setor,
- }));
- },
-});
-
-/**
- * Lista todos os usuários (para criar nova conversa)
- */
-export const listarTodosUsuarios = query({
- args: {},
- handler: async (ctx) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- const usuarios = await ctx.db
- .query("usuarios")
- .withIndex("by_ativo", (q) => q.eq("ativo", true))
- .collect();
-
- // Excluir o usuário atual e buscar matrículas
- const usuariosComMatricula = await Promise.all(
- usuarios
- .filter((u) => u._id !== usuarioAtual._id)
- .map(async (u) => {
- let matricula: string | undefined = undefined;
- if (u.funcionarioId) {
- const funcionario = await ctx.db.get(u.funcionarioId);
- matricula = funcionario?.matricula;
- }
- return {
- _id: u._id,
- nome: u.nome,
- email: u.email,
- matricula,
- avatar: u.avatar,
- fotoPerfil: u.fotoPerfil,
- statusPresenca: u.statusPresenca,
- statusMensagem: u.statusMensagem,
- setor: u.setor,
- };
- })
- );
-
- return usuariosComMatricula;
- },
-});
-
-/**
- * Busca mensagens em conversas
- */
-export const buscarMensagens = query({
- args: {
- query: v.string(),
- conversaId: v.optional(v.id("conversas")),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- // Buscar em todas as conversas do usuário
- const todasConversas = await ctx.db.query("conversas").collect();
- const conversasDoUsuario = todasConversas.filter((c) =>
- c.participantes.includes(usuarioAtual._id)
- );
-
- let mensagens: Doc<"mensagens">[] = [];
-
- if (args.conversaId !== undefined) {
- // Buscar em conversa específica
- const mensagensConversa = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!))
- .collect();
- mensagens = mensagensConversa;
- } else {
- // Buscar em todas as conversas
- for (const conversa of conversasDoUsuario) {
- const mensagensConversa = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
- .collect();
- mensagens.push(...mensagensConversa);
- }
- }
-
- // Filtrar por query
- const queryLower = args.query.toLowerCase();
- const mensagensFiltradas = mensagens.filter(
- (m) =>
- !m.deletada &&
- !m.agendadaPara &&
- m.conteudo.toLowerCase().includes(queryLower)
- );
-
- // Enriquecer com informações
- const mensagensEnriquecidas = await Promise.all(
- mensagensFiltradas.map(async (mensagem) => {
- const remetente = await ctx.db.get(mensagem.remetenteId);
- const conversa = await ctx.db.get(mensagem.conversaId);
- return {
- ...mensagem,
- remetente,
- conversa,
- };
- })
- );
-
- return mensagensEnriquecidas
- .sort((a, b) => b.enviadaEm - a.enviadaEm)
- .slice(0, 50);
- },
-});
-
-/**
- * Obtém quem está digitando em uma conversa
- */
-export const obterDigitando = query({
- args: {
- conversaId: v.id("conversas"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return [];
-
- // Buscar indicadores de digitação (últimos 10 segundos)
- const dezSegundosAtras = Date.now() - 10000;
- const digitando = await ctx.db
- .query("digitando")
- .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
- .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras))
- .collect();
-
- // Filtrar usuário atual e buscar informações
- const digitandoFiltrado = digitando.filter(
- (d) => d.usuarioId !== usuarioAtual._id
- );
-
- const usuarios = await Promise.all(
- digitandoFiltrado.map(async (d) => {
- const usuario = await ctx.db.get(d.usuarioId);
- return usuario;
- })
- );
-
- return usuarios.filter((u) => u !== null);
- },
-});
-
-/**
- * Conta mensagens não lidas de uma conversa
- */
-export const contarNaoLidas = query({
- args: {
- conversaId: v.id("conversas"),
- },
- handler: async (ctx, args) => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) return 0;
-
- const leitura = await ctx.db
- .query("leituras")
- .withIndex("by_conversa_usuario", (q) =>
- q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id)
- )
- .first();
-
- const mensagens = await ctx.db
- .query("mensagens")
- .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
- .filter((q) => q.eq(q.field("agendadaPara"), undefined))
- .collect();
-
- if (leitura) {
- return mensagens.filter(
- (m) =>
- m.enviadaEm > (leitura.lidaEm || 0) &&
- m.remetenteId !== usuarioAtual._id
- ).length;
- }
-
- return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length;
- },
-});
-
-// ========== INTERNAL MUTATIONS (para crons) ==========
-
-/**
- * Envia mensagens agendadas (chamado pelo cron)
- */
-export const enviarMensagensAgendadas = internalMutation({
- args: {},
- handler: async (ctx) => {
- const agora = Date.now();
-
- // Buscar mensagens que deveriam ser enviadas
- const mensagensAgendadas = await ctx.db
- .query("mensagens")
- .withIndex("by_agendamento")
- .filter((q) =>
- q.and(
- q.neq(q.field("agendadaPara"), undefined),
- q.lte(q.field("agendadaPara"), agora)
- )
- )
- .collect();
-
- for (const mensagem of mensagensAgendadas) {
- // Atualizar mensagem para "enviada"
- await ctx.db.patch(mensagem._id, {
- agendadaPara: undefined,
- enviadaEm: agora,
- });
-
- // Atualizar última mensagem da conversa
- const conversa = await ctx.db.get(mensagem.conversaId);
- if (conversa) {
- await ctx.db.patch(mensagem.conversaId, {
- ultimaMensagem: mensagem.conteudo.substring(0, 100),
- ultimaMensagemTimestamp: agora,
- });
-
- // Criar notificações para outros participantes
- const remetente = await ctx.db.get(mensagem.remetenteId);
- for (const participanteId of conversa.participantes) {
- if (participanteId !== mensagem.remetenteId) {
- await ctx.db.insert("notificacoes", {
- usuarioId: participanteId,
- tipo: "nova_mensagem",
- conversaId: mensagem.conversaId,
- mensagemId: mensagem._id,
- remetenteId: mensagem.remetenteId,
- titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`,
- descricao: mensagem.conteudo.substring(0, 100),
- lida: false,
- criadaEm: agora,
- });
- }
- }
- }
- }
-
- return mensagensAgendadas.length;
- },
-});
-
-/**
- * Limpa indicadores de digitação antigos (chamado pelo cron)
- */
-export const limparIndicadoresDigitacao = internalMutation({
- args: {},
- handler: async (ctx) => {
- const dezSegundosAtras = Date.now() - 10000;
-
- const indicadoresAntigos = await ctx.db
- .query("digitando")
- .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras))
- .collect();
-
- for (const indicador of indicadoresAntigos) {
- await ctx.db.delete(indicador._id);
- }
-
- return indicadoresAntigos.length;
- },
-});
+import { v } from "convex/values";
+import { mutation, query, internalMutation } from "./_generated/server";
+import { Doc, Id } from "./_generated/dataModel";
+import type { QueryCtx, MutationCtx } from "./_generated/server";
+import { internal, api } from "./_generated/api";
+
+// ========== HELPERS ==========
+
+/**
+ * Normaliza texto para busca (remove acentos, converte para lowercase)
+ */
+function normalizarTextoParaBusca(texto: string): string {
+ return texto
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "") // Remove diacríticos
+ .trim();
+}
+
+/**
+ * Helper function para obter usuário autenticado (Better Auth ou Sessão)
+ */
+async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
+ // Tentar autenticação via Better Auth primeiro
+ const identity = await ctx.auth.getUserIdentity();
+ let usuarioAtual = null;
+
+ if (identity && identity.email) {
+ usuarioAtual = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+ }
+
+ // Se não encontrou via Better Auth, tentar via sessão mais recente
+ if (!usuarioAtual) {
+ const sessaoAtiva = await ctx.db
+ .query("sessoes")
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .order("desc")
+ .first();
+
+ if (sessaoAtiva) {
+ usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
+ }
+ }
+
+ 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 {
+ 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 ==========
+
+/**
+ * Cria uma nova conversa (individual ou grupo)
+ */
+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()),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ // Validar participantes
+ if (!args.participantes.includes(usuarioAtual._id)) {
+ args.participantes.push(usuarioAtual._id);
+ }
+
+ // Se for conversa individual, verificar se já existe
+ if (args.tipo === "individual" && args.participantes.length === 2) {
+ const conversaExistente = await ctx.db
+ .query("conversas")
+ .filter((q) => q.eq(q.field("tipo"), "individual"))
+ .collect();
+
+ for (const conversa of conversaExistente) {
+ if (
+ conversa.participantes.length === 2 &&
+ conversa.participantes.every((p) => args.participantes.includes(p))
+ ) {
+ return conversa._id;
+ }
+ }
+ }
+
+ // 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" || 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: tipoNotificacao,
+ conversaId,
+ remetenteId: usuarioAtual._id,
+ 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,
+ criadaEm: Date.now(),
+ });
+ }
+ }
+ }
+
+ return conversaId;
+ },
+});
+
+/**
+ * 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
+ */
+export const criarOuBuscarConversaIndividual = mutation({
+ args: {
+ outroUsuarioId: v.id("usuarios"),
+ },
+ returns: v.id("conversas"),
+ handler: async (ctx, args) => {
+ // TENTAR BETTER AUTH PRIMEIRO
+ const identity = await ctx.auth.getUserIdentity();
+
+ let usuarioAtual = null;
+
+ if (identity && identity.email) {
+ // Buscar por email (Better Auth)
+ usuarioAtual = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+ }
+
+ // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado)
+ if (!usuarioAtual) {
+ const sessaoAtiva = await ctx.db
+ .query("sessoes")
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .order("desc")
+ .first();
+
+ if (sessaoAtiva) {
+ usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
+ }
+ }
+
+ if (!usuarioAtual) throw new Error("Usuário não autenticado");
+
+ // Buscar conversa individual existente entre os dois usuários
+ const conversasExistentes = await ctx.db
+ .query("conversas")
+ .filter((q) => q.eq(q.field("tipo"), "individual"))
+ .collect();
+
+ for (const conversa of conversasExistentes) {
+ if (
+ conversa.participantes.length === 2 &&
+ conversa.participantes.includes(usuarioAtual._id) &&
+ conversa.participantes.includes(args.outroUsuarioId)
+ ) {
+ return conversa._id;
+ }
+ }
+
+ // Se não existe, criar nova conversa individual
+ const conversaId = await ctx.db.insert("conversas", {
+ tipo: "individual",
+ participantes: [usuarioAtual._id, args.outroUsuarioId],
+ criadoPor: usuarioAtual._id,
+ criadoEm: Date.now(),
+ });
+
+ return conversaId;
+ },
+});
+
+/**
+ * Envia uma mensagem em uma conversa
+ */
+export const enviarMensagem = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ conteudo: v.string(),
+ tipo: v.union(
+ v.literal("texto"),
+ v.literal("arquivo"),
+ v.literal("imagem")
+ ),
+ arquivoId: v.optional(v.id("_storage")),
+ arquivoNome: v.optional(v.string()),
+ arquivoTamanho: v.optional(v.number()),
+ 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
+ },
+ 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");
+ }
+
+ // Normalizar conteúdo para busca (remover acentos, lowercase)
+ const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
+
+ // Verificar se é resposta a outra mensagem
+ if (args.respostaPara) {
+ const mensagemOriginal = await ctx.db.get(args.respostaPara);
+ if (!mensagemOriginal || mensagemOriginal.conversaId !== args.conversaId) {
+ throw new Error("Mensagem original não encontrada ou não pertence à mesma conversa");
+ }
+ // SEGURANÇA: Verificar se o remetente da mensagem original é participante da conversa
+ if (!conversa.participantes.includes(mensagemOriginal.remetenteId)) {
+ throw new Error("Mensagem original inválida");
+ }
+ if (mensagemOriginal.deletada) {
+ throw new Error("Não é possível responder a uma mensagem deletada");
+ }
+ }
+
+ // Criar mensagem
+ const mensagemId = await ctx.db.insert("mensagens", {
+ conversaId: args.conversaId,
+ remetenteId: usuarioAtual._id,
+ tipo: args.tipo,
+ conteudo: args.conteudo,
+ conteudoBusca,
+ arquivoId: args.arquivoId,
+ arquivoNome: args.arquivoNome,
+ arquivoTamanho: args.arquivoTamanho,
+ arquivoTipo: args.arquivoTipo,
+ mencoes: args.mencoes,
+ respostaPara: args.respostaPara,
+ enviadaEm: Date.now(),
+ });
+
+ // Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
+ if (args.tipo === "texto") {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ const urls = args.conteudo.match(urlRegex);
+ if (urls && urls.length > 0) {
+ // Pegar primeira URL encontrada
+ const primeiraUrl = urls[0];
+ // Agendar processamento de preview via action wrapper
+ ctx.scheduler.runAfter(1000, api.actions.linkPreview.processarPreviewLink, {
+ mensagemId,
+ url: primeiraUrl,
+ }).catch((error) => {
+ console.error("Erro ao agendar processamento de preview de link:", error);
+ });
+ }
+ }
+
+ // Atualizar última mensagem da conversa
+ await ctx.db.patch(args.conversaId, {
+ ultimaMensagem: args.conteudo.substring(0, 100),
+ ultimaMensagemTimestamp: Date.now(),
+ });
+
+ // Criar notificações para participantes (com tratamento de erro)
+ try {
+ for (const participanteId of conversa.participantes) {
+ // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa
+ const ehOMesmoUsuario = participanteId === usuarioAtual._id;
+ const deveCriarNotificacao =
+ !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo;
+
+ if (deveCriarNotificacao) {
+ const tipoNotificacao = args.mencoes?.includes(participanteId)
+ ? "mencao"
+ : "nova_mensagem";
+
+ const titulo =
+ tipoNotificacao === "mencao"
+ ? `${usuarioAtual.nome} mencionou você`
+ : `Nova mensagem de ${usuarioAtual.nome}`;
+ const descricao = args.conteudo.substring(0, 100);
+
+ // Criar notificação no banco
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: tipoNotificacao,
+ conversaId: args.conversaId,
+ mensagemId,
+ remetenteId: usuarioAtual._id,
+ titulo,
+ descricao,
+ lida: false,
+ criadaEm: Date.now(),
+ });
+
+ // Enviar push notification (assíncrono, não bloqueia)
+ ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
+ usuarioId: participanteId,
+ titulo,
+ corpo: descricao,
+ data: {
+ conversaId: args.conversaId,
+ mensagemId,
+ tipo: tipoNotificacao,
+ },
+ }).catch((error) => {
+ console.error(`Erro ao agendar push para usuário ${participanteId}:`, error);
+ });
+
+ // Se usuário offline, enviar email (assíncrono)
+ const usuarioOnline = await ctx.runQuery(internal.pushNotifications.verificarUsuarioOnline, {
+ usuarioId: participanteId,
+ });
+
+ if (!usuarioOnline) {
+ // Verificar preferências de email para esta conversa
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", participanteId).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ const deveEnviarEmail = !preferencias || preferencias.emailAtivado !== false;
+
+ if (deveEnviarEmail) {
+ // Buscar email do usuário
+ const usuarioParticipante = await ctx.db.get(participanteId);
+ if (usuarioParticipante?.email) {
+ // Obter URL do sistema (padrão: localhost para dev)
+ const urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
+
+ ctx.scheduler.runAfter(1000, api.email.enviarEmailComTemplate, {
+ destinatario: usuarioParticipante.email,
+ destinatarioId: participanteId,
+ templateCodigo: tipoNotificacao === "mencao" ? "chat_mencao" : "chat_mensagem",
+ variaveis: {
+ remetente: usuarioAtual.nome,
+ mensagem: descricao,
+ conversaId: args.conversaId.toString(),
+ urlSistema,
+ },
+ enviadoPor: usuarioAtual._id,
+ }).catch((error) => {
+ console.error(`Erro ao agendar email para usuário ${participanteId}:`, error);
+ });
+ }
+ }
+ }
+ }
+ }
+ } catch (error) {
+ // Log do erro mas não falhar o envio da mensagem
+ console.error("Erro ao criar notificações:", error);
+ // A mensagem já foi criada, então retornamos o ID normalmente
+ }
+
+ return mensagemId;
+ },
+});
+
+/**
+ * Agenda uma mensagem para envio futuro
+ */
+export const agendarMensagem = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ conteudo: v.string(),
+ agendadaPara: v.number(), // timestamp
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ // Validar data futura
+ if (args.agendadaPara <= Date.now()) {
+ throw new Error("Data de agendamento deve ser futura");
+ }
+
+ // 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");
+ }
+
+ // Criar mensagem agendada
+ const mensagemId = await ctx.db.insert("mensagens", {
+ conversaId: args.conversaId,
+ remetenteId: usuarioAtual._id,
+ tipo: "texto",
+ conteudo: args.conteudo,
+ agendadaPara: args.agendadaPara,
+ enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada
+ });
+
+ return mensagemId;
+ },
+});
+
+/**
+ * Cancela uma mensagem agendada
+ */
+export const cancelarMensagemAgendada = mutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) {
+ return { sucesso: false, erro: "Usuário 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" };
+ }
+
+ if (mensagem.remetenteId !== usuarioAtual._id) {
+ return {
+ sucesso: false,
+ erro: "Você só pode cancelar suas próprias mensagens",
+ };
+ }
+
+ if (!mensagem.agendadaPara) {
+ return { sucesso: false, erro: "Esta mensagem não está agendada" };
+ }
+
+ if (mensagem.agendadaPara <= Date.now()) {
+ return { sucesso: false, erro: "A data de agendamento já passou" };
+ }
+
+ await ctx.db.delete(args.mensagemId);
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Adiciona uma reação (emoji) a uma mensagem
+ * SEGURANÇA: Usuário só pode reagir a mensagens de conversas onde é participante
+ */
+export const reagirMensagem = mutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ emoji: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ const mensagem = await ctx.db.get(args.mensagemId);
+ if (!mensagem) throw new Error("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)) {
+ throw new Error("Você não pode reagir a mensagens de conversas onde não participa");
+ }
+
+ // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
+ if (!conversa.participantes.includes(mensagem.remetenteId)) {
+ throw new Error("Mensagem inválida");
+ }
+
+ const reacoes = mensagem.reagiuPor || [];
+ const reacaoExistente = reacoes.find(
+ (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji
+ );
+
+ if (reacaoExistente) {
+ // Remover reação
+ await ctx.db.patch(args.mensagemId, {
+ reagiuPor: reacoes.filter(
+ (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji)
+ ),
+ });
+ } else {
+ // Adicionar reação
+ await ctx.db.patch(args.mensagemId, {
+ reagiuPor: [
+ ...reacoes,
+ { usuarioId: usuarioAtual._id, emoji: args.emoji },
+ ],
+ });
+ }
+
+ return true;
+ },
+});
+
+/**
+ * Marca mensagens de uma conversa como lidas
+ * SEGURANÇA: Usuário só pode marcar como lida mensagens de conversas onde é participante
+ */
+export const marcarComoLida = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ mensagemId: v.id("mensagens"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ // SEGURANÇA: Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ throw new Error("Você não pertence a esta conversa");
+ }
+
+ // SEGURANÇA: Verificar se a mensagem pertence à conversa e se o remetente é participante
+ const mensagem = await ctx.db.get(args.mensagemId);
+ if (!mensagem || mensagem.conversaId !== args.conversaId) {
+ throw new Error("Mensagem não encontrada nesta conversa");
+ }
+ if (!conversa.participantes.includes(mensagem.remetenteId)) {
+ throw new Error("Mensagem inválida");
+ }
+
+ // Buscar registro de leitura existente
+ const leituraExistente = await ctx.db
+ .query("leituras")
+ .withIndex("by_conversa_usuario", (q) =>
+ q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id)
+ )
+ .first();
+
+ if (leituraExistente) {
+ await ctx.db.patch(leituraExistente._id, {
+ ultimaMensagemLida: args.mensagemId,
+ lidaEm: Date.now(),
+ });
+ } else {
+ await ctx.db.insert("leituras", {
+ conversaId: args.conversaId,
+ usuarioId: usuarioAtual._id,
+ ultimaMensagemLida: args.mensagemId,
+ lidaEm: Date.now(),
+ });
+ }
+
+ // Marcar notificações desta conversa como lidas
+ const notificacoes = await ctx.db
+ .query("notificacoes")
+ .withIndex("by_usuario_lida", (q) =>
+ q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
+ )
+ .filter((q) => q.eq(q.field("conversaId"), args.conversaId))
+ .collect();
+
+ for (const notificacao of notificacoes) {
+ await ctx.db.patch(notificacao._id, { lida: true });
+ }
+
+ return true;
+ },
+});
+
+/**
+ * Atualiza o status de presença do usuário
+ */
+export const atualizarStatusPresenca = mutation({
+ args: {
+ status: v.union(
+ v.literal("online"),
+ v.literal("offline"),
+ v.literal("ausente"),
+ v.literal("externo"),
+ v.literal("em_reuniao")
+ ),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ await ctx.db.patch(usuarioAtual._id, {
+ statusPresenca: args.status,
+ ultimaAtividade: Date.now(),
+ });
+
+ return true;
+ },
+});
+
+/**
+ * Indica que o usuário está digitando em uma conversa
+ * SEGURANÇA: Usuário só pode indicar digitação em conversas onde é participante
+ */
+export const indicarDigitacao = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ // SEGURANÇA: Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ throw new Error("Você não pertence a esta conversa");
+ }
+
+ // Buscar indicador existente
+ const indicadorExistente = await ctx.db
+ .query("digitando")
+ .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id))
+ .filter((q) => q.eq(q.field("conversaId"), args.conversaId))
+ .first();
+
+ if (indicadorExistente) {
+ await ctx.db.patch(indicadorExistente._id, {
+ iniciouEm: Date.now(),
+ });
+ } else {
+ await ctx.db.insert("digitando", {
+ conversaId: args.conversaId,
+ usuarioId: usuarioAtual._id,
+ iniciouEm: Date.now(),
+ });
+ }
+
+ return true;
+ },
+});
+
+/**
+ * Gera URL para upload de arquivo no chat
+ */
+export const uploadArquivoChat = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ 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");
+ }
+
+ return await ctx.storage.generateUploadUrl();
+ },
+});
+
+/**
+ * Marca uma notificação como lida
+ * SEGURANÇA: Usuário só pode marcar como lida suas próprias notificações
+ */
+export const marcarNotificacaoLida = mutation({
+ args: {
+ notificacaoId: v.id("notificacoes"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ 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");
+
+ // SEGURANÇA: Verificar se a notificação pertence ao usuário atual
+ if (notificacao.usuarioId !== usuarioAtual._id) {
+ throw new Error("Você não tem permissão para marcar esta notificação como lida");
+ }
+
+ // SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante
+ if (notificacao.conversaId) {
+ const conversa = await ctx.db.get(notificacao.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ throw new Error("Você não tem acesso a esta notificação");
+ }
+ }
+
+ await ctx.db.patch(args.notificacaoId, { lida: true });
+ return true;
+ },
+});
+
+/**
+ * Marca todas as notificações como lidas
+ */
+export const marcarTodasNotificacoesLidas = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ const notificacoes = await ctx.db
+ .query("notificacoes")
+ .withIndex("by_usuario_lida", (q) =>
+ q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
+ )
+ .collect();
+
+ for (const notificacao of notificacoes) {
+ await ctx.db.patch(notificacao._id, { lida: true });
+ }
+
+ return true;
+ },
+});
+
+/**
+ * Deleta uma mensagem (soft delete)
+ */
+/**
+ * Editar mensagem enviada
+ */
+export const editarMensagem = mutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ novoConteudo: v.string(),
+ },
+ 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 é o remetente
+ if (mensagem.remetenteId !== usuarioAtual._id) {
+ return { sucesso: false, erro: "Você só pode editar suas próprias mensagens" };
+ }
+
+ // Verificar se mensagem não foi deletada
+ if (mensagem.deletada) {
+ return { sucesso: false, erro: "Não é possível editar uma mensagem deletada" };
+ }
+
+ // Verificar se não é mensagem agendada
+ if (mensagem.agendadaPara) {
+ return { sucesso: false, erro: "Não é possível editar mensagens agendadas" };
+ }
+
+ // Validar novo conteúdo
+ if (!args.novoConteudo || args.novoConteudo.trim().length === 0) {
+ return { sucesso: false, erro: "O conteúdo da mensagem não pode estar vazio" };
+ }
+
+ // Normalizar conteúdo para busca
+ const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
+
+ // Atualizar mensagem
+ await ctx.db.patch(args.mensagemId, {
+ conteudo: args.novoConteudo.trim(),
+ conteudoBusca,
+ editadaEm: Date.now(),
+ });
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Mutation interna para atualizar link preview
+ */
+export const atualizarLinkPreview = internalMutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ linkPreview: v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ }),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.mensagemId, {
+ linkPreview: args.linkPreview,
+ });
+ return null;
+ },
+});
+
+export const deletarMensagem = mutation({
+ args: {
+ mensagemId: v.id("mensagens"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) throw new Error("Não autenticado");
+
+ const mensagem = await ctx.db.get(args.mensagemId);
+ if (!mensagem) throw new Error("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)) {
+ throw new Error("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)) {
+ throw new Error("Mensagem inválida");
+ }
+
+ // 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");
+ }
+
+ await ctx.db.patch(args.mensagemId, {
+ deletada: true,
+ conteudo: "Mensagem deletada",
+ });
+
+ return true;
+ },
+});
+
+/**
+ * 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
+ */
+export const listarConversas = query({
+ args: {},
+ handler: async (ctx) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ // Buscar todas as conversas do usuário (SEGURANÇA: filtrar por participante)
+ const todasConversas = await ctx.db.query("conversas").collect();
+ const conversasDoUsuario = todasConversas.filter((c) =>
+ c.participantes.includes(usuarioAtual._id)
+ );
+
+ // Ordenar por última mensagem
+ conversasDoUsuario.sort((a, b) => {
+ const timestampA = a.ultimaMensagemTimestamp || a.criadoEm;
+ const timestampB = b.ultimaMensagemTimestamp || b.criadoEm;
+ return timestampB - timestampA;
+ });
+
+ // Enriquecer com informações dos participantes
+ const conversasEnriquecidas = await Promise.all(
+ conversasDoUsuario.map(async (conversa) => {
+ // Buscar participantes
+ const participantes = await Promise.all(
+ conversa.participantes.map((id) => ctx.db.get(id))
+ );
+
+ // Para conversas individuais, pegar o outro usuário
+ let outroUsuario = null;
+ if (conversa.tipo === "individual") {
+ const outroUsuarioRaw = participantes.find(
+ (p) => p?._id !== usuarioAtual._id
+ );
+ if (outroUsuarioRaw) {
+ // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot)
+ const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id);
+
+ if (usuarioAtualizado) {
+ // Adicionar URL da foto de perfil
+ let fotoPerfilUrl = null;
+ if (usuarioAtualizado.fotoPerfil) {
+ fotoPerfilUrl = await ctx.storage.getUrl(
+ usuarioAtualizado.fotoPerfil
+ );
+ }
+ outroUsuario = {
+ ...usuarioAtualizado,
+ fotoPerfilUrl,
+ };
+ }
+ }
+ }
+
+ // Contar mensagens não lidas (apenas mensagens NÃO agendadas)
+ const leitura = await ctx.db
+ .query("leituras")
+ .withIndex("by_conversa_usuario", (q) =>
+ q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id)
+ )
+ .first();
+
+ // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined)
+ // SEGURANÇA: Filtrar apenas mensagens de participantes da conversa
+ const todasMensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
+ .collect();
+
+ // Filtrar mensagens agendadas e garantir que remetente é participante
+ const mensagens = todasMensagens.filter((m) => {
+ if (m.agendadaPara) return false;
+ // Garantir que o remetente é participante da conversa
+ return conversa.participantes.includes(m.remetenteId);
+ });
+
+ let naoLidas = 0;
+ if (leitura) {
+ naoLidas = mensagens.filter(
+ (m) =>
+ m.enviadaEm > (leitura.lidaEm || 0) &&
+ m.remetenteId !== usuarioAtual._id
+ ).length;
+ } else {
+ naoLidas = mensagens.filter(
+ (m) => m.remetenteId !== usuarioAtual._id
+ ).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: participantesInfo.filter((p) => p !== null),
+ naoLidas,
+ isAdmin, // Adicionar flag de admin
+ };
+ })
+ );
+
+ return conversasEnriquecidas;
+ },
+});
+
+/**
+ * Obtém as mensagens de uma conversa com paginação
+ * SEGURANÇA: Usuário só vê mensagens de conversas onde é participante
+ */
+export const obterMensagens = query({
+ args: {
+ conversaId: v.id("conversas"),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ // 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 [];
+ }
+
+ // Buscar mensagens (excluir agendadas)
+ const mensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
+ .order("desc")
+ .take(args.limit || 50);
+
+ // 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) => {
+ // Excluir agendadas
+ if (m.agendadaPara) return false;
+
+ // Garantir que a mensagem pertence à conversa correta (segurança adicional)
+ if (m.conversaId !== args.conversaId) return false;
+
+ // SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa
+ // Isso garante que usuários só veem mensagens de conversas onde participam
+ return conversa.participantes.includes(m.remetenteId);
+ });
+
+ // Enriquecer com informações do remetente e mensagem respondida
+ const mensagensEnriquecidas = await Promise.all(
+ mensagensFiltradas.map(async (mensagem) => {
+ const remetente = await ctx.db.get(mensagem.remetenteId);
+
+ // SEGURANÇA: Não retornar informações de remetente se não for participante
+ if (!remetente || !conversa.participantes.includes(remetente._id)) {
+ return null;
+ }
+
+ let arquivoUrl = null;
+ if (mensagem.arquivoId) {
+ arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
+ }
+
+ // Buscar mensagem original se for resposta
+ let mensagemOriginal = null;
+ if (mensagem.respostaPara) {
+ const original = await ctx.db.get(mensagem.respostaPara);
+ if (original && conversa.participantes.includes(original.remetenteId)) {
+ const remetenteOriginal = await ctx.db.get(original.remetenteId);
+ mensagemOriginal = {
+ _id: original._id,
+ conteudo: original.conteudo.substring(0, 100), // Limitar tamanho
+ remetente: remetenteOriginal ? {
+ _id: remetenteOriginal._id,
+ nome: remetenteOriginal.nome,
+ } : null,
+ deletada: original.deletada || false,
+ };
+ }
+ }
+
+ return {
+ ...mensagem,
+ remetente,
+ arquivoUrl,
+ mensagemOriginal,
+ };
+ })
+ );
+
+ // Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
+ return mensagensEnriquecidas.filter((m) => m !== null).reverse();
+ },
+});
+
+/**
+ * Obtém mensagens agendadas de uma conversa
+ * SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde é participante
+ */
+export const obterMensagensAgendadas = query({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ handler: async (ctx, args): Promise[]> => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ // SEGURANÇA: Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return [];
+ }
+
+ // Buscar mensagens agendadas
+ const todasMensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
+ .collect();
+
+ // Filtrar apenas as agendadas do usuário atual (SEGURANÇA: só suas próprias mensagens)
+ const minhasMensagensAgendadas = todasMensagens.filter(
+ (m) =>
+ m.remetenteId === usuarioAtual._id &&
+ m.agendadaPara !== undefined &&
+ m.agendadaPara > Date.now() &&
+ m.conversaId === args.conversaId // Garantir que pertence à conversa correta
+ );
+
+ return minhasMensagensAgendadas.sort(
+ (a, b) => (a.agendadaPara ?? 0) - (b.agendadaPara ?? 0)
+ );
+ },
+});
+
+/**
+ * Listar todas as mensagens agendadas do usuário atual (para página de notificações)
+ * SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde ainda é participante
+ */
+export const listarAgendamentosChat = query({
+ args: {},
+ handler: async (
+ ctx
+ ): Promise<
+ Array<
+ Doc<"mensagens"> & {
+ conversaInfo: Doc<"conversas"> | null;
+ destinatarioInfo: Doc<"usuarios"> | null;
+ }
+ >
+ > => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) {
+ return [];
+ }
+
+ // Buscar todas as mensagens agendadas do usuário
+ const todasMensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_remetente", (q) => q.eq("remetenteId", usuarioAtual._id))
+ .collect();
+
+ // Filtrar apenas as que têm agendamento (passadas ou futuras)
+ const mensagensAgendadas = todasMensagens.filter(
+ (m) => m.agendadaPara !== undefined
+ );
+
+ // Enriquecer com informações da conversa e destinatário
+ const mensagensEnriquecidas = await Promise.all(
+ mensagensAgendadas.map(async (mensagem) => {
+ const conversaInfo = await ctx.db.get(mensagem.conversaId);
+
+ // SEGURANÇA: Verificar se usuário ainda é participante da conversa
+ if (!conversaInfo || !conversaInfo.participantes.includes(usuarioAtual._id)) {
+ return null; // Usuário não é mais participante, não mostrar mensagem
+ }
+
+ // SEGURANÇA: Verificar se o remetente (que deve ser o usuário atual) é participante
+ if (!conversaInfo.participantes.includes(mensagem.remetenteId)) {
+ return null; // Remetente não é participante, mensagem inválida
+ }
+
+ let destinatarioInfo: Doc<"usuarios"> | null = null;
+
+ // Se for conversa individual, encontrar o outro participante
+ if (conversaInfo.tipo === "individual") {
+ const outroParticipanteId = conversaInfo.participantes.find(
+ (p) => p !== usuarioAtual._id
+ );
+ if (outroParticipanteId) {
+ destinatarioInfo = await ctx.db.get(outroParticipanteId);
+ }
+ }
+
+ return {
+ ...mensagem,
+ conversaInfo,
+ destinatarioInfo,
+ };
+ })
+ );
+
+ // Filtrar nulls e ordenar por data de agendamento (mais próximos primeiro)
+ return mensagensEnriquecidas
+ .filter((m): m is NonNullable => m !== null)
+ .sort((a, b) => {
+ const dataA = a.agendadaPara ?? 0;
+ const dataB = b.agendadaPara ?? 0;
+ return dataA - dataB;
+ });
+ },
+});
+
+/**
+ * Obtém as notificações do usuário
+ * SEGURANÇA: Usuário só vê notificações de conversas onde ainda é participante
+ */
+export const obterNotificacoes = query({
+ args: {
+ apenasPendentes: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ let query = ctx.db
+ .query("notificacoes")
+ .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id));
+
+ if (args.apenasPendentes) {
+ query = ctx.db
+ .query("notificacoes")
+ .withIndex("by_usuario_lida", (q) =>
+ q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
+ );
+ }
+
+ const notificacoes = await query.order("desc").take(50);
+
+ // Enriquecer com informações do remetente e validar acesso
+ const notificacoesEnriquecidas = await Promise.all(
+ notificacoes.map(async (notificacao) => {
+ // SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante
+ if (notificacao.conversaId) {
+ const conversa = await ctx.db.get(notificacao.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return null; // Usuário não é mais participante, não mostrar notificação
+ }
+
+ // SEGURANÇA: Se tem remetenteId, verificar se é participante da conversa
+ if (notificacao.remetenteId && !conversa.participantes.includes(notificacao.remetenteId)) {
+ return null; // Remetente não é participante, notificação inválida
+ }
+ }
+
+ let remetente = null;
+ if (notificacao.remetenteId) {
+ remetente = await ctx.db.get(notificacao.remetenteId);
+ }
+ return {
+ ...notificacao,
+ remetente,
+ };
+ })
+ );
+
+ // Filtrar nulls antes de retornar
+ return notificacoesEnriquecidas.filter((n): n is NonNullable => n !== null);
+ },
+});
+
+/**
+ * Conta o número de notificações não lidas
+ */
+export const contarNotificacoesNaoLidas = query({
+ args: {},
+ handler: async (ctx) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return 0;
+
+ const notificacoes = await ctx.db
+ .query("notificacoes")
+ .withIndex("by_usuario_lida", (q) =>
+ q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
+ )
+ .collect();
+
+ return notificacoes.length;
+ },
+});
+
+/**
+ * Obtém usuários online
+ */
+export const obterUsuariosOnline = query({
+ args: {},
+ handler: async (ctx) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ const usuarios = await ctx.db
+ .query("usuarios")
+ .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online"))
+ .collect();
+
+ return usuarios.map((u) => ({
+ _id: u._id,
+ nome: u.nome,
+ email: u.email,
+ avatar: u.avatar,
+ fotoPerfil: u.fotoPerfil,
+ statusPresenca: u.statusPresenca,
+ statusMensagem: u.statusMensagem,
+ setor: u.setor,
+ }));
+ },
+});
+
+/**
+ * Lista todos os usuários (para criar nova conversa)
+ */
+export const listarTodosUsuarios = query({
+ args: {},
+ handler: async (ctx) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ const usuarios = await ctx.db
+ .query("usuarios")
+ .withIndex("by_ativo", (q) => q.eq("ativo", true))
+ .collect();
+
+ // Excluir o usuário atual e buscar matrículas
+ const usuariosComMatricula = await Promise.all(
+ usuarios
+ .filter((u) => u._id !== usuarioAtual._id)
+ .map(async (u) => {
+ let matricula: string | undefined = undefined;
+ if (u.funcionarioId) {
+ const funcionario = await ctx.db.get(u.funcionarioId);
+ matricula = funcionario?.matricula;
+ }
+ return {
+ _id: u._id,
+ nome: u.nome,
+ email: u.email,
+ matricula,
+ avatar: u.avatar,
+ fotoPerfil: u.fotoPerfil,
+ statusPresenca: u.statusPresenca,
+ statusMensagem: u.statusMensagem,
+ setor: u.setor,
+ };
+ })
+ );
+
+ return usuariosComMatricula;
+ },
+});
+
+/**
+ * Busca mensagens em conversas com filtros avançados
+ * SEGURANÇA: Usuário só vê mensagens de conversas onde é participante e onde o remetente também é participante
+ */
+export const buscarMensagens = query({
+ args: {
+ query: v.string(),
+ conversaId: v.optional(v.id("conversas")),
+ remetenteId: v.optional(v.id("usuarios")),
+ tipo: v.optional(v.union(v.literal("texto"), v.literal("arquivo"), v.literal("imagem"))),
+ dataInicio: v.optional(v.number()),
+ dataFim: v.optional(v.number()),
+ limite: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ // Normalizar query para busca
+ const queryNormalizada = normalizarTextoParaBusca(args.query);
+
+ // Buscar em todas as conversas do usuário
+ const todasConversas = await ctx.db.query("conversas").collect();
+ const conversasDoUsuario = todasConversas.filter((c) =>
+ c.participantes.includes(usuarioAtual._id)
+ );
+
+ // SEGURANÇA: Se filtrar por remetente, verificar se ele é participante de alguma conversa do usuário
+ if (args.remetenteId) {
+ const remetenteEParticipante = conversasDoUsuario.some(c =>
+ c.participantes.includes(args.remetenteId!)
+ );
+ if (!remetenteEParticipante) {
+ return []; // Remetente não é participante de nenhuma conversa do usuário
+ }
+ }
+
+ let mensagens: Doc<"mensagens">[] = [];
+
+ if (args.conversaId !== undefined) {
+ // Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return [];
+ }
+
+ // Buscar em conversa específica
+ const mensagensConversa = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!))
+ .collect();
+
+ // SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica
+ mensagens = mensagensConversa.filter(m =>
+ conversa.participantes.includes(m.remetenteId)
+ );
+ } else {
+ // Buscar em todas as conversas do usuário
+ for (const conversa of conversasDoUsuario) {
+ const mensagensConversa = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
+ .collect();
+
+ // SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica
+ const mensagensValidas = mensagensConversa.filter(m =>
+ conversa.participantes.includes(m.remetenteId)
+ );
+ mensagens.push(...mensagensValidas);
+ }
+ }
+
+ // Aplicar filtros
+ let mensagensFiltradas = mensagens.filter((m) => {
+ // Excluir deletadas e agendadas
+ if (m.deletada || m.agendadaPara) {
+ return false;
+ }
+
+ // SEGURANÇA CRÍTICA: Garantir que a mensagem pertence a uma conversa do usuário
+ // e que o remetente é participante dessa conversa específica
+ const conversaDaMensagem = conversasDoUsuario.find(c => c._id === m.conversaId);
+ if (!conversaDaMensagem) {
+ return false;
+ }
+
+ // SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa específica da mensagem
+ if (!conversaDaMensagem.participantes.includes(m.remetenteId)) {
+ return false;
+ }
+
+ // Filtrar por query (busca no conteúdo normalizado)
+ if (queryNormalizada && queryNormalizada.length > 0) {
+ const conteudoBusca = m.conteudoBusca || normalizarTextoParaBusca(m.conteudo);
+ if (!conteudoBusca.includes(queryNormalizada)) {
+ return false;
+ }
+ }
+
+ // Filtrar por remetente (já verificado acima, mas garantir novamente)
+ if (args.remetenteId) {
+ if (m.remetenteId !== args.remetenteId) {
+ return false;
+ }
+ // Verificar novamente se o remetente é participante da conversa específica desta mensagem
+ if (!conversaDaMensagem.participantes.includes(args.remetenteId)) {
+ return false;
+ }
+ }
+
+ // Filtrar por tipo
+ if (args.tipo && m.tipo !== args.tipo) {
+ return false;
+ }
+
+ // Filtrar por data
+ if (args.dataInicio && m.enviadaEm < args.dataInicio) {
+ return false;
+ }
+ if (args.dataFim && m.enviadaEm > args.dataFim) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Ordenar por data (mais recentes primeiro)
+ mensagensFiltradas.sort((a, b) => b.enviadaEm - a.enviadaEm);
+
+ // Limitar resultados
+ if (args.limite) {
+ mensagensFiltradas = mensagensFiltradas.slice(0, args.limite);
+ }
+
+ // Enriquecer com informações (apenas para mensagens válidas)
+ const mensagensEnriquecidas = await Promise.all(
+ mensagensFiltradas.map(async (mensagem) => {
+ const conversaDaMensagem = conversasDoUsuario.find(c => c._id === mensagem.conversaId);
+
+ // SEGURANÇA: Validar novamente antes de enriquecer
+ if (!conversaDaMensagem || !conversaDaMensagem.participantes.includes(mensagem.remetenteId)) {
+ return null;
+ }
+
+ const remetente = await ctx.db.get(mensagem.remetenteId);
+
+ // SEGURANÇA: Só retornar se remetente for participante
+ if (!remetente || !conversaDaMensagem.participantes.includes(remetente._id)) {
+ return null;
+ }
+
+ return {
+ ...mensagem,
+ remetente,
+ conversa: conversaDaMensagem,
+ };
+ })
+ );
+
+ // Filtrar nulls antes de retornar
+ return mensagensEnriquecidas
+ .filter((m): m is NonNullable => m !== null)
+ .sort((a, b) => b.enviadaEm - a.enviadaEm)
+ .slice(0, 50);
+ },
+});
+
+/**
+ * Obtém quem está digitando em uma conversa
+ * SEGURANÇA: Usuário só vê digitação de conversas onde é participante
+ */
+export const obterDigitando = query({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return [];
+
+ // SEGURANÇA: Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return [];
+ }
+
+ // Buscar indicadores de digitação (últimos 10 segundos)
+ const dezSegundosAtras = Date.now() - 10000;
+ const digitando = await ctx.db
+ .query("digitando")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
+ .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras))
+ .collect();
+
+ // Filtrar usuário atual e garantir que são participantes da conversa
+ const digitandoFiltrado = digitando.filter(
+ (d) => {
+ if (d.usuarioId === usuarioAtual._id) return false;
+ // Garantir que o usuário digitando é participante da conversa
+ return conversa.participantes.includes(d.usuarioId);
+ }
+ );
+
+ const usuarios = await Promise.all(
+ digitandoFiltrado.map(async (d) => {
+ const usuario = await ctx.db.get(d.usuarioId);
+ // SEGURANÇA: Só retornar se for participante
+ if (!usuario || !conversa.participantes.includes(usuario._id)) {
+ return null;
+ }
+ return usuario;
+ })
+ );
+
+ return usuarios.filter((u) => u !== null);
+ },
+});
+
+/**
+ * Conta mensagens não lidas de uma conversa
+ * SEGURANÇA: Usuário só conta mensagens de conversas onde é participante
+ */
+export const contarNaoLidas = query({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ handler: async (ctx, args) => {
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+ if (!usuarioAtual) return 0;
+
+ // SEGURANÇA: Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
+ return 0;
+ }
+
+ const leitura = await ctx.db
+ .query("leituras")
+ .withIndex("by_conversa_usuario", (q) =>
+ q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id)
+ )
+ .first();
+
+ const todasMensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
+ .filter((q) => q.eq(q.field("agendadaPara"), undefined))
+ .collect();
+
+ // SEGURANÇA: Filtrar apenas mensagens de participantes da conversa
+ const mensagens = todasMensagens.filter((m) =>
+ conversa.participantes.includes(m.remetenteId)
+ );
+
+ if (leitura) {
+ return mensagens.filter(
+ (m) =>
+ m.enviadaEm > (leitura.lidaEm || 0) &&
+ m.remetenteId !== usuarioAtual._id
+ ).length;
+ }
+
+ return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length;
+ },
+});
+
+// ========== INTERNAL MUTATIONS (para crons) ==========
+
+/**
+ * Envia mensagens agendadas (chamado pelo cron)
+ */
+export const enviarMensagensAgendadas = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const agora = Date.now();
+
+ // Buscar mensagens que deveriam ser enviadas
+ const mensagensAgendadas = await ctx.db
+ .query("mensagens")
+ .withIndex("by_agendamento")
+ .filter((q) =>
+ q.and(
+ q.neq(q.field("agendadaPara"), undefined),
+ q.lte(q.field("agendadaPara"), agora)
+ )
+ )
+ .collect();
+
+ for (const mensagem of mensagensAgendadas) {
+ // Atualizar mensagem para "enviada"
+ await ctx.db.patch(mensagem._id, {
+ agendadaPara: undefined,
+ enviadaEm: agora,
+ });
+
+ // Atualizar última mensagem da conversa
+ const conversa = await ctx.db.get(mensagem.conversaId);
+ if (conversa) {
+ await ctx.db.patch(mensagem.conversaId, {
+ ultimaMensagem: mensagem.conteudo.substring(0, 100),
+ ultimaMensagemTimestamp: agora,
+ });
+
+ // Criar notificações para outros participantes
+ const remetente = await ctx.db.get(mensagem.remetenteId);
+ for (const participanteId of conversa.participantes) {
+ if (participanteId !== mensagem.remetenteId) {
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: "nova_mensagem",
+ conversaId: mensagem.conversaId,
+ mensagemId: mensagem._id,
+ remetenteId: mensagem.remetenteId,
+ titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`,
+ descricao: mensagem.conteudo.substring(0, 100),
+ lida: false,
+ criadaEm: agora,
+ });
+ }
+ }
+ }
+ }
+
+ return mensagensAgendadas.length;
+ },
+});
+
+/**
+ * Limpa indicadores de digitação antigos (chamado pelo cron)
+ */
+export const limparIndicadoresDigitacao = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const dezSegundosAtras = Date.now() - 10000;
+
+ const indicadoresAntigos = await ctx.db
+ .query("digitando")
+ .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras))
+ .collect();
+
+ for (const indicador of indicadoresAntigos) {
+ await ctx.db.delete(indicador._id);
+ }
+
+ return indicadoresAntigos.length;
+ },
+});
diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts
index f826469..d891af4 100644
--- a/packages/backend/convex/crons.ts
+++ b/packages/backend/convex/crons.ts
@@ -10,6 +10,13 @@ crons.interval(
internal.chat.enviarMensagensAgendadas
);
+// Processar fila de emails (incluindo agendados) a cada minuto
+crons.interval(
+ "processar-fila-emails",
+ { minutes: 1 },
+ internal.email.processarFilaEmails
+);
+
// Limpar indicadores de digitação antigos (>10s) a cada minuto
crons.interval(
"limpar-indicadores-digitacao",
@@ -33,13 +40,5 @@ crons.interval(
{}
);
-// Processar fila de emails pendentes a cada 2 minutos
-crons.interval(
- "processar-fila-emails",
- { minutes: 2 },
- internal.email.processarFilaEmails,
- {}
-);
-
export default crons;
diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts
index af8208f..6e29450 100644
--- a/packages/backend/convex/email.ts
+++ b/packages/backend/convex/email.ts
@@ -1,81 +1,130 @@
import { v } from "convex/values";
-import {
- mutation,
- query,
- action,
- internalMutation,
- internalQuery,
-} from "./_generated/server";
-import { Doc, Id } from "./_generated/dataModel";
-import type { QueryCtx, MutationCtx } from "./_generated/server";
-import { renderizarTemplate } from "./templatesMensagens";
+import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
+import { renderizarTemplate } from "./templatesMensagens";
+import type { Doc, Id } from "./_generated/dataModel";
-// ========== HELPERS ==========
+// ========== INTERNAL QUERIES ==========
/**
- * Helper function para obter usuário autenticado (Better Auth ou Sessão)
+ * Obter email por ID (internal query)
*/
-async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise | null> {
- // Tentar autenticação via Better Auth primeiro
- const identity = await ctx.auth.getUserIdentity();
- let usuarioAtual: Doc<"usuarios"> | null = null;
-
- if (identity && identity.email) {
- usuarioAtual = await ctx.db
- .query("usuarios")
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
- .first();
- }
-
- // Se não encontrou via Better Auth, tentar via sessão mais recente
- if (!usuarioAtual) {
- const sessaoAtiva = await ctx.db
- .query("sessoes")
- .filter((q) => q.eq(q.field("ativo"), true))
- .order("desc")
- .first();
-
- if (sessaoAtiva) {
- usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
- }
- }
-
- return usuarioAtual;
-}
+export const getEmailById = internalQuery({
+ args: {
+ emailId: v.id("notificacoesEmail"),
+ },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.emailId);
+ },
+});
/**
- * Enfileirar email para envio
+ * Obter configuração SMTP ativa (internal query)
+ */
+export const getActiveEmailConfig = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ const config = await ctx.db
+ .query("configuracaoEmail")
+ .withIndex("by_ativo", (q) => q.eq("ativo", true))
+ .first();
+
+ return config;
+ },
+});
+
+/**
+ * Listar emails pendentes (internal query)
+ */
+export const listarEmailsPendentes = internalQuery({
+ args: {
+ limite: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const emails = await ctx.db
+ .query("notificacoesEmail")
+ .withIndex("by_status", (q) => q.eq("status", "pendente"))
+ .order("asc") // Mais antigos primeiro
+ .take(args.limite || 10);
+
+ return emails;
+ },
+});
+
+// ========== INTERNAL MUTATIONS ==========
+
+/**
+ * Marcar email como enviando (internal mutation)
+ */
+export const markEmailEnviando = internalMutation({
+ args: {
+ emailId: v.id("notificacoesEmail"),
+ },
+ handler: async (ctx, args) => {
+ const email = await ctx.db.get(args.emailId);
+ if (!email) return;
+
+ await ctx.db.patch(args.emailId, {
+ status: "enviando",
+ ultimaTentativa: Date.now(),
+ tentativas: email.tentativas + 1,
+ });
+ },
+});
+
+/**
+ * Marcar email como enviado (internal mutation)
+ */
+export const markEmailEnviado = internalMutation({
+ args: {
+ emailId: v.id("notificacoesEmail"),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.emailId, {
+ status: "enviado",
+ enviadoEm: Date.now(),
+ });
+ },
+});
+
+/**
+ * Marcar email como falha (internal mutation)
+ */
+export const markEmailFalha = internalMutation({
+ args: {
+ emailId: v.id("notificacoesEmail"),
+ erro: v.string(),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.emailId, {
+ status: "falha",
+ erroDetalhes: args.erro,
+ ultimaTentativa: Date.now(),
+ });
+ },
+});
+
+// ========== PUBLIC MUTATIONS ==========
+
+/**
+ * Enfileirar email para envio assíncrono
*/
export const enfileirarEmail = mutation({
args: {
- destinatario: v.string(), // email
+ destinatario: v.string(),
destinatarioId: v.optional(v.id("usuarios")),
assunto: v.string(),
corpo: v.string(),
templateId: v.optional(v.id("templatesMensagens")),
- enviadoPorId: v.id("usuarios"),
+ enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
- returns: v.object({
- sucesso: v.boolean(),
- emailId: v.optional(v.id("notificacoesEmail")),
- }),
handler: async (ctx, args) => {
- // Validar email
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- if (!emailRegex.test(args.destinatario)) {
- return { sucesso: false };
- }
-
// Validar agendamento se fornecido
- if (args.agendadaPara !== undefined) {
- if (args.agendadaPara <= Date.now()) {
- return { sucesso: false };
- }
+ if (args.agendadaPara !== undefined && args.agendadaPara <= Date.now()) {
+ throw new Error("Data de agendamento deve ser futura");
}
- // Adicionar à fila
const emailId = await ctx.db.insert("notificacoesEmail", {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
@@ -84,280 +133,177 @@ export const enfileirarEmail = mutation({
templateId: args.templateId,
status: "pendente",
tentativas: 0,
- enviadoPor: args.enviadoPorId,
criadoEm: Date.now(),
+ enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
- // Agendar envio
- if (args.agendadaPara !== undefined) {
- // Agendar para o momento especificado
- const delayMs = args.agendadaPara - Date.now();
- await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, {
- emailId,
- });
- } else {
- // Envio imediato
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId,
- });
- }
+ // O cron job processará emails automaticamente:
+ // - Emails sem agendamento serão processados imediatamente (próxima execução do cron)
+ // - Emails agendados serão processados quando a hora chegar
- return { sucesso: true, emailId };
+ return emailId;
},
});
/**
* Enviar email usando template
*/
-export const enviarEmailComTemplate = mutation({
+export const enviarEmailComTemplate = action({
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id("usuarios")),
templateCodigo: v.string(),
- variaveis: v.record(v.string(), v.string()),
- enviadoPorId: v.id("usuarios"),
+ variaveis: v.optional(v.record(v.string(), v.string())),
+ enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
- returns: v.object({
- sucesso: v.boolean(),
- emailId: v.optional(v.id("notificacoesEmail")),
- }),
- handler: async (ctx, args) => {
+ handler: async (ctx, args): Promise> => {
// Buscar template
- const template = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", args.templateCodigo))
- .first();
+ const template: Doc<"templatesMensagens"> | null = await ctx.runQuery(
+ api.templatesMensagens.obterTemplatePorCodigo,
+ {
+ codigo: args.templateCodigo,
+ }
+ );
if (!template) {
- console.error("Template não encontrado:", args.templateCodigo);
- return { sucesso: false };
+ throw new Error(`Template não encontrado: ${args.templateCodigo}`);
}
- // Validar agendamento se fornecido
- if (args.agendadaPara !== undefined) {
- if (args.agendadaPara <= Date.now()) {
- return { sucesso: false };
- }
- }
+ // Renderizar template com variáveis
+ const variaveisTemplate = args.variaveis || {};
+ const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
+ const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
- // Renderizar template
- const assunto = renderizarTemplate(template.titulo, args.variaveis);
- const corpo = renderizarTemplate(template.corpo, args.variaveis);
-
- // Enfileirar email
- const emailId = await ctx.db.insert("notificacoesEmail", {
+ // Enfileirar email via mutation
+ const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
- assunto,
- corpo,
- templateId: template._id,
- status: "pendente",
- tentativas: 0,
- enviadoPor: args.enviadoPorId,
- criadoEm: Date.now(),
+ assunto: tituloRenderizado,
+ corpo: corpoRenderizado,
+ templateId: template._id, // template._id sempre existe se template não é null
+ enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
- // Agendar envio
- if (args.agendadaPara !== undefined) {
- // Agendar para o momento especificado
- const delayMs = args.agendadaPara - Date.now();
- await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, {
- emailId,
- });
- } else {
- // Envio imediato
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId,
- });
+ if (!emailId) {
+ throw new Error("Erro ao enfileirar email: ID não retornado");
}
- return { sucesso: true, emailId };
+ return emailId;
},
});
+// ========== INTERNAL MUTATION (CRON) ==========
+
/**
- * Listar emails na fila
+ * Processar fila de emails pendentes (chamado pelo cron)
+ */
+export const processarFilaEmails = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const agora = Date.now();
+
+ // Buscar emails pendentes que devem ser processados agora
+ // (sem agendamento OU com agendamento que já passou)
+ const emailsParaProcessar = await ctx.db
+ .query("notificacoesEmail")
+ .filter((q) => {
+ const statusPendente = q.eq(q.field("status"), "pendente");
+ const semAgendamento = q.eq(q.field("agendadaPara"), undefined);
+ const agendamentoJaPassou = q.and(
+ q.neq(q.field("agendadaPara"), undefined),
+ q.lte(q.field("agendadaPara"), agora)
+ );
+
+ return q.and(
+ statusPendente,
+ q.or(semAgendamento, agendamentoJaPassou)
+ );
+ })
+ .order("asc") // Mais antigos primeiro
+ .take(10);
+
+ if (emailsParaProcessar.length === 0) {
+ return { processados: 0 };
+ }
+
+ // Agendar envio de cada email via action
+ for (const email of emailsParaProcessar) {
+ // Agendar envio assíncrono (não bloqueia o cron)
+ ctx.scheduler.runAfter(0, api.actions.email.enviar, {
+ emailId: email._id,
+ }).catch((error: unknown) => {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
+ });
+ }
+
+ return { processados: emailsParaProcessar.length };
+ }
+});
+
+// ========== QUERIES ==========
+
+/**
+ * Listar emails da fila (para monitoramento)
*/
export const listarFilaEmails = query({
args: {
- status: v.optional(
- v.union(
- v.literal("pendente"),
- v.literal("enviando"),
- v.literal("enviado"),
- v.literal("falha")
- )
- ),
limite: v.optional(v.number()),
+ status: v.optional(v.union(
+ v.literal("pendente"),
+ v.literal("enviando"),
+ v.literal("enviado"),
+ v.literal("falha")
+ )),
+ _refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
- // Tipo inferido automaticamente pelo Convex
handler: async (ctx, args) => {
+ let emails;
+
+ // Filtrar por status se fornecido
if (args.status) {
- const emails = await ctx.db
+ emails = await ctx.db
.query("notificacoesEmail")
.withIndex("by_status", (q) => q.eq("status", args.status!))
.order("desc")
- .take(args.limite ?? 100);
- return emails;
+ .take(args.limite || 50);
+ } else {
+ // Sem filtro, buscar todos e ordenar por data de criação
+ const todosEmails = await ctx.db.query("notificacoesEmail").collect();
+ todosEmails.sort((a, b) => b.criadoEm - a.criadoEm);
+ emails = todosEmails.slice(0, args.limite || 50);
}
- const emails = await ctx.db
- .query("notificacoesEmail")
- .withIndex("by_criado_em")
- .order("desc")
- .take(args.limite ?? 100);
return emails;
},
});
/**
- * Reenviar email falhado
- */
-export const reenviarEmail = mutation({
- args: {
- emailId: v.id("notificacoesEmail"),
- },
- returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
- handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
- const email = await ctx.db.get(args.emailId);
- if (!email) {
- return { sucesso: false, erro: "Email não encontrado" };
- }
-
- // Verificar se o email não foi enviado com sucesso ainda
- if (email.status === "enviado") {
- return { sucesso: false, erro: "Este email já foi enviado com sucesso" };
- }
-
- // Verificar se ainda não excedeu o limite de tentativas (max 3)
- if ((email.tentativas || 0) >= 3 && email.status !== "falha") {
- return { sucesso: false, erro: "Número máximo de tentativas excedido. Crie um novo email." };
- }
-
- // Resetar status para pendente
- await ctx.db.patch(args.emailId, {
- status: "pendente",
- tentativas: 0,
- ultimaTentativa: undefined,
- erroDetalhes: undefined,
- });
-
- // Agendar envio imediato
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId: args.emailId,
- });
-
- return { sucesso: true };
- },
-});
-
-/**
- * Cancelar agendamento de email
- */
-export const cancelarAgendamentoEmail = mutation({
- args: {
- emailId: v.id("notificacoesEmail"),
- },
- returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
- handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) {
- return { sucesso: false, erro: "Usuário não autenticado" };
- }
-
- const email = await ctx.db.get(args.emailId);
- if (!email) {
- return { sucesso: false, erro: "Email não encontrado" };
- }
-
- // Verificar se o email pertence ao usuário atual
- if (email.enviadoPor !== usuarioAtual._id) {
- return { sucesso: false, erro: "Você não tem permissão para cancelar este agendamento" };
- }
-
- // Verificar se o email está agendado
- if (!email.agendadaPara) {
- return { sucesso: false, erro: "Este email não está agendado" };
- }
-
- // Verificar se ainda não foi enviado
- if (email.status === "enviado") {
- return { sucesso: false, erro: "Este email já foi enviado" };
- }
-
- // Verificar se já passou a data de agendamento
- if (email.agendadaPara <= Date.now()) {
- return { sucesso: false, erro: "A data de agendamento já passou" };
- }
-
- // Deletar o email agendado
- await ctx.db.delete(args.emailId);
-
- return { sucesso: true };
- },
-});
-
-/**
- * Action para enviar email (será implementado com nodemailer)
- *
- * NOTA: Este é um placeholder. Implementação real requer nodemailer.
- */
-export const getEmailById = internalQuery({
- args: { emailId: v.id("notificacoesEmail") },
- // Tipo inferido automaticamente pelo Convex
- handler: async (ctx, args) => {
- return await ctx.db.get(args.emailId);
- },
-});
-
-/**
- * Buscar emails por IDs (query pública)
- */
-export const buscarEmailsPorIds = query({
- args: {
- emailIds: v.array(v.id("notificacoesEmail")),
- },
- handler: async (ctx, args): Promise[]> => {
- const emails: Doc<"notificacoesEmail">[] = [];
- for (const emailId of args.emailIds) {
- const email = await ctx.db.get(emailId);
- if (email) {
- emails.push(email);
- }
- }
- return emails;
- },
-});
-
-/**
- * Obter estatísticas da fila de emails
+ * Obter estatísticas da fila de emails (para debug e monitoramento)
*/
export const obterEstatisticasFilaEmails = query({
- args: {},
+ args: {
+ _refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
+ },
returns: v.object({
- total: v.number(),
pendentes: v.number(),
enviando: v.number(),
enviados: v.number(),
falhas: v.number(),
- comErro: v.number(),
- ultimaExecucaoCron: v.optional(v.number()),
+ total: v.number(),
}),
handler: async (ctx) => {
- const todosEmails = await ctx.db
- .query("notificacoesEmail")
- .collect();
-
+ const todosEmails = await ctx.db.query("notificacoesEmail").collect();
+
const estatisticas = {
- total: todosEmails.length,
pendentes: 0,
enviando: 0,
enviados: 0,
falhas: 0,
- comErro: 0,
+ total: todosEmails.length,
};
for (const email of todosEmails) {
@@ -373,9 +319,6 @@ export const obterEstatisticasFilaEmails = query({
break;
case "falha":
estatisticas.falhas++;
- if (email.erroDetalhes) {
- estatisticas.comErro++;
- }
break;
}
}
@@ -384,275 +327,59 @@ export const obterEstatisticasFilaEmails = query({
},
});
-/**
- * Listar agendamentos de email do usuário atual
- */
-export const listarAgendamentosEmail = query({
- args: {},
- handler: async (ctx): Promise & { destinatarioInfo: Doc<"usuarios"> | null; templateInfo: Doc<"templatesMensagens"> | null }>> => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) {
- return [];
- }
-
- // Buscar todos os emails do usuário
- const todosEmails = await ctx.db
- .query("notificacoesEmail")
- .withIndex("by_enviado_por", (q) => q.eq("enviadoPor", usuarioAtual._id))
- .collect();
-
- // Filtrar apenas os que têm agendamento (passados ou futuros)
- const emailsAgendados = todosEmails.filter((email) => email.agendadaPara !== undefined);
-
- // Enriquecer com informações do destinatário e template
- const emailsEnriquecidos = await Promise.all(
- emailsAgendados.map(async (email) => {
- let destinatarioInfo: Doc<"usuarios"> | null = null;
- let templateInfo: Doc<"templatesMensagens"> | null = null;
-
- if (email.destinatarioId) {
- destinatarioInfo = await ctx.db.get(email.destinatarioId);
- }
-
- if (email.templateId) {
- templateInfo = await ctx.db.get(email.templateId);
- }
-
- return {
- ...email,
- destinatarioInfo,
- templateInfo,
- };
- })
- );
-
- // Ordenar por data de agendamento (mais próximos primeiro)
- return emailsEnriquecidos.sort((a, b) => {
- const dataA = a.agendadaPara ?? 0;
- const dataB = b.agendadaPara ?? 0;
- return dataA - dataB;
- });
- },
-});
-
-export const getActiveEmailConfig = internalQuery({
- args: {},
- // Tipo inferido automaticamente pelo Convex
- handler: async (ctx) => {
- return await ctx.db
- .query("configuracaoEmail")
- .withIndex("by_ativo", (q) => q.eq("ativo", true))
- .first();
- },
-});
-
-// Query interna para obter configuração com senha descriptografada
-export const getActiveEmailConfigWithPassword = internalQuery({
- args: {},
- handler: async (ctx) => {
- const { decryptSMTPPassword } = await import("./auth/utils");
- const config = await ctx.db
- .query("configuracaoEmail")
- .withIndex("by_ativo", (q) => q.eq("ativo", true))
- .first();
-
- if (!config) {
- return null;
- }
-
- // Descriptografar senha
- const senhaDescriptografada = await decryptSMTPPassword(config.senhaHash);
-
- return {
- ...config,
- senha: senhaDescriptografada,
- };
- },
-});
-
-export const markEmailEnviando = internalMutation({
- args: { emailId: v.id("notificacoesEmail") },
- returns: v.null(),
- handler: async (ctx, args) => {
- const email = await ctx.db.get(args.emailId);
- if (!email) return null;
- await ctx.db.patch(args.emailId, {
- status: "enviando",
- tentativas: (email.tentativas || 0) + 1,
- ultimaTentativa: Date.now(),
- });
- return null;
- },
-});
-
-export const markEmailEnviado = internalMutation({
- args: { emailId: v.id("notificacoesEmail") },
- returns: v.null(),
- handler: async (ctx, args) => {
- await ctx.db.patch(args.emailId, {
- status: "enviado",
- enviadoEm: Date.now(),
- });
- return null;
- },
-});
-
-export const markEmailFalha = internalMutation({
- args: { emailId: v.id("notificacoesEmail"), erro: v.string() },
- returns: v.null(),
- handler: async (ctx, args) => {
- const email = await ctx.db.get(args.emailId);
- if (!email) return null;
- await ctx.db.patch(args.emailId, {
- status: "falha",
- erroDetalhes: args.erro,
- tentativas: (email.tentativas || 0) + 1,
- });
- return null;
- },
-});
-
-// Action de envio foi movida para `actions/email.ts`
+// ========== PUBLIC MUTATIONS (MANUAL) ==========
/**
- * Processar fila de emails (cron job - processa emails pendentes)
+ * Processar fila de emails manualmente (para uso em interface)
*/
-export const processarFilaEmails = internalMutation({
- args: {},
- returns: v.object({ processados: v.number(), falhas: v.number() }),
- handler: async (ctx) => {
- // Buscar emails pendentes que não estão agendados para o futuro (max 10 por execução)
- const agora = Date.now();
- const emailsPendentes = await ctx.db
- .query("notificacoesEmail")
- .withIndex("by_status", (q) => q.eq("status", "pendente"))
- .filter((q) =>
- q.or(
- q.eq(q.field("agendadaPara"), undefined),
- q.lte(q.field("agendadaPara"), agora)
- )
- )
- .take(10);
-
- let processados = 0;
- let falhas = 0;
-
- for (const email of emailsPendentes) {
- // Verificar se não excedeu tentativas (max 3)
- if ((email.tentativas || 0) >= 3) {
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: "Número máximo de tentativas excedido",
- });
- falhas++;
- continue;
- }
-
- // Agendar envio via action
- try {
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId: email._id,
- });
- processados++;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
- tentativas: (email.tentativas || 0) + 1,
- });
- falhas++;
- }
- }
-
- if (processados > 0 || falhas > 0) {
- console.log(
- `📧 Fila de emails processada: ${processados} emails agendados, ${falhas} falhas`
- );
- }
-
- return { processados, falhas };
- },
-});
-
-/**
- * Processar fila de emails manualmente (para testes e envio imediato)
- */
-export const processarFilaEmailsManual = mutation({
+export const processarFilaEmailsManual = action({
args: {
limite: v.optional(v.number()),
},
- returns: v.object({
- sucesso: v.boolean(),
- processados: v.number(),
+ returns: v.object({
+ sucesso: v.boolean(),
+ processados: v.number(),
falhas: v.number(),
- erro: v.optional(v.string())
+ erro: v.optional(v.string()),
}),
- handler: async (ctx, args): Promise<{
- sucesso: boolean;
- processados: number;
- falhas: number;
- erro?: string
- }> => {
- const usuarioAtual = await getUsuarioAutenticado(ctx);
- if (!usuarioAtual) {
- return { sucesso: false, processados: 0, falhas: 0, erro: "Usuário não autenticado" };
- }
+ handler: async (ctx, args) => {
+ try {
+ // Buscar emails pendentes
+ const emailsPendentes = await ctx.runQuery(internal.email.listarEmailsPendentes, {
+ limite: args.limite || 10,
+ });
- // Verificar se usuário tem permissão (TI_MASTER ou admin)
- const role = await ctx.db.get(usuarioAtual.roleId);
- if (!role || (role.nivel !== 0 && role.nivel !== 1)) {
- return { sucesso: false, processados: 0, falhas: 0, erro: "Permissão negada" };
- }
-
- const limite = args.limite || 10;
- const agora = Date.now();
-
- // Buscar emails pendentes que não estão agendados para o futuro
- const emailsPendentes = await ctx.db
- .query("notificacoesEmail")
- .withIndex("by_status", (q) => q.eq("status", "pendente"))
- .filter((q) =>
- q.or(
- q.eq(q.field("agendadaPara"), undefined),
- q.lte(q.field("agendadaPara"), agora)
- )
- )
- .take(limite);
-
- let processados = 0;
- let falhas = 0;
-
- for (const email of emailsPendentes) {
- // Verificar se não excedeu tentativas (max 3)
- if ((email.tentativas || 0) >= 3) {
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: "Número máximo de tentativas excedido",
- });
- falhas++;
- continue;
+ if (emailsPendentes.length === 0) {
+ return { sucesso: true, processados: 0, falhas: 0 };
}
- // Agendar envio via action
- try {
- await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
- emailId: email._id,
- });
- processados++;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
- await ctx.db.patch(email._id, {
- status: "falha",
- erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
- tentativas: (email.tentativas || 0) + 1,
- });
- falhas++;
- }
- }
+ let processados = 0;
+ let falhas = 0;
- return { sucesso: true, processados, falhas };
+ // Processar cada email
+ for (const email of emailsPendentes) {
+ try {
+ // Agendar envio via action
+ await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
+ emailId: email._id,
+ });
+ processados++;
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
+ falhas++;
+ }
+ }
+
+ return { sucesso: true, processados, falhas };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ sucesso: false,
+ processados: 0,
+ falhas: 0,
+ erro: errorMessage,
+ };
+ }
},
});
diff --git a/packages/backend/convex/preferenciasNotificacao.ts b/packages/backend/convex/preferenciasNotificacao.ts
new file mode 100644
index 0000000..ede1089
--- /dev/null
+++ b/packages/backend/convex/preferenciasNotificacao.ts
@@ -0,0 +1,136 @@
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import { Id } from "./_generated/dataModel";
+
+/**
+ * Obter preferências de notificação para uma conversa
+ */
+export const obterPreferenciasConversa = query({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ returns: v.union(
+ v.object({
+ pushAtivado: v.boolean(),
+ emailAtivado: v.boolean(),
+ somAtivado: v.boolean(),
+ silenciado: v.boolean(),
+ apenasMencoes: v.boolean(),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return null;
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return null;
+ }
+
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ if (!preferencias) {
+ // Retornar valores padrão
+ return {
+ pushAtivado: true,
+ emailAtivado: true,
+ somAtivado: true,
+ silenciado: false,
+ apenasMencoes: false,
+ };
+ }
+
+ return {
+ pushAtivado: preferencias.pushAtivado,
+ emailAtivado: preferencias.emailAtivado,
+ somAtivado: preferencias.somAtivado,
+ silenciado: preferencias.silenciado,
+ apenasMencoes: preferencias.apenasMencoes,
+ };
+ },
+});
+
+/**
+ * Atualizar preferências de notificação para uma conversa
+ */
+export const atualizarPreferenciasConversa = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ pushAtivado: v.optional(v.boolean()),
+ emailAtivado: v.optional(v.boolean()),
+ somAtivado: v.optional(v.boolean()),
+ silenciado: v.optional(v.boolean()),
+ apenasMencoes: v.optional(v.boolean()),
+ },
+ returns: v.object({ sucesso: v.boolean() }),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return { sucesso: false };
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return { sucesso: false };
+ }
+
+ // Verificar se usuário pertence à conversa
+ const conversa = await ctx.db.get(args.conversaId);
+ if (!conversa || !conversa.participantes.includes(usuario._id)) {
+ return { sucesso: false };
+ }
+
+ const preferenciasExistentes = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
+ )
+ .first();
+
+ const agora = Date.now();
+
+ if (preferenciasExistentes) {
+ // Atualizar preferências existentes
+ await ctx.db.patch(preferenciasExistentes._id, {
+ pushAtivado: args.pushAtivado ?? preferenciasExistentes.pushAtivado,
+ emailAtivado: args.emailAtivado ?? preferenciasExistentes.emailAtivado,
+ somAtivado: args.somAtivado ?? preferenciasExistentes.somAtivado,
+ silenciado: args.silenciado ?? preferenciasExistentes.silenciado,
+ apenasMencoes: args.apenasMencoes ?? preferenciasExistentes.apenasMencoes,
+ atualizadoEm: agora,
+ });
+ } else {
+ // Criar novas preferências com valores padrão
+ await ctx.db.insert("preferenciasNotificacaoConversa", {
+ usuarioId: usuario._id,
+ conversaId: args.conversaId,
+ pushAtivado: args.pushAtivado ?? true,
+ emailAtivado: args.emailAtivado ?? true,
+ somAtivado: args.somAtivado ?? true,
+ silenciado: args.silenciado ?? false,
+ apenasMencoes: args.apenasMencoes ?? false,
+ criadoEm: agora,
+ atualizadoEm: agora,
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
diff --git a/packages/backend/convex/pushNotifications.ts b/packages/backend/convex/pushNotifications.ts
new file mode 100644
index 0000000..f719ab2
--- /dev/null
+++ b/packages/backend/convex/pushNotifications.ts
@@ -0,0 +1,278 @@
+import { v } from "convex/values";
+import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
+import { Id } from "./_generated/dataModel";
+import { internal, api } from "./_generated/api";
+
+/**
+ * Registrar subscription de push notification
+ */
+export const registrarPushSubscription = mutation({
+ args: {
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ userAgent: v.optional(v.string()),
+ },
+ returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
+ handler: async (ctx, args) => {
+ // Obter usuário autenticado
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.email) {
+ return { sucesso: false, erro: "Usuário não autenticado" };
+ }
+
+ const usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+
+ if (!usuario) {
+ return { sucesso: false, erro: "Usuário não encontrado" };
+ }
+
+ // Verificar se já existe subscription com este endpoint
+ const existente = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
+ .first();
+
+ if (existente) {
+ // Atualizar subscription existente
+ await ctx.db.patch(existente._id, {
+ usuarioId: usuario._id,
+ keys: args.keys,
+ userAgent: args.userAgent,
+ ultimaAtividade: Date.now(),
+ ativo: true,
+ });
+ } else {
+ // Criar nova subscription
+ await ctx.db.insert("pushSubscriptions", {
+ usuarioId: usuario._id,
+ endpoint: args.endpoint,
+ keys: args.keys,
+ userAgent: args.userAgent,
+ criadoEm: Date.now(),
+ ultimaAtividade: Date.now(),
+ ativo: true,
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Remover subscription de push notification
+ */
+export const removerPushSubscription = mutation({
+ args: {
+ endpoint: v.string(),
+ },
+ returns: v.object({ sucesso: v.boolean() }),
+ handler: async (ctx, args) => {
+ const subscription = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
+ .first();
+
+ if (subscription) {
+ await ctx.db.patch(subscription._id, { ativo: false });
+ }
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Obter subscriptions ativas de um usuário
+ */
+export const obterPushSubscriptions = internalQuery({
+ args: {
+ usuarioId: v.id("usuarios"),
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id("pushSubscriptions"),
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const subscriptions = await ctx.db
+ .query("pushSubscriptions")
+ .withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId).eq("ativo", true))
+ .collect();
+
+ return subscriptions.map((sub) => ({
+ _id: sub._id,
+ endpoint: sub.endpoint,
+ keys: sub.keys,
+ }));
+ },
+});
+
+/**
+ * Enviar push notification para um usuário
+ * Esta função será chamada quando uma nova mensagem chegar
+ */
+export const enviarPushNotification = internalMutation({
+ args: {
+ usuarioId: v.id("usuarios"),
+ titulo: v.string(),
+ corpo: v.string(),
+ data: v.optional(
+ v.object({
+ conversaId: v.optional(v.id("conversas")),
+ mensagemId: v.optional(v.id("mensagens")),
+ tipo: v.optional(v.string()),
+ })
+ ),
+ },
+ returns: v.object({ enviados: v.number(), falhas: v.number() }),
+ handler: async (ctx, args) => {
+ // Buscar subscriptions ativas do usuário
+ const subscriptions = await ctx.runQuery(internal.pushNotifications.obterPushSubscriptions, {
+ usuarioId: args.usuarioId,
+ });
+
+ if (subscriptions.length === 0) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Verificar preferências do usuário
+ const usuario = await ctx.db.get(args.usuarioId);
+ if (!usuario || usuario.notificacoesAtivadas === false) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Se há conversaId, verificar preferências específicas da conversa
+ const conversaId = args.data?.conversaId;
+ if (conversaId) {
+ const preferencias = await ctx.db
+ .query("preferenciasNotificacaoConversa")
+ .withIndex("by_usuario_conversa", (q) =>
+ q.eq("usuarioId", args.usuarioId).eq("conversaId", conversaId)
+ )
+ .first();
+
+ if (preferencias) {
+ // Se silenciado ou push desativado, não enviar
+ if (preferencias.silenciado || !preferencias.pushAtivado) {
+ return { enviados: 0, falhas: 0 };
+ }
+
+ // Se apenas menções e não é menção, não enviar
+ if (preferencias.apenasMencoes && args.data?.tipo !== "mencao") {
+ return { enviados: 0, falhas: 0 };
+ }
+ }
+ }
+
+ // Agendar envio de push via action (que roda em Node.js)
+ let enviados = 0;
+ let falhas = 0;
+
+ // Converter IDs para strings ao passar para a action
+ // A action espera strings, mas recebemos Ids do Convex
+ const dataParaAction = args.data
+ ? {
+ conversaId: args.data.conversaId ? String(args.data.conversaId) : undefined,
+ mensagemId: args.data.mensagemId ? String(args.data.mensagemId) : undefined,
+ tipo: args.data.tipo,
+ }
+ : undefined;
+
+ for (const subscription of subscriptions) {
+ try {
+ await ctx.scheduler.runAfter(0, api.actions.pushNotifications.enviarPush, {
+ subscriptionId: subscription._id,
+ titulo: args.titulo,
+ corpo: args.corpo,
+ data: dataParaAction,
+ });
+ enviados++;
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`Erro ao agendar push para subscription ${subscription._id}:`, errorMessage);
+ falhas++;
+ }
+ }
+
+ return { enviados, falhas };
+ },
+});
+
+/**
+ * Obter subscription por ID (para actions)
+ */
+export const getSubscriptionById = internalQuery({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ },
+ returns: v.union(
+ v.object({
+ _id: v.id("pushSubscriptions"),
+ endpoint: v.string(),
+ keys: v.object({
+ p256dh: v.string(),
+ auth: v.string(),
+ }),
+ ativo: v.boolean(),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const subscription = await ctx.db.get(args.subscriptionId);
+ if (!subscription) {
+ return null;
+ }
+
+ return {
+ _id: subscription._id,
+ endpoint: subscription.endpoint,
+ keys: subscription.keys,
+ ativo: subscription.ativo,
+ };
+ },
+});
+
+/**
+ * Marcar subscription como inativa
+ */
+export const marcarSubscriptionInativa = internalMutation({
+ args: {
+ subscriptionId: v.id("pushSubscriptions"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.subscriptionId, { ativo: false });
+ return null;
+ },
+});
+
+/**
+ * Verificar se usuário está online (última atividade recente)
+ */
+export const verificarUsuarioOnline = internalQuery({
+ args: {
+ usuarioId: v.id("usuarios"),
+ },
+ returns: v.boolean(),
+ handler: async (ctx, args) => {
+ const usuario = await ctx.db.get(args.usuarioId);
+ if (!usuario || !usuario.ultimaAtividade) {
+ return false;
+ }
+
+ // Considerar online se última atividade foi há menos de 5 minutos
+ const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
+ return usuario.ultimaAtividade >= cincoMinutosAtras;
+ },
+});
+
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index d561bbd..28bfdae 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -268,6 +268,42 @@ export default defineSchema({
.index("by_destinatario", ["destinatarioId"])
.index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
+ // Solicitações de Ausências
+ solicitacoesAusencias: defineTable({
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ motivo: v.string(),
+ status: v.union(
+ v.literal("aguardando_aprovacao"),
+ v.literal("aprovado"),
+ v.literal("reprovado")
+ ),
+ gestorId: v.optional(v.id("usuarios")),
+ dataAprovacao: v.optional(v.number()),
+ dataReprovacao: v.optional(v.number()),
+ motivoReprovacao: v.optional(v.string()),
+ observacao: v.optional(v.string()),
+ criadoEm: v.number(),
+ })
+ .index("by_funcionario", ["funcionarioId"])
+ .index("by_status", ["status"])
+ .index("by_funcionario_and_status", ["funcionarioId", "status"]),
+
+ notificacoesAusencias: defineTable({
+ destinatarioId: v.id("usuarios"),
+ solicitacaoAusenciaId: v.id("solicitacoesAusencias"),
+ tipo: v.union(
+ v.literal("nova_solicitacao"),
+ v.literal("aprovado"),
+ v.literal("reprovado")
+ ),
+ lida: v.boolean(),
+ mensagem: v.string(),
+ })
+ .index("by_destinatario", ["destinatarioId"])
+ .index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
+
// Períodos aquisitivos e saldos de férias
periodosAquisitivos: defineTable({
funcionarioId: v.id("funcionarios"),
@@ -568,12 +604,26 @@ export default defineSchema({
descricao: v.string(),
}).index("by_chave", ["chave"]),
+ // Rate Limiting de Emails
+ rateLimitEmails: defineTable({
+ remetenteId: v.id("usuarios"),
+ timestamp: v.number(),
+ contador: v.number(), // quantidade de emails enviados neste período
+ periodo: v.union(
+ v.literal("minuto"), // último minuto
+ v.literal("hora") // última hora
+ ),
+ })
+ .index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"])
+ .index("by_timestamp", ["timestamp"]),
+
// 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"),
@@ -592,10 +642,20 @@ export default defineSchema({
v.literal("imagem")
),
conteudo: v.string(), // texto ou nome do arquivo
+ conteudoBusca: v.optional(v.string()), // versão normalizada para busca
arquivoId: v.optional(v.id("_storage")),
arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
+ linkPreview: v.optional(
+ v.object({
+ url: v.string(),
+ titulo: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ imagem: v.optional(v.string()),
+ site: v.optional(v.string()),
+ })
+ ),
reagiuPor: v.optional(
v.array(
v.object({
@@ -605,6 +665,7 @@ export default defineSchema({
)
),
mencoes: v.optional(v.array(v.id("usuarios"))),
+ respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
agendadaPara: v.optional(v.number()), // timestamp
enviadaEm: v.number(),
editadaEm: v.optional(v.number()),
@@ -612,7 +673,8 @@ export default defineSchema({
})
.index("by_conversa", ["conversaId", "enviadaEm"])
.index("by_remetente", ["remetenteId"])
- .index("by_agendamento", ["agendadaPara"]),
+ .index("by_agendamento", ["agendadaPara"])
+ .index("by_resposta", ["respostaPara"]),
leituras: defineTable({
conversaId: v.id("conversas"),
@@ -650,6 +712,37 @@ export default defineSchema({
.index("by_conversa", ["conversaId", "iniciouEm"])
.index("by_usuario", ["usuarioId"]),
+ // Push Notifications
+ pushSubscriptions: defineTable({
+ usuarioId: v.id("usuarios"),
+ endpoint: v.string(), // URL do serviço de push
+ keys: v.object({
+ p256dh: v.string(), // Chave pública
+ auth: v.string(), // Chave de autenticação
+ }),
+ userAgent: v.optional(v.string()),
+ criadoEm: v.number(),
+ ultimaAtividade: v.number(),
+ ativo: v.boolean(),
+ })
+ .index("by_usuario", ["usuarioId", "ativo"])
+ .index("by_endpoint", ["endpoint"]),
+
+ // Preferências de Notificação por Conversa
+ preferenciasNotificacaoConversa: defineTable({
+ usuarioId: v.id("usuarios"),
+ conversaId: v.id("conversas"),
+ pushAtivado: v.boolean(), // Receber push notifications
+ emailAtivado: v.boolean(), // Receber emails quando offline
+ somAtivado: v.boolean(), // Tocar som
+ silenciado: v.boolean(), // Silenciar completamente
+ apenasMencoes: v.boolean(), // Notificar apenas quando mencionado
+ criadoEm: v.number(),
+ atualizadoEm: v.number(),
+ })
+ .index("by_usuario_conversa", ["usuarioId", "conversaId"])
+ .index("by_conversa", ["conversaId"]),
+
// Tabelas de Monitoramento do Sistema
systemMetrics: defineTable({
timestamp: v.number(),
diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts
index c1e5959..d76bc11 100644
--- a/packages/backend/convex/templatesMensagens.ts
+++ b/packages/backend/convex/templatesMensagens.ts
@@ -1,262 +1,312 @@
-import { v } from "convex/values";
-import { mutation, query } from "./_generated/server";
-import { registrarAtividade } from "./logsAtividades";
-import { Doc } from "./_generated/dataModel";
-
-/**
- * Listar todos os templates
- */
-export const listarTemplates = query({
- args: {},
- handler: async (ctx) => {
- const templates = await ctx.db.query("templatesMensagens").collect();
- return templates;
- },
-});
-
-/**
- * Obter template por código
- */
-export const obterTemplatePorCodigo = query({
- args: {
- codigo: v.string(),
- },
- handler: async (ctx, args) => {
- const template = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
- .first();
-
- return template;
- },
-});
-
-/**
- * Criar template customizado (apenas TI_MASTER)
- */
-export const criarTemplate = mutation({
- args: {
- codigo: v.string(),
- nome: v.string(),
- titulo: v.string(),
- corpo: v.string(),
- variaveis: v.optional(v.array(v.string())),
- criadoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- // Verificar se código já existe
- const existente = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
- .first();
-
- if (existente) {
- return { sucesso: false as const, erro: "Código de template já existe" };
- }
-
- // Criar template
- const templateId = await ctx.db.insert("templatesMensagens", {
- codigo: args.codigo,
- nome: args.nome,
- tipo: "customizado",
- titulo: args.titulo,
- corpo: args.corpo,
- variaveis: args.variaveis,
- criadoPor: args.criadoPorId,
- criadoEm: Date.now(),
- });
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.criadoPorId,
- "criar",
- "templates",
- JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
- templateId
- );
-
- return { sucesso: true as const, templateId };
- },
-});
-
-/**
- * Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
- */
-export const editarTemplate = mutation({
- args: {
- templateId: v.id("templatesMensagens"),
- nome: v.optional(v.string()),
- titulo: v.optional(v.string()),
- corpo: v.optional(v.string()),
- variaveis: v.optional(v.array(v.string())),
- editadoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true) }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- const template = await ctx.db.get(args.templateId);
- if (!template) {
- return { sucesso: false as const, erro: "Template não encontrado" };
- }
-
- // Não permite editar templates do sistema
- if (template.tipo === "sistema") {
- return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
- }
-
- // Atualizar template
- const updates: Partial> = {};
- if (args.nome !== undefined) updates.nome = args.nome;
- if (args.titulo !== undefined) updates.titulo = args.titulo;
- if (args.corpo !== undefined) updates.corpo = args.corpo;
- if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
-
- await ctx.db.patch(args.templateId, updates);
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.editadoPorId,
- "editar",
- "templates",
- JSON.stringify(updates),
- args.templateId
- );
-
- return { sucesso: true as const };
- },
-});
-
-/**
- * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
- */
-export const excluirTemplate = mutation({
- args: {
- templateId: v.id("templatesMensagens"),
- excluidoPorId: v.id("usuarios"),
- },
- returns: v.union(
- v.object({ sucesso: v.literal(true) }),
- v.object({ sucesso: v.literal(false), erro: v.string() })
- ),
- handler: async (ctx, args) => {
- const template = await ctx.db.get(args.templateId);
- if (!template) {
- return { sucesso: false as const, erro: "Template não encontrado" };
- }
-
- // Não permite excluir templates do sistema
- if (template.tipo === "sistema") {
- return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
- }
-
- // Excluir template
- await ctx.db.delete(args.templateId);
-
- // Log de atividade
- await registrarAtividade(
- ctx,
- args.excluidoPorId,
- "excluir",
- "templates",
- JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
- args.templateId
- );
-
- return { sucesso: true as const };
- },
-});
-
-/**
- * Renderizar template com variáveis
- */
-export function renderizarTemplate(template: string, variaveis: Record): string {
- let resultado = template;
-
- for (const [chave, valor] of Object.entries(variaveis)) {
- const placeholder = `{{${chave}}}`;
- resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
- }
-
- return resultado;
-}
-
-/**
- * Criar templates padrão do sistema (chamado no seed)
- */
-export const criarTemplatesPadrao = mutation({
- args: {},
- handler: async (ctx) => {
- const templatesPadrao = [
- {
- codigo: "USUARIO_BLOQUEADO",
- nome: "Usuário Bloqueado",
- titulo: "Sua conta foi bloqueada",
- corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
- variaveis: ["motivo"],
- },
- {
- codigo: "USUARIO_DESBLOQUEADO",
- nome: "Usuário Desbloqueado",
- titulo: "Sua conta foi desbloqueada",
- corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
- variaveis: [],
- },
- {
- codigo: "SENHA_RESETADA",
- nome: "Senha Resetada",
- titulo: "Sua senha foi resetada",
- corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
- variaveis: ["senha"],
- },
- {
- codigo: "PERMISSAO_ALTERADA",
- nome: "Permissão Alterada",
- titulo: "Suas permissões foram atualizadas",
- corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
- variaveis: [],
- },
- {
- codigo: "AVISO_GERAL",
- nome: "Aviso Geral",
- titulo: "{{titulo}}",
- corpo: "{{mensagem}}",
- variaveis: ["titulo", "mensagem"],
- },
- {
- codigo: "BEM_VINDO",
- nome: "Boas-vindas",
- titulo: "Bem-vindo ao SGSE",
- corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
- variaveis: ["nome", "matricula", "senha"],
- },
- ];
-
- for (const template of templatesPadrao) {
- // Verificar se já existe
- const existente = await ctx.db
- .query("templatesMensagens")
- .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
- .first();
-
- if (!existente) {
- await ctx.db.insert("templatesMensagens", {
- ...template,
- tipo: "sistema",
- criadoEm: Date.now(),
- });
- }
- }
-
- return { sucesso: true };
- },
-});
-
-
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import { registrarAtividade } from "./logsAtividades";
+import { Doc } from "./_generated/dataModel";
+
+/**
+ * Listar todos os templates
+ */
+export const listarTemplates = query({
+ args: {},
+ handler: async (ctx) => {
+ const templates = await ctx.db.query("templatesMensagens").collect();
+ return templates;
+ },
+});
+
+/**
+ * Obter template por código
+ */
+export const obterTemplatePorCodigo = query({
+ args: {
+ codigo: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const template = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
+ .first();
+
+ return template;
+ },
+});
+
+/**
+ * Criar template customizado (apenas TI_MASTER)
+ */
+export const criarTemplate = mutation({
+ args: {
+ codigo: v.string(),
+ nome: v.string(),
+ titulo: v.string(),
+ corpo: v.string(),
+ variaveis: v.optional(v.array(v.string())),
+ criadoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ // Verificar se código já existe
+ const existente = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
+ .first();
+
+ if (existente) {
+ return { sucesso: false as const, erro: "Código de template já existe" };
+ }
+
+ // Criar template
+ const templateId = await ctx.db.insert("templatesMensagens", {
+ codigo: args.codigo,
+ nome: args.nome,
+ tipo: "customizado",
+ titulo: args.titulo,
+ corpo: args.corpo,
+ variaveis: args.variaveis,
+ criadoPor: args.criadoPorId,
+ criadoEm: Date.now(),
+ });
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.criadoPorId,
+ "criar",
+ "templates",
+ JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
+ templateId
+ );
+
+ return { sucesso: true as const, templateId };
+ },
+});
+
+/**
+ * Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
+ */
+export const editarTemplate = mutation({
+ args: {
+ templateId: v.id("templatesMensagens"),
+ nome: v.optional(v.string()),
+ titulo: v.optional(v.string()),
+ corpo: v.optional(v.string()),
+ variaveis: v.optional(v.array(v.string())),
+ editadoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true) }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ const template = await ctx.db.get(args.templateId);
+ if (!template) {
+ return { sucesso: false as const, erro: "Template não encontrado" };
+ }
+
+ // Não permite editar templates do sistema
+ if (template.tipo === "sistema") {
+ return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
+ }
+
+ // Atualizar template
+ const updates: Partial> = {};
+ if (args.nome !== undefined) updates.nome = args.nome;
+ if (args.titulo !== undefined) updates.titulo = args.titulo;
+ if (args.corpo !== undefined) updates.corpo = args.corpo;
+ if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
+
+ await ctx.db.patch(args.templateId, updates);
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.editadoPorId,
+ "editar",
+ "templates",
+ JSON.stringify(updates),
+ args.templateId
+ );
+
+ return { sucesso: true as const };
+ },
+});
+
+/**
+ * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
+ */
+export const excluirTemplate = mutation({
+ args: {
+ templateId: v.id("templatesMensagens"),
+ excluidoPorId: v.id("usuarios"),
+ },
+ returns: v.union(
+ v.object({ sucesso: v.literal(true) }),
+ v.object({ sucesso: v.literal(false), erro: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ const template = await ctx.db.get(args.templateId);
+ if (!template) {
+ return { sucesso: false as const, erro: "Template não encontrado" };
+ }
+
+ // Não permite excluir templates do sistema
+ if (template.tipo === "sistema") {
+ return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
+ }
+
+ // Excluir template
+ await ctx.db.delete(args.templateId);
+
+ // Log de atividade
+ await registrarAtividade(
+ ctx,
+ args.excluidoPorId,
+ "excluir",
+ "templates",
+ JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
+ args.templateId
+ );
+
+ return { sucesso: true as const };
+ },
+});
+
+/**
+ * Renderizar template com variáveis
+ */
+export function renderizarTemplate(template: string, variaveis: Record): string {
+ let resultado = template;
+
+ for (const [chave, valor] of Object.entries(variaveis)) {
+ const placeholder = `{{${chave}}}`;
+ resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
+ }
+
+ return resultado;
+}
+
+/**
+ * Criar templates padrão do sistema (chamado no seed)
+ */
+export const criarTemplatesPadrao = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const templatesPadrao = [
+ {
+ codigo: "USUARIO_BLOQUEADO",
+ nome: "Usuário Bloqueado",
+ titulo: "Sua conta foi bloqueada",
+ corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
+ variaveis: ["motivo"],
+ },
+ {
+ codigo: "USUARIO_DESBLOQUEADO",
+ nome: "Usuário Desbloqueado",
+ titulo: "Sua conta foi desbloqueada",
+ corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
+ variaveis: [],
+ },
+ {
+ codigo: "SENHA_RESETADA",
+ nome: "Senha Resetada",
+ titulo: "Sua senha foi resetada",
+ corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
+ variaveis: ["senha"],
+ },
+ {
+ codigo: "PERMISSAO_ALTERADA",
+ nome: "Permissão Alterada",
+ titulo: "Suas permissões foram atualizadas",
+ corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
+ variaveis: [],
+ },
+ {
+ codigo: "AVISO_GERAL",
+ nome: "Aviso Geral",
+ titulo: "{{titulo}}",
+ corpo: "{{mensagem}}",
+ variaveis: ["titulo", "mensagem"],
+ },
+ {
+ codigo: "BEM_VINDO",
+ nome: "Boas-vindas",
+ titulo: "Bem-vindo ao SGSE",
+ corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
+ variaveis: ["nome", "matricula", "senha"],
+ },
+ {
+ codigo: "chat_mensagem",
+ nome: "Nova Mensagem no Chat",
+ titulo: "Nova mensagem de {{remetente}}",
+ corpo: ""
+ + ""
+ + "
Nova mensagem no chat "
+ + "
{{remetente}} enviou uma nova mensagem:
"
+ + "
"
+ + "
{{mensagem}}
"
+ + "
"
+ + "
"
+ + ""
+ + "Ver conversa"
+ + " "
+ + "
"
+ + "
"
+ + "Você está recebendo este email porque não estava online quando a mensagem foi enviada. "
+ + "Você pode desativar essas notificações nas configurações da conversa."
+ + "
"
+ + "
",
+ variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
+ },
+ {
+ codigo: "chat_mencao",
+ nome: "Menção no Chat",
+ titulo: "{{remetente}} mencionou você",
+ corpo: ""
+ + ""
+ + "
Você foi mencionado! "
+ + "
{{remetente}} mencionou você em uma mensagem:
"
+ + "
"
+ + "
{{mensagem}}
"
+ + "
"
+ + "
"
+ + ""
+ + "Ver mensagem"
+ + " "
+ + "
"
+ + "
"
+ + "Você está recebendo este email porque foi mencionado em uma conversa. "
+ + "Você pode desativar essas notificações nas configurações da conversa."
+ + "
"
+ + "
",
+ variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
+ },
+ ];
+
+ for (const template of templatesPadrao) {
+ // Verificar se já existe
+ const existente = await ctx.db
+ .query("templatesMensagens")
+ .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
+ .first();
+
+ if (!existente) {
+ await ctx.db.insert("templatesMensagens", {
+ ...template,
+ tipo: "sistema",
+ criadoEm: Date.now(),
+ });
+ }
+ }
+
+ return { sucesso: true };
+ },
+});
+
+
diff --git a/packages/backend/convex/types/web-push.d.ts b/packages/backend/convex/types/web-push.d.ts
new file mode 100644
index 0000000..2eeec01
--- /dev/null
+++ b/packages/backend/convex/types/web-push.d.ts
@@ -0,0 +1,46 @@
+declare module "web-push" {
+ export interface PushSubscription {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+ }
+
+ export interface SendOptions {
+ TTL?: number;
+ headers?: Record;
+ vapidDetails?: {
+ subject: string;
+ publicKey: string;
+ privateKey: string;
+ };
+ }
+
+ export function setVapidDetails(
+ subject: string,
+ publicKey: string,
+ privateKey: string
+ ): void;
+
+ export function sendNotification(
+ subscription: PushSubscription,
+ payload: string | Buffer,
+ options?: SendOptions
+ ): Promise;
+
+ export function generateVAPIDKeys(): {
+ publicKey: string;
+ privateKey: string;
+ };
+
+ interface WebPush {
+ setVapidDetails: typeof setVapidDetails;
+ sendNotification: typeof sendNotification;
+ generateVAPIDKeys: typeof generateVAPIDKeys;
+ }
+
+ const webpush: WebPush;
+ export default webpush;
+}
+
diff --git a/packages/backend/convex/usuarios.ts b/packages/backend/convex/usuarios.ts
index 95946d9..c5b2f0f 100644
--- a/packages/backend/convex/usuarios.ts
+++ b/packages/backend/convex/usuarios.ts
@@ -4,7 +4,7 @@ import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { Id, Doc } from "./_generated/dataModel";
import { api } from "./_generated/api";
-import type { QueryCtx } from "./_generated/server";
+import type { QueryCtx, MutationCtx } from "./_generated/server";
/**
* Helper para obter a matrícula do usuário (do funcionário se houver)
@@ -20,6 +20,38 @@ async function obterMatriculaUsuario(
return undefined;
}
+/**
+ * Helper para obter usuário autenticado (Better Auth ou Sessão)
+ * Usa a mesma lógica do obterPerfil para garantir consistência
+ */
+async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
+ // Tentar autenticação via Better Auth primeiro
+ const identity = await ctx.auth.getUserIdentity();
+ let usuarioAtual = null;
+
+ if (identity && identity.email) {
+ usuarioAtual = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+ }
+
+ // Se não encontrou via Better Auth, tentar via sessão mais recente
+ if (!usuarioAtual) {
+ const sessaoAtiva = await ctx.db
+ .query("sessoes")
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .order("desc")
+ .first();
+
+ if (sessaoAtiva) {
+ usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
+ }
+ }
+
+ return usuarioAtual;
+}
+
/**
* Associar funcionário a um usuário
*/
@@ -761,15 +793,25 @@ export const listarParaChat = query({
})
),
handler: async (ctx) => {
+ // Obter usuário autenticado usando função helper compartilhada
+ const usuarioAtual = await getUsuarioAutenticado(ctx);
+
// Buscar todos os usuários ativos
const usuarios = await ctx.db
.query("usuarios")
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
+ // Filtrar o usuário atual da lista apenas se conseguimos identificá-lo com certeza
+ // Se não conseguimos identificar (usuarioAtual é null), retornar todos
+ // O frontend fará um filtro adicional usando obterPerfil como camada de segurança
+ const usuariosFiltrados = usuarioAtual
+ ? usuarios.filter((u) => u._id !== usuarioAtual._id)
+ : usuarios;
+
// Buscar foto de perfil URL para cada usuário
const usuariosComFoto = await Promise.all(
- usuarios.map(async (usuario) => {
+ usuariosFiltrados.map(async (usuario) => {
let fotoPerfilUrl = null;
if (usuario.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
diff --git a/scripts/configurar-push-notifications.ps1 b/scripts/configurar-push-notifications.ps1
new file mode 100644
index 0000000..733c2ce
--- /dev/null
+++ b/scripts/configurar-push-notifications.ps1
@@ -0,0 +1,71 @@
+# Script para configurar Push Notifications
+# Execute este script após iniciar o Convex (npx convex dev)
+
+Write-Host "🔔 Configurando Push Notifications..." -ForegroundColor Cyan
+Write-Host ""
+
+# Verificar se estamos no diretório correto
+if (-not (Test-Path "packages/backend")) {
+ Write-Host "❌ Erro: Execute este script da raiz do projeto" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "📝 Configurando variáveis de ambiente no Convex..." -ForegroundColor Yellow
+Write-Host ""
+
+cd packages/backend
+
+# VAPID Keys geradas
+$VAPID_PUBLIC_KEY = "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks"
+$VAPID_PRIVATE_KEY = "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4"
+$FRONTEND_URL = "http://localhost:5173"
+
+Write-Host "Configurando VAPID_PUBLIC_KEY..." -ForegroundColor Gray
+npx convex env set VAPID_PUBLIC_KEY $VAPID_PUBLIC_KEY
+
+Write-Host "Configurando VAPID_PRIVATE_KEY..." -ForegroundColor Gray
+npx convex env set VAPID_PRIVATE_KEY $VAPID_PRIVATE_KEY
+
+Write-Host "Configurando FRONTEND_URL..." -ForegroundColor Gray
+npx convex env set FRONTEND_URL $FRONTEND_URL
+
+Write-Host ""
+Write-Host "✅ Variáveis de ambiente configuradas no Convex!" -ForegroundColor Green
+Write-Host ""
+
+cd ../..
+
+# Configurar arquivo .env do frontend
+Write-Host "📝 Criando arquivo .env no frontend..." -ForegroundColor Yellow
+
+$envContent = @"
+# VAPID Public Key para Push Notifications
+VITE_VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY
+"@
+
+$envPath = "apps/web/.env"
+
+if (Test-Path $envPath) {
+ Write-Host "⚠️ Arquivo .env já existe. Adicionando VAPID_PUBLIC_KEY..." -ForegroundColor Yellow
+ # Verificar se já existe a variável
+ $currentContent = Get-Content $envPath -Raw
+ if ($currentContent -notmatch "VITE_VAPID_PUBLIC_KEY") {
+ Add-Content $envPath "`n$envContent"
+ Write-Host "✅ VAPID_PUBLIC_KEY adicionada ao .env" -ForegroundColor Green
+ } else {
+ Write-Host "ℹ️ VITE_VAPID_PUBLIC_KEY já existe no .env" -ForegroundColor Cyan
+ }
+} else {
+ Set-Content $envPath $envContent
+ Write-Host "✅ Arquivo .env criado em apps/web/.env" -ForegroundColor Green
+}
+
+Write-Host ""
+Write-Host "✨ Configuração concluída!" -ForegroundColor Green
+Write-Host ""
+Write-Host "📋 Próximos passos:" -ForegroundColor Cyan
+Write-Host "1. Reinicie o servidor Convex (se estiver rodando)" -ForegroundColor White
+Write-Host "2. Reinicie o servidor frontend (se estiver rodando)" -ForegroundColor White
+Write-Host "3. Teste as push notifications conforme o guia de testes" -ForegroundColor White
+Write-Host ""
+