feat: implement cancellation and deletion functionality for LGPD requests; enhance UI with confirmation modals and update backend to support new operations

This commit is contained in:
2025-12-03 16:57:10 -03:00
parent b145fcc74a
commit 4a662c08a0
5 changed files with 549 additions and 59 deletions

View File

@@ -2,6 +2,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { import {
Shield, Shield,
FileText, FileText,
@@ -10,7 +11,9 @@
Clock, Clock,
XCircle, XCircle,
AlertCircle, AlertCircle,
Send Send,
Trash2,
X
} from 'lucide-svelte'; } from 'lucide-svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
@@ -28,17 +31,29 @@
let dadosSolicitados = $state(''); let dadosSolicitados = $state('');
let observacoes = $state(''); let observacoes = $state('');
let carregando = $state(false); let carregando = $state(false);
let cancelandoId = $state<Id<'solicitacoesLGPD'> | null>(null);
let excluindoId = $state<Id<'solicitacoesLGPD'> | null>(null);
let mostrarConfirmacaoExclusao = $state(false);
let solicitacaoParaExcluir = $state<Id<'solicitacoesLGPD'> | null>(null);
const client = useConvexClient(); 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 // Garantir que sempre seja um array ou undefined
const minhasSolicitacoes = $derived( const minhasSolicitacoes = $derived(
minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null
? undefined ? undefined
: Array.isArray(minhasSolicitacoesQuery) : Array.isArray(minhasSolicitacoesQuery?.data)
? minhasSolicitacoesQuery ? minhasSolicitacoesQuery.data
: [] : minhasSolicitacoesQuery?.data === null
? []
: []
); );
const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {}); const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {});
@@ -86,6 +101,8 @@
return { label: 'Concluída', class: 'badge-success' }; return { label: 'Concluída', class: 'badge-success' };
case 'rejeitada': case 'rejeitada':
return { label: 'Rejeitada', class: 'badge-error' }; return { label: 'Rejeitada', class: 'badge-error' };
case 'cancelada':
return { label: 'Cancelada', class: 'badge-neutral' };
default: default:
return { label: status, class: 'badge-neutral' }; return { label: status, class: 'badge-neutral' };
} }
@@ -101,6 +118,8 @@
return CheckCircle; return CheckCircle;
case 'rejeitada': case 'rejeitada':
return XCircle; return XCircle;
case 'cancelada':
return XCircle;
default: default:
return FileText; return FileText;
} }
@@ -125,6 +144,8 @@
tipoSelecionado = null; tipoSelecionado = null;
dadosSolicitados = ''; dadosSolicitados = '';
observacoes = ''; observacoes = '';
// Forçar atualização das queries
refreshKeyLGPD++;
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao criar solicitação'; const message = error instanceof Error ? error.message : 'Erro ao criar solicitação';
toast.error(message); toast.error(message);
@@ -150,6 +171,56 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('Download iniciado!'); 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;
}
}
</script> </script>
<div class="container mx-auto max-w-6xl px-4 py-8"> <div class="container mx-auto max-w-6xl px-4 py-8">
@@ -324,6 +395,40 @@
})} })}
</div> </div>
{/if} {/if}
<!-- Ações -->
<div class="mt-4 flex gap-2 border-t border-base-300 pt-3">
{#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'}
<button
type="button"
onclick={() => cancelarSolicitacao(solicitacao._id)}
disabled={cancelandoId === solicitacao._id}
class="btn btn-sm btn-warning btn-outline gap-2"
>
{#if cancelandoId === solicitacao._id}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<X class="h-4 w-4" />
{/if}
Cancelar
</button>
{/if}
{#if solicitacao.status === 'pendente' || solicitacao.status === 'cancelada'}
<button
type="button"
onclick={() => solicitarExclusao(solicitacao._id)}
disabled={excluindoId === solicitacao._id}
class="btn btn-sm btn-error btn-outline gap-2"
>
{#if excluindoId === solicitacao._id}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Trash2 class="h-4 w-4" />
{/if}
Excluir
</button>
{/if}
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -394,3 +499,35 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Modal de Confirmação de Exclusão -->
{#if mostrarConfirmacaoExclusao}
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="mb-4 text-lg font-bold">Confirmar Exclusão</h3>
<p class="mb-6 text-base-content/70">
Tem certeza que deseja excluir esta solicitação? Esta ação não pode ser desfeita.
</p>
<div class="modal-action">
<button type="button" onclick={fecharConfirmacaoExclusao} class="btn btn-ghost">
Cancelar
</button>
<button
type="button"
onclick={confirmarExclusao}
disabled={excluindoId !== null}
class="btn btn-error gap-2"
>
{#if excluindoId}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}
<Trash2 class="h-4 w-4" />
Excluir
{/if}
</button>
</div>
</div>
<div class="modal-backdrop" onclick={fecharConfirmacaoExclusao}></div>
</dialog>
{/if}

View File

@@ -11,7 +11,12 @@
XCircle, XCircle,
AlertCircle, AlertCircle,
Search, Search,
Filter Filter,
Upload,
Download,
User,
Mail,
Hash
} from 'lucide-svelte'; } from 'lucide-svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
@@ -39,12 +44,18 @@
tipo: tipoFiltro || undefined 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<string | null>(null); let solicitacaoSelecionada = $state<string | null>(null);
let solicitacaoDetalhes = $derived(
solicitacoesFiltradas.find((s) => s._id === solicitacaoSelecionada) || null
);
let resposta = $state(''); let resposta = $state('');
let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida'); let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida');
let carregando = $state(false); let carregando = $state(false);
let arquivoResposta = $state<File | null>(null);
let uploadandoArquivo = $state(false);
function getStatusBadge(status: string) { function getStatusBadge(status: string) {
switch (status) { switch (status) {
@@ -90,7 +101,7 @@
function filtrarSolicitacoes() { function filtrarSolicitacoes() {
// Verificar se solicitacoes existe e é um array // 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 // Se não há termo de busca, retorna todas as solicitações
if (!termoBusca || termoBusca.trim() === '') return solicitacoes; if (!termoBusca || termoBusca.trim() === '') return solicitacoes;
@@ -107,24 +118,61 @@
); );
} }
async function uploadArquivo(): Promise<Id<'_storage'> | 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() { async function responder() {
if (!solicitacaoSelecionada || !resposta.trim()) { if (!solicitacaoSelecionada || !resposta.trim()) {
toast.error('Preencha a resposta'); toast.error('Preencha a resposta');
return; 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; carregando = true;
try { 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, { await client.mutation(api.lgpd.responderSolicitacao, {
solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>, solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>,
resposta: resposta.trim(), resposta: resposta.trim(),
status: statusResposta status: statusResposta,
arquivoResposta: arquivoStorageId
}); });
toast.success('Solicitação respondida com sucesso!'); toast.success('Solicitação respondida com sucesso!');
solicitacaoSelecionada = null; solicitacaoSelecionada = null;
resposta = ''; resposta = '';
arquivoResposta = null;
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao responder solicitação'; const message = error instanceof Error ? error.message : 'Erro ao responder solicitação';
toast.error(message); toast.error(message);
@@ -133,6 +181,18 @@
} }
} }
function getInstrucoesResposta(tipo: string): string {
const instrucoes: Record<string, string> = {
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()); const solicitacoesFiltradas = $derived(filtrarSolicitacoes());
</script> </script>
@@ -208,19 +268,19 @@
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="card-title text-2xl">Solicitações</h2> <h2 class="card-title text-2xl">Solicitações Recebidas</h2>
{#if solicitacoes && Array.isArray(solicitacoes)} {#if Array.isArray(solicitacoes) && solicitacoes.length > 0}
<div class="badge badge-outline"> <div class="badge badge-outline">
Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length} Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length}
</div> </div>
{/if} {/if}
</div> </div>
{#if solicitacoes === undefined || solicitacoes === null} {#if solicitacoesQueryResult === undefined}
<div class="flex items-center justify-center py-20"> <div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else if !Array.isArray(solicitacoes) || solicitacoes.length === 0} {:else if solicitacoes.length === 0}
<div class="py-10 text-center"> <div class="py-10 text-center">
<FileText class="text-base-content/30 mx-auto mb-4 h-16 w-16" /> <FileText class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
<p class="text-base-content/60"> <p class="text-base-content/60">
@@ -269,18 +329,70 @@
<span class="badge {statusInfo.class}">{statusInfo.label}</span> <span class="badge {statusInfo.class}">{statusInfo.label}</span>
</div> </div>
<div class="text-base-content/70 space-y-1 text-sm"> <div class="text-base-content/70 space-y-2 text-sm">
<div> <div class="flex items-center gap-2">
<span class="font-semibold">Solicitante:</span> <User class="h-4 w-4 text-base-content/50" />
{solicitacao.usuarioNome} <div>
{#if solicitacao.usuarioMatricula} <span class="font-semibold">Solicitante:</span> {solicitacao.usuarioNome}
({solicitacao.usuarioMatricula}) {#if solicitacao.usuarioMatricula}
{/if} <span class="text-base-content/50"> ({solicitacao.usuarioMatricula})</span>
{/if}
</div>
</div> </div>
<div> <div class="flex items-center gap-2">
<span class="font-semibold">E-mail:</span> <Mail class="h-4 w-4 text-base-content/50" />
{solicitacao.usuarioEmail} <div>
<span class="font-semibold">E-mail:</span> {solicitacao.usuarioEmail}
</div>
</div> </div>
{#if solicitacao.usuarioMatricula}
<div class="flex items-center gap-2">
<Hash class="h-4 w-4 text-base-content/50" />
<div>
<span class="font-semibold">Matrícula:</span> {solicitacao.usuarioMatricula}
</div>
</div>
{/if}
{#if solicitacao.dadosSolicitados}
<div class="mt-2 rounded-lg bg-base-200 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-base-content/60">
Dados Solicitados:
</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.dadosSolicitados}
</p>
</div>
{/if}
{#if solicitacao.observacoes}
<div class="mt-2 rounded-lg bg-base-200 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-base-content/60">
Observações do Solicitante:
</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.observacoes}
</p>
</div>
{/if}
{#if solicitacao.resposta}
<div class="mt-2 rounded-lg bg-success/10 border border-success/20 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-success">Resposta:</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.resposta}
</p>
{#if solicitacao.arquivoResposta}
<div class="mt-2">
<a
href={solicitacao.arquivoResposta}
target="_blank"
class="btn btn-sm btn-outline btn-success gap-2"
>
<Download class="h-4 w-4" />
Baixar Arquivo de Resposta
</a>
</div>
{/if}
</div>
{/if}
{#if solicitacao.consentimentoTermo} {#if solicitacao.consentimentoTermo}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold">Termo de Consentimento:</span> <span class="font-semibold">Termo de Consentimento:</span>
@@ -346,20 +458,74 @@
</div> </div>
<!-- Modal de Resposta --> <!-- Modal de Resposta -->
{#if solicitacaoSelecionada} {#if solicitacaoSelecionada && solicitacaoDetalhes}
<div class="modal modal-open"> <div class="modal modal-open">
<div class="modal-box"> <div class="modal-box max-w-3xl max-h-[90vh] overflow-y-auto">
<h3 class="mb-4 text-lg font-bold">Responder Solicitação</h3> <h3 class="mb-4 text-lg font-bold">Responder Solicitação</h3>
<!-- Informações da Solicitação -->
<div class="mb-6 rounded-lg bg-base-200 p-4">
<div class="mb-3">
<h4 class="mb-2 font-semibold">Tipo de Solicitação:</h4>
<p class="text-base-content/80">{getTipoLabel(solicitacaoDetalhes.tipo)}</p>
</div>
<div class="mb-3">
<h4 class="mb-2 font-semibold">Solicitante:</h4>
<p class="text-base-content/80">
{solicitacaoDetalhes.usuarioNome}
{#if solicitacaoDetalhes.usuarioMatricula}
<span class="text-base-content/50"> ({solicitacaoDetalhes.usuarioMatricula})</span>
{/if}
</p>
<p class="text-base-content/60 text-sm">{solicitacaoDetalhes.usuarioEmail}</p>
</div>
{#if solicitacaoDetalhes.dadosSolicitados}
<div class="mb-3">
<h4 class="mb-2 font-semibold">Dados Solicitados:</h4>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacaoDetalhes.dadosSolicitados}
</p>
</div>
{/if}
{#if solicitacaoDetalhes.observacoes}
<div>
<h4 class="mb-2 font-semibold">Observações:</h4>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacaoDetalhes.observacoes}
</p>
</div>
{/if}
</div>
<!-- Instruções -->
<div class="alert alert-info mb-4">
<AlertCircle class="h-5 w-5" />
<div class="text-sm">
<p class="font-semibold">Instruções:</p>
<p>{getInstrucoesResposta(solicitacaoDetalhes.tipo)}</p>
</div>
</div>
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"> <label class="label">
<span class="label-text font-semibold">Status</span> <span class="label-text font-semibold">Status da Resposta *</span>
</label> </label>
<select bind:value={statusResposta} class="select select-bordered"> <select bind:value={statusResposta} class="select select-bordered">
<option value="concluida">Concluída</option> <option value="concluida">Concluída</option>
<option value="rejeitada">Rejeitada</option> <option value="rejeitada">Rejeitada</option>
<option value="em_analise">Em Análise</option> <option value="em_analise">Em Análise</option>
</select> </select>
<label class="label">
<span class="label-text-alt text-base-content/60">
{#if statusResposta === 'concluida'}
A solicitação foi atendida com sucesso
{:else if statusResposta === 'rejeitada'}
A solicitação foi rejeitada (informe o motivo na resposta)
{:else}
A solicitação está em análise e será respondida posteriormente
{/if}
</span>
</label>
</div> </div>
<div class="form-control mb-4"> <div class="form-control mb-4">
@@ -372,33 +538,95 @@
placeholder="Digite a resposta para o solicitante..." placeholder="Digite a resposta para o solicitante..."
rows="6" rows="6"
></textarea> ></textarea>
<label class="label">
<span class="label-text-alt text-base-content/60">
Forneça uma resposta clara e objetiva conforme as regras da LGPD
</span>
</label>
</div> </div>
<!-- Upload de Arquivo (obrigatório para portabilidade concluída) -->
{#if solicitacaoDetalhes.tipo === 'portabilidade' || solicitacaoDetalhes.tipo === 'acesso'}
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">
Arquivo de Resposta
{#if solicitacaoDetalhes.tipo === 'portabilidade' && statusResposta === 'concluida'}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
type="file"
accept=".json,.csv,.txt,.pdf"
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
arquivoResposta = file || null;
}}
class="file-input file-input-bordered w-full"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
{#if solicitacaoDetalhes.tipo === 'portabilidade'}
Para portabilidade, anexe o arquivo com os dados em formato estruturado (JSON, CSV,
etc)
{:else}
Opcional: Anexe arquivo com informações adicionais ou dados exportados
{/if}
</span>
</label>
{#if arquivoResposta}
<div class="mt-2 flex items-center gap-2">
<FileText class="h-4 w-4 text-base-content/60" />
<span class="text-sm text-base-content/80">{arquivoResposta.name}</span>
<button
type="button"
onclick={() => (arquivoResposta = null)}
class="btn btn-xs btn-ghost"
>
Remover
</button>
</div>
{/if}
</div>
{/if}
<div class="modal-action"> <div class="modal-action">
<button <button
onclick={() => { onclick={() => {
solicitacaoSelecionada = null; solicitacaoSelecionada = null;
resposta = ''; resposta = '';
arquivoResposta = null;
}} }}
class="btn btn-ghost" class="btn btn-ghost"
disabled={carregando}
> >
Cancelar Cancelar
</button> </button>
<button <button
onclick={responder} onclick={responder}
disabled={!resposta.trim() || carregando} disabled={!resposta.trim() || carregando || uploadandoArquivo}
class="btn btn-primary" class="btn btn-primary gap-2"
> >
{#if carregando} {#if carregando || uploadandoArquivo}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
Enviando... {uploadandoArquivo ? 'Enviando arquivo...' : 'Enviando...'}
{:else} {:else}
Enviar Resposta Enviar Resposta
{/if} {/if}
</button> </button>
</div> </div>
</div> </div>
<div class="modal-backdrop" onclick={() => (solicitacaoSelecionada = null)}></div> <div
class="modal-backdrop"
onclick={() => {
if (!carregando) {
solicitacaoSelecionada = null;
resposta = '';
arquivoResposta = null;
}
}}
></div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -289,9 +289,11 @@ export const listarMinhasSolicitacoes = query({
v.literal('pendente'), v.literal('pendente'),
v.literal('em_analise'), v.literal('em_analise'),
v.literal('concluida'), 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( returns: v.array(
v.object({ v.object({
@@ -357,7 +359,8 @@ export const listarSolicitacoes = query({
v.literal('pendente'), v.literal('pendente'),
v.literal('em_analise'), v.literal('em_analise'),
v.literal('concluida'), v.literal('concluida'),
v.literal('rejeitada') v.literal('rejeitada'),
v.literal('cancelada')
) )
), ),
tipo: v.optional( tipo: v.optional(
@@ -372,29 +375,33 @@ export const listarSolicitacoes = query({
), ),
limite: v.optional(v.number()) limite: v.optional(v.number())
}, },
returns: v.array( returns: v.array(
v.object({ v.object({
_id: v.id('solicitacoesLGPD'), _id: v.id('solicitacoesLGPD'),
tipo: v.string(), tipo: v.string(),
status: v.string(), status: v.string(),
usuarioNome: v.string(), usuarioNome: v.string(),
usuarioEmail: v.string(), usuarioEmail: v.string(),
usuarioMatricula: v.union(v.string(), v.null()), usuarioMatricula: v.union(v.string(), v.null()),
criadoEm: v.number(), dadosSolicitados: v.union(v.string(), v.null()),
prazoResposta: v.number(), observacoes: v.union(v.string(), v.null()),
respondidoEm: v.union(v.number(), v.null()), resposta: v.union(v.string(), v.null()),
respondidoPorNome: v.union(v.string(), v.null()), arquivoResposta: v.union(v.string(), v.null()),
consentimentoTermo: v.union( criadoEm: v.number(),
v.object({ prazoResposta: v.number(),
aceito: v.boolean(), respondidoEm: v.union(v.number(), v.null()),
versao: v.string(), respondidoPorNome: v.union(v.string(), v.null()),
aceitoEm: v.number(), consentimentoTermo: v.union(
revogadoEm: v.union(v.number(), v.null()) v.object({
}), aceito: v.boolean(),
v.null() versao: v.string(),
) aceitoEm: v.number(),
}) revogadoEm: v.union(v.number(), v.null())
), }),
v.null()
)
})
),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
if (!usuario) { if (!usuario) {
@@ -433,6 +440,10 @@ export const listarSolicitacoes = query({
usuarioNome: string; usuarioNome: string;
usuarioEmail: string; usuarioEmail: string;
usuarioMatricula: string | null; usuarioMatricula: string | null;
dadosSolicitados: string | null;
observacoes: string | null;
resposta: string | null;
arquivoResposta: string | null;
criadoEm: number; criadoEm: number;
prazoResposta: number; prazoResposta: number;
respondidoEm: number | null; 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 { return {
_id: s._id, _id: s._id,
tipo: s.tipo, tipo: s.tipo,
@@ -504,6 +521,10 @@ export const listarSolicitacoes = query({
usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido', usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido',
usuarioEmail: usuarioSolicitante?.email ?? '', usuarioEmail: usuarioSolicitante?.email ?? '',
usuarioMatricula: matricula, usuarioMatricula: matricula,
dadosSolicitados: s.dadosSolicitados ?? null,
observacoes: s.observacoes ?? null,
resposta: s.resposta ?? null,
arquivoResposta: arquivoRespostaUrl,
criadoEm: s.criadoEm, criadoEm: s.criadoEm,
prazoResposta: s.prazoResposta, prazoResposta: s.prazoResposta,
respondidoEm: s.respondidoEm ?? null, respondidoEm: s.respondidoEm ?? null,
@@ -520,6 +541,10 @@ export const listarSolicitacoes = query({
usuarioNome: 'Erro ao carregar', usuarioNome: 'Erro ao carregar',
usuarioEmail: '', usuarioEmail: '',
usuarioMatricula: null, usuarioMatricula: null,
dadosSolicitados: s.dadosSolicitados ?? null,
observacoes: s.observacoes ?? null,
resposta: s.resposta ?? null,
arquivoResposta: null,
criadoEm: s.criadoEm, criadoEm: s.criadoEm,
prazoResposta: s.prazoResposta, prazoResposta: s.prazoResposta,
respondidoEm: s.respondidoEm ?? null, 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) * Exportar dados do usuário (portabilidade)
*/ */

View File

@@ -20,7 +20,8 @@ export const lgpdTables = {
v.literal('pendente'), v.literal('pendente'),
v.literal('em_analise'), v.literal('em_analise'),
v.literal('concluida'), v.literal('concluida'),
v.literal('rejeitada') v.literal('rejeitada'),
v.literal('cancelada')
), ),
dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação
resposta: v.optional(v.string()), // Resposta da solicitação resposta: v.optional(v.string()), // Resposta da solicitação

View File

@@ -2,7 +2,7 @@
"name": "@sgse-app/backend", "name": "@sgse-app/backend",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "convex dev", "dev": "convex dev",
"dev:setup": "convex dev --configure --until-success", "dev:setup": "convex dev --configure --until-success",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write ." "format": "prettier --write ."