From 4a662c08a0756336a79d4ebf45a7317b2e16984d Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 3 Dec 2025 16:57:10 -0300 Subject: [PATCH] feat: implement cancellation and deletion functionality for LGPD requests; enhance UI with confirmation modals and update backend to support new operations --- .../privacidade/meus-dados/+page.svelte | 147 ++++++++- .../ti/lgpd/solicitacoes/+page.svelte | 280 ++++++++++++++++-- packages/backend/convex/lgpd.ts | 176 +++++++++-- packages/backend/convex/tables/lgpdTables.ts | 3 +- packages/backend/package.json | 2 +- 5 files changed, 549 insertions(+), 59 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte b/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte index c7b8137..a135745 100644 --- a/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte +++ b/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; + import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { Shield, FileText, @@ -10,7 +11,9 @@ Clock, XCircle, AlertCircle, - Send + Send, + Trash2, + X } from 'lucide-svelte'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; @@ -28,17 +31,29 @@ let dadosSolicitados = $state(''); let observacoes = $state(''); let carregando = $state(false); + let cancelandoId = $state | null>(null); + let excluindoId = $state | null>(null); + let mostrarConfirmacaoExclusao = $state(false); + let solicitacaoParaExcluir = $state | null>(null); const client = useConvexClient(); - const minhasSolicitacoesQuery = useQuery(api.lgpd.listarMinhasSolicitacoes, {}); + + // Estado para forçar atualização das queries + let refreshKeyLGPD = $state(0); + + const minhasSolicitacoesQuery = useQuery(api.lgpd.listarMinhasSolicitacoes, { + _refresh: refreshKeyLGPD + }); // Garantir que sempre seja um array ou undefined const minhasSolicitacoes = $derived( minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null ? undefined - : Array.isArray(minhasSolicitacoesQuery) - ? minhasSolicitacoesQuery - : [] + : Array.isArray(minhasSolicitacoesQuery?.data) + ? minhasSolicitacoesQuery.data + : minhasSolicitacoesQuery?.data === null + ? [] + : [] ); const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {}); @@ -86,6 +101,8 @@ return { label: 'Concluída', class: 'badge-success' }; case 'rejeitada': return { label: 'Rejeitada', class: 'badge-error' }; + case 'cancelada': + return { label: 'Cancelada', class: 'badge-neutral' }; default: return { label: status, class: 'badge-neutral' }; } @@ -101,6 +118,8 @@ return CheckCircle; case 'rejeitada': return XCircle; + case 'cancelada': + return XCircle; default: return FileText; } @@ -125,6 +144,8 @@ tipoSelecionado = null; dadosSolicitados = ''; observacoes = ''; + // Forçar atualização das queries + refreshKeyLGPD++; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Erro ao criar solicitação'; toast.error(message); @@ -150,6 +171,56 @@ URL.revokeObjectURL(url); toast.success('Download iniciado!'); } + + async function cancelarSolicitacao(solicitacaoId: Id<'solicitacoesLGPD'>) { + if (cancelandoId === solicitacaoId) return; + + cancelandoId = solicitacaoId; + try { + await client.mutation(api.lgpd.cancelarSolicitacao, { + solicitacaoId + }); + toast.success('Solicitação cancelada com sucesso!'); + refreshKeyLGPD++; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao cancelar solicitação'; + toast.error(message); + } finally { + cancelandoId = null; + } + } + + function solicitarExclusao(solicitacaoId: Id<'solicitacoesLGPD'>) { + solicitacaoParaExcluir = solicitacaoId; + mostrarConfirmacaoExclusao = true; + } + + function fecharConfirmacaoExclusao() { + mostrarConfirmacaoExclusao = false; + solicitacaoParaExcluir = null; + } + + async function confirmarExclusao() { + if (!solicitacaoParaExcluir) return; + + const solicitacaoId = solicitacaoParaExcluir; + excluindoId = solicitacaoId; + mostrarConfirmacaoExclusao = false; + + try { + await client.mutation(api.lgpd.excluirSolicitacao, { + solicitacaoId + }); + toast.success('Solicitação excluída com sucesso!'); + refreshKeyLGPD++; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao excluir solicitação'; + toast.error(message); + } finally { + excluindoId = null; + solicitacaoParaExcluir = null; + } + }
@@ -324,6 +395,40 @@ })}
{/if} + + +
+ {#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'} + + {/if} + {#if solicitacao.status === 'pendente' || solicitacao.status === 'cancelada'} + + {/if} +
{/each} @@ -394,3 +499,35 @@ + + +{#if mostrarConfirmacaoExclusao} + + + + +{/if} diff --git a/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte index dbb838d..d40e436 100644 --- a/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte @@ -11,7 +11,12 @@ XCircle, AlertCircle, Search, - Filter + Filter, + Upload, + Download, + User, + Mail, + Hash } from 'lucide-svelte'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; @@ -39,12 +44,18 @@ tipo: tipoFiltro || undefined }); - const solicitacoes = useQuery(api.lgpd.listarSolicitacoes, solicitacoesQuery); + const solicitacoesQueryResult = useQuery(api.lgpd.listarSolicitacoes, solicitacoesQuery); + const solicitacoes = $derived(solicitacoesQueryResult?.data || []); let solicitacaoSelecionada = $state(null); + let solicitacaoDetalhes = $derived( + solicitacoesFiltradas.find((s) => s._id === solicitacaoSelecionada) || null + ); let resposta = $state(''); let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida'); let carregando = $state(false); + let arquivoResposta = $state(null); + let uploadandoArquivo = $state(false); function getStatusBadge(status: string) { switch (status) { @@ -90,7 +101,7 @@ function filtrarSolicitacoes() { // Verificar se solicitacoes existe e é um array - if (!solicitacoes || !Array.isArray(solicitacoes)) return []; + if (!Array.isArray(solicitacoes) || solicitacoes.length === 0) return []; // Se não há termo de busca, retorna todas as solicitações if (!termoBusca || termoBusca.trim() === '') return solicitacoes; @@ -107,24 +118,61 @@ ); } + async function uploadArquivo(): Promise | null> { + if (!arquivoResposta) return null; + + uploadandoArquivo = true; + try { + const storageId = await client.storage.store(arquivoResposta); + return storageId; + } catch (error) { + console.error('Erro ao fazer upload do arquivo:', error); + toast.error('Erro ao fazer upload do arquivo'); + return null; + } finally { + uploadandoArquivo = false; + } + } + async function responder() { if (!solicitacaoSelecionada || !resposta.trim()) { toast.error('Preencha a resposta'); return; } + // Validações específicas por tipo + const tipo = solicitacaoDetalhes?.tipo; + if (tipo === 'portabilidade' && statusResposta === 'concluida' && !arquivoResposta) { + toast.error('Para solicitações de portabilidade concluídas, é necessário anexar o arquivo com os dados'); + return; + } + carregando = true; try { + // Fazer upload do arquivo se houver + let arquivoStorageId: Id<'_storage'> | undefined = undefined; + if (arquivoResposta) { + const uploadedId = await uploadArquivo(); + if (uploadedId) { + arquivoStorageId = uploadedId; + } else { + carregando = false; + return; + } + } + await client.mutation(api.lgpd.responderSolicitacao, { solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>, resposta: resposta.trim(), - status: statusResposta + status: statusResposta, + arquivoResposta: arquivoStorageId }); toast.success('Solicitação respondida com sucesso!'); solicitacaoSelecionada = null; resposta = ''; + arquivoResposta = null; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Erro ao responder solicitação'; toast.error(message); @@ -133,6 +181,18 @@ } } + function getInstrucoesResposta(tipo: string): string { + const instrucoes: Record = { + acesso: 'Informe quais dados pessoais estão sendo tratados e forneça acesso aos mesmos conforme solicitado.', + correcao: 'Confirme as correções realizadas ou explique o motivo caso não seja possível corrigir.', + exclusao: 'Confirme a exclusão dos dados ou explique o motivo caso não seja possível excluir (ex: obrigação legal de retenção).', + portabilidade: 'Anexe o arquivo com os dados em formato estruturado e de uso comum (JSON, CSV, etc).', + revogacao_consentimento: 'Confirme a revogação do consentimento e informe sobre as consequências (se houver).', + informacao_compartilhamento: 'Informe com quais terceiros os dados são compartilhados e para quais finalidades.' + }; + return instrucoes[tipo] || 'Forneça uma resposta clara e objetiva sobre a solicitação.'; + } + const solicitacoesFiltradas = $derived(filtrarSolicitacoes()); @@ -208,19 +268,19 @@
-

Solicitações

- {#if solicitacoes && Array.isArray(solicitacoes)} +

Solicitações Recebidas

+ {#if Array.isArray(solicitacoes) && solicitacoes.length > 0}
Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length}
{/if}
- {#if solicitacoes === undefined || solicitacoes === null} + {#if solicitacoesQueryResult === undefined}
- {:else if !Array.isArray(solicitacoes) || solicitacoes.length === 0} + {:else if solicitacoes.length === 0}

@@ -269,18 +329,70 @@ {statusInfo.label}

-
-
- Solicitante: - {solicitacao.usuarioNome} - {#if solicitacao.usuarioMatricula} - ({solicitacao.usuarioMatricula}) - {/if} +
+
+ +
+ Solicitante: {solicitacao.usuarioNome} + {#if solicitacao.usuarioMatricula} + ({solicitacao.usuarioMatricula}) + {/if} +
-
- E-mail: - {solicitacao.usuarioEmail} +
+ +
+ E-mail: {solicitacao.usuarioEmail} +
+ {#if solicitacao.usuarioMatricula} +
+ +
+ Matrícula: {solicitacao.usuarioMatricula} +
+
+ {/if} + {#if solicitacao.dadosSolicitados} +
+

+ Dados Solicitados: +

+

+ {solicitacao.dadosSolicitados} +

+
+ {/if} + {#if solicitacao.observacoes} +
+

+ Observações do Solicitante: +

+

+ {solicitacao.observacoes} +

+
+ {/if} + {#if solicitacao.resposta} +
+

Resposta:

+

+ {solicitacao.resposta} +

+ {#if solicitacao.arquivoResposta} + + {/if} +
+ {/if} {#if solicitacao.consentimentoTermo}
Termo de Consentimento: @@ -346,20 +458,74 @@
- {#if solicitacaoSelecionada} + {#if solicitacaoSelecionada && solicitacaoDetalhes} diff --git a/packages/backend/convex/lgpd.ts b/packages/backend/convex/lgpd.ts index f1a5fcd..4b7f120 100644 --- a/packages/backend/convex/lgpd.ts +++ b/packages/backend/convex/lgpd.ts @@ -289,9 +289,11 @@ export const listarMinhasSolicitacoes = query({ v.literal('pendente'), v.literal('em_analise'), v.literal('concluida'), - v.literal('rejeitada') + v.literal('rejeitada'), + v.literal('cancelada') ) - ) + ), + _refresh: v.optional(v.number()) // Parâmetro para forçar atualização no frontend }, returns: v.array( v.object({ @@ -357,7 +359,8 @@ export const listarSolicitacoes = query({ v.literal('pendente'), v.literal('em_analise'), v.literal('concluida'), - v.literal('rejeitada') + v.literal('rejeitada'), + v.literal('cancelada') ) ), tipo: v.optional( @@ -372,29 +375,33 @@ export const listarSolicitacoes = query({ ), limite: v.optional(v.number()) }, - returns: v.array( - v.object({ - _id: v.id('solicitacoesLGPD'), - tipo: v.string(), - status: v.string(), - usuarioNome: v.string(), - usuarioEmail: v.string(), - usuarioMatricula: v.union(v.string(), v.null()), - criadoEm: v.number(), - prazoResposta: v.number(), - respondidoEm: v.union(v.number(), v.null()), - respondidoPorNome: v.union(v.string(), v.null()), - consentimentoTermo: v.union( - v.object({ - aceito: v.boolean(), - versao: v.string(), - aceitoEm: v.number(), - revogadoEm: v.union(v.number(), v.null()) - }), - v.null() - ) - }) - ), + returns: v.array( + v.object({ + _id: v.id('solicitacoesLGPD'), + tipo: v.string(), + status: v.string(), + usuarioNome: v.string(), + usuarioEmail: v.string(), + usuarioMatricula: v.union(v.string(), v.null()), + dadosSolicitados: v.union(v.string(), v.null()), + observacoes: v.union(v.string(), v.null()), + resposta: v.union(v.string(), v.null()), + arquivoResposta: v.union(v.string(), v.null()), + criadoEm: v.number(), + prazoResposta: v.number(), + respondidoEm: v.union(v.number(), v.null()), + respondidoPorNome: v.union(v.string(), v.null()), + consentimentoTermo: v.union( + v.object({ + aceito: v.boolean(), + versao: v.string(), + aceitoEm: v.number(), + revogadoEm: v.union(v.number(), v.null()) + }), + v.null() + ) + }) + ), handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { @@ -433,6 +440,10 @@ export const listarSolicitacoes = query({ usuarioNome: string; usuarioEmail: string; usuarioMatricula: string | null; + dadosSolicitados: string | null; + observacoes: string | null; + resposta: string | null; + arquivoResposta: string | null; criadoEm: number; prazoResposta: number; respondidoEm: number | null; @@ -497,6 +508,12 @@ export const listarSolicitacoes = query({ } } + // Buscar URL do arquivo de resposta se existir + let arquivoRespostaUrl: string | null = null; + if (s.arquivoResposta) { + arquivoRespostaUrl = await ctx.storage.getUrl(s.arquivoResposta); + } + return { _id: s._id, tipo: s.tipo, @@ -504,6 +521,10 @@ export const listarSolicitacoes = query({ usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido', usuarioEmail: usuarioSolicitante?.email ?? '', usuarioMatricula: matricula, + dadosSolicitados: s.dadosSolicitados ?? null, + observacoes: s.observacoes ?? null, + resposta: s.resposta ?? null, + arquivoResposta: arquivoRespostaUrl, criadoEm: s.criadoEm, prazoResposta: s.prazoResposta, respondidoEm: s.respondidoEm ?? null, @@ -520,6 +541,10 @@ export const listarSolicitacoes = query({ usuarioNome: 'Erro ao carregar', usuarioEmail: '', usuarioMatricula: null, + dadosSolicitados: s.dadosSolicitados ?? null, + observacoes: s.observacoes ?? null, + resposta: s.resposta ?? null, + arquivoResposta: null, criadoEm: s.criadoEm, prazoResposta: s.prazoResposta, respondidoEm: s.respondidoEm ?? null, @@ -583,6 +608,105 @@ export const responderSolicitacao = mutation({ } }); +/** + * Cancelar solicitação LGPD (apenas pelo próprio usuário) + */ +export const cancelarSolicitacao = mutation({ + args: { + solicitacaoId: v.id('solicitacoesLGPD') + }, + returns: v.object({ sucesso: v.boolean() }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error('Solicitação não encontrada'); + } + + // Verificar se a solicitação pertence ao usuário + if (solicitacao.usuarioId !== usuario._id) { + throw new Error('Você não tem permissão para cancelar esta solicitação'); + } + + // Só pode cancelar se estiver pendente ou em análise + if (solicitacao.status !== 'pendente' && solicitacao.status !== 'em_analise') { + throw new Error('Só é possível cancelar solicitações pendentes ou em análise'); + } + + // Atualizar status para cancelada + await ctx.db.patch(args.solicitacaoId, { + status: 'cancelada' + }); + + // Log de atividade + await registrarAtividade( + ctx, + usuario._id, + 'cancelar_solicitacao_lgpd', + 'solicitacoesLGPD', + JSON.stringify({ solicitacaoId: args.solicitacaoId }), + args.solicitacaoId.toString() + ); + + return { sucesso: true }; + } +}); + +/** + * Excluir solicitação LGPD (apenas pelo próprio usuário e apenas se cancelada ou pendente) + */ +export const excluirSolicitacao = mutation({ + args: { + solicitacaoId: v.id('solicitacoesLGPD') + }, + returns: v.object({ sucesso: v.boolean() }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error('Solicitação não encontrada'); + } + + // Verificar se a solicitação pertence ao usuário + if (solicitacao.usuarioId !== usuario._id) { + throw new Error('Você não tem permissão para excluir esta solicitação'); + } + + // Só pode excluir se estiver pendente ou cancelada + if (solicitacao.status !== 'pendente' && solicitacao.status !== 'cancelada') { + throw new Error('Só é possível excluir solicitações pendentes ou canceladas'); + } + + // Excluir arquivo de resposta se existir + if (solicitacao.arquivoResposta) { + await ctx.storage.delete(solicitacao.arquivoResposta); + } + + // Excluir a solicitação + await ctx.db.delete(args.solicitacaoId); + + // Log de atividade + await registrarAtividade( + ctx, + usuario._id, + 'excluir_solicitacao_lgpd', + 'solicitacoesLGPD', + JSON.stringify({ solicitacaoId: args.solicitacaoId }), + args.solicitacaoId.toString() + ); + + return { sucesso: true }; + } +}); + /** * Exportar dados do usuário (portabilidade) */ diff --git a/packages/backend/convex/tables/lgpdTables.ts b/packages/backend/convex/tables/lgpdTables.ts index 75822cc..7287bcd 100644 --- a/packages/backend/convex/tables/lgpdTables.ts +++ b/packages/backend/convex/tables/lgpdTables.ts @@ -20,7 +20,8 @@ export const lgpdTables = { v.literal('pendente'), v.literal('em_analise'), v.literal('concluida'), - v.literal('rejeitada') + v.literal('rejeitada'), + v.literal('cancelada') ), dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação resposta: v.optional(v.string()), // Resposta da solicitação diff --git a/packages/backend/package.json b/packages/backend/package.json index abb03b2..2367cee 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -2,7 +2,7 @@ "name": "@sgse-app/backend", "version": "1.0.0", "scripts": { - "dev": "convex dev", + "dev": "convex dev", "dev:setup": "convex dev --configure --until-success", "lint": "eslint .", "format": "prettier --write ."