Amedi modo demo · arquitectura técnica
← volver al diseño del flujo
cómo se aísla y se siembra un demo en producción

La máquina detrás
del modo demo.

Todo lo de aquí está anclado en el código real de apps/api. La decisión de fondo: en vez del schema demo separado que ya existe (con switch de search_path) —frágil de mantener y casi sin uso—, el demo vive en public detrás de dos columnas: is_demo y demo_session_id. La buena noticia: la mitad de la maquinaria ya está construida —los decoradores de guardrail, la extracción de sesión, el contexto async y el watermark de PDF— solo hay que repuntarla de "qué schema" a "qué bandera".

punto de partida · el código real
Lo que ya hay y lo que cambia.

Inventario honesto contra apps/api/src/demo y auth. Casi todo se reutiliza; lo que cambia es de dónde sale la identidad demo y cómo se filtra al leer.

ya existese reutiliza
Decoradores de guardrail
@DemoBlocked({reason}) y @DemoLimit({action,max}) ya existen y ya están puestos en los controllers de pacientes, citas, chat, archivos y pagos.
src/demo/decorators/*.decorator.ts
DemoGuardrailsGuard
Guard global que lee el metadata y lanza 403 DEMO_BLOCKED / 429 DEMO_LIMIT_REACHED. Se queda tal cual.
src/demo/demo-guardrails.guard.ts
Identidad + sesión
jwt.strategy ya lee app_metadata.is_demo; session-id.ts extrae el demoSessionId; demo-context.ts (AsyncLocalStorage) lo expone en los services.
auth/strategies/jwt.strategy.ts · src/common/demo-context.ts
Guardrail en el front
DemoGuardrailHandler escucha la cache de React Query y muestra el toast con CTA a registrarse. Montado global.
consultorio/components/demo/demo-guardrail-handler.tsx
Watermark [demo] en PDF
PdfService ya estampa la marca cuando data.isDemo === true. Se mantiene.
src/pdf/pdf.service.ts
cambia / faltanuevo
Columnas is_demo / demo_session_id
isDemo en Profile y Clinic; demoSessionId en lo que el demo crea (Appointment, consultas, conversaciones). Migración Prisma normal.
prisma/schema.prisma
Matriz anti-fuga
Filtrar isDemo = false en todo lo que ve el paciente (directorio de doctores, búsqueda de clínicas, perfil por slug) en apps/client + doctors.service.
apps/client · doctors/clinics.service
Lectura aislada por sesión
Las queries del doctor demo (agenda, chat, consultas) filtran demoSessionId = sesión actual; los pacientes base quedan null (compartidos, lectura).
appointments/chat/consultation services
Seed en public
Clínica demo + doctor demo + membresía DoctorClinic (hoy falta) + 8 pacientes base + disponibilidad. Prisma normal, no SQL crudo.
prisma/seeds/demo.seed.ts
Retirar el schema viejo
Eliminar DemoSchemaInterceptor (search_path), sync-demo-schema.sh y el reseed por truncate. Re-habilitar el cron de limpieza por sesión.
src/demo/demo-schema.interceptor.ts · scripts/
El doctor demo ya tiene identidad fija
Existe el uid de Supabase 514dce14-2292-4317-82c0-ccd4412ae82b (demo-doctor@amedisalud.com) con app_metadata.is_demo = true. Lo reusamos como el único doctor demo; la migración solo le pone isDemo = true en profiles y lo amarra a la clínica demo.
modelo de datos
Dos columnas y un índice.

El cambio de schema es mínimo y todo modelado en Prisma. isDemo marca quién es demo; demoSessionId marca de quién es lo creado dentro del demo. Los pacientes base se siembran con demoSessionId = null: compartidos y de solo lectura.

prisma/schema.prisma · diff
model Profile {
  // …campos existentes…
  isDemo      Boolean @default(false) @map("is_demo")
  @@index([isDemo])
}

model Clinic {
  // …campos existentes…
  isDemo      Boolean @default(false) @map("is_demo")
  @@index([isDemo])
}

model Appointment {
  // …campos existentes…
  demoSessionId String? @map("demo_session_id")
  @@index([doctorId, demoSessionId, date])   // agenda demo por sesión + día
}

// mismo demoSessionId? en ConsultationRecord y Conversation
// (todo lo que el doctor demo CREA dentro de una sesión)
la isla demo · todo cuelga del flag, lo creado cuelga de la sesión
flowchart LR
  subgraph DEMO["public · marcado is_demo"]
    C["Clinic
isDemo = true"] D["Doctor demo
profile.isDemo = true"] DC["DoctorClinic
(membresía)"] P["8 pacientes base
isDemo = true · session = null"] end subgraph SESS["creado por sesión · demo_session_id"] A["Appointment
session = a3f1"] CR["ConsultationRecord
session = a3f1"] CH["Conversation
session = a3f1"] end D --- DC --- C D -->|"agenda"| A P -->|"sobre paciente base"| A A --> CR D --> CH REAL(["pacientes reales
isDemo = false"]) -. "nunca se cruzan" .-> DEMO
arquitectura del request
Un request demo, de punta a punta.

La cadena de guards e interceptores ya existe; solo cambia que la identidad sale del JWT + columnas y no del search_path. El orden es Throttler → JwtAuth → Permissions → DemoGuardrails; el escribir estampa, el leer filtra.

flujo · de la cookie de sesión al filtro por demo_session_id
flowchart TB
  REQ["request del doctor demo"]
  JWT["JwtAuthGuard
lee app_metadata.is_demo
→ req.isDemo · req.demoSessionId"] GR{"DemoGuardrailsGuard
@DemoBlocked? @DemoLimit?"} B403["403 DEMO_BLOCKED
(registrar paciente, equipo, cobros)"] L429["429 DEMO_LIMIT_REACHED
(tope diario de citas)"] ALS["demo-context (AsyncLocalStorage)
isDemo() · demoSessionId()"] SVC["service"] WRITE["WRITE → estampa
isDemo = true · demoSessionId = sesión"] READ["READ → filtra
isDemo + (session = la mía OR null)"] DB[("public")] FE["DemoGuardrailHandler
toast + CTA registrarse"] REQ --> JWT --> GR GR -- "bloqueado" --> B403 --> FE GR -- "tope" --> L429 --> FE GR -- "ok" --> ALS --> SVC SVC --> WRITE --> DB SVC --> READ --> DB
diagrama de secuencia · 1 de 3
Agendar una cita de prueba.

El doctor demo agenda sobre un paciente base. El guard cuenta su cuota por sesión, el service estampa el demoSessionId, y la cita solo será visible para esa sesión.

secuencia · crear cita demo con tope por sesión
sequenceDiagram
  autonumber
  actor Doc as Doctor demo
  participant GR as DemoGuardrailsGuard
  participant SV as AppointmentsService
  participant DB as appointments (public)
  participant FE as DemoGuardrailHandler

  Doc->>GR: POST /appointments (paciente base)
  GR->>DB: count citas demoSessionId = sesión · date = hoy
  alt cuota disponible · 3 de 5
    GR->>SV: ok · pasa
    SV->>DB: INSERT isDemo = true · demoSessionId = sesión
    SV-->>Doc: 201 · cita creada
  else tope alcanzado · 5 de 5
    GR-->>FE: 429 DEMO_LIMIT_REACHED
    FE-->>Doc: toast "llegaste al tope · regístrate"
  end
      
diagrama de secuencia · 2 de 3
El muro: registrar un paciente.

El botón "Nuevo paciente" llama al endpoint real, pero el guard lo corta antes de tocar la base. El front recibe el DEMO_BLOCKED y lo vuelve la invitación a crear cuenta —no un error—.

secuencia · @DemoBlocked en POST /patients
sequenceDiagram
  autonumber
  actor Doc as Doctor demo
  participant GR as DemoGuardrailsGuard
  participant CT as PatientsController
  participant FE as DemoGuardrailHandler

  Doc->>GR: POST /patients  @DemoBlocked
  GR->>GR: req.isDemo === true ?
  GR-->>FE: 403 DEMO_BLOCKED
reason = "register_patient" FE->>FE: intercepta en la cache de React Query FE-->>Doc: modal "esto es parte de Amedi real"
→ Crear mi cuenta Note over CT: el controller nunca se ejecuta
diagrama de secuencia · 3 de 3
Dos doctores, sin pisarse.

Lo no obvio: con un solo doctor demo, dos personas leen la misma agenda. El filtro por demoSessionId hace que cada quien vea solo lo suyo —más los pacientes base compartidos—.

secuencia · lectura de agenda aislada por sesión
sequenceDiagram
  autonumber
  actor A as Visitante A (sesión a3f1)
  actor B as Visitante B (sesión 9c72)
  participant SV as AppointmentsService
  participant DB as appointments (public)

  A->>SV: GET /appointments?date=hoy
  SV->>DB: WHERE doctorId = demo AND isDemo
AND demoSessionId = 'a3f1' DB-->>A: solo las 2 citas de A B->>SV: GET /appointments?date=hoy SV->>DB: WHERE doctorId = demo AND isDemo
AND demoSessionId = '9c72' DB-->>B: solo las 4 citas de B Note over DB: los 8 pacientes base (session = null)
los ven ambos · solo lectura
el riesgo principal · fuga a producción
La matriz anti-fuga.

Como el demo vive en public, el trabajo real es esconderlo de todo lo que ve un paciente real y acotar lo que ve el doctor demo. Esta es la lista de superficies a tocar — cada una es un filtro, no una pantalla nueva.

SuperficieQuién la usaReglaDónde
Directorio de doctores
buscar / explorar médicos
pacienteexcluir isDemoapps/client · doctors.service.search
Búsqueda de clínicas / centrospacienteexcluir isDemoclinics.service · GET /clinics
Perfil público por slug
/doctor/[slug]
paciente404 si isDemoapps/client · doctor profile loader
Agendar desde el lado pacientepacienteno listar doctor demobooking público / QR landing
Métricas y agregados
conteos, dashboards admin
adminexcluir isDemoapps/admin · stats queries
Agenda del consultoriodoctor demoscope por sesiónappointments.service
Lista de pacientesdoctor demosolo base + sin realespatients.service
Chat / conversacionesdoctor demoscope por sesiónchat.service
Registrar paciente · equipo · cobrosdoctor demo@DemoBlockedcontrollers ya decorados
Crear cita · chat saliente · archivosdoctor demo@DemoLimitcontrollers ya decorados
La regla de oro: el default es ocultar
Toda query de cara al paciente arranca con isDemo: false. Conviene un helper compartido (excludeDemo() en el query builder) y un test e2e que falle si un doctor/clínica demo aparece en cualquier endpoint público — la fuga es el único modo de fallo que importa.
seeds
Sembrar la isla en public.

Un seed Prisma normal, idempotente por uid/ids fijos. Cierra el hueco del seed viejo: la membresía DoctorClinic, sin la cual el doctor demo no tenía clínica y la agenda no cuadraba.

prisma/seeds/demo.seed.ts
const DEMO_DOCTOR_ID = "514dce14-2292-4317-82c0-ccd4412ae82b"
const DEMO_CLINIC_ID = "d0000000-0000-4000-a000-000000000001"

export async function seedDemo(prisma) {
  // 1 · clínica demo — invisible para pacientes
  await prisma.clinic.upsert({ where:{ clinicId: DEMO_CLINIC_ID },
    create:{ clinicId: DEMO_CLINIC_ID, name:"Clínica demo", isDemo:true, city:"—" }, update:{ isDemo:true } })

  // 2 · doctor demo — profile + doctor + isDemo
  await prisma.profile.update({ where:{ id: DEMO_DOCTOR_ID }, data:{ isDemo:true } })

  // 3 · membresía (el hueco del seed viejo)
  await prisma.doctorClinic.upsert({
    where:{ doctorId_clinicId:{ doctorId: DEMO_DOCTOR_ID, clinicId: DEMO_CLINIC_ID } },
    create:{ doctorId: DEMO_DOCTOR_ID, clinicId: DEMO_CLINIC_ID, isPrimary:true }, update:{} })

  // 4 · 8 pacientes base — isDemo, demoSessionId = null (compartidos)
  for (const p of BASE_PATIENTS)   // HM-000001 … HM-000008
    await prisma.profile.upsert({ /* …isDemo:true + patient{ medicalHistoryNumber } */ })

  // 5 · disponibilidad lun–vie 09:00–17:00 + citas/consultas de ejemplo
}
limpieza + migración del sistema viejo
Vuelve a cero y se despide del schema.

