Doc 14 2 Flujos de Administracion y Operacion - ronalc90/koptup GitHub Wiki
Resumen. Estos son los diagramas de todo lo que hace el equipo de KopTup para operar el sitio: procesar solicitudes de demo, gestionar accesos, cambiar el modo de una demo, dar roles, el job que vence los accesos, el camino de un cambio de código hasta producción, la publicación de esta wiki y siete procedimientos ante fallas (runbooks). Cada caja corresponde a algo que el código de
mainhace de verdad; los caminos de error y de bloqueo también están dibujados.Para quién: el dueño y el equipo comercial (secciones 1 a 6) y quien opera o despliega el sitio (secciones 7 a 9). Las pantallas, con capturas, están en el Manual del administrador; las variables y comandos, en Operación y despliegue. El índice de todos los diagramas está en Diagramas de flujo.
| Color | Qué representa |
|---|---|
| Azul | Visitante, prospecto o cliente |
| Gris | Sistema: web, backend o base de datos |
| Morado | Admin, comercial o equipo técnico de KopTup |
| Verde | Aviso: email, WhatsApp o notificación en pantalla |
| Ámbar (rombo) | Decisión |
| Rojo | Error, bloqueo o camino que no termina bien |
| Fucsia | Servicio externo: GitHub, Vercel, Railway, OpenAI o un proveedor |
- Procesar una solicitud de demo
- Gestionar accesos
- Catálogo de demos
- Roles
- 5.1 Rutina diaria sugerida del admin o comercial
- Job de vencimiento y correos
- Despliegue
- 8.1 Publicación de la wiki
- Runbooks (procedimientos ante fallas)
- Hallazgos
Quién puede hacerlo: admin y comercial (sales) aprueban y rechazan; manager solo ve. Las demos «Solo por invitación» (privado) solo las aprueba un admin.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
A1["Admin › Solicitudes de demo<br/>pestaña Pendientes"]:::admin
A2["Abre el detalle DR-AAAA-XXXXXX:<br/>persona, caso de uso, demos, avisos"]:::admin
D1{"¿Necesita revisarla antes?"}:::decision
A3["Marcar en revisión o agregar nota<br/>PATCH /api/admin/demo-requests/ID"]:::admin
A4["Pestaña Aprobar: demos (máx. 10),<br/>días (1 a 365), nota interna y mensaje"]:::admin
D2{"¿Hay una demo Solo por invitación<br/>y tu rol no es admin?"}:::decision
E1["Botón Aprobar deshabilitado<br/>(el backend respondería 403 requires_admin)"]:::error
S1["POST /api/admin/demo-requests/ID/approve"]:::sistema
D3{"¿Sigue pendiente o en revisión?"}:::decision
E2["409 already_processed:<br/>otra persona ya la procesó"]:::error
S2["Reclama la solicitud: estado aprobada"]:::sistema
D4{"¿Ya hay una cuenta con ese email?"}:::decision
D5{"¿Es una cuenta del equipo?"}:::decision
E3["422 team_email: se deshace todo<br/>y la solicitud vuelve a su estado"]:::error
S3["Vincula la cuenta sin cambiarle el rol"]:::sistema
S4["Crea cuenta prospect en estado invitado<br/>(sin contraseña)"]:::sistema
S5["Un acceso por demo: lo crea<br/>o extiende el que ya está vigente"]:::sistema
D6{"¿La cuenta necesita activarse?"}:::decision
S6["Enlace /activar/TOKEN de un solo uso (72 h)<br/>invalida los anteriores"]:::sistema
S7["Sin enlace nuevo: se usa el de<br/>iniciar sesión hacia Mis demos"]:::sistema
S8["Guarda la decisión y registra<br/>demo_request.approve en la bitácora"]:::sistema
A5["El panel muestra el resultado<br/>(sigue en 1.2)"]:::admin
A1 --> A2 --> D1
D1 -->|"Sí"| A3 --> A4
D1 -->|"No"| A4
A4 --> D2
D2 -->|"Sí"| E1
D2 -->|"No"| S1 --> D3
D3 -->|"No"| E2
D3 -->|"Sí"| S2 --> D4
D4 -->|"Sí"| D5
D5 -->|"Sí"| E3
D5 -->|"No"| S3 --> S5
D4 -->|"No"| S4 --> S5
S5 --> D6
D6 -->|"Sí"| S6 --> S8
D6 -->|"No"| S7 --> S8
S8 --> A5

Aprobar crea (o vincula) la cuenta, un acceso por demo y, si la cuenta no tiene contraseña, un enlace de activación. La transición es condicionada al estado: un doble clic recibe 409 sin duplicar nada, y si algo falla a mitad se borran la cuenta y los accesos recién creados. La vigencia propuesta es la mayor «vigencia por defecto» de las demos elegidas.
Fuente en el código: apps/backend/src/services/demo-requests.service.ts (approveDemoRequest), apps/backend/src/services/demo-grants.service.ts (resolveDemosForGrant, linkOrCreateProspect, upsertGrant, grantDemosToPerson), apps/backend/src/routes/admin-demos.routes.ts, apps/web/src/app/admin/solicitudes/[id]/page.tsx.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
B1["Respuesta de la aprobación:<br/>enlace y estado del correo"]:::sistema
D1{"¿Correo configurado en el backend?<br/>Resend o SMTP"}:::decision
B2["Espera el envío hasta 15 s"]:::sistema
D2{"¿Resultado del correo?"}:::decision
N1["También se envió por email"]:::aviso
N2["El correo se sigue enviando"]:::aviso
E1["El correo no se pudo enviar:<br/>comparte el enlace"]:::error
E2["El email no está configurado:<br/>comparte el enlace tú mismo"]:::error
B3["Panel: enlace con botón Copiar<br/>(se muestra una sola vez)"]:::admin
D3{"¿La solicitud tiene un teléfono válido?"}:::decision
N3["Enviar por WhatsApp: abre wa.me con el<br/>mensaje listo, lo envía la persona del equipo"]:::aviso
B4["Sin teléfono: copia el enlace y<br/>envíalo por el medio que prefieras"]:::admin
P1["El prospecto abre /activar/TOKEN<br/>y crea su contraseña"]:::visitante
D4{"¿Lo usó antes de 72 h y<br/>es el último emitido?"}:::decision
P2["Cuenta activa: entra a Mis demos"]:::visitante
B5["Enlace nuevo (invalida los anteriores)<br/>POST .../resend-activation"]:::admin
E3["409 account_active si la cuenta<br/>ya se había activado"]:::error
B6["Seguimiento en el detalle: estado de la cuenta,<br/>accesos, avisos e historial"]:::admin
B7["Seguimiento en Accesos:<br/>último ingreso y visitas por demo"]:::admin
B1 --> D1
D1 -->|"Sí"| B2 --> D2
D2 -->|"Enviado"| N1
D2 -->|"Más de 15 s"| N2
D2 -->|"Falló"| E1
D1 -->|"No"| E2
N1 --> B3
N2 --> B3
E1 --> B3
E2 --> B3
B3 --> D3
D3 -->|"Sí"| N3 --> P1
D3 -->|"No"| B4 --> P1
P1 --> D4
D4 -->|"Sí"| P2 --> B6 --> B7
D4 -->|"No"| B5
B5 -->|"Cuenta invitada"| B1
B5 -->|"Ya activa"| E3

