Importador genérico de registros
Pantalla: /dashboard/settings/import (frontend/src/sections/data-import/). Base:
supabase/migrations/20261116000001_data_import_platform.sql. Manual de usuario:
«Administración → Importar datos» en el manual.
Reparto de responsabilidades
Navegador Server action (sesión) Postgres (SECURITY DEFINER)
parseSpreadsheet (papaparse / xlsx) → actions/data-import.ts → import_create_job
autoMap + buildRows del destino (TS) lotes de 200 filas import_apply_chunk(job, rows, dry_run)
runImport: lotes, reintento, cancelar ├─ import_guard
├─ import_target_contacts
└─ import_target_health_history
- El navegador hace todo lo que es texto: decodificar (UTF-8 estricto, si no Windows-1252),
mapear columnas, limpiar documentos, parsear fechas, horas y diagnósticos
(
lib/transforms.ts). Es TS puro con pruebas sobre casos del primer archivo real. - La base solo valida y persiste un payload ya tipado. No parsea texto libre.
- Nada pasa por el backend NestJS. Allí todo corre con service-role,
auth.uid()es NULL yuser_has_permission()siempre da falso. Con la sesión del usuario, la auditoría y elcreated_byson de quien importa de verdad.
Autorización
import_guard(tenant, target), en cada RPC salvo la configuración:
- Hay sesión: service-role se rechaza, porque
created_by, la firma y la auditoría necesitan un usuario. import_tenant_settings.enabledpara el tenant. Solo el superadmin lo escribe, conimport_save_tenant_settings, desde la tarjeta de la ficha del tenant.core.import.run.- El destino está en
allowed_targetsy el usuario tiene el permiso del destino:contact.writeohealth.records.manage, este último víahealth_can. - En
import_apply_chunkyimport_finish_job, el trabajo es del mismo usuario.
Las cinco tablas import_* tienen RLS activado y REVOKE ALL a anon y authenticated. Solo se
accede por las RPC.
Idempotencia y prueba
import_external_ids (tenant, target, external_key)hace de «ID externo» de Odoo. Claves:contact:<id>,patient:<n.º historia>,encounter:<documento|h:historia>|<fecha>|<hora>. Reimportar el mismo archivo da 0 creados.- Dry-run («Probar»): el destino se ejecuta entero dentro de un bloque que termina con
RAISE ... SQLSTATE 'XIDRY', capturado. Se revierte todo; los conteos sobreviven en variables PL/pgSQL. No toca los contadores del trabajo. - Cada fila corre en su propia subtransacción. Una fila mala da un error de fila y el lote sigue.
- Las incidencias (
import_job_errors) guardan fila, campo, código y gravedad. Sin valores: el archivo es PHI.
Historias clínicas: qué se escribe
Por fila se escribe un contacto (con health_patients, metadata.history_number) y una fila en
clinical_encounters:
reason,findingsytreatment.diagnoses, normalizado conhealth_normalize_diagnosesy validado contrahealth_code_entries.notescon los bloques rotulados.metadata.import = {job_id, file, origin_ref}.status:signed(consigned_at = occurred_atysigned_by= usuario vinculado al recurso) odraft, segúntarget_config['health.history'].
El profesional sale de options.resource_id o, si no hay, del nombre de la columna, comparado como
conjunto de palabras sin tildes. Se escribe una fila de health_audit por lote.
Añadir un destino nuevo
- TS:
frontend/src/sections/data-import/targets/<destino>.tsconfields(clave, tipo, grupo y alias de columna ya normalizados) ybuildRows, que devuelve filas tipadas e incidencias. Regístralo entargets/registry.tsy en el tipoImportTargetKey. - SQL: una migración nueva con
import_target_<destino>(tenant, job, rows) → jsonb, que es interna, sin GRANT aauthenticated. Añade la clave aimport_known_targets(), su permiso aimport_target_permission()y la rama enimport_apply_chunk. - i18n:
dataImport.targets.<destino>,dataImport.fields.*y los códigos nuevos endataImport.issues. - Pruebas:
buildRowscon filas reales anonimizadas ysupabase/tests/data_import/, con el caso rojo antes que el verde.
Pruebas
cd frontend && yarn test src/sections/data-import
cd supabase/tests/data_import && ./run.sh ../../migrations/20261116000001_data_import_platform.sql