El reset deja de ser un TRUNCATE del schema entero: ahora es un borrado quirúrgico por sesión, y un cron nocturno que limpia lo que las sesiones dejaron. El reseed.cron ya existe pero está desactivado — se re-habilita apuntando a este borrado.

src/demo/demo-cleanup.service.ts
// reset puntual: una sesión pide "reiniciar mi demo"
async resetSession(sessionId) {
  await prisma.appointment.deleteMany({ where:{ demoSessionId: sessionId } })
  await prisma.consultationRecord.deleteMany({ where:{ demoSessionId: sessionId } })
  await prisma.conversation.deleteMany({ where:{ demoSessionId: sessionId } })
  // los pacientes base (session = null) NO se tocan
}

@Cron("0 4 * * *")   // 04:00 America/Caracas · re-habilitar reseed.cron
async nightlySweep() {
  await prisma.appointment.deleteMany({ where:{ demoSessionId:{ not: null } } })
  // idem consultas + conversaciones · la isla base queda intacta
}
Qué se retira del sistema viejo
Una vez verde el flujo nuevo: borrar DemoSchemaInterceptor (el switch de search_path), scripts/sync-demo-schema.sh, el reseed() por truncate de demo.*, y el bloque schemas = ["public","demo"] de Prisma. El schema demo se dropea en una migración final.
plan de construcción
Cuatro fases, a nivel de archivo.
fase 1
Cimientos
  • Migración: isDemo en profiles/clinics, demoSessionId en appointments + consultas + conversaciones, con índices.
  • prisma/seeds/demo.seed.ts: clínica + doctor + DoctorClinic + 8 pacientes base + disponibilidad.
  • Helper excludeDemo() + filtros en directorio de doctores y clínicas de apps/client. Test e2e anti-fuga.