El enlace siempre se muestra en el panel, haya o no correo: así la aprobación nunca depende del proveedor de correo. El botón de WhatsApp no envía nada por sí solo: abre WhatsApp con el texto listo (los celulares colombianos de 10 dígitos que empiezan por 3 reciben el prefijo 57). Si la persona ya tenía cuenta activa, el panel muestra el enlace de inicio de sesión en lugar del de activación.
Fuente en el código: apps/web/src/components/admin/demos/shared.tsx (ActivationLinkPanel), apps/web/src/lib/demo-system.ts (whatsappNumber, whatsappLink), apps/backend/src/services/demo-grants.service.ts (awaitEmail, resendActivation), apps/backend/src/services/magic-link.service.ts, apps/backend/src/config/demos.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
R1["Pestaña Rechazar en el detalle"]:::admin
R2["Motivo: Spam, Fuera de perfil, Datos inválidos,<br/>Duplicada u Otro, más un detalle opcional"]:::admin
D1{"¿El motivo es Spam?"}:::decision
R3["La casilla Avisar se deshabilita:<br/>a un spam nunca se le escribe"]:::admin
D2{"¿Marcaste Avisar al solicitante?"}:::decision
R4["Mensaje opcional para el correo<br/>(el motivo es interno)"]:::admin
S1["POST /api/admin/demo-requests/ID/reject"]:::sistema
D3{"¿Sigue pendiente o en revisión?"}:::decision
E1["409 already_processed"]:::error
S2["Estado rechazada, motivo y revisor guardados"]:::sistema
D4{"¿Avisar y no es spam?"}:::decision
D5{"¿Correo configurado?"}:::decision
N1["Correo: Sobre tu solicitud de demo en KopTup<br/>(con tu mensaje o un texto genérico)"]:::aviso
S3["No se envía nada"]:::sistema
S4["Bitácora: demo_request.reject"]:::sistema
S5["Estado final: no se puede reabrir<br/>(409 invalid_transition)"]:::error
R1 --> R2 --> D1
D1 -->|"Sí"| R3 --> S1
D1 -->|"No"| D2
D2 -->|"Sí"| R4 --> S1
D2 -->|"No"| S1
S1 --> D3
D3 -->|"No"| E1
D3 -->|"Sí"| S2 --> D4
D4 -->|"Sí"| D5
D4 -->|"No"| S3
D5 -->|"Sí"| N1 --> S4
D5 -->|"No"| S3
S3 --> S4 --> S5

El motivo queda solo para el equipo; al solicitante le llega, si se elige avisar, el mensaje opcional o un texto genérico. Un rechazo es definitivo: si la persona vuelve a llenar el formulario se crea una solicitud nueva (las solicitudes solo se fusionan con una abierta de los últimos 30 días).
Fuente en el código: apps/backend/src/services/demo-requests.service.ts (rejectDemoRequest, updateDemoRequest, createDemoRequest), apps/backend/src/services/demo-emails.service.ts (sendRequestRejected), apps/web/src/app/admin/solicitudes/[id]/page.tsx.
Un acceso es una demo concedida a una persona con fecha de vencimiento. Se gestionan en Admin › Accesos a demos (/admin/accesos). Admin y comercial actúan; manager solo ve; los accesos a demos «Solo por invitación» solo los toca un admin.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
G1["Accesos a demos: Activos, Por vencer,<br/>Vencidos, Revocados, Todos"]:::admin
D0{"¿Demo Solo por invitación<br/>y tu rol no es admin?"}:::decision
E0["Sin botones en la fila<br/>(el backend respondería 403 requires_admin)"]:::error
D1{"¿Qué acción?"}:::decision
X1["Elige los días (1 a 365)"]:::admin
X2["POST /api/admin/demo-grants/ID/extend"]:::sistema
D2{"¿El acceso está revocado?"}:::decision
E1["409 grant_revoked: para volver a dar<br/>acceso hay que conceder uno nuevo"]:::error
D3{"¿La persona ya tiene otro acceso<br/>vigente a la misma demo?"}:::decision
E2["409 active_grant_exists: extiende ese"]:::error
X3["Nueva fecha = vencimiento futuro (o ahora) + días<br/>estado activo, recordatorio reiniciado"]:::sistema
N1["Correo: Extendimos tu acceso a DEMO<br/>(si hay correo configurado)"]:::aviso
X4["Bitácora: demo_grant.extend"]:::sistema
V1["Motivo opcional (interno)"]:::admin
V2["POST /api/admin/demo-grants/ID/revoke"]:::sistema
D4{"¿Ya estaba revocado?"}:::decision
E3["409 grant_revoked"]:::error
V3["Estado revocado con fecha, autor y motivo<br/>(no se envía correo)"]:::sistema
V4["Bitácora: demo_grant.revoke"]:::sistema
F1["Efecto en el sitio: ver 2.3"]:::sistema
G1 --> D0
D0 -->|"Sí"| E0
D0 -->|"No"| D1
D1 -->|"Extender"| X1 --> X2 --> D2
D2 -->|"Sí"| E1
D2 -->|"No"| D3
D3 -->|"Sí"| E2
D3 -->|"No"| X3 --> N1 --> X4 --> F1
D1 -->|"Revocar"| V1 --> V2 --> D4
D4 -->|"Sí"| E3
D4 -->|"No"| V3 --> V4 --> F1

Extender también reactiva un acceso vencido (cuenta los días desde hoy). Un acceso revocado no se puede extender: es una decisión explícita del equipo y para deshacerla se concede uno nuevo (2.2). Revocar no avisa a la persona.
Fuente en el código: apps/backend/src/services/demo-grants.service.ts (loadGrantForAction, extendGrant, revokeGrant), apps/backend/src/routes/admin-demos.routes.ts, apps/web/src/app/admin/accesos/page.tsx (ExtendDialog, RevokeDialog).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
C1["Accesos › Conceder acceso"]:::admin
C2["Email, nombre, empresa, teléfono,<br/>demos (máx. 10), días, nota y mensaje"]:::admin
D1{"¿Email válido y al menos una demo?"}:::decision
E1["Error en el formulario"]:::error
D2{"¿Eliges una demo Solo por invitación<br/>sin ser admin?"}:::decision
E2["Casilla bloqueada en el panel<br/>(403 requires_admin en el backend)"]:::error
S1["POST /api/admin/demo-grants"]:::sistema
D3{"¿El email ya tiene cuenta?"}:::decision
D4{"¿Es una cuenta del equipo?"}:::decision
E3["422 team_email: el equipo ya abre todas las demos"]:::error
D5{"¿Escribiste el nombre?"}:::decision
E4["400 name_required"]:::error
S2["Vincula la cuenta existente"]:::sistema
S3["Crea cuenta prospect invitado"]:::sistema
S4["Crea o extiende un acceso por demo"]:::sistema
S5["Enlace de activación si la cuenta no tiene<br/>contraseña y correo si está configurado"]:::sistema
B1["Panel: enlace para copiar o enviar por<br/>WhatsApp al teléfono escrito"]:::aviso
S6["Bitácora: demo_grant.create"]:::sistema
C1 --> C2 --> D1
D1 -->|"No"| E1
D1 -->|"Sí"| D2
D2 -->|"Sí"| E2
D2 -->|"No"| S1 --> D3
D3 -->|"Sí"| D4
D4 -->|"Sí"| E3
D4 -->|"No"| S2 --> S4
D3 -->|"No"| D5
D5 -->|"No"| E4
D5 -->|"Sí"| S3 --> S4
S4 --> S5 --> B1 --> S6

Sirve para dar una demo sin solicitud previa; es la única vía para las demos «Solo por invitación» (cuentas médicas y sistema experto en la configuración inicial), y solo la usa un admin. Usa el mismo motor que la aprobación (cuenta, accesos, enlace y correo).
Fuente en el código: apps/backend/src/services/demo-grants.service.ts (DirectGrantSchema, createDirectGrants, grantDemosToPerson), apps/web/src/app/admin/accesos/page.tsx (InviteDialog).
sequenceDiagram
autonumber
actor P as Prospecto
participant MW as Web (middleware)
participant API as API KopTup
participant DB as MongoDB
actor A as Admin o comercial
A->>API: POST /api/admin/demo-grants/ID/revoke
API->>DB: estado revocado con fecha, autor y motivo
API-->>A: Acceso revocado (sin correo al prospecto)
Note over P: Una pestaña ya abierta no se cierra sola
P->>MW: Abre o recarga /demo/linkedin-ads
MW->>API: GET /api/demo-access/linkedin-ads con su sesión
API->>DB: Busca sus accesos a esa demo
DB-->>API: El último acceso está revocado
API-->>MW: allowed false, reason revocado
MW-->>P: Pantalla de acceso Acceso retirado (la URL no cambia)
P->>API: Llamada a la API de la demo desde una pestaña vieja
API-->>P: 403 demo_access_required, motivo revocado
alt Si luego el equipo concede un acceso nuevo
A->>API: POST /api/admin/demo-grants
API-->>P: Correo Tu acceso está listo (si hay correo configurado)
P->>MW: Abre /demo/linkedin-ads
MW->>API: GET /api/demo-access/linkedin-ads
API-->>MW: allowed true, reason grant
MW-->>P: Demo abierta
end

