Skip to content

PRD — Dominio 07: discovery

Búsqueda y descubrimiento: permite a Sofía encontrar lo que necesita por categoría, ubicación, disponibilidad y reputación. Mantiene índices denormalizados alimentados por eventos de otros dominios. No es fuente de verdad de nada — es una vista optimizada para lectura.


1. Propósito

Convertir la estructura normalizada del dominio (Accounts, Branches, Professionals, BranchServices) en índices orientados a búsqueda que respondan rápido a las consultas típicas del cliente final:

  • "Manicura cerca de mí, disponible este sábado, con rating ≥ 4."
  • "Maquilladora en Chapinero que haga a domicilio."
  • "Glamour Studio Usaquén — mostrame todo lo que ofrecen."

El dominio mantiene tres vistas de búsqueda:

  1. Servicios (BranchService aprobados + activos) — el pin del mapa, el resultado principal.
  2. Profesionales (Professional con perfil público) — la reputación portable.
  3. Sedes (Branch físicas públicas) — el salón completo.

discovery no crea ni modifica datos operativos — solo los indexa, rankea y sirve.

2. Entidades

EntidadResponsabilidad
ServiceIndexDocumento por BranchService aprobado+activo. Contiene datos denormalizados + agregados.
ProfessionalIndexDocumento por Professional con perfil público. Agrega sedes donde trabaja, servicios ofrecidos, rating portable.
BranchIndexDocumento por Branch física pública. Agrega servicios disponibles, profesionales activos, rating.
SearchEventLog de búsquedas y clicks (para analytics y señales de ranking).

Estrategia de almacenamiento (MVP)

  • Postgres + PostGIS + GIN/GIST como backend del índice. Nada de Elasticsearch/Meili/Typesense en MVP.
  • tsvector para full-text en español (dict spanish), con índice GIN.
  • geography(Point, 4326) con GIST para consultas por distancia.
  • Vistas materializadas o tablas físicas escritas por workers — no CTEs caras por request.

Razón: el dataset de MVP cabe cómodo en Postgres con índices apropiados. Agregar un motor externo duplica superficie operativa, coste y posibles inconsistencias. Cuando la búsqueda full-text se vuelva cuello de botella (> 100k servicios activos o > 100 QPS de search), se migra a un motor dedicado; hasta entonces Postgres alcanza.

Estructura de ServiceIndex (campos clave)

id                        (= branch_service_id)
branch_service_id
branch_id
business_id
professional_account_id
professional_membership_id

service_name              (copiado de ProfessionalService / template)
service_category
description_tsvector      (GIN)
price, currency
duration_minutes

branch_name
branch_geo                (geography Point — GIST)
branch_city, branch_country
branch_timezone
branch_is_virtual         (bool — true = profesional a domicilio / independiente)
branch_kind               (physical | virtual)

professional_display_name
professional_rating_avg   (denorm de reviews)
professional_reviews_count
professional_portable_score (señal de reputación portable — ver RN-5)

branch_rating_avg
branch_reviews_count

availability_flag_24h     (bool: ¿tiene algún slot en próximas 24h?)
availability_flag_7d      (bool: ¿algún slot en próximos 7d?)
availability_refreshed_at (timestamp de último refresh de flags)

popularity_score          (ver RN-6)
updated_at                (última vez que el documento fue reescrito)

Relaciones lógicas

BranchService (catalog, approved+active) ──► ServiceIndex (1:1)
Professional (accounts, public)           ──► ProfessionalIndex (1:1)
Branch (accounts, physical + public)      ──► BranchIndex (1:1)

Branches virtual no aparecen en BranchIndex — no son un destino físico buscable. Sus servicios sí aparecen en ServiceIndex con branch_is_virtual=true (para el filtro "a domicilio").

