Saltar a contenido

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_hash es nullable: los clientes que entran por OAuth no tienen contraseña. oauth_provider ('google', 'apple' o None), oauth_id y picture_url cubren 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:

  1. 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.

  2. Caso de uso. RegisterCustomerUseCase.execute(...) comprueba unicidad vía ICustomerAccountRepository.get_by_email; si el email ya existe lanza EmailAlreadyRegisteredError, que el router traduce a HTTP 400. En caso contrario delega la persistencia en create_password_customer (que hashea la contraseña internamente).

  3. Respuesta. CustomerResponse con 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 object frozen que solo admite enteros en el rango inclusivo 1..5 (rechaza bool y 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