El servidor decide en cada apertura de una demo con acceso y en cada llamada a las APIs de las demos con backend propio (cuentas médicas, sistema experto, LinkedIn Ads y gestor de contenido): extender, revocar o conceder se nota en la siguiente navegación, sin esperar al job. Lo único que no se corta es una página que ya estaba cargada en el navegador y no vuelve a llamar al servidor.
Fuente en el código: apps/backend/src/services/demo-access.service.ts (evaluateDemoAccess), apps/backend/src/middleware/access.ts (requireStaffOrDemoAccess), apps/web/src/middleware.ts (demoGate, fetchDemoAccess), apps/backend/src/routes/demo-public.routes.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
K1["Admin › Catálogo de demos<br/>filtros: Abiertas, Requieren acceso, Por invitación, Desactivadas"]:::admin
D1{"¿Tu rol es admin?"}:::decision
E1["Solo lectura: Solo un administrador<br/>puede editar el catálogo"]:::error
D2{"¿Es la demo del chatbot RAG?"}:::decision
E2["Siempre abierta y activa<br/>(422 fixed_mode si se intenta)"]:::error
K2["Cambia modo, Activa o vigencia por defecto<br/>(1 a 365 días) y pulsa Guardar"]:::admin
S1["PATCH /api/admin/demo-catalog/SLUG<br/>solo con los campos cambiados"]:::sistema
D3{"¿Datos válidos?"}:::decision
E3["400 invalid_request<br/>(campo no permitido o sin cambios)"]:::error
D4{"¿La demo existe en el catálogo?"}:::decision
E4["404 not_found"]:::error
S2["Guarda en MongoDB (DemoCatalogItem)<br/>y anota quién lo cambió"]:::sistema
S3["Bitácora: demo_catalog.update<br/>con valores antes y después"]:::sistema
N1["Aviso en pantalla: Se guardó"]:::aviso
S4["Los accesos ya concedidos no se tocan"]:::sistema
F1["Propagación al sitio: ver 3.2"]:::sistema
K1 --> D1
D1 -->|"No"| E1
D1 -->|"Sí"| D2
D2 -->|"Sí"| E2
D2 -->|"No"| K2 --> S1 --> D3
D3 -->|"No"| E3
D3 -->|"Sí"| D4
D4 -->|"No"| E4
D4 -->|"Sí"| S2 --> S3 --> N1 --> S4 --> F1

Los tres modos son Abierta (publico), Requiere acceso (solicitud) y Solo por invitación (privado); «Activa» apagada muestra «En mantenimiento» a todos menos al equipo. El catálogo nace de una semilla de 28 demos que se inserta al arrancar sin pisar nunca lo que el admin cambió.
Fuente en el código: apps/backend/src/routes/admin-demos.routes.ts (patchCatalog, FIXED_PUBLIC_DEMOS), apps/backend/src/services/demo-catalog.service.ts, apps/backend/src/data/demo-catalog.seed.ts, apps/web/src/app/admin/catalogo-demos/page.tsx.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
C0["Cambio guardado en MongoDB"]:::sistema
B1["Backend: GET /api/demo-access y APIs de demos<br/>leen el catálogo en cada petición"]:::sistema
B2["Efecto inmediato en el servidor"]:::sistema
H1["Hub /demo, inicio, landings y servicios<br/>piden el catálogo al cargar la página"]:::sistema
H2["Etiqueta nueva al recargar<br/>(primero pintan la semilla)"]:::visitante
W1["Un visitante abre /demo/SLUG"]:::visitante
D1{"¿La copia del middleware<br/>tiene más de 60 s?"}:::decision
W2["Pide GET /api/demo-catalog<br/>(espera máx. 2,5 s)"]:::sistema
D2{"¿Responde?"}:::decision
W3["Guarda el modo nuevo en memoria"]:::sistema
E1["Sigue con la última copia o con la tabla<br/>de respaldo y reintenta en 10 s"]:::error
W4["Usa la copia en memoria<br/>(puede tener el modo anterior)"]:::error
D3{"¿Modo Abierta y demo activa?"}:::decision
W5["Abre la demo sin más consultas"]:::visitante
D4{"¿Tiene sesión?"}:::decision
E2["Pantalla de acceso: iniciar sesión o solicitar"]:::error
W6["GET /api/demo-access/SLUG<br/>decide con el modo nuevo"]:::sistema
C0 --> B1 --> B2
C0 --> H1 --> H2
C0 --> W1 --> D1
D1 -->|"Sí"| W2 --> D2
D2 -->|"Sí"| W3 --> D3
D2 -->|"No"| E1 --> D3
D1 -->|"No"| W4 --> D3
D3 -->|"Sí"| W5
D3 -->|"No"| D4
D4 -->|"No"| E2
D4 -->|"Sí"| W6

El backend aplica el cambio al instante; la web lo aplica en máximo un minuto, porque el middleware de cada instancia guarda el catálogo 60 s en memoria. Ese minuto solo se nota cuando la copia vieja dice «Abierta» (deja pasar sin preguntar) o cuando el visitante no tiene sesión; si hay sesión y la demo no es abierta, pregunta al backend, que ya decide con el modo nuevo.
Fuente en el código: apps/web/src/middleware.ts (catalogEntry, CATALOG_TTL_MS, CATALOG_RETRY_MS, demoGate), apps/web/src/lib/use-demo-catalog.ts, apps/web/src/lib/demo-access-defaults.ts, apps/backend/src/routes/demo-public.routes.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
P0["Requisito: la cuenta ya existe<br/>(registro, Google o aprobación de demo)"]:::visitante
D0{"¿Por qué vía?"}:::decision
A1["Railway arranca el backend (index.ts)"]:::externo
A2["Valida variables y conecta MongoDB"]:::sistema
D1{"¿ADMIN_EMAIL definida?"}:::decision
A3["Log: el arranque no modifica roles"]:::sistema
D2{"¿Hay un usuario con ese email?"}:::decision
E1["Advertencia: ADMIN_EMAIL no corresponde<br/>a ningún usuario"]:::error
E2["Advertencia en el log y el arranque sigue"]:::error
D5{"¿La cuenta está activa y<br/>no hay ningún otro admin?"}:::decision
E6["Log: ya existe otro admin o la cuenta no está activada<br/>no se cambia ningún rol"]:::sistema
A4["role admin para esa cuenta<br/>y registro user.role_change en la bitácora<br/>(origen ADMIN_EMAIL)"]:::sistema
S1["Desarrollador en apps/backend<br/>(lee MONGODB_URI de .env o de railway run):<br/>npx ts-node --transpile-only src/scripts/set-admin.ts EMAIL"]:::admin
D3{"¿Recibió un email?<br/>(argumento o ADMIN_EMAIL)"}:::decision
E3["Muestra el uso y termina con código 1"]:::error
S2["Conecta MongoDB y busca el usuario"]:::sistema
D4{"¿Resultado?"}:::decision
E4["No existe la cuenta, código 1<br/>(debe registrarse o activarse antes)"]:::error
E5["Error o sin conexión a MongoDB, código 1"]:::error
S3["role admin y registro user.role_change<br/>en la bitácora (origen set-admin), código 0"]:::sistema
R1["El backend lo trata como admin de inmediato:<br/>lee el rol de la BD en cada petición"]:::sistema
R2["Para ver el panel debe cerrar sesión y volver a entrar<br/>(la web guarda el rol al iniciar sesión)"]:::visitante
P0 --> D0
D0 -->|"Variable ADMIN_EMAIL"| A1 --> A2 --> D1
D1 -->|"No"| A3
D1 -->|"Sí"| D2
D2 -->|"No"| E1
D2 -->|"Error de BD"| E2
D2 -->|"Sí"| D5
D5 -->|"No"| E6
D5 -->|"Sí"| A4 --> R1
D0 -->|"Script set-admin"| S1 --> D3
D3 -->|"No"| E3
D3 -->|"Sí"| S2 --> D4
D4 -->|"No existe"| E4
D4 -->|"Error"| E5
D4 -->|"Actualizado"| S3 --> R1
R1 --> R2

