Saltar al contenido principal

Exportación y Portabilidad de Datos — Referencia Técnica

← Volver a Exportación y Portabilidad de Datos

Dónde vive esto

Backend

Frontend

Checklist de implementación técnica

  • requestDataExport — resolver conectado en user-data-export.resolver.js, llama a data-export.manager.js#requestDataExport; persiste una fila pending en la tabla data_export (ahora con columnas reales export_type/date_range_start/date_range_end/include_profile/include_messages/include_media/download_token, no solo un blob opaco) y activa processDataExport en segundo plano mediante setImmediate. Conectado al botón "Request download" en Settings → Download data.
  • dataExportStatus — resolver conectado en user-data-export.resolver.js. Corregido en esta sesión: antes ignoraba por completo el argumento exportId y siempre devolvía la exportación más reciente del propio usuario, y el objeto devuelto no coincidía con el tipo de esquema DataExportStatus (le faltaban id/exportType, así que consultar esos campos no nulos daba error). Ahora pasa exportId a getDataExportStatus, busca esa exportación específica (verificando que pertenezca al usuario que llama) y la mapea al tipo de esquema completo mediante _toStatusShape; devuelve null (un resultado válido para este campo anulable) cuando la exportación no existe o no es del usuario. El frontend sigue usando myDataExports al montar (no hay exportId que pasar hasta que existe una solicitud), pero dataExportStatus(exportId) ahora funciona correctamente para búsquedas directas.
  • cancelDataExport — resolver conectado en user-data-export.resolver.js; solo funciona mientras el estado es pending/processing, publica dataExportStatusChanged. Conectado al botón "Cancel export".
  • myDataExports — resolver conectado en user-data-export.resolver.js, llama a data-export.manager.js#getUserDataExports. Corregido en esta sesión: antes envolvía la exportación más reciente (getDataExportStatus) en un array de 0 o 1 elemento pese a su nombre; ahora consulta data_export para obtener el historial real del usuario (findByUserId, más reciente primero, con un límite de 50) y mapea cada fila mediante el mismo _toStatusShape que usa dataExportStatus. DownloadDataPage.tsx sigue renderizando solo el primer elemento (data?.myDataExports?.[0]), sin embargo — la UI aún no se actualizó para mostrar un historial, así que esta corrección todavía no es visible para los usuarios más allá de un polling de exportación única más correcto.
  • deleteDataExport — resolver conectado en user-data-export.resolver.js, llama a data-export.manager.js#deleteDataExport. Corregido en esta sesión: antes no tenía lógica de eliminación independiente y simplemente llamaba a cancelDataExport internamente, por lo que solo funcionaba mientras la exportación seguía en pending/processing (y solo cambiaba la fila a cancelled). Ahora es una operación real y distinta: funciona en exportaciones terminales (ready/failed/cancelled) — rechazando las activas con un error de "cancela primero" — elimina definitivamente la fila de data_export, y limpia de forma best-effort el archivo almacenado localmente cuando downloadUrl apunta a /uploads/exports/... (las exportaciones respaldadas por S3 dependen de la propia política de ciclo de vida del bucket). Esto coincide con cómo se usa realmente el botón "Delete record" de la UI, en exportaciones ready/failed.
  • downloadDataExport — resolver conectado en user-data-export.resolver.js, llama a data-export.manager.js#downloadDataExport; verifica que la exportación esté ready y no haya expirado. Corregido en esta sesión: DataExportDownloadResponse.downloadToken es no nulo en el esquema, pero antes nada generaba uno. requestDataExport ahora genera y persiste un downloadToken (crypto.randomBytes(32)) al crear la fila, y downloadDataExport genera y persiste uno de forma diferida para cualquier fila anterior a la corrección, de modo que el campo siempre se resuelve.
  • processDataExport — nueva mutation que expone el trabajo en segundo plano (data-export.manager.js#processDataExport) directamente; también es invocada internamente por requestDataExport. Recopila publicaciones/comentarios/mensajes/conexiones de seguimiento, escribe el archivo, y cambia el estado de processingready (o failed). Corregido en esta sesión: ahora respeta includeProfile/includeMessages/includeMedia y el filtro dateRangeStart/dateRangeEnd (ver Solicitar una exportación más abajo) en lugar de recopilar todo incondicionalmente.
  • dataExportStatusChanged — nueva suscripción (DATA_EXPORT_STATUS_${userId} mediante services/pubsub.service.js), publicada en cada transición de estado (processing/ready/failed/cancelled). DownloadDataPage.tsx se suscribe a ella y vuelve a consultar en lugar de hacer polling.
  • getDataPortabilityInfo (canExport, exportFormats, retentionPeriod, lastExport) — resolver conectado en user-data-export.resolver.js, pero userManager.getDataPortabilityInfo simplemente devuelve dataExportManager.getDataExportStatus(...), que no tiene ninguna de esas claves. Dado que todos excepto lastExport son no nulos en el tipo DataPortabilityInfo, consultar este campo fallará en lugar de devolver datos reales de la política de retención/formato.

Solicitar una exportación

requestDataExport persiste una fila pending en la tabla data_export y activa data-export.manager.js#processDataExport en segundo plano (setImmediate), de modo que la mutation retorna de inmediato. Corregido en esta sesión: el objeto de respuesta del manager solía usar la clave estimatedTime (una cadena fija, "a few minutes") en lugar de estimatedCompletion como se llama el campo del esquema, por lo que estimatedCompletion siempre se resolvía como null. Ahora construye y devuelve un estimatedCompletion real de tipo DateTime (~5 minutos desde la solicitud), junto con exportId.

De los interruptores de DataExportRequestInput a continuación, todos excepto exportType ahora realmente controlan qué se recopila en collectUserData. Corregido en esta sesión: includeMessages e includeMedia antes se aceptaban pero se ignoraban — los mensajes y los archivos multimedia estaban codificados como vacíos sin importar los flags; ahora son columnas reales de data_export (include_messages/include_media) y se respetan (los mensajes se recopilan mediante messageAccessService.getBySender, y el contenido multimedia se elimina de publicaciones/mensajes cuando includeMedia es false). includeProfile también es ahora una columna real y controla si se incluye el blob de perfil sanitizado. dateRangeStart/dateRangeEnd son ahora columnas reales (date_range_start/date_range_end) y se aplican como filtro de createdAt sobre publicaciones, comentarios y mensajes. exportType ahora se persiste (columna export_type) pero es solo metadata — no cambia qué se recopila ni el formato de salida. El formato de salida sigue siendo siempre json: la columna format del manager tiene por defecto json, DataExportRequestInput no tiene campo format, y nada en el flujo de solicitud actual establece un valor distinto al predeterminado, así que la conversión a CSV/XML existe en el manager pero sigue siendo inalcanzable vía GraphQL.

mutation RequestDataExport($input: DataExportRequestInput!) {
requestDataExport(input: $input) {
success
message
exportId
estimatedCompletion
}
}

Campos de DataExportRequestInput

CampoTipoDescripción
exportTypeStringSe persiste (columna export_type) como metadata; no cambia qué se recopila — el formato de salida siempre es json
includePostsBooleanIncluir las publicaciones del usuario (se aplica; se guarda en el blob JSONB options)
includeCommentsBooleanIncluir comentarios (se aplica; se guarda en el blob JSONB options)
includeMessagesBooleanIncluir mensajes (columna include_messages). Corregido en esta sesión — antes se aceptaba pero se ignoraba
includeMediaBooleanIncluir contenido multimedia de publicaciones/mensajes (columna include_media). Corregido en esta sesión — antes se aceptaba pero se ignoraba
includeProfileBooleanIncluir el blob de perfil sanitizado (columna include_profile). Corregido en esta sesión — antes se aceptaba pero no se usaba
dateRangeStartDateTimeFiltra publicaciones/comentarios/mensajes por createdAt (columna date_range_start). Corregido en esta sesión — antes se aceptaba pero no se usaba
dateRangeEndDateTimeFiltra publicaciones/comentarios/mensajes por createdAt (columna date_range_end). Corregido en esta sesión — antes se aceptaba pero no se usaba

Seguimiento del progreso de la exportación

myDataExports es lo que DownloadDataPage.tsx realmente usa para cargar y monitorear la exportación actual: no recibe argumentos, por lo que es usable antes de que el cliente tenga un exportId que pasarle a dataExportStatus. Corregido en esta sesión: antes envolvía la exportación más reciente en un array de 0 o 1 elemento pese a su nombre; ahora devuelve un historial real (más reciente primero, con un límite de 50). La página en sí todavía no se actualizó para renderizar ese historial — sigue leyendo data?.myDataExports?.[0], es decir, solo la exportación más reciente.

dataExportStatus(exportId) también existe, para buscar una exportación específica por id. Corregido en esta sesión: antes ignoraba por completo el argumento exportId (siempre devolvía la exportación más reciente del usuario que llama) y a su objeto subyacente le faltaban las claves id/exportType del esquema, por lo que seleccionar esos campos no nulos daba error. Ahora respeta exportId, verifica la propiedad, devuelve el tipo DataExportStatus completo, y devuelve null si la exportación no existe o pertenece a otro usuario. El frontend sigue usando myDataExports por defecto para la carga inicial (no hay exportId en ese punto), pero dataExportStatus ahora es seguro de usar para búsquedas directas.

subscription DataExportStatusChanged($userId: ID!) {
dataExportStatusChanged(userId: $userId) {
id status
}
}

query MyExports {
myDataExports {
id status exportType requestedAt completedAt expiresAt
downloadUrl fileSize progressPercentage errorMessage
}
}

El campo status recorre los siguientes estados: pendingprocessingready (o failed), y también puede pasar a cancelled. Una vez que status es ready, downloadUrl se completa (una URL pre-firmada de S3 si services/s3.service.js está configurado, o de lo contrario una ruta local /uploads/exports/...) y sigue siendo válida hasta expiresAt (30 días después de la solicitud).

Gestión de exportaciones

cancelDataExport aborta una exportación en progreso antes de que se complete. Solo funciona cuando status es pending o processing; publica dataExportStatusChanged. downloadDataExport valida que la exportación esté ready y no haya expirado, y devuelve la URL de descarga/información del archivo, incluyendo DataExportDownloadResponse.downloadToken (corregido en esta sesiónrequestDataExport ahora genera y persiste un downloadToken desde el principio, y downloadDataExport lo rellena de forma diferida para cualquier fila preexistente que aún no lo tenga, así que el campo no nulo siempre se resuelve). deleteDataExport ahora es (corregido en esta sesión) una operación real e independiente, distinta de cancelDataExport: elimina definitivamente la fila de data_export y limpia de forma best-effort el archivo almacenado localmente, y funciona en exportaciones terminales (ready/failed/cancelled) en lugar de solo pending/processing — rechaza las exportaciones activas indicando que hay que cancelarlas primero. Esto coincide con la UI de "Delete record", que se muestra para exportaciones ready/failed.

# Abort a pending or in-progress export
mutation CancelDataExport($exportId: ID!) {
cancelDataExport(exportId: $exportId) { success message }
}

# Prepare a ready export for download
mutation DownloadDataExport($exportId: ID!) {
downloadDataExport(exportId: $exportId) { success downloadUrl fileSize expiresAt fileName }
}

# Remove the export record
mutation DeleteDataExport($exportId: ID!) {
deleteDataExport(exportId: $exportId) { success message }
}

Información de portabilidad de datos

getDataPortabilityInfo está declarado para devolver las políticas de retención de datos y formato de la plataforma (canExport, exportFormats, retentionPeriod, lastExport — el tipo DataPortabilityInfo vive en graphql/types/user-presence.type.js), pero su resolver (userManager.getDataPortabilityInfo) simplemente reenvía al mismo getDataExportStatus de exportación única usado por dataExportStatus/myDataExports, que no tiene ninguna de esas claves. Dado que canExport, exportFormats y retentionPeriod son no nulos en el esquema, consultar este campo actualmente falla en lugar de devolver datos reales de la política — esto necesita una implementación dedicada.

query DataPortabilityInfo {
getDataPortabilityInfo {
canExport
exportFormats
retentionPeriod # days data is kept after account deletion
lastExport
}
}

Modelo de datos

La tabla data_export (migración 20260719080000-create-data-export.js, más 20260731200000-add-columns-to-data-export.jsagregada en esta sesión) contiene una fila por solicitud de exportación:

ColumnaTipoNotas
idUUID (PK)
user_idUUID
statusSTRING(20)pending (por defecto) → processingready / failed / cancelled
formatSTRING(10)por defecto es json; existe conversión a csv/xml en el manager, pero nada en el flujo de solicitud actual establece un formato distinto al predeterminado
export_typeSTRING(20)agregada en esta sesión; el valor de DataExportRequestInput.exportType (por defecto full) — solo metadata, no afecta qué se recopila
optionsJSONBel objeto includePosts/includeComments/includeAnalytics capturado en el momento de la solicitud (los interruptores que no tienen columna dedicada)
date_range_start, date_range_endDATEagregadas en esta sesión; DataExportRequestInput.dateRangeStart/dateRangeEnd, ahora realmente aplicadas como filtro de createdAt sobre publicaciones/comentarios/mensajes
include_profile, include_messages, include_mediaBOOLEANagregadas en esta sesión, todas con valor por defecto true; ahora realmente controlan qué incluye collectUserData
download_tokenSTRING(64)agregada en esta sesión; generada con crypto.randomBytes(32) al momento de la solicitud (o de forma diferida por downloadDataExport para filas anteriores) para que DataExportDownloadResponse.downloadToken siempre se resuelva
progress_percentageINTEGERagregada en esta sesión; se persiste en cada transición de estado (010 al pasar a processing100 al pasar a ready), además del progress estimado en vivo que se calcula al vuelo para una exportación en curso
requested_at, started_at, completed_at, expires_atDATEexpires_at se establece a 30 días después de la solicitud
download_url, file_path, file_sizeTEXT / BIGINTse completa una vez que status es ready
error_messageTEXTse completa en failed

Indexado en (user_id, created_at).