services/api/src/modules/auth/account-export.service.js

Overview

Este módulo construye y valida la exportación temporal de actividad de cuenta. Genera un enlace firmado, verifica su vigencia contra el estado actual del usuario y arma un ZIP en memoria con activity.csv y leads.csv. También se integra con auth.controller.js para servir la descarga y con auth.service.js para generar enlaces de correo. fileciteturn0file5turn0file8turn0file11

El archivo está orientado a la ventana de cierre de cuenta. Solo funciona mientras la cuenta sigue siendo exportable y antes de la purga definitiva. El ZIP se reconstruye en cada petición y no se persiste en disco ni en almacenamiento externo. fileciteturn0file5turn0file17

[!IMPORTANT] La disponibilidad del export no depende solo del JWT. Cada descarga vuelve a revisar accountStatus, deletionCompletedAt y purgeScheduledAt en tiempo real. fileciteturn0file5turn0file9turn0file17

How it works

flowchart TD
    Client[Client]
    AuthService[auth service]
    Controller[auth controller]
    VerifyReq[verifyAccountExportRequest]
    VerifyToken[verifyAccessToken]
    UserModel[User model]
    CheckAvail[isAccountExportAvailable]
    ZipBuild[buildAccountExportZip]
    ActivityCsv[buildAccountExportCsv]
    LeadsCsv[buildLeadsExportCsv]
    ListingModel[Listing model]
    LeadModel[Lead model]
    ZipFileNode[ZipFile]
    StreamBuffer[streamToBuffer]
    Download[ZIP response]
    UrlBuild[buildAccountExportUrl]
    TokenBuild[buildAccountExportToken]
    SignToken[signScopedToken]

    AuthService --> UrlBuild
    UrlBuild --> CheckAvail
    UrlBuild --> TokenBuild
    TokenBuild --> SignToken

    Client --> Controller
    Controller --> VerifyReq
    VerifyReq --> VerifyToken
    VerifyReq --> UserModel
    VerifyReq --> CheckAvail
    VerifyReq --> ZipBuild
    ZipBuild --> ActivityCsv
    ZipBuild --> LeadsCsv
    ActivityCsv --> ListingModel
    ActivityCsv --> LeadModel
    LeadsCsv --> LeadModel
    ZipBuild --> ZipFileNode
    ZipFileNode --> StreamBuffer
    StreamBuffer --> Download

La ruta de descarga primero valida el token con verifyAccessToken. Después vuelve a leer al usuario y comprueba que siga siendo elegible. Si todo pasa, el módulo genera ambos CSV, los empaqueta con yazl, y convierte el outputStream en Buffer con node:stream/consumers. fileciteturn0file5turn0file9turn0file18

Key components

{
    "title": "isAccountExportAvailable",
    "content": "Valida si una cuenta puede descargar el export. Revisa `accountStatus`, `deletionCompletedAt` y `purgeScheduledAt` contra la fecha actual.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildAccountExportToken",
    "content": "Crea un JWT de un solo prop\u00f3sito con `sub` y `purpose=account-export`. Su expiraci\u00f3n se calcula hasta `purgeScheduledAt`.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildAccountExportUrl",
    "content": "Devuelve la URL directa de descarga para el correo. Si la cuenta no califica, devuelve `null`.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "verifyAccountExportRequest",
    "content": "Comprueba la firma y el prop\u00f3sito del token, luego revalida el estado en vivo del usuario. Devuelve el `user._id` o lanza `ApiError`.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildAccountExportCsv",
    "content": "Construye `activity.csv` con consultas de solo lectura sobre `Listing` y un agregado de `Lead`. Usa columnas allowlist y produce un CSV en memoria.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildLeadsExportCsv",
    "content": "Construye `leads.csv` con los `Lead` del propietario autenticado. Devuelve cabeceras aunque no existan filas.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildAccountExportZip",
    "content": "Empaqueta `activity.csv` y `leads.csv` en un ZIP ef\u00edmero. Usa `ZipFile.addBuffer` y `streamToBuffer`, sin tocar disco.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/account-export.service.js",
    "badges": [
        "public"
    ]
}

Data & fields