No hay correo de admin por defecto en el código: sin ADMIN_EMAIL el arranque no toca a nadie. Con ella, el arranque solo da el rol si no hay ningún otro admin (el primer admin o la recuperación cuando no queda ninguno): si un admin le quita el rol a esa cuenta desde el panel, la decisión se respeta. Las dos vías quedan en la bitácora con su origen.
Fuente en el código: apps/backend/src/services/admin-bootstrap.service.ts (ensureAdminFromEnv), apps/backend/src/index.ts, apps/backend/src/scripts/set-admin.ts, apps/backend/src/middleware/access.ts (requireRole, loadFreshUser), apps/web/src/components/admin/AdminLayout.tsx, apps/web/e2e/global-setup.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
U1["Admin › Usuarios (admin y manager la ven)"]:::admin
U2["Selector de rol: Usuario, Developer,<br/>Manager o Admin, y confirma"]:::admin
S1["PATCH /api/admin/users/ID/role"]:::sistema
D1{"¿Quien lo pide es admin?<br/>(rol leído de la BD)"}:::decision
E1["403 Forbidden<br/>(un manager no cambia roles)"]:::error
D2{"¿El rol es válido?"}:::decision
E2["400 Rol inválido"]:::error
D3{"¿Te estás quitando tu propio admin?"}:::decision
E3["400 No puedes quitarte tu propio rol<br/>de administrador"]:::error
D4{"¿Existe el usuario?"}:::decision
E4["404 Usuario no encontrado"]:::error
S2["Actualiza el rol en MongoDB"]:::sistema
S3["Bitácora: user.role_change (antes y después)"]:::sistema
R1["Backend: rige en la siguiente petición"]:::sistema
R2["Middleware de la web: hasta 30 s<br/>(caché de la sesión verificada)"]:::sistema
R3["Panel de la persona: al volver a iniciar sesión"]:::visitante
U1 --> U2 --> S1 --> D1
D1 -->|"No"| E1
D1 -->|"Sí"| D2
D2 -->|"No"| E2
D2 -->|"Sí"| D3
D3 -->|"Sí"| E3
D3 -->|"No"| D4
D4 -->|"No"| E4
D4 -->|"Sí"| S2 --> S3
S3 --> R1
S3 --> R2
S3 --> R3

Quitar un rol corta el acceso enseguida en el servidor (aunque el token siga vigente). Dar un rol de equipo también rige enseguida en el servidor, pero la persona debe salir y volver a entrar para que su navegador le muestre el panel. El selector no ofrece «comercial» (sales), prospect ni client: esos se asignan con la API (ver Hallazgos).
Fuente en el código: apps/backend/src/controllers/admin.controller.ts (adminUpdateUserRole), apps/backend/src/routes/admin.routes.ts, apps/web/src/app/admin/users/page.tsx, apps/web/src/middleware.ts (ME_CACHE_TTL_MS).
flowchart LR
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
V1["Registro en /register o con Google"]:::visitante
V2["Aprobación de solicitud o Conceder acceso"]:::admin
V3["Selector de Admin › Usuarios (solo admin)"]:::admin
V4["API PATCH /api/admin/users/ID/role<br/>(solo admin, cualquier rol válido)"]:::admin
V5["ADMIN_EMAIL al arrancar o script set-admin"]:::externo
subgraph Externos
RU["user"]:::visitante
RP["prospect<br/>(invitado hasta activar)"]:::visitante
RC["client"]:::visitante
end
subgraph Equipo
RD["developer"]:::admin
RM["manager"]:::admin
RS["sales (comercial)"]:::admin
RA["admin"]:::admin
end
V1 --> RU
V2 --> RP
V3 --> RU
V3 --> RD
V3 --> RM
V3 --> RA
V4 --> RS
V4 --> RC
V4 --> RP
V5 --> RA

Hay siete roles. Los cuatro del equipo los da un admin; prospect lo crea el sistema de demos; user es el de cualquier registro; client hoy solo se asigna por API (o con los datos de ejemplo de desarrollo).
| Rol | Panel /admin
|
Solicitudes y accesos | Demos «Solo por invitación» | Catálogo | Cambiar roles | Abre todas las demos |
|---|---|---|---|---|---|---|
admin |
Todo | Gestiona | Sí | Edita | Sí | Sí |
manager |
Operación (pedidos, facturas, usuarios, contactos) | Solo ve | No | Solo ve | No | Sí |
sales (comercial) |
Solo las secciones de demos | Gestiona | No | Solo ve | No | Sí |
developer |
No | No | No | No | No | Sí |
prospect |
No: solo Mis demos y su perfil | No | No | No | No | Solo las de sus accesos |
client y user
|
No: portal /dashboard
|
No | No | No | No | Solo las abiertas o con acceso |
Detalle completo en Roles y permisos.
Fuente en el código: apps/backend/src/models/User.ts (USER_ROLES), apps/backend/src/config/demos.ts (roles del sistema de demos), apps/web/src/lib/auth-roles.ts, apps/backend/src/services/demo-grants.service.ts, apps/backend/src/controllers/auth.controller.ts, apps/backend/src/config/passport.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
T1["Inicia sesión: admin y manager llegan a /admin,<br/>comercial a Solicitudes de demo"]:::admin
T2["Mira el contador de pendientes en el menú"]:::admin
D1{"¿Hay solicitudes pendientes?"}:::decision
T3["Abre cada una: caso de uso, empresa,<br/>demos pedidas y bloque Avisos"]:::admin
D2{"¿Spam, duplicada o fuera de perfil?"}:::decision
T4["Rechaza con motivo (1.3)"]:::admin
D3{"¿Falta información para decidir?"}:::decision
T5["Marca En revisión, deja una nota y<br/>escribe por WhatsApp o email"]:::admin
T6["Aprueba con demos, días y mensaje (1.1)"]:::admin
D4{"¿El panel dice que el correo salió?"}:::decision
N1["Envía el enlace por WhatsApp (1.2)"]:::aviso
T7["Accesos › Por vencer (3 días o menos)"]:::admin
D5{"¿La persona está usando la demo?<br/>(último ingreso y visitas)"}:::decision
T8["Llama o escribe para avanzar,<br/>y extiende si hace falta"]:::admin
T9["Seguimiento comercial o deja vencer"]:::admin
T10["Cuentas sin activar con acceso vigente:<br/>enlace nuevo si el anterior venció"]:::admin
T11["Admin o manager: revisa Contactos del formulario"]:::admin
T12["Una vez por semana (admin): revisa el catálogo"]:::admin
T1 --> T2 --> D1
D1 -->|"Sí"| T3 --> D2
D2 -->|"Sí"| T4 --> T7
D2 -->|"No"| D3
D3 -->|"Sí"| T5 --> T7
D3 -->|"No"| T6 --> D4
D4 -->|"No"| N1 --> T7
D4 -->|"Sí"| T7
D1 -->|"No"| T7
T7 --> D5
D5 -->|"Sí"| T8 --> T10
D5 -->|"No"| T9 --> T10
T10 --> T11 --> T12

