Skip to content

PRD — Dominio 08: reviews

Reseñas post-servicio. Captura la reputación portable del profesional (diferenciador clave de la plataforma, ver PRD.md §1) y también la experiencia de la sede. Se dispara desde booking.completed y se bloquea en booking.no_show. Es fuente de verdad de ratings; discovery denormaliza agregados.


1. Propósito

Resolver dos preguntas centrales del lado cliente y del lado negocio:

  1. "¿Qué tan bueno es este profesional?" — la reseña se guarda atada al Professional, no a la sede. Si María se muda de Glamour a otro salón, su score la sigue.
  2. "¿Qué tan bueno es este salón?" — la sede agrega las reseñas que recibieron los profesionales mientras trabajaban ahí (Glamour acumula mérito por cada María, Pedro, Juan que pasó).

reviews es la fuente de verdad de rating. No calcula ranking (eso es discovery), no modera proactivamente (eso es acción de platform_admin), no envía notificaciones (las delega).

2. Entidades

EntidadResponsabilidad
ReviewReseña de un booking completado. Única por booking.
ReviewResponseRespuesta del profesional o del negocio a la reseña. 0..1 por Review.
ReviewFlagReporte de contenido inapropiado (por cualquier usuario). 0..N por Review.

Estructura de Review

id
booking_id                  (UNIQUE — 1 review por booking)
client_account_id           (quien reseña)
professional_account_id     (a quién se reseña — portable)
professional_membership_id  (en qué sede ocurrió — snapshot)
branch_id                   (sede snapshot)
branch_service_id           (servicio snapshot, para filtrado por tipo)

rating_overall              (INT 1..5, obligatorio)
rating_punctuality          (INT 1..5, opcional)
rating_quality              (INT 1..5, opcional)
rating_communication        (INT 1..5, opcional)
rating_cleanliness          (INT 1..5, opcional)  — solo si branch_is_virtual=false

comment                     (TEXT, opcional, max 1000 chars)
visibility                  ('public' | 'hidden_by_moderation')
created_at, updated_at, edited_at

Estructura de ReviewResponse

id
review_id                   (UNIQUE — 1 respuesta por review)
responder_account_id
role_snapshot               ('professional' | 'business_owner' | 'branch_manager')
text                        (max 500 chars)
created_at, updated_at

Relaciones

Booking (completed) ──1:1── Review
Review ──0:1── ReviewResponse
Review ──0:N── ReviewFlag

Account (cliente)          ──< Review (autor)
Account (profesional)      ──< Review (reseñado, portable)
ProfessionalMembership     ──< Review (contexto de la sede al momento)
Branch                     ──< Review (snapshot)

Doble destino del mismo rating: una sola Review contribuye simultáneamente al agregado del profesional (Professional.rating_portable) y al de la sede (Branch.rating_at_branch, calculado solo con reviews cuya branch_id coincide). discovery lee y denormaliza ambos.

3. Historias de usuario

  • U-1. Como Sofía, cuando termina mi cita con María, recibo una notificación pidiéndome reseñar. Puedo poner 1-5 estrellas y un comentario opcional.
  • U-2. Como Sofía, tengo 14 días después del servicio para reseñar. Después, se cierra.
  • U-3. Como Sofía, si no me presenté (no_show), no puedo reseñar. Tiene sentido — no recibí el servicio.
  • U-4. Como Sofía, puedo editar mi reseña dentro de los primeros 7 días tras publicarla (por si me arrepiento o quiero agregar algo).
  • U-5. Como Sofía, puedo borrar mi reseña cuando quiera (estado deleted, no visible — conserva historial).
  • U-6. Como María (profesional), puedo responder a la reseña (una sola vez) públicamente.
  • U-7. Como Carlos (dueño), también puedo responder a reseñas de servicios en mi sede — aunque la reputación sea del profesional, a mí me afecta.
  • U-8. Como María, cuando me mudo a otro salón, mis reseñas históricas siguen conmigo. Mi rating portable no cae a 0.
  • U-9. Como Carlos, la ficha pública de mi sede muestra reseñas que recibieron mis profesionales actuales y pasados mientras trabajaban conmigo.
  • U-10. Como cualquier usuario, puedo reportar una reseña ofensiva.
  • U-11. Como Diego (platform admin), puedo ocultar una reseña reportada si viola políticas. No la borro — queda oculta pero auditable.
  • U-12. Como Sofía, en la ficha de María veo reseñas ordenadas por recientes, filtrables por sede y por servicio.

