services/api/src/scripts/migrateLegacyEmailVerification.js

Overview

Este archivo implementa una migración explícita para cuentas legacy de la Fase 7B. Su objetivo es retro-poblar emailVerifiedAt con createdAt solo para usuarios antiguos que nunca pasaron por el flujo real de verificación. El script se ejecuta desde consola y está expuesto como npm run migrate:legacy-email-verification.

[!IMPORTANT] La migración no usa solo emailVerifiedAt: null. También exige createdAt < cutoffDate y accountStatus válido. Esa combinación evita tocar cuentas nuevas o cuentas purgadas.

How it works

flowchart TB
    Start([Inicio])
    LoadEnv[Carga dotenv]
    ParseArgs[Lee flags de consola]
    Connect[Conecta a MongoDB]
    RunFunc[runMigrateLegacyEmailVerification]
    BuildQuery[Construye filtro de usuarios]
    DryRun[Modo dry run]
    Apply[Modo apply]
    Batch[Procesa lotes]
    Update[User updateOne]
    ErrorLog[Log seguro de error]
    Summary[Imprime summary JSON]
    Disconnect[Desconecta MongoDB]

    Start --> LoadEnv --> ParseArgs --> Connect --> RunFunc --> BuildQuery
    BuildQuery --> DryRun --> Summary
    BuildQuery --> Apply --> Batch --> Update --> Batch
    Update --> ErrorLog --> Batch
    DryRun --> Summary
    Apply --> Summary
    Summary --> Disconnect

El flujo comienza solo cuando el módulo se ejecuta directamente. Primero carga variables de entorno, luego resuelve MONGODB_URI, --apply, --dry-run, --batch-size y --cutoff. Después abre la conexión con mongoose, ejecuta la función de migración y finalmente imprime un resumen JSON y cierra la conexión.

La función principal usa un filtro determinista sobre User. En dry-run, solo pagina resultados y cuenta coincidencias. En apply, actualiza cada usuario válido con emailVerifiedAt = createdAt. Si una escritura falla, el script guarda el _id afectado y registra solo un código seguro y el userId.

Key components

{
    "title": "runMigrateLegacyEmailVerification",
    "content": "Funci\u00f3n p\u00fablica que localiza usuarios legacy elegibles y, en modo apply, fija emailVerifiedAt con createdAt. Devuelve un resumen con contadores de coincidencias, migraciones, errores e IDs fallidos.",
    "type": "function",
    "filePath": "services/api/src/scripts/migrateLegacyEmailVerification.js",
    "badges": [
        "public"
    ]
}
  • LEGACY_CUTOFF_ISO — constante con el cutoff por defecto: 2026-08-12T00:00:00.000Z. Sirve como fecha base cuando no se pasa --cutoff.
  • ELIGIBLE_ACCOUNT_STATUSES — lista interna con los estados permitidos: active y pending_deletion. El script excluye cualquier otro estado.
  • isMainModule — guard que evita ejecutar la migración al importar el archivo desde tests u otros módulos.

Data & fields

