Nunca Apague Nada: Soft Delete, Auditoria e LGPD
Soft Delete com @DeleteDateColumn no TypeORM, subscriber para auditoria de deleções, unique constraint em colunas com soft delete, withDeleted para histórico, e como a LGPD exige deleção física que conflita com o soft delete.
Um DELETE FROM users WHERE id = '...' parece inocente, mas tem efeitos colaterais graves: registros relacionados ficam órfãos, relatórios históricos perdem dados, e o cliente que deletou a conta por engano perde tudo permanentemente. O Soft Delete — registrar uma data de exclusão em vez de apagar fisicamente — mantém o histórico íntegro enquanto o registro se torna invisível nas queries normais.
Implementação com @DeleteDateColumn
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
// Unique precisa levar deletedAt em conta:
// dois usuários não podem ter o mesmo email ATIVO,
// mas um deletado pode ter o mesmo email de um ativo
@Column({ unique: true })
email: string;
@Column()
passwordHash: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// @DeleteDateColumn: o TypeORM sabe que esta entidade usa Soft Delete
// Automaticamente injeta WHERE deletedAt IS NULL em TODAS as queries
@DeleteDateColumn({ nullable: true })
deletedAt: Date | null;
}Repositório: Soft Delete, Restore e withDeleted
O @DeleteDateColumn transforma o comportamento de todos os métodos de busca do TypeORM: findOne, find, findBy, count — todos injetam automaticamente WHERE deleted_at IS NULL. Você nunca precisa lembrar de filtrar manualmente. O withDeleted: true é o opt-in explícito para quando você precisa ver registros deletados, como em paineis de auditoria ou relatórios históricos.
async delete(id: string): Promise<void> {
// softDelete: faz UPDATE SET deletedAt = NOW() em vez de DELETE
// O TypeORM injeta automaticamente WHERE deletedAt IS NULL
await this.repo.softDelete({ id });
}
async restore(id: string): Promise<void> {
// restore: limpa o deletedAt — o registro volta a aparecer nas queries
await this.repo.restore({ id });
}
async findById(id: string): Promise<User | null> {
// find/findOne: automaticamente filtra WHERE deletedAt IS NULL
return this.repo.findOneBy({ id });
}
async findDeletedById(id: string): Promise<User | null> {
// withDeleted: inclui registros soft-deleted na busca
return this.repo.findOne({
where: { id },
withDeleted: true,
});
}
async findAllIncludingDeleted(): Promise<Array<User>> {
// Histórico completo para relatórios de auditoria
return this.repo.find({ withDeleted: true });
}
async hardDelete(id: string): Promise<void> {
// Deleção física — use APENAS quando exigido por LGPD/GDPR
// (direito ao esquecimento do titular)
await this.repo.delete({ id }); // DELETE real no banco
}O Problema do Unique Constraint com Soft Delete
O @Column({ unique: true }) no email causa um problema: se um usuário é soft-deleted e tenta criar uma nova conta com o mesmo email, o banco rejeita com violação de unique — mesmo com o registro anterior marcado como deletado. A solução é usar um índice condicional no PostgreSQL:
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddConditionalUniqueIndex1234 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
// Remove o unique constraint simples
await queryRunner.query(`
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
`);
// Cria um índice único APENAS para registros não-deletados
// Registros com deletedAt != NULL podem ter o mesmo email
await queryRunner.query(`
CREATE UNIQUE INDEX users_email_unique_active
ON users (email)
WHERE deleted_at IS NULL;
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS users_email_unique_active;`);
await queryRunner.query(`ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);`);
}
}Soft Delete vs LGPD/GDPR
A LGPD (Lei Geral de Proteção de Dados) e o GDPR europeu garantem ao titular o direito ao esquecimento: quando solicitado, os dados pessoais devem ser eliminados de forma permanente. O Soft Delete conflita diretamente com isso — o dado ainda existe no banco. A solução não é remover o Soft Delete, mas implementar anonimização: substituir dados pessoais por valores genéricos enquanto mantém o registro para integridade referencial e obrigações legais de retenção.
- Dados de negócio (pedidos, faturas, histórico financeiro) — use Soft Delete. Eles têm obrigação legal de retenção (Código Civil, Receita Federal).
- Dados pessoais identificáveis (nome, CPF, email, endereço) — implemente anonimização: ao invés de apagar, substitua por valores genéricos (
[REMOVIDO], UUID aleatório). O histórico de negócio continua íntegro, mas sem identificar a pessoa. - Dados sensíveis (senhas, tokens) — hard delete imediato sem exceção.
export class AnonymizeUserUseCase {
async execute(userId: string): Promise<void> {
const user = await this.usersRepo.findById(userId);
if (!user) throw new AppError('Usuário não encontrado.', 404);
// Anonimiza dados pessoais — mantém o registro para integridade referencial
user.name = '[CONTA ENCERRADA]';
user.email = `removed-${randomUUID()}@encerrado.com`; // Único mas não rastreável
user.passwordHash = 'INVALIDATED'; // Torna o hash inválido
user.avatarUrl = null;
user.deletedAt = new Date();
await this.usersRepo.save(user);
// Hard delete de dados sensíveis relacionados
await this.refreshTokensRepo.deleteAllByUser(userId);
await this.addressesRepo.deleteAllByUser(userId);
// Log de auditoria: 'usuário ID X anonimizado em data Y'
await this.auditLog.record('USER_ANONYMIZED', { userId });
}
}Documente sua política de retenção de dados. Para cada tipo de dado, defina: quanto tempo fica armazenado, em qual formato (completo vs anonimizado), e qual o trigger para deleção (solicitação do usuário, prazo legal, inatividade). Essa documentação é parte obrigatória da conformidade com LGPD.
Conclusão
Soft Delete com @DeleteDateColumn resolve integridade referencial, auditoria e recuperação acidental com zero esforço extra — o TypeORM injeta o filtro automaticamente em todas as queries. O índice condicional no PostgreSQL resolve o conflito com unique constraints. E a anonimização para LGPD garante compliance sem quebrar o histórico de negócio: o usuário some como entidade identificável, mas os pedidos e faturas permanecem consistentes.