services/api/src/scripts/purgeExpiredAccounts.js

Overview

Este script ejecuta la purga final de cuentas que ya pasaron por la fase de anonimización y quedaron listas para eliminación definitiva. Busca usuarios con accountStatus: 'anonymized_pending_purge', purgeScheduledAt <= now y deletionCompletedAt: null, y procesa cada cuenta por lotes. El archivo exporta runPurgeExpiredAccounts para pruebas y uso programático, y solo abre la conexión a MongoDB cuando se ejecuta directamente como CLI.

[!IMPORTANT] El modo por defecto es dry-run. La mutación real solo ocurre con --apply. El flujo mantiene la idempotencia porque el filtro exige deletionCompletedAt: null, y las cuentas ya purgadas dejan de coincidir en corridas posteriores.

El código también aplica una política estricta de saneamiento. Reemplaza email y phone por placeholders deterministas derivados de derivePurgedIdentifierDigest, para no romper índices únicos. Además, limpia campos sensibles del usuario, elimina conversaciones, purga leads y vacía datos públicos de listings asociados.

How it works

sequenceDiagram
    participant CLI as CLI entrypoint
    participant Env as dotenv
    participant DB as mongoose
    participant Job as runPurgeExpiredAccounts
    participant User as User model
    participant Listing as Listing model
    participant Media as deleteListingMediaAssets
    participant Lead as Lead model
    participant Comment as ListingComment model
    participant Conversation as Conversation model
    participant Outbox as NotificationOutbox
    participant Notify as emitNotificationEvent

    CLI->>Env: load env files
    CLI->>DB: connect to MongoDB
    CLI->>Job: run purge
    Job->>User: find matching accounts
    alt dry run
        Job-->>CLI: summary only
    else apply
        loop each user
            Job->>Listing: find anonymized listings
            Job->>Media: delete listing assets
            Job->>Listing: clear listing data
            Job->>Lead: purge leads
            Job->>Comment: mark comments purged
            Job->>Conversation: delete user conversations
            Job->>Notify: emit ACCOUNT_PURGED
            Job->>Outbox: delete user outbox rows
            Job->>User: mark account purged
        end
        Job-->>CLI: summary JSON
    end
    CLI->>DB: disconnect

El flujo empieza con la carga de variables de entorno y la conexión a MongoDB. Luego runPurgeExpiredAccounts recupera usuarios elegibles y decide entre solo contar coincidencias o aplicar cambios. En modo apply, cada usuario pasa por purgeSingleUser, que primero limpia listings y después purga el resto de datos del propietario.

Key components