Los datos del módulo son pequeños, pero están muy definidos. El archivo usa constantes para fijar el propósito del token, las cuentas exportables, los nombres de archivos del ZIP y las columnas de ambos CSV. fileciteturn0file5turn0file8

  • EXPORT_TOKEN_PURPOSE [badge:read-only] string — valor fijo account-export. Se usa para firmar y validar el token. fileciteturn0file5turn0file9
  • EXPORTABLE_ACCOUNT_STATUSES [badge:read-only] string[] — estados permitidos: pending_deletion y anonymized_pending_purge.
  • CSV_COLUMNS [badge:read-only] string[] — cabeceras de activity.csv. fileciteturn0file5turn0file18
  • LEADS_CSV_COLUMNS [badge:read-only] string[] — cabeceras de leads.csv. fileciteturn0file5turn0file18
  • ZIP_ACTIVITY_ENTRY [badge:read-only] string — nombre de la entrada ZIP para actividad.
  • ZIP_LEADS_ENTRY [badge:read-only] string — nombre de la entrada ZIP para leads. fileciteturn0file5turn0file8
  • ZIP_ENTRY_MTIME [badge:read-only] Date — marca de tiempo fija para hacer estable el ZIP entre descargas. fileciteturn0file5turn0file8
  • FORMULA_INJECTION_PREFIXES [badge:read-only] string[] — prefijos neutralizados: =, +, - y @.
  • ACCOUNT_EXPORT_CSV_COLUMNS [badge:read-only] string[] — alias exportado de CSV_COLUMNS. fileciteturn0file5turn0file18
  • LEADS_EXPORT_CSV_COLUMNS [badge:read-only] string[] — alias exportado de LEADS_CSV_COLUMNS. fileciteturn0file5turn0file18
  • ACCOUNT_EXPORT_ZIP_ENTRIES [badge:read-only] { activity, leads } — mapa inmutable de entradas del ZIP.
  • listing_id — identificador del listing.
  • title — título del listing.
  • created_at — fecha de creación en ISO.
  • publication_status — estado de publicación.
  • lifecycle_status — estado de ciclo de vida.
  • likes — conteo agregado de likes.
  • comments — conteo agregado de comentarios.
  • detail_opens — conteo agregado de aperturas de detalle.
  • contact_clicks — conteo agregado de clics de contacto.
  • leads — conteo agregado de leads por listing. fileciteturn0file5turn0file18
  • lead_id — identificador del lead.
  • name — nombre del lead.
  • email — correo del lead.
  • phone — teléfono del lead.
  • status — estado del lead.
  • source — origen del lead.
  • listing_id — listing asociado.
  • listing_title — título del listing asociado.
  • message — mensaje del lead.
  • notes — notas internas visibles en el snapshot.
  • created_at — fecha de creación en ISO.
  • updated_at — fecha de actualización en ISO. fileciteturn0file5turn0file18
  • user.accountStatus — debe estar en uno de los estados exportables.
  • user.deletionCompletedAt — debe ser null o no existir.
  • user.purgeScheduledAt — debe existir y ser una fecha futura.
  • user._id — se usa como sub del token y como clave de consulta.
  • token.purpose — debe ser exactamente account-export. fileciteturn0file5turn0file9turn0file17

Behaviors

El módulo está dividido en cinco comportamientos visibles y tres helpers internos. Cada uno trabaja sobre datos de solo lectura o sobre contenido efímero en memoria. fileciteturn0file5turn0file9

  • isAccountExportAvailable(user, now) — devuelve true solo si la cuenta existe, está en un estado exportable, no tiene deletionCompletedAt y su purgeScheduledAt sigue en el futuro.
  • buildAccountExportToken(user, now) — firma un token scoped con sub y purpose=account-export. La expiración queda limitada por purgeScheduledAt.
  • buildAccountExportUrl(user, now) — devuelve una URL completa contra env.apiUrl con token en query string, o null si la cuenta no califica.
  • verifyAccountExportRequest(token, now) — verifica la firma del token, exige el propósito correcto, consulta al usuario en User, y devuelve user._id. Si falla, lanza ApiError con 401 o 410. fileciteturn0file9turn0file17
  • buildAccountExportCsv(userId) — consulta Listing del usuario, agrega conteos de Lead por listing, y genera activity.csv con BOM UTF-8 y saltos \r\n. fileciteturn0file5turn0file18
  • buildLeadsExportCsv(userId) — consulta Lead por ownerId, ordena por createdAt, y genera leads.csv con BOM UTF-8. Si no hay leads, devuelve solo cabeceras. fileciteturn0file5turn0file18
  • buildAccountExportZip(userId) — construye ambos CSV, los añade al ZIP como buffers y consume el outputStream hacia memoria.
  • csvEscape(value) — aplica el quoting CSV estándar cuando el valor contiene comillas, saltos de línea, retornos de carro o comas.
  • neutralizeFormulaInjection(value) — antepone un apóstrofo si el texto empieza con =, +, - o @.
  • csvField(value) — combina la neutralización de fórmula y el escape CSV. Es la única ruta de escape usada por ambos exports. fileciteturn0file5turn0file9