3. Historias de usuario

  • U-1. Como Sofía, quiero buscar "manicura" en el mapa cerca de mi ubicación y ver opciones ordenadas por cercanía + rating.
  • U-2. Como Sofía, quiero filtrar por precio, categoría, disponibilidad próxima (hoy / esta semana) y rating mínimo.
  • U-3. Como Sofía, quiero escribir "mak" y que el autocomplete me sugiera "maquillaje", "maquillaje social", etc.
  • U-4. Como Sofía, quiero tocar "a domicilio" para ver solo profesionales que van a mi zona.
  • U-5. Como Sofía, quiero ver la ficha de Glamour Studio con todos sus servicios activos en una sola vista.
  • U-6. Como Sofía, quiero buscar a María por nombre y ver su perfil portable: dónde atiende, qué hace, qué opinan de ella.
  • U-7. Como Carlos, cuando actualizo el horario o bloqueo mi sede, los flags de disponibilidad en discovery reflejan el cambio en minutos, no en horas.
  • U-8. Como Carlos, cuando apruebo un servicio nuevo, aparece en los resultados de búsqueda rápido (< 1 min).
  • U-9. Como María, cuando termino asociación con Glamour, mis servicios en esa sede desaparecen de resultados casi inmediatamente.
  • U-10. Como Diego (platform admin), puedo excluir un documento del índice si el contenido viola políticas (sin borrar los datos operativos).

4. Requerimientos funcionales

Consumo de eventos (mantenimiento del índice)

  • RF-1 branch_service.approved (de catalog) → upsert ServiceIndex con todos los datos denormalizados (join a ProfessionalService, ProfessionalMembership, Branch, Account, rating de reviews).
  • RF-2 branch_service.rejected, branch_service.archived, o cambio a active=false/pauseddelete del ServiceIndex (el servicio no debe aparecer). En paused: delete. Cuando se reanude → evento re-indexa.
  • RF-3 branch_services.bulk_archived (de catalog, originado por membership.ended) → delete masivo.
  • RF-4 branch_service.price_changed → upsert de campos de precio.
  • RF-5 branch.updated (nombre, timezone, geo, kind) → reescribir todos los ServiceIndex y el BranchIndex de esa branch. Idem si la branch se archiva.
  • RF-6 professional.updated (perfil) → reescribir todos los ServiceIndex del profesional y su ProfessionalIndex.
  • RF-7 review.created, review.updated, review.deleted (de reviews) → recalcular agregados y escribir en los documentos afectados (ServiceIndex, ProfessionalIndex, BranchIndex).
  • RF-8 booking.confirmed / booking.cancelled / booking.completed / booking.no_show → invalidar availability_flag_* del ServiceIndex correspondiente; agendar refresh (ver RF-10).
  • RF-9 availability.invalidated (de scheduling, al cambiar horario, block, time-off) → invalidar flags de disponibilidad de todos los ServiceIndex de la branch (opcional: del profesional).

Flags de disponibilidad (cálculo diferido)

  • RF-10 Worker availability-refresher corre cada 5 minutos y recalcula availability_flag_24h y availability_flag_7d para ServiceIndex marcados como "dirty" o con availability_refreshed_at más viejo que 15 min.
    • Llama a booking.GET /availability?branch_service_id=X&from=now&to=now+7d y escribe un bool según si hay al menos un slot en el resultado.
    • Esto no reemplaza la validación real al reservar (ese momento vuelve a consultar booking) — los flags son señales de UI ("disponible pronto" / "sin huecos"), no garantía.

Endpoints de búsqueda

  • RF-11 GET /search/services — query params:
    • q (texto libre, full-text en service_name + description_tsvector + professional_display_name)
    • category (hair | nails | makeup | …)
    • lat, lng, radius_km (default 5, max 50) — requiere PostGIS ST_DWithin.
    • price_min, price_max
    • duration_max_minutes
    • rating_min (0..5)
    • available_within (24h | 7d) — filtra por flag.
    • at_home (bool) — filtra branch_is_virtual=true.
    • sort (distance | rating | price_asc | price_desc | popularity | relevance — default: híbrido, ver RN-6).
    • limit (default 20, max 50), cursor (paginación opaca).
    • Respuesta: lista de documentos + next_cursor.
    • Público.
  • RF-12 GET /search/professionals — análogo con campos de ProfessionalIndex. Público.
  • RF-13 GET /search/branches — análogo con BranchIndex. Público.
  • RF-14 GET /search/autocomplete?q= — devuelve sugerencias: categorías canónicas + top names (nombres de servicios, profesionales, sedes) con prefijo. Rate-limit por IP. Público.
  • RF-15 GET /branches/{id}/overview — ficha agregada de una sede: datos de BranchIndex + lista de ServiceIndex + profesionales activos. Público.
  • RF-16 GET /professionals/{account_id}/overview — ficha del profesional: ProfessionalIndex + sedes donde atiende + servicios. Público.

