Clientes y reseñas¶
Esta página explica dos dominios complementarios del ciclo de vida del comensal: el módulo customer, que modela la cuenta y las preferencias de cada cliente, y el módulo review (RD07), que captura su valoración tras el servicio. Ambos conviven con la sesión de mesa y la comanda, pero —siguiendo ADR-004— no se importan entre sí: se comunican a través de puertos compartidos.
PII
Una entidad Customer agrega datos personales (email, nombre, teléfono) y datos próximos a la salud (alergias, restricciones dietéticas). El propio dominio advierte de no volcar instancias completas en logs y de redactar la PII en cualquier salida de diagnóstico.
Módulo customer: cuentas y preferencias¶
La entidad de dominio¶
El agregado raíz es Customer (backend/app/modules/customer/domain/entities.py). Combina identidad, contacto y un bloque de preferencias para la IA:
| Campo | Tipo | Propósito |
|---|---|---|
email |
Email (value object) |
Identidad única del cliente |
name, phone, picture_url |
str \| None |
Perfil básico |
dietary_restrictions |
list[str] |
Alergias / restricciones (p. ej. sin gluten) |
favorite_flavors |
list[str] |
Gustos del cliente |
disliked_ingredients |
list[str] |
Ingredientes a evitar |
El value object Email se reexporta desde app.shared.domain.value_objects (customer/domain/value_objects.py): los primitivos solo viven en el borde, el dominio trabaja con el VO.
La actualización de perfil sigue semántica PATCH mediante Customer.update_preferences(...): cada argumento se aplica solo cuando no es None, de modo que un cliente puede tocar una sola preferencia sin pisar el resto.
Persistencia¶
El modelo ORM Customer (tabla customers, en customer/infrastructure/models/customer_model.py) almacena las tres listas de preferencias como columnas JSON. Es relevante para entender cómo se relaciona con el resto del sistema:
- Campos de autenticación.
password_hashes nullable: los clientes que entran por OAuth no tienen contraseña.oauth_provider('google','apple'oNone),oauth_idypicture_urlcubren la identidad federada. - Relación con comandas.
orders: list["Comanda"]enlaza al cliente con sus pedidos (back_populates="customer"). Así, una comanda puede atribuirse a una cuenta de cliente cuando esta existe.
El repositorio de dominio ICustomerRepository (customer/domain/repository.py) es deliberadamente mínimo —find_by_id y update— porque el módulo customer se encarga del autoservicio de perfil, no del alta (de eso se ocupa auth).
Endpoints de autoservicio¶
El router customer/interface/router.py se monta bajo /api/v1/customers y expone el perfil del cliente autenticado:
GET /api/v1/customers/me— devuelve el perfil (GetCustomerProfileUseCase).PATCH /api/v1/customers/me— actualiza perfil y preferencias (UpdateCustomerProfileUseCase).
Ambos resuelven al cliente actual con la dependencia get_current_customer y devuelven 404 (CustomerNotFoundError) si la cuenta no existe. El schema de salida es CustomerResponse y el de entrada CustomerUpdateRequest; este último normaliza cualquier lista de preferencias None a [] mediante un field_validator.
Módulo auth: registro e identidad del cliente¶
El alta de clientes vive en el módulo auth, cuyo router de cliente (auth/interface/customer_router.py) se monta bajo /api/v1/auth.
POST /api/v1/auth/register¶
Crea una cuenta email + contraseña. El flujo:
-
Validación de entrada (Pydantic → 422). El schema
RegisterRequest(auth/interface/schemas.py) valida:- email con expresión regular; lo normaliza a minúsculas.
- password con reglas de complejidad: mínimo 8 caracteres, al menos una mayúscula, una minúscula y un dígito.
Cualquier entrada que incumpla estas reglas es rechazada por Pydantic con HTTP 422 antes de llegar al caso de uso.
-
Caso de uso.
RegisterCustomerUseCase.execute(...)comprueba unicidad víaICustomerAccountRepository.get_by_email; si el email ya existe lanzaEmailAlreadyRegisteredError, que el router traduce a HTTP 400. En caso contrario delega la persistencia encreate_password_customer(que hashea la contraseña internamente). -
Respuesta.
CustomerResponsecon estado 201 Created.
422 frente a 400
El 422 proviene del contrato Pydantic (formato de email o complejidad de contraseña). El 400 se reserva para el conflicto de email ya registrado. Son rutas distintas: validación de forma frente a regla de negocio.
POST /api/v1/auth/login¶
LoginCustomerUseCase busca un cliente con hash de contraseña usable (get_authenticatable_by_email), verifica la contraseña con IPasswordHasher y emite un JWT de cliente (ITokenService.mint_customer_token, con sub=email). Credenciales inválidas → HTTP 401 (InvalidCredentialsError). La verificación de contraseña es asíncrona a propósito: bcrypt.checkpw es bloqueante (~100 ms) y se despacha a un hilo para no detener el event loop.
Identidad OAuth (Google / Apple)¶
GET /api/v1/auth/oauth/google y .../oauth/apple inician el flujo y devuelven la authorization_url. POST /api/v1/auth/oauth/callback intercambia el code por la identidad y emite el token, además de devolver los datos del cliente (CustomerOAuthResponse). El flujo aplica protección CSRF (SEC02) validando el state de un solo uso y PKCE (SEC03) para Google. El repositorio resuelve la cuenta con get_or_create_from_oauth: si la identidad federada no existe, crea un cliente OAuth (sin password_hash).
Para el detalle de JWT, OAuth, PKCE y multitenancy, ver Seguridad y multitenancy.
Autenticación de cliente: ICustomerAuthenticator¶
El puerto compartido ICustomerAuthenticator (app/shared/application/ports.py) desacopla la verificación del token del resto de módulos: las dependencias HTTP compartidas (app.core.deps.get_current_customer) consumen este puerto en vez de importar el módulo auth directamente (ADR-004).
El adaptador JoseCustomerAuthenticator (auth/infrastructure/adapters/authenticators.py) lo implementa: decodifica el bearer token con JoseTokenService, extrae el claim sub (el email) y carga el Customer vía el repositorio. Devuelve None ante cualquier fallo (token inválido o sub ausente). El adaptador se registra en el arranque con register_customer_authenticator.
Módulo review (RD07): reseñas post-servicio¶
El módulo review captura la valoración del cliente una vez cerrada/pagada la sesión.
Dominio¶
La entidad Review (review/domain/entity.py) liga una puntuación y un comentario opcional a una sesión y a su restaurante:
rating: Rating— value objectfrozenque solo admite enteros en el rango inclusivo 1..5 (rechazabooly valores fuera de rango).comment: str | None— máximo 1000 caracteres (REVIEW_COMMENT_MAX_LENGTH), validado en__post_init__.restaurant_id,session_id.
La reseña es inmutable de facto: se crea una vez por sesión, garantizado por la restricción única uq_reviews_session_id sobre session_id en la tabla reviews (review/infrastructure/models/review_model.py). Esa tabla añade además un índice ix_reviews_restaurant_created para listar por restaurante en orden cronológico, y declara FKs con ondelete="CASCADE" hacia restaurants y table_sessions.
Relación con sesión y comanda¶
Aquí está la clave de cómo encaja en el resto del sistema. El caso de uso CreateReviewUseCase (review/application/use_cases/create_review.py) no importa el módulo session: resuelve la sesión a partir de su token mediante el puerto compartido ISessionCartGateway.get_session(...), que devuelve un SessionSummary con id y restaurant_id. Con esos datos construye el Review y lo persiste. De este modo, la reseña queda anclada a la sesión concreta (y, transitivamente, al restaurante) sin acoplamiento entre módulos.
Ver Sesiones y carrito y Comanda para el ciclo de vida que precede a la reseña.
Endpoint de cliente¶
POST /api/v1/sessions/{session_token}/review (review/interface/customer_router.py, montado bajo /api/v1) recibe ReviewCreate (rating con Field(ge=1, le=5), comment con max_length=1000) y responde 201 con ReviewResponse. Mapeo de errores:
| Situación | Estado |
|---|---|
| Éxito | 201 |
El token no resuelve a ninguna sesión (NotFoundError) |
404 |
La sesión ya tenía reseña (ReviewAlreadyExistsError) |
409 |
Rating fuera de rango o comentario demasiado largo (ValueError) |
422 |
Defensa en profundidad e idempotencia
Las restricciones de Field rechazan la mayoría de entradas inválidas con 422 antes de tocar el dominio; el except ValueError del router es defensa adicional para construcciones directas del VO. El endpoint acepta además la cabecera Idempotency-Key (alcance: el token de sesión, una reseña por sesión): un reintento con la misma clave reproduce la primera respuesta 201 sin reintentar el insert; llamadas concurrentes con la misma clave reciben 409.
Endpoint de administración¶
GET /api/v1/admin/reviews/restaurant/{restaurant_id} (review/interface/admin_router.py) lista las reseñas de un restaurante de forma paginada (ListReviewsUseCase), ordenadas por fecha descendente. Aplica el guard multitenant require_restaurant_access (404 para restaurante desconocido, 403 para admin sin acceso; SUPER_ADMIN lo bypassa) y devuelve el sobre canónico PaginatedResponse vía ReviewListResponse.
Véase también¶
- Arquitectura hexagonal — capas, puertos y ADR-004.
- Seguridad y multitenancy — JWT, OAuth, PKCE.
- Sesiones y carrito y Comanda — el ciclo previo a la reseña.
- Recomendación — cómo las preferencias alimentan a la IA.
- Referencia API y Eventos SSE.