fase 2
Guardrails + sesión
  • Repuntar identidad: jwt.strategy resuelve el doctor demo desde public (no demo.*); quitar DemoSchemaInterceptor.
  • Scope por demoSessionId en lecturas de agenda, pacientes, chat y consultas del doctor demo.
  • @DemoLimit cuenta citas por sesión + día; entrada por enlace discreto en el login → sesión demo.
fase 3
Experiencia
  • Banda informativa, modal de bienvenida, modal de invitación al bloquear "Nuevo paciente".
  • Picker de pacientes solo-demo + medidor "3 de 5" en el diálogo de cita.
  • Reusar DemoGuardrailHandler para DEMO_BLOCKED / DEMO_LIMIT_REACHED.
fase 4
Pulido + retiro
  • DemoCleanupService: reset por sesión + re-habilitar el cron nocturno.
  • Watermark [demo] en PDFs verificado punta a punta.
  • Retirar schema demo, sync-demo-schema.sh y el reseed por truncate; migración que dropea el schema.
casos borde
Lo que puede salir mal.
El doctor demo aparece en la búsqueda
El modo de fallo que importa. Mitigación: excludeDemo() obligatorio en todo query público + un test e2e que recorra directorio, slug y clínicas y falle si ve isDemo.
Un paciente real intenta agendar con él
No debería poder: el doctor demo nunca se lista del lado paciente. Defensa en profundidad: el booking público rechaza doctorId con isDemo.
¿Cómo persiste la sesión demo?
El demoSessionId sale del JWT/cookie de la sesión Supabase. Al expirar, su data queda huérfana (session ≠ null) y el barrido nocturno la limpia. No hace falta login real.
El tope diario y la zona horaria
El conteo "hoy" usa America/Caracas (igual que el resto de la agenda), no UTC, para que el tope reinicie a medianoche local.
Data demo contaminando analítica
PostHog y los dashboards admin filtran isDemo. El grupo doctor del demo se excluye de funnels y conteos de activación.
Una migración agrega una tabla nueva
Ventaja del enfoque is_demo: no hay schema que sincronizar. Si la tabla la crea el doctor demo, solo se le añade demoSessionId y se incluye en el barrido.