Sistema de Desativação/Exclusão de Conta LGPD-compliant
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implementar sistema completo de desativação e exclusão de conta seguindo LGPD/GDPR, com período de carência de 30 dias, notificações por email, e opção de cancelamento.
Architecture:
- Dois modos: Desativar (membership → deactivated, reativável) e Excluir (período 30 dias → exclusão permanente)
- Modelos:
AccountDeletionRequestpara rastrear pedidos, Celery tasks para processamento agendado - APIs REST para solicitar/cancelar/excluir
- Frontend com UI para gerenciar exclusão
Tech Stack: Django, PostgreSQL, Celery, Redis, React (TanStack), Tailwind, shadcn/ui
File Structure
Backend (apps/users/)
Novos modelos:
apps/users/models/account_deletion.py— AccountDeletionRequest, AccountDeletionLog
Novos views:
apps/users/views/account_deletion.py— RequestDeletionView, CancelDeletionView, DeletionStatusView
Novas tasks Celery:
apps/users/tasks/deletion.py— process_deletion_reminders, execute_account_deletion
Serializers:
apps/users/serializers/account_deletion.py— RequestDeletionSerializer, DeletionStatusSerializer
URLs:
apps/users/urls.py— Adicionar rotas de exclusão
Frontend (frontend/)
Novas rotas:
frontend/src/routes/settings/account.tsx— Atualizar UI de exclusãofrontend/src/routes/cancel-deletion.tsx— Página de cancelamento
Novos componentes:
frontend/src/components/account/DeletionRequestDialog.tsx— Modal de confirmaçãofrontend/src/components/account/DeletionStatusCard.tsx— Status da exclusão
API client:
frontend/src/api/generated.ts— Regenerar com novos endpoints
Task 1: Criar modelos de exclusão
Covers: [S2]
Files:
Create:
apps/users/models/account_deletion.pyModify:
apps/users/models/__init__.py[ ] Step 1: Criar modelo AccountDeletionRequest
# apps/users/models/account_deletion.py
from django.db import models
from django.utils import timezone
from apps.utils.models import BaseModel
class AccountDeletionRequest(BaseModel):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
CONFIRMED = "confirmed", "Confirmed"
CANCELLED = "cancelled", "Cancelled"
COMPLETED = "completed", "Completed"
user = models.ForeignKey(
"users.CustomUser",
on_delete=models.CASCADE,
related_name="deletion_requests",
)
status = models.CharField(
max_length=20,
choices=Status,
default=Status.PENDING,
)
requested_at = models.DateTimeField(auto_now_add=True)
scheduled_for = models.DateTimeField(
help_text="When the account should be permanently deleted"
)
confirmed_at = models.DateTimeField(null=True, blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
cancellation_token = models.CharField(
max_length=64,
unique=True,
db_index=True,
)
class Meta:
ordering = ["-requested_at"]
def __str__(self):
return f"Deletion request for {self.user.email} ({self.status})"
@property
def days_remaining(self):
if self.status != self.Status.CONFIRMED:
return 0
delta = self.scheduled_for - timezone.now()
return max(0, delta.days)
def cancel(self):
self.status = self.Status.CANCELLED
self.cancelled_at = timezone.now()
self.save(update_fields=["status", "cancelled_at", "updated_at"])
class AccountDeletionLog(BaseModel):
class Action(models.TextChoices):
REQUESTED = "requested", "Requested"
CONFIRMED = "confirmed", "Confirmed"
REMINDER_SENT = "reminder_sent", "Reminder Sent"
CANCELLED = "cancelled", "Cancelled"
EXECUTED = "executed", "Executed"
deletion_request = models.ForeignKey(
AccountDeletionRequest,
on_delete=models.CASCADE,
related_name="logs",
)
action = models.CharField(max_length=20, choices=Action)
details = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"{self.action} for {self.deletion_request.user.email}"- [ ] Step 2: Atualizar init.py
# apps/users/models/__init__.py
from .custom_user import CustomUser
from .account_deletion import AccountDeletionRequest, AccountDeletionLog
__all__ = ["CustomUser", "AccountDeletionRequest", "AccountDeletionLog"]- [ ] Step 3: Criar migration
cd /home/juninho/dev/go/django-boilerplate
uv run manage.py makemigrations users- [ ] Step 4: Aplicar migration
uv run manage.py migrate- [ ] Step 5: Commit
git add apps/users/models/account_deletion.py apps/users/models/__init__.py apps/users/migrations/
git commit -m "feat(users): add account deletion models"Task 2: Criar serializers de exclusão
Covers: [S2, S3]
Files:
Create:
apps/users/serializers/account_deletion.pyModify:
apps/users/serializers/__init__.py[ ] Step 1: Criar serializers
# apps/users/serializers/account_deletion.py
from rest_framework import serializers
from apps.users.models.account_deletion import AccountDeletionRequest
class RequestDeletionSerializer(serializers.Serializer):
"""Serializer for requesting account deletion."""
confirm_email = serializers.EmailField(
help_text="Must match the user's email address"
)
def validate_confirm_email(self, value):
user = self.context["request"].user
if value != user.email:
raise serializers.ValidationError(
"Email does not match your account email."
)
return value
class DeletionStatusSerializer(serializers.ModelSerializer):
"""Serializer for deletion status."""
days_remaining = serializers.IntegerField(read_only=True)
user_email = serializers.EmailField(source="user.email", read_only=True)
class Meta:
model = AccountDeletionRequest
fields = [
"id",
"status",
"requested_at",
"scheduled_for",
"confirmed_at",
"cancelled_at",
"completed_at",
"days_remaining",
"user_email",
]
read_only_fields = fields
class CancelDeletionSerializer(serializers.Serializer):
"""Serializer for cancellation token."""
token = serializers.CharField(max_length=64)- [ ] Step 2: Atualizar init.py
# apps/users/serializers/__init__.py
from .account_deletion import (
RequestDeletionSerializer,
DeletionStatusSerializer,
CancelDeletionSerializer,
)
__all__ = [
"RequestDeletionSerializer",
"DeletionStatusSerializer",
"CancelDeletionSerializer",
]- [ ] Step 3: Commit
git add apps/users/serializers/account_deletion.py apps/users/serializers/__init__.py
git commit -m "feat(users): add account deletion serializers"Task 3: Criar views de exclusão
Covers: [S2, S3, S4]
Files:
Create:
apps/users/views/account_deletion.pyModify:
apps/users/views/__init__.py[ ] Step 1: Criar views
# apps/users/views/account_deletion.py
import secrets
from django.utils import timezone
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.users.models.account_deletion import AccountDeletionRequest, AccountDeletionLog
from apps.users.serializers.account_deletion import (
RequestDeletionSerializer,
DeletionStatusSerializer,
CancelDeletionSerializer,
)
GRACE_PERIOD_DAYS = 30
class RequestDeletionView(APIView):
"""POST /api/users/account/request-deletion"""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = RequestDeletionSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)
# Check if user already has a pending request
existing = AccountDeletionRequest.objects.filter(
user=request.user,
status__in=[AccountDeletionRequest.Status.PENDING, AccountDeletionRequest.Status.CONFIRMED],
).first()
if existing:
return Response(
{"detail": "You already have a pending deletion request."},
status=status.HTTP_400_BAD_REQUEST,
)
# Create deletion request
deletion_request = AccountDeletionRequest.objects.create(
user=request.user,
status=AccountDeletionRequest.Status.CONFIRMED,
scheduled_for=timezone.now() + timezone.timedelta(days=GRACE_PERIOD_DAYS),
cancellation_token=secrets.token_urlsafe(48),
)
# Log the action
AccountDeletionLog.objects.create(
deletion_request=deletion_request,
action=AccountDeletionLog.Action.REQUESTED,
details={"ip_address": request.META.get("REMOTE_ADDR")},
)
# TODO: Send confirmation email with cancellation link
# send_deletion_confirmation_email(deletion_request)
return Response(
DeletionStatusSerializer(deletion_request).data,
status=status.HTTP_201_CREATED,
)
class CancelDeletionView(APIView):
"""POST /api/users/account/cancel-deletion"""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = CancelDeletionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
token = serializer.validated_data["token"]
try:
deletion_request = AccountDeletionRequest.objects.get(
cancellation_token=token,
status=AccountDeletionRequest.Status.CONFIRMED,
)
except AccountDeletionRequest.DoesNotExist:
return Response(
{"detail": "Invalid or expired cancellation token."},
status=status.HTTP_404_NOT_FOUND,
)
# Verify user owns this request
if deletion_request.user != request.user:
return Response(
{"detail": "This cancellation token does not belong to your account."},
status=status.HTTP_403_FORBIDDEN,
)
deletion_request.cancel()
# Log the action
AccountDeletionLog.objects.create(
deletion_request=deletion_request,
action=AccountDeletionLog.Action.CANCELLED,
details={"ip_address": request.META.get("REMOTE_ADDR")},
)
# TODO: Send cancellation confirmation email
# send_cancellation_confirmation_email(deletion_request)
return Response({"detail": "Deletion request cancelled successfully."})
class DeletionStatusView(APIView):
"""GET /api/users/account/deletion-status"""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
deletion_request = AccountDeletionRequest.objects.filter(
user=request.user,
status__in=[
AccountDeletionRequest.Status.PENDING,
AccountDeletionRequest.Status.CONFIRMED,
],
).first()
if not deletion_request:
return Response({"has_pending_deletion": False})
return Response({
"has_pending_deletion": True,
**DeletionStatusSerializer(deletion_request).data,
})- [ ] Step 2: Atualizar init.py
# apps/users/views/__init__.py
from .account_deletion import RequestDeletionView, CancelDeletionView, DeletionStatusView
__all__ = ["RequestDeletionView", "CancelDeletionView", "DeletionStatusView"]- [ ] Step 3: Commit
git add apps/users/views/account_deletion.py apps/users/views/__init__.py
git commit -m "feat(users): add account deletion views"Task 4: Adicionar rotas de exclusão
Covers: [S3]
Files:
Modify:
apps/users/urls.py[ ] Step 1: Adicionar rotas
# apps/users/urls.py - adicionar no final
from apps.users.views.account_deletion import RequestDeletionView, CancelDeletionView, DeletionStatusView
urlpatterns = [
# ... existing patterns ...
path("account/request-deletion/", RequestDeletionView.as_view(), name="request-deletion"),
path("account/cancel-deletion/", CancelDeletionView.as_view(), name="cancel-deletion"),
path("account/deletion-status/", DeletionStatusView.as_view(), name="deletion-status"),
]- [ ] Step 2: Commit
git add apps/users/urls.py
git commit -m "feat(users): add account deletion URLs"Task 5: Criar Celery tasks para processamento
Covers: [S4, S5]
Files:
Create:
apps/users/tasks/deletion.pyModify:
apps/users/tasks/__init__.py[ ] Step 1: Criar tasks
# apps/users/tasks/deletion.py
from celery import shared_task
from django.utils import timezone
from django.contrib.auth import get_user_model
from apps.users.models.account_deletion import AccountDeletionRequest, AccountDeletionLog
User = get_user_model()
@shared_task
def process_deletion_reminders():
"""
Send reminders for accounts scheduled for deletion.
Runs daily via celery-beat.
"""
from apps.users.services.email import (
send_deletion_reminder_email,
send_deletion_final_warning_email,
)
now = timezone.now()
# Find confirmed requests
requests = AccountDeletionRequest.objects.filter(
status=AccountDeletionRequest.Status.CONFIRMED,
).select_related("user")
for deletion_request in requests:
days_remaining = deletion_request.days_remaining
# 7-day reminder
if days_remaining == 7:
send_deletion_reminder_email(deletion_request)
AccountDeletionLog.objects.create(
deletion_request=deletion_request,
action=AccountDeletionLog.Action.REMINDER_SENT,
details={"days_remaining": 7},
)
# 24-hour final warning
elif days_remaining <= 1:
send_deletion_final_warning_email(deletion_request)
AccountDeletionLog.objects.create(
deletion_request=deletion_request,
action=AccountDeletionLog.Action.REMINDER_SENT,
details={"days_remaining": days_remaining},
)
@shared_task
def execute_account_deletion(deletion_request_id):
"""
Execute permanent account deletion.
Called by celery-beat when scheduled_for is reached.
"""
from django.db import transaction
try:
deletion_request = AccountDeletionRequest.objects.get(
id=deletion_request_id,
status=AccountDeletionRequest.Status.CONFIRMED,
)
except AccountDeletionRequest.DoesNotExist:
return
user = deletion_request.user
with transaction.atomic():
# 1. Anonymize posts (keep content, remove author association)
from apps.spaces.models import Post, Comment
Post.objects.filter(author__user=user).update(author=None)
Comment.objects.filter(author__user=user).update(author=None)
# 2. Delete memberships
user.memberships.all().delete()
# 3. Delete user
user.delete()
# 4. Mark deletion as completed
deletion_request.status = AccountDeletionRequest.Status.COMPLETED
deletion_request.completed_at = timezone.now()
deletion_request.save(update_fields=["status", "completed_at", "updated_at"])
# 5. Log the action
AccountDeletionLog.objects.create(
deletion_request=deletion_request,
action=AccountDeletionLog.Action.EXECUTED,
)- [ ] Step 2: Atualizar init.py
# apps/users/tasks/__init__.py
from .deletion import process_deletion_reminders, execute_account_deletion
__all__ = ["process_deletion_reminders", "execute_account_deletion"]- [ ] Step 3: Adicionar ao celery-beat
# project/celery.py ou settings.py - adicionar schedule
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"process-deletion-reminders": {
"task": "apps.users.tasks.deletion.process_deletion_reminders",
"schedule": crontab(hour=8, minute=0), # Daily at 8 AM
},
}- [ ] Step 4: Commit
git add apps/users/tasks/deletion.py apps/users/tasks/__init__.py
git commit -m "feat(users): add Celery tasks for account deletion"Task 6: Criar serviço de email
Covers: [S4]
Files:
Create:
apps/users/services/email.py[ ] Step 1: Criar serviço de email
# apps/users/services/email.py
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
def send_deletion_confirmation_email(deletion_request):
"""Send initial confirmation email with cancellation link."""
user = deletion_request.user
cancellation_url = f"{settings.FRONTEND_URL}/cancel-deletion?token={deletion_request.cancellation_token}"
subject = "Solicitação de exclusão de conta"
html_message = render_to_string(
"emails/account_deletion_confirmation.html",
{
"user": user,
"cancellation_url": cancellation_url,
"days_remaining": deletion_request.days_remaining,
},
)
plain_message = strip_tags(html_message)
send_mail(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
html_message=html_message,
)
def send_deletion_reminder_email(deletion_request):
"""Send 7-day reminder email."""
user = deletion_request.user
cancellation_url = f"{settings.FRONTEND_URL}/cancel-deletion?token={deletion_request.cancellation_token}"
subject = "Lembrete: Sua conta será excluída em 7 dias"
html_message = render_to_string(
"emails/account_deletion_reminder.html",
{
"user": user,
"cancellation_url": cancellation_url,
"days_remaining": deletion_request.days_remaining,
},
)
plain_message = strip_tags(html_message)
send_mail(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
html_message=html_message,
)
def send_deletion_final_warning_email(deletion_request):
"""Send 24-hour final warning email."""
user = deletion_request.user
cancellation_url = f"{settings.FRONTEND_URL}/cancel-deletion?token={deletion_request.cancellation_token}"
subject = "URGENTE: Sua conta será excluída em 24 horas"
html_message = render_to_string(
"emails/account_deletion_final_warning.html",
{
"user": user,
"cancellation_url": cancellation_url,
"days_remaining": deletion_request.days_remaining,
},
)
plain_message = strip_tags(html_message)
send_mail(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
html_message=html_message,
)
def send_cancellation_confirmation_email(deletion_request):
"""Send confirmation after cancellation."""
user = deletion_request.user
subject = "Exclusão de conta cancelada"
html_message = render_to_string(
"emails/account_deletion_cancelled.html",
{"user": user},
)
plain_message = strip_tags(html_message)
send_mail(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
html_message=html_message,
)- [ ] Step 2: Criar templates de email
<!-- templates/emails/account_deletion_confirmation.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #333;">Solicitação de exclusão de conta</h1>
<p>Olá {{ user.first_name|default:user.email }},</p>
<p>Recebemos sua solicitação de exclusão de conta. Sua conta será permanentemente excluída em <strong>{{ days_remaining }} dias</strong>.</p>
<p>Se mudou de ideia, você pode cancelar a exclusão clicando no botão abaixo:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ cancellation_url }}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">
Cancelar exclusão
</a>
</p>
<p style="color: #666; font-size: 12px;">Se você não solicitou a exclusão, ignore este email.</p>
</body>
</html><!-- templates/emails/account_deletion_reminder.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #FF9800;">⚠️ Lembrete: Exclusão em 7 dias</h1>
<p>Olá {{ user.first_name|default:user.email }},</p>
<p>Sua conta será permanentemente excluída em <strong>{{ days_remaining }} dias</strong>.</p>
<p>Se mudou de ideia, você pode cancelar a exclusão:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ cancellation_url }}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">
Cancelar exclusão
</a>
</p>
</body>
</html><!-- templates/emails/account_deletion_final_warning.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #F44336;">🚨 URGENTE: Exclusão em 24 horas</h1>
<p>Olá {{ user.first_name|default:user.email }},</p>
<p>Sua conta será permanentemente excluída em <strong>menos de 24 horas</strong>.</p>
<p>Para cancelar a exclusão, clique imediatamente:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ cancellation_url }}" style="background-color: #F44336; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">
Cancelar exclusão AGORA
</a>
</p>
</body>
</html><!-- templates/emails/account_deletion_cancelled.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #4CAF50;">✅ Exclusão cancelada</h1>
<p>Olá {{ user.first_name|default:user.email }},</p>
<p>Sua solicitação de exclusão foi cancelada com sucesso. Sua conta continua ativa.</p>
<p>Se precisar de ajuda, entre em contato conosco.</p>
</body>
</html>- [ ] Step 3: Commit
git add apps/users/services/email.py templates/emails/
git commit -m "feat(users): add email service for account deletion"Task 7: Gerar API client no frontend
Covers: [S3]
Files:
Modify:
frontend/openapi.json(via schema update)Modify:
frontend/src/api/generated.ts(via Orval)[ ] Step 1: Atualizar schema OpenAPI
Adicionar endpoints no openapi.json:
POST /api/users/account/request-deletion/
POST /api/users/account/cancel-deletion/
GET /api/users/account/deletion-status/
[ ] Step 2: Regenerar client
cd /home/juninho/dev/go/django-boilerplate/frontend
bunx orval- [ ] Step 3: Commit
git add frontend/src/api/generated.ts
git commit -m "feat(frontend): regenerate API client with deletion endpoints"Task 8: Criar componente DeletionRequestDialog
Covers: [S6]
Files:
Create:
frontend/src/components/account/DeletionRequestDialog.tsx[ ] Step 1: Criar componente
// frontend/src/components/account/DeletionRequestDialog.tsx
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertTriangle } from "lucide-react";
import { useApiUsersAccountRequestDeletionCreate } from "@/api/generated";
import { toast } from "sonner";
interface DeletionRequestDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userEmail: string;
}
export function DeletionRequestDialog({ open, onOpenChange, userEmail }: DeletionRequestDialogProps) {
const [confirmEmail, setConfirmEmail] = useState("");
const [error, setError] = useState("");
const requestDeletion = useApiUsersAccountRequestDeletionCreate();
const handleSubmit = () => {
if (confirmEmail !== userEmail) {
setError("O email não corresponde à sua conta.");
return;
}
requestDeletion.mutate(
{ confirmEmail },
{
onSuccess: () => {
toast.success("Solicitação de exclusão enviada. Verifique seu email.");
onOpenChange(false);
setConfirmEmail("");
},
onError: (err) => {
setError(err.message || "Erro ao solicitar exclusão.");
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Excluir conta
</DialogTitle>
<DialogDescription>
Esta ação é irreversível após 30 dias. Todos os seus dados serão excluídos permanentemente.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">O que será excluído:</p>
<ul className="mt-2 list-disc list-inside space-y-1">
<li>Sua conta e perfil</li>
<li>Todas as suas memberships</li>
<li>Mensagens e comentários (anonimizados)</li>
<li>Notificações e preferências</li>
</ul>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-email">
Digite <span className="font-mono">{userEmail}</span> para confirmar:
</Label>
<Input
id="confirm-email"
type="email"
value={confirmEmail}
onChange={(e) => {
setConfirmEmail(e.target.value);
setError("");
}}
placeholder={userEmail}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={requestDeletion.isPending || confirmEmail !== userEmail}
>
{requestDeletion.isPending ? "Enviando..." : "Solicitar exclusão"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}- [ ] Step 2: Commit
git add frontend/src/components/account/DeletionRequestDialog.tsx
git commit -m "feat(frontend): add deletion request dialog"Task 9: Criar componente DeletionStatusCard
Covers: [S6]
Files:
Create:
frontend/src/components/account/DeletionStatusCard.tsx[ ] Step 1: Criar componente
// frontend/src/components/account/DeletionStatusCard.tsx
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AlertTriangle, Clock, XCircle } from "lucide-react";
import { useApiUsersAccountDeletionStatusRetrieve, useApiUsersAccountCancelDeletionCreate } from "@/api/generated";
import { toast } from "sonner";
export function DeletionStatusCard() {
const { data: statusResponse, isLoading } = useApiUsersAccountDeletionStatusRetrieve();
const cancelDeletion = useApiUsersAccountCancelDeletionCreate();
if (isLoading || !statusResponse?.data?.has_pending_deletion) {
return null;
}
const status = statusResponse.data;
const handleCancel = () => {
cancelDeletion.mutate(
{ token: status.cancellation_token },
{
onSuccess: () => {
toast.success("Exclusão cancelada com sucesso!");
},
onError: () => {
toast.error("Erro ao cancelar exclusão.");
},
},
);
};
return (
<Card className="border-destructive/50 bg-destructive/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Conta agendada para exclusão
</CardTitle>
<CardDescription>
Sua conta será permanentemente excluída em {status.days_remaining} dias.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>Excluída em: {new Date(status.scheduled_for).toLocaleDateString("pt-BR")}</span>
</div>
<Button variant="outline" onClick={handleCancel} disabled={cancelDeletion.isPending}>
<XCircle className="h-4 w-4 mr-2" />
{cancelDeletion.isPending ? "Cancelando..." : "Cancelar exclusão"}
</Button>
</CardContent>
</Card>
);
}- [ ] Step 2: Commit
git add frontend/src/components/account/DeletionStatusCard.tsx
git commit -m "feat(frontend): add deletion status card"Task 10: Atualizar página de configurações da conta
Covers: [S6]
Files:
Modify:
frontend/src/routes/settings/account.tsx[ ] Step 1: Adicionar componentes de exclusão
// Adicionar imports no topo
import { DeletionRequestDialog } from "@/components/account/DeletionRequestDialog";
import { DeletionStatusCard } from "@/components/account/DeletionStatusCard";
// Adicionar estado
const [showDeletionDialog, setShowDeletionDialog] = useState(false);
// Adicionar DeletionStatusCard antes do formulário
<DeletionStatusCard />
// Substituir botão de desativar por dois botões
<div className="space-y-3">
<Button
variant="destructive"
onClick={() => setShowDeletionDialog(true)}
>
<Trash2 className="h-4 w-4 mr-2" />
Excluir minha conta
</Button>
<p className="text-xs text-muted-foreground">
A exclusão leva 30 dias. Você pode cancelar neste período.
</p>
</div>
// Adicionar DeletionRequestDialog
<DeletionRequestDialog
open={showDeletionDialog}
onOpenChange={setShowDeletionDialog}
userEmail={user?.email ?? ""}
/>- [ ] Step 2: Commit
git add frontend/src/routes/settings/account.tsx
git commit -m "feat(frontend): update account settings with deletion options"Task 11: Criar página de cancelamento
Covers: [S6]
Files:
Create:
frontend/src/routes/cancel-deletion.tsx[ ] Step 1: Criar página
// frontend/src/routes/cancel-deletion.tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
import { useApiUsersAccountCancelDeletionCreate } from "@/api/generated";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const Route = createFileRoute("/cancel-deletion")({
validateSearch: (search: Record<string, unknown>) => ({
token: (search.token as string) ?? "",
}),
component: CancelDeletionPage,
});
function CancelDeletionPage() {
const { token } = Route.useSearch();
const navigate = useNavigate();
const cancelDeletion = useApiUsersAccountCancelDeletionCreate();
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
useEffect(() => {
if (!token) {
setStatus("error");
return;
}
cancelDeletion.mutate(
{ token },
{
onSuccess: () => setStatus("success"),
onError: () => setStatus("error"),
},
);
}, [token]);
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
{status === "success" ? (
<>
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<CardTitle>Exclusão cancelada!</CardTitle>
<CardDescription>
Sua conta continua ativa. Obrigado por continuar conosco!
</CardDescription>
</>
) : status === "error" ? (
<>
<XCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
<CardTitle>Erro ao cancelar</CardTitle>
<CardDescription>
O link de cancelamento é inválido ou expirou.
</CardDescription>
</>
) : (
<>
<Loader2 className="h-12 w-12 text-muted-foreground mx-auto mb-4 animate-spin" />
<CardTitle>Processando...</CardTitle>
<CardDescription>
Aguarde enquanto cancelamos a exclusão.
</CardDescription>
</>
)}
</CardHeader>
<CardContent className="text-center">
<Button onClick={() => navigate({ to: "/" })}>
Voltar ao início
</Button>
</CardContent>
</Card>
</div>
);
}- [ ] Step 2: Commit
git add frontend/src/routes/cancel-deletion.tsx
git commit -m "feat(frontend): add cancellation page"Task 12: Testes
Covers: [S7]
Files:
Create:
apps/users/tests/test_account_deletion.py[ ] Step 1: Criar testes
# apps/users/tests/test_account_deletion.py
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from apps.users.models import CustomUser
from apps.users.models.account_deletion import AccountDeletionRequest, AccountDeletionLog
from apps.communities.models import Membership
class AccountDeletionTests(TestCase):
def setUp(self):
self.user = CustomUser.objects.create_user(
email="test@example.com",
password="testpass123",
)
self.community = Community.objects.create(name="Test", slug="test")
self.membership = Membership.objects.create(
user=self.user,
community=self.community,
status=Membership.Status.ACTIVE,
)
def test_request_deletion(self):
"""Test requesting account deletion."""
deletion_request = AccountDeletionRequest.objects.create(
user=self.user,
status=AccountDeletionRequest.Status.CONFIRMED,
scheduled_for=timezone.now() + timedelta(days=30),
cancellation_token="test-token",
)
self.assertEqual(deletion_request.status, AccountDeletionRequest.Status.CONFIRMED)
self.assertEqual(deletion_request.days_remaining, 30)
def test_cancel_deletion(self):
"""Test cancelling deletion request."""
deletion_request = AccountDeletionRequest.objects.create(
user=self.user,
status=AccountDeletionRequest.Status.CONFIRMED,
scheduled_for=timezone.now() + timedelta(days=30),
cancellation_token="test-token",
)
deletion_request.cancel()
self.assertEqual(deletion_request.status, AccountDeletionRequest.Status.CANCELLED)
self.assertIsNotNone(deletion_request.cancelled_at)
def test_deactivation_vs_deletion(self):
"""Test that deactivation doesn't delete the user."""
self.membership.status = Membership.Status.ACTIVE
self.membership.save()
# Simulate deactivation
self.membership.status = "deactivated"
self.membership.save(update_fields=["status"])
self.assertEqual(self.membership.status, "deactivated")
self.assertTrue(CustomUser.objects.filter(id=self.user.id).exists())- [ ] Step 2: Rodar testes
cd /home/juninho/dev/go/django-boilerplate
uv run manage.py test apps.users.tests.test_account_deletion -v 2- [ ] Step 3: Commit
git add apps/users/tests/test_account_deletion.py
git commit -m "test(users): add account deletion tests"Task 13: Documentação
Covers: [S8]
Files:
Create:
docs/account-deletion.md[ ] Step 1: Criar documentação
# Sistema de Exclusão de Conta
## Visão Geral
O sistema de exclusão de conta segue as melhores práticas de LGPD/GDPR, oferecendo:
1. **Desativação de membership** — Acesso revogado mas dados mantidos
2. **Exclusão de conta** — Período de 30 dias antes da exclusão permanente
## Fluxo de Exclusão
1. Usuário solicita exclusão em Configurações > Conta
2. Confirma digitando o email
3. Email de confirmação enviado com link de cancelamento
4. Conta entra em período de carência (30 dias)
5. Lembrete enviado 7 dias antes
6. Último aviso 24 horas antes
7. Exclusão permanente executada
## O que é excluído
- Conta do usuário (CustomUser)
- Todas as memberships
- Notificações
- Preferências
- Transações de pontos
## O que é mantido (anonimizado)
- Posts (autor removido)
- Comentários (autor removido)
## Como cancelar
- Clique no link de cancelamento nos emails
- Acesse /cancel-deletion?token=...
- Faça login e cancele em Configurações > Conta
## APIs
- `POST /api/users/account/request-deletion/` — Solicitar exclusão
- `POST /api/users/account/cancel-deletion/` — Cancelar exclusão
- `GET /api/users/account/deletion-status/` — Verificar status- [ ] Step 2: Commit
git add docs/account-deletion.md
git commit -m "docs: add account deletion documentation"Resumo
| Task | Descrição | Status |
|---|---|---|
| 1 | Modelos de exclusão | - [ ] |
| 2 | Serializers | - [ ] |
| 3 | Views | - [ ] |
| 4 | Rotas | - [ ] |
| 5 | Celery tasks | - [ ] |
| 6 | Serviço de email | - [ ] |
| 7 | API client frontend | - [ ] |
| 8 | DeletionRequestDialog | - [ ] |
| 9 | DeletionStatusCard | - [ ] |
| 10 | Atualizar settings/account | - [ ] |
| 11 | Página de cancelamento | - [ ] |
| 12 | Testes | - [ ] |
| 13 | Documentação | - [ ] |