Skip to content

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: define Base, la clase de la que heredan todos los modelos, con una naming_convention fija. 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 hacer DROP CONSTRAINT en un downgrade porque no sabe el nombre exacto.

  • mixins.py: tres piezas reutilizables.

  • UUIDPKMixin — toda tabla tiene un id UUID generado en la base de datos (gen_random_uuid(), de la extensión pgcrypto), no en Python.
  • CreatedAtMixin / UpdatedAtMixin — timestamps automáticos. La diferencia importante: updated_at NO se agrega a todas las tablas, solo a las que se espera que muten (ver más abajo el porqué).
  • enum_check() — genera un CHECK CONSTRAINT (columna IN (...)) en vez de un ENUM nativo 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 de ondelete.

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_at en todas las tablas; updated_at solo en las que se esperan editar (no en asignaciones ni en feeds append-only).
  • Enums: CHECK en vez de ENUM nativo (ver el porqué arriba).
  • Índices: toda FK tiene índice explícito (Postgres no los crea solo para FKs), más email, estado y cliente_id donde existen.
  • Nombres de constraints: fijados en base.py para que todo downgrade sea determinístico.

Qué se verificó

Levanté un Postgres 16 real en este entorno y corrí:

  1. alembic upgrade head desde una base vacía → llegó limpio a las 11 tablas (10 de negocio + alembic_version), con los 41 índices/PKs/UQs esperados.
  2. Probé el trigger de audit_log insertando y luego intentando UPDATE/DELETE por SQL directo — ambos fueron rechazados.
  3. 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ón pgcrypto).

¿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).
  • users sin rol ni totp_secret, con cognito_sub único — sí.
  • user_roles como tabla de asignación — sí.
  • clientes — sí.
  • proyectos sin notas_internas — sí.
  • proyecto_notas_internas como tabla aparte — sí.
  • tareas sin visible_cliente — sí.
  • proyecto_actualizaciones como feed curado — sí.
  • facturas como registro interno (con la advertencia DIAN documentada) — sí.
  • tickets — sí.
  • audit_log solo-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 downgrade funciona 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.