Es una rutina sugerida: el código no la impone, pero cada paso usa una función real del panel (contador de pendientes, pestaña «Por vencer», columnas «Último ingreso» y «Visitas», botón «Enlace de activación»). La sección Contactos no la ve el rol comercial.
Fuente en el código: apps/web/src/components/admin/AdminLayout.tsx (contador y menú por rol), apps/web/src/app/admin/solicitudes/page.tsx, apps/web/src/app/admin/accesos/page.tsx, apps/web/src/lib/auth-roles.ts (homePathForRole), apps/backend/src/services/demo-grants.service.ts (GRANT_LIST_STATES).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
J0["El backend arranca (index.ts)"]:::sistema
D0{"¿NODE_ENV test o<br/>DEMO_GRANTS_JOB_ENABLED false?"}:::decision
J1["El job no se programa en esta instancia"]:::error
J2["Primera corrida a los 30 s y luego cada<br/>DEMO_GRANTS_JOB_INTERVAL_MS<br/>1 h por defecto o si vale menos de 1 min"]:::sistema
D1{"¿Sigue en curso la corrida anterior?"}:::decision
J3["Salta esta corrida"]:::sistema
D2{"¿MongoDB conectado?"}:::decision
J4["Salta: db_unavailable"]:::error
J5["Candado en Redis: SET NX<br/>jobs:demo-grants:lock por 10 min"]:::sistema
D3{"¿Resultado del candado?"}:::decision
J6["Otra instancia lo tiene: salta"]:::sistema
J7["Redis no responde: corre sin candado<br/>(los pasos son idempotentes)"]:::error
J8["Paso 1: accesos activos con fecha vencida<br/>pasan a expirado"]:::sistema
D4{"¿Correo configurado?"}:::decision
J9["No envía ni marca recordatorios"]:::error
J10["Paso 2: activos que vencen en 3 días o menos<br/>y sin recordatorio (máx. 500)"]:::sistema
J11["Reclama cada uno: recordatorioEnviadoEn = ahora"]:::sistema
J12["Agrupa por persona: un correo con todas sus demos"]:::sistema
N1["Correo: Tu acceso a las demos<br/>de KopTup vence pronto"]:::aviso
D5{"¿Se envió?"}:::decision
J13["Libera esos accesos para<br/>reintentar en la siguiente corrida"]:::error
J14["Cuenta el recordatorio"]:::sistema
J15["Log con expirados y recordatorios,<br/>libera el candado si lo tomó"]:::sistema
J0 --> D0
D0 -->|"Sí"| J1
D0 -->|"No"| J2 --> D1
D1 -->|"Sí"| J3
D1 -->|"No"| D2
D2 -->|"No"| J4
D2 -->|"Sí"| J5 --> D3
D3 -->|"Ocupado"| J6
D3 -->|"Sin Redis"| J7 --> J8
D3 -->|"Tomado"| J8
J8 --> D4
D4 -->|"No"| J9 --> J15
D4 -->|"Sí"| J10 --> J11 --> J12 --> N1 --> D5
D5 -->|"No"| J13 --> J15
D5 -->|"Sí"| J14 --> J15

El job solo ordena los datos y avisa: la vigencia real no depende de él, porque el servidor compara la fecha de vencimiento en cada apertura. Cada acceso recibe un solo recordatorio (se reinicia al extenderlo); si la cuenta sigue sin activar, el correo le recuerda usar el enlace de activación.
Fuente en el código: apps/backend/src/jobs/demo-grants.job.ts, apps/backend/src/services/redis-lock.service.ts, apps/backend/src/services/demo-emails.service.ts (sendExpiryReminder), apps/backend/src/config/demos.ts (GRANT_REMINDER_DAYS_BEFORE), apps/backend/src/index.ts.
flowchart LR
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
M1["Solicitud nueva"]:::visitante
M2["Aprobación o Conceder acceso"]:::admin
M3["Enlace de activación nuevo"]:::admin
M4["Extender un acceso"]:::admin
M5["Rechazo con Avisar marcado (no spam)"]:::admin
M6["Job: vence en 3 días o menos"]:::sistema
M7["Revocar o vencimiento"]:::admin
D1{"¿La cuenta tiene accesos vigentes?"}:::decision
C1["Acuse: Recibimos tu solicitud de demo (código)"]:::aviso
C2["Aviso al dueño por correo (NOTIFY_EMAIL)<br/>y por WhatsApp (plantilla de Meta)"]:::aviso
C3["Tu acceso a las demos de KopTup está listo<br/>(con el enlace si falta activar)"]:::aviso
C4["Extendimos tu acceso a DEMO"]:::aviso
C5["Sobre tu solicitud de demo en KopTup"]:::aviso
C6["Tu acceso a las demos de KopTup vence pronto"]:::aviso
C0["Sin correo"]:::error
M1 --> C1
M1 --> C2
M2 --> C3
M3 --> D1
D1 -->|"Sí"| C3
D1 -->|"No"| C0
M4 --> C4
M5 --> C5
M6 --> C6
M7 --> C0

Todos los correos al prospecto salen por el proveedor de correo del backend: Resend por API HTTPS con RESEND_API_KEY (SMTP solo como respaldo, porque Railway Free, Trial y Hobby lo bloquean). Sin correo no sale ninguno y el panel lo dice. El aviso al dueño de una solicitud nueva es el mismo del formulario de contacto: correo a NOTIFY_EMAIL (o ADMIN_EMAIL) y WhatsApp con la plantilla koptup_aviso, cada uno con su estado en el contacto y reintentos automáticos (Flujos técnicos › 9).
Fuente en el código: apps/backend/src/services/demo-emails.service.ts, apps/backend/src/services/email.service.ts, apps/backend/src/services/lead.service.ts, apps/backend/src/services/owner-notify.service.ts, apps/backend/src/services/whatsapp.service.ts, apps/backend/src/services/demo-requests.service.ts, apps/backend/src/services/demo-grants.service.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
P1["Desarrollador hace push a una rama y abre un PR"]:::admin
P2["GitHub Actions: workflow CI<br/>(pull_request, push a main o manual)"]:::externo
J1["Job calidad: mensajes i18n al día,<br/>lint, tipos y títulos de página"]:::externo
J2["Job pruebas (matriz backend y web):<br/>Jest con MongoDB 7 y Redis 7"]:::externo
J3["Job build: tsc del backend y next build<br/>apuntando a un backend local"]:::externo
J4["Job e2e: MongoDB, Redis, mock de OpenAI,<br/>backend y web compilados y Playwright"]:::externo
J5["Job seguridad: npm audit de producción<br/>(umbral alto) y gitleaks"]:::externo
D1{"¿Todos los jobs en verde?"}:::decision
E1["Revisa logs y el reporte de Playwright,<br/>corrige y haz push otra vez"]:::error
E2["El push nuevo cancela la corrida en curso<br/>(concurrency)"]:::sistema
P3["PR listo para revisión y merge (7.2)"]:::admin
P1 --> P2
P2 --> J1
P2 --> J2
P2 --> J3 --> J4
P2 --> J5
J1 --> D1
J2 --> D1
J4 --> D1
J5 --> D1
D1 -->|"No"| E1 --> E2 --> P2
D1 -->|"Sí"| P3

CI solo verifica, no despliega. El job e2e espera al de build y usa sus compilados; recorre la plataforma de punta a punta (solicitud, aprobación, activación, acceso, revocación, permisos, modo de acceso, contacto y la demo RAG con un PDF) con datos ficticios de CI. El job de seguridad falla con una alerta alta o crítica de una dependencia de producción que no esté revisada en .github/audit-allowlist.json o con algo con forma de secreto en los archivos o en los commits nuevos.
Fuente en el código: .github/workflows/ci.yml, scripts/audit-check.mjs, .gitleaks.toml, apps/web/e2e/global-setup.ts, apps/web/e2e/support/openai-mock.js, apps/web/playwright.config.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
M1["Merge del PR a main<br/>(GitHub no exige CI en verde)"]:::admin
M2["Push a main"]:::externo
C1["CI corre otra vez sobre main (solo verifica)"]:::externo
V1["Vercel (integración con GitHub):<br/>npm install y next build de apps/web"]:::externo
D1{"¿Build de la web OK?"}:::decision
E1["Despliegue fallido: sigue la versión anterior"]:::error
V2["www.koptup.com con la versión nueva<br/>(NEXT_PUBLIC_* se fijan en este build)"]:::sistema
R1["Railway (Nixpacks):<br/>npm install y npm run build"]:::externo
R2["Arranque: npm run start (node dist/index.js)"]:::externo
D2{"¿Faltan MONGODB_URI o JWT_SECRET?"}:::decision
E2["El proceso termina: Configuración incompleta"]:::error
R3["Conecta MongoDB (si falla, arranca igual),<br/>ADMIN_EMAIL, semilla del catálogo, puerto y job"]:::sistema
D3{"¿GET /health responde 200 en 120 s?<br/>(solo con MongoDB conectado)"}:::decision
E3["Despliegue fallido en Railway:<br/>sigue la versión anterior"]:::error
R4["API en producción"]:::sistema
F1["Producción: web y API con el código de main"]:::sistema
M1 --> M2
M2 --> C1
M2 --> V1 --> D1
D1 -->|"No"| E1
D1 -->|"Sí"| V2 --> F1
M2 --> R1 --> R2 --> D2
D2 -->|"Sí"| E2 --> E3
D2 -->|"No"| R3 --> D3
D3 -->|"No"| E3
D3 -->|"Sí"| R4 --> F1