[
    {
        "name": "options",
        "type": "object",
        "description": "Opciones de ejecución para la migración.",
        "children": [
            {
                "name": "dryRun",
                "type": "boolean",
                "description": "Si es true, el script solo cuenta coincidencias y no modifica documentos. Valor por defecto: true."
            },
            {
                "name": "batchSize",
                "type": "number",
                "description": "Tamaño del lote para paginar usuarios. Valor por defecto: 50."
            },
            {
                "name": "cutoffDate",
                "type": "Date",
                "description": "Fecha límite usada para excluir cuentas nuevas. Por defecto usa LEGACY_CUTOFF_ISO."
            }
        ]
    }
]
[
    {
        "name": "summary",
        "type": "object",
        "required": true,
        "description": "Resultado final que imprime el script en JSON.",
        "children": [
            {
                "name": "mode",
                "type": "string",
                "required": true,
                "description": "Modo de ejecución: dry-run o apply."
            },
            {
                "name": "batchSize",
                "type": "number",
                "required": true,
                "description": "Tamaño de lote usado durante la ejecución."
            },
            {
                "name": "cutoffDate",
                "type": "string",
                "required": true,
                "description": "Cutoff final serializado en ISO-8601."
            },
            {
                "name": "matched",
                "type": "number",
                "required": true,
                "description": "Cantidad de documentos que cumplieron el filtro."
            },
            {
                "name": "migrated",
                "type": "number",
                "required": true,
                "description": "Cantidad de documentos actualizados con éxito."
            },
            {
                "name": "errors",
                "type": "number",
                "required": true,
                "description": "Cantidad de fallos de actualización."
            },
            {
                "name": "errorUserIds",
                "type": "string[]",
                "required": true,
                "description": "IDs de usuarios que fallaron durante apply."
            }
        ]
    }
]
[
    {
        "name": "emailVerifiedAt",
        "type": "null",
        "required": true,
        "description": "El campo debe estar en null. En MongoDB, esto también puede coincidir con campo ausente."
    },
    {
        "name": "createdAt",
        "type": "object",
        "required": true,
        "description": "Debe ser anterior al cutoff.",
        "children": [
            {
                "name": "$lt",
                "type": "Date",
                "required": true,
                "description": "Limita la búsqueda a usuarios creados antes de cutoffDate."
            }
        ]
    },
    {
        "name": "accountStatus",
        "type": "object",
        "required": true,
        "description": "Restringe la migración a estados elegibles.",
        "children": [
            {
                "name": "$in",
                "type": "string[]",
                "required": true,
                "description": "Valores permitidos: active y pending_deletion."
            }
        ]
    }
]

Behaviors

  • runMigrateLegacyEmailVerification — consulta usuarios candidatos, pagina resultados por lotes y retorna un resumen con contadores y errores. En apply, escribe emailVerifiedAt = createdAt por documento.
  • dotenv.config — carga primero ../../.env relativo al script y luego variables del entorno actual.
  • mongoose.connect — abre la conexión antes de ejecutar la migración.
  • mongoose.disconnect — cierra la conexión en un finally, incluso si la migración falla.
  • console.error — registra fallos de escritura con un código seguro y el userId, sin exponer email, nombre ni teléfono.

Workflows

1. Cargar entorno | El script carga dotenv desde `../../.env` y desde el entorno actual.
2. Resolver opciones | Lee `--apply`, `--dry-run`, `--batch-size` y `--cutoff`.
3. Validar cutoff | Rechaza el valor si no es una fecha ISO-8601 válida.
4. Conectar MongoDB | Usa `MONGODB_URI` o el URI local por defecto.
5. Ejecutar migración | Llama a `runMigrateLegacyEmailVerification` con las opciones calculadas.
6. Mostrar resultado | Imprime el resumen final como JSON con sangría.
7. Cerrar conexión | Ejecuta `mongoose.disconnect` en un bloque `finally`.

En modo dry-run, el código avanza con skip, porque no cambia el filtro entre iteraciones. En modo apply, cada usuario exitoso deja de cumplir el filtro después de actualizarse, y los fallidos se excluyen con _id: { $nin: failedIds }. Eso evita bucles infinitos.

Gotchas & edge cases

[!WARNING] En MongoDB, { emailVerifiedAt: null } también puede coincidir con documentos donde el campo no existe. Por eso el script añade accountStatus y createdAt al filtro. Sin esa protección, cuentas purgadas o anonimizadas podrían quedar marcadas como verificadas por error.

  • dryRun es el valor por defecto si no se pasa --apply. Eso significa que el script no escribe nada salvo que la ejecución sea explícitamente aplicada.
  • --cutoff debe ser una fecha ISO-8601 válida. Si no lo es, el proceso lanza un error antes de conectar.
  • El script solo incluye cuentas con accountStatus active o pending_deletion. Otros estados quedan excluidos por diseño.
  • La migración escribe emailVerifiedAt con el propio createdAt del usuario. No usa una fecha derivada ni un valor fijo.

Este script se expone en services/api/package.json como migrate:legacy-email-verification, junto a otros scripts de mantenimiento del backend. Su comportamiento está cubierto por services/api/test/migrate-legacy-email-verification.test.js, que valida cuentas active, pending_deletion, exclusiones por estado, cutoff, campo ya verificado, modo dry-run e idempotencia.

El comentario del propio script lo conecta con purgeExpiredAccounts.js, porque ese proceso puede hacer $unset de emailVerifiedAt. Esa relación explica por qué el filtro exige estado de cuenta explícito.