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 desdebooking.completedy se bloquea enbooking.no_show. Es fuente de verdad de ratings;discoverydenormaliza agregados.
1. Propósito
Resolver dos preguntas centrales del lado cliente y del lado negocio:
- "¿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. - "¿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
| Entidad | Responsabilidad |
|---|---|
Review | Reseña de un booking completado. Única por booking. |
ReviewResponse | Respuesta del profesional o del negocio a la reseña. 0..1 por Review. |
ReviewFlag | Reporte 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_atEstructura 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_atRelaciones
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(debooking) → crea unReviewInviteinterno:booking_id,client_account_id,expires_at = completed_at + 14 días,status=pending.- Emite evento
review.invitedquenotificationsconsume para pedir reseña al cliente.
- RF-2 Consume
booking.no_show→ no crea invite. Si ya existía (edge case: evento fuera de orden) → cancela. - RF-3
POST /bookings/{id}/reviewcrea la reseña:- Auth: caller ==
booking.client_account_id. - Valida:
ReviewInvite.status == pending AND now() < expires_at. - Body:
{ rating_overall, rating_*?, comment? }.rating_overallobligatorio. - Si
branch_is_virtual = true→rating_cleanlinessse ignora (RN-4). - BEGIN TX:
- INSERT
Review(visibility=public) - UPDATE
ReviewInvite.status = submitted - INSERT
authz_outbox(tuples necesarios para el tiporeview):review:X#author@account:sofiareview:X#professional@account:mariareview:X#located_at@branch:chap
- INSERT
- COMMIT
- Emite
review.created.
- Auth: caller ==
- RF-4
GET /bookings/{id}/reviewdevuelve la reseña si existe. Acceso: cliente, profesional, staff concan_read_bookings, admin.
Edición y borrado
- RF-5
PATCH /reviews/{id}— solo autor, solo dentro de 7 días desdecreated_at. Campos editables:rating_*,comment.edited_at = now().- Emite
review.updated(discovery recalcula agregados).
- Emite
- RF-6
DELETE /reviews/{id}— solo autor, en cualquier momento. Soft-delete (visibility = 'deleted_by_author'), se excluye de agregados. Emitereview.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}/responsecrea la respuesta. Solo una por review.- Autorizado si
authz.Check(caller, can_respond, review:id)→ true. La accióncan_respondcubre:- El profesional reseñado (tuple
review:X#professional@account:caller), o - Quien tenga
can_respond_branch_reviewssobre la branch donde ocurrió (owner por defecto).
- El profesional reseñado (tuple
- Persiste
role_snapshotcon el rol de quien respondió al momento.
- Autorizado si
- RF-9
PATCH /responses/{id}— editable dentro de 30 días por el autor original. - RF-10
DELETE /responses/{id}— por autor o porcan_deletesobre la review (moderador de plataforma).
Reportes y moderación
- RF-11
POST /reviews/{id}/flag— cualquier usuario autenticado. Body:{ reason_code, details? }. CreaReviewFlag. Rate-limit por account (3/día). - RF-12
POST /admin/reviews/{id}/hide— requiereauthz.Check(caller, can_hide, review:id)→ true (moderador de plataforma). Body:{ reason }. Seteavisibility = 'hidden_by_moderation'. No cuenta en agregados. - RF-13
POST /admin/reviews/{id}/restore— requiereauthz.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.
- Solo retorna
- 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.
- Solo
- 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.restored→discovery. - RF-21
review.response_created/review.response_updated/review.response_deleted→notifications(al autor de la review).
Agregados (cálculo interno)
- RF-22 Worker
aggregates-refresherrecalcula 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,ReviewInviteexpira y la API responde410 gone. - RN-4
rating_cleanlinesssolo 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_portableagrega 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_branchagrega solo las reviews cuyaReview.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 endiscoveryvia evento en < 60s. - RN-12
ReviewResponsees 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 != publicno son visibles públicamente, ni siquiera para el autor (salvo endpoint de "mis reseñas borradas" — no en MVP). - RN-14
platform_adminno 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_idvá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)
→ 200Profesional 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_expired7. Dependencias
| Dominio | Tipo |
|---|---|
authz | Checks de permisos: can_respond (review), can_hide (review), can_restore (review), can_delete (review). |
booking | Trigger principal: consume booking.completed (habilita) y booking.no_show (bloquea). Lee snapshots de booking al crear review. |
accounts | Lee Professional, ProfessionalMembership, Branch para snapshots. |
discovery | Consumidor: denormaliza rating_avg, rating_count, portable_score. |
notifications | Enví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ónimas — nunca. Se muestra el
display_namedel cliente. Opción "ocultar mi nombre" → fase 4. - Review del profesional al cliente (Uber-style bidireccional) — fase 3. MVP:
Account.risk_no_showcubre el caso borde. - Respuestas privadas / chat post-review — fase 3 (requiere módulo chat).
- Ranking "más útiles" con votos — fase 2. MVP:
sort=recentsolo. - 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.createdy actualización de agregados < 15s. - Lag p95 entre
review.createdy reflejo endiscovery< 60s. - 0 reviews con
booking_idduplicado (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_countyCOUNT(Review WHERE professional=X AND visibility=public)(prueba de consistencia incremental).