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.
DoctorContractSignature: contractVersion, contractHash (sha-256 del HTML), ipAddress, userAgent. Es el molde de LegalAcceptance.MedicalFolder.consentAt + consentTextVersion = 'v1-2026-07'. Patrón de «versión de texto» que se generaliza.Appointment.shareHistoryWithDoctor · historySharedAt · historyRevokedAt · SharedHistoryAccessLog.PatientAccessLog + PatientAccessLogInterceptor (APP_INTERCEPTOR) + @LogsPatientAccess en 36 handlers. Base de «quién vio mi historia» (fase 3).cb007-write-side-audit sobre 11 modelos PHI → audit_logs. ContentReport y LegalAcceptance entran al registro de redacción.Doctor.publishedAt + POST /doctors/me/publish + PUBLIC_REQUIREMENTS (7). Despublicar es el espejo: mismo servicio, mismo guard.SacsService contra sistemas.sacs.gob.ve/consultas/prfsnal_salud; recomputeIsVerified exige ambos proveedores. Solo falta enlazarlo de cara al paciente.POST /profiles/sync (syncFromSupabase) corre en cada alta: el lugar natural para escribir la primera LegalAcceptance.supabaseSsrCookieOptions + cookieDomain() (NEXT_PUBLIC_COOKIE_DOMAIN = .amedisalud.com en prod). La cookie amedi-consent usa el mismo helper.gtag('consent','default',{analytics_storage:'denied'}) antes de interactivo. Se amplía a las 4 señales.company_name / company_address ya se inyectan en Postmark. Pasan a leer de @workspace/legal/company.registry.ts (slug, versión, vigencia, hash), company.ts. Sustituye app/(landing)/terms, /privacy y sus copias en consultorio.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.useConsent + ConsentBanner + ConsentPreferences; cookie JSON amedi-consent. Retira cookie-consent-banner.tsx y la clave localStorage amedi-cookie-consent.PHProvider con opt_out_capturing_by_default, disable_session_recording:true, autocapture:false, respect_dnt:true; identify sin email ni nombre (3 sitios).POST /legal/acceptances + GET /legal/acceptances/me; escritura desde syncFromSupabase y desde el interstitial de cambio de versión.reports: POST /reports público con throttle y honeypot, GET/PATCH /admin/reports, notificación a admins, acuse por correo.POST /doctors/me/unpublish → publishedAt = null, listingOptOutAt = now(); interruptor en el perfil del consultorio./legal/[slug], /ayuda, /reportar, /seguridad, /como-verificamos, app/sitemap.ts; redirects /terms, /privacy, /help.ClinicMapEmbed y APIProvider detrás de «ver mapa» + categoría maps del consentimiento.terms_updated, report_received, report_resolved, profile_unpublished en NotificationType.COMPANY_LEGAL_NAME, COMPANY_RIF, COMPANY_ADDRESS, SUPPORT_WHATSAPP en render.yaml y los ejemplos de entorno; valores aportados por Amedi.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.
/// 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.
{ "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.
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.
| Superficie | Hoy | Riesgo | Después | Dónde |
|---|---|---|---|---|
| identify() | email, first_name, last_name como person properties en browser, callback OAuth y API | PII directa en un procesador de EE. UU. sin consentimiento | Solo profileId, role, locale, environment | identity.ts:18-27 · auth/callback/route.ts:134-140 · profiles.service.ts:591-594 |
| $pageview | history_change captura $current_url completas: slugs de médico, ids de cita, rutas de historia | Un id de cita en una URL es un dato de salud indirecto | capture_pageview:false; el provider emite solo fuera de rutas clínicas y scrubClinicalUrls reemplaza ids por :id | client.tsx:23 |
| Grabación de sesión | No deshabilitada en código; la gobierna el toggle remoto del proyecto | Un replay de una consulta es la historia clínica en vídeo | disable_session_recording:true en las tres apps; además, apagado en el proyecto PostHog | client.tsx · dashboard PostHog |
| Autocaptura | Default true: clicks e inputs con texto | Nombres de pacientes en botones y selects del consultorio | autocapture:false; eventos explícitos de lib/analytics/events.ts con allowlist de propiedades | client.tsx · events.ts |
| doctor_searched | Envía el texto libre query del paciente | «oncólogo para mi mamá» es un dato de salud | Se envía solo specialtyId, city, hasResults; nunca la query | events.ts:4-12 · track.ts:55-60 |
| $exception | capture_exceptions:true + reportError con route y contexto libre | Un error serializa el payload que lo causó | Contexto reducido a route ya scrubbed + code; nunca objetos de dominio | report-error.ts:12-15 |
| Cookie ph_* | cross_subdomain_cookie:true en .amedisalud.com antes de consentir | Identificador persistente sin base | Solo existe tras opt_in_capturing; borrar al pasar a «solo necesarias» | default del SDK |
| GA4 | gtag.js siempre; ID de prod hardcodeado; solo analytics_storage declarado | Pings cookieless en áreas autenticadas; señales de ads en granted | 4 señales en denied; script solo en el grupo (landing); sin fallback de ID | layout.tsx:135-145 |
| Google Maps Embed | iframe de google.com durante la reserva autenticada | Cookies NID de Google en una sesión de paciente | Placeholder «ver mapa»; carga al tocar; categoría maps recuerda la elección | clinic-map-embed.tsx:45-58 |
| MetaMap | Script de terceros con cámara, cargado solo en /verificacion | Biometría sin aviso específico | Aviso en pantalla antes del botón + nombrado en privacidad v2. El scope ya es correcto | metamap-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.
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.