4. Requerimientos funcionales

Habilitación y creación

  • RF-1 Consume booking.completed (de booking) → crea un ReviewInvite interno:
    • booking_id, client_account_id, expires_at = completed_at + 14 días, status=pending.
    • Emite evento review.invited que notifications consume para pedir reseña al cliente.
  • RF-2 Consume booking.no_showno crea invite. Si ya existía (edge case: evento fuera de orden) → cancela.
  • RF-3 POST /bookings/{id}/review crea la reseña:
    • Auth: caller == booking.client_account_id.
    • Valida: ReviewInvite.status == pending AND now() < expires_at.
    • Body: { rating_overall, rating_*?, comment? }. rating_overall obligatorio.
    • Si branch_is_virtual = truerating_cleanliness se ignora (RN-4).
    • BEGIN TX:
      • INSERT Review (visibility=public)
      • UPDATE ReviewInvite.status = submitted
      • INSERT authz_outbox (tuples necesarios para el tipo review):
        • review:X#author@account:sofia
        • review:X#professional@account:maria
        • review:X#located_at@branch:chap
    • COMMIT
    • Emite review.created.
  • RF-4 GET /bookings/{id}/review devuelve la reseña si existe. Acceso: cliente, profesional, staff con can_read_bookings, admin.

Edición y borrado

  • RF-5 PATCH /reviews/{id} — solo autor, solo dentro de 7 días desde created_at. Campos editables: rating_*, comment. edited_at = now().
    • Emite review.updated (discovery recalcula agregados).
  • RF-6 DELETE /reviews/{id} — solo autor, en cualquier momento. Soft-delete (visibility = 'deleted_by_author'), se excluye de agregados. Emite review.deleted.
  • RF-7 No hay undelete. Si el cliente quiere volver a reseñar, crea una nueva (pero el invite ya consumió su oportunidad, salvo admin override).

