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".
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.
@DemoBlocked({reason}) y @DemoLimit({action,max}) ya existen y ya están puestos en los controllers de pacientes, citas, chat, archivos y pagos.403 DEMO_BLOCKED / 429 DEMO_LIMIT_REACHED. Se queda tal cual.jwt.strategy ya lee app_metadata.is_demo; session-id.ts extrae el demoSessionId; demo-context.ts (AsyncLocalStorage) lo expone en los services.DemoGuardrailHandler escucha la cache de React Query y muestra el toast con CTA a registrarse. Montado global.PdfService ya estampa la marca cuando data.isDemo === true. Se mantiene.is_demo / demo_session_idisDemo en Profile y Clinic; demoSessionId en lo que el demo crea (Appointment, consultas, conversaciones). Migración Prisma normal.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.demoSessionId = sesión actual; los pacientes base quedan null (compartidos, lectura).publicDoctorClinic (hoy falta) + 8 pacientes base + disponibilidad. Prisma normal, no SQL crudo.DemoSchemaInterceptor (search_path), sync-demo-schema.sh y el reseed por truncate. Re-habilitar el cron de limpieza por sesión.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.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.
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)
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
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.
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
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.
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
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—.
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
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—.
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
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.
| Superficie | Quién la usa | Regla | Dónde |
|---|---|---|---|
| Directorio de doctores buscar / explorar médicos | paciente | apps/client · doctors.service.search | |
| Búsqueda de clínicas / centros | paciente | clinics.service · GET /clinics | |
Perfil público por slug/doctor/[slug] | paciente | apps/client · doctor profile loader | |
| Agendar desde el lado paciente | paciente | booking público / QR landing | |
| Métricas y agregados conteos, dashboards admin | admin | apps/admin · stats queries | |
| Agenda del consultorio | doctor demo | scope por sesión | appointments.service |
| Lista de pacientes | doctor demo | solo base + sin reales | patients.service |
| Chat / conversaciones | doctor demo | scope por sesión | chat.service |
| Registrar paciente · equipo · cobros | doctor demo | @DemoBlocked | controllers ya decorados |
| Crear cita · chat saliente · archivos | doctor demo | @DemoLimit | controllers ya decorados |
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.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.
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 }
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.
// 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 }
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.isDemo en profiles/clinics, demoSessionId en appointments + consultas + conversaciones, con índices.prisma/seeds/demo.seed.ts: clínica + doctor + DoctorClinic + 8 pacientes base + disponibilidad.excludeDemo() + filtros en directorio de doctores y clínicas de apps/client. Test e2e anti-fuga.jwt.strategy resuelve el doctor demo desde public (no demo.*); quitar DemoSchemaInterceptor.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.DemoGuardrailHandler para DEMO_BLOCKED / DEMO_LIMIT_REACHED.DemoCleanupService: reset por sesión + re-habilitar el cron nocturno.[demo] en PDFs verificado punta a punta.demo, sync-demo-schema.sh y el reseed por truncate; migración que dropea el schema.excludeDemo() obligatorio en todo query público + un test e2e que recorra directorio, slug y clínicas y falle si ve isDemo.doctorId con isDemo.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.America/Caracas (igual que el resto de la agenda), no UTC, para que el tope reinicie a medianoche local.isDemo. El grupo doctor del demo se excluye de funnels y conteos de activación.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.