[!WARNING] verifyAccountExportRequest no distingue entre token inválido y token ajeno. En ambos casos devuelve el mismo 401 con ACCOUNT_EXPORT_TOKEN_INVALID. fileciteturn0file9turn0file17

[!CAUTION] Un token válido puede dejar de servir antes de expirar. Si la cuenta cambia de estado, si deletionCompletedAt se marca, o si pasa purgeScheduledAt, la descarga responde 410. fileciteturn0file5turn0file9turn0file17

API

La ruta está expuesta desde auth.controller.js, que importa verifyAccountExportRequest y buildAccountExportZip desde este módulo. La documentación de backend confirma que el endpoint devuelve un ZIP y usa el token de propósito único como prueba de identidad. fileciteturn0file8turn0file17

GET /api/auth/account-export

{
    "title": "Download account export",
    "description": "Genera y devuelve el ZIP efimero con activity.csv y leads.csv para una cuenta elegible.",
    "method": "GET",
    "baseUrl": "env.apiUrl",
    "endpoint": "/api/auth/account-export",
    "headers": [
        {
            "key": "Accept",
            "value": "application/zip",
            "required": false
        }
    ],
    "queryParams": [
        {
            "key": "token",
            "value": "JWT con purpose account-export",
            "required": true
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "ZIP con activity.csv y leads.csv",
            "body": "<binary ZIP with activity.csv and leads.csv>"
        },
        "401": {
            "description": "Token ausente, invalido, expirado o con purpose incorrecto",
            "body": "{\n  \"error\": \"El enlace de exportacion no es valido o expiro\",\n  \"code\": \"ACCOUNT_EXPORT_TOKEN_INVALID\"\n}"
        },
        "410": {
            "description": "La cuenta ya no califica para exportacion",
            "body": "{\n  \"error\": \"El resumen de actividad ya no esta disponible\",\n  \"code\": \"ACCOUNT_EXPORT_UNAVAILABLE\"\n}"
        }
    }
}

La respuesta exitosa devuelve 200, Content-Type: application/zip y Content-Disposition: attachment; filename="somos-plano-export.zip". El ZIP contiene activity.csv y leads.csv, cada uno con su propio BOM UTF-8. fileciteturn0file17turn0file18

Workflows

Este flujo resume la secuencia real del módulo. No introduce pasos externos ni almacenamiento intermedio. fileciteturn0file5turn0file8

1. Validar elegibilidad | `isAccountExportAvailable` comprueba el estado actual del usuario.
2. Firmar token | `buildAccountExportToken` crea un JWT con `purpose=account-export`.
3. Construir CSVs | `buildAccountExportCsv` y `buildLeadsExportCsv` leen datos con consultas de solo lectura.
4. Empaquetar ZIP | `buildAccountExportZip` añade ambos buffers con `ZipFile.addBuffer` y consume el stream a memoria.
5. Responder descarga | `auth.controller.js` devuelve el ZIP al cliente con el token verificado.

Gotchas & edge cases

[!WARNING] activity.csv no incluye datos sensibles de contacto de listings ni detalles completos de leads. Solo expone métricas agregadas y conteos. fileciteturn0file5turn0file17

[!WARNING] leads.csv sí incluye PII real del propietario del lead, porque ese archivo representa el CRM privado previo a la purga.

[!CAUTION] El módulo neutraliza CSV injection. Si un valor empieza con =, +, - o @, se prefija un apóstrofo antes del escape CSV. Esto incluye teléfonos internacionales legítimos.

[!NOTE] El ZIP usa ZIP_ENTRY_MTIME fijo para mantener bytes estables entre descargas con el mismo contenido. Eso evita que la hora real de descarga cambie el archivo generado. fileciteturn0file5turn0file8

[!IMPORTANT] buildAccountExportUrl devuelve null si la cuenta no califica. Así evita emitir enlaces ya muertos para correos o notificaciones.

Related

Este módulo se conecta con services/api/src/modules/auth/auth.controller.js, donde el endpoint GET /api/auth/account-export usa verifyAccountExportRequest y buildAccountExportZip. También se conecta con services/api/src/modules/auth/auth.service.js, que importa buildAccountExportUrl para incluir el enlace de descarga en correos relacionados con el ciclo de cuenta. fileciteturn0file8turn0file11

El comportamiento general también aparece documentado en la referencia de backend y en los flujos críticos del proyecto. Esas referencias confirman la política de no persistencia, el formato ZIP y el uso exclusivo antes de la purga definitiva. fileciteturn0file17turn0file0