-
- Nova Solicitação de Ausência
-
-
+
{/if}
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
index 356f023..47809cc 100644
--- a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
@@ -1,48 +1,43 @@
-
-
-
-
-
-
-
-
-
-
Programas Esportivos
-
Gestão de programas e projetos esportivos
-
-
-
-
-
-
-
-
-
Módulo em Desenvolvimento
-
- O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos.
-
-
-
- Em Desenvolvimento
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
Programas Esportivos
+
Gestão de programas e projetos esportivos
+
+
+
+
+
+
+
+
+
Módulo em Desenvolvimento
+
+ O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos.
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte
index 90cf160..f3e9bec 100644
--- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte
@@ -1,281 +1,253 @@
-
-
-
-
-
-
Recursos Humanos
-
- Gerencie funcionários, símbolos e visualize relatórios do departamento
-
-
-
-
- {#if statsQuery.data}
-
-
-
-
-
Total
-
{statsQuery.data.totalFuncionarios}
-
Funcionários cadastrados
-
-
-
-
-
-
-
Ativos
-
{statsQuery.data.funcionariosAtivos}
-
Funcionários ativos
-
-
-
-
-
-
-
Símbolos
-
{statsQuery.data.totalSimbolos}
-
Cargos e funções
-
-
-
-
-
-
-
CC / FG
-
{statsQuery.data.cargoComissionado} / {statsQuery.data.funcaoGratificada}
-
Distribuição
-
-
-
- {/if}
-
-
-
- {#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 de Recursos Humanos.
-
-
-
-
-
-
+
+
+
+
+
+
Recursos Humanos
+
+ Gerencie funcionários, símbolos e visualize relatórios do departamento
+
+
+
+
+ {#if statsQuery.data}
+
+
+
+
+
+
+
Total
+
{statsQuery.data.totalFuncionarios}
+
Funcionários cadastrados
+
+
+
+
+
+
+
+
+
Ativos
+
{statsQuery.data.funcionariosAtivos}
+
Funcionários ativos
+
+
+
+
+
+
+
+
+
Símbolos
+
{statsQuery.data.totalSimbolos}
+
Cargos e funções
+
+
+
+
+
+
+
+
+
CC / FG
+
{statsQuery.data.cargoComissionado} / {statsQuery.data.funcaoGratificada}
+
Distribuição
+
+
+
+ {/if}
+
+
+
+ {#each menuItems as categoria}
+
+
+
+
+
+
+
+
+
+ {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 de Recursos Humanos.
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
index ff7e182..78a5641 100644
--- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
@@ -208,6 +208,21 @@
templates.find((t) => t._id === templateId),
);
+ // Função para renderizar template com variáveis (similar à função do backend)
+ 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;
+ }
+
// Função para mostrar mensagens
function mostrarMensagem(tipo: "success" | "error" | "info", texto: string) {
mensagem = { tipo, texto };
@@ -733,8 +748,11 @@
);
if (conversaId) {
- const mensagem = usarTemplate
- ? templateSelecionado?.corpo || ""
+ const mensagem = usarTemplate && templateSelecionado
+ ? renderizarTemplate(templateSelecionado.corpo, {
+ nome: destinatario.nome,
+ matricula: destinatario.matricula || "",
+ })
: mensagemPersonalizada;
if (agendadaPara) {
@@ -988,10 +1006,12 @@
);
if (conversaId) {
- // Para templates, usar corpo direto (o backend já faz substituição via email)
- // Para mensagem personalizada, usar diretamente
- const mensagem = usarTemplate
- ? templateSelecionado?.corpo || ""
+ // Renderizar template com variáveis do destinatário
+ const mensagem = usarTemplate && templateSelecionado
+ ? renderizarTemplate(templateSelecionado.corpo, {
+ nome: destinatario.nome,
+ matricula: destinatario.matricula || "",
+ })
: mensagemPersonalizada;
if (agendadaPara) {
diff --git a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte
index 0349c24..d005651 100644
--- a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte
@@ -2,6 +2,7 @@
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import StatsCard from "$lib/components/ti/StatsCard.svelte";
+ import { BarChart3, Users, CheckCircle2, Ban, Clock, Plus, Layers, FileText, Info } from "lucide-svelte";
const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {});
@@ -45,9 +46,7 @@
Dashboard Administrativo TI
@@ -62,7 +61,7 @@
@@ -70,7 +69,7 @@
title="Usuários Ativos"
value={stats.ativos}
description="{stats.total > 0 ? ((stats.ativos / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
- icon='
'
+ Icon={CheckCircle2}
color="success"
/>
@@ -78,7 +77,7 @@
title="Usuários Bloqueados"
value={stats.bloqueados}
description="Requerem atenção"
- icon='
'
+ Icon={Ban}
color="error"
/>
@@ -86,7 +85,7 @@
title="Usuários Inativos"
value={stats.inativos}
description="Desativados"
- icon='
'
+ Icon={Clock}
color="warning"
/>
@@ -102,23 +101,17 @@
Ações Rápidas
@@ -127,9 +120,7 @@
-
+
Sistema de Gestão da Secretaria de Esportes - Versão 2.0 com controle avançado de acesso
diff --git a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte
index e61b8e5..0da0cea 100644
--- a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte
@@ -6,6 +6,7 @@
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
+ import { Users, Shield, AlertTriangle, Info, Building2 } from "lucide-svelte";
type Role = {
_id: Id<"roles">;
@@ -174,35 +175,35 @@
diff --git a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte
index 1034330..43dfb7f 100644
--- a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte
@@ -6,6 +6,7 @@
import StatsCard from "$lib/components/ti/StatsCard.svelte";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
+ import { FileText, Clock, CheckCircle2, XCircle } from "lucide-svelte";
type StatusSolicitacao = "pendente" | "aprovado" | "rejeitado";
@@ -294,7 +295,7 @@
@@ -302,7 +303,7 @@
title="Pendentes"
value={stats.pendentes}
description="{stats.total > 0 ? ((stats.pendentes / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
- icon=''
+ Icon={Clock}
color="warning"
/>
@@ -310,7 +311,7 @@
title="Aprovadas"
value={stats.aprovadas}
description="{stats.total > 0 ? ((stats.aprovadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
- icon=''
+ Icon={CheckCircle2}
color="success"
/>
@@ -318,7 +319,7 @@
title="Rejeitadas"
value={stats.rejeitadas}
description="{stats.total > 0 ? ((stats.rejeitadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
- icon=''
+ Icon={XCircle}
color="error"
/>
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index e13ff68..e43a543 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -4,4 +4,7 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
+ resolve: {
+ dedupe: ["lucide-svelte"],
+ },
});
diff --git a/bun.lock b/bun.lock
index 16a9a59..fca6f51 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,7 @@
"dependencies": {
"@tanstack/svelte-form": "^1.23.8",
"chart.js": "^4.5.1",
- "lucide-svelte": "^0.548.0",
+ "lucide-svelte": "^0.552.0",
"svelte-chartjs": "^3.1.5",
"svelte-sonner": "^1.0.5",
},
@@ -61,7 +61,7 @@
"version": "1.0.0",
"dependencies": {
"@dicebear/avataaars": "^9.2.4",
- "convex": "^1.17.4",
+ "convex": "catalog:",
"nodemailer": "^7.0.10",
},
"devDependencies": {
@@ -624,7 +624,7 @@
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
- "lucide-svelte": ["lucide-svelte@0.548.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-aW2BfHWBLWf/XPSKytTPV16AWfFeFIJeUyOg7eHY2rhzVQ0u0LIvoS4pm2oskr+OJVw+NsS8fPvlBVqPfUO1XQ=="],
+ "lucide-svelte": ["lucide-svelte@0.552.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
diff --git a/package.json b/package.json
index f8b266d..9088df1 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"dependencies": {
"@tanstack/svelte-form": "^1.23.8",
"chart.js": "^4.5.1",
- "lucide-svelte": "^0.548.0",
+ "lucide-svelte": "^0.552.0",
"svelte-chartjs": "^3.1.5",
"svelte-sonner": "^1.0.5"
},
diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts
index b36b449..b2f024d 100644
--- a/packages/backend/convex/chat.ts
+++ b/packages/backend/convex/chat.ts
@@ -346,6 +346,7 @@ export const enviarMensagem = mutation({
mencoes: args.mencoes,
respostaPara: args.respostaPara,
enviadaEm: Date.now(),
+ lidaPor: [], // Inicializar como array vazio
});
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
@@ -369,6 +370,7 @@ export const enviarMensagem = mutation({
await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100),
ultimaMensagemTimestamp: Date.now(),
+ ultimaMensagemRemetenteId: usuarioAtual._id, // Guardar ID do remetente da última mensagem
});
// Criar notificações para participantes (com tratamento de erro)
@@ -494,14 +496,19 @@ export const agendarMensagem = mutation({
throw new Error("Você não pertence a esta conversa");
}
+ // Normalizar conteúdo para busca
+ const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
+
// Criar mensagem agendada
const mensagemId = await ctx.db.insert("mensagens", {
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
tipo: "texto",
conteudo: args.conteudo,
+ conteudoBusca,
agendadaPara: args.agendadaPara,
- enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada
+ enviadaEm: args.agendadaPara, // Será atualizado quando a mensagem for enviada
+ lidaPor: [], // Inicializar como array vazio
});
return mensagemId;
@@ -661,6 +668,29 @@ export const marcarComoLida = mutation({
});
}
+ // Atualizar status de leitura nas mensagens
+ // Buscar todas as mensagens até a mensagem atual (incluindo ela) na conversa
+ const todasMensagens = await ctx.db
+ .query("mensagens")
+ .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
+ .filter((q) =>
+ q.and(
+ q.lte(q.field("enviadaEm"), mensagem.enviadaEm),
+ q.neq(q.field("remetenteId"), usuarioAtual._id) // Apenas mensagens de outros usuários
+ )
+ )
+ .collect();
+
+ // Atualizar cada mensagem para incluir o usuário atual no array lidaPor (se ainda não estiver)
+ for (const msg of todasMensagens) {
+ const lidaPor = msg.lidaPor || [];
+ if (!lidaPor.includes(usuarioAtual._id)) {
+ await ctx.db.patch(msg._id, {
+ lidaPor: [...lidaPor, usuarioAtual._id],
+ });
+ }
+ }
+
// Marcar notificações desta conversa como lidas
const notificacoes = await ctx.db
.query("notificacoes")
@@ -825,6 +855,54 @@ export const marcarTodasNotificacoesLidas = mutation({
},
});
+/**
+ * Deleta todas as notificações do usuário
+ * SEGURANÇA: Usuário só pode deletar suas próprias notificações
+ */
+export const limparTodasNotificacoes = 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", (q) => q.eq("usuarioId", usuarioAtual._id))
+ .collect();
+
+ for (const notificacao of notificacoes) {
+ await ctx.db.delete(notificacao._id);
+ }
+
+ return { excluidas: notificacoes.length };
+ },
+});
+
+/**
+ * Deleta apenas as notificações não lidas do usuário
+ * SEGURANÇA: Usuário só pode deletar suas próprias notificações
+ */
+export const limparNotificacoesNaoLidas = 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.delete(notificacao._id);
+ }
+
+ return { excluidas: notificacoes.length };
+ },
+});
+
/**
* Deleta uma mensagem (soft delete)
*/
@@ -1300,6 +1378,193 @@ export const rebaixarAdministrador = mutation({
},
});
+/**
+ * Permite que um usuário saia de um grupo ou sala de reunião
+ */
+export const sairGrupoOuSala = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ 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: "Conversa não encontrada" };
+ }
+
+ // Verificar se é grupo ou sala de reunião
+ if (conversa.tipo !== "grupo" && conversa.tipo !== "sala_reuniao") {
+ return { sucesso: false, erro: "Esta funcionalidade é apenas para grupos e salas de reunião" };
+ }
+
+ // Verificar se usuário é participante
+ if (!conversa.participantes.includes(usuarioAtual._id)) {
+ return { sucesso: false, erro: "Você não é participante desta conversa" };
+ }
+
+ // Remover usuário dos participantes
+ const novosParticipantes = conversa.participantes.filter((p) => p !== usuarioAtual._id);
+
+ // Se for sala de reunião e o usuário for administrador, removê-lo também dos administradores
+ let novosAdministradores = conversa.administradores;
+ if (conversa.tipo === "sala_reuniao" && conversa.administradores) {
+ novosAdministradores = conversa.administradores.filter((adminId) => adminId !== usuarioAtual._id);
+ }
+
+ await ctx.db.patch(args.conversaId, {
+ participantes: novosParticipantes,
+ administradores: novosAdministradores && novosAdministradores.length > 0 ? novosAdministradores : undefined,
+ });
+
+ // Criar notificação para outros participantes informando que o usuário saiu
+ const tipoTexto = conversa.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
+ for (const participanteId of novosParticipantes) {
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: "nova_mensagem",
+ conversaId: args.conversaId,
+ remetenteId: usuarioAtual._id,
+ titulo: "Participante saiu",
+ descricao: `${usuarioAtual.nome} saiu da ${tipoTexto} "${conversa.nome || "Sem nome"}"`,
+ lida: false,
+ criadaEm: Date.now(),
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Encerra uma sala de reunião (apenas administradores)
+ * Remove todos os participantes e marca a sala como encerrada
+ */
+export const encerrarReuniao = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ },
+ 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 encerrar a reunião" };
+ }
+
+ // Criar notificação para todos os participantes informando que a reunião foi encerrada
+ for (const participanteId of conversa.participantes) {
+ if (participanteId !== usuarioAtual._id) {
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: "nova_mensagem",
+ conversaId: args.conversaId,
+ remetenteId: usuarioAtual._id,
+ titulo: "Reunião encerrada",
+ descricao: `A sala de reunião "${conversa.nome || "Sem nome"}" foi encerrada por ${usuarioAtual.nome}`,
+ lida: false,
+ criadaEm: Date.now(),
+ });
+ }
+ }
+
+ // Remover todos os participantes (exceto o criador, se necessário manter histórico)
+ // Por enquanto, vamos apenas limpar a lista de participantes
+ await ctx.db.patch(args.conversaId, {
+ participantes: [],
+ administradores: undefined,
+ });
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Envia uma notificação para todos os participantes de uma sala de reunião (apenas administradores)
+ */
+export const enviarNotificacaoReuniao = mutation({
+ args: {
+ conversaId: v.id("conversas"),
+ titulo: v.string(),
+ mensagem: 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 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 enviar notificações" };
+ }
+
+ // Criar notificação para todos os participantes
+ for (const participanteId of conversa.participantes) {
+ const tituloNotificacao = args.titulo || "Notificação da sala de reunião";
+ const descricaoNotificacao = args.mensagem.substring(0, 100); // Limitar descrição para push
+
+ // Criar notificação no banco
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: "nova_mensagem",
+ conversaId: args.conversaId,
+ remetenteId: usuarioAtual._id,
+ titulo: tituloNotificacao,
+ descricao: args.mensagem,
+ lida: false,
+ criadaEm: Date.now(),
+ });
+
+ // Enviar push notification (assíncrono, não bloqueia)
+ ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
+ usuarioId: participanteId,
+ titulo: tituloNotificacao,
+ corpo: descricaoNotificacao,
+ data: {
+ conversaId: args.conversaId,
+ tipo: "notificacao_reuniao",
+ },
+ }).catch((error) => {
+ console.error(`Erro ao agendar push para usuário ${participanteId}:`, error);
+ });
+ }
+
+ return { sucesso: true };
+ },
+});
+
// ========== QUERIES ==========
/**
@@ -1645,10 +1910,10 @@ export const listarAgendamentosChat = query({
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;
- });
+ const dataA = a.agendadaPara ?? 0;
+ const dataB = b.agendadaPara ?? 0;
+ return dataA - dataB;
+ });
},
});
@@ -1781,6 +2046,13 @@ export const listarTodosUsuarios = query({
const funcionario = await ctx.db.get(u.funcionarioId);
matricula = funcionario?.matricula;
}
+
+ // Buscar URL da foto de perfil se existir
+ let fotoPerfilUrl: string | null = null;
+ if (u.fotoPerfil) {
+ fotoPerfilUrl = await ctx.storage.getUrl(u.fotoPerfil);
+ }
+
return {
_id: u._id,
nome: u.nome,
@@ -1788,6 +2060,7 @@ export const listarTodosUsuarios = query({
matricula,
avatar: u.avatar,
fotoPerfil: u.fotoPerfil,
+ fotoPerfilUrl,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor,
@@ -1901,7 +2174,7 @@ export const buscarMensagens = query({
// Filtrar por remetente (já verificado acima, mas garantir novamente)
if (args.remetenteId) {
if (m.remetenteId !== args.remetenteId) {
- return false;
+ return false;
}
// Verificar novamente se o remetente é participante da conversa específica desta mensagem
if (!conversaDaMensagem.participantes.includes(args.remetenteId)) {
@@ -2075,22 +2348,23 @@ export const enviarMensagensAgendadas = internalMutation({
const agora = Date.now();
// Buscar mensagens que deveriam ser enviadas
+ // Como o índice by_agendamento indexa por agendadaPara, podemos usar range query
+ // Buscar mensagens com agendadaPara entre 0 e agora (mensagens agendadas que já devem ser enviadas)
+ // Valores undefined não aparecem no índice, então só buscamos mensagens realmente agendadas
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)
- )
- )
+ .withIndex("by_agendamento", (q) => q.gte("agendadaPara", 0).lte("agendadaPara", agora))
.collect();
for (const mensagem of mensagensAgendadas) {
+ // Normalizar conteúdo para busca (se ainda não foi feito)
+ const conteudoBusca = mensagem.conteudoBusca || normalizarTextoParaBusca(mensagem.conteudo);
+
// Atualizar mensagem para "enviada"
await ctx.db.patch(mensagem._id, {
agendadaPara: undefined,
enviadaEm: agora,
+ conteudoBusca: conteudoBusca, // Garantir que tem conteúdo de busca
});
// Atualizar última mensagem da conversa
@@ -2099,23 +2373,48 @@ export const enviarMensagensAgendadas = internalMutation({
await ctx.db.patch(mensagem.conversaId, {
ultimaMensagem: mensagem.conteudo.substring(0, 100),
ultimaMensagemTimestamp: agora,
+ ultimaMensagemRemetenteId: mensagem.remetenteId, // Guardar ID do remetente
});
// 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,
- });
+ if (remetente) {
+ // Determinar tipo de notificação (se há menções)
+ const tipoNotificacao = mensagem.mencoes && mensagem.mencoes.length > 0 ? "mencao" : "nova_mensagem";
+ const titulo = tipoNotificacao === "mencao"
+ ? `${remetente.nome} mencionou você`
+ : `Nova mensagem de ${remetente.nome}`;
+ const descricao = mensagem.conteudo.substring(0, 100);
+
+ for (const participanteId of conversa.participantes) {
+ if (participanteId !== mensagem.remetenteId) {
+ // Criar notificação no banco
+ await ctx.db.insert("notificacoes", {
+ usuarioId: participanteId,
+ tipo: tipoNotificacao,
+ conversaId: mensagem.conversaId,
+ mensagemId: mensagem._id,
+ remetenteId: mensagem.remetenteId,
+ titulo,
+ descricao,
+ lida: false,
+ criadaEm: agora,
+ });
+
+ // Enviar push notification (assíncrono, não bloqueia)
+ ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
+ usuarioId: participanteId,
+ titulo,
+ corpo: descricao,
+ data: {
+ conversaId: mensagem.conversaId,
+ mensagemId: mensagem._id,
+ tipo: tipoNotificacao,
+ },
+ }).catch((error) => {
+ console.error(`Erro ao agendar push para usuário ${participanteId}:`, error);
+ });
+ }
}
}
}
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 28bfdae..c5b501e 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -626,6 +626,7 @@ export default defineSchema({
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()),
+ ultimaMensagemRemetenteId: v.optional(v.id("usuarios")), // ID do remetente da última mensagem
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
})
@@ -670,6 +671,7 @@ export default defineSchema({
enviadaEm: v.number(),
editadaEm: v.optional(v.number()),
deletada: v.optional(v.boolean()),
+ lidaPor: v.optional(v.array(v.id("usuarios"))), // IDs dos usuários que leram a mensagem
})
.index("by_conversa", ["conversaId", "enviadaEm"])
.index("by_remetente", ["remetenteId"])