Exclusión administrativa

  • RF-17 POST /admin/discovery/exclude con { kind: service|professional|branch, id, reason } — solo platform_admin. Marca documento como excluded=true; no aparece en resultados pero queda auditable.
  • RF-18 DELETE /admin/discovery/exclude/{id} — restaura.

Señales de búsqueda (analytics + ranking)

  • RF-19 POST /search/events (llamado por las apps client-side) con { kind: search|click|impression, query?, result_id?, session_id, lat?, lng? }. Guarda en SearchEvent. No requiere auth (usa session anónima).
  • RF-20 Worker popularity-scorer corre cada hora y recalcula popularity_score por documento usando una fórmula simple (ver RN-6). Escribe en ServiceIndex/ProfessionalIndex/BranchIndex.

5. Reglas de negocio

  • RN-1 discovery nunca es source of truth — si hay divergencia, se re-indexa desde los dominios originales. Ante duda, gana el dato operativo.
  • RN-2 Solo aparecen en ServiceIndex bookings con BranchService.status = approved AND active = true AND NOT paused AND NOT archived. Estado pending_approval nunca es visible.
  • RN-3 Solo aparecen en ProfessionalIndex y BranchIndex aquellos con status = active y public = true. Perfiles suspended o private se ocultan.
  • RN-4 Disponibilidad mostrada es diferidaavailability_flag_7d = true no garantiza que el slot exista al reservar; la UI debe re-validar contra scheduling antes de confirmar. Flags son hint, no contrato.
  • RN-5 professional_portable_score es la reputación del profesional agregada sobre todas las sedes donde atiende o ha atendido (señal diferenciadora de la plataforma — las reviews son del profesional, no de la sede, ver PRD.md §1). Se calcula como promedio ponderado de reviews de cualquier sede.
  • RN-6 popularity_score MVP (fórmula simple, ajustable): 0.4 * normalize(completed_bookings_last_30d) + 0.3 * clicks_last_7d + 0.2 * rating_avg/5 + 0.1 * review_count_log. Normalizaciones por quantile. Documentar la fórmula en el código y permitir ajustarla por configuración.
  • RN-7 El sort híbrido por defecto (sort=relevance) combina ST_Distance (si hay lat/lng), rating_avg, popularity_score y similaridad full-text. Pesos iniciales documentados; no se exponen al cliente.
  • RN-8 Paginación por cursor opaco (no offset). Cursor codifica (score_tiebreaker, id). Evita inconsistencia al re-ordenar.
  • RN-9 radius_km máximo 50; consultas sin lat/lng son globales limitadas a la primera página por popularity.
  • RN-10 Full-text usa dict spanish y unaccent. Queries con caracteres extraños se sanitizan antes de tsquery.
  • RN-11 TTL de cache HTTP en respuestas públicas de search: 30s con stale-while-revalidate=60s. Ficha de branch/professional: 60s.
  • RN-12 Lag máximo aceptable de indexación ante un evento: 1 min p95, 5 min p99. Si el lag excede → alerta.
  • RN-13 SearchEvent se retiene 90 días en caliente y luego se agrega y archiva. MVP: sin export, solo uso interno para popularity_score.

6. Flujos críticos

Aprobación de servicio → visible en segundos:

(catalog emite: branch_service.approved { branch_service_id })
  ↓ discovery consume
  SELECT JOIN catalog+accounts+reviews para construir documento
  UPSERT ServiceIndex (todos los campos denormalizados)
  Marca availability como dirty (para que el worker refresquee)
  → típicamente < 10s desde el approve

Búsqueda con geo + filtros:

GET /search/services?q=manicura&lat=4.65&lng=-74.05&radius_km=3
    &category=nails&rating_min=4&available_within=7d&sort=relevance

  Construye query dinámica:
    WHERE tsvector @@ plainto_tsquery('spanish', :q)
      AND category='nails'
      AND ST_DWithin(branch_geo::geography, ST_Point(lng,lat)::geography, radius_km*1000)
      AND professional_rating_avg >= 4
      AND availability_flag_7d = true
      AND excluded = false
    ORDER BY hybrid_score DESC
    LIMIT 20
  Cache 30s por (normalize query, geo cell, filtros)
  → 200 { results, next_cursor }

