Exportación y Portabilidad de Datos — Referencia Técnica
← Volver a Exportación y Portabilidad de Datos
Dónde vive esto
Backend
apps/backend/graphql/resolvers/user-data-export.resolver.js- resuelverequestDataExport,dataExportStatus,myDataExports,cancelDataExport,deleteDataExport,downloadDataExport,processDataExport,getDataPortabilityInfo, y la suscripcióndataExportStatusChanged. (El antiguouser.resolver.jsmonolítico ya no existe — su contenido se dividió en archivos por funcionalidad como este.)apps/backend/graphql/types/data-export.type.js- tipos de esquema de exportación de datos (DataExportStatus,DataExportResponse,DataExportDownloadResponse,ProcessDataExportResponse,DataExportRequestInput, la suscripcióndataExportStatusChanged). El tipoDataPortabilityInfoen sí está declarado por separado enapps/backend/graphql/types/user-presence.type.js.apps/backend/managers/user-managers/data-export.manager.js- construye y rastrea el trabajo de exportación asíncrono, recopila los datos del usuario, escribe el archivo de exportación (S3, con respaldo local en/uploads), y envía un correo al usuario cuando está listoapps/backend/data-access-services/user/data-export.access-service.js- envoltorio de Sequelize alrededor de la tabladata_export(create,findById,findLatestByUserId,findByUserId,update,delete)apps/backend/database/models/DataExport.js- el modelo SequelizeDataExportque respalda la tabla descrita abajoapps/backend/database/migrations/20260719080000-create-data-export.js- crea la tabladata_export(una fila por solicitud de exportación; ver Modelo de datos más abajo);apps/backend/database/migrations/20260731200000-add-columns-to-data-export.js- agregaexport_type,date_range_start/date_range_end,include_profile/include_messages/include_media,download_tokenyprogress_percentagecomo columnas reales, reemplazando lo que antes era un blob JSONB opaco (options) para la mayoría de estos
Frontend
apps/frontend-nextjs/src/page-components/settings/DownloadDataPage.tsx, enrutado enapps/frontend-nextjs/src/app/settings/download-data/page.tsx(Settings → Download data). Solicita una exportación, muestra el estado pendiente/en proceso/lista/fallida, descarga, cancela y elimina el registro, y muestra el pie de página de portabilidad de datos. No tiene hooks de Apollo generados (no existen archivos de operación.graphqlcorrespondientes), por lo que las queries/mutations/subscription se declaran en línea dentro del componente.
Checklist de implementación técnica
-
requestDataExport— resolver conectado enuser-data-export.resolver.js, llama adata-export.manager.js#requestDataExport; persiste una filapendingen la tabladata_export(ahora con columnas realesexport_type/date_range_start/date_range_end/include_profile/include_messages/include_media/download_token, no solo un blob opaco) y activaprocessDataExporten segundo plano mediantesetImmediate. Conectado al botón "Request download" enSettings → Download data. -
dataExportStatus— resolver conectado enuser-data-export.resolver.js. Corregido en esta sesión: antes ignoraba por completo el argumentoexportIdy siempre devolvía la exportación más reciente del propio usuario, y el objeto devuelto no coincidía con el tipo de esquemaDataExportStatus(le faltabanid/exportType, así que consultar esos campos no nulos daba error). Ahora pasaexportIdagetDataExportStatus, busca esa exportación específica (verificando que pertenezca al usuario que llama) y la mapea al tipo de esquema completo mediante_toStatusShape; devuelvenull(un resultado válido para este campo anulable) cuando la exportación no existe o no es del usuario. El frontend sigue usandomyDataExportsal montar (no hayexportIdque pasar hasta que existe una solicitud), perodataExportStatus(exportId)ahora funciona correctamente para búsquedas directas. -
cancelDataExport— resolver conectado enuser-data-export.resolver.js; solo funciona mientras el estado espending/processing, publicadataExportStatusChanged. Conectado al botón "Cancel export". -
myDataExports— resolver conectado enuser-data-export.resolver.js, llama adata-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 consultadata_exportpara obtener el historial real del usuario (findByUserId, más reciente primero, con un límite de 50) y mapea cada fila mediante el mismo_toStatusShapeque usadataExportStatus.DownloadDataPage.tsxsigue 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 enuser-data-export.resolver.js, llama adata-export.manager.js#deleteDataExport. Corregido en esta sesión: antes no tenía lógica de eliminación independiente y simplemente llamaba acancelDataExportinternamente, por lo que solo funcionaba mientras la exportación seguía enpending/processing(y solo cambiaba la fila acancelled). 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 dedata_export, y limpia de forma best-effort el archivo almacenado localmente cuandodownloadUrlapunta 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 exportacionesready/failed. -
downloadDataExport— resolver conectado enuser-data-export.resolver.js, llama adata-export.manager.js#downloadDataExport; verifica que la exportación estéreadyy no haya expirado. Corregido en esta sesión:DataExportDownloadResponse.downloadTokenes no nulo en el esquema, pero antes nada generaba uno.requestDataExportahora genera y persiste undownloadToken(crypto.randomBytes(32)) al crear la fila, ydownloadDataExportgenera 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 porrequestDataExport. Recopila publicaciones/comentarios/mensajes/conexiones de seguimiento, escribe el archivo, y cambia el estado deprocessing→ready(ofailed). Corregido en esta sesión: ahora respetaincludeProfile/includeMessages/includeMediay el filtrodateRangeStart/dateRangeEnd(ver Solicitar una exportación más abajo) en lugar de recopilar todo incondicionalmente. -
dataExportStatusChanged— nueva suscripción (DATA_EXPORT_STATUS_${userId}medianteservices/pubsub.service.js), publicada en cada transición de estado (processing/ready/failed/cancelled).DownloadDataPage.tsxse suscribe a ella y vuelve a consultar en lugar de hacer polling. -
getDataPortabilityInfo(canExport,exportFormats,retentionPeriod,lastExport) — resolver conectado enuser-data-export.resolver.js, perouserManager.getDataPortabilityInfosimplemente devuelvedataExportManager.getDataExportStatus(...), que no tiene ninguna de esas claves. Dado que todos exceptolastExportson no nulos en el tipoDataPortabilityInfo, 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
| Campo | Tipo | Descripción |
|---|---|---|
exportType | String | Se persiste (columna export_type) como metadata; no cambia qué se recopila — el formato de salida siempre es json |
includePosts | Boolean | Incluir las publicaciones del usuario (se aplica; se guarda en el blob JSONB options) |
includeComments | Boolean | Incluir comentarios (se aplica; se guarda en el blob JSONB options) |
includeMessages | Boolean | Incluir mensajes (columna include_messages). Corregido en esta sesión — antes se aceptaba pero se ignoraba |
includeMedia | Boolean | Incluir contenido multimedia de publicaciones/mensajes (columna include_media). Corregido en esta sesión — antes se aceptaba pero se ignoraba |
includeProfile | Boolean | Incluir el blob de perfil sanitizado (columna include_profile). Corregido en esta sesión — antes se aceptaba pero no se usaba |
dateRangeStart | DateTime | Filtra publicaciones/comentarios/mensajes por createdAt (columna date_range_start). Corregido en esta sesión — antes se aceptaba pero no se usaba |
dateRangeEnd | DateTime | Filtra 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: pending → processing → ready (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ón — requestDataExport 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.js — agregada en esta sesión) contiene una fila por solicitud de exportación:
| Columna | Tipo | Notas |
|---|---|---|
id | UUID (PK) | |
user_id | UUID | |
status | STRING(20) | pending (por defecto) → processing → ready / failed / cancelled |
format | STRING(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_type | STRING(20) | agregada en esta sesión; el valor de DataExportRequestInput.exportType (por defecto full) — solo metadata, no afecta qué se recopila |
options | JSONB | el objeto includePosts/includeComments/includeAnalytics capturado en el momento de la solicitud (los interruptores que no tienen columna dedicada) |
date_range_start, date_range_end | DATE | agregadas en esta sesión; DataExportRequestInput.dateRangeStart/dateRangeEnd, ahora realmente aplicadas como filtro de createdAt sobre publicaciones/comentarios/mensajes |
include_profile, include_messages, include_media | BOOLEAN | agregadas en esta sesión, todas con valor por defecto true; ahora realmente controlan qué incluye collectUserData |
download_token | STRING(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_percentage | INTEGER | agregada en esta sesión; se persiste en cada transición de estado (0 → 10 al pasar a processing → 100 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_at | DATE | expires_at se establece a 30 días después de la solicitud |
download_url, file_path, file_size | TEXT / BIGINT | se completa una vez que status es ready |
error_message | TEXT | se completa en failed |
Indexado en (user_id, created_at).