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:
- Servicios (
BranchServiceaprobados + activos) — el pin del mapa, el resultado principal. - Profesionales (
Professionalcon perfil público) — la reputación portable. - Sedes (
Branchfísicas públicas) — el salón completo.
discovery no crea ni modifica datos operativos — solo los indexa, rankea y sirve.
2. Entidades
| Entidad | Responsabilidad |
|---|---|
ServiceIndex | Documento por BranchService aprobado+activo. Contiene datos denormalizados + agregados. |
ProfessionalIndex | Documento por Professional con perfil público. Agrega sedes donde trabaja, servicios ofrecidos, rating portable. |
BranchIndex | Documento por Branch física pública. Agrega servicios disponibles, profesionales activos, rating. |
SearchEvent | Log 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.
tsvectorpara full-text en español (dictspanish), 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(decatalog) → upsertServiceIndexcon todos los datos denormalizados (join aProfessionalService,ProfessionalMembership,Branch,Account, rating dereviews). - RF-2
branch_service.rejected,branch_service.archived, o cambio aactive=false/paused→ delete delServiceIndex(el servicio no debe aparecer). Enpaused: delete. Cuando se reanude → evento re-indexa. - RF-3
branch_services.bulk_archived(decatalog, originado pormembership.ended) → delete masivo. - RF-4
branch_service.price_changed→ upsert de campos de precio. - RF-5
branch.updated(nombre, timezone, geo, kind) → reescribir todos losServiceIndexy elBranchIndexde esa branch. Idem si la branch se archiva. - RF-6
professional.updated(perfil) → reescribir todos losServiceIndexdel profesional y suProfessionalIndex. - RF-7
review.created,review.updated,review.deleted(dereviews) → recalcular agregados y escribir en los documentos afectados (ServiceIndex,ProfessionalIndex,BranchIndex). - RF-8
booking.confirmed/booking.cancelled/booking.completed/booking.no_show→ invalidaravailability_flag_*delServiceIndexcorrespondiente; agendar refresh (ver RF-10). - RF-9
availability.invalidated(descheduling, al cambiar horario, block, time-off) → invalidar flags de disponibilidad de todos losServiceIndexde la branch (opcional: del profesional).
Flags de disponibilidad (cálculo diferido)
- RF-10 Worker
availability-refreshercorre cada 5 minutos y recalculaavailability_flag_24hyavailability_flag_7dparaServiceIndexmarcados como "dirty" o conavailability_refreshed_atmás viejo que 15 min.- Llama a
booking.GET /availability?branch_service_id=X&from=now&to=now+7dy 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.
- Llama a
Endpoints de búsqueda
- RF-11
GET /search/services— query params:q(texto libre, full-text enservice_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_maxduration_max_minutesrating_min(0..5)available_within(24h | 7d) — filtra por flag.at_home(bool) — filtrabranch_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 deProfessionalIndex. Público. - RF-13
GET /search/branches— análogo conBranchIndex. 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 deBranchIndex+ lista deServiceIndex+ 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/excludecon{ kind: service|professional|branch, id, reason }— soloplatform_admin. Marca documento comoexcluded=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 enSearchEvent. No requiere auth (usa session anónima). - RF-20 Worker
popularity-scorercorre cada hora y recalculapopularity_scorepor documento usando una fórmula simple (ver RN-6). Escribe enServiceIndex/ProfessionalIndex/BranchIndex.
5. Reglas de negocio
- RN-1
discoverynunca 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
ServiceIndexbookings conBranchService.status = approved AND active = true AND NOT paused AND NOT archived. Estadopending_approvalnunca es visible. - RN-3 Solo aparecen en
ProfessionalIndexyBranchIndexaquellos constatus = activeypublic = true. Perfilessuspendedoprivatese ocultan. - RN-4 Disponibilidad mostrada es diferida —
availability_flag_7d = trueno garantiza que el slot exista al reservar; la UI debe re-validar contraschedulingantes de confirmar. Flags son hint, no contrato. - RN-5
professional_portable_scorees 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, verPRD.md §1). Se calcula como promedio ponderado de reviews de cualquier sede. - RN-6
popularity_scoreMVP (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) combinaST_Distance(si haylat/lng),rating_avg,popularity_scorey 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_kmmáximo 50; consultas sinlat/lngson globales limitadas a la primera página porpopularity. - RN-10 Full-text usa dict
spanishy unaccent. Queries con caracteres extraños se sanitizan antes detsquery. - 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
SearchEventse retiene 90 días en caliente y luego se agrega y archiva. MVP: sin export, solo uso interno parapopularity_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 approveBú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 eventoRefresh 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_atBloqueo 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
| Dominio | Tipo |
|---|---|
authz | Check platform_admin en endpoints de exclusión. |
accounts | Consumidor de eventos branch.updated, professional.updated, membership.ended (indirecto). Lee campos para denormalizar. |
catalog | Fuente primaria: consume branch_service.approved/rejected/archived/price_changed/bulk_archived. |
scheduling | Consumidor de availability.invalidated. Llama a GET /availability desde el worker de refresh. |
booking | Consume eventos .confirmed/.cancelled/.completed/.no_show para invalidar flags y alimentar popularity_score. |
reviews | Consume review.created/updated/deleted para agregados rating_avg, reviews_count, portable_score. |
notifications | Recibe 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/servicescon 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 descheduling< 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=truedevueltos al público. - < 5% de búsquedas sin resultados en top 20 ciudades — señal de cobertura (driver de adquisición de negocios).