Terminación de asociación → desaparece rápido:

(accounts emite: membership.ended → catalog emite: branch_services.bulk_archived)
  ↓ discovery consume
  DELETE FROM ServiceIndex WHERE branch_service_id IN (...)
  UPDATE BranchIndex/ProfessionalIndex (recount servicios, refresh professional/branch si aplica)
  → típicamente < 5s desde el evento

Refresh periódico de disponibilidad:

cron every 5min:
  SELECT ServiceIndex
  WHERE dirty = true OR availability_refreshed_at < now()-15min
  LIMIT 500  (paginado)
  FOR EACH:
    call booking.GET /availability(branch_service_id, from=now, to=now+7d)
    UPDATE flags + availability_refreshed_at

Bloqueo administrativo de contenido:

POST /admin/discovery/exclude
  body: { kind: "service", id: "bs_xxx", reason: "contenido inapropiado" }
  authz.Check(caller, platform_admin) → true
  UPDATE ServiceIndex SET excluded=true, excluded_reason=..., excluded_at=now()
  → ya no aparece en búsquedas (mas sigue operativo en catalog)
  notifications.send(branch.owner, "content-flagged")

7. Dependencias

DominioTipo
authzCheck platform_admin en endpoints de exclusión.
accountsConsumidor de eventos branch.updated, professional.updated, membership.ended (indirecto). Lee campos para denormalizar.
catalogFuente primaria: consume branch_service.approved/rejected/archived/price_changed/bulk_archived.
schedulingConsumidor de availability.invalidated. Llama a GET /availability desde el worker de refresh.
bookingConsume eventos .confirmed/.cancelled/.completed/.no_show para invalidar flags y alimentar popularity_score.
reviewsConsume review.created/updated/deleted para agregados rating_avg, reviews_count, portable_score.
notificationsRecibe avisos de exclusión administrativa.

discovery no es consumido por ningún dominio operativo — es la hoja de lectura del sistema. Otros dominios no dependen de su disponibilidad para operar.

8. Fuera de alcance (MVP)

  • Motor de búsqueda dedicado (Elasticsearch / Meilisearch / Typesense) — fase 3, cuando Postgres sea insuficiente.
  • Autocomplete con ML / embeddings — fase 4. MVP: prefijo + diccionario de categorías.
  • Recomendaciones personalizadas ("para vos") con historial del usuario — fase 4.
  • Ranking A/B testing con experiments framework — fase 3.
  • Búsqueda por imagen ("buscame un corte como este") — no en roadmap MVP.
  • Búsqueda de profesionales por idioma o atributo de identidad (LGBT-friendly, certificaciones específicas) — fase 2. MVP: solo los campos básicos de Professional.
  • Rutas / tiempo de viaje al servicio (en vez de distancia lineal) — fase 3.
  • Heatmaps y densidad para la UI del cliente — fase 3.
  • Disponibilidad a mayor granularidad en el índice (ej. "hoy 15:00-18:00") — fase 3. MVP: solo flags 24h / 7d.
  • Seguimiento de profesionales favoritos y notificaciones cuando habilitan servicios — fase 2 (requiere favorites, no modelado aún).
  • Multi-idioma — fase 2, MVP solo español.

9. Métricas

  • p95 GET /search/services con geo + filtros < 250ms, p99 < 500ms.
  • p95 GET /search/autocomplete < 80ms (consulta muy caliente).
  • Lag p95 entre evento de dominio y reflejo en índice < 60s.
  • Lag p95 de availability_flag_* respecto a estado real de scheduling < 10 min.
  • CTR (clicks / impresiones) ≥ 15% en primeros 5 resultados en top 3 categorías — indicador de ranking sano.
  • < 1% de resultados que, al abrir, estén en estado inválido (servicio archivado, branch suspendida) — prueba de latencia de indexación aceptable.
  • 0 resultados con excluded=true devueltos al público.
  • < 5% de búsquedas sin resultados en top 20 ciudades — señal de cobertura (driver de adquisición de negocios).

Documentación interna — BeautyHub