{
    "title": "runPurgeExpiredAccounts",
    "content": "Recorre usuarios elegibles por lotes, soporta dry-run y apply, y devuelve un resumen JSON con matched, purged, errors y errorUserIds.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}
{
    "title": "purgeSingleUser",
    "content": "Coordina la purga completa de una cuenta. Limpia listings, leads, comentarios, conversaciones, outbox y finalmente marca el usuario como purged.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}
{
    "title": "purgeListingsForUser",
    "content": "Purga cada listing del usuario de forma independiente. Elimina assets de media, limpia campos p\u00fablicos y falla de forma agregada si alguna media no pudo borrarse.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildPurgedIdentifiers",
    "content": "Genera placeholders deterministas para email y phone usando un digest derivado del _id del usuario.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildListingSanitizationSet",
    "content": "Construye el conjunto de campos que se vac\u00edan en un listing durante la purga definitiva.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}
{
    "title": "CLI entrypoint",
    "content": "Cuando el archivo se ejecuta directamente, carga dotenv, conecta a MongoDB, ejecuta la purga y escribe el resumen en JSON.",
    "type": "function",
    "filePath": "services/api/src/scripts/purgeExpiredAccounts.js",
    "badges": [
        "public"
    ]
}

Data & fields

Este script trabaja con varios conjuntos de datos relacionados. El usuario es la unidad principal de trabajo, pero la purga también afecta listings, leads, comentarios, conversaciones y la cola de notificaciones. La mayor parte de los campos se limpian por User.updateOne y Listing.updateOne, mientras que otras colecciones usan updateMany o deleteMany.

Execution options

[
    {
        "name": "dryRun",
        "type": "boolean",
        "description": "Cuando es true, solo cuenta coincidencias y no muta datos. El valor por defecto es true."
    },
    {
        "name": "batchSize",
        "type": "number",
        "description": "Cantidad máxima de usuarios procesados por iteración. El valor por defecto es 50."
    },
    {
        "name": "now",
        "type": "Date",
        "description": "Marca temporal de referencia para la elegibilidad y para los timestamps de purga. El valor por defecto es new Date()."
    }
]

Summary object

[
    {
        "name": "mode",
        "type": "string",
        "required": true,
        "description": "Indica dry-run o apply."
    },
    {
        "name": "batchSize",
        "type": "number",
        "required": true,
        "description": "Batch size efectivo usado en la ejecución."
    },
    {
        "name": "matched",
        "type": "number",
        "required": true,
        "description": "Cantidad de usuarios que coincidieron con el filtro."
    },
    {
        "name": "purged",
        "type": "number",
        "required": true,
        "description": "Cantidad de usuarios purgados con éxito."
    },
    {
        "name": "errors",
        "type": "number",
        "required": true,
        "description": "Cantidad de fallos registrados a nivel de usuario."
    },
    {
        "name": "errorUserIds",
        "type": "string[]",
        "required": true,
        "description": "Lista de IDs de usuario que fallaron durante la ejecución."
    }
]

Query principal de usuarios

  • accountStatus [badge:required] string — Debe ser anonymized_pending_purge.
  • purgeScheduledAt [badge:required] Date — Debe ser menor o igual que now.
  • deletionCompletedAt [badge:required] null — Garantiza que el proceso sea idempotente.

Identificadores purgados

Genera dos valores deterministas:

  • email — purged+<digest>@account.invalid
  • phone — purged-<digest>

El digest proviene de derivePurgedIdentifierDigest(userId). Los tests confirman que el digest es estable por usuario y distinto entre usuarios. También confirman que los placeholders siguen ese patrón exacto.

Campos que se escriben en User

  • accountStatus → purged
  • deletionCompletedAt → now
  • email → placeholder determinista
  • phone → placeholder determinista
  • passwordHash → ''
  • retentionContact → ''
  • name → Usuario eliminado
  • firstName → Usuario
  • lastName → eliminado
  • bio → ''
  • photo → ''
  • coverPhoto → ''
  • website → ''
  • location → ''
  • seo.descriptors → ''
  • seo.aiKeywords → ''
  • seo.schemaType → ''

Los tests verifican que email, phone, username, passwordHash, retentionContact y accountStatus quedan en los valores esperados. También verifican que deletionCompletedAt se completa.

  • username
  • restorationToken
  • restorationTokenExpiresAt
  • passwordResetToken
  • passwordResetExpires
  • emailVerifiedAt
  • emailVerificationTokenHash
  • emailVerificationExpiresAt
  • emailVerificationRequestedAt
  • phoneVerifiedAt
  • contactGate
  • google

El código limpia google para liberar google.sub, porque el comentario explica que existe un índice único parcial. También elimina el estado de verificación de email y teléfono.

Campos que se escriben en Listing

  • lifecycle.status → purged
  • lifecycle.purgedAt → now
  • media → []
  • sourceDocuments → []
  • meta.coverImage → ''
  • contact.name → ''
  • contact.phone → ''
  • contact.link → ''
  • contact.telegramUsername → ''
  • contact.message → ''
  • location.address → ''
  • location.postalCode → ''
  • location.latitude → null
  • location.longitude → null
  • location.mapSnapshotUrl → ''
  • location.googleMapsUrl → ''
  • requirement.searchZones → []

Los comentarios del archivo aclaran que title, price, description, meta salvo coverImage, y las métricas se conservan. También se conserva la ubicación general como ciudad o estado, pero no la dirección exacta ni coordenadas.

Otras colecciones afectadas

  • Lead — Se actualizan los leads del usuario con name: '[lead purgado]', email: '', phone: '', message: '', notes: '' y deliveryStatus: 'purged'.
  • ListingComment — Se marca authorPurged: true para que la lectura sustituya el autor por “Usuario eliminado”.
  • Conversation — Se eliminan por completo con deleteMany({ ownerId: user._id }).
  • NotificationOutbox — Se eliminan todas las filas del usuario, incluida la fila ACCOUNT_PURGED creada durante la purga.

Behaviors

  • buildPurgedIdentifiers(userId) — Devuelve placeholders deterministas para email y phone, usando derivePurgedIdentifierDigest.
  • buildListingSanitizationSet(now) — Construye el objeto de saneamiento para listings y fija lifecycle.status en purged.
  • purgeListingsForUser(userId, now) — Busca listings anonymized, borra media de ImageKit, limpia campos públicos y actualiza referencias de relatedListingId.
  • purgeSingleUser(user, now) — Ejecuta la purga completa de una cuenta en orden estricto.
  • runPurgeExpiredAccounts(options) — Procesa usuarios elegibles por lotes, soporta dryRun y devuelve un resumen JSON.
  • Bloque CLI — Detecta ejecución directa, carga .env, resuelve MONGODB_URI, conecta a MongoDB, ejecuta el runner y desconecta al final.

La función purgeListingsForUser tiene una regla importante: si falla la eliminación de media de un listing, el listing no se marca como purged. El código acumula los IDs fallidos, lanza un error agregado y evita que purgeSingleUser continúe.

purgeSingleUser ejecuta luego estas acciones, en este orden:

  • purga listings;
  • purga leads;
  • marca comentarios como purgados;
  • elimina conversaciones;
  • emite ACCOUNT_PURGED;
  • borra outbox del usuario;
  • actualiza el propio User.

runPurgeExpiredAccounts maneja el paginado de forma distinta según el modo:

  • en dryRun, usa skip;
  • en apply, excluye los IDs fallidos con $nin;
  • en ambos casos, incrementa matched;
  • en apply, incrementa purged solo después de purgeSingleUser.

Workflows

1. Detectar ejecución directa | El script compara `process.argv[1]` con su propia URL de archivo.
2. Cargar configuración | Primero carga `../../.env` y después `dotenv.config()` con el entorno actual.
3. Resolver la conexión | Usa `MONGODB_URI` o el fallback `mongodb://127.0.0.1:27017/plano_backoffice`.
4. Elegir modo | `--apply` activa escrituras; `--dry-run` o la ausencia de `--apply` mantienen el modo de solo lectura.
5. Ejecutar la purga | `runPurgeExpiredAccounts` procesa usuarios por lotes y devuelve un resumen JSON.
6. Cerrar la conexión | El proceso desconecta MongoDB en un bloque `finally`.

Ejemplos de uso directo:

node services/api/src/scripts/purgeExpiredAccounts.js
node services/api/src/scripts/purgeExpiredAccounts.js --dry-run
node services/api/src/scripts/purgeExpiredAccounts.js --apply --batch-size=100

El bloque CLI imprime el resumen con JSON.stringify(summary, null, 2). Si ocurre un fallo fatal, escribe [purgeExpiredAccounts] fatal: y deja process.exitCode = 1.

Gotchas & edge cases

[!WARNING] dryRun es el comportamiento por defecto. Si no se pasa --apply, el script no escribe nada.

[!CAUTION] Si falla la eliminación de media de un listing, la purga de ese usuario queda incompleta. El usuario permanece en anonymized_pending_purge y se reintenta en una corrida futura.

[!IMPORTANT] NotificationOutbox.deleteMany({ recipientUserId }) elimina también la fila recién creada para ACCOUNT_PURGED. El comentario del código dice que ese historial ya cumplió su propósito operativo.

[!NOTE] El User.updateOne solo se ejecuta si el documento sigue en accountStatus: 'anonymized_pending_purge'. Eso evita tocar cuentas que ya cambiaron de estado.

[!NOTE] El script no reemplaza username con un placeholder. Lo elimina con $unset, porque el comentario indica que su índice parcial lo permite.

Este archivo usa varios módulos del backend ya existentes. Cada relación está explícita en los imports del archivo.

  • services/api/src/models/User.js — Fuente del modelo User.
  • services/api/src/models/Listing.js — Fuente del modelo Listing.
  • services/api/src/models/ListingComment.js — Fuente del modelo ListingComment.
  • services/api/src/models/Lead.js — Fuente del modelo Lead.
  • services/api/src/models/Conversation.js — Fuente del modelo Conversation.
  • services/api/src/models/NotificationOutbox.js — Fuente del modelo NotificationOutbox.
  • services/api/src/utils/encryption.js — Provee derivePurgedIdentifierDigest.
  • services/api/src/modules/media/media.service.js — Provee deleteListingMediaAssets.
  • services/api/src/modules/notifications/notification.events.js — Provee EVENT_TYPES y emitNotificationEvent.

También existe contexto de scripts hermanos. Los tests del repositorio muestran que los jobs de ciclo de vida exportan su runner, aceptan --dry-run, --apply y --batch-size, y solo conectan a la base cuando se ejecutan directamente.

Además, el repositorio contiene un script histórico para NotificationOutbox. Ese script aclara que purgeExpiredAccounts.js ya limpia outbox para purgas nuevas, mientras que el script histórico cubre cuentas ya purgadas en corridas anteriores.