Convierte el backend Postgres de platform/ en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo.
El problema que resuelve
El locationId ya era por negocio, pero el token vivía en la variable de entorno CRM_TOKEN, una sola para todo el proceso. Con dos negocios eso usaba el token del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en la subcuenta equivocada en el peor.
Multi-tenancy
lib/crypto.ts — AES-256-GCM para los tokens. Autenticado a propósito: una fila manipulada hace que el descifrado falle, en vez de devolver basura que acabaríamos mandando como credencial al CRM. La clave maestra vive en CRM_MASTER_KEY, fuera de la base.
crm/ctx.ts — CrmCtx { businessId, locationId, token } sustituye al locationId suelto que viajaba por once firmas. Es un objeto y no dos parámetros porque dos string seguidos se cruzan sin que el compilador diga nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro.
crm/client.ts — el token pasa a ser obligatorio en el tipo: olvidarlo es ahora un error de compilación. El estrangulador es por token y aprende la cuota de las cabeceras x-ratelimit-*, que declaran 100 peticiones por 10 s — el cliente iba 6,5× por debajo con una estimación.
Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes por negocio. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
Consola de superadministración
/api/admin y la pantalla /admin/cuentas: alta de cuentas con su dueña en una transacción, vínculo, desvínculo y suspensión.
Las credenciales se comprueban contra el CRM antes de guardarse — un token sin validar traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. El error distingue «token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Verificada en navegador: el campo del token es de contraseña y viene vacío, porque no hay valor que traer.
Sincronización por identificador
POST /api/crm/sync/:entidad/:id para contacto, conversación, mensaje, cita y servicio. La dirección la decide la entidad, no quien llama:
Entidad
Dirección
Por qué
contacto, conversación, mensaje
← del CRM
Es su dueño: ahí viven la deduplicación y las automatizaciones
cita, servicio
→ al CRM
Medido: el calendario del CRM tiene una cita en dos años y su catálogo está vacío
El espejo persistido de conversaciones y mensajes también es nuevo: las tablas existían desde 002_crm.sql y nadie escribía en ellas.
Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un finally, y antes se comprobó que el borrado existe: preguntar si se puede deshacer antes de escribir en el CRM de un cliente, no después. La subcuenta quedó como estaba.
47 hallazgos medidos en crm/HALLAZGOS.md, y la referencia de endpoints en crm/API.md con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend de demo de server/ no se ha tocado y sigue con sus 43 pruebas.
Deuda conocida, dicha sin rodeos
La bandeja de mensajes todavía lee en vivo del CRM, no del espejo que este PR construye.
La autenticación sigue siendo el id del usuario en texto plano, también para el rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima de esa base: no debe quedar expuesta a internet hasta endurecerla.
Convierte el backend Postgres de `platform/` en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo.
## El problema que resuelve
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno `CRM_TOKEN`, **una sola para todo el proceso**. Con dos negocios eso usaba el token del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en la subcuenta equivocada en el peor.
## Multi-tenancy
- **`lib/crypto.ts`** — AES-256-GCM para los tokens. Autenticado a propósito: una fila manipulada hace que el descifrado **falle**, en vez de devolver basura que acabaríamos mandando como credencial al CRM. La clave maestra vive en `CRM_MASTER_KEY`, fuera de la base.
- **`crm/ctx.ts`** — `CrmCtx { businessId, locationId, token }` sustituye al `locationId` suelto que viajaba por once firmas. Es un objeto y no dos parámetros porque dos `string` seguidos se cruzan sin que el compilador diga nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro.
- **`crm/client.ts`** — el token pasa a ser **obligatorio en el tipo**: olvidarlo es ahora un error de compilación. El estrangulador es por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran 100 peticiones por 10 s — el cliente iba 6,5× por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes **por negocio**. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin` y la pantalla `/admin/cuentas`: alta de cuentas con su dueña en una transacción, vínculo, desvínculo y suspensión.
**Las credenciales se comprueban contra el CRM antes de guardarse** — un token sin validar traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. El error distingue «token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Verificada en navegador: el campo del token es de contraseña y **viene vacío**, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y servicio. La dirección la decide la entidad, no quien llama:
| Entidad | Dirección | Por qué |
|---|---|---|
| contacto, conversación, mensaje | ← del CRM | Es su dueño: ahí viven la deduplicación y las automatizaciones |
| cita, servicio | → al CRM | Medido: el calendario del CRM tiene **una** cita en dos años y su catálogo está **vacío** |
El espejo persistido de conversaciones y mensajes también es nuevo: las tablas existían desde `002_crm.sql` y **nadie escribía en ellas**.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en ciclo **crear → releer → borrar → confirmar borrado**, con la limpieza en un `finally`, y **antes** se comprobó que el borrado existe: preguntar si se puede deshacer antes de escribir en el CRM de un cliente, no después. La subcuenta quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en `crm/API.md` con la lista explícita de dónde la documentación oficial falla.
**110 pruebas de plataforma en verde, typecheck limpio, build correcto.** El backend de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo que este PR construye.
- La autenticación sigue siendo el id del usuario en texto plano, también para el rol `admin`. Esta consola crea cuentas y guarda credenciales de clientes encima de esa base: **no debe quedar expuesta a internet hasta endurecerla.**
El VOLUME anónimo del Dockerfile y el volumen con nombre de Coolify son dos piezas
del mismo mecanismo: quitar una y no poner la otra deja la base efímera. Queda
documentado junto con el respaldo diario y por qué usa VACUUM INTO en vez de cp.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Prepara el backend Postgres para producción y lo despliega como stack de Swarm
en el servidor de Consultoría E3, detrás de Traefik.
## Lo que le faltaba al proyecto para poder empaquetarse
- **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers
de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio
README declara. El typecheck pasaba limpio porque en el equipo de desarrollo
el archivo existe; solo se vio al contenerizar, cuando el proceso murió al
arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia
deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide
que vuelva a colarse una importación cruzada.
- **No había endpoint de salud.** `/api/health` consulta la base: uno que solo
responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el
orquestador debería reiniciar.
- **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye
`/api/`, para que una ruta de API inexistente devuelva 404 y no el index.
- **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones
en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra.
## Imagen
`platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la
agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México
haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al
arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio
deja el esquema a medias si el arranque falla.
`tsx` pasa de devDependencies a dependencies: este backend corre TypeScript
directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión
fijada y sobre todo el código del servidor.
## Estado en el servidor
- Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC`
revocado. Verificado: el rol no ve ninguna tabla de las otras bases.
- Clave maestra de cifrado **generada en el servidor**, nunca copiada de
desarrollo.
- Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas.
- Salud, SPA y API de administración verificadas desde dentro de la red overlay.
## Pendiente y bloqueante para el acceso externo
El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a
mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por
HTTP-01.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Convierte el backend Postgres de
platform/en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo.El problema que resuelve
El
locationIdya era por negocio, pero el token vivía en la variable de entornoCRM_TOKEN, una sola para todo el proceso. Con dos negocios eso usaba el token del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en la subcuenta equivocada en el peor.Multi-tenancy
lib/crypto.ts— AES-256-GCM para los tokens. Autenticado a propósito: una fila manipulada hace que el descifrado falle, en vez de devolver basura que acabaríamos mandando como credencial al CRM. La clave maestra vive enCRM_MASTER_KEY, fuera de la base.crm/ctx.ts—CrmCtx { businessId, locationId, token }sustituye allocationIdsuelto que viajaba por once firmas. Es un objeto y no dos parámetros porque dosstringseguidos se cruzan sin que el compilador diga nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro.crm/client.ts— el token pasa a ser obligatorio en el tipo: olvidarlo es ahora un error de compilación. El estrangulador es por token y aprende la cuota de las cabecerasx-ratelimit-*, que declaran 100 peticiones por 10 s — el cliente iba 6,5× por debajo con una estimación.Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
Consola de superadministración
/api/adminy la pantalla/admin/cuentas: alta de cuentas con su dueña en una transacción, vínculo, desvínculo y suspensión.Las credenciales se comprueban contra el CRM antes de guardarse — un token sin validar traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. El error distingue «token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Verificada en navegador: el campo del token es de contraseña y viene vacío, porque no hay valor que traer.
Sincronización por identificador
POST /api/crm/sync/:entidad/:idpara contacto, conversación, mensaje, cita y servicio. La dirección la decide la entidad, no quien llama:El espejo persistido de conversaciones y mensajes también es nuevo: las tablas existían desde
002_crm.sqly nadie escribía en ellas.Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
finally, y antes se comprobó que el borrado existe: preguntar si se puede deshacer antes de escribir en el CRM de un cliente, no después. La subcuenta quedó como estaba.47 hallazgos medidos en
crm/HALLAZGOS.md, y la referencia de endpoints encrm/API.mdcon la lista explícita de dónde la documentación oficial falla.110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend de demo de
server/no se ha tocado y sigue con sus 43 pruebas.Deuda conocida, dicha sin rodeos
admin. Esta consola crea cuentas y guarda credenciales de clientes encima de esa base: no debe quedar expuesta a internet hasta endurecerla.El backend Postgres de `platform/` asumía un solo negocio con un solo token del CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo. ## Multi-tenancy El `locationId` ya era por negocio, pero el token vivía en la variable de entorno `CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en la subcuenta equivocada en el peor. - `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una fila manipulada hace que el descifrado FALLE, en vez de devolver basura que acabaríamos mandando como credencial al CRM. La clave maestra vive en `CRM_MASTER_KEY`, fuera de la base. - `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos parámetros porque dos `string` seguidos se cruzan sin que el compilador diga nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es el único sitio donde el token existe descifrado, y solo en memoria. - `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran 100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación. - Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez. Lo único de la credencial que sale del servidor es la huella de 6 caracteres. ## Consola de superadministración `/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN contra el CRM antes de guardarse — un token sin validar traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. El error distingue «token inválido» de «subcuenta inexistente» de «token de otra subcuenta». Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de contraseña y viene vacío, porque no hay valor que traer. ## Sincronización por identificador `POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene una sola cita en dos años y su catálogo de servicios está vacío. - `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos. - `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde 002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo. - `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento` escribe la hora de pared del negocio con su desplazamiento; `toISOString()` habría movido la hora que el CRM enseña en su interfaz. - `crm/services.ts` — publicación de servicios al catálogo. ## Verificado contra la subcuenta real, no deducido Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un `finally`, y antes se comprobó que el borrado existe: preguntar si se puede deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta quedó como estaba. 47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en `crm/API.md`, con la lista explícita de dónde la documentación oficial falla. 110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend de demo de `server/` no se ha tocado y sigue con sus 43 pruebas. ## Deuda conocida, dicha sin rodeos - La bandeja de mensajes todavía lee en vivo del CRM, no del espejo. - La autenticación sigue siendo el id del usuario en texto plano, también para el rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima de esa base: no debe quedar expuesta a internet hasta endurecerla. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>Prepara el backend Postgres para producción y lo despliega como stack de Swarm en el servidor de Consultoría E3, detrás de Traefik. ## Lo que le faltaba al proyecto para poder empaquetarse - **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio README declara. El typecheck pasaba limpio porque en el equipo de desarrollo el archivo existe; solo se vio al contenerizar, cuando el proceso murió al arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide que vuelva a colarse una importación cruzada. - **No había endpoint de salud.** `/api/health` consulta la base: uno que solo responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el orquestador debería reiniciar. - **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye `/api/`, para que una ruta de API inexistente devuelva 404 y no el index. - **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra. ## Imagen `platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio deja el esquema a medias si el arranque falla. `tsx` pasa de devDependencies a dependencies: este backend corre TypeScript directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión fijada y sobre todo el código del servidor. ## Estado en el servidor - Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC` revocado. Verificado: el rol no ve ninguna tabla de las otras bases. - Clave maestra de cifrado **generada en el servidor**, nunca copiada de desarrollo. - Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas. - Salud, SPA y API de administración verificadas desde dentro de la red overlay. ## Pendiente y bloqueante para el acceso externo El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por HTTP-01. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.