Vercel y Railway despliegan desde main por su cuenta, en paralelo y sin esperar a CI (hasta que el dueño active la protección de main y la espera de CI: acciones pendientes). El chequeo de Railway es /health: un despliegue sin MongoDB no entra en servicio y la versión anterior sigue atendiendo. Situación actual: el dominio del backend en Railway no tiene servicio (ver 9.1 y Estado y próximos pasos).
Fuente en el código: apps/backend/railway.json, apps/backend/package.json (build, start), apps/web/vercel.json, apps/backend/src/index.ts, apps/backend/src/config/env.ts, apps/backend/src/app.ts (/health/live, /health), README.md (sección Despliegue).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
W1["Cambio en docs/wiki o en wiki-sync.yml"]:::admin
D1{"¿A qué rama va el push?"}:::decision
W2["No se publica"]:::sistema
W3["Workflow Publicar wiki<br/>(uno a la vez, sin cancelar el anterior)"]:::externo
W0["Ejecución manual (workflow_dispatch)"]:::admin
W4["Clona el repositorio .wiki.git con GITHUB_TOKEN"]:::externo
D2{"¿Se pudo clonar?"}:::decision
E1["Error: La wiki no está inicializada.<br/>Actívala y crea la primera página"]:::error
W5["Borra todo menos .git y copia docs/wiki"]:::sistema
W6["Quita la extensión .md de los enlaces entre páginas"]:::sistema
D3{"¿Hay cambios?"}:::decision
W7["Sin cambios en la wiki"]:::sistema
W8["Commit docs(wiki): sincroniza desde docs/wiki<br/>y push a la wiki"]:::externo
W9["Wiki publicada en GitHub"]:::aviso
W1 --> D1
D1 -->|"Otra rama"| W2
D1 -->|"main"| W3
W0 --> W3
W3 --> W4 --> D2
D2 -->|"No"| E1
D2 -->|"Sí"| W5 --> W6 --> D3
D3 -->|"No"| W7
D3 -->|"Sí"| W8 --> W9

La fuente de la wiki vive versionada en docs/wiki/ y el workflow la copia completa a la wiki de GitHub: lo que se edite directamente en la wiki se pierde en la siguiente publicación. Solo se publica sola desde main; otra rama se publica únicamente a mano, y la siguiente publicación desde main la vuelve a dejar igual a main.
Fuente en el código: .github/workflows/wiki-sync.yml.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
S0["Síntoma: el login falla, /admin manda al login,<br/>fallan contacto y solicitudes de demo"]:::error
S1["Abre la URL del backend + /health/live"]:::admin
D1{"¿Qué responde?"}:::decision
A1["404 Application not found (situación actual):<br/>no hay servicio en ese dominio"]:::error
D2{"¿El servicio existe en<br/>el proyecto de Railway?"}:::decision
A2["Recréalo o reactívalo desde main<br/>con railway.json y sus variables"]:::externo
D3{"¿El dominio del backend cambió?"}:::decision
A3["Actualiza NEXT_PUBLIC_API_URL en Vercel<br/>y redespliega la web (se fija al compilar)"]:::externo
B1["Error 5xx o sin respuesta:<br/>logs del despliegue en Railway"]:::externo
D4{"¿Dice Configuración incompleta?"}:::decision
B2["Define MONGODB_URI y JWT_SECRET<br/>(y JWT_REFRESH_SECRET) y redespliega"]:::externo
B3["Otro error de arranque: revierte<br/>el último despliegue (9.6)"]:::admin
H1["Prueba GET /health"]:::admin
D5{"¿Responde 200 healthy?"}:::decision
H2["503: MongoDB desconectado.<br/>Revisa MONGODB_URI y la base"]:::error
H3["Prueba el login en /admin y el formulario de contacto"]:::admin
D6{"¿Funcionan?"}:::decision
H4["Revisa CORS_ORIGIN si la web usa otro dominio<br/>y FRONTEND_URL"]:::externo
H5["Servicio restablecido"]:::sistema
S0 --> S1 --> D1
D1 -->|"404 Application not found"| A1 --> D2
D2 -->|"No o pausado"| A2 --> D3
D2 -->|"Sí, con otro dominio"| D3
D3 -->|"Sí"| A3 --> H1
D3 -->|"No"| H1
D1 -->|"5xx o nada"| B1 --> D4
D4 -->|"Sí"| B2 --> H1
D4 -->|"No"| B3 --> H1
D1 -->|"200 alive"| H1
H1 --> D5
D5 -->|"No"| H2 --> H1
D5 -->|"Sí"| H3 --> D6
D6 -->|"No"| H4 --> H3
D6 -->|"Sí"| H5

Situación actual (9 de octubre de 2026): el dominio configurado en la web responde «Application not found» de Railway. La web pública funciona, pero todo lo que usa el backend falla. Como Railway responde 404, el middleware de la web lo interpreta como «sin sesión» y manda al login en lugar de mostrar la página «No pudimos verificar tu sesión» (ver Hallazgos).
Fuente en el código: apps/backend/src/app.ts (/health/live, /health, CORS), apps/backend/src/config/env.ts, apps/backend/railway.json, apps/web/src/lib/backend-url.ts, apps/web/.env.production, apps/web/src/middleware.ts (fetchMe, unavailable).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
Q0["Síntoma: un correo no llega"]:::error
D0{"¿Qué correo falta?"}:::decision
P0["Admin › Configuración ›<br/>Avisos de nuevos contactos"]:::admin
D1{"¿Qué dice la tarjeta<br/>del correo?"}:::decision
A1["No configurado: falta RESEND_API_KEY<br/>o NOTIFY_EMAIL"]:::error
A2["Define las variables en Railway<br/>(Operación y despliegue 4.5)"]:::externo
S1["Proveedor SMTP: Railway Free, Trial<br/>y Hobby bloquean el SMTP saliente"]:::error
T1["Enviar aviso de prueba"]:::admin
D2{"¿Resultado del correo?"}:::decision
B1["Resend 401 o 403: key revocada<br/>o sin permiso de envío"]:::error
B2["Crea otra key con Sending access<br/>y cámbiala en Railway"]:::externo
B3["Dominio no verificado: revisa<br/>koptup.com en Resend y su DNS"]:::externo
B4["Sin respuesta o 5xx: el job<br/>reintenta cada 5 min"]:::aviso
G1["Enviado: busca en spam y crea<br/>el filtro de Gmail"]:::admin
F2["Recordatorios: revisa el correo,<br/>DEMO_GRANTS_JOB_ENABLED<br/>y el log demo-grants-job"]:::externo
Q1["Mientras tanto: copia el enlace<br/>o envíalo por WhatsApp"]:::aviso
D3{"¿Qué dice el panel<br/>junto al enlace?"}:::decision
C1["El correo tardó más de 15 s:<br/>mira luego el bloque Avisos"]:::aviso
D4{"¿El enlace apunta<br/>a localhost?"}:::decision
C2["Define FRONTEND_URL<br/>con la URL pública"]:::externo
C3["Pide revisar spam<br/>y el email escrito"]:::admin
A3["Redespliega en Railway: la<br/>configuración se lee al arrancar"]:::externo
Q0 --> D0
D0 -->|"Aviso de un contacto"| P0 --> D1
D0 -->|"Recordatorios"| F2 --> A3
D0 -->|"Al prospecto"| Q1 --> D3
D1 -->|"No configurado"| A1 --> A2 --> A3
D1 -->|"Proveedor SMTP"| S1 --> A2
D1 -->|"Configurado con Resend"| T1 --> D2
D2 -->|"401 o 403"| B1 --> B2 --> A3
D2 -->|"Dominio no verificado"| B3
D2 -->|"Sin respuesta o 5xx"| B4
D2 -->|"Enviado"| G1
D3 -->|"No está configurado"| P0
D3 -->|"No se pudo enviar"| T1
D3 -->|"Se sigue enviando"| C1
D3 -->|"Se envió"| D4
D4 -->|"Sí"| C2 --> A3
D4 -->|"No"| C3

