From 48c8fe3d96d7df39bc6c75c513f6bce369bd3924 Mon Sep 17 00:00:00 2001 From: Jermed Date: Sun, 25 Oct 2026 13:51:20 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(organization):=20HU-002=20registro=20y?= =?UTF-8?q?=20aprobaci=C3=B3n=20de=20proveedores,=20con=20la=20autorizaci?= =?UTF-8?q?=C3=B3n=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Módulo organization (domain, application, infrastructure, api): - Registro público del proveedor con cuenta propia (rol PROVIDER), al menos una sede y su política de operación v1. Queda PENDING_APPROVAL. Rechaza la identificación duplicada (409, también en carrera), la categoría o ciudad desconocidas y la anticipación mínima mayor que la máxima. Lo que falla después de crear la cuenta la borra (como D7). - Aprobación por un administrador con motivo obligatorio (HU-003). El correo se envía después del commit. - Cambio de los parámetros de operación por OWNER o MANAGER: crea una versión nueva y las reservas existentes conservan la suya. - Consulta del propio proveedor y catálogos públicos de categorías y ciudades. Autorización base (ADR-0008): /admin/** solo ADMIN; /organizations/** solo PROVIDER o ADMIN; OrganizationAccess responde 404 fuera del proveedor y 403 si el rol no alcanza. Módulo notification (ADR-0007): SMTP con Spring Mail; sin SPRING_MAIL_HOST, el correo se registra en el log. Migraciones: V4 tipo y número de identificación (renombra nit); V5 categorías, 32 ciudades y COP como datos de referencia. Otros: auth crea cuentas PROVIDER; la confirmación de correo acepta cuentas sin perfil de cliente; runbook (primer admin, SMTP), render.yaml y README al día. Pruebas: 718 unitarias y 89 de integración; ProviderOnboardingIT cubre los escenarios de HU-002 y los 404/403 de HU-022. Co-Authored-By: Claude Opus 5.5 --- README.md | 24 +- docs/adr/ADR-0007-correo-saliente-smtp.md | 68 ++++ .../ADR-0008-autorizacion-roles-y-ambito.md | 78 ++++ docs/database/README.md | 2 + docs/database/consultas-clave.md | 7 +- docs/database/consultas-clave.sql | 8 +- docs/database/modelo-logico.md | 3 +- docs/database/pruebas-integridad.sql | 4 +- docs/database/seed.sql | 2 +- docs/operations/runbook.md | 17 + pom.xml | 14 + render.yaml | 11 + .../auth/application/UserProvisioning.java | 3 + .../application/UserProvisioningService.java | 11 +- .../api/dto/ConfirmEmailResponse.java | 6 +- .../application/ConfirmEmailUseCase.java | 16 +- .../application/EmailNotifications.java | 26 ++ .../domain/model/EmailMessage.java | 21 + .../notification/domain/port/EmailSender.java | 12 + .../infrastructure/config/MailConfig.java | 29 ++ .../mail/LoggingEmailSender.java | 23 ++ .../infrastructure/mail/SmtpEmailSender.java | 35 ++ .../api/AdminOrganizationController.java | 41 ++ .../api/OrganizationController.java | 69 ++++ .../api/ProviderRegistrationController.java | 54 +++ .../api/ReferenceDataController.java | 37 ++ .../api/dto/OperationPolicyRequest.java | 21 + .../api/dto/OrganizationResponse.java | 40 ++ .../api/dto/ProviderRegistrationResponse.java | 17 + .../api/dto/ReferenceDataResponses.java | 27 ++ .../api/dto/RegisterProviderRequest.java | 91 +++++ .../api/dto/StatusChangeRequest.java | 13 + .../application/ApproveProviderUseCase.java | 50 +++ .../ChangeOperationPolicyUseCase.java | 45 ++ .../application/FindOrganizationsUseCase.java | 36 ++ .../application/OrganizationAccess.java | 47 +++ .../application/ProviderApprovalMailer.java | 46 +++ .../application/ProviderApproved.java | 7 + .../application/ProviderRegistration.java | 9 + .../application/ReferenceDataQuery.java | 30 ++ .../application/RegisterProviderCommand.java | 30 ++ .../application/RegisterProviderUseCase.java | 121 ++++++ .../organization/domain/model/Category.java | 7 + .../organization/domain/model/City.java | 7 + .../model/DuplicateOrganizationException.java | 12 + .../domain/model/IdentificationType.java | 9 + .../organization/domain/model/Location.java | 10 + .../organization/domain/model/MemberRole.java | 17 + .../domain/model/OperationPolicy.java | 38 ++ .../domain/model/Organization.java | 111 +++++ .../domain/model/OrganizationStatus.java | 9 + .../domain/port/OrganizationRepository.java | 42 ++ .../domain/port/ReferenceDataRepository.java | 19 + .../AccountStatusChangeEntity.java | 58 +++ .../AccountStatusChangeJpaRepository.java | 8 + .../persistence/CityEntity.java | 29 ++ .../persistence/CityJpaRepository.java | 8 + .../persistence/LocationEntity.java | 44 ++ .../persistence/LocationJpaRepository.java | 12 + .../persistence/OperationPolicyEntity.java | 46 +++ .../OperationPolicyJpaRepository.java | 24 ++ .../OrganizationCategoryEntity.java | 26 ++ .../OrganizationCategoryJpaRepository.java | 8 + .../persistence/OrganizationEntity.java | 58 +++ .../OrganizationJpaRepository.java | 11 + .../persistence/OrganizationMemberEntity.java | 42 ++ .../OrganizationMemberJpaRepository.java | 14 + .../OrganizationRepositoryAdapter.java | 181 +++++++++ .../ReferenceDataRepositoryAdapter.java | 46 +++ .../shared/config/SecurityConfig.java | 7 + .../shared/error/ErrorCode.java | 2 + .../shared/security/AuthenticatedUser.java | 20 + src/main/resources/application.yml | 13 + .../V4__identificacion_del_proveedor.sql | 30 ++ .../db/migration/V5__datos_de_referencia.sql | 60 +++ .../UserProvisioningServiceTest.java | 27 ++ .../database/BookingRulesIT.java | 11 +- .../application/ConfirmEmailUseCaseTest.java | 15 +- .../notification/NotificationModuleTest.java | 171 ++++++++ .../organization/ProviderOnboardingIT.java | 383 ++++++++++++++++++ .../ApproveProviderUseCaseTest.java | 102 +++++ .../ChangeOperationPolicyUseCaseTest.java | 98 +++++ .../FindOrganizationsUseCaseTest.java | 63 +++ .../application/OrganizationAccessTest.java | 95 +++++ .../ProviderApprovalMailerTest.java | 67 +++ .../RegisterProviderUseCaseTest.java | 245 +++++++++++ .../domain/model/OperationPolicyTest.java | 68 ++++ .../domain/model/OrganizationTest.java | 163 ++++++++ .../OrganizationRepositoryAdapterTest.java | 138 +++++++ .../shared/error/ErrorCodeTest.java | 2 + .../security/AuthenticatedUserTest.java | 37 ++ 91 files changed, 3931 insertions(+), 33 deletions(-) create mode 100644 docs/adr/ADR-0007-correo-saliente-smtp.md create mode 100644 docs/adr/ADR-0008-autorizacion-roles-y-ambito.md create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/application/EmailNotifications.java create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/domain/model/EmailMessage.java create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/domain/port/EmailSender.java create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/infrastructure/config/MailConfig.java create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/infrastructure/mail/LoggingEmailSender.java create mode 100644 src/main/java/com/codefactory/bookingplatform/notification/infrastructure/mail/SmtpEmailSender.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/AdminOrganizationController.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/OrganizationController.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/ProviderRegistrationController.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/ReferenceDataController.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/OperationPolicyRequest.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/OrganizationResponse.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/ProviderRegistrationResponse.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/ReferenceDataResponses.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/RegisterProviderRequest.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/api/dto/StatusChangeRequest.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ApproveProviderUseCase.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ChangeOperationPolicyUseCase.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/FindOrganizationsUseCase.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/OrganizationAccess.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ProviderApprovalMailer.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ProviderApproved.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ProviderRegistration.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/ReferenceDataQuery.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/RegisterProviderCommand.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/application/RegisterProviderUseCase.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/Category.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/City.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/DuplicateOrganizationException.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/IdentificationType.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/Location.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/MemberRole.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/OperationPolicy.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/Organization.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/model/OrganizationStatus.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/port/OrganizationRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/domain/port/ReferenceDataRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/AccountStatusChangeEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/AccountStatusChangeJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/CityEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/CityJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/LocationEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/LocationJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OperationPolicyEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OperationPolicyJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationCategoryEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationCategoryJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationMemberEntity.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationMemberJpaRepository.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationRepositoryAdapter.java create mode 100644 src/main/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/ReferenceDataRepositoryAdapter.java create mode 100644 src/main/java/com/codefactory/bookingplatform/shared/security/AuthenticatedUser.java create mode 100644 src/main/resources/db/migration/V4__identificacion_del_proveedor.sql create mode 100644 src/main/resources/db/migration/V5__datos_de_referencia.sql create mode 100644 src/test/java/com/codefactory/bookingplatform/notification/NotificationModuleTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/ProviderOnboardingIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/ApproveProviderUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/ChangeOperationPolicyUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/FindOrganizationsUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/OrganizationAccessTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/ProviderApprovalMailerTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/application/RegisterProviderUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/domain/model/OperationPolicyTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/domain/model/OrganizationTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/organization/infrastructure/persistence/OrganizationRepositoryAdapterTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/security/AuthenticatedUserTest.java diff --git a/README.md b/README.md index f9848bf..d3230a7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ contraseña). MFA de administradores diferido a Sprint 2. | ADR-0004 API versionada, ProblemDetail, traceId | `docs/adr/ADR-0004-*.md` | | ADR-0005 Modelo físico escrito a mano + CI de BD | `docs/adr/ADR-0005-*.md` | | ADR-0006 Migraciones de esquema con Flyway | `docs/adr/ADR-0006-*.md` | +| ADR-0007 Correo saliente por SMTP | `docs/adr/ADR-0007-*.md` | +| ADR-0008 Autorización: rol de plataforma y rol en el proveedor | `docs/adr/ADR-0008-*.md` | | Guía de la carpeta de base de datos | `docs/database/README.md` | | Modelo lógico completo (ER) | `docs/database/modelo-logico.md` | | Modelo físico: migraciones de Flyway (V1 = las 19 tablas del Sprint 1) | `src/main/resources/db/migration/` | @@ -42,9 +44,13 @@ com.codefactory.bookingplatform │ (security, OpenAPI, properties), observability, persistence ├── identity/ HU-001: domain (Client puro + ports) → application (use cases) │ → infrastructure (JPA + MapStruct) → api (REST /registrations) -└── auth/ HU-021: domain (policies + ports) → application (fachada - UserProvisioning + use cases) → infrastructure (GoTrueClient, - login_attempts) → api (REST /auth) +├── auth/ HU-021: domain (policies + ports) → application (fachada +│ UserProvisioning + use cases) → infrastructure (GoTrueClient, +│ login_attempts) → api (REST /auth) +├── organization/ HU-002: domain (Organization + política versionada) → application +│ (registro, aprobación, OrganizationAccess) → infrastructure (JPA) +│ → api (REST /provider-registrations, /organizations, /admin) +└── notification/ correo saliente: fachada EmailNotifications → SMTP o log (ADR-0007) ``` Reglas de dependencia verificadas con ArchUnit en `src/test/.../architecture/ArchitectureTest.java`. @@ -73,6 +79,11 @@ Notas: | `DATABASE_URL` | `jdbc:postgresql://aws-0-.pooler.supabase.com:5432/postgres?sslmode=require` | JDBC por session pooler, puerto **5432** (nunca 6543) | | `DATABASE_USER` | `postgres.` | Usuario del pooler (con sufijo del proyecto) | | `DATABASE_PASSWORD` | `...` | Contraseña de la BD (Dashboard → Settings → Database) | +| `SPRING_MAIL_HOST` | `smtp.gmail.com` | Opcional. Sin ella el correo se registra en el log en vez de enviarse (ADR-0007) | +| `SPRING_MAIL_PORT` | `587` | Puerto SMTP con STARTTLS | +| `SPRING_MAIL_USERNAME` | `cuenta@gmail.com` | Usuario SMTP | +| `SPRING_MAIL_PASSWORD` | `...` | Contraseña de aplicación de Gmail, no la de la cuenta | +| `APP_MAIL_FROM` | `reservas@...` | Opcional. Remitente; si falta, el usuario SMTP | La app **no lee archivos `.env` por sí sola**: las variables deben existir en el entorno de la terminal donde corres `mvnw`. Cada terminal tiene su @@ -184,6 +195,13 @@ Linux/macOS: | POST | `/api/v1/auth/password-recovery-requests` | pública | HU-021 | | POST | `/api/v1/auth/password-resets` | pública | HU-021 | | GET | `/api/v1/auth/me` | Bearer | HU-021 | +| GET | `/api/v1/organization-categories` | pública | HU-002 | +| GET | `/api/v1/cities` | pública | HU-002 | +| POST | `/api/v1/provider-registrations` | pública | HU-002 | +| GET | `/api/v1/organizations` | Bearer `PROVIDER` | HU-002 | +| GET | `/api/v1/organizations/{id}` | Bearer `PROVIDER` miembro o `ADMIN` | HU-002, HU-022 | +| PUT | `/api/v1/organizations/{id}/operation-policy` | Bearer `OWNER` o `MANAGER` del proveedor | HU-002 | +| POST | `/api/v1/admin/organizations/{id}/approval` | Bearer `ADMIN` | HU-002 | Errores: `application/problem+json` con `errorCode`, `traceId` y `details`. diff --git a/docs/adr/ADR-0007-correo-saliente-smtp.md b/docs/adr/ADR-0007-correo-saliente-smtp.md new file mode 100644 index 0000000..64f25b5 --- /dev/null +++ b/docs/adr/ADR-0007-correo-saliente-smtp.md @@ -0,0 +1,68 @@ +# ADR-0007: El correo propio de la plataforma sale por SMTP con Spring Mail + +- **Estado:** Aceptado +- **Fecha:** 2026-10-25 +- **Prioridad:** Media +- **Decisores:** Equipo de desarrollo — rol Arquitecto de Software + +## Contexto + +Hasta el Sprint 1 todos los correos los enviaba Supabase Auth (GoTrue): la +verificación de la cuenta y la recuperación de la contraseña. GoTrue solo envía +esos correos de autenticación; no sirve para mensajes de la plataforma. + +HU-002 pide el primero de esos mensajes: "cuando un administrador aprueba mi +proveedor, recibo una notificación por correo". HU-015 pedirá después +recordatorios y confirmaciones de reservas, así que la decisión no es solo de +este sprint. + +## Decisión + +1. **Un módulo `notification`** con la estructura de los demás. Los otros + módulos envían a través de su fachada `EmailNotifications` y nunca tocan el + transporte. El puerto `EmailSender` aísla el proveedor de correo. +2. **SMTP con Spring Mail.** Sirve cualquier servidor SMTP; para el MVP, Gmail + con una contraseña de aplicación, que es gratis y llega a cualquier + destinatario sin verificar un dominio. Se configura con las variables + `SPRING_MAIL_HOST`, `SPRING_MAIL_PORT`, `SPRING_MAIL_USERNAME`, + `SPRING_MAIL_PASSWORD` y, opcionalmente, `APP_MAIL_FROM`. STARTTLS y + autenticación están activados en `application.yml`, con tiempos de espera + de 5 s. +3. **Sin SMTP, el correo va al log.** Sin `SPRING_MAIL_HOST`, Spring Boot no + crea el `JavaMailSender` y `MailConfig` instala `LoggingEmailSender`, que + registra destinatario y asunto, pero no el cuerpo. Así el desarrollo local, + las pruebas y un despliegue sin configurar funcionan igual, sin enviar nada. +4. **El correo se envía después del commit.** Un caso de uso publica un evento + y un `@TransactionalEventListener(AFTER_COMMIT)` envía el correo. Si el + envío falla, se registra en el log y la operación de negocio queda en pie. + Esto también evita una llamada SMTP dentro de una transacción, que retiene + una conexión del pool (el mismo hallazgo de QA sobre el registro de + clientes). + +## Alternativas consideradas + +1. **Resend u otro servicio con API HTTP:** API cómoda y plan gratuito, pero + para escribir a cualquier destinatario exige verificar un dominio propio, + que el proyecto no tiene. Cambiar a Resend más adelante es otro adaptador de + `EmailSender`. +2. **Las plantillas de correo de Supabase:** solo cubren los eventos de + autenticación; no hay forma de enviar un mensaje arbitrario. +3. **Diferir el correo y solo registrarlo en el log:** dejaba sin cumplir un + escenario de HU-002 y aplazaba una decisión que HU-015 necesita de todas + formas. + +## Consecuencias + +- (+) HU-002 cumple su escenario de notificación, y HU-015 tiene el canal listo. +- (+) Sin credenciales SMTP la aplicación arranca y funciona igual; nada + depende del correo para completar una operación. +- (−) Gmail limita los envíos diarios de una cuenta personal (del orden de + cientos). Basta para el MVP; con volumen real hay que pasar a un proveedor + transaccional. +- (−) El envío es síncrono, después del commit y en el hilo de la petición: un + SMTP lento retrasa la respuesta, aunque como mucho los 5 s del tiempo de + espera. Si HU-015 lo hace frecuente, se pasa a envío asíncrono o con cola. +- (−) Un correo que falla no se reintenta; queda registrado en el log. + +Ver `notification/` (el módulo), `ProviderApprovalMailer` (el primer uso) y +`docs/operations/runbook.md` (cómo configurar las variables). diff --git a/docs/adr/ADR-0008-autorizacion-roles-y-ambito.md b/docs/adr/ADR-0008-autorizacion-roles-y-ambito.md new file mode 100644 index 0000000..e8572d4 --- /dev/null +++ b/docs/adr/ADR-0008-autorizacion-roles-y-ambito.md @@ -0,0 +1,78 @@ +# ADR-0008: Autorización en dos niveles, rol de plataforma y rol dentro del proveedor + +- **Estado:** Aceptado +- **Fecha:** 2026-10-25 +- **Prioridad:** Alta +- **Decisores:** Equipo de desarrollo — rol Arquitecto de Software, con el Product Owner para los roles + +## Contexto + +HU-022 nombra cinco roles: administrador, gerente, proveedor, recepcionista y +cliente. Además exige que "cada acción valide el rol del usuario y además que +el recurso solicitado pertenezca a su ámbito", y que el acceso fuera de ámbito +"no revele la existencia del recurso". + +El JWT de Supabase lleva un único rol en `app_metadata.role`, que el usuario no +puede editar y que `SupabaseJwtAuthConverter` traduce a `ROLE_*`. El esquema ya +tenía `organization_members` con los roles `OWNER`, `MANAGER` y `STAFF`. +Hasta HU-002 no existía ninguna regla de autorización: cualquier usuario +autenticado podía llamar a cualquier endpoint protegido. + +La pregunta era si el gerente y el recepcionista son roles de la plataforma o +roles dentro de cada proveedor. Una misma persona puede ser recepcionista de +una clínica y no tener nada que ver con otra. + +## Decisión + +1. **Rol de plataforma, en el JWT:** `ADMIN`, `PROVIDER` y `CLIENT`. Dice qué + tipo de usuario es alguien y qué rutas puede usar. Se comprueba en + `SecurityConfig`: + - `/api/v1/admin/**` solo `ADMIN`; + - `/api/v1/organizations/**` solo `PROVIDER` o `ADMIN`; + - lo demás, autenticado o público según el caso. +2. **Rol dentro del proveedor, en `organization_members`:** dice qué puede + hacer alguien en un proveedor concreto. Correspondencia con HU-022: + `OWNER` = proveedor (quien registró el negocio), `MANAGER` = gerente, + `STAFF` = recepcionista y demás personal. Lo comprueban los casos de uso a + través de `OrganizationAccess`, que también usarán los módulos `catalog` y + `scheduling`. +3. **Fuera de ámbito, 404; rol insuficiente, 403.** Quien no es miembro de un + proveedor recibe `404 RESOURCE_NOT_FOUND`, la misma respuesta que para un + proveedor que no existe; así no se revela su existencia. Un miembro cuyo rol + no permite la acción recibe `403 ACCESS_DENIED` con un mensaje que nombra su + rol, que es el "mensaje descriptivo" que pide HU-022. +4. **El primer administrador se crea a mano.** No hay endpoint para crear + administradores: se asigna `app_metadata.role = ADMIN` a un usuario desde + Supabase (runbook, sección 3, paso 6). Así no existe ninguna superficie de ataque para + escalar a administrador. +5. **El rol de un proveedor nuevo es `PROVIDER` desde el registro** (HU-002), + con una cuenta propia, distinta de cualquier cuenta de cliente. + +## Alternativas consideradas + +1. **Cinco roles de plataforma en el JWT:** con un solo rol por token, alguien + que es gerente de un proveedor y cliente de otro no cabría, y el rol global + no dice a qué proveedor se aplica. El ámbito habría que resolverlo igual. +2. **Responder 403 también fuera de ámbito:** es más simple, pero confirma que + el recurso existe, y HU-022 lo prohíbe expresamente. +3. **Un endpoint para crear administradores protegido por otro administrador:** + es útil más adelante (HU-022 "gestionar roles"), pero no resuelve cómo se + crea el primero y añade una superficie sensible antes de necesitarla. + +## Consecuencias + +- (+) El ámbito por proveedor queda resuelto en un solo lugar, reutilizable por + los módulos del sprint. +- (+) Un usuario puede pertenecer a varios proveedores con roles distintos sin + tocar el token. +- (−) El rol de plataforma se lee del JWT: un cambio de rol no toma efecto hasta + que el token expira (15 min). HU-022 pide que sea inmediato; queda pendiente + junto con D8 (revocación de tokens). +- (−) Cada caso de uso de un proveedor debe acordarse de llamar a + `OrganizationAccess`. Las pruebas de integración de cada HU tienen que cubrir + el 404 y el 403. +- (−) Los usuarios `STAFF` y `MANAGER` tienen rol de plataforma `PROVIDER`; + invitarlos a un proveedor es trabajo de una HU posterior. + +Ver `OrganizationAccess`, `SecurityConfig`, `AuthenticatedUser` y +`ProviderOnboardingIT` (las reglas en acción). diff --git a/docs/database/README.md b/docs/database/README.md index 26a1e59..4a9cc0a 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -13,6 +13,8 @@ arrancar, en orden de versión: | `V1__esquema_inicial.sql` | Las diecinueve tablas del Sprint 1 con sus restricciones, triggers e índices. Es el antiguo `schema.sql` (ADR-0005) | | `V2__rls.sql` | Habilita Row Level Security en las diecinueve tablas. Es el antiguo `rls.sql` | | `V3__correcciones_reservas_y_servicios.sql` | Un servicio nuevo nace inactivo; las reglas de reservas ya no impiden cancelar ni cerrar las de un proveedor suspendido o un servicio desactivado; el aforo grupal resiste reservas simultáneas | +| `V4__identificacion_del_proveedor.sql` | El proveedor se identifica con tipo (NIT, CC, CE, PASAPORTE) y número; `nit` pasa a `identification_number` y la unicidad es por la pareja | +| `V5__datos_de_referencia.sql` | Categorías, ciudades y la moneda COP. Son datos de referencia que la aplicación necesita en toda base, no datos de prueba | | `afterMigrate__rls_historial_flyway.sql` | Callback, no migración: corre al final de cada `migrate` y habilita RLS en `flyway_schema_history`, que Flyway tiene bloqueada mientras migra | Una migración aplicada no se modifica nunca: Flyway guarda su checksum y no diff --git a/docs/database/consultas-clave.md b/docs/database/consultas-clave.md index c34ff93..a169229 100644 --- a/docs/database/consultas-clave.md +++ b/docs/database/consultas-clave.md @@ -96,7 +96,7 @@ from bookings b inner join services s on s.id = b.service_id inner join clients c on c.id = b.client_id inner join locations l on l.id = b.location_id -where s.organization_id = (select id from organizations where nit = :nit) +where s.organization_id = (select id from organizations where identification_type = 'NIT' and identification_number = :nit) and b.starts_at >= now() and b.status in ('RESERVED', 'CONFIRMED') order by b.starts_at; @@ -124,7 +124,7 @@ order by l.name, r.name; ### 7. ¿Qué proveedores llevan más tiempo esperando aprobación? (cola del administrador) ```sql -select o.name as organizacion, o.nit, oc.name as categoria, o.contact_email, +select o.name as organizacion, o.identification_type, o.identification_number, oc.name as categoria, o.contact_email, o.created_at::date as solicitado_el, current_date - o.created_at::date as dias_esperando from organizations o @@ -184,7 +184,8 @@ Las tres validaciones previas al registro en una sola ida a la base. ```sql select exists (select 1 from clients where lower(email) = lower(:email)) as correo_ya_registrado, exists (select 1 from clients where document = :documento) as documento_ya_registrado, - exists (select 1 from organizations where nit = :nit) as nit_ya_registrado; + exists (select 1 from organizations where identification_type = 'NIT' + and identification_number = :nit) as nit_ya_registrado; ``` ### 12. ¿Qué clientes se registraron y nunca han reservado? (subconsulta correlacionada) diff --git a/docs/database/consultas-clave.sql b/docs/database/consultas-clave.sql index 7f3e647..2237a33 100644 --- a/docs/database/consultas-clave.sql +++ b/docs/database/consultas-clave.sql @@ -110,7 +110,7 @@ from bookings b inner join services s on s.id = b.service_id inner join clients c on c.id = b.client_id inner join locations l on l.id = b.location_id -where s.organization_id = (select id from organizations where nit = '900123456-1') +where s.organization_id = (select id from organizations where identification_type = 'NIT' and identification_number = '900123456-1') and b.starts_at >= now() and b.status in ('RESERVED', 'CONFIRMED') order by b.starts_at; @@ -140,7 +140,8 @@ order by l.name, r.name; -- de trabajo del administrador (HU-003). \echo '=== 7. Cola de proveedores pendientes de aprobación ===' select o.name as organizacion, - o.nit, + o.identification_type, + o.identification_number, oc.name as categoria, o.contact_email, o.created_at::date as solicitado_el, @@ -204,7 +205,8 @@ order by o.name, b.starts_at; \echo '=== 11. Validación de unicidad antes de registrar ===' select exists (select 1 from clients where lower(email) = lower('laura@example.com')) as correo_ya_registrado, exists (select 1 from clients where document = '1020334455') as documento_ya_registrado, - exists (select 1 from organizations where nit = '900123456-1') as nit_ya_registrado; + exists (select 1 from organizations where identification_type = 'NIT' + and identification_number = '900123456-1') as nit_ya_registrado; -- 12. ¿Qué clientes se registraron y nunca han reservado? Subconsulta diff --git a/docs/database/modelo-logico.md b/docs/database/modelo-logico.md index 8fc7536..d75eeae 100644 --- a/docs/database/modelo-logico.md +++ b/docs/database/modelo-logico.md @@ -70,7 +70,8 @@ erDiagram uuid id PK uuid category_id FK string name - string nit UK "HU-002" + string identification_type "NIT, CC, CE o PASAPORTE (V4)" + string identification_number UK "HU-002, único con su tipo" string contact_email "CHECK de formato" string contact_phone string timezone diff --git a/docs/database/pruebas-integridad.sql b/docs/database/pruebas-integridad.sql index e708259..3dd0c31 100644 --- a/docs/database/pruebas-integridad.sql +++ b/docs/database/pruebas-integridad.sql @@ -17,7 +17,7 @@ \echo '' \echo '--- 1. Clave candidata: el NIT ya está registrado (HU-002) ---' -insert into organizations (category_id, name, nit, contact_email, contact_phone) +insert into organizations (category_id, name, identification_number, contact_email, contact_phone) values ('22222222-0000-0000-0000-000000000001', 'Otra Clínica', '900123456-1', 'x@y.com', '6040000000'); @@ -37,7 +37,7 @@ values ('66666666-0000-0000-0000-000000000001', 3, 60, 1000, 60); \echo '' \echo '--- 4. Una organización activa sin fecha de aprobación (HU-002) ---' -insert into organizations (category_id, name, nit, contact_email, contact_phone, status) +insert into organizations (category_id, name, identification_number, contact_email, contact_phone, status) values ('22222222-0000-0000-0000-000000000001', 'Sin Aprobar', '903000000-9', 'a@b.com', '6040000001', 'ACTIVE'); diff --git a/docs/database/seed.sql b/docs/database/seed.sql index e7b4f1e..b996f8b 100644 --- a/docs/database/seed.sql +++ b/docs/database/seed.sql @@ -66,7 +66,7 @@ insert into login_attempts (email, success, attempted_at) values ('mariana@example.com', true, now() - interval '10 minutes'); -- ---------- organizaciones ---------- -insert into organizations (id, category_id, name, nit, contact_email, contact_phone, timezone, status, approved_at, created_at, updated_at) values +insert into organizations (id, category_id, name, identification_number, contact_email, contact_phone, timezone, status, approved_at, created_at, updated_at) values ('66666666-0000-0000-0000-000000000001', '22222222-0000-0000-0000-000000000002', 'Barbería El Poblado', '900123456-1', 'contacto@barberia.com', '6041112233', 'America/Bogota', 'ACTIVE', now() - interval '60 days', now() - interval '65 days', now() - interval '10 days'), ('66666666-0000-0000-0000-000000000002', '22222222-0000-0000-0000-000000000001', 'Clínica Dental Sur', '901234567-2', 'citas@dentalsur.com', '6042223344', 'America/Bogota', 'ACTIVE', now() - interval '30 days', now() - interval '35 days', now() - interval '30 days'), -- Pendiente hace 45 días: no es visible NI reservable (HU-002), diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index b25da98..596d1dc 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -56,6 +56,16 @@ devolver 401 es el comportamiento esperado, no una falla. ``` El backend conecta como dueño y bypasea RLS; el Data API público queda bloqueado. +6. **Primer administrador (ADR-0008):** no hay endpoint para crear + administradores. Registrar la cuenta como cliente (o crearla en + Authentication → Users), confirmar el correo y, en el SQL Editor: + ```sql + update auth.users + set raw_app_meta_data = raw_app_meta_data || '{"role": "ADMIN"}' + where email = 'admin@ejemplo.com'; + ``` + El rol entra en el JWT en el siguiente inicio de sesión. Si la cuenta tenía + perfil de cliente, sigue existiendo; no estorba. ## 4. Despliegue en Render @@ -65,6 +75,13 @@ Variables requeridas: `SPRING_PROFILES_ACTIVE=cloud`, `DATABASE_URL` `SUPABASE_URL`, `SUPABASE_SECRET_KEY`. Sin Secret Files (la app lee entorno, no archivos). **Health Check Path:** `/actuator/health/readiness`. +Correo saliente (ADR-0007), opcional: `SPRING_MAIL_HOST=smtp.gmail.com`, +`SPRING_MAIL_PORT=587`, `SPRING_MAIL_USERNAME` y `SPRING_MAIL_PASSWORD` (una +contraseña de aplicación de Gmail, que exige verificación en dos pasos en la +cuenta), y `APP_MAIL_FROM` si el remitente no es el usuario. Sin +`SPRING_MAIL_HOST` la app funciona igual y deja cada correo en el log con +`No SMTP server configured; email not sent`. + Restricciones del free tier aplicadas en `application-cloud.yml`: `server.port=${PORT:8080}` (Render inyecta `PORT`) y pool Hikari de 5/1, porque el session pooler gratuito admite **15 clientes totales** entre todas diff --git a/pom.xml b/pom.xml index 36e289c..770ab68 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,11 @@ spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-mail + + org.springdoc @@ -357,6 +362,15 @@ com.codefactory.bookingplatform.shared.config.OpenApiConfig com.codefactory.bookingplatform.shared.config.ClockConfig com.codefactory.bookingplatform.shared.persistence.JpaAuditingConfig + com.codefactory.bookingplatform.organization.domain.model.OrganizationStatus + com.codefactory.bookingplatform.organization.domain.model.IdentificationType + com.codefactory.bookingplatform.organization.domain.model.MemberRole + com.codefactory.bookingplatform.organization.domain.model.Location + com.codefactory.bookingplatform.organization.domain.model.Category + com.codefactory.bookingplatform.organization.domain.model.City + com.codefactory.bookingplatform.organization.application.RegisterProviderCommand* + com.codefactory.bookingplatform.organization.application.ProviderRegistration + com.codefactory.bookingplatform.organization.application.ProviderApproved