Cerrar una cuenta: borrado y anonimización
Cualquier proyecto con usuarios acaba necesitando esto, y el primer instinto —un DELETE o un
deletedAt— se queda corto en cuanto alguien pide que sus datos desaparezcan de verdad.
La decisión que ordena todo lo demás es la primera:
Darse de baja y suprimir los datos no son la misma operación. La baja corta el acceso y es reversible; la supresión destruye la identidad y no lo es.
Si viven en el mismo caso de uso, la irreversible se acaba ejecutando por rutina.
Dos operaciones, dos caminos
DeleteUserUseCase → marca deletedAt, borra tokens de push, emite evento
AnonymizeUserUseCase → compone el anterior y además sustituye la identidad
| Qué hace | Reversible | Para quién | |
|---|---|---|---|
| Baja | Cierra la cuenta y arranca el reloj del margen | Sí | El que se va y puede arrepentirse |
| Supresión | Sustituye la identidad en el acto | No | El que ejerce su derecho de borrado |
La supresión compone la baja en vez de duplicarla, y eso trae un detalle de orden que no se adivina:
// La baja va primero y con la dirección real todavía puesta: el aviso de
// "cuenta eliminada" no se puede mandar a una dirección que ya se borró.
if (active) await this.deleteUser.execute(targetUserId, AccountClosureKind.ERASED);
El kind es lo único que las distingue de cara al usuario: decide qué correo recibe y si se le dice
una fecha de vuelta. Un booleano habría funcionado hoy y habría envejecido mal.
El margen es del servicio, no del usuario
Las cuentas dadas de baja se anonimizan solas al cumplir el plazo, con una tarea programada. Pero ese margen existe para beneficio del servicio —recuperar a quien se arrepiente, y una ventana para deshacer un error— así que no se le puede imponer a quien pide expresamente que se borren sus datos.
De ahí salen dos puertas en la interfaz, no un botón con dos confirmaciones: “desactivar” y “eliminar ahora”. Y las dos piden la contraseña, porque cerrar la cuenta de otro es exactamente lo que hace una sesión robada.
La identidad de sustitución
Anonimizar no es poner null en todo: email es único y hace falta un valor por cada baja.
const ANONYMIZED_EMAIL_DOMAIN = "anonymized.invalid";
export function anonymizedEmailFor(userId: string): string {
return `anonymized-${userId}@${ANONYMIZED_EMAIL_DOMAIN}`;
}
Tres decisiones metidas en dos líneas:
.invalidestá reservado por la RFC 2606 y no resuelve ni puede resolver nunca. Una dirección anonimizada no puede recibir correo por accidente, ni siquiera si alguien reutiliza esa fila en un envío masivo.- Se deriva del id, que da tantas direcciones distintas como bajas. El id no es dato personal: ya está en cada fila del historial que se conserva a propósito.
- El campo de contraseña lleva un marcador, no un hash válido, así que no puede coincidir con ninguna comprobación aunque el login llegase hasta ahí.
El sufijo, exportado a propósito
/** Exportado porque el cron filtra por él en SQL, donde no puede llamar a
* `isAnonymizedEmail`. Las dos formas de reconocer una identidad sustituida
* salen de aquí para que no puedan divergir. */
export const ANONYMIZED_EMAIL_SUFFIX = `@${ANONYMIZED_EMAIL_DOMAIN}`;
Cuando el mismo criterio se aplica en TypeScript y en una consulta, vive en una constante compartida o termina escrito dos veces con dos formas distintas.
Idempotencia por el propio dato
Una solicitud de supresión se reintenta: el CRM la reenvía, alguien pulsa dos veces, la tarea la vuelve a coger. No hace falta un flag nuevo, porque el dato ya lo dice:
if (isAnonymizedEmail(user.email)) {
this.logger.log(`User ${targetUserId} is already anonymized, skipping`);
return;
}
Sin esto, el segundo intento reenvía el correo de despedida a una dirección que ya no existe, y eso es un rebote en el proveedor por una operación que debería no hacer nada.
El orden de los pasos: fallar del lado bueno
Esta es la parte que distingue un borrado que aguanta de uno que deja basura. Cada paso va donde va porque un fallo a mitad tiene que dejar el sistema en el estado menos malo:
// Antes del soft delete: si algo falla después, es preferible un usuario sin
// tokens a un usuario dado de baja que sigue recibiendo push.
await this.devices.deleteByUserId(userId);
await this.users.delete(userId);
// Antes de anonimizar, no después: si algo falla en medio es preferible una
// cuenta intacta sin códigos —ya está cerrada, no los va a usar— que una
// identidad borrada con credenciales vivas colgando.
await this.backupCodes.deleteForUser(targetUserId);
await this.users.anonymize(targetUserId, anonymizedEmailFor(targetUserId));
La regla general: primero lo que quita capacidades, después lo que quita identidad. Al revés se queda una cuenta sin dueño con credenciales que siguen funcionando.
Y el borrado de tokens está repetido en las dos operaciones a propósito: para una cuenta cerrada hace meses ya se hizo, pero un token de push es dato personal y no puede depender de que aquel borrado lo hiciera bien.
onDelete: Cascade no te salva
El modelo declara cascadas, y no se disparan nunca: la cascada es de borrado duro y aquí no se borra ninguna fila. Todo lo que cuelga de la identidad —códigos de respaldo, dispositivos, cualquier satélite— se limpia a mano, en su repositorio, desde el caso de uso.
Lo que sí es transacción es la sustitución en sí:
// Una sola transacción: el perfil es un satélite 1:1 del usuario, así que
// dejarlos a medias sería dejar medio identificador publicado.
await this.prisma.$transaction([
this.prisma.user.update({ where: { id }, data: { email: anonymizedEmail, /* … */ } }),
// updateMany y no update: si la fila de perfil no existiera, update lanzaría
// y tumbaría la transacción entera por nada.
this.prisma.profile.updateMany({ where: { userId: id }, data: { name: "", /* … */ } }),
]);
Qué no se borra
Lo más importante de la página: el historial no se toca. Los informes, las citas y el resto de filas que apuntan al usuario por id se quedan, porque pertenecen al animal y a la clínica que los escribió, no a la cuenta.
Y por eso mismo hay una regla de dominio que sorprende a quien llega:
if (user.role !== Role.USER) {
throw new CannotAnonymizeStaffError(targetUserId);
}
El personal no se anonimiza desde aquí, y el 422 no es un permiso: el nombre de un veterinario
es la autoría de cada informe que firmó, así que vaciarlo reescribiría el historial clínico de toda
la clínica. Es un 422 y no un 403 porque no falta autorización — la operación no tiene sentido
sobre ese objetivo. Ver errores de dominio.
El registro que no es tuyo para editarlo
Todo lo anterior trata de una identidad, que su titular puede corregir cuando quiera. Un registro que escribe un profesional es otra cosa: el informe clínico, el apunte contable, el acta. Ahí el borrado blando no basta, porque el problema no es quién lo ve, es que se pueda reescribir el pasado.
Dos patrones, y la misma idea detrás: lo que ocurrió no se quita, se corrige.
Rectificar en vez de editar
Si el registro es inmutable —y suele serlo por buenas razones— “editarlo” acaba siendo borrarlo y crear otro. Eso son dos hechos sin relación entre ellos, y lo que ocurrió es uno: este registro corrige aquel. Así que la operación se llama como lo que es:
POST /reports/:reportId/diagnosis/corrections
Emite la fila nueva, retira la anterior con su motivo y guarda el enlace entre las dos. Cuatro decisiones que no se ven en la firma:
- El puntero vive en la fila nueva (
supersedesId): se escribe al crearla, sin volver a tocar la vieja. Un@uniquesobre él mantiene la cadena en línea recta en vez de en árbol. - El único del padre pasa a parcial. De
@@unique([reportId])a un índiceWHERE "deletedAt" IS NULL: un informe tiene un registro vigente y N corregidos. Prisma no sabe expresar elWHERE, así que vive en la migración — mismo caso que el histórico de chapas. - Se retira antes de crear, y no al revés: el índice parcial sólo admite un vigente, así que el orden inverso choca contra la base de datos. Si algo falla en medio queda el padre sin registro vigente y la cadena intacta, que es recuperable emitiendo el nuevo.
- El motivo es obligatorio. Sin él, el historial dice que algo cambió y no por qué, que es lo único que se pregunta tres meses después.
Y la consecuencia de tipos que sorprende: el padre deja de poder declarar la relación como 1:1.
Prisma exige campo único en el lado que la define, así que diagnosis: Diagnosis? pasa a
diagnoses: Diagnosis[]. El “uno vigente” lo garantiza el índice, no el tipo, y hay que decirlo
en el schema porque a partir de ahí nadie lo deduce leyendo.
La lápida: destruir el fichero, conservar el registro
El borrado blando de una fila es gratis. Un fichero en el almacenamiento no: si nunca se borra nada, el bucket sólo crece. Y hay casos en que borrarlo es lo correcto y no lo tolerado:
- Archivado donde no debía. Un adjunto subido al paciente equivocado lleva datos de un tercero colgados de una ficha que no es la suya. Archivarlo “por trazabilidad” es mantener una brecha abierta.
- Contenido que no debería estar ahí. La foto de un documento de identidad, algo que no es clínico. Minimizar dice borrar, no etiquetar.
- Fin del plazo de conservación, cuando exista.
La lápida es el mecanismo: se destruye el objeto y la fila se queda vacía de lo que apunta a él.
// Null = lápida: el objeto se destruyó y de él queda sólo este registro.
// El @unique aguanta porque en Postgres varios null no colisionan.
storageKey String? @unique
purgedAt DateTime? @db.Timestamptz
purgedReason String?
purgedById String?
Se conservan nombre, tipo, tamaño, quién lo subió y cuándo: la constancia de que ese documento existió. Y eso arrastra tres cosas que hay que implementar con ella, o la lápida se nota en forma de error:
| Dónde | Qué cambia |
|---|---|
| La descarga | 410 y no 404: la fila existe y dice que el documento existió; lo que no hay es fichero |
| El constructor de URLs firmadas | Devuelve vacío: un enlace para una lápida es un enlace que promete un 410 |
| La exportación de datos | Entrega el registro sin fichero, con su fecha y motivo |
Dos operaciones, no una con un flag
Retirar conserva los bytes y sólo cambia la visibilidad; la lápida los destruye. Son distintas en todo lo que importa —una es rutina y la otra irreversible—, así que son dos endpoints, dos permisos y dos textos:
DELETE /reports/:reportId/resources/:id → retirar (staff)
DELETE /reports/:reportId/resources/:id/file → destruir el fichero (admin)
:id/file y no :id porque lo que se borra es el fichero, y el registro es justo lo que se
conserva. Y la segunda exige que la primera se haya hecho: retirar es lo que corta la exposición
—sale del listado de quien no debe verlo, así que no puede ni obtener un token de descarga—, de modo
que lo urgente se resuelve en ese paso y destruir es limpieza posterior. El paso intermedio deja
además registrado por qué se retiró, que la lápida hereda.
En la interfaz, la consecuencia es que lo irreversible no vive en el flujo rutinario: la acción de destruir se ofrece al revisar lo retirado, no en el diálogo que alguien abre veinte veces al mes. Y con motivos predefinidos, porque los supuestos legítimos son dos y conviene que queden escritos igual siempre.
La trampa del listado
Un findById normal filtra por deletedAt: null, y una solicitud de supresión llega casi siempre
sobre una cuenta ya cerrada. Así que la operación necesita ver lo que el resto del sistema esconde:
const active = await this.users.findById(targetUserId);
const user = active ?? (await this.users.findByIdIncludingDeleted(targetUserId));
active hace doble papel: el usuario, y si todavía lo está.
Lo mismo pasa un nivel arriba, en el panel: si el listado filtra las cerradas, la pantalla que
tiene que atender estas solicitudes no ve a nadie a quien atender. Hace falta un filtro de estado
explícito, no un listado que las esconda. Es el primer sitio donde aparece este findByIdIncludingDeleted,
y luego vuelve a hacer falta para entregar una copia de los datos
de alguien que ya se fue.
Qué probar
| Qué | Por qué |
|---|---|
| Anonimizar dos veces no hace nada la segunda | Es la idempotencia, y sin ella hay un correo de más |
| Alcanza a una cuenta ya cerrada | Es el caso normal, no el raro |
| Sobre otro tenant responde como si no existiera | Un 404 y no un 403: quién existe en otra clínica no se filtra |
| Sobre personal devuelve 422 | La regla de dominio que protege la autoría del historial |
| Los tokens de push desaparecen en las dos operaciones | Es dato personal y la repetición es deliberada |
| El historial sigue ahí después | Es lo que separa esto de un borrado a secas |
| Corregir emite una fila nueva enlazada, y retira antes de crear | El orden inverso choca contra el índice parcial |
| No se puede destruir un fichero que sigue vigente | Retirar es lo que corta la exposición; destruir es lo de después |