Con RESEND_API_KEY todo el correo del backend sale por la API HTTPS de Resend; sin ella, por SMTP si hay usuario y contraseña (el servidor por defecto, si falta SMTP_HOST, es el de Gmail), algo que en Railway Free, Trial y Hobby no funciona porque bloquean los puertos SMTP. Si Resend rechaza el remitente porque el dominio no está verificado, el aviso al dueño se reintenta una vez desde [email protected]. Ninguna acción del panel falla por el correo: aprobar, invitar y extender responden igual y muestran el enlace. El procedimiento con texto y los códigos de error está en Operación y despliegue › 9.7.
Fuente en el código: apps/backend/src/services/email.service.ts (getProvider, send, sendViaResend), apps/backend/src/services/owner-notify.service.ts (getNotificationStatus, sendOwnerTestNotice), apps/backend/src/services/demo-grants.service.ts (awaitEmail, EMAIL_WAIT_MS), apps/backend/src/jobs/demo-grants.job.ts, apps/backend/src/jobs/notify-retry.job.ts, apps/backend/src/config/demos.ts (frontendUrl), apps/web/src/components/admin/demos/shared.tsx, apps/web/src/components/admin/OwnerNotificationsCard.tsx.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
B0["Un prospecto dice que no puede abrir una demo"]:::visitante
B1["Pide el estado que ve en pantalla<br/>y busca su email en Accesos"]:::admin
D1{"¿Qué estado muestra<br/>la pantalla de acceso?"}:::decision
D2{"¿Su cuenta sigue sin activar (invitado)?"}:::decision
A1["Genera un enlace de activación nuevo<br/>y compártelo (1.2)"]:::admin
A2["Debe iniciar sesión con el email<br/>que se aprobó"]:::visitante
D3{"¿Tiene el acceso con otro email?"}:::decision
A3["Concede el acceso a esa demo (2.2)"]:::admin
A4["Extiende el acceso (2.1)"]:::admin
A5["Admin: activa la demo en el catálogo (3.1)"]:::admin
A6["Backend o MongoDB sin respuesta: ver 9.1"]:::error
W1["Espera hasta 60 s (copia del catálogo<br/>en el middleware)"]:::sistema
R1["Pide que recargue la página de la demo"]:::visitante
D4{"¿Ya abre?"}:::decision
R2["Listo"]:::sistema
R3["Revisa el acceso en el detalle de la solicitud<br/>y la bitácora (GET /api/admin/audit-log)"]:::admin
B0 --> B1 --> D1
D1 -->|"Sin sesión"| D2
D2 -->|"Sí"| A1 --> R1
D2 -->|"No"| A2 --> R1
D1 -->|"Sin acceso"| D3
D3 -->|"Sí"| A2
D3 -->|"No"| A3 --> R1
D1 -->|"Acceso vencido"| A4 --> R1
D1 -->|"Acceso retirado"| A3
D1 -->|"En mantenimiento"| A5 --> W1 --> R1
D1 -->|"No se pudo verificar"| A6
R1 --> D4
D4 -->|"Sí"| R2
D4 -->|"No"| R3

Los estados son los de la pantalla /demo-acceso: «Sin sesión», «Sin acceso», «Acceso vencido», «Acceso retirado», «En mantenimiento» y «No se pudo verificar». Un acceso retirado (revocado) no se extiende: se concede uno nuevo. Si la demo es «Solo por invitación», el acceso lo da un admin.
Fuente en el código: apps/backend/src/services/demo-access.service.ts (motivos, en orden), apps/web/src/middleware.ts (accessScreen), apps/web/src/components/demo/DemoAccessGate.tsx, apps/web/messages/es.json (demoAccess.status), apps/backend/src/routes/admin-demos.routes.ts (bitácora).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
I0["Síntoma: una demo con IA dice que alcanzó el cupo<br/>o que la IA no está disponible"]:::error
D1{"¿Qué función?"}:::decision
I1["Chatbot: responde en modo extractivo<br/>(sin IA) y lo dice"]:::sistema
I2["Prueba con tu documento: 503<br/>Alcanzamos el cupo de pruebas de este mes"]:::error
I3["LinkedIn Ads: 503, usa el generador local"]:::error
I4["Gestor de contenido: 503<br/>Alcanzamos el cupo mensual de IA"]:::error
D2{"¿El mensaje habla de cupo<br/>o de IA no disponible?"}:::decision
N1["Revisa OPENAI_API_KEY y REDIS_URL:<br/>sin Redis no se mide el gasto y la IA se apaga"]:::externo
D3{"¿La variable del tope<br/>es un número válido?"}:::decision
N2["Corrígela: un valor inválido o negativo<br/>deja la función con tope 0"]:::externo
D4{"¿Quieres subir el tope<br/>este mes?"}:::decision
U1["Revisa el saldo y los límites de la cuenta en OpenAI"]:::externo
U2["Sube en Railway DEMO_, CHATBOT_, LINKEDIN_ADS_, CONTENT_,<br/>DOCUMENTS_ o HEALTH_MONTHLY_BUDGET_USD y redespliega"]:::externo
U3["Espera al cambio de mes UTC (7 p. m. del último día,<br/>hora de Colombia): el contador arranca en cero"]:::sistema
U4["La función vuelve a usar IA en la siguiente consulta"]:::sistema
I0 --> D1
D1 -->|"Chatbot"| I1 --> D2
D1 -->|"Documento propio"| I2 --> D2
D1 -->|"LinkedIn Ads"| I3 --> D2
D1 -->|"Contenido"| I4 --> D2
D2 -->|"No disponible"| N1 --> U4
D2 -->|"Cupo"| D3
D3 -->|"No"| N2 --> U4
D3 -->|"Sí"| D4
D4 -->|"Sí"| U1 --> U2 --> U4
D4 -->|"No"| U3 --> U4

Cada función con IA tiene su tope mensual en dólares (por defecto 50 para el chatbot y para «Prueba con tu documento», 20 para LinkedIn Ads y para el gestor de contenido) y el gasto se acumula en Redis por mes UTC. El panel no muestra el gasto: se ve en Redis (clave <función>:spend:AAAA-MM) o en la cuenta de OpenAI.
Fuente en el código: apps/backend/src/services/ai-budget.service.ts, apps/backend/src/routes/chatbot.routes.ts, apps/backend/src/services/chatbot.service.ts, apps/backend/src/services/demo-rag.service.ts, apps/backend/src/routes/linkedin-ads.routes.ts, apps/backend/src/routes/content-manager.routes.ts.
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
K0["Decides rotar: urgente para MongoDB y JWT,<br/>que quedaron en el historial del repositorio"]:::admin
D1{"¿Qué secreto?"}:::decision
M1["Cambia la contraseña en el proveedor de la base"]:::externo
M2["Actualiza MONGODB_URI en Railway"]:::externo
J1["JWT_SECRET: valor nuevo, largo (32 o más)<br/>y distinto del de renovación"]:::admin
J2["Los tokens de acceso actuales dejan de valer:<br/>la web los renueva con el de renovación"]:::sistema
J3["JWT_REFRESH_SECRET: valor nuevo y distinto"]:::admin
J4["Las sesiones no se pueden renovar: todos<br/>vuelven a iniciar sesión (máx. 15 min)"]:::visitante
K1["INTERNAL_API_KEY: el mismo valor nuevo<br/>en Vercel y en Railway"]:::externo
K2["Redespliega también la web"]:::externo
P1["Resend, OpenAI o Meta (WhatsApp): revoca la clave<br/>o el token en el proveedor y crea uno nuevo"]:::externo
P2["Actualiza la variable en Railway"]:::externo
R1["Redespliegue del backend en Railway"]:::externo
D2{"¿GET /health responde 200?"}:::decision
R2["Revisa el valor nuevo en los logs (ver 9.1)"]:::error
R3["Prueba el login en /admin y Enviar aviso de prueba"]:::admin
R4["Rotación terminada"]:::sistema
K0 --> D1
D1 -->|"MongoDB"| M1 --> M2 --> R1
D1 -->|"JWT_SECRET"| J1 --> J2 --> R1
D1 -->|"JWT_REFRESH_SECRET"| J3 --> J4 --> R1
D1 -->|"INTERNAL_API_KEY"| K1 --> K2 --> R1
D1 -->|"Proveedores"| P1 --> P2 --> R1
R1 --> D2
D2 -->|"No"| R2 --> R1
D2 -->|"Sí"| R3 --> R4

