Explicación del ticket T4¶
Qué es este código, en una frase¶
Es el esquema de base de datos (Postgres) de NHEO, definido con SQLAlchemy como modelos Python y aplicado con Alembic como una secuencia de migraciones versionadas. No hay lógica de negocio ni endpoints acá — solo la forma de las tablas, sus relaciones, sus restricciones y cómo se crean y revierten paso a paso.
Cómo se relacionan las piezas¶
app/db/base.py -> la clase Base + la convención de nombres de constraints
app/db/mixins.py -> id/created_at/updated_at/enum_check reutilizables
app/db/models/*.py -> un modelo SQLAlchemy por tabla (o grupo de tablas)
app/db/models/__init__.py -> importa todos los modelos (para que Alembic los "vea")
alembic/env.py -> conecta Alembic con Base.metadata y con DATABASE_URL
alembic/versions/*.py -> 9 migraciones, en orden, que crean el esquema completo
El flujo real es: cada modelo en app/db/models/ describe una tabla en
Python. alembic/env.py importa Base.metadata (que ya conoce todos los
modelos gracias a models/__init__.py) y lo usa como "la verdad" para
comparar contra la base de datos real. Cuando corres
alembic revision --autogenerate, Alembic compara ambas cosas y genera el
código de migración; los 9 archivos en alembic/versions/ son el
resultado de eso, ya revisado y con los ajustes manuales necesarios
(el trigger de audit_log, por ejemplo, no lo genera autogenerate — se
agregó a mano).
Las piezas base (app/db/)¶
-
base.py: defineBase, la clase de la que heredan todos los modelos, con unanaming_conventionfija. Esto es lo que hace que cada índice, unique, check y foreign key tenga un nombre predecible (ix_<tabla>_<columna>,fk_<tabla>_<columna>_<tabla_referenciada>, etc.) en vez de un nombre autogenerado por Postgres. Sin esto, Alembic a veces no puede hacerDROP CONSTRAINTen un downgrade porque no sabe el nombre exacto. -
mixins.py: tres piezas reutilizables. UUIDPKMixin— toda tabla tiene unidUUID generado en la base de datos (gen_random_uuid(), de la extensiónpgcrypto), no en Python.CreatedAtMixin/UpdatedAtMixin— timestamps automáticos. La diferencia importante:updated_atNO se agrega a todas las tablas, solo a las que se espera que muten (ver más abajo el porqué).-
enum_check()— genera unCHECK CONSTRAINT(columna IN (...)) en vez de unENUMnativo de Postgres, porque agregar un valor a un ENUM nativo no siempre se puede hacer dentro de una transacción, y Alembic envuelve cada migración en una. -
models/*.py: cada archivo define una o más tablas. Ya venían con buenos docstrings explicando las decisiones de diseño; ahora además tienen comentarios línea por línea explicando cada columna, cada FK y su política deondelete.
Las 11 tablas, qué guardan y por qué están separadas así¶
| Tabla | Qué guarda | Detalle de diseño relevante |
|---|---|---|
users |
Usuarios internos y externos (empleados, clientes con acceso al portal, aliados) | Sin rol (va en user_roles) ni totp_secret (fuera de scope). cognito_sub único: el puente hacia AWS Cognito, que resuelve la autenticación real. |
user_roles |
Una fila por cada rol asignado a un usuario | Un usuario puede tener varios roles simultáneos. Sin updated_at: un rol no se edita, se borra y se recrea. |
clientes |
Registro comercial de cada cliente (empresa) | aliado_id existe como UUID pero sin FK todavía — la tabla aliados es de T5-T8. mrr en DECIMAL, no float. |
proyectos |
Proyectos que se ejecutan para un cliente | Sin notas_internas (va en proyecto_notas_internas). |
proyecto_notas_internas |
Notas de uso interno sobre un proyecto | Tabla aparte para que un SELECT * en el portal del cliente no pueda exponer estas notas — la columna simplemente no existe en proyectos. |
proyecto_actualizaciones |
Feed de hitos/avances que sí ve el cliente | Contenido curado a mano (o por proceso), no un filtro sobre tareas. Append-only: sin updated_at. |
tareas |
Tareas internas del equipo, con subtareas (tarea_padre_id) |
Sin visible_cliente: la visibilidad del cliente no es un filtro sobre esta tabla. |
facturas |
Registro interno de cobros | No es una factura fiscal DIAN — ver advertencia abajo. |
tickets |
Tickets de soporte al cliente | Sin updated_at: el historial de cambios de estado importa para SLA, y una sola columna no lo captura. creado_por usa ondelete="RESTRICT" (única FK así en todo el esquema). |
audit_log |
Bitácora de acciones críticas | Solo INSERT, nunca UPDATE/DELETE — reforzado con un trigger de Postgres, no solo por convención de código. |
alembic_version |
Tabla técnica de Alembic (no es un modelo) | Guarda cuál es la última migración aplicada. |
La advertencia de facturas (importante)¶
Factura.numero_factura es un correlativo interno, usado para
conciliar con Stripe y llevar historial. No es el número de factura
que exige la DIAN para facturación electrónica en Colombia. Si el negocio
necesita emitir facturas fiscales válidas, hay que integrar un proveedor
autorizado por la DIAN, y esta tabla probablemente pase a ser un espejo de
lo que ese proveedor genera (con su propio número, CUFE, etc.) — no la
fuente de verdad. Esto queda documentado en el docstring de
app/db/models/facturas.py, en CONVENTIONS.md y en el README.md.
No se debe usar numero_factura como número fiscal sin confirmar esto
con contabilidad primero.
El trigger de audit_log¶
La migración f783ec8d0d11_create_audit_log.py no solo crea la tabla:
también crea una función PL/pgSQL (audit_log_block_mutation) y un
trigger (trg_audit_log_block_mutation) que se disparan antes de
cualquier UPDATE o DELETE sobre audit_log y lanzan una excepción,
abortando la operación. Esto lo probé directamente:
INSERT INTO audit_log (accion, entidad) VALUES ('test', 'test'); -- funciona
UPDATE audit_log SET accion = 'hacked'; -- ERROR (bloqueado)
DELETE FROM audit_log; -- ERROR (bloqueado)
La razón de hacerlo con un trigger de base de datos (y no con permisos de rol o solo con disciplina de código) es que aplica sin importar qué usuario de conexión use la aplicación, ahora o en el futuro.
Convenciones fijadas (ver CONVENTIONS.md)¶
- PKs: UUID en todas las tablas, generadas por Postgres.
- Timestamps:
created_aten todas las tablas;updated_atsolo en las que se esperan editar (no en asignaciones ni en feeds append-only). - Enums:
CHECKen vez deENUMnativo (ver el porqué arriba). - Índices: toda FK tiene índice explícito (Postgres no los crea solo
para FKs), más
email,estadoycliente_iddonde existen. - Nombres de constraints: fijados en
base.pypara que todo downgrade sea determinístico.
Qué se verificó¶
Levanté un Postgres 16 real en este entorno y corrí:
alembic upgrade headdesde una base vacía → llegó limpio a las 11 tablas (10 de negocio +alembic_version), con los 41 índices/PKs/UQs esperados.- Probé el trigger de
audit_loginsertando y luego intentandoUPDATE/DELETEpor SQL directo — ambos fueron rechazados. alembic downgrade base, migración por migración (9 pasos) → volvió a dejar la base completamente vacía (solo quedóalembic_version, sin residuos, sin la extensiónpgcrypto).
¿Está terminado el ticket T4?¶
Sí, según su propio criterio de aceptación ("una base vacía llega al
esquema completo corriendo alembic upgrade head") — se verificó
corriendo el ciclo completo contra un Postgres real, no solo leyendo el
código. Repasando el checklist del ticket punto por punto:
- Alembic inicializado, con una primera migración vacía como base — sí (
32006c84e65f_base.py). userssinrolnitotp_secret, concognito_subúnico — sí.user_rolescomo tabla de asignación — sí.clientes— sí.proyectossinnotas_internas— sí.proyecto_notas_internascomo tabla aparte — sí.tareassinvisible_cliente— sí.proyecto_actualizacionescomo feed curado — sí.facturascomo registro interno (con la advertencia DIAN documentada) — sí.tickets— sí.audit_logsolo-insert, reforzado con trigger — sí, y lo probé a mano.- Índices en todas las FK y en
email/estado/cliente_id— sí, revisé cada tabla contra sus FKs reales y no falta ninguno. - Convención de nombres y timestamps documentada — sí, en
CONVENTIONS.md. alembic downgradefunciona en cada migración — sí, probado paso a paso hasta la base vacía.
Lo único que queda abierto es lo que el propio ticket ya marca como
"pendiente para T5-T8" y no como parte de su propio alcance: la FK de
clientes.aliado_id → aliados (la tabla aliados no existe aún) y la
decisión con contabilidad sobre si facturas necesita evolucionar hacia
un proveedor de facturación electrónica DIAN. Ninguna de las dos bloquea
el criterio de aceptación de T4 tal como está escrito.