el pie legal · arquitectura · v1.0 · ago 2026

documento técnico · compañero de el pie legal

La máquina detrás del pie legal: lo que ya existe, lo que falta y por dónde pasa cada decisión

Anclado en apps/api, packages/auth y las tres apps tal como están en development el 21 de agosto de 2026. Ledger existe/falta, el diff exacto de Prisma, el ciclo del consentimiento con PostHog y GA detrás del gate, cuatro diagramas de secuencia, la matriz anti-fuga de datos de salud en analítica, las constantes de identidad y las fases archivo por archivo. La página de diseño dice qué; esta dice cómo.

modelos nuevos
3 · LegalAcceptance · ContentReport · TeleconsultConsent
endpoints
6 nuevos · 1 modificado
paquetes
@workspace/legal nuevo · ui ampliado
se retira
banner binario · 2 páginas JSX duplicadas · 4 placeholders

01 ledger · existe / falta

Más de la mitad de la plomería ya está puesta

Antes de diseñar modelos nuevos, lo que el repo ya resuelve. La sorpresa de la auditoría: la capa forense (rastro de firma, bitácoras de lectura y escritura, consentimiento por cita) es sólida. Lo que falta es lo que el usuario puede ver y hacer.

Ya existe y se reutiliza11
✓
Firma versionada con hash e IP
DoctorContractSignature: contractVersion, contractHash (sha-256 del HTML), ipAddress, userAgent. Es el molde de LegalAcceptance.
apps/api/prisma/schema.prisma:901-939 · doctor-contracts.service.ts:31,193
✓
Consentimiento sellado en servidor
MedicalFolder.consentAt + consentTextVersion = 'v1-2026-07'. Patrón de «versión de texto» que se generaliza.
medical-folder-sections.service.ts:40,128-132
✓
Consentimiento por cita, revocable, con bitácora
Appointment.shareHistoryWithDoctor · historySharedAt · historyRevokedAt · SharedHistoryAccessLog.
schema.prisma:1436-1438, 2300-2315 · appointments.controller.ts:394-412
✓
Bitácora de lectura de PHI
PatientAccessLog + PatientAccessLogInterceptor (APP_INTERCEPTOR) + @LogsPatientAccess en 36 handlers. Base de «quién vio mi historia» (fase 3).
common/access-log/patient-access-log.interceptor.ts:36-132
✓
Bitácora de escritura con diff redactado
Extensión Prisma cb007-write-side-audit sobre 11 modelos PHI → audit_logs. ContentReport y LegalAcceptance entran al registro de redacción.
common/audit-log/audit-prisma.extension.ts:12-60 · redaction.ts:75-312
✓
Publicar como acto explícito
Doctor.publishedAt + POST /doctors/me/publish + PUBLIC_REQUIREMENTS (7). Despublicar es el espejo: mismo servicio, mismo guard.
doctors.controller.ts:188-210 · doctors.public-readiness.ts:14-22 · doctors.service.ts:835-878
✓
Verificación SACS + MetaMap
SacsService contra sistemas.sacs.gob.ve/consultas/prfsnal_salud; recomputeIsVerified exige ambos proveedores. Solo falta enlazarlo de cara al paciente.
verification/sacs.service.ts:55 · verification.service.ts:147-231
✓
Sincronización de perfil tras el alta
POST /profiles/sync (syncFromSupabase) corre en cada alta: el lugar natural para escribir la primera LegalAcceptance.
profiles.controller.ts:76-79
✓
Cookies por app con dominio compartido
supabaseSsrCookieOptions + cookieDomain() (NEXT_PUBLIC_COOKIE_DOMAIN = .amedisalud.com en prod). La cookie amedi-consent usa el mismo helper.
packages/auth/src/cookie-options.ts:62-80
✓
Consent Mode inicial de GA
gtag('consent','default',{analytics_storage:'denied'}) antes de interactivo. Se amplía a las 4 señales.
apps/client/app/layout.tsx:135-145
✓
Variables de identidad en correos
company_name / company_address ya se inyectan en Postmark. Pasan a leer de @workspace/legal/company.
apps/api/src/email/email.service.ts:137-138,182-183 · email.constants.ts:41-46
Falta y se construye12
+
@workspace/legal
Paquete nuevo: MDX por documento e idioma, registry.ts (slug, versión, vigencia, hash), company.ts. Sustituye app/(landing)/terms, /privacy y sus copias en consultorio.
packages/legal/ (nuevo)
+
SiteFooter · CompactFooter
En packages/ui/src/components/layout/shared/; enlaces e identidad por props. Montados en /search, perfil (móvil incluido), shells de paciente, doctor, secretaria y admin.
packages/ui · apps/* layouts
+
Consentimiento por categorías
useConsent + ConsentBanner + ConsentPreferences; cookie JSON amedi-consent. Retira cookie-consent-banner.tsx y la clave localStorage amedi-cookie-consent.
packages/ui/src/hooks/consent/ · components/consent/
+
Gate de PostHog y GA
PHProvider con opt_out_capturing_by_default, disable_session_recording:true, autocapture:false, respect_dnt:true; identify sin email ni nombre (3 sitios).
apps/client/lib/analytics/client.tsx:19-39 · identity.ts:18-27 · auth/callback/route.ts:134-140 · profiles.service.ts:591-594
+
LegalAcceptance
Modelo + POST /legal/acceptances + GET /legal/acceptances/me; escritura desde syncFromSupabase y desde el interstitial de cambio de versión.
apps/api/src/legal/ (nuevo)
+
ContentReport + bandeja
Modelo + módulo reports: POST /reports público con throttle y honeypot, GET/PATCH /admin/reports, notificación a admins, acuse por correo.
apps/api/src/reports/ · apps/admin/app/(dashboard)/reportes/
+
Despublicar
POST /doctors/me/unpublish → publishedAt = null, listingOptOutAt = now(); interruptor en el perfil del consultorio.
doctors.controller.ts · doctors.service.ts · consultorio/components/doctor-profile/
+
TeleconsultConsent
Paso del wizard de reserva cuando la modalidad es virtual; fila por cita con versión, fecha, IP. Ley de Telesalud art. 12.
apps/api/src/appointments/ · apps/client/components/booking/steps/
+
Rutas públicas nuevas
/legal/[slug], /ayuda, /reportar, /seguridad, /como-verificamos, app/sitemap.ts; redirects /terms, /privacy, /help.
apps/client/app/(landing)/ · apps/consultorio/app/legal/
+
Mapa bajo demanda
ClinicMapEmbed y APIProvider detrás de «ver mapa» + categoría maps del consentimiento.
packages/ui/src/components/common/clinic-map-embed.tsx:45-58 · search-map-view.tsx:82
+
Notificaciones nuevas
terms_updated, report_received, report_resolved, profile_unpublished en NotificationType.
schema.prisma:144-162
+
Identidad real en variables de entorno
COMPANY_LEGAL_NAME, COMPANY_RIF, COMPANY_ADDRESS, SUPPORT_WHATSAPP en render.yaml y los ejemplos de entorno; valores aportados por Amedi.
render.yaml · apps/* · ejemplos de entorno

02 modelo de datos

Tres modelos nuevos, dos campos, cuatro tipos de notificación

Todo en public, con @@map en snake_case como el resto del schema, y todos con ipAddress @db.Inet + userAgent porque la prueba de aceptación sin contexto no prueba nada. Ningún modelo guarda el texto: guarda versión y hash, y el texto vive versionado en @workspace/legal.

apps/api/prisma/schema.prisma · diff
/// Aceptación de un documento legal por un perfil. Una fila por documento y versión.
model LegalAcceptance {
  id          String   @id @default(uuid())
  profileId   String
  profile     Profile  @relation(fields: [profileId], references: [id], onDelete: Cascade)
  document    LegalDocument
  version     String   @db.VarChar(20)   // "2.0" — la del registry en el momento de aceptar
  contentHash String   @db.VarChar(64)   // sha-256 del MDX renderizado, como el contrato
  locale      String   @db.VarChar(5)    // "es" | "en": qué texto leyó
  source      LegalAcceptanceSource
  acceptedAt  DateTime @default(now())
  ipAddress   String?  @db.Inet
  userAgent   String?  @db.Text
  @@unique([profileId, document, version])
  @@index([document, version])
  @@map("legal_acceptances")
  @@schema("public")
}
enum LegalDocument         { terminos  privacidad  cookies  telesalud }
enum LegalAcceptanceSource { signup  oauth_callback  interstitial  booking }

/// Reporte público de contenido o conducta. El reportante puede no tener cuenta.
model ContentReport {
  id                String   @id @default(uuid())
  publicRef         String   @unique @db.VarChar(12)  // "RPT-7F3K2Q" — el número del acuse
  targetType        ReportTargetType
  targetId          String?                          // doctorId · reviewId · conversationId
  targetUrl         String?  @db.Text
  reason            ReportReason
  description       String   @db.Text
  reporterEmail     String?  @db.VarChar(255)
  reporterProfileId String?
  reporterProfile   Profile? @relation(fields: [reporterProfileId], references: [id], onDelete: SetNull)
  status            ReportStatus @default(received)
  slaDueAt          DateTime                         // +6 h si reason = odio · +72 h el resto
  resolvedAt        DateTime?
  resolvedById      String?
  resolutionKind    ReportResolution?
  resolutionNote    String?  @db.Text
  ipAddress         String?  @db.Inet
  userAgent         String?  @db.Text
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt
  @@index([status, slaDueAt])
  @@index([targetType, targetId])
  @@map("content_reports")
  @@schema("public")
}
enum ReportTargetType { doctor_profile  review  chat_message  impersonation  other }
enum ReportReason     { odio  ilegal  suplantacion  datos_falsos  contenido_ofensivo  spam  otro }
enum ReportStatus     { received  in_review  resolved  dismissed }
enum ReportResolution { content_removed  profile_unpublished  warning_sent  no_action  escalated }

/// Consentimiento informado de telesalud, por cita virtual (Ley de Telesalud art. 12).
model TeleconsultConsent {
  id             String      @id @default(uuid())
  appointmentId  String      @unique
  appointment    Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
  patientId      String
  version        String      @db.VarChar(20)
  contentHash    String      @db.VarChar(64)
  locale         String      @db.VarChar(5)
  guardianName   String?     @db.VarChar(255)   // representante legal si el paciente es menor
  acceptedAt     DateTime    @default(now())
  ipAddress      String?     @db.Inet
  userAgent      String?     @db.Text
  @@index([patientId])
  @@map("teleconsult_consents")
  @@schema("public")
}

model Doctor {
  // … campos existentes …
  publishedAt      DateTime?   // ya existe · null = no aparece en búsqueda
+ listingOptOutAt  DateTime?   // nuevo · cuándo el doctor pidió salir; sobrevive a una nueva publicación como rastro
}

model Profile {
  // … campos existentes …
+ analyticsOptIn  Boolean?    // espejo server-side de la cookie, sincronizado en cada login; lo consulta AnalyticsService
}

enum NotificationType {
  // … existentes …
+ terms_updated
+ report_received
+ report_resolved
+ profile_unpublished
}
erDiagram
  Profile ||--o{ LegalAcceptance : acepta
  Profile ||--o{ ContentReport : reporta
  Appointment ||--o| TeleconsultConsent : consiente
  Doctor ||--o{ ContentReport : es_objetivo
  LegalAcceptance {
    uuid id
    enum document
    string version
    string contentHash
    enum source
    datetime acceptedAt
  }
  ContentReport {
    uuid id
    string publicRef
    enum targetType
    enum reason
    enum status
    datetime slaDueAt
  }
  TeleconsultConsent {
    uuid id
    string version
    string guardianName
    datetime acceptedAt
  }
    

Relaciones nuevas. ContentReport.targetId no lleva FK dura porque el objetivo puede ser un perfil ya borrado o una URL externa de suplantación; la integridad se resuelve en el servicio.

Migración sin drama

Tres CREATE TABLE, dos ALTER TABLE (doctors.listing_opt_out_at, profiles.analytics_opt_in), cuatro valores nuevos en el enum. Nada se backfillea: los usuarios existentes no tienen aceptación registrada y el interstitial de la fase 2 se la pide la próxima vez que entran (una sola vez por versión). Los tres modelos entran al registro de redacción de audit-log/redaction.ts con description y resolutionNote como campos PHI-posibles (hash, no texto).

03 el ciclo del consentimiento

Nada se mide antes de que exista la cookie

Hoy PHProvider llama posthog.init en el primer useEffect del árbol y GA inyecta gtag.js en el layout raíz. El orden nuevo: el servidor lee amedi-consent, el provider arranca en opt_out, el banner solo aparece si no hay cookie, y la decisión viaja a las dos apps por el dominio compartido.

amedi-consent · la cookie (dominio .amedisalud.com · SameSite=Lax · 365 días)
{ "v": 1, "analytics": false, "maps": false, "ts": "2026-08-21T14:02:11Z" }

// v sube solo si cambian las categorías. Cambiar copy no vuelve a preguntar.
// necesarias no se serializa: siempre es true y no se puede apagar.
// Sin cookie = sin decisión = todo apagado salvo necesarias.
flowchart TD
  A[Request a client o consultorio] --> B{Cookie amedi-consent}
  B -->|no existe| C[Provider en opt_out
GA en denied x4] C --> D[Render con ConsentBanner] D --> E{Decisión} E -->|solo necesarias| F[Escribe cookie analytics false] E -->|aceptar medición| G[Escribe cookie analytics true] E -->|configurar| H[ConsentPreferences] H --> F H --> G B -->|existe| I[Provider lee categorías] G --> J[posthog.opt_in_capturing
gtag consent update granted] I -->|analytics true| J I -->|analytics false| K[Sigue en opt_out] F --> K J --> L{Ruta clínica} L -->|patient doctor secretary| M[autocapture off
replay off siempre
solo eventos explícitos] L -->|marketing y búsqueda| N[pageview y eventos explícitos
replay off siempre]

La grabación de sesión no depende de ninguna rama: disable_session_recording: true en la configuración, en las tres apps. La rama «ruta clínica» solo decide si además hay autocaptura.

sequenceDiagram
  autonumber
  participant U as Paciente
  participant N as Next server
  participant C as ConsentProvider
  participant P as PostHog SDK
  participant G as gtag
  U->>N: GET /search
  N->>N: lee cookie amedi-consent (ausente)
  N-->>U: HTML con banner y provider en opt_out
  P->>P: init con opt_out_capturing_by_default
  G->>G: consent default denied (4 señales)
  U->>C: toca Aceptar medición
  C->>C: set-cookie amedi-consent analytics true (dominio compartido)
  C->>P: opt_in_capturing y register environment
  C->>G: consent update analytics_storage granted
  P-->>P: pageview de la ruta actual
  U->>N: abre consultorio.amedisalud.com (doctor)
  N->>N: lee la misma cookie: analytics true
  N-->>U: sin banner, provider ya en opt_in
  Note over P: en /doctor/* autocapture off y replay off
    

Una sola decisión para client y consultorio. El admin no carga trackers; monta CompactFooter con enlace a preferencias para que la regla sea visible, pero el banner no aparece porque no hay nada que consentir.

apps/client/lib/analytics/client.tsx · apps/consultorio/lib/analytics/client.tsx (el mismo archivo, deduplicado a packages/ui/hooks/consent)
posthog.init(token, {
  api_host: '/ingest',
  ui_host: 'https://us.posthog.com',
  defaults: '2026-01-30',
  opt_out_capturing_by_default: true,   // nada sale hasta opt_in_capturing()
  disable_session_recording: true,      // regla, no preferencia
  autocapture: false,                   // solo eventos explícitos de lib/analytics/events.ts
  respect_dnt: true,
  capture_pageview: false,              // el provider emite $pageview solo fuera de rutas clínicas
  capture_exceptions: true,             // sigue: $exception sin contexto libre (ver matriz)
  before_send: scrubClinicalUrls,       // reemplaza ids de cita y slugs por tokens
});

// identity.ts · antes: { email, first_name, last_name } · después:
posthog.identify(profileId, { role, locale, environment });

04 aceptación registrada

El checkbox deja de ser decorativo: una fila por documento y versión

Hoy signUp() descarta terms. La fila se escribe en el mismo POST /profiles/sync que ya corre tras cada alta, con la versión que el cliente mostró. Para OAuth, el callback de Google pasa por el mismo camino con source = oauth_callback. Cuando el registry sube de versión, un interstitial de una sola vez pide la nueva aceptación.

sequenceDiagram
  autonumber
  participant U as Paciente
  participant W as client (server action)
  participant S as Supabase Auth
  participant A as apps/api
  participant L as registry @workspace/legal
  U->>W: envía registro con checkbox marcado
  W->>L: versión y hash vigentes de terminos y privacidad
  W->>S: signUp con user_metadata accepted_terms_version
  S-->>W: usuario creado
  W->>A: POST /profiles/sync con legal { terminos 2.0, privacidad 2.0, locale }
  A->>A: upsert Profile
  A->>A: create LegalAcceptance x2 (source signup, ip, userAgent)
  A-->>W: 201
  Note over U,A: semanas después el registry pasa privacidad a 2.1
  U->>W: abre /patient
  W->>A: GET /legal/acceptances/me
  A-->>W: terminos 2.0 ok · privacidad 2.0 desactualizada
  W-->>U: interstitial Actualizamos la política (una vez)
  U->>W: acepta
  W->>A: POST /legal/acceptances { privacidad 2.1, source interstitial }
  A->>A: notificación terms_updated marcada como leída
    

El registry es la única fuente de «qué versión está vigente»: packages/legal/src/registry.ts exporta { slug, version, effectiveFrom, hash } calculado en build a partir del MDX. El API lo importa como paquete del workspace; no hay tabla de versiones.

Qué cuenta como aceptación

Solo un acto: checkbox en el alta, botón del interstitial, o el paso de consentimiento de la reserva. Navegar no acepta nada. La línea pasiva «al continuar aceptas» del login se mantiene como aviso, no como prueba.

Qué dispara el interstitial

Solo cambios de versión mayor (2.0 → 3.0). Un cambio menor (2.0 → 2.1: erratas, un proveedor más) registra la versión nueva pero no interrumpe; la notificación terms_updated informa sin bloquear.

Doctores y secretarias

Mismo mecanismo: el alta del consultorio pasa a llevar checkbox explícito (hoy solo la línea pasiva). El contrato de doctores conserva su propio rastro (DoctorContractSignature) — no se fusiona.

Telesalud

TeleconsultConsent es por cita, no por usuario: cada videoconsulta nueva vuelve a preguntar, como exige un consentimiento informado. El texto se muestra completo en el paso, no detrás de un enlace.

05 reportar un problema

Público, con acuse numerado y un reloj que corre

El formulario no exige cuenta: la Ley contra el Odio obliga a retirar en 6 horas lo que cualquiera reporte. El endpoint es @Public() con @Throttle propio (5 por IP y hora), un honeypot de campo oculto, y devuelve un publicRef que el reportante recibe por correo si dejó uno.

sequenceDiagram
  autonumber
  participant R as Reportante (sin cuenta)
  participant W as client /reportar
  participant A as apps/api reports
  participant DB as Postgres
  participant N as notifications
  participant AD as admin /reportes
  R->>W: tipo, enlace, descripción, correo opcional
  W->>A: POST /reports (Public, Throttle 5 por hora, honeypot vacío)
  A->>A: resuelve targetId desde targetUrl si es un perfil de Amedi
  A->>A: slaDueAt = ahora + 6 h si reason odio, si no + 72 h
  A->>DB: insert ContentReport status received
  A->>N: report_received a todos los ADMIN
  A-->>W: 201 { publicRef RPT-7F3K2Q, slaDueAt }
  W-->>R: Recibimos tu reporte · número · plazo
  A-)R: correo de acuse (si dejó correo)
  AD->>A: GET /admin/reports?status=received orden slaDueAt
  AD->>A: PATCH /admin/reports/:id { status in_review }
  AD->>A: PATCH /admin/reports/:id { status resolved, resolutionKind profile_unpublished, note }
  A->>A: si profile_unpublished: doctors.unpublish(targetId) con motivo admin
  A->>N: report_resolved al reportante si tiene perfil
  A-)R: correo de cierre con la decisión
    

La bandeja del admin ordena por slaDueAt y pinta en ámbar lo que vence en menos de 2 horas. Un cron de 15 minutos marca sla_breached en el log y avisa por Slack (canal de operaciones) — no cambia el estado, solo hace ruido.

Lo que el reporte no hace solo

Ninguna resolución es automática: retirar una reseña, despublicar un perfil o bloquear un chat lo decide una persona en la bandeja. El sistema garantiza que el reloj sea visible y que la decisión quede escrita (resolvedById, resolutionNote); no garantiza el criterio. Para reseñas, la resolución content_removed pone Review.isVisible = false — la columna ya existe y hoy nadie la escribe.

06 despublicar

El espejo de publicar, sin tocar la verificación

sequenceDiagram
  autonumber
  participant D as Doctor (consultorio)
  participant A as apps/api doctors
  participant DB as Postgres
  participant S as búsqueda pública
  D->>A: POST /doctors/me/unpublish (PrimaryAuthorizedDoctorId)
  A->>DB: update Doctor set publishedAt null, listingOptOutAt now
  A->>A: notificación profile_unpublished (confirmación al doctor)
  A-->>D: 200 ProfileCompletionDto con published false
  Note over D,S: las citas ya agendadas siguen — el perfil por URL directa muestra «no acepta citas nuevas»
  S->>DB: search prefiltra publishedAt not null
  DB-->>S: el doctor ya no aparece
  D->>A: POST /doctors/me/publish (más tarde)
  A->>A: valida las 7 PUBLIC_REQUIREMENTS otra vez
  A->>DB: publishedAt now · listingOptOutAt se conserva
    

Mismo guard que publicar (@SkipPermissions + @SecretaryEndpoint + @PrimaryAuthorizedDoctorId): la secretaria autorizada puede despublicar igual que publica. isVerified no se toca — despublicarse no es perder la licencia.

Perfil por URL directa

El slug público sigue resolviendo para no romper enlaces compartidos, pero sin botón de reserva y con el aviso «Este profesional no está aceptando citas nuevas por Amedi». isDoctorPublic pasa a distinguir listado (búsqueda) de visible (URL).

Oposición de terceros

Quien no es usuario y aparece como clínica, secretaria o en una reseña usa /reportar con targetType impersonation o other. No hay un segundo formulario: el canal de reporte ya es el de oposición.

07 matriz anti-fuga

Por dónde se escapa hoy un dato de salud hacia la analítica

PostHog corre detrás de un proxy first-party (/ingest) que evade bloqueadores; eso obliga a que el gate sea nuestro. Cada fila es una superficie verificada en el código, con lo que sale hoy y lo que sale después.

SuperficieHoyRiesgoDespuésDónde
identify()email, first_name, last_name como person properties en browser, callback OAuth y APIPII directa en un procesador de EE. UU. sin consentimientoSolo profileId, role, locale, environmentidentity.ts:18-27 · auth/callback/route.ts:134-140 · profiles.service.ts:591-594
$pageviewhistory_change captura $current_url completas: slugs de médico, ids de cita, rutas de historiaUn id de cita en una URL es un dato de salud indirectocapture_pageview:false; el provider emite solo fuera de rutas clínicas y scrubClinicalUrls reemplaza ids por :idclient.tsx:23
Grabación de sesiónNo deshabilitada en código; la gobierna el toggle remoto del proyectoUn replay de una consulta es la historia clínica en vídeodisable_session_recording:true en las tres apps; además, apagado en el proyecto PostHogclient.tsx · dashboard PostHog
AutocapturaDefault true: clicks e inputs con textoNombres de pacientes en botones y selects del consultorioautocapture:false; eventos explícitos de lib/analytics/events.ts con allowlist de propiedadesclient.tsx · events.ts
doctor_searchedEnvía el texto libre query del paciente«oncólogo para mi mamá» es un dato de saludSe envía solo specialtyId, city, hasResults; nunca la queryevents.ts:4-12 · track.ts:55-60
$exceptioncapture_exceptions:true + reportError con route y contexto libreUn error serializa el payload que lo causóContexto reducido a route ya scrubbed + code; nunca objetos de dominioreport-error.ts:12-15
Cookie ph_*cross_subdomain_cookie:true en .amedisalud.com antes de consentirIdentificador persistente sin baseSolo existe tras opt_in_capturing; borrar al pasar a «solo necesarias»default del SDK
GA4gtag.js siempre; ID de prod hardcodeado; solo analytics_storage declaradoPings cookieless en áreas autenticadas; señales de ads en granted4 señales en denied; script solo en el grupo (landing); sin fallback de IDlayout.tsx:135-145
Google Maps Embediframe de google.com durante la reserva autenticadaCookies NID de Google en una sesión de pacientePlaceholder «ver mapa»; carga al tocar; categoría maps recuerda la elecciónclinic-map-embed.tsx:45-58
MetaMapScript de terceros con cámara, cargado solo en /verificacionBiometría sin aviso específicoAviso en pantalla antes del botón + nombrado en privacidad v2. El scope ya es correctometamap-button.tsx:6,126

Cómo se verifica la puerta de la fase 1

Con la pestaña de red en una sesión limpia: cero requests a /ingest y a google-analytics.com antes de tocar el banner; tras «solo necesarias», sigue en cero; tras «aceptar medición», aparecen y ninguna lleva email ni nombre. En /doctor/* con medición aceptada: solo eventos nombrados, ningún $autocapture, ningún $snapshot.

08 identidad

Una constante, que falla en voz alta si está vacía

Hoy hay cuatro copyrights distintos, dos correos de soporte, tres dominios obsoletos y ningún RIF. La identidad pasa a un solo módulo que leen los footers, Postmark, los PDFs y las ranuras del contrato. Si falta un valor en producción, el build no inventa: marca la ranura y lo registra.

packages/legal/src/company.ts
export const COMPANY = {
  brand:        'Amedi',
  legalName:    'Altiora Tech, C.A.',
  rif:          requireInProd('COMPANY_RIF'),        // "J-XXXXXXXX-X" · lo aporta Amedi
  address:      requireInProd('COMPANY_ADDRESS'),    // calle, municipio, Caracas
  city:         'Caracas, Venezuela',
  supportEmail: 'soporte@amedisalud.com',
  supportWhatsapp: optional('SUPPORT_WHATSAPP'),    // si falta, el enlace no se renderiza
  domains: ['amedisalud.com', 'consultorio.amedisalud.com', 'admin.amedisalud.com', 'api.amedisalud.com'],
  social: { instagram: 'https://instagram.com/amedisalud' },   // solo las reales
  hours: 'lunes a viernes, 8:00 a. m. a 6:00 p. m. (GMT-4)',
} as const;

// requireInProd: en producción lanza en el arranque si la variable falta;
// en dev devuelve el marcador visible "J-•••••••••-•" para que la ranura se vea, no se tape.

Quién lo consume

SiteFooter y CompactFooter (props desde cada app) · email.service.ts (company_name, company_address, nuevo company_rif) · pdf.constants.ts · las ranuras [razón social] y RIF J-________ de doctor-contract.html · la invitación de Google Calendar (hoy dice https://amedi.com).

Lo que se retira

teaser-content.ts:14-16 (copyright fijo), SOCIAL_LINKS con yourcompany, la clave muerta consultorio.login.brandPanel.copyright, DEFAULT_COMPANY_INFO con info@amedi.com, el wa.me/584241234567 del diálogo de soporte y soporte@amedi.com en las dos claves i18n del consultorio.

09 fases · archivo por archivo

Tres fases, y cada una se puede mergear sola

Fase 1 el pie y la verdad

nuevo
  • packages/legal/ · package.json, src/registry.ts, src/company.ts, src/documents/{terminos,privacidad,cookies,aviso-legal}/{es,en}.mdx
  • packages/ui/src/components/layout/shared/site-footer.tsx · compact-footer.tsx
  • packages/ui/src/components/consent/consent-banner.tsx · consent-preferences.tsx
  • packages/ui/src/hooks/consent/use-consent.ts · consent-cookie.ts
  • apps/client/app/(landing)/legal/[slug]/page.tsx · apps/consultorio/app/legal/[slug]/page.tsx
  • apps/client/app/(landing)/ayuda/page.tsx · app/sitemap.ts
modificado
  • apps/client/lib/analytics/client.tsx · identity.ts · events.ts · track.ts (y los espejos en consultorio)
  • apps/client/app/auth/callback/route.ts:134-140 · apps/api/src/profiles/profiles.service.ts:591-594 (identify sin PII)
  • apps/client/app/layout.tsx (GA solo en landing, 4 señales denied, banner nuevo)
  • search-page-content.tsx · landing-doctor-profile.tsx:120 · patient-shell.tsx · doctor-desktop-shell.tsx · secretary-*-shell.tsx · admin DashboardLayoutClient.tsx (montar footers)
  • packages/ui/src/components/common/clinic-map-embed.tsx · search-map-view.tsx (ver mapa)
  • patient-navigation.config.ts:97-102 (/help → /ayuda) · next.config.mjs redirects /terms /privacy
  • apps/api/src/email/email.constants.ts · email.service.ts · pdf.constants.ts · assets/contracts/doctor-contract.html (identidad)
  • doctor-support-dialog.tsx:41-64 · consultorio es.json:486,1757 · constants/landing/social-links.ts
  • render.yaml + ejemplos de entorno: COMPANY_RIF, COMPANY_ADDRESS, SUPPORT_WHATSAPP
se retira
  • apps/client/components/common/cookie-consent-banner.tsx
  • apps/client/app/(landing)/terms · /privacy · apps/consultorio/app/terms · /privacy (reemplazados por redirects)
  • apps/client/constants/teaser/teaser-content.ts:14-16 (copyright fijo)

● nuevo · ● modificado · ■ se retira

Fase 2 lo que pueden hacer

nuevo
  • prisma/migrations/…_legal_acceptances_content_reports_teleconsult/migration.sql
  • apps/api/src/legal/ · legal.module.ts, legal.controller.ts (POST, GET me), legal.service.ts, dto/
  • apps/api/src/reports/ · reports.module.ts, reports.controller.ts (POST público, GET/PATCH admin), reports.service.ts, sla.cron.ts, dto/
  • apps/admin/app/(dashboard)/reportes/page.tsx · components/reports/* · hooks/reports/use-reports.ts
  • apps/client/app/(landing)/reportar/page.tsx · seguridad/page.tsx · como-verificamos/page.tsx
  • packages/legal/src/documents/{como-ordenamos,seguridad,telesalud}/{es,en}.mdx
  • apps/client/components/booking/steps/step-teleconsult-consent.tsx
  • apps/client/components/legal/terms-update-interstitial.tsx (y espejo en consultorio)
  • apps/consultorio/components/doctor-profile/listing-toggle.tsx
modificado
  • schema.prisma (3 modelos, listingOptOutAt, 4 NotificationType) · common/audit-log/redaction.ts
  • doctors.controller.ts (POST me/unpublish) · doctors.service.ts (unpublishProfile) · doctors.public-readiness.ts (listado vs visible)
  • profiles.controller.ts:76 SyncProfileDto + profiles.service.ts syncFromSupabase (escribe LegalAcceptance)
  • apps/client/lib/auth/actions.ts:49-71 · consultorio/lib/auth/actions.ts:247-250 (versión aceptada en metadata)
  • register-step-account.tsx:234-248 (checkbox explícito en consultorio)
  • reviews.service.ts (isVisible escrito por resolución) · doctor-trust-block.tsx:55-62 (matrícula enlazada + tooltip)
  • search-page-content.tsx (enlace «¿Por qué este orden?») · packages/ai-knowledge doctor-profile.ts:37 (corregir la regla falsa)

Puerta: reporte de prueba con acuse · doctor oculto fuera de /search · alta nueva con fila en legal_acceptances

Fase 3 derechos que hoy son promesa

nuevo
  • apps/api/src/me/me-data.controller.ts · GET /me/export (paquete JSON + PDFs firmados) · GET /me/access-log
  • apps/api/src/profiles/erasure.service.ts (anonimización: email, phone, nationalIdNumber; historia conservada por FK Restrict)
  • apps/api/src/notifications/preferences.controller.ts sobre NotificationPreference
  • apps/client/app/patient/profile/(security|notifications|data)/ — hoy «próximamente»
  • docs/operations/dsar-runbook.md
modificado
  • profile-tabs.tsx:27 (habilitar seguridad) · security-tab.tsx:274-297 (botón real en vez de mailto)
  • reminders-scheduler.service.ts:313-324 (WhatsApp tras opt-in) · whatsapp inbound (palabra de baja)
  • packages/legal privacidad → v3: quita «por soporte» de acceso, exportación, eliminación y preferencias

Puerta: cada derecho de la política tiene botón o endpoint

10 casos borde y retiro

Lo que se rompe si no se piensa antes

Cookie en local y en preview

cookieDomain() devuelve vacío fuera de prod, así que en localhost la cookie es host-only y client y consultorio no la comparten. Es el mismo comportamiento que las cookies de sesión hoy; se documenta, no se parchea.

Rechazó y luego inició sesión

identify solo corre si analytics es true. Con «solo necesarias», el usuario logueado no existe para PostHog; los eventos server-side del API (AnalyticsService) pasan a consultar la preferencia guardada en Profile.analyticsOptIn (sincronizada desde la cookie en cada login) antes de emitir.

Cambio de categorías

Subir v en la cookie invalida la decisión y vuelve a mostrar el banner. Un cambio de copy no sube v. La política de cookies lleva la fecha de la última versión de categorías.

Reportes en ráfaga

Throttle por IP (5/h) + honeypot + longitud mínima de descripción. Si el mismo targetId acumula 3 reportes abiertos, la bandeja lo sube al tope con una marca, pero sigue sin acción automática.

Despublicar con citas futuras

Las citas agendadas no se cancelan: despublicar es «no acepto nuevas», no «cierro». La notificación al doctor lo dice en esas palabras. Si quiere cancelar, es otro flujo que ya existe.

Alta con Google sin checkbox

El callback OAuth no tiene formulario. La página /auth/continue muestra el aviso con la versión vigente y un botón «Continuar y aceptar» antes de entrar; source = oauth_callback. Sin ese clic no se crea el perfil.

Sitemap y perfiles

app/sitemap.ts lista solo doctores con publishedAt not null y isVerified; un perfil despublicado sale del sitemap en la siguiente regeneración (ISR 1 h) aunque su URL siga resolviendo.

Demo

El doctor demo no puede ser objetivo de reportes (EXCLUDE_DEMO_DOCTOR) ni escribe LegalAcceptance; sus sesiones ya se excluyen de la bitácora y del mismo modo se excluyen de la analítica.

Redirects que no se pueden saltar

/terms y /privacy están en correos ya enviados, en el checkbox del registro y en TEASER_ALLOWED_PATHS del middleware. Se conservan como redirects 308 a /legal/terminos y /legal/privacidad durante al menos un año; el middleware del teaser añade /legal/* a la lista permitida. /help → /ayuda igual.

Lo forense ya estaba. Lo que se construye es lo que se ve.

Tres tablas, un campo, cuatro notificaciones, un paquete de textos y dos componentes de pie. El resto del trabajo es quitar: el banner binario, las páginas duplicadas, los placeholders, el email en identify. La página de diseño muestra cómo se ve cada pieza; esta página es el contrato para construirla.

el pie legal · diseñoarquitecturagoal-phases