Respuestas

  • RF-8 POST /reviews/{id}/response crea la respuesta. Solo una por review.
    • Autorizado si authz.Check(caller, can_respond, review:id) → true. La acción can_respond cubre:
      • El profesional reseñado (tuple review:X#professional@account:caller), o
      • Quien tenga can_respond_branch_reviews sobre la branch donde ocurrió (owner por defecto).
    • Persiste role_snapshot con el rol de quien respondió al momento.
  • RF-9 PATCH /responses/{id} — editable dentro de 30 días por el autor original.
  • RF-10 DELETE /responses/{id} — por autor o por can_delete sobre la review (moderador de plataforma).

Reportes y moderación

  • RF-11 POST /reviews/{id}/flag — cualquier usuario autenticado. Body: { reason_code, details? }. Crea ReviewFlag. Rate-limit por account (3/día).
  • RF-12 POST /admin/reviews/{id}/hide — requiere authz.Check(caller, can_hide, review:id) → true (moderador de plataforma). Body: { reason }. Setea visibility = 'hidden_by_moderation'. No cuenta en agregados.
  • RF-13 POST /admin/reviews/{id}/restore — requiere authz.Check(caller, can_restore, review:id) → true. Revierte el hide.
  • RF-14 GET /admin/reviews/flagged?status=pending — cola de moderación.

Lecturas públicas

  • RF-15 GET /professionals/{account_id}/reviews?limit=&cursor=&branch_id=&service_category=&sort=recent|helpful — reseñas del profesional (portable, across sedes).
    • Solo retorna visibility = public.
    • Público.
  • RF-16 GET /branches/{id}/reviews?limit=&cursor=&professional_id=&sort= — reseñas recibidas en esa sede (por cualquier profesional que trabajó ahí).
    • Solo visibility = public.
    • Público.
  • RF-17 GET /professionals/{account_id}/rating / GET /branches/{id}/rating — devuelve agregados precomputados: rating_avg, rating_count, distribución 1-5, breakdown por dimensión.

Eventos emitidos

  • RF-18 review.invited { booking_id, expires_at }notifications.
  • RF-19 review.created { review_id, professional_account_id, branch_id, rating_overall }discovery (actualiza agregados), notifications (avisa al profesional y a la sede).
  • RF-20 review.updated / review.deleted / review.hidden_by_moderation / review.restoreddiscovery.
  • RF-21 review.response_created / review.response_updated / review.response_deletednotifications (al autor de la review).

Agregados (cálculo interno)

  • RF-22 Worker aggregates-refresher recalcula agregados incrementales por evento (no cron masivo). Almacena en tablas:
    • professional_rating_stats(professional_account_id, rating_avg, rating_count, distribution_json, updated_at).
    • branch_rating_stats(branch_id, rating_avg, rating_count, distribution_json, updated_at).
    • professional_branch_rating_stats(professional_account_id, branch_id, ...) — útil para filtrar "cómo es María en Glamour".
  • RF-23 Agregados solo cuentan reviews con visibility = public.

5. Reglas de negocio

  • RN-1 Una review por booking. UNIQUE(booking_id).
  • RN-2 Solo el cliente del booking puede reseñar. No hay reseñas "de terceros" en MVP.
  • RN-3 Ventana para reseñar: 14 días desde booking.completed_at. Después, ReviewInvite expira y la API responde 410 gone.
  • RN-4 rating_cleanliness solo aplica si la sede es física (branch.kind = physical). Para branches virtuales (profesional a domicilio o independiente), ese rating se ignora si se envía y no cuenta en agregados.
  • RN-5 Reputación portable: Professional.rating_portable agrega todas las reviews públicas de ese profesional, sin importar la sede. Cuando María se muda a otro salón, su score la sigue.
  • RN-6 Reputación de la sede: Branch.rating_at_branch agrega solo las reviews cuya Review.branch_id == branch.id. Reviews del mismo profesional en otras sedes no cuentan acá.
  • RN-7 Terminar la asociación de un profesional no borra sus reviews en esa sede — el mérito/demérito acumulado por la sede permanece (RN-6). Las reviews ya existen en el registro histórico.
  • RN-8 Suspensión de un Account: sus reviews siguen visibles (son del cliente, no del suspendido). Si el suspendido es el reseñado (profesional), las reviews siguen visibles pero el perfil no es reachable.
  • RN-9 Edición de review dentro de 7 días. Después, inmutable (solo eliminable por autor o admin).
  • RN-10 Una misma persona no puede reportar la misma review más de una vez. UNIQUE(review_id, reporter_account_id).
  • RN-11 Moderación administrativa (hidden_by_moderation) se refleja en discovery via evento en < 60s.
  • RN-12 ReviewResponse es pública igual que la review. No hay respuestas "privadas al cliente" en MVP (para eso existe chat, fuera de MVP).
  • RN-13 Reviews con visibility != public no son visibles públicamente, ni siquiera para el autor (salvo endpoint de "mis reseñas borradas" — no en MVP).
  • RN-14 platform_admin no puede crear reviews en nombre de otros ni editar contenido. Solo puede ocultar/restaurar. La confianza se preserva.
  • RN-15 Spam / abuse: la plataforma acepta reviews sin verificación extra más allá del booking_id válido (es decir, quien se agendó y completó). Pesos anti-fraude (detección de booking fantasma, etc.) son responsabilidad de dominios de billing/fraud, fase 2+.

6. Flujos críticos

Completar booking → habilitar reseña:

(booking emite: booking.completed { booking_id, completed_at })
  ↓ reviews consume
  INSERT ReviewInvite (booking_id, expires_at=completed_at+14d, status=pending)
  Emite review.invited
  (notifications envía a Sofía: "Cómo estuvo tu cita con María?")

Sofía reseña a María:

POST /bookings/bk_123/review
  body: { rating_overall: 5, rating_quality: 5, rating_punctuality: 4,
          comment: "Excelente, super puntual y buenísimo el resultado" }

  Valida caller == booking.client AND invite.status=pending AND now() < invite.expires_at
  BEGIN TX
  INSERT Review (professional_account=maria, professional_membership=m_maria_chap, branch=chap, ...)
  UPDATE ReviewInvite SET status=submitted
  COMMIT
  Emite review.created
  → 201
  (aggregates worker actualiza Maria.rating_portable y Chap.rating_at_branch)
  (discovery consume y refresca ServiceIndex / ProfessionalIndex / BranchIndex)
  (notifications avisa a María y a Carlos)

María responde:

POST /reviews/{id}/response
  body: { text: "Gracias Sofía! La espero pronto." }
  authz: caller == review.professional_account → OK (role_snapshot=professional)
  INSERT ReviewResponse
  Emite review.response_created
  (notifications avisa a Sofía)

Reporte y moderación:

POST /reviews/{id}/flag
  body: { reason_code: "offensive_language", details: "..." }
  INSERT ReviewFlag (reporter, reason, created_at)
  → 201

(Diego revisa cola)
POST /admin/reviews/{id}/hide
  body: { reason: "violates community guidelines" }
  authz: platform_admin → OK
  UPDATE Review SET visibility='hidden_by_moderation'
  Emite review.hidden_by_moderation
  (aggregates worker re-resta la review; discovery actualiza)
  → 200

Profesional se muda (reputación portable):

(accounts emite: membership.ended { professional_account_id=maria, branch_id=chap })

  reviews: NO TOCA las reviews de María en Chapinero
  Professional.rating_portable SIGUE incluyendo esas reviews (across sedes)
  Branch.rating_at_branch de Chapinero SIGUE incluyendo esas reviews (histórico)
  → María mantiene su reputación; Glamour mantiene el mérito.

Cliente edita dentro de 7d, pero no después:

PATCH /reviews/{id}  (caller: Sofía)
  body: { rating_overall: 4 }  (se bajó la calificación porque tuvo una 2a cita mala)
  Si created_at > now()-7d → OK, UPDATE + emite review.updated
  Si created_at <= now()-7d → 403 edit_window_expired

7. Dependencias

DominioTipo
authzChecks de permisos: can_respond (review), can_hide (review), can_restore (review), can_delete (review).
bookingTrigger principal: consume booking.completed (habilita) y booking.no_show (bloquea). Lee snapshots de booking al crear review.
accountsLee Professional, ProfessionalMembership, Branch para snapshots.
discoveryConsumidor: denormaliza rating_avg, rating_count, portable_score.
notificationsEnvía pedidos de review, avisos de nueva review, respuestas, moderación.

reviews no depende de discovery ni de catalog (excepto por branch_service_id como snapshot informativo).

8. Fuera de alcance (MVP)

  • Reviews con fotos — fase 2. MVP: solo rating + texto.
  • Reviews anónimasnunca. Se muestra el display_name del cliente. Opción "ocultar mi nombre" → fase 4.
  • Review del profesional al cliente (Uber-style bidireccional) — fase 3. MVP: Account.risk_no_show cubre el caso borde.
  • Respuestas privadas / chat post-review — fase 3 (requiere módulo chat).
  • Ranking "más útiles" con votos — fase 2. MVP: sort=recent solo.
  • Respuestas múltiples a la misma review — no previsto. Una por review.
  • Detección automática de contenido ofensivo (ML/LLM) — fase 3. MVP: solo moderación reactiva por reportes.
  • Reviews verificadas con foto/comprobante — fase 4.
  • Importar reviews de Google / Instagram para arrancar perfiles — explorar fase 3 con legal.
  • Rating agregado por categoría del servicio (María tiene 4.8 en maquillaje, 4.2 en cejas) — fase 3.
  • Expiración de reviews viejas (peso decreciente en score) — fase 3.

9. Métricas

  • ≥ 35% de bookings completados generan una review (tasa de conversión del invite).
  • p95 POST /bookings/{id}/review < 250ms.
  • Lag p95 entre review.created y actualización de agregados < 15s.
  • Lag p95 entre review.created y reflejo en discovery < 60s.
  • 0 reviews con booking_id duplicado (violación RN-1).
  • 0 reviews creadas sin booking completado (violación RN-3 o RF-3).
  • < 0.5% de reviews reportadas → moderadas y ocultadas (salud del contenido).
  • < 48h tiempo mediano desde reporte hasta decisión de moderación.
  • ≥ 98% de correspondencia entre professional_rating_stats.rating_count y COUNT(Review WHERE professional=X AND visibility=public) (prueba de consistencia incremental).

Documentación interna — BeautyHub