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 (
BranchServiceactivos y reservables) — el resultado principal. Un documento por servicio de sede, con los profesionales que lo ejecutan embebidos (modelo branch-first decatalog: un servicio existe una vez por sede, ver04-catalogRN-1). - Profesionales (
Professionalcon perfil público) — la reputación portable. - Sedes (
Branchfísicas públicas) — el salón completo. El pin del mapa es la sede, no el servicio: una búsqueda nunca muestra el mismo servicio canónico repetido para la misma sede.
discovery no crea ni modifica datos operativos — solo los indexa, rankea y sirve.
2. Entidades
| Entidad | Responsabilidad |
|---|---|
ServiceIndex | Documento por BranchService activo y reservable (≥ 1 adopción activa, 04-catalog RN-5). Contiene datos denormalizados + agregados + profesionales embebidos. |
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
template_id (nullable — el servicio canónico del que deriva)
service_name (copiado de BranchService)
service_category (obligatoria — enum de catalog, eje principal de filtrado)
name_normalized (lower + unaccent — dedup y matching de autocomplete)
description_tsvector (GIN)
price_cents_min, price_cents_max (rango: precio base ± overrides de profesionales)
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)
professionals (JSONB array embebido — los que adoptaron el servicio:
[{ membership_id, account_id, display_name,
rating_avg, reviews_count, portable_score,
price_cents_effective, duration_minutes_effective }])
professionals_count
best_professional_rating (max rating entre adopciones — para filtro rating_min)
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, active + ≥1 adopción) ──► ServiceIndex (1:1, professionals embebidos)
Professional (accounts, public) ──► ProfessionalIndex (1:1)
Branch (accounts, physical + public) ──► BranchIndex (1:1)El documento embebe a los profesionales en vez de duplicar el servicio por profesional: la deduplicación de resultados está garantizada por el modelo (1 documento por servicio de sede), no por lógica de ranking. La UI muestra el servicio una vez y expande los profesionales como variantes dentro de la ficha.
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
⚠️ = definido en este PRD pero pendiente de implementación — ver justificación en
openspec/changes/discovery/proposal.md§2 "Out of scope (deferred, with justification)". Todo lo no marcado está implementado (backend,apps/api/internal/discovery/) al cierre del changediscovery(Batches 0-7).
Consumo de eventos (mantenimiento del índice)
- RF-1
services.branch_service.approved(decatalog) → upsertServiceIndexcon todos los datos denormalizados (join aBranchService, adopciones activas,Branch,Account, rating dereviews). Si el servicio no tiene adopciones activas, no se indexa (04-catalogRN-5). - RF-2
services.branch_service.archivedo.paused→ delete delServiceIndex..resumed→ re-upsert completo. - RF-3
services.professional_service.adopted→ agrega el profesional al arrayprofessionals, recalculaprice_cents_min/max,professionals_count,best_professional_rating. Si es la primera adopción → crea el documento (el servicio pasa a ser reservable). - RF-3b
services.professional_service.archived(opt-out omembership.endeden cascada) → quita el profesional del array y recalcula agregados. Siprofessionals_countllega a 0 → delete del documento (el catálogo de la sede queda intacto encatalog, pero sin profesionales no es reservable). - RF-4
services.branch_service.updated→ upsert de nombre/descripción/precio/duración (recalcula rango con overrides). - 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). El dominioreviewsno existe en código todavía —rating_avg,reviews_count,portable_score,best_professional_ratingquedan hardcodeados a0en todo rebuild. Se cablea cuandoreviewsaterrice. - 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). Cubierto de forma indirecta, no vía consumer explícito:schedulingsí emiteavailability.invalidated(internal/scheduling/service/{consumers,branch_schedule}.go), perodiscovery.ConsumedTopicsno lo escucha — el efecto deseado (reflejar el cambio "en minutos") lo logra en la práctica el fallback de staleness delavailability-refresher(RF-10: cualquier fila se recalcula igual cada ≤15 min), no un dirty-flip inmediato como conbooking.*. Gap real documentado en el issue de seguimiento (ver tracking issue de este batch).
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 ⚠️ (no implementado — sin límite de tasa al día de hoy). Público. Implementado sobrename_normalizedcon índice btreetext_pattern_ops(sin infra de búsqueda separada, según lo que dejó abierto la propuesta). El orden degrada apopularity_score DESC, name_normalized ASC, peropopularity_scorees una columna stub que siempre vale0(RF-20 diferido) — en la práctica hoy el orden es puramente alfabético. - 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. Diferido: sin contenido que moderar al lanzamiento, agregaría superficie de authz + notifications sin valor de usuario inmediato. La columnaexcludedsí existe en las 3 tablas (schema-stub, sin migración futura pendiente) y toda query de búsqueda la respeta incondicionalmente (excluded = false) — falta únicamente el endpoint para setearla. - RF-18 ⚠️
DELETE /admin/discovery/exclude/{id}— restaura. Mismo estado que RF-17.
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). Diferido: depende de que las apps cliente emitan eventos, y de quepopularity_scoreimporte — ninguna de las dos cosas es cierta al lanzamiento (sin tráfico que puntuar todavía). - RF-20 ⚠️ Worker
popularity-scorercorre cada hora y recalculapopularity_scorepor documento usando una fórmula simple (ver RN-6). Escribe enServiceIndex/ProfessionalIndex/BranchIndex. Diferido junto con RF-19. La columnapopularity_scoresí existe (schema-stub,DEFAULT 0) y elsortpor defecto de/search/*degrada correctamente a distancia+rating+relevancia sin popularidad — no es un blocker funcional, solo falta el worker que la alimente.
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
ServiceIndexservicios conBranchService.status = activey ≥ 1 adopción activa. Estadosdraft,pausedyarchivednunca son visibles. Un servicio activo sin profesionales tampoco (no es reservable). - RN-2b (dedup por diseño). Nunca existen dos documentos para el mismo servicio canónico en la misma sede — lo garantiza
catalog(UNIQUE (branch_id, lower(unaccent(name)))). Discovery no necesita lógica de deduplicación: si aparecen duplicados en resultados, el bug está en catalog, no acá. El filtrorating_minusabest_professional_rating. - 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: services.branch_service.approved { branch_service_id })
↓ discovery consume
SELECT JOIN catalog (servicio + adopciones activas) + accounts + reviews
¿≥ 1 adopción activa? → UPSERT ServiceIndex (professionals embebidos)
¿0 adopciones? → no se indexa todavía (se indexará con el primer adopted)
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 → el catálogo de la sede sobrevive:
(accounts emite: membership.ended → catalog emite: services.professional_service.archived × N)
↓ discovery consume (por cada adopción archivada)
UPDATE ServiceIndex: quita al profesional del array, recalcula agregados
Si professionals_count == 0 → DELETE del documento (sin staff no es reservable)
UPDATE BranchIndex/ProfessionalIndex (recount servicios, refresh 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 services.branch_service.approved/updated/paused/resumed/archived y services.professional_service.adopted/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).