Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions .github/workflows/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Expand All @@ -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`.
Expand Down Expand Up @@ -73,6 +87,11 @@ Notas:
| `DATABASE_URL` | `jdbc:postgresql://aws-0-<region>.pooler.supabase.com:5432/postgres?sslmode=require` | JDBC por session pooler, puerto **5432** (nunca 6543) |
| `DATABASE_USER` | `postgres.<ref>` | 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
Expand Down Expand Up @@ -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`.

Expand Down
68 changes: 68 additions & 0 deletions docs/adr/ADR-0007-correo-de-la-plataforma.md
Original file line number Diff line number Diff line change
@@ -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).
78 changes: 78 additions & 0 deletions docs/adr/ADR-0008-autorizacion-roles-y-ambito.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading