diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index da35f24..cfa19a3 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -70,26 +70,29 @@ jobs: command -v psql || sudo apt-get update && sudo apt-get install -y postgresql-client psql --version - - name: Las migraciones ejecutan contra una base vacía + # V1 y V2 se vuelven a aplicar sobre las bases creadas antes de Flyway + # (línea base 0), así que tienen que seguir siendo idempotentes. En ese + # caso corren antes que las demás migraciones, y así se comprueba: V1 y + # V2 dos veces, y después el resto, que corre una sola vez. + - name: V1 y V2 ejecutan contra una base vacía y son idempotentes + run: | + for vez in 1 2; do + $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V1__esquema_inicial.sql + $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V2__rls.sql + done + + - name: Las migraciones siguientes ejecutan en orden run: | - for f in $(ls $MIGRACIONES/V*__*.sql | sort -V); do + for f in $(ls $MIGRACIONES/V*__*.sql | sort -V | grep -v -e '/V1__' -e '/V2__'); do echo "aplicando $f" $PSQL -v ON_ERROR_STOP=1 -q -f "$f" done - - name: Se crearon las 19 tablas + - name: Se crearon las 23 tablas run: | n=$($PSQL -tAc "select count(*) from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r'") echo "tablas creadas: $n" - test "$n" -eq 19 - - # V1 y V2 se vuelven a aplicar sobre las bases creadas antes de Flyway - # (línea base 0), así que tienen que seguir siendo idempotentes. Las - # migraciones siguientes corren una sola vez y no necesitan serlo. - - name: V1 y V2 son idempotentes - run: | - $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V1__esquema_inicial.sql - $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V2__rls.sql + test "$n" -eq 23 - name: Los datos de prueba cargan run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/seed.sql @@ -114,7 +117,7 @@ jobs: rechazos=$(echo "$salida" | grep -cE '(^|: )ERROR:' || true) coladas=$(echo "$salida" | grep -cE '^(INSERT|UPDATE|DELETE) ' || true) echo "rechazos: $rechazos · instrucciones que pasaron: $coladas" - test "$rechazos" -eq 19 + test "$rechazos" -eq 24 test "$coladas" -eq 0 # Ninguna tabla de public sin RLS, sean cuantas sean: una migración diff --git a/README.md b/README.md index f9848bf..2f652d9 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 de la plataforma: bandeja en la app, SMTP opcional | `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,21 @@ 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) +├── catalog/ HU-004, HU-009: domain (OfferedService, Resource con mantenimientos y +│ retiro) → application (use cases, ámbito vía OrganizationAccess) → +│ infrastructure (JPA + JDBC) → api (REST /organizations/{id}/services, +│ /resource-types, /resources) +├── scheduling/ HU-006: domain (Agenda con bloques, vigencia y granularidad) → +│ application (use cases; reservas futuras vía puerto) → infrastructure +│ (JPA + JDBC) → api (REST /organizations/{id}/agendas) +├── organization/ HU-002: domain (Organization + política versionada) → application +│ (registro, aprobación, OrganizationAccess) → infrastructure (JPA) +│ → api (REST /provider-registrations, /organizations, /admin) +└── notification/ correo de la plataforma: fachada EmailNotifications → bandeja en la + app (GET /notifications/emails) o SMTP si se configura (ADR-0007) ``` Reglas de dependencia verificadas con ArchUnit en `src/test/.../architecture/ArchitectureTest.java`. @@ -73,6 +87,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, para local. Sin ella los correos van a la bandeja de la app (ADR-0007). Render gratis bloquea SMTP | +| `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 +203,30 @@ 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/catalog/services?q=&categoryId=&cityId=&providerId=&minPrice=&maxPrice=&sort=&direction=&page=&size=` | pública | HU-005 | +| 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 | +| GET | `/api/v1/admin/organizations`, `/api/v1/admin/organizations/export` (CSV) | Bearer `ADMIN` | HU-003 | +| POST | `/api/v1/admin/organizations/{id}/suspension`, `/reactivation`, `/rejection` | Bearer `ADMIN` | HU-003 | +| GET | `/api/v1/admin/clients`, `/api/v1/admin/clients/export` (CSV) | Bearer `ADMIN` | HU-003 | +| POST | `/api/v1/admin/clients/{id}/suspension`, `/reactivation` | Bearer `ADMIN` | HU-003 | +| GET | `/api/v1/notifications/emails` | Bearer | HU-002 (correo de aprobación), ADR-0007 | +| GET, POST | `/api/v1/organizations/{id}/resource-types` | Bearer miembro (POST: `OWNER` o `MANAGER`) | HU-009 | +| GET, POST | `/api/v1/organizations/{id}/resources` | Bearer miembro (POST: `OWNER` o `MANAGER`) | HU-009 | +| POST | `/api/v1/organizations/{id}/resources/{resourceId}/maintenances` | Bearer `OWNER` o `MANAGER` | HU-009 | +| POST | `/api/v1/organizations/{id}/resources/{resourceId}/retirement` | Bearer `OWNER` o `MANAGER` | HU-009 | +| GET, POST | `/api/v1/organizations/{id}/services` | Bearer miembro (POST: `OWNER` o `MANAGER`) | HU-004 | +| GET, PUT, DELETE | `/api/v1/organizations/{id}/services/{serviceId}` | Bearer miembro (PUT, DELETE: `OWNER` o `MANAGER`) | HU-004 | +| POST | `/api/v1/organizations/{id}/services/{serviceId}/activation`, `/deactivation` | Bearer `OWNER` o `MANAGER` | HU-004, HU-009 | +| GET, POST | `/api/v1/organizations/{id}/agendas` | Bearer miembro (POST: `OWNER` o `MANAGER`) | HU-006 | +| GET | `/api/v1/organizations/{id}/agendas/{agendaId}` | Bearer miembro | HU-006 | +| PUT | `/api/v1/organizations/{id}/agendas/{agendaId}/blocks`, `/granularity` | Bearer `OWNER` o `MANAGER` | HU-006 | +| POST | `/api/v1/organizations/{id}/agendas/{agendaId}/day-copies` | Bearer `OWNER` o `MANAGER` | HU-006 | Errores: `application/problem+json` con `errorCode`, `traceId` y `details`. diff --git a/docs/adr/ADR-0007-correo-de-la-plataforma.md b/docs/adr/ADR-0007-correo-de-la-plataforma.md new file mode 100644 index 0000000..81a2740 --- /dev/null +++ b/docs/adr/ADR-0007-correo-de-la-plataforma.md @@ -0,0 +1,68 @@ +# ADR-0007: El correo de la plataforma llega a una bandeja dentro de la aplicación + +- **Estado:** Aceptado +- **Fecha:** 2026-10-25 +- **Prioridad:** Media +- **Decisores:** Equipo de desarrollo — rol Arquitecto de Software, con el Product Owner + +## 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. + +La primera idea fue SMTP con una cuenta de Gmail. No funciona en producción: +desde el 26 de septiembre de 2025, el plan gratuito de Render bloquea la salida +a los puertos SMTP (25, 465 y 587). Las alternativas que sí salen por HTTP +(Brevo, Resend, SendGrid) exigen crear una cuenta, verificar un remitente o un +dominio y gestionar una clave. Para el MVP y su demostración, el equipo prefiere +no depender de un servicio externo más. + +## 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 el puerto + `EmailSender` aísla el transporte. +2. **Sin SMTP configurado, el correo se guarda en una bandeja de la + aplicación** (tabla `sent_emails`, V6). Su destinatario lo lee con + `GET /api/v1/notifications/emails`, que devuelve los correos dirigidos al + email de su token, del más reciente al más antiguo. Es lo que ocurre en + Render: no hay nada que configurar. +3. **SMTP queda como opción.** Con `SPRING_MAIL_HOST` (y `SPRING_MAIL_PORT`, + `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD`, `APP_MAIL_FROM`), Spring + Boot crea el `JavaMailSender` y `MailConfig` envía por SMTP. Sirve en local + o en un plan de pago. +4. **El correo se envía después del commit.** Un caso de uso publica un evento + y un `@TransactionalEventListener(AFTER_COMMIT)` lo envía. Si falla, se + registra en el log y la operación de negocio queda en pie. Como en ese + momento la transacción de negocio ya confirmó pero sigue ligada al hilo, la + bandeja escribe en una transacción propia (`REQUIRES_NEW`). + +## Alternativas consideradas + +1. **Gmail por SMTP:** gratis y sin dominio, pero bloqueado en el plan + gratuito de Render; solo funcionaría en local. +2. **Un proveedor con API HTTP (Brevo, Resend):** es el destino si la + plataforma necesita correo real, y es otro adaptador de `EmailSender`, sin + tocar los casos de uso. Hoy exigiría cuentas y claves que el equipo no + quiere gestionar para el MVP. +3. **Solo registrar el correo en el log:** no requiere nada, pero no se puede + mostrar en una demostración ni lo puede consultar el destinatario. + +## Consecuencias + +- (+) HU-002 cumple su escenario de notificación sin configurar nada, y el + mensaje se puede mostrar: el proveedor abre su bandeja y ve la aprobación. +- (+) HU-015 tiene el canal listo, y pasar a correo real es un adaptador más. +- (−) En producción el correo no sale de la plataforma: el destinatario tiene + que entrar a leerlo. Es una bandeja de notificaciones, no correo real. +- (−) La bandeja crece sin límite; no hay borrado ni paginación. Para el MVP es + aceptable. +- (−) Un correo que falla no se reintenta; queda registrado en el log. + +Ver `notification/` (el módulo), `ProviderApprovalMailer` (el primer uso) y +`ProviderOnboardingIT` (la aprobación llega a la bandeja del proveedor). 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..d037b97 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -13,6 +13,12 @@ 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 | +| `V6__bandeja_de_correo.sql` | `sent_emails`: los correos de la plataforma se guardan ahí y su destinatario los lee por la API, porque Render gratis bloquea SMTP (ADR-0007) | +| `V7__recursos_capacidad_y_mantenimiento.sql` | Recursos con capacidad, estado `ACTIVE`/`RETIRED` y organización (que cuadra tipo y sede); tabla `resource_maintenances` de periodos sin solape (HU-009) | +| `V8__agendas.sql` | `agendas` (sede, recurso opcional, granularidad 15/30/60, vigencia, sin dos vigentes a la vez por sede o recurso), `agenda_services` y `schedules` como bloques de una agenda (HU-006) | +| `V9__busqueda_sin_tildes.sql` | Función `f_sin_tildes`: minúsculas y sin tildes, para la búsqueda del catálogo (HU-005). Sin la extensión `unaccent`, que cambia de esquema entre Supabase y PostgreSQL | | `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 @@ -27,7 +33,7 @@ siguiente. Si crea tablas, habilita RLS en ellas en la misma migración; |---|---|---| | `seed.sql` | Datos de prueba. Tres organizaciones, cinco servicios, ocho reservas | Sí, después de las migraciones | | `consultas-clave.sql` | Las catorce preguntas de negocio, ejecutables de corrido | Sí, para ver resultados | -| `pruebas-integridad.sql` | Diecinueve instrucciones que violan una regla cada una y deben ser rechazadas | Sí, para comprobar | +| `pruebas-integridad.sql` | Veinticuatro instrucciones que violan una regla cada una y deben ser rechazadas | Sí, para comprobar | | `modelo-logico.md` | Diagrama entidad-relación, decisiones de modelado y normalización | No, se lee | | `consultas-clave.md` | Las mismas catorce preguntas con su explicación | No, se lee | | `diagrama-er.drawio` | Diagrama editable. La primera página, **Modelo completo**, tiene las diecinueve entidades, las notas y las historias de usuario en una sola hoja; las cuatro siguientes son las tres vistas por dominio y la hoja de historias de usuario | No | @@ -87,12 +93,11 @@ No ejecutes `seed.sql` ni `pruebas-integridad.sql` contra Supabase. El workflow `.github/workflows/database.yml` corre en cada pull request que toque esta carpeta, las migraciones o el mapeo JPA. Comprueba que: -- las migraciones ejecuten contra una base vacía; -- V1 y V2 sigan siendo idempotentes; -- se creen las diecinueve tablas; +- V1 y V2 ejecuten dos veces seguidas, como sobre una base anterior a Flyway, y después las demás migraciones en orden; +- se creen las veintitrés tablas (las diecinueve de V1, la bandeja de V6, los mantenimientos de V7 y las dos de agendas de V8); - los datos de prueba carguen; - las catorce consultas devuelvan filas; -- las diecinueve pruebas de integridad sean rechazadas; +- las veinticuatro pruebas de integridad sean rechazadas; - ninguna tabla quede sin RLS. Las pruebas de integración del build corren sobre el esquema que crea Flyway, diff --git a/docs/database/consultas-clave.md b/docs/database/consultas-clave.md index c34ff93..1c31d01 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; @@ -110,7 +110,10 @@ El `left join` es lo que deja ver también los recursos completamente libres. select l.name as sede, r.name as recurso, sch.start_time as abre, sch.end_time as cierra, count(br.booking_id) as reservas_vivas from schedules sch - inner join resources r on r.id = sch.resource_id + inner join agendas a on a.id = sch.agenda_id + and (now() + interval '2 days')::date >= a.valid_from + and (a.valid_until is null or (now() + interval '2 days')::date <= a.valid_until) + inner join resources r on r.id = a.resource_id inner join locations l on l.id = r.location_id left join booking_resources br on br.resource_id = r.id and br.status in ('RESERVED', 'CONFIRMED') @@ -124,7 +127,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 +187,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..bc2fb59 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; @@ -125,7 +125,10 @@ select l.name as sede, sch.end_time as cierra, count(br.booking_id) as reservas_vivas from schedules sch - inner join resources r on r.id = sch.resource_id + inner join agendas a on a.id = sch.agenda_id + and (now() + interval '2 days')::date >= a.valid_from + and (a.valid_until is null or (now() + interval '2 days')::date <= a.valid_until) + inner join resources r on r.id = a.resource_id inner join locations l on l.id = r.location_id left join booking_resources br on br.resource_id = r.id and br.status in ('RESERVED', 'CONFIRMED') @@ -140,7 +143,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 +208,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..b800295 100644 --- a/docs/database/modelo-logico.md +++ b/docs/database/modelo-logico.md @@ -27,8 +27,12 @@ erDiagram RESOURCE_TYPE ||--o{ SERVICE_RESOURCE_REQUIREMENT : "es exigido por" RESOURCE_TYPE ||--|{ RESOURCE : "se instancia en" LOCATION ||--o{ RESOURCE : "aloja" - LOCATION ||--o{ SCHEDULE : "abre en" - RESOURCE ||--o{ SCHEDULE : "disponible en" + LOCATION ||--o{ AGENDA : "abre en" + RESOURCE |o--o{ AGENDA : "opcional, agenda propia" + AGENDA ||--|{ SCHEDULE : "bloques semanales" + AGENDA ||--|{ AGENDA_SERVICE : "reserva" + SERVICE_LOCATION ||--o{ AGENDA_SERVICE : "se reserva en" + RESOURCE ||--o{ RESOURCE_MAINTENANCE : "se detiene en" CLIENT ||--o{ BOOKING : "reserva" ORGANIZATION ||--o{ BOOKING : "atiende" SERVICE ||--o{ BOOKING : "es reservado en" @@ -70,7 +74,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 @@ -153,17 +158,40 @@ erDiagram } RESOURCE { uuid id PK + uuid organization_id FK "V7, cuadra tipo y sede" uuid resource_type_id FK uuid location_id FK "UK con name" string name - string status "ACTIVE INACTIVE" + int capacity "V7, personas a la vez" + string status "ACTIVE RETIRED (V7)" } - SCHEDULE { + RESOURCE_MAINTENANCE { + uuid id PK + uuid resource_id FK + timestamptz starts_at "sin solape por recurso" + timestamptz ends_at + string reason + } + AGENDA { uuid id PK + uuid organization_id FK uuid location_id FK - uuid resource_id FK "nulo = toda la sede" + uuid resource_id FK "nulo = toda la sede (V8)" + string name "UK por organización" + smallint slot_minutes "15, 30 o 60" + date valid_from "EXCLUDE: sin dos vigentes por sede o recurso" + date valid_until "nulo = sin fin" + } + AGENDA_SERVICE { + uuid agenda_id PK + uuid service_id PK + uuid location_id FK "el servicio se presta en la sede" + } + SCHEDULE { + uuid id PK + uuid agenda_id FK "V8" smallint day_of_week "ISO 1 lunes a 7 domingo" - time start_time "EXCLUDE sin solapes" + time start_time "EXCLUDE sin solapes en la agenda" time end_time boolean active } diff --git a/docs/database/pruebas-integridad.sql b/docs/database/pruebas-integridad.sql index e708259..91dcb08 100644 --- a/docs/database/pruebas-integridad.sql +++ b/docs/database/pruebas-integridad.sql @@ -8,7 +8,7 @@ -- -- Uso: con las migraciones de src/main/resources/db/migration ya aplicadas, -- psql -f seed.sql -f pruebas-integridad.sql --- Se ejecuta SIN ON_ERROR_STOP para que se vean los diecinueve rechazos. +-- Se ejecuta SIN ON_ERROR_STOP para que se vean los veinticuatro rechazos. -- Usa metacomandos de psql, así que se ejecuta con `psql -f`. -- ===================================================================== @@ -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'); @@ -112,9 +112,9 @@ where booking_id = 'dddd0000-0000-0000-0000-000000000001'; \echo '' -\echo '--- 13. Franja de agenda solapada con otra del mismo recurso ---' -insert into schedules (location_id, resource_id, day_of_week, start_time, end_time) -values ('88888888-0000-0000-0000-000000000001', 'cccc0000-0000-0000-0000-000000000001', 1, '07:00', '23:00'); +\echo '--- 13. Bloque solapado con otro de la misma agenda el mismo día (HU-006) ---' +insert into schedules (agenda_id, day_of_week, start_time, end_time) +select id, 1, '07:00', '23:00' from agendas where resource_id = 'cccc0000-0000-0000-0000-000000000001'; \echo '' @@ -158,9 +158,44 @@ values ('44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-0000000 now() + interval '9 days', now() + interval '9 days' + interval '60 minutes', 250000); +\echo '' +\echo '--- 20. Recurso con el tipo de un proveedor y la sede de otro (HU-009, V7) ---' +insert into resources (organization_id, resource_type_id, location_id, name) +values ('66666666-0000-0000-0000-000000000002', 'bbbb0000-0000-0000-0000-000000000001', + '88888888-0000-0000-0000-000000000001', 'Silla cruzada'); + + +\echo '' +\echo '--- 21. Dos mantenimientos solapados del mismo recurso (HU-009, V7) ---' +insert into resource_maintenances (resource_id, starts_at, ends_at) +values ('cccc0000-0000-0000-0000-000000000001', now() + interval '1 day', now() + interval '3 days'), + ('cccc0000-0000-0000-0000-000000000001', now() + interval '2 days', now() + interval '4 days'); + + +\echo '' +\echo '--- 22. Agenda con una granularidad que no es 15, 30 ni 60 minutos (HU-006, V8) ---' +insert into agendas (organization_id, location_id, name, slot_minutes, valid_from) +values ('66666666-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000002', + 'Agenda de 20 minutos', 20, current_date); + + +\echo '' +\echo '--- 23. Dos agendas vigentes a la vez para el mismo recurso (HU-006, V8) ---' +insert into agendas (organization_id, location_id, resource_id, name, slot_minutes, valid_from) +values ('66666666-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', + 'cccc0000-0000-0000-0000-000000000001', 'Agenda duplicada', 30, current_date); + + +\echo '' +\echo '--- 24. Servicio reservable en una agenda de una sede donde no se presta (HU-006, V8) ---' +insert into agenda_services (agenda_id, service_id, location_id) +select id, 'aaaa0000-0000-0000-0000-000000000002', location_id +from agendas where resource_id = 'cccc0000-0000-0000-0000-000000000006'; + + \echo '' \echo '=====================================================================' -\echo ' Las diecinueve instrucciones anteriores debieron ser rechazadas.' +\echo ' Las veinticuatro instrucciones anteriores debieron ser rechazadas.' \echo ' Si alguna dice INSERT, UPDATE o DELETE en vez de ERROR, hay una' \echo ' regla de negocio que la base no está garantizando.' \echo '=====================================================================' diff --git a/docs/database/seed.sql b/docs/database/seed.sql index e7b4f1e..5843fd8 100644 --- a/docs/database/seed.sql +++ b/docs/database/seed.sql @@ -23,7 +23,7 @@ begin end $$; truncate table account_status_changes, booking_status_changes, booking_resources, - bookings, schedules, resources, service_resource_requirements, + bookings, schedules, agenda_services, agendas, resource_maintenances, sent_emails, resources, service_resource_requirements, service_locations, services, resource_types, locations, organization_policies, organization_members, organizations, login_attempts, clients, @@ -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), @@ -129,24 +129,37 @@ insert into service_resource_requirements (service_id, resource_type_id, organiz ('aaaa0000-0000-0000-0000-000000000004', 'bbbb0000-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002', 1), ('aaaa0000-0000-0000-0000-000000000005', 'bbbb0000-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002', 1); -insert into resources (id, resource_type_id, location_id, name, status) values - ('cccc0000-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 1', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000002', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 2', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000003', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 3', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000004', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 4', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000005', 'bbbb0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000001', 'Salón principal', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000006', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000002', 'Silla única', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000007', 'bbbb0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000003', 'Unidad A', 'ACTIVE'), - ('cccc0000-0000-0000-0000-000000000008', 'bbbb0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000003', 'Unidad B', 'INACTIVE'); - --- ---------- agenda: lunes a viernes 8:00-18:00, sábado 8:00-13:00 ---------- -insert into schedules (location_id, resource_id, day_of_week, start_time, end_time, active) -select r.location_id, r.id, d.dow, time '08:00', - case when d.dow in (6, 7) then time '13:00' else time '18:00' end, true +-- capacity: personas que el recurso atiende a la vez (V7). El salón del taller recibe al grupo. +insert into resources (id, organization_id, resource_type_id, location_id, name, capacity, status) values + ('cccc0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 1', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 2', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 3', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000004', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', 'Silla 4', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000005', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000001', 'Salón principal', 12, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000006', '66666666-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000002', 'Silla única', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000007', '66666666-0000-0000-0000-000000000002', 'bbbb0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000003', 'Unidad A', 1, 'ACTIVE'), + ('cccc0000-0000-0000-0000-000000000008', '66666666-0000-0000-0000-000000000002', 'bbbb0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000003', 'Unidad B', 1, 'RETIRED'); + +-- ---------- agendas (V8): una por recurso activo ---------- +-- Granularidad de 30 minutos, vigente desde hace un mes y sin fecha de fin, +-- con los servicios que se prestan en su sede. Bloques de lunes a viernes +-- 8:00-18:00 y fines de semana 8:00-13:00. +insert into agendas (organization_id, location_id, resource_id, name, slot_minutes, valid_from) +select r.organization_id, r.location_id, r.id, 'Agenda ' || r.name, 30, current_date - 30 from resources r -cross join (select generate_series(1, 7) as dow) d where r.status = 'ACTIVE'; +insert into agenda_services (agenda_id, service_id, location_id) +select a.id, sl.service_id, a.location_id +from agendas a + inner join service_locations sl on sl.location_id = a.location_id; + +insert into schedules (agenda_id, day_of_week, start_time, end_time, active) +select a.id, d.dow, time '08:00', + case when d.dow in (6, 7) then time '13:00' else time '18:00' end, true +from agendas a +cross join (select generate_series(1, 7) as dow) d; + -- ---------- reservas ---------- -- session_id: en servicios individuales coincide con el id de la reserva; -- en el taller grupal DOS reservas comparten la misma sesión ffff...0001 diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index b25da98..aa3fff9 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 de la plataforma (ADR-0007): no hay que configurar nada. El plan +gratuito de Render bloquea SMTP, así que los correos (por ejemplo, la +aprobación de un proveedor) se guardan en `sent_emails` y su destinatario los +lee con `GET /api/v1/notifications/emails`. En el log aparece +`Email '...' stored in the in-app inbox`. Las variables `SPRING_MAIL_*` solo +sirven en local o en un plan de pago. + 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..106d0b6 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,24 @@ 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 + com.codefactory.bookingplatform.notification.domain.model.SentEmail + com.codefactory.bookingplatform.catalog.domain.model.ResourceStatus + com.codefactory.bookingplatform.catalog.domain.model.ResourceCondition + com.codefactory.bookingplatform.catalog.domain.model.ResourceType + com.codefactory.bookingplatform.catalog.domain.model.ServiceStatus + com.codefactory.bookingplatform.catalog.domain.model.CatalogEntry + com.codefactory.bookingplatform.organization.domain.model.ProviderSummary + com.codefactory.bookingplatform.identity.domain.model.ClientSummary + com.codefactory.bookingplatform.catalog.domain.model.OfferedService$Details