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]>
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]>
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.
Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
`public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
single easing, and reduced-motion resolves `initial` to the final state so a
never-firing `whileInView` cannot leave a section invisible forever.
Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
framer-motion (~40 KB gz) into every panel load and the landing downloaded
recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
making the entry import 111 KB gz for a 200-byte utility.
Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
so it left the login's side padding at 0 on anything but an iPhone in
landscape. No overflow check could see it — there was no overflow, just zero
margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
`raw-viewport-unit` scan covers those instead.
Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
constant, so without it Vite eliminated the magic login and the "Ver como…"
switcher: the deployed landing promised "no registration" and led to an empty
form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
from returning visitors' caches.
Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
POST /book and POST /appointments now resolve the specialist via autoAssign (or isAvailable-guard a chosen one) inside BEGIN IMMEDIATE...COMMIT, returning 409 on conflict. Slots endpoint derives the day window from real working_hours and ranks the best specialist per slot. settings/employees expose + persist the new fields. Adds booking-e2e.mjs and adapts e2e-test.mjs fixtures to in-hours slots.