Los secretos viven solo en Vercel y Railway, nunca en el repositorio. Los enlaces de activación pendientes no se afectan: se guardan como un hash en MongoDB y no dependen de los secretos JWT. Si rotas los dos JWT a la vez, todas las sesiones se cierran.
Fuente en el código: apps/backend/src/middleware/auth.ts (generateAccessToken, generateRefreshToken, verifyRefreshToken), apps/backend/src/controllers/auth.controller.ts (refreshToken), apps/backend/src/config/env.ts, apps/backend/src/services/magic-link.service.ts, apps/web/src/middleware.ts (refreshAccessToken, forwardHeaders).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
V0["Detectas un error después de un despliegue"]:::error
D0{"¿La causa fue una variable<br/>y no el código?"}:::decision
V1["Restaura el valor en Vercel o Railway y redespliega<br/>(NEXT_PUBLIC_* exige reconstruir la web)"]:::externo
D1{"¿Qué parte falla?"}:::decision
V2["Vercel: vuelve a poner en producción<br/>el despliegue anterior de la web"]:::externo
V3["Railway: vuelve a desplegar el despliegue<br/>anterior del backend"]:::externo
V4["Verifica /health y el flujo afectado"]:::admin
V5["GitHub: PR con git revert del merge en main"]:::admin
V6["CI del PR en verde (7.1)"]:::externo
V7["Merge: Vercel y Railway despliegan<br/>main ya revertido (7.2)"]:::externo
E1["Si no reviertes main, el siguiente push<br/>vuelve a desplegar el error"]:::error
D2{"¿El cambio escribió<br/>datos en MongoDB?"}:::decision
V8["Los datos no se revierten solos:<br/>revísalos a mano (no hay migraciones automáticas)"]:::error
V9["main y producción vuelven a coincidir"]:::sistema
V0 --> D0
D0 -->|"Sí"| V1 --> V4
D0 -->|"No"| D1
D1 -->|"Web o ambas"| V2 --> V4
D1 -->|"API o ambas"| V3 --> V4
V4 --> V5 --> V6 --> V7 --> D2
V5 -.->|"Si no lo haces"| E1
D2 -->|"Sí"| V8 --> V9
D2 -->|"No"| V9

Volver a un despliegue anterior es una función de los paneles de Vercel y Railway (no está en el repositorio) y sirve para salir del paso en minutos; la reversión definitiva es un git revert en main, porque las dos plataformas despliegan desde ahí. El catálogo de demos y los demás datos viven en MongoDB y no cambian al revertir el código.
Fuente en el código: .github/workflows/ci.yml (CI no despliega), apps/backend/railway.json, apps/web/vercel.json, apps/backend/src/index.ts (la semilla del catálogo solo inserta lo que falta), README.md (sección Despliegue).
flowchart TD
classDef visitante fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sistema fill:#f1f5f9,stroke:#475569,color:#0f172a
classDef admin fill:#f3e8ff,stroke:#9333ea,color:#0f172a
classDef aviso fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef decision fill:#fef3c7,stroke:#d97706,color:#0f172a
classDef error fill:#fee2e2,stroke:#dc2626,color:#0f172a
classDef externo fill:#fdf4ff,stroke:#c026d3,color:#0f172a
W0["Síntoma: el aviso de un contacto<br/>no llega por WhatsApp"]:::error
P0["Admin › Configuración ›<br/>Avisos de nuevos contactos"]:::admin
D1{"¿WhatsApp configurado?"}:::decision
A1["Falta WHATSAPP_API_TOKEN,<br/>WHATSAPP_PHONE_NUMBER_ID<br/>o ADMIN_WHATSAPP_NUMBER"]:::error
A2["WHATSAPP_PROVIDER pide twilio o ultramsg:<br/>ya no se soportan, usa meta"]:::error
A3["Define las variables en Railway<br/>y redespliega"]:::externo
T1["Enviar aviso de prueba"]:::admin
D2{"¿Código de Meta?"}:::decision
E1["190: token vencido<br/>usa el de usuario del sistema"]:::externo
E2["131030: tu número no está en la<br/>lista del número de prueba"]:::externo
E3["132xxx: plantilla koptup_aviso<br/>no aprobada o distinta"]:::externo
E4["Respaldo hello_world: sonó el teléfono<br/>pero la plantilla falló"]:::aviso
D3{"¿Enviado y te llegó?"}:::decision
H1["Sin webhook el panel solo sabe<br/>que Meta lo aceptó"]:::sistema
H2["Activa el webhook: verás entregado,<br/>leído o fallido y una alerta por correo"]:::externo
OK["Canal funcionando. Si había<br/>contactos fallidos, Reenviar aviso"]:::aviso
W0 --> P0 --> D1
D1 -->|"No"| A1 --> A3
D1 -->|"No, proveedor retirado"| A2 --> A3
D1 -->|"Sí"| T1 --> D2
D2 -->|"190"| E1 --> A3
D2 -->|"131030"| E2
D2 -->|"132000 a 132016"| E3
D2 -->|"Ninguno, salió con respaldo"| E4 --> E3
D2 -->|"Ninguno, Enviado"| D3
D3 -->|"No llegó"| H1 --> H2
D3 -->|"Sí"| OK

El aviso por WhatsApp siempre usa la plantilla aprobada koptup_aviso de la WhatsApp Cloud API de Meta: con un aviso al mes casi nunca hay una conversación abierta, y fuera de ella Meta solo acepta plantillas. Si la plantilla falla por un error de plantilla, el backend envía la de ejemplo hello_world para que al menos suene el teléfono; ese aviso cuenta como enviado y el panel guarda el error para que la arregles. Lo que falla por otras causas se reintenta solo cada 5 minutos, hasta 6 veces en los primeros 3 días. La tabla completa de códigos de Meta y qué hacer con cada uno está en Operación y despliegue › 9.11.
Fuente en el código: apps/backend/src/services/whatsapp.service.ts (sendTemplate, describeMetaError, TEMPLATE_ERROR_CODES, getStatus), apps/backend/src/services/owner-notify.service.ts (sendOwnerTestNotice, processWhatsAppStatuses), apps/backend/src/routes/whatsapp-webhook.routes.ts, apps/backend/src/config/env.ts (notifyWarnings), apps/web/src/components/admin/OwnerNotificationsCard.tsx.
Cosas del código o de la configuración que no tienen sentido o pueden confundir a quien opera el sitio. Están dibujadas tal como son.
-
Con el backend caído, el panel manda al login en lugar de avisar. Railway responde 404 («Application not found») a todo, y el middleware de la web trata un 404 de
GET /api/auth/meigual que una sesión inválida: redirige a/loginen vez de mostrar la página 503 «No pudimos verificar tu sesión». Quien opera puede creer que el problema son sus credenciales. (apps/web/src/middleware.ts,fetchMe). -
mainno tiene protección de rama. GitHub no exige CI en verde para fusionar, y Vercel y Railway despliegan desdemainsin esperar a CI: un merge con CI en rojo llega igual a producción. Es una configuración de las plataformas: los pasos están en Operación y despliegue › Acciones pendientes del dueño. -
El selector de roles de Admin › Usuarios está incompleto. Ofrece Usuario, Developer, Manager y Admin: no hay forma de dar el rol comercial (
sales),prospectniclientdesde la interfaz, solo por API. Si un usuario ya tiene uno de esos roles, el selector no lo puede mostrar. Un manager ve el selector, pero el backend le responde 403. - Un rol nuevo no se ve en el panel hasta volver a iniciar sesión. El panel decide qué mostrar con el rol guardado en el navegador al iniciar sesión; el servidor ya aplica el rol nuevo, pero la persona es enviada a su página anterior hasta que sale y vuelve a entrar.
-
La bitácora no tiene pantalla.
GET /api/admin/audit-logexiste (solo admin), pero no hay una sección del panel para consultarla. - En Accesos, el enlace de activación nuevo no ofrece WhatsApp. El modal se abre sin teléfono, así que solo permite copiar el enlace, aunque la persona tenga teléfono (en el detalle de la solicitud sí aparece el botón).
- Se puede conceder acceso a una demo desactivada. Ni aprobar ni invitar revisan si la demo está activa: la persona recibe el correo y al abrirla ve «En mantenimiento».
- Diagramas de flujo · Flujos de negocio · Flujos técnicos
- Manual del administrador (las mismas acciones con capturas)
- Roles y permisos · Operación y despliegue
- Flujos del prospecto y cliente (lo mismo visto desde el otro lado)
- Estado y próximos pasos