diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml new file mode 100644 index 0000000..bce50f6 --- /dev/null +++ b/.github/workflows/database.yml @@ -0,0 +1,109 @@ +name: database + +# Comprobación automática del modelo de datos. +# El caso exige "integrar mediante pull request, revisión por pares y +# comprobaciones automáticas obligatorias". Este workflow cubre la parte +# de base de datos: levanta un PostgreSQL vacío y verifica que el DDL +# ejecute, que los datos de prueba carguen, que las consultas clave +# devuelvan filas y que las restricciones rechacen lo que deben rechazar. +# +# También se dispara cuando cambia el mapeo JPA, porque es justo ahí donde +# el esquema escrito a mano y las entidades pueden divergir (ADR-0005). + +on: + push: + branches: [main] + paths: + - 'docs/database/**' + - 'src/main/java/**/persistence/**' + - '.github/workflows/database.yml' + pull_request: + paths: + - 'docs/database/**' + - 'src/main/java/**/persistence/**' + - '.github/workflows/database.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: database-${{ github.ref }} + cancel-in-progress: true + +jobs: + esquema: + name: Esquema, datos y restricciones + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: reservas + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + PGPASSWORD: postgres + PSQL: psql -h localhost -U postgres -d reservas + + steps: + - uses: actions/checkout@v4 + + - name: Comprobar el cliente de PostgreSQL + run: | + command -v psql || sudo apt-get update && sudo apt-get install -y postgresql-client + psql --version + + - name: El DDL ejecuta contra una base vacía + run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/schema.sql + + - name: Se crearon las 19 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 + + - name: El DDL es idempotente + run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/schema.sql + + - name: Los datos de prueba cargan + run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/seed.sql + + - name: Las catorce consultas clave devuelven filas + run: | + salida=$($PSQL -v ON_ERROR_STOP=1 -f docs/database/consultas-clave.sql) + echo "$salida" + # Sólo cuentan los bloques con al menos una fila: "(0 rows)" no vale. + con_filas=$(echo "$salida" | grep -cE '^\([1-9][0-9]* row' || true) + vacias=$(echo "$salida" | grep -cE '^\(0 rows\)' || true) + echo "consultas con filas: $con_filas · vacías: $vacias" + test "$con_filas" -eq 14 + test "$vacias" -eq 0 + + - name: Las restricciones rechazan lo que deben rechazar + run: | + salida=$($PSQL -f docs/database/pruebas-integridad.sql 2>&1) + echo "$salida" + # psql -f antepone "archivo:linea:" a cada error, así que el + # patrón no puede anclarse sólo al principio de la línea. + 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 18 + test "$coladas" -eq 0 + + - name: La seguridad de fila queda habilitada + run: | + $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/rls.sql + n=$($PSQL -tAc "select count(*) from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r' and relrowsecurity") + echo "tablas con RLS: $n" + test "$n" -eq 19 diff --git a/README.md b/README.md index 4dc8ad2..8203944 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,16 @@ contraseña). MFA de administradores diferido a Sprint 2. | ADR-0002 Supabase (BD + identidad) | `docs/adr/ADR-0002-*.md` | | ADR-0003 Autenticación JWT/JWKS + bloqueo propio | `docs/adr/ADR-0003-*.md` | | 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` | +| 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 inicial (DDL) | `docs/database/schema.sql` | +| Modelo físico (DDL, 19 tablas) | `docs/database/schema.sql` | +| Datos de prueba | `docs/database/seed.sql` | +| Restricciones de integridad en acción | `docs/database/pruebas-integridad.sql` | | Seguridad Supabase (RLS) | `docs/database/rls.sql` | | Consultas clave del negocio | `docs/database/consultas-clave.md` | +| Consultas clave, versión ejecutable | `docs/database/consultas-clave.sql` | +| Diagrama ER editable | `docs/database/diagrama-er.drawio` | | Runbook operativo (producción, E2E, troubleshooting) | `docs/operations/runbook.md` | | Colección REST de Sprint 1 | `docs/api/sprint1.http` | @@ -137,8 +143,10 @@ Linux/macOS: Notas: -- El primer arranque crea solo las tablas (`ddl-auto=update`). Después - ejecuta `docs/database/rls.sql` en el SQL Editor de Supabase. +- El esquema completo vive en `docs/database/schema.sql` y se aplica con + `psql -f` o desde el SQL Editor de Supabase (ADR-0005). `ddl-auto=update` + sigue creando en local las tablas que tienen entidad JPA. Después + ejecuta `docs/database/rls.sql`. - Swagger UI: http://localhost:8080/swagger-ui.html - Health: http://localhost:8080/actuator/health diff --git a/docs/adr/ADR-0002-supabase-postgres-auth.md b/docs/adr/ADR-0002-supabase-postgres-auth.md index 45a63bc..14d0b1d 100644 --- a/docs/adr/ADR-0002-supabase-postgres-auth.md +++ b/docs/adr/ADR-0002-supabase-postgres-auth.md @@ -1,6 +1,6 @@ # ADR-0002: PostgreSQL administrado en Supabase como base de datos y proveedor de identidad -- **Estado:** Aceptado +- **Estado:** Aceptado; la estrategia de esquema la modifica el ADR-0005 - **Fecha:** 2026-09-18 - **Prioridad:** Alta - **Decisores:** Equipo de desarrollo — rol Arquitecto de Software y BD diff --git a/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md b/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md new file mode 100644 index 0000000..e7d63d5 --- /dev/null +++ b/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md @@ -0,0 +1,81 @@ +# ADR-0005: El modelo físico se escribe a mano y se verifica en integración continua + +- **Estado:** Aceptado; modifica la estrategia de esquema del ADR-0002 +- **Fecha:** 2026-09-21 +- **Prioridad:** Alta +- **Decisores:** Equipo de desarrollo — rol Arquitecto de Software y BD + +## Contexto + +El ADR-0002 dejó `hibernate.ddl-auto=update` en desarrollo y `validate` en la +nube, con el modelo físico "versionado como script exportado en +`docs/database/schema.sql`". Esa estrategia cumplió para arrancar el Sprint 1, +pero produjo tres huecos: el script exportado documenta las dos tablas que hoy +tienen entidad JPA (`clients`, `login_attempts`) mientras `modelo-logico.md` +describe dieciocho; un script generado desde el mapeo nunca lleva claves +foráneas, índices de consulta ni restricciones de negocio; y las consultas de +`consultas-clave.md` no se pueden ejecutar porque las tablas que referencian no +existen en ningún lado. El caso exige además "comprobaciones automáticas +obligatorias" en los pull requests, y el repositorio no tenía ninguna. + +## Decisión + +Invertir la relación entre el código y el esquema: **`docs/database/schema.sql` +pasa a ser la fuente del modelo de datos**, escrita a mano, y deja de ser un +volcado de lo que Hibernate genera. + +1. **Alcance completo del caso:** diecinueve tablas que cubren identidad, + organización, catálogo de servicios, recursos, agenda y reservas. Las dos + tablas ya implementadas se reproducen con los mismos nombres, tipos y largos + que produce el mapeo actual, incluido el `timestamp(6)` sin zona de + `AuditableEntity`, para no romper el `validate` del perfil `cloud`. +2. **Restricciones de negocio en la base:** las reglas de HU-001 a HU-004 que + se pueden expresar de forma declarativa viven en el esquema. Entre ellas, la + restricción `exclude using gist` que impide que un recurso quede asignado a + dos sesiones solapadas, que es el problema de sobreocupación del enunciado. +3. **Verificación automática:** el workflow `.github/workflows/database.yml` + levanta un PostgreSQL vacío y comprueba que el DDL ejecute, que sea + idempotente, que los datos de prueba carguen, que las catorce consultas + clave devuelvan filas y que las dieciocho violaciones de + `pruebas-integridad.sql` sean rechazadas. Se dispara tanto con cambios en + `docs/database/` como en el mapeo JPA, que es donde puede nacer la + divergencia entre el esquema y las entidades. + +El script es idempotente (`if not exists` en todas las sentencias `create`), de +modo que se puede aplicar sobre la base actual de Supabase sin alterar las dos +tablas existentes. La política de **RLS activado en todas las tablas de +negocio** del ADR-0002 se extiende a las diecinueve en `docs/database/rls.sql`. + +## Alternativas consideradas + +1. **Mantener el script como export de `ddl-auto`:** cuesta cero esfuerzo y + sigue el flujo actual, pero el esquema solo puede crecer al ritmo al que se + escriben entidades JPA, y el entregable de datos del sprint quedaría sin + claves foráneas ni consultas ejecutables. +2. **Adoptar Flyway ya en el Sprint 1:** es el destino correcto y el ADR-0002 + lo dejó como deuda priorizada, pero exige decidir la línea base, reescribir + el arranque local y coordinarlo con el despliegue; se mantiene para el + Sprint 2, y este ADR le prepara el terreno al dejar un esquema explícito que + puede convertirse en la migración `V1__baseline.sql`. + +## Consecuencias + +- (+) El modelo de datos del caso queda completo y ejecutable, no solo dibujado. +- (+) Las reglas de negocio quedan garantizadas por el motor y no dependen de + que cada caso de uso se acuerde de validarlas. +- (+) El repositorio gana su primera comprobación automática, que es un + lineamiento explícito del caso. +- (−) Aparece la posibilidad de que el esquema escrito a mano y el mapeo JPA + diverjan; se compensa con `ddl-auto=validate` en la nube, que falla al + arrancar si una entidad no cuadra con su tabla. +- (−) Diecisiete de las diecinueve tablas todavía no tienen entidad JPA: son + modelo de datos adelantado al código. Mientras no se implementen, `validate` + las ignora porque solo comprueba las tablas mapeadas. +- (−) `seed.sql` y `pruebas-integridad.sql` usan datos ficticios acoplados a + identificadores fijos; si el esquema cambia hay que mantenerlos, y el + workflow avisa cuando se rompen. + +Ver `docs/database/schema.sql` (modelo físico), `modelo-logico.md` (entidades y +normalización), `consultas-clave.md` (preguntas de negocio), `seed.sql` (datos +de prueba), `pruebas-integridad.sql` (restricciones en acción) y `rls.sql` +(seguridad Supabase). diff --git a/docs/database/README.md b/docs/database/README.md new file mode 100644 index 0000000..4b4a6fc --- /dev/null +++ b/docs/database/README.md @@ -0,0 +1,71 @@ +# Base de datos — guía de la carpeta + +Qué hay aquí, en qué orden se ejecuta y cómo levantarlo para trabajar. + +## Los archivos + +| Archivo | Qué es | ¿Se ejecuta? | +|---|---|---| +| `schema.sql` | Modelo físico. Diecinueve tablas con sus restricciones e índices. Es la fuente del esquema (ADR-0005) | Sí, primero | +| `seed.sql` | Datos de prueba. Tres organizaciones, cinco servicios, ocho reservas | Sí, después del esquema | +| `consultas-clave.sql` | Las catorce preguntas de negocio, ejecutables de corrido | Sí, para ver resultados | +| `pruebas-integridad.sql` | Dieciocho instrucciones que violan una regla cada una y deben ser rechazadas | Sí, para comprobar | +| `rls.sql` | Habilita Row Level Security en las diecinueve tablas | Sí, al final | +| `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 | +| `der-0-modelo-completo.png` y `der-1..4-*.png` | Las cinco páginas del `.drawio` exportadas | No | +| `er-identidad.png`, `er-catalogo.png`, `er-reservas.png` | Los diagramas de `modelo-logico.md` exportados, por si Mermaid no carga | No | + +Los dos `.sql` de consultas usan metacomandos de `psql` (`\echo`), así que se +ejecutan con `psql -f` y no pegándolos en el editor SQL de Supabase. + +## Levantarlo en local + +El `docker-compose.yml` de la raíz ya trae un PostgreSQL 16. No hace falta nada +más: + +```bash +docker compose up -d db +docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < docs/database/schema.sql +docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < docs/database/seed.sql +docker compose exec -T db psql -U postgres -d postgres < docs/database/consultas-clave.sql +``` + +Y para mirar los datos: + +```bash +docker compose exec db psql -U postgres -d postgres +``` + +Cuando termines: `docker compose down -v` borra también el volumen. + +**`seed.sql` empieza con un `truncate`.** Tiene una guarda que aborta si la base +no parece local, pero aun así no lo ejecutes contra la base compartida. + +## Aplicarlo en Supabase + +`schema.sql` es idempotente: se salta `clients` y `login_attempts`, que ya +existen porque las crea el mapeo JPA, y crea las diecisiete restantes sin tocar +ninguna columna de las dos primeras. Después hay que ejecutar `rls.sql`, porque +sin él las tablas nuevas quedan expuestas por PostgREST con el anon key, que es +público (ADR-0002). + +No ejecutes `seed.sql` ni `pruebas-integridad.sql` contra Supabase. + +## Qué verifica la integración continua + +El workflow `.github/workflows/database.yml` corre en cada pull request que +toque esta carpeta o el mapeo JPA, y comprueba que el DDL ejecute contra una +base vacía, que sea idempotente, que se creen las diecinueve tablas, que los +datos de prueba carguen, que las catorce consultas devuelvan filas y que las +dieciocho pruebas de integridad sean rechazadas. + +Si tocas el esquema y el workflow se pone rojo, lo más probable es que haya que +actualizar `seed.sql` o alguno de los conteos del workflow. + +## Qué falta + +Está listado al final de `schema.sql` y de `modelo-logico.md`: reglas de negocio +que el esquema no puede garantizar de forma declarativa y siguen viviendo en la +aplicación. Conviene leerlo antes de dar algo por cubierto. diff --git a/docs/database/consultas-clave.md b/docs/database/consultas-clave.md index d40475e..a9b70b4 100644 --- a/docs/database/consultas-clave.md +++ b/docs/database/consultas-clave.md @@ -1,116 +1,239 @@ # Consultas clave — Plataforma de Reservas -Preguntas de negocio del caso traducidas a SQL. Las consultas 1-5 operan sobre -el modelo físico del Sprint 1; las 6-9 son del modelo lógico completo y se -habilitarán cuando existan las tablas de proveedores/servicios/reservas. +Preguntas de negocio del caso traducidas a SQL. Todas se ejecutan sobre las +tablas que crea `schema.sql` y todas devuelven filas con los datos de +`seed.sql`. La versión ejecutable de corrido, con separadores, está en +`consultas-clave.sql`; el workflow `database.yml` la corre en cada pull request +y falla si alguna deja de devolver resultado. -## Sprint 1 (implementadas sobre el esquema actual) +## Las preguntas -### 1. ¿El correo o el documento ya están registrados? (unicidad HU-001) +### 1. ¿Qué servicios puede reservar un cliente en Medellín, en qué sede y a qué precio? (catálogo público) + +Aplica la regla de HU-002: un proveedor pendiente de aprobación no es visible. ```sql -select exists(select 1 from clients where lower(email) = lower(:email)) as email_taken, - exists(select 1 from clients where upper(document) = upper(:doc)) as document_taken; +select o.name as organizacion, s.name as servicio, l.name as sede, + s.duration_minutes as minutos, s.price, cur.code as moneda, + s.customer_capacity as cupo +from services s + inner join organizations o on o.id = s.organization_id + inner join service_locations sl on sl.service_id = s.id + inner join locations l on l.id = sl.location_id + inner join cities c on c.id = l.city_id + inner join currencies cur on cur.id = s.currency_id +where c.name = 'Medellín' + and s.status = 'ACTIVE' + and l.status = 'ACTIVE' + and o.status = 'ACTIVE' +order by o.name, s.name; ``` -### 2. ¿Qué clientes llevan más de 24 h pendientes de verificación? (re-engagement) +### 2. ¿Cuál es la ocupación por sede y semana: reservas, asistentes e ingresos? (reporte de uso) ```sql -select id, full_name, email, city, created_at -from clients -where status = 'PENDING_VERIFICATION' - and created_at < now() - interval '24 hours' -order by created_at; +select l.name as sede, date_trunc('week', b.starts_at)::date as semana, + count(*) as reservas, sum(b.attendees) as asistentes, + sum(b.total_price) as ingresos +from bookings b + inner join locations l on l.id = b.location_id +where b.status in ('CONFIRMED', 'COMPLETED') +group by l.name, date_trunc('week', b.starts_at) +order by semana, sede; ``` -### 3. ¿Está bloqueada una cuenta por intentos fallidos? (HU-021) +### 3. ¿Qué servicios no puede prestar una sede por falta de recursos? (capacidad instalada) + +Detecta servicios publicados en una sede que en la práctica no se pueden +ejecutar ahí. ```sql -select count(*) as recent_failures -from login_attempts -where lower(email) = lower(:email) - and success = false - and attempted_at > now() - interval '15 minutes'; --- bloqueada si recent_failures >= 5 +select o.name as organizacion, s.name as servicio, l.name as sede, + rt.name as tipo_de_recurso, srr.quantity_required as requiere, + count(r.id) as disponibles +from services s + inner join organizations o on o.id = s.organization_id + inner join service_locations sl on sl.service_id = s.id + inner join locations l on l.id = sl.location_id + inner join service_resource_requirements srr on srr.service_id = s.id + inner join resource_types rt on rt.id = srr.resource_type_id + left join resources r on r.resource_type_id = rt.id + and r.location_id = l.id + and r.status = 'ACTIVE' +group by o.name, s.name, l.name, rt.name, srr.quantity_required +having count(r.id) < srr.quantity_required +order by o.name, s.name, l.name; +``` + +### 4. ¿Cuáles son los servicios más reservados de cada organización? (con empates) + +Con `rank()` y no `row_number()`, para que dos servicios empatados compartan +posición. + +```sql +select organizacion, servicio, reservas, posicion +from ( + select o.name as organizacion, s.name as servicio, count(b.id) as reservas, + rank() over (partition by o.id order by count(b.id) desc) as posicion + from services s + inner join organizations o on o.id = s.organization_id + left join bookings b on b.service_id = s.id + and b.status in ('CONFIRMED', 'COMPLETED') + group by o.id, o.name, s.name +) ranking +where posicion <= 3 +order by organizacion, posicion, servicio; +``` + +### 5. Si se suspende un proveedor, ¿qué reservas futuras quedan afectadas? (HU-003) + +Es el informe que HU-003 exige antes de confirmar la suspensión. + +```sql +select b.id as reserva, c.full_name as cliente, c.email, c.phone, + s.name as servicio, l.name as sede, b.starts_at, b.status +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) + and b.starts_at >= now() + and b.status in ('RESERVED', 'CONFIRMED') +order by b.starts_at; +``` + +### 6. ¿Qué agenda tiene cada recurso y cuánta carga lleva encima? (disponibilidad) + +El `left join` es lo que deja ver también los recursos completamente libres. + +```sql +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 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') + and br.starts_at::date = :dia +where sch.day_of_week = extract(isodow from date :dia) + and sch.active +group by l.name, r.name, sch.start_time, sch.end_time +order by l.name, r.name; ``` -### 4. Historial de intentos de login de un correo (auditoría de seguridad) +### 7. ¿Qué proveedores llevan más tiempo esperando aprobación? (cola del administrador) ```sql -select email, success, attempted_at +select o.name as organizacion, o.nit, 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 + inner join organization_categories oc on oc.id = o.category_id +where o.status = 'PENDING_APPROVAL' +order by dias_esperando desc, o.name; +``` + +### 8. ¿Está bloqueada una cuenta por intentos fallidos? (HU-021) + +El umbral y la ventana son los de `LoginLockPolicy`: cinco fallos en quince +minutos. + +```sql +select email, count(*) as fallos_recientes, max(attempted_at) as ultimo_intento, + count(*) >= 5 as bloqueada from login_attempts -where lower(email) = lower(:email) -order by attempted_at desc -limit 50; +where success = false + and attempted_at > localtimestamp - interval '15 minutes' +group by email +order by fallos_recientes desc; ``` -### 5. ¿Cuántos clientes hay por ciudad y canal de notificación? (crecimiento) +### 9. ¿Qué clientes llevan más de 24 h pendientes de verificación? (re-engagement) ```sql -select city, notification_channel, count(*) as clients, - count(*) filter (where status = 'ACTIVE') as active_clients +select id, full_name, email, city, created_at, + localtimestamp - created_at as lleva_esperando from clients -group by city, notification_channel -order by clients desc; +where status = 'PENDING_VERIFICATION' + and created_at < localtimestamp - interval '24 hours' +order by created_at; ``` -## Sprints 2+ (sobre el modelo lógico completo) +### 10. ¿Qué condiciones de cancelación rigen para cada reserva? (HU-002) -### 6. Reservas futuras de un cliente (historial y próximas citas) +Es la prueba de que la regla está resuelta en el modelo. Dos reservas de la +misma organización salen con ventanas distintas, porque cada una apunta a la +versión de política que regía cuando se creó. ```sql -select b.id, s.name as service, l.name as location, b.starts_at, b.status +select o.name as organizacion, b.starts_at, b.status, + op.version as version_politica, + op.free_cancellation_window_minutes as minutos_cancelacion_gratis, + case when op.replaced_at is null then 'vigente' else 'histórica' end + as estado_de_la_politica from bookings b -join services s on s.id = b.service_id -join locations l on l.id = b.location_id -where b.client_id = :clientId - and b.starts_at >= now() -order by b.starts_at; + inner join organization_policies op on op.id = b.policy_id + inner join organizations o on o.id = op.organization_id +order by o.name, b.starts_at; ``` -### 7. ¿Qué reservas afecta la suspensión de un proveedor? (HU-003) +### 11. ¿El correo, el documento o el NIT ya están registrados? (unicidad HU-001 y HU-002) + +Las tres validaciones previas al registro en una sola ida a la base. ```sql -select b.id, c.full_name, c.email, s.name, b.starts_at -from bookings b -join clients c on c.id = b.client_id -join services s on s.id = b.service_id -where s.provider_id = :providerId - and b.starts_at >= now() - and b.status in ('RESERVED', 'CONFIRMED'); +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; ``` -### 8. Ocupación de un servicio en una sede y semana (reporte HU de reportes) +### 12. ¿Qué clientes se registraron y nunca han reservado? (subconsulta correlacionada) ```sql -select date_trunc('day', b.starts_at) as day, - count(*) as bookings, - sum(b.attendees) as attendees, - sum(b.total_price) as revenue -from bookings b -where b.service_id = :serviceId - and b.location_id = :locationId - and b.starts_at >= :weekStart and b.starts_at < :weekStart + interval '7 days' - and b.status in ('CONFIRMED', 'COMPLETED') -group by 1 -order by 1; +select c.id, c.full_name, c.email, c.city, c.status +from clients c +where not exists (select 1 from bookings b where b.client_id = c.id) +order by c.full_name; ``` -### 9. Disponibilidad real de un recurso en un día (control de disponibilidad) +### 13. ¿Qué servicios se prestan en una sede y no en otra? (operador de diferencia) ```sql -select st.start_time, st.end_time, - coalesce(sum(b.attendees) filter (where b.status in ('RESERVED','CONFIRMED')), 0) as booked -from schedule_templates st -left join bookings b - on b.resource_id = st.resource_id - and b.starts_at::date = :day - and b.starts_at::time >= st.start_time - and b.starts_at::time < st.end_time -where st.location_id = :locationId - and st.resource_id = :resourceId - and st.day_of_week = extract(isodow from date :day) - and st.active -group by st.id -order by st.start_time; +select s.name as servicio +from service_locations sl + inner join services s on s.id = sl.service_id + inner join locations l on l.id = sl.location_id +where l.name = 'Sede Poblado' +except +select s.name +from service_locations sl + inner join services s on s.id = sl.service_id + inner join locations l on l.id = sl.location_id +where l.name = 'Sede Envigado' +order by servicio; ``` + +### 14. ¿Qué sedes ofrecen todos los servicios activos de su organización? (división relacional) + +El operador de división expresado con la doble negación: no existe un servicio +activo de la organización que no se preste en esa sede. + +```sql +select o.name as organizacion, l.name as sede +from locations l + inner join organizations o on o.id = l.organization_id +where l.status = 'ACTIVE' + and not exists (select 1 + from services s + where s.organization_id = l.organization_id + and s.status = 'ACTIVE' + and not exists (select 1 + from service_locations sl + where sl.service_id = s.id + and sl.location_id = l.id)) +order by o.name, l.name; +``` + +Ver `schema.sql` (modelo físico), `modelo-logico.md` (entidades y +normalización), `seed.sql` (los datos sobre los que corren estas consultas) y +`consultas-clave.sql` (la versión ejecutable). diff --git a/docs/database/consultas-clave.sql b/docs/database/consultas-clave.sql new file mode 100644 index 0000000..a37e1f7 --- /dev/null +++ b/docs/database/consultas-clave.sql @@ -0,0 +1,254 @@ +-- ===================================================================== +-- Consultas clave del negocio +-- Sprint 1 +-- +-- Catorce preguntas. Todas corren sobre las tablas de schema.sql y todas +-- devuelven filas con los datos de seed.sql. +-- +-- Usa metacomandos de psql (\echo), así que se ejecuta con `psql -f`, +-- no pegándolo en el editor SQL de Supabase. +-- +-- Uso: psql -f schema.sql -f seed.sql -f consultas-clave.sql +-- ===================================================================== + + +-- 1. ¿Qué servicios puede reservar un cliente en Medellín, en qué sede +-- y a qué precio? Es la consulta del catálogo público, y aplica la +-- regla de HU-002: un proveedor pendiente no es visible. +\echo '=== 1. Catálogo de servicios reservables en Medellín ===' +select o.name as organizacion, + s.name as servicio, + l.name as sede, + s.duration_minutes as minutos, + s.price, + cur.code as moneda, + s.customer_capacity as cupo +from services s + inner join organizations o on o.id = s.organization_id + inner join service_locations sl on sl.service_id = s.id + inner join locations l on l.id = sl.location_id + inner join cities c on c.id = l.city_id + inner join currencies cur on cur.id = s.currency_id +where c.name = 'Medellín' + and s.status = 'ACTIVE' + and l.status = 'ACTIVE' + and o.status = 'ACTIVE' +order by o.name, s.name; + + +-- 2. ¿Cuál es la ocupación por sede y semana? Es el reporte de uso que +-- pide el enunciado del caso. +\echo '=== 2. Ocupación e ingresos por sede y semana ===' +select l.name as sede, + date_trunc('week', b.starts_at)::date as semana, + count(*) as reservas, + sum(b.attendees) as asistentes, + sum(b.total_price) as ingresos +from bookings b + inner join locations l on l.id = b.location_id +where b.status in ('CONFIRMED', 'COMPLETED') +group by l.name, date_trunc('week', b.starts_at) +order by semana, sede; + + +-- 3. ¿Qué servicios no puede prestar una sede porque no tiene recursos +-- suficientes? Detecta servicios publicados que en la práctica no se +-- pueden ejecutar ahí. +\echo '=== 3. Servicios publicados en una sede sin recursos suficientes ===' +select o.name as organizacion, + s.name as servicio, + l.name as sede, + rt.name as tipo_de_recurso, + srr.quantity_required as requiere, + count(r.id) as disponibles +from services s + inner join organizations o on o.id = s.organization_id + inner join service_locations sl on sl.service_id = s.id + inner join locations l on l.id = sl.location_id + inner join service_resource_requirements srr on srr.service_id = s.id + inner join resource_types rt on rt.id = srr.resource_type_id + left join resources r on r.resource_type_id = rt.id + and r.location_id = l.id + and r.status = 'ACTIVE' +group by o.name, s.name, l.name, rt.name, srr.quantity_required +having count(r.id) < srr.quantity_required +order by o.name, s.name, l.name; + + +-- 4. ¿Cuáles son los servicios más reservados de cada organización? +-- Con rank() y no row_number() para que los empates compartan puesto. +\echo '=== 4. Servicios más reservados por organización (con empates) ===' +select organizacion, servicio, reservas, posicion +from ( + select o.name as organizacion, + s.name as servicio, + count(b.id) as reservas, + rank() over (partition by o.id order by count(b.id) desc) as posicion + from services s + inner join organizations o on o.id = s.organization_id + left join bookings b on b.service_id = s.id + and b.status in ('CONFIRMED', 'COMPLETED') + group by o.id, o.name, s.name +) ranking +where posicion <= 3 +order by organizacion, posicion, servicio; + + +-- 5. Si se suspende un proveedor, ¿qué reservas futuras quedan +-- afectadas? HU-003 pide ese informe antes de confirmar la suspensión. +\echo '=== 5. Informe de reservas afectadas por suspender un proveedor ===' +select b.id as reserva, + c.full_name as cliente, + c.email, + c.phone, + s.name as servicio, + l.name as sede, + b.starts_at, + b.status +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') + and b.starts_at >= now() + and b.status in ('RESERVED', 'CONFIRMED') +order by b.starts_at; + + +-- 6. ¿Qué agenda tiene cada recurso pasado mañana y cuánta carga lleva? +-- El left join es lo que deja ver también los recursos libres. +\echo '=== 6. Agenda y carga de cada recurso para pasado mañana ===' +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 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') + and br.starts_at::date = (now() + interval '2 days')::date +where sch.day_of_week = extract(isodow from now() + interval '2 days') + and sch.active +group by l.name, r.name, sch.start_time, sch.end_time +order by l.name, r.name; + + +-- 7. ¿Qué proveedores llevan más tiempo esperando aprobación? Es la cola +-- de trabajo del administrador (HU-003). +\echo '=== 7. Cola de proveedores pendientes de aprobación ===' +select o.name as organizacion, + o.nit, + 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 + inner join organization_categories oc on oc.id = o.category_id +where o.status = 'PENDING_APPROVAL' +order by dias_esperando desc, o.name; + + +-- 8. ¿Está bloqueada una cuenta por intentos fallidos? La política es +-- cinco fallos en quince minutos (HU-021). +\echo '=== 8. Cuentas bloqueadas por intentos fallidos ===' +select email, + count(*) as fallos_recientes, + max(attempted_at) as ultimo_intento, + count(*) >= 5 as bloqueada +from login_attempts +where success = false + and attempted_at > localtimestamp - interval '15 minutes' +group by email +order by fallos_recientes desc; + + +-- 9. ¿Qué clientes llevan más de 24 horas sin verificar el correo? Es la +-- lista a la que hay que reenviarles el enlace (HU-001). +\echo '=== 9. Clientes sin verificar hace más de 24 h ===' +select id, + full_name, + email, + city, + created_at, + localtimestamp - created_at as lleva_esperando +from clients +where status = 'PENDING_VERIFICATION' + and created_at < localtimestamp - interval '24 hours' +order by created_at; + + +-- 10. ¿Qué condiciones de cancelación rigen para cada reserva? Como cada +-- reserva apunta a la versión de política que regía al crearse, dos +-- reservas del mismo negocio salen con ventanas distintas. Es la +-- prueba de que la regla de HU-002 está resuelta en el modelo. +\echo '=== 10. Condiciones congeladas en cada reserva ===' +select o.name as organizacion, + b.starts_at, + b.status, + op.version as version_politica, + op.free_cancellation_window_minutes as minutos_cancelacion_gratis, + case when op.replaced_at is null + then 'vigente' + else 'histórica' end as estado_de_la_politica +from bookings b + inner join organization_policies op on op.id = b.policy_id + inner join organizations o on o.id = op.organization_id +order by o.name, b.starts_at; + + +-- 11. ¿El correo, el documento o el NIT ya están registrados? Es la +-- validación previa al registro, en una sola ida a la base. +\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; + + +-- 12. ¿Qué clientes se registraron y nunca han reservado? Subconsulta +-- correlacionada con el operador de existencia. +\echo '=== 12. Clientes que nunca han reservado ===' +select c.id, c.full_name, c.email, c.city, c.status +from clients c +where not exists (select 1 + from bookings b + where b.client_id = c.id) +order by c.full_name; + + +-- 13. ¿Qué servicios se prestan en la Sede Poblado pero no en Envigado? +-- Operador de diferencia del álgebra relacional. +\echo '=== 13. Servicios de una sede que la otra no ofrece (diferencia) ===' +select s.name as servicio +from service_locations sl + inner join services s on s.id = sl.service_id + inner join locations l on l.id = sl.location_id +where l.name = 'Sede Poblado' +except +select s.name +from service_locations sl + inner join services s on s.id = sl.service_id + inner join locations l on l.id = sl.location_id +where l.name = 'Sede Envigado' +order by servicio; + + +-- 14. ¿Qué sedes ofrecen TODOS los servicios activos de su organización? +-- Es el operador de división del álgebra relacional, expresado con +-- la doble negación: no existe un servicio activo de la organización +-- que no se preste en esa sede. +\echo '=== 14. Sedes que ofrecen todos los servicios activos de su organización (división) ===' +select o.name as organizacion, l.name as sede +from locations l + inner join organizations o on o.id = l.organization_id +where l.status = 'ACTIVE' + and not exists (select 1 + from services s + where s.organization_id = l.organization_id + and s.status = 'ACTIVE' + and not exists (select 1 + from service_locations sl + where sl.service_id = s.id + and sl.location_id = l.id)) +order by o.name, l.name; diff --git a/docs/database/der-0-modelo-completo.png b/docs/database/der-0-modelo-completo.png new file mode 100644 index 0000000..dd1b5fe Binary files /dev/null and b/docs/database/der-0-modelo-completo.png differ diff --git a/docs/database/der-1-identidad.png b/docs/database/der-1-identidad.png new file mode 100644 index 0000000..508414d Binary files /dev/null and b/docs/database/der-1-identidad.png differ diff --git a/docs/database/der-2-catalogo.png b/docs/database/der-2-catalogo.png new file mode 100644 index 0000000..07aac61 Binary files /dev/null and b/docs/database/der-2-catalogo.png differ diff --git a/docs/database/der-3-reservas.png b/docs/database/der-3-reservas.png new file mode 100644 index 0000000..cc989e0 Binary files /dev/null and b/docs/database/der-3-reservas.png differ diff --git a/docs/database/der-4-historias-de-usuario.png b/docs/database/der-4-historias-de-usuario.png new file mode 100644 index 0000000..94024c0 Binary files /dev/null and b/docs/database/der-4-historias-de-usuario.png differ diff --git a/docs/database/diagrama-er.drawio b/docs/database/diagrama-er.drawio new file mode 100644 index 0000000..63e7c6f --- /dev/null +++ b/docs/database/diagrama-er.drawio @@ -0,0 +1,4590 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/database/er-catalogo.png b/docs/database/er-catalogo.png new file mode 100644 index 0000000..199c701 Binary files /dev/null and b/docs/database/er-catalogo.png differ diff --git a/docs/database/er-identidad.png b/docs/database/er-identidad.png new file mode 100644 index 0000000..4b5f207 Binary files /dev/null and b/docs/database/er-identidad.png differ diff --git a/docs/database/er-reservas.png b/docs/database/er-reservas.png new file mode 100644 index 0000000..2ba2ecc Binary files /dev/null and b/docs/database/er-reservas.png differ diff --git a/docs/database/modelo-logico.md b/docs/database/modelo-logico.md index edc569b..3fa3355 100644 --- a/docs/database/modelo-logico.md +++ b/docs/database/modelo-logico.md @@ -1,212 +1,279 @@ # Modelo lógico — Plataforma de Reservas de Servicios -Modelo entidad-relación del dominio completo del caso. Las tablas marcadas con -**(S1)** pertenecen al modelo físico inicial del Sprint 1; el resto se implementará -en sprints posteriores conforme a las HU-002/003/004 y de reservas. +Modelo entidad-relación del dominio completo del caso. Las diecinueve entidades +existen en `schema.sql`; las marcadas con **(S1)** además tienen entidad JPA y +están en producción, el resto es modelo de datos adelantado al código. -Las credenciales (email, password, confirmación de correo) viven en `auth.users` -de Supabase; el backend guarda únicamente perfiles y datos de negocio enlazados -por `user_id` (UUID). +Las credenciales (correo, contraseña, confirmación) viven en `auth.users` de +Supabase, que el script no crea. Por eso `CLIENT.id`, +`ORGANIZATION_MEMBER.user_id` y `ACCOUNT_STATUS_CHANGE.changed_by` no tienen +clave foránea declarada: su destino está en otro esquema. ```mermaid erDiagram - AUTH_USERS ||--o| CLIENT : "perfil (S1)" - AUTH_USERS ||--o| PROVIDER_OWNER : "duenio de negocio" - AUTH_USERS ||--o| PROVIDER_STAFF : "personal de sede" - AUTH_USERS ||--o| ADMIN_USER : "administrador" - - PROVIDER ||--|{ LOCATION : "tiene sedes" - PROVIDER ||--|| OPERATING_RULES : "reglas de operacion" - PROVIDER ||--|{ SERVICE : "ofrece" - PROVIDER ||--|{ RESOURCE_TYPE : "define" - PROVIDER ||--o{ PROVIDER_STAFF : "emplea" - PROVIDER ||--o| PROVIDER_OWNER : "pertenece" - + CITY ||--o{ LOCATION : "ubica" + ORGANIZATION_CATEGORY ||--o{ ORGANIZATION : "clasifica" + ORGANIZATION ||--|{ LOCATION : "tiene sede" + ORGANIZATION ||--|{ ORGANIZATION_POLICY : "versiona" + ORGANIZATION ||--o{ ORGANIZATION_MEMBER : "emplea a" + ORGANIZATION ||--o{ SERVICE : "oferta" + ORGANIZATION ||--o{ RESOURCE_TYPE : "define" + ORGANIZATION ||--o{ ACCOUNT_STATUS_CHANGE : "audita" + CLIENT ||--o{ ACCOUNT_STATUS_CHANGE : "audita" + CURRENCY ||--o{ SERVICE : "tarifa en" SERVICE ||--|{ SERVICE_LOCATION : "se presta en" LOCATION ||--|{ SERVICE_LOCATION : "alberga" - SERVICE ||--|{ SERVICE_RESOURCE_TYPE : "requiere" - RESOURCE_TYPE ||--|{ SERVICE_RESOURCE_TYPE : "es requerido por" - RESOURCE_TYPE ||--|{ RESOURCE : "instancias" - LOCATION ||--o{ RESOURCE : "posee" - LOCATION ||--o{ SCHEDULE_TEMPLATE : "agenda" - RESOURCE ||--o{ SCHEDULE_TEMPLATE : "disponibilidad" - + SERVICE ||--|{ SERVICE_RESOURCE_REQUIREMENT : "exige" + 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" CLIENT ||--o{ BOOKING : "reserva" - SERVICE ||--o{ BOOKING : "es reservado" - LOCATION ||--o{ BOOKING : "atiende" - RESOURCE ||--o{ BOOKING : "asignado" - BOOKING ||--|| BOOKING_CONDITION_SNAPSHOT : "condiciones vigentes" - BOOKING ||--o{ BOOKING_STATUS_CHANGE : "historia de estados" + ORGANIZATION ||--o{ BOOKING : "atiende" + SERVICE ||--o{ BOOKING : "es reservado en" + LOCATION ||--o{ BOOKING : "acoge" + ORGANIZATION_POLICY ||--o{ BOOKING : "rige, congelada" + BOOKING ||--|{ BOOKING_RESOURCE : "ocupa" + RESOURCE ||--o{ BOOKING_RESOURCE : "es ocupado por" + BOOKING ||--o{ BOOKING_STATUS_CHANGE : "registra cambio de" - AUTH_USERS { - uuid id PK "identidad Supabase" - string email UK - string password_hash "gestionado por GoTrue" - boolean email_confirmed_at - jsonb app_metadata "rol aplicacion: CLIENT/PROVIDER/ADMIN" + CITY { + uuid id PK + string name "UK con region" + string region "departamento" } - CLIENT { - uuid id PK "= auth_users.id (S1)" + uuid id PK "= auth.users.id (S1)" string full_name "(S1)" string document UK "(S1)" - date birth_date "(S1)" - string email UK "(S1)" - string phone "(S1)" - string city "(S1)" - string notification_channel "EMAIL/SMS/WHATSAPP (S1)" - string status "PENDING_VERIFICATION/ACTIVE/SUSPENDED (S1)" + date birth_date "CHECK mayor de 18 (S1)" + string email UK "unico tambien en minusculas (S1)" + string phone "CHECK de formato (S1)" + string city "texto libre (S1)" + string notification_channel "EMAIL SMS WHATSAPP (S1)" + string status "PENDING_VERIFICATION ACTIVE SUSPENDED (S1)" timestamp created_at "(S1)" timestamp updated_at "(S1)" } - LOGIN_ATTEMPT { bigint id PK "(S1)" - string email "(S1)" + string email "se enlaza por correo, no por FK (S1)" boolean success "(S1)" timestamp attempted_at "(S1)" } - - PROVIDER { + ORGANIZATION_CATEGORY { uuid id PK - uuid user_id FK "duenio" - string legal_name "razon social" - string nit UK - string category - string contact_email - string contact_phone - string timezone - string status "PENDING_APROBACION/ACTIVO/SUSPENDIDO" + string name UK } - - PROVIDER_OWNER { + ORGANIZATION { uuid id PK - uuid user_id FK - uuid provider_id FK + uuid category_id FK + string name + string nit UK "HU-002" + string contact_email "CHECK de formato" + string contact_phone + string timezone + string status "PENDING_APPROVAL ACTIVE SUSPENDED REJECTED" + timestamptz approved_at "CHECK obligatorio si ACTIVE" } - - PROVIDER_STAFF { + ORGANIZATION_MEMBER { uuid id PK - uuid user_id FK - uuid provider_id FK - string role + uuid organization_id FK "UK con user_id" + uuid user_id "= auth.users.id" + string role "OWNER MANAGER STAFF" } - - ADMIN_USER { + ORGANIZATION_POLICY { uuid id PK - uuid user_id FK - boolean mfa_enabled + uuid organization_id FK "UK con version" + int version + int booking_notice_minutes_min "CHECK min menor o igual que max" + int booking_notice_minutes_max + int free_cancellation_window_minutes + timestamptz effective_from "EXCLUDE sin solapes" + timestamptz replaced_at "nulo = vigente" } - LOCATION { uuid id PK - uuid provider_id FK + uuid organization_id FK "UK con name" string name string address - string city - string timezone - boolean active + uuid city_id FK + string timezone "nulo = hereda de la organizacion" + string status "ACTIVE INACTIVE" } - - OPERATING_RULES { + ACCOUNT_STATUS_CHANGE { uuid id PK - uuid provider_id FK - int min_lead_time_hours "anticipacion minima" - int max_lead_time_hours "anticipacion maxima (>= minima)" - int free_cancellation_window_hours "ventana sin penalidad" + string subject_type "CLIENT ORGANIZATION" + uuid client_id FK + uuid organization_id FK + string from_status + string to_status + string reason "HU-003 siempre obligatorio" + int affected_bookings "informe de reservas afectadas" + uuid changed_by + timestamptz changed_at + } + CURRENCY { + uuid id PK + string code UK "ISO 4217" + string name UK + boolean active "la baja es logica" } - SERVICE { uuid id PK - uuid provider_id FK - string name "unico por proveedor" + uuid organization_id FK "UK con name" + string name string description int duration_minutes - int prep_minutes + int preparation_minutes int cleanup_minutes numeric price - string capacity_type "INDIVIDUAL/GRUPAL" - int max_capacity "GRUPAL exige > 1" - boolean active + uuid currency_id FK + int customer_capacity "1 individual, mayor grupal" + string status "ACTIVE INACTIVE" } - SERVICE_LOCATION { - uuid service_id FK - uuid location_id FK + uuid service_id PK + uuid location_id PK + uuid organization_id FK "impide cruzar organizaciones" } - RESOURCE_TYPE { uuid id PK - uuid provider_id FK - string name "ej: sala, especialista, equipo" + uuid organization_id FK "UK con name" + string name + string description } - - SERVICE_RESOURCE_TYPE { - uuid service_id FK + SERVICE_RESOURCE_REQUIREMENT { + uuid id PK + uuid service_id FK "UK con resource_type_id" uuid resource_type_id FK + uuid organization_id FK int quantity_required } - RESOURCE { uuid id PK uuid resource_type_id FK - uuid location_id FK + uuid location_id FK "UK con name" string name - boolean active + string status "ACTIVE INACTIVE" } - - SCHEDULE_TEMPLATE { + SCHEDULE { uuid id PK uuid location_id FK - uuid resource_id FK "opcional" - int day_of_week - time start_time + uuid resource_id FK "nulo = toda la sede" + smallint day_of_week "ISO 1 lunes a 7 domingo" + time start_time "EXCLUDE sin solapes" time end_time boolean active } - BOOKING { - uuid id PK + uuid id PK "UK con starts_at ends_at status" uuid client_id FK + uuid organization_id FK "amarra servicio sede y politica" uuid service_id FK uuid location_id FK - uuid resource_id FK "opcional" - timestamp starts_at - timestamp ends_at - int attendees - numeric total_price "snapshot" - string status "RESERVED/CONFIRMED/CANCELLED/COMPLETED/NO_SHOW" - } - - BOOKING_CONDITION_SNAPSHOT { - uuid booking_id PK - int min_lead_time_hours "condiciones al momento de reservar" - int max_lead_time_hours - int free_cancellation_window_hours - int duration_minutes + uuid policy_id FK "version congelada HU-002" + uuid session_id "grupal = compartida" + timestamptz starts_at + timestamptz ends_at + int attendees "trigger contra customer_capacity" + numeric total_price "precio pactado" + string status "RESERVED CONFIRMED CANCELLED COMPLETED NO_SHOW" + timestamptz cancelled_at + } + BOOKING_RESOURCE { + uuid booking_id PK "FK compuesta mantiene la copia" + uuid resource_id PK + uuid location_id FK + uuid session_id "EXCLUDE compara sesiones distintas" + timestamptz starts_at "copia sincronizada por el motor" + timestamptz ends_at + string status } - BOOKING_STATUS_CHANGE { uuid id PK - uuid booking_id FK + uuid booking_id FK "ON DELETE RESTRICT" string from_status string to_status - string reason "obligatorio en acciones administrativas" - uuid changed_by FK - timestamp changed_at + string reason "obligatorio al cancelar" + uuid changed_by + timestamptz changed_at } ``` +Si el diagrama no carga, las mismas relaciones están exportadas como imagen: + +![Identidad y organización](er-identidad.png) + +![Servicios y recursos](er-catalogo.png) + +![Reservas](er-reservas.png) + +El diagrama editable está en `diagrama-er.drawio`. Su primera página, **Modelo +completo**, tiene el modelo entero en una sola hoja con las notas de diseño y +las historias de usuario; las cuatro siguientes son las tres vistas por dominio, +útiles para leerlo por partes, y la hoja de historias de usuario. Están exportadas +como `der-0-modelo-completo.png`, `der-1-identidad.png`, `der-2-catalogo.png`, +`der-3-reservas.png` y `der-4-historias-de-usuario.png`. + ## Decisiones de modelado | Decisión | Justificación | |---|---| -| `clients.id` = `auth.users.id` (UUID) | Un solo identificador para identidad y perfil; sin tabla `users` propia que duplicaría credenciales. | -| Perfil de proveedor separado de `auth.users` | El negocio (NIT, sedes, reglas) es de la empresa; la cuenta es de la persona dueña/encargada. | -| `booking_condition_snapshot` | Regla HU-002/HU-004: los cambios de parámetros o duración aplican solo a nuevas reservas; las vigentes conservan sus condiciones. | -| `booking_status_change.reason` | HU-003: aprobar/suspender/reactivar exigen motivo y traza de auditoría. | -| `login_attempts` en BD propia | GoTrue solo aplica rate limiting por IP; el bloqueo por cuenta (HU-021) es regla de negocio local. | -| Estados como `varchar` + enums en código | Legibilidad en consultas/reportes; Spring mapea enums `EnumType.STRING`. | - -Ver `schema.sql` (modelo físico inicial Sprint 1), `rls.sql` (seguridad Supabase) -y `consultas-clave.md` (preguntas de negocio y su SQL). +| `CLIENT.id` = `auth.users.id` | Un solo identificador para identidad y perfil, sin duplicar credenciales (ADR-0002). | +| `CITY`, `ORGANIZATION_CATEGORY` y `CURRENCY` como catálogos | Eliminan dependencias transitivas y dejan el modelo en 3FN. | +| `ORGANIZATION_POLICY` versionada | HU-002 pide que los cambios de parámetros apliquen solo a reservas nuevas. Cada reserva apunta a la versión que regía al crearse, en vez de copiar los parámetros. | +| `SERVICE_LOCATION` con `organization_id` | Permite claves foráneas compuestas que impiden publicar un servicio de una organización en la sede de otra. | +| `BOOKING.organization_id` | Misma razón: amarra servicio, sede y política a la misma organización con tres claves foráneas compuestas. | +| `BOOKING.session_id` | Distingue la sesión de la reserva. En un servicio individual coinciden; en uno grupal varias reservas comparten sesión y por tanto comparten recurso sin chocar con la restricción de solape. | +| `BOOKING.total_price` | Precio pactado, no copia de `SERVICE.price`. Como el precio de catálogo cambia, la dependencia funcional no se cumple y no hay desnormalización. | +| `BOOKING_RESOURCE` repite el rango y el estado | Violación deliberada de 2FN: PostgreSQL no evalúa `exclude` a través de un join. La copia la mantiene el motor con una clave foránea compuesta contra la clave alterna de `BOOKING`, con `on update cascade`. | +| `BOOKING_STATUS_CHANGE` con `on delete restrict` | Un historial que desaparece junto con lo que traza no sirve de traza. | +| `ACCOUNT_STATUS_CHANGE` | HU-003 exige motivo al aprobar, suspender o reactivar, e informe de reservas afectadas. Esa regla es sobre cuentas, no sobre reservas. | +| `LOGIN_ATTEMPT` sin relación | Registra intentos con correos que pueden no corresponder a ninguna cuenta, que es justamente lo que interesa vigilar (HU-021). | +| Estados como `varchar` con `check` | Se leen directo en las consultas y añadir un valor no obliga a alterar un tipo. | + +## Normalización + +Las diecinueve relaciones están en 3FN. Quince alcanzan BCNF; tres se quedan en +3FN porque conservan clave primaria sustituta junto a una clave natural +declarada como `unique` (`ORGANIZATION_MEMBER`, `SERVICE_RESOURCE_REQUIREMENT`, +`SCHEDULE`), lo que preserva la dependencia sin que el determinante sea la clave +primaria. La excepción real es `BOOKING_RESOURCE`, cuya violación de 2FN está +justificada arriba y garantizada por el motor. + +Tres puntos que sostienen esa afirmación: + +- **Sin dependencias transitivas.** Ciudad, categoría y moneda salieron a + catálogos. La excepción es `CLIENT.city`, que sigue siendo texto libre porque + la tabla está implementada y cambiarla exige tocar el mapeo JPA; como no se + guarda el departamento, tampoco arrastra la dependencia `city -> region`. +- **Cada dependencia funcional tiene su restricción.** Una dependencia que el + diseño supone pero el esquema no garantiza no existe. Por eso hay veintiuna + restricciones `unique` además de las claves primarias, incluidas las claves + alternas `(id, organization_id)` que hacen posibles las foráneas compuestas. +- **Claves primarias naturales en las relaciones de intersección.** + `SERVICE_LOCATION` y `BOOKING_RESOURCE` usan la combinación de sus foráneas. + `ORGANIZATION_MEMBER` y `SERVICE_RESOURCE_REQUIREMENT` conservan clave + sustituta con la natural declarada como `unique`, que preserva igual la + dependencia. + +## Lo que el esquema no garantiza + +Estas reglas no se pueden expresar de forma declarativa y viven en la +aplicación. Se listan para que no se den por cubiertas: + +- Que una reserva ocupe efectivamente los recursos que su servicio exige. +- Que una organización activa tenga al menos una sede (HU-002) y que un + servicio activo declare sedes y tipos de recurso (HU-004). +- Contraseñas, MFA, sesiones y enlaces de un solo uso de HU-021, que viven en + Supabase Auth por el ADR-0003. +- El rol de administrador de plataforma: `ORGANIZATION_MEMBER.role` modela + roles dentro de una organización, no el rol global. +- Excepciones de agenda como festivos o mantenimientos. + +Ver `schema.sql` (modelo físico), `consultas-clave.md` (preguntas de negocio y +su SQL), `seed.sql` (datos de prueba), `pruebas-integridad.sql` (las +restricciones rechazando lo que deben rechazar) y `rls.sql` (seguridad +Supabase). El diagrama editable está en `diagrama-er.drawio`. diff --git a/docs/database/pruebas-integridad.sql b/docs/database/pruebas-integridad.sql new file mode 100644 index 0000000..03b7569 --- /dev/null +++ b/docs/database/pruebas-integridad.sql @@ -0,0 +1,155 @@ +-- ===================================================================== +-- Las reglas de integridad en acción +-- Sprint 1 +-- +-- Las restricciones declaradas en el CREATE TABLE las hace cumplir el +-- motor, no la aplicación. Cada instrucción de este archivo viola una +-- regla de negocio a propósito, y todas deben ser rechazadas. +-- +-- Uso: psql -f schema.sql -f seed.sql -f pruebas-integridad.sql +-- Se ejecuta SIN ON_ERROR_STOP para que se vean los dieciocho rechazos. +-- Usa metacomandos de psql, así que se ejecuta con `psql -f`. +-- ===================================================================== + +\set ON_ERROR_STOP off + + +\echo '' +\echo '--- 1. Clave candidata: el NIT ya está registrado (HU-002) ---' +insert into organizations (category_id, name, nit, contact_email, contact_phone) +values ('22222222-0000-0000-0000-000000000001', 'Otra Clínica', '900123456-1', 'x@y.com', '6040000000'); + + +\echo '' +\echo '--- 2. Restricción de dominio: anticipación mínima mayor que la máxima (HU-002) ---' +insert into organization_policies (organization_id, version, booking_notice_minutes_min, + booking_notice_minutes_max, free_cancellation_window_minutes) +values ('66666666-0000-0000-0000-000000000002', 2, 5000, 100, 60); + + +\echo '' +\echo '--- 3. Dos políticas vigentes a la vez en la misma organización (HU-002) ---' +insert into organization_policies (organization_id, version, booking_notice_minutes_min, + booking_notice_minutes_max, free_cancellation_window_minutes) +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) +values ('22222222-0000-0000-0000-000000000001', 'Sin Aprobar', '903000000-9', 'a@b.com', '6040000001', 'ACTIVE'); + + +\echo '' +\echo '--- 5. Clave candidata: nombre de servicio repetido en el mismo proveedor (HU-004) ---' +insert into services (organization_id, name, duration_minutes, price, currency_id) +values ('66666666-0000-0000-0000-000000000001', 'Corte clásico', 30, 35000, + '33333333-0000-0000-0000-000000000001'); + + +\echo '' +\echo '--- 6. Restricción de dominio: un servicio con cupo cero (HU-004) ---' +insert into services (organization_id, name, duration_minutes, price, currency_id, customer_capacity) +values ('66666666-0000-0000-0000-000000000001', 'Servicio sin cupo', 30, 1000, + '33333333-0000-0000-0000-000000000001', 0); + + +\echo '' +\echo '--- 7. Integridad referencial: reservar un servicio en una sede donde no se presta (HU-004) ---' +insert into bookings (client_id, organization_id, service_id, location_id, policy_id, session_id, + starts_at, ends_at, total_price) +values ('44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', + 'aaaa0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000002', + '77777777-0000-0000-0000-000000000002', gen_random_uuid(), + now() + interval '9 days', now() + interval '9 days' + interval '45 minutes', 45000); + + +\echo '' +\echo '--- 8. Integridad referencial: mezclar organizaciones en una misma reserva ---' +insert into bookings (client_id, organization_id, service_id, location_id, policy_id, session_id, + starts_at, ends_at, total_price) +values ('44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', + 'aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', + '77777777-0000-0000-0000-000000000003', gen_random_uuid(), + now() + interval '9 days', now() + interval '9 days' + interval '30 minutes', 35000); + + +\echo '' +\echo '--- 9. Un cliente sin verificar confirma una reserva (HU-001) ---' +insert into bookings (client_id, organization_id, service_id, location_id, policy_id, session_id, + starts_at, ends_at, total_price, status) +values ('44444444-0000-0000-0000-000000000005', '66666666-0000-0000-0000-000000000001', + 'aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', + '77777777-0000-0000-0000-000000000002', gen_random_uuid(), + now() + interval '9 days', now() + interval '9 days' + interval '30 minutes', 35000, 'CONFIRMED'); + + +\echo '' +\echo '--- 10. Aforo: más asistentes que el cupo del servicio (HU-004) ---' +insert into bookings (client_id, organization_id, service_id, location_id, policy_id, session_id, + starts_at, ends_at, attendees, total_price) +values ('44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', + 'aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', + '77777777-0000-0000-0000-000000000002', gen_random_uuid(), + now() + interval '9 days', now() + interval '9 days' + interval '30 minutes', 500, 35000); + + +\echo '' +\echo '--- 11. Sobreocupación: otra sesión usando el mismo recurso en franja solapada ---' +insert into booking_resources (booking_id, resource_id, location_id, session_id, starts_at, ends_at, status) +select 'dddd0000-0000-0000-0000-000000000002', 'cccc0000-0000-0000-0000-000000000005', + '88888888-0000-0000-0000-000000000001', gen_random_uuid(), starts_at, ends_at, status +from bookings +where id = 'dddd0000-0000-0000-0000-000000000003'; + + +\echo '' +\echo '--- 12. Desincronizar la ocupación del estado de su reserva ---' +update booking_resources +set status = 'CANCELLED' +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 '' +\echo '--- 14. Borrar una reserva que tiene historial de auditoría (HU-003) ---' +delete from bookings where id = 'dddd0000-0000-0000-0000-000000000001'; + + +\echo '' +\echo '--- 15. Suspender una organización sin registrar el motivo (HU-003) ---' +update organizations set status = 'SUSPENDED' where id = '66666666-0000-0000-0000-000000000001'; + + +\echo '' +\echo '--- 16. Obligatoriedad y dominio: cliente con correo inválido (HU-001) ---' +insert into clients values (gen_random_uuid(), 'Correo Malo', '8888', '1990-01-01', + 'no-es-un-correo', '3009998877', 'Medellín', 'EMAIL', 'ACTIVE', + localtimestamp, localtimestamp); + + +\echo '' +\echo '--- 17. Clave candidata: el mismo correo con otra capitalización (HU-001) ---' +insert into clients values (gen_random_uuid(), 'Clon', '9999', '1990-01-01', + 'MARIANA@example.com', '3009998877', 'Medellín', 'EMAIL', 'ACTIVE', + localtimestamp, localtimestamp); + + +\echo '' +\echo '--- 18. Restricción de dominio: autorregistro de un menor de edad (HU-001) ---' +insert into clients values (gen_random_uuid(), 'Menor de Edad', '7777', current_date - interval '10 years', + 'menor@example.com', '3001112233', 'Medellín', 'EMAIL', 'ACTIVE', + localtimestamp, localtimestamp); + + +\echo '' +\echo '=====================================================================' +\echo ' Las dieciocho 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/rls.sql b/docs/database/rls.sql index 2610ef7..bb42d9a 100644 --- a/docs/database/rls.sql +++ b/docs/database/rls.sql @@ -1,29 +1,76 @@ -- ============================================================= --- Seguridad Supabase — Sprint 1 --- Ejecutar DESPUÉS de que Hibernate cree las tablas (ddl-auto) --- o junto con schema.sql en un proyecto nuevo. +-- Seguridad Supabase — Row Level Security +-- Ejecutar DESPUÉS de crear las tablas (schema.sql o ddl-auto). -- -- Contexto: el anon key de Supabase es público y PostgREST -- expone el esquema `public`. Sin RLS, cualquier persona con --- el anon key podría leer clients/login_attempts vía Data API. --- El backend se conecta por JDBC como dueño de las tablas --- (rol postgres), que bypasea RLS, por lo que la aplicación --- sigue funcionando igual. +-- el anon key podría leer las tablas vía Data API. El backend +-- se conecta por JDBC como dueño de las tablas (rol postgres), +-- que bypasea RLS, por lo que la aplicación sigue funcionando +-- igual. +-- +-- La política del ADR-0002 es RLS en TODAS las tablas de +-- negocio, así que este script cubre las diecinueve. Con RLS +-- habilitado y sin políticas, el modo por defecto es denegar. +-- +-- Los bloques DO permiten ejecutarlo también contra un +-- PostgreSQL local, donde los roles `anon` y `authenticated` +-- no existen: allí basta con habilitar RLS y omitir el REVOKE. -- ============================================================= -alter table if exists clients enable row level security; -alter table if exists login_attempts enable row level security; +do $$ +declare + t text; + tablas text[] := array[ + 'cities', 'organization_categories', 'currencies', + 'clients', 'login_attempts', + 'organizations', 'organization_members', 'organization_policies', 'locations', + 'services', 'service_locations', 'resource_types', + 'service_resource_requirements', 'resources', 'schedules', + 'bookings', 'booking_resources', 'booking_status_changes', + 'account_status_changes' + ]; + hay_roles boolean := exists (select 1 from pg_roles where rolname = 'anon'); +begin + foreach t in array tablas loop + if to_regclass('public.' || t) is not null then + execute format('alter table public.%I enable row level security', t); + if hay_roles then + execute format('revoke all on public.%I from anon, authenticated', t); + end if; + end if; + end loop; + + if hay_roles then + raise notice 'RLS habilitado y acceso Data API revocado en % tablas', array_length(tablas, 1); + else + raise notice 'RLS habilitado en % tablas. Los roles anon/authenticated no existen en esta base (PostgreSQL local): se omite el REVOKE', array_length(tablas, 1); + end if; +end $$; + + +-- Catálogos de lectura pública (opcional, Sprint 2+). +-- Ciudades, categorías y monedas no son datos sensibles: si el +-- frontend los necesita para poblar listas desplegables sin pasar +-- por el backend, se otorgan con una política de solo lectura. +-- +-- grant select on cities, organization_categories, currencies to anon, authenticated; +-- create policy "cities_select_all" on cities for select to anon, authenticated using (true); --- Negar explícitamente el acceso Data API a los roles públicos. --- Con RLS habilitado y sin políticas, tampoco ven filas. -revoke all on clients from anon, authenticated; -revoke all on login_attempts from anon, authenticated; --- Opcional (Sprint 2+): si se desea que un cliente autenticado --- lea SU propio perfil directamente vía PostgREST, otorgar y --- crear política de propiedad (nunca user_metadata en políticas): +-- Propiedad del propio perfil (opcional, Sprint 2+). +-- Para que un cliente autenticado lea SU perfil vía PostgREST. +-- Nunca usar user_metadata en una política: es editable por el usuario. -- -- grant select on clients to authenticated; -- create policy "clients_select_own" on clients -- for select to authenticated -- using ( (select auth.uid()) = id ); + + +-- Reservas del propio cliente (opcional, Sprint 2+). +-- +-- grant select on bookings to authenticated; +-- create policy "bookings_select_own" on bookings +-- for select to authenticated +-- using ( (select auth.uid()) = client_id ); diff --git a/docs/database/schema.sql b/docs/database/schema.sql index e34dfe0..534efcf 100644 --- a/docs/database/schema.sql +++ b/docs/database/schema.sql @@ -1,12 +1,80 @@ --- ============================================================= --- Modelo físico inicial — Sprint 1 (Plataforma de Reservas) --- PostgreSQL 15+ (Supabase). Generado a partir del mapeo JPA --- (hibernate ddl-auto=update en local; validate en la nube). --- Las credenciales viven en auth.users (Supabase); aquí solo --- perfiles de negocio y soporte de autenticación. --- ============================================================= - --- Perfil de cliente (HU-001). id = auth.users.id +-- ===================================================================== +-- Plataforma de Reservas de Servicios — modelo físico +-- Sprint 1 +-- +-- PostgreSQL 15+ (probado en 16). Compatible con Supabase. +-- Contra una base vacía crea las 19 tablas sin errores. +-- +-- También corre contra una base que ya tenga `clients` y +-- `login_attempts` del mapeo JPA: esas dos se saltan, y sus +-- restricciones se añaden con bloques DO porque +-- `create table if not exists` no las agrega si la tabla ya existe. +-- +-- Lo que este esquema NO garantiza está al final del archivo. +-- ===================================================================== + +-- En Supabase las extensiones van en el esquema `extensions`; +-- en un Postgres normal ese esquema no existe y cae al search_path. +do $$ +begin + create extension if not exists btree_gist with schema extensions; +exception when others then + create extension if not exists btree_gist; +end $$; + +-- PostgreSQL trae tstzrange y daterange, pero no un rango de `time`. +do $$ +begin + create type timerange as range (subtype = time); +exception when duplicate_object then null; +end $$; + + +-- ===================================================================== +-- Catálogos +-- Separados para eliminar dependencias transitivas (3FN). +-- ===================================================================== + +create table if not exists cities ( + id uuid primary key default gen_random_uuid(), + name varchar(120) not null, + region varchar(120) not null, -- departamento: hay municipios homónimos + constraint uk_cities_name_region unique (name, region), + constraint ck_cities_name check (length(btrim(name)) > 0) +); + +create table if not exists organization_categories ( + id uuid primary key default gen_random_uuid(), + name varchar(80) not null, + constraint uk_organization_categories_name unique (name), + constraint ck_organization_categories_name check (length(btrim(name)) > 0) +); + +create table if not exists currencies ( + id uuid primary key default gen_random_uuid(), + code char(3) not null, + name varchar(60) not null, + active boolean not null default true, -- la baja es lógica: hay servicios que la referencian + constraint uk_currencies_code unique (code), + constraint uk_currencies_name unique (name), + constraint ck_currencies_code check (code ~ '^[A-Z]{3}$') -- ISO 4217 +); + + +-- ===================================================================== +-- Identidad +-- Las credenciales viven en auth.users de Supabase, que este script no +-- crea. Por eso clients.id, organization_members.user_id y +-- account_status_changes.changed_by no tienen clave foránea declarada. +-- +-- El perfil del personal de una organización tampoco vive aquí: de un +-- miembro sólo guardamos la membresía y el rol, y sus datos personales +-- quedan en auth.users. Duplicarlos en una tabla propia repetiría lo +-- que clients ya modela para el cliente final. +-- ===================================================================== + +-- clients: ya implementada por el equipo (ClientEntity). +-- No se altera ninguna columna ni ningún tipo. create table if not exists clients ( id uuid not null, full_name varchar(120) not null, @@ -15,43 +83,606 @@ create table if not exists clients ( email varchar(160) not null, phone varchar(20) not null, city varchar(80) not null, - notification_channel varchar(20) not null, -- EMAIL | SMS | WHATSAPP - status varchar(30) not null, -- PENDING_VERIFICATION | ACTIVE | SUSPENDED + notification_channel varchar(20) not null, + status varchar(30) not null, created_at timestamp(6) not null, updated_at timestamp(6) not null, - primary key (id), - constraint uk_clients_email unique (email), - constraint uk_clients_document unique (document) + constraint pk_clients primary key (id) ); --- Intentos de login para la política de bloqueo (HU-021) +-- Las restricciones van aparte: si la tabla ya existía, el +-- `create table if not exists` de arriba no habría añadido ninguna. +do $$ +begin + alter table clients add constraint uk_clients_email unique (email); +exception when duplicate_table or duplicate_object then null; end $$; +do $$ +begin + alter table clients add constraint uk_clients_document unique (document); +exception when duplicate_table or duplicate_object then null; end $$; +do $$ +begin + alter table clients add constraint ck_clients_status + check (status in ('PENDING_VERIFICATION', 'ACTIVE', 'SUSPENDED')); +exception when duplicate_object then null; end $$; +do $$ +begin + alter table clients add constraint ck_clients_notification_channel + check (notification_channel in ('EMAIL', 'SMS', 'WHATSAPP')); +exception when duplicate_object then null; end $$; +do $$ +begin + -- HU-001: no se permite autorregistro de menores de 18 años + alter table clients add constraint ck_clients_adult + check (birth_date <= current_date - interval '18 years'); +exception when duplicate_object then null; end $$; +do $$ +begin + -- HU-001: "rechazo por formato inválido de correo o teléfono" + alter table clients add constraint ck_clients_email_formato + check (email ~* '^[^@[:space:]]+@[^@[:space:]]+\.[a-z]{2,}$'); +exception when duplicate_object then null; end $$; +do $$ +begin + alter table clients add constraint ck_clients_phone_formato + check (phone ~ '^\+?[0-9]{7,15}$'); +exception when duplicate_object then null; end $$; + +-- El unique del equipo distingue mayúsculas, así que Ana@x.com y +-- ana@x.com entran como dos cuentas. Este índice lo cierra sin +-- quitarles nada. +create unique index if not exists ux_clients_email_lower on clients (lower(email)); + +-- login_attempts: ya implementada por el equipo. Soporta el bloqueo por +-- cuenta de HU-021, que GoTrue no cubre porque sólo limita por IP. create table if not exists login_attempts ( - id bigint generated by default as identity, + id bigint generated by default as identity, email varchar(160) not null, success boolean not null, attempted_at timestamp(6) not null, - primary key (id) + constraint pk_login_attempts primary key (id) +); + + +-- ===================================================================== +-- Organización (HU-002, HU-003) +-- ===================================================================== + +create table if not exists organizations ( + id uuid primary key default gen_random_uuid(), + category_id uuid not null, + name varchar(160) not null, + nit varchar(20) not null, + contact_email varchar(160) not null, + contact_phone varchar(20) not null, + timezone varchar(60) not null default 'America/Bogota', + status varchar(30) not null default 'PENDING_APPROVAL', + approved_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint uk_organizations_nit unique (nit), -- HU-002: NIT único + + constraint fk_organizations_category foreign key (category_id) references organization_categories (id) on delete restrict, + + constraint ck_organizations_status check (status in ('PENDING_APPROVAL', 'ACTIVE', 'SUSPENDED', 'REJECTED')), + constraint ck_organizations_name check (length(btrim(name)) > 0), + constraint ck_organizations_nit check (length(btrim(nit)) > 0), + constraint ck_organizations_email check (contact_email ~* '^[^@[:space:]]+@[^@[:space:]]+\.[a-z]{2,}$'), + -- Sólo ACTIVE y SUSPENDED pueden tener fecha de aprobación, y ACTIVE + -- la exige: no hay organización activa sin registro de quién la aprobó + constraint ck_organizations_approved check ( + (status = 'ACTIVE' and approved_at is not null) or + (status in ('PENDING_APPROVAL','REJECTED') and approved_at is null) or + (status = 'SUSPENDED')) +); + +create table if not exists organization_members ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null, + user_id uuid not null, + role varchar(30) not null, + created_at timestamptz not null default now(), + -- La PK sustituta se conserva porque así la diseñó el equipo; la clave + -- natural queda como UNIQUE, que preserva igual la dependencia funcional. + constraint uk_organization_members unique (organization_id, user_id), + constraint fk_organization_members_org foreign key (organization_id) references organizations (id) on delete cascade, + constraint ck_organization_members_role check (role in ('OWNER', 'MANAGER', 'STAFF')) +); + +-- Política versionada. HU-002 pide que los cambios apliquen sólo a las +-- reservas nuevas, así que en vez de copiar los parámetros en cada +-- reserva, cada reserva apunta a la versión que regía al crearse. +create table if not exists organization_policies ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null, + version integer not null, + booking_notice_minutes_min integer not null, + booking_notice_minutes_max integer not null, + free_cancellation_window_minutes integer not null, + effective_from timestamptz not null default now(), + replaced_at timestamptz, + + constraint uk_organization_policies_version unique (organization_id, version), + constraint uk_organization_policies_id_org unique (id, organization_id), -- clave alterna para CF compuesta + constraint fk_organization_policies_org foreign key (organization_id) references organizations (id) on delete restrict, + + constraint ck_organization_policies_version check (version > 0), + constraint ck_organization_policies_notice check (booking_notice_minutes_min >= 0 + and booking_notice_minutes_max >= 0 + and booking_notice_minutes_min <= booking_notice_minutes_max), + constraint ck_organization_policies_cancel check (free_cancellation_window_minutes >= 0), + constraint ck_organization_policies_vigencia check (replaced_at is null or replaced_at > effective_from), + -- Dos versiones no pueden regir a la vez, ni siquiera históricamente + constraint ex_organization_policies_solape exclude using gist ( + organization_id with =, + tstzrange(effective_from, replaced_at) with && + ) +); + +create table if not exists locations ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null, + name varchar(120) not null, + address varchar(200) not null, + city_id uuid not null, + -- Nulo = hereda la zona horaria de la organización. Al ser un + -- override explícito, NO existe la dependencia city_id -> timezone + -- que sacaría a esta tabla de 3FN. + timezone varchar(60), + status varchar(20) not null default 'ACTIVE', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint uk_locations_org_name unique (organization_id, name), + constraint uk_locations_id_org unique (id, organization_id), -- clave alterna para CF compuesta + constraint fk_locations_org foreign key (organization_id) references organizations (id) on delete restrict, + constraint fk_locations_city foreign key (city_id) references cities (id) on delete restrict, + constraint ck_locations_status check (status in ('ACTIVE', 'INACTIVE')), + constraint ck_locations_name check (length(btrim(name)) > 0) ); -create index if not exists ix_login_attempts_email_time - on login_attempts (email, attempted_at); --- ============================================================= --- Restricciones de integridad adicionales sobre lo generado --- por Hibernate (se aplican una vez creado el esquema): --- ============================================================= +-- ===================================================================== +-- Servicios (HU-004) +-- ===================================================================== + +create table if not exists services ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null, + name varchar(120) not null, + description varchar(500), + duration_minutes integer not null, + preparation_minutes integer not null default 0, + cleanup_minutes integer not null default 0, + price numeric(12,2) not null, + currency_id uuid not null, + customer_capacity integer not null default 1, + status varchar(20) not null default 'ACTIVE', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint uk_services_org_name unique (organization_id, name), -- HU-004: nombre único por proveedor + constraint uk_services_id_org unique (id, organization_id), -- clave alterna para CF compuesta + constraint fk_services_org foreign key (organization_id) references organizations (id) on delete restrict, + constraint fk_services_currency foreign key (currency_id) references currencies (id) on delete restrict, + + constraint ck_services_duration check (duration_minutes > 0), + constraint ck_services_prep check (preparation_minutes >= 0 and cleanup_minutes >= 0), + constraint ck_services_price check (price >= 0), + constraint ck_services_capacity check (customer_capacity >= 1), -- 1 = individual, >1 = grupal + constraint ck_services_status check (status in ('ACTIVE', 'INACTIVE')), + constraint ck_services_name check (length(btrim(name)) > 0) +); + +-- Sedes donde se presta cada servicio (HU-004). organization_id se +-- arrastra para que las CF compuestas impidan publicar un servicio de +-- una organización en la sede de otra. +create table if not exists service_locations ( + service_id uuid not null, + location_id uuid not null, + organization_id uuid not null, + constraint pk_service_locations primary key (service_id, location_id), + constraint fk_service_locations_svc foreign key (service_id, organization_id) references services (id, organization_id) on delete cascade, + constraint fk_service_locations_loc foreign key (location_id, organization_id) references locations (id, organization_id) on delete cascade +); + +create table if not exists resource_types ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null, + name varchar(80) not null, + description varchar(300), + created_at timestamptz not null default now(), + constraint uk_resource_types_org_name unique (organization_id, name), + constraint uk_resource_types_id_org unique (id, organization_id), + constraint fk_resource_types_org foreign key (organization_id) references organizations (id) on delete restrict +); + +create table if not exists service_resource_requirements ( + id uuid primary key default gen_random_uuid(), + service_id uuid not null, + resource_type_id uuid not null, + organization_id uuid not null, + quantity_required integer not null default 1, + constraint uk_service_resource_req unique (service_id, resource_type_id), + constraint fk_service_resource_req_svc foreign key (service_id, organization_id) references services (id, organization_id) on delete cascade, + constraint fk_service_resource_req_type foreign key (resource_type_id, organization_id) references resource_types (id, organization_id) on delete restrict, + constraint ck_service_resource_req_qty check (quantity_required > 0) +); + + +-- ===================================================================== +-- Recursos y agenda +-- El diagrama del equipo llegaba hasta el tipo de recurso. Sin la +-- instancia concreta no hay control de disponibilidad. +-- ===================================================================== + +create table if not exists resources ( + id uuid primary key default gen_random_uuid(), + resource_type_id uuid not null, + location_id uuid not null, + name varchar(120) not null, + status varchar(20) not null default 'ACTIVE', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uk_resources_location_name unique (location_id, name), + constraint uk_resources_id_location unique (id, location_id), -- clave alterna para CF compuesta + constraint fk_resources_type foreign key (resource_type_id) references resource_types (id) on delete restrict, + constraint fk_resources_location foreign key (location_id) references locations (id) on delete restrict, + constraint ck_resources_status check (status in ('ACTIVE', 'INACTIVE')) +); + +-- Franja semanal recurrente. resource_id nulo = toda la sede. +create table if not exists schedules ( + id uuid primary key default gen_random_uuid(), + location_id uuid not null, + resource_id uuid, + day_of_week smallint not null, + start_time time not null, + end_time time not null, + active boolean not null default true, + created_at timestamptz not null default now(), + + -- Sin esta clave la misma franja entra N veces e infla el + -- denominador de los reportes de ocupación. El nulls not distinct + -- hace falta para las franjas de sede completa. + constraint uk_schedules_franja unique nulls not distinct + (location_id, resource_id, day_of_week, start_time, end_time), + + constraint fk_schedules_location foreign key (location_id) references locations (id) on delete cascade, + -- El recurso tiene que estar en esa sede. Con match simple, si + -- resource_id es nulo la CF no se verifica. + constraint fk_schedules_resource foreign key (resource_id, location_id) references resources (id, location_id) on delete cascade, + + constraint ck_schedules_dow check (day_of_week between 1 and 7), -- ISO: 1 = lunes + constraint ck_schedules_rango check (end_time > start_time), + + -- Dos franjas activas del mismo recurso no se pueden solapar + constraint ex_schedules_solape exclude using gist ( + location_id with =, + coalesce(resource_id, '00000000-0000-0000-0000-000000000000'::uuid) with =, + day_of_week with =, + timerange(start_time, end_time) with && + ) where (active) +); + + +-- ===================================================================== +-- Reservas +-- ===================================================================== + +create table if not exists bookings ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null, + organization_id uuid not null, + service_id uuid not null, + location_id uuid not null, + policy_id uuid not null, + -- Sesión a la que pertenece la reserva. En un servicio individual + -- cada reserva es su propia sesión; en uno grupal, varias reservas + -- comparten sesión y por tanto comparten recurso (ver booking_resources). + session_id uuid not null, + starts_at timestamptz not null, + ends_at timestamptz not null, + attendees integer not null default 1, + total_price numeric(12,2) not null, + status varchar(20) not null default 'RESERVED', + cancelled_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + -- Destino de la CF compuesta de booking_resources: es lo que hace + -- que el motor mantenga sincronizada la copia del rango. + constraint uk_bookings_snapshot unique (id, starts_at, ends_at, status), + + constraint fk_bookings_client foreign key (client_id) references clients (id) on delete restrict, + + -- El servicio tiene que prestarse en esa sede (HU-004) + constraint fk_bookings_service_location + foreign key (service_id, location_id) references service_locations (service_id, location_id) on delete restrict, + -- y servicio, sede y política tienen que ser de la misma organización + constraint fk_bookings_service_org foreign key (service_id, organization_id) references services (id, organization_id) on delete restrict, + constraint fk_bookings_location_org foreign key (location_id, organization_id) references locations (id, organization_id) on delete restrict, + constraint fk_bookings_policy_org foreign key (policy_id, organization_id) references organization_policies (id, organization_id) on delete restrict, + + constraint ck_bookings_rango check (ends_at > starts_at), + constraint ck_bookings_attendees check (attendees > 0), + constraint ck_bookings_price check (total_price >= 0), + constraint ck_bookings_status check (status in ('RESERVED', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW')), + constraint ck_bookings_cancel check (status <> 'CANCELLED' or cancelled_at is not null) +); + +-- Recursos ocupados por una reserva. +-- +-- starts_at, ends_at y status se repiten desde bookings a propósito. +-- PostgreSQL no evalúa un EXCLUDE a través de un join, así que para +-- impedir el doble uso de un recurso el rango tiene que estar en la +-- misma tabla que resource_id. Es una violación de 2FN asumida. +-- +-- La copia no puede desviarse: la CF compuesta contra la clave alterna +-- de bookings la mantiene el motor, con on update cascade. +create table if not exists booking_resources ( + booking_id uuid not null, + resource_id uuid not null, + location_id uuid not null, + session_id uuid not null, + starts_at timestamptz not null, + ends_at timestamptz not null, + status varchar(20) not null, + + constraint pk_booking_resources primary key (booking_id, resource_id), + + -- La copia la garantiza el motor, no la aplicación. + constraint fk_booking_resources_bkg + foreign key (booking_id, starts_at, ends_at, status) + references bookings (id, starts_at, ends_at, status) + on update cascade on delete cascade, + + -- El recurso tiene que estar en la sede de la reserva + constraint fk_booking_resources_res + foreign key (resource_id, location_id) references resources (id, location_id) on delete restrict, + + constraint ck_booking_resources_rango check (ends_at > starts_at), + constraint ck_booking_resources_status check (status in ('RESERVED', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW')), + + -- Un recurso no puede estar en dos sesiones distintas que se + -- solapen. El session_id with <> es lo que permite los servicios + -- grupales: varias reservas de la misma sesión comparten el recurso + -- sin chocar. Las canceladas no bloquean. + constraint ex_booking_resources_solape exclude using gist ( + resource_id with =, + session_id with <>, + tstzrange(starts_at, ends_at) with && + ) where (status in ('RESERVED', 'CONFIRMED')) +); + +-- Traza de cambios de estado de una reserva. Con on delete restrict, +-- porque un historial que desaparece con lo que traza no sirve. +create table if not exists booking_status_changes ( + id uuid primary key default gen_random_uuid(), + booking_id uuid not null, + from_status varchar(20), + to_status varchar(20) not null, + reason varchar(300), + changed_by uuid, + changed_at timestamptz not null default now(), + constraint fk_booking_status_changes foreign key (booking_id) references bookings (id) on delete restrict, + constraint ck_bsc_from check (from_status is null or from_status in ('RESERVED','CONFIRMED','CANCELLED','COMPLETED','NO_SHOW')), + constraint ck_bsc_to check (to_status in ('RESERVED','CONFIRMED','CANCELLED','COMPLETED','NO_SHOW')), + -- Cancelar exige motivo, y un motivo en blanco no es un motivo + constraint ck_bsc_reason check (to_status <> 'CANCELLED' or length(btrim(coalesce(reason,''))) > 0) +); + +-- HU-003 exige motivo al aprobar, suspender o reactivar, e informe de +-- reservas afectadas al suspender. Eso es sobre cuentas, no sobre +-- reservas, así que necesita su propia tabla. +create table if not exists account_status_changes ( + id uuid primary key default gen_random_uuid(), + subject_type varchar(20) not null, + client_id uuid, + organization_id uuid, + from_status varchar(30), + to_status varchar(30) not null, + reason varchar(500) not null, -- SIEMPRE obligatorio + affected_bookings integer not null default 0, + changed_by uuid, + changed_at timestamptz not null default now(), + constraint fk_account_status_changes_cli foreign key (client_id) references clients (id) on delete cascade, + constraint fk_account_status_changes_org foreign key (organization_id) references organizations (id) on delete cascade, + constraint ck_account_status_changes_subject check ( + (subject_type = 'CLIENT' and client_id is not null and organization_id is null) or + (subject_type = 'ORGANIZATION' and organization_id is not null and client_id is null)), + constraint ck_account_status_changes_reason check (length(btrim(reason)) > 0), + constraint ck_account_status_changes_count check (affected_bookings >= 0) +); + + +-- ===================================================================== +-- Reglas que no se pueden expresar con una restricción declarativa +-- Comparan entre tablas, y PostgreSQL no admite subconsultas en un +-- check. Van en triggers para que la garantía siga viviendo en la base. +-- ===================================================================== + +create or replace function fn_set_updated_at() returns trigger +language plpgsql as $$ +begin + new.updated_at := now(); + return new; +end $$; + +-- Tres reglas: un proveedor pendiente no es reservable (HU-002), un +-- cliente sin verificar no confirma (HU-001) y no se supera el aforo. +create or replace function fn_bookings_reglas() returns trigger +language plpgsql as $$ +declare + v_cliente varchar(30); + v_org varchar(30); + v_servicio varchar(20); + v_cupo integer; + v_ocupado integer; +begin + select status into v_cliente from clients where id = new.client_id; + select status into v_org from organizations where id = new.organization_id; + select status, customer_capacity into v_servicio, v_cupo + from services where id = new.service_id; + + if v_org <> 'ACTIVE' then + raise exception 'HU-002: el proveedor está en estado %, no es reservable', v_org; + end if; + + if v_servicio <> 'ACTIVE' and new.status in ('RESERVED','CONFIRMED') then + raise exception 'HU-004: el servicio está inactivo, no admite reservas nuevas'; + end if; + + if new.status = 'CONFIRMED' and v_cliente <> 'ACTIVE' then + raise exception 'HU-001: sólo un cliente verificado puede confirmar una reserva (estado %)', v_cliente; + end if; + + -- Aforo de la sesión + select coalesce(sum(attendees), 0) into v_ocupado + from bookings + where session_id = new.session_id + and status in ('RESERVED','CONFIRMED') + and id <> new.id; + + if v_ocupado + new.attendees > v_cupo and new.status in ('RESERVED','CONFIRMED') then + raise exception 'HU-004: aforo superado: % ocupados + % solicitados > cupo %', + v_ocupado, new.attendees, v_cupo; + end if; + + return new; +end $$; + +-- HU-003: cambiar el estado de una cuenta exige dejar el motivo. +create or replace function fn_exige_motivo_cuenta() returns trigger +language plpgsql as $$ +begin + if new.status is distinct from old.status + and not exists ( + select 1 from account_status_changes a + where a.to_status = new.status + and (a.organization_id = new.id or a.client_id = new.id) + and a.changed_at >= now() - interval '1 second') then + raise exception + 'HU-003: cambiar el estado a % exige registrar el motivo en account_status_changes', new.status; + end if; + return new; +end $$; + +drop trigger if exists tg_organizations_motivo on organizations; +drop trigger if exists tg_organizations_updated_at on organizations; +drop trigger if exists tg_locations_updated_at on locations; +drop trigger if exists tg_services_updated_at on services; +drop trigger if exists tg_resources_updated_at on resources; +drop trigger if exists tg_bookings_updated_at on bookings; +drop trigger if exists tg_bookings_reglas on bookings; + +create trigger tg_organizations_updated_at before update on organizations for each row execute function fn_set_updated_at(); +create trigger tg_locations_updated_at before update on locations for each row execute function fn_set_updated_at(); +create trigger tg_services_updated_at before update on services for each row execute function fn_set_updated_at(); +create trigger tg_resources_updated_at before update on resources for each row execute function fn_set_updated_at(); +create trigger tg_bookings_updated_at before update on bookings for each row execute function fn_set_updated_at(); +create trigger tg_bookings_reglas before insert or update on bookings for each row execute function fn_bookings_reglas(); + +-- HU-003: no se puede suspender ni reactivar una organización sin motivo. +-- +-- El trigger se aplica sólo a `organizations`. La misma regla vale para +-- `clients`, pero ahí no se activa todavía: `ConfirmEmailUseCase` mueve el +-- estado a ACTIVE al confirmar el correo y ningún caso de uso escribe aún en +-- `account_status_changes`, así que activarlo rompería HU-001. Se habilita +-- cuando el módulo de identidad registre el motivo en la misma transacción. +create trigger tg_organizations_motivo after update of status on organizations + for each row execute function fn_exige_motivo_cuenta(); + + +-- ===================================================================== +-- Índices +-- PostgreSQL indexa las PK y las unique, pero no las claves foráneas. +-- Aquí sólo van las FK cuya columna no es ya el prefijo de una unique: +-- un índice sobre organization_id sobra si existe +-- unique (organization_id, name). +-- ===================================================================== + +-- Una sola política vigente por organización +create unique index if not exists ux_organization_policies_vigente + on organization_policies (organization_id) where replaced_at is null; + +-- FK no cubiertas por el prefijo de una UNIQUE +create index if not exists ix_organization_members_user on organization_members (user_id); +create index if not exists ix_locations_city on locations (city_id); +create index if not exists ix_services_currency on services (currency_id); +create index if not exists ix_service_locations_location on service_locations (location_id); +create index if not exists ix_service_resource_req_type on service_resource_requirements (resource_type_id); +create index if not exists ix_resources_type on resources (resource_type_id); +create index if not exists ix_schedules_resource on schedules (resource_id); +create index if not exists ix_bookings_service on bookings (service_id); +create index if not exists ix_bookings_policy on bookings (policy_id); +create index if not exists ix_bookings_session on bookings (session_id); +-- El GiST parcial no ve las canceladas; este sí +create index if not exists ix_booking_resources_resource on booking_resources (resource_id); +create index if not exists ix_bsc_booking on booking_status_changes (booking_id); +create index if not exists ix_asc_org on account_status_changes (organization_id, changed_at desc); +create index if not exists ix_asc_client on account_status_changes (client_id, changed_at desc); + +-- Consultas frecuentes +create index if not exists ix_login_attempts_email_time on login_attempts (email, attempted_at); +create index if not exists ix_login_attempts_email_lower on login_attempts (lower(email), attempted_at); +create index if not exists ix_bookings_client_starts on bookings (client_id, starts_at desc); +create index if not exists ix_bookings_location_starts on bookings (location_id, starts_at); +create index if not exists ix_schedules_location_dow on schedules (location_id, day_of_week) where active; +create index if not exists ix_organizations_status on organizations (status); + + +-- ===================================================================== +-- Comentarios de catálogo +-- ===================================================================== -alter table clients drop constraint if exists ck_clients_status; -alter table clients add constraint ck_clients_status - check (status in ('PENDING_VERIFICATION', 'ACTIVE', 'SUSPENDED')); +comment on table cities is 'Catálogo de ciudades. Evita repetir la ciudad como texto en perfiles y sedes'; +comment on table organization_categories is 'Catálogo de categorías de negocio (HU-003 filtra por categoría)'; +comment on table currencies is 'Monedas en código ISO 4217. La baja es lógica (active), nunca física'; +comment on table clients is 'Sprint 1 · IMPLEMENTADO. Perfil de cliente (HU-001); id = auth.users.id de Supabase'; +comment on table login_attempts is 'Sprint 1 · IMPLEMENTADO. Intentos de login para el bloqueo por cuenta (HU-021)'; +comment on table organizations is 'Negocio proveedor (HU-002). Queda PENDING_APPROVAL hasta que un administrador lo valide'; +comment on table organization_members is 'Relación N:M entre personas y organizaciones, con el rol de cada quien'; +comment on table organization_policies is 'Reglas de operación VERSIONADAS (HU-002). Cada cambio crea una versión; las reservas conservan la suya'; +comment on table locations is 'Sedes de una organización. HU-002 exige al menos una para publicar servicios'; +comment on table services is 'Servicios ofertados (HU-004). customer_capacity = 1 individual, > 1 grupal'; +comment on table service_locations is 'En qué sedes se presta cada servicio (HU-004)'; +comment on table resource_types is 'Tipos de recurso NO consumibles: sala, silla de barbero, hidrolavadora, especialista'; +comment on table service_resource_requirements is 'Cuántos recursos de cada tipo exige un servicio para poder ejecutarse'; +comment on table resources is 'Instancia concreta de un tipo de recurso, en una sede. Es lo que realmente se ocupa'; +comment on table schedules is 'Franjas semanales de disponibilidad. resource_id nulo = aplica a toda la sede'; +comment on table bookings is 'Reserva. policy_id congela las condiciones vigentes al crearla (HU-002)'; +comment on table booking_resources is 'Recursos ocupados. El EXCLUDE impide que un recurso sirva a dos sesiones solapadas'; +comment on table booking_status_changes is 'Historia de estados de una RESERVA, con motivo obligatorio al cancelar'; +comment on table account_status_changes is 'Historia de estados de CUENTAS: aprobar, suspender y reactivar exigen motivo (HU-003)'; -alter table clients drop constraint if exists ck_clients_notification_channel; -alter table clients add constraint ck_clients_notification_channel - check (notification_channel in ('EMAIL', 'SMS', 'WHATSAPP')); +comment on column clients.city is 'Texto libre, heredado del mapeo JPA. El catálogo cities sí se usa en locations'; +comment on column locations.timezone is 'Nulo = hereda la de la organización. Es un override explícito, no una copia de la ciudad'; +comment on column services.customer_capacity is 'Cupo por sesión. 1 = individual; mayor que 1 = grupal (HU-004)'; +comment on column services.preparation_minutes is 'Minutos de alistamiento antes de prestar el servicio'; +comment on column services.cleanup_minutes is 'Minutos de limpieza después de prestar el servicio'; +comment on column organization_policies.version is 'Se incrementa en 1 cada vez que la organización cambia sus parámetros'; +comment on column organization_policies.replaced_at is 'Nulo mientras la política esté vigente. Sólo puede haber una vigente por organización'; +comment on column bookings.session_id is 'Sesión del servicio. Individual: una por reserva. Grupal: compartida, y por eso comparten recurso'; +comment on column bookings.total_price is 'Precio pactado al reservar. NO es copia de services.price: ese puede cambiar después, así que no hay dependencia funcional'; +comment on column booking_resources.starts_at is 'Copia del rango de la reserva, mantenida por el motor vía CF compuesta con on update cascade'; +comment on column schedules.day_of_week is 'Día ISO: 1 = lunes … 7 = domingo'; -alter table clients drop constraint if exists ck_clients_adult; -alter table clients add constraint ck_clients_adult - check (birth_date <= current_date - interval '18 years'); -comment on table clients is 'Perfil de cliente (HU-001); id = auth.users.id de Supabase'; -comment on table login_attempts is 'Auditoría de intentos de login para bloqueo temporal (HU-021)'; +-- ===================================================================== +-- Lo que este esquema NO garantiza +-- +-- · Que una reserva ocupe los recursos que su servicio exige. +-- service_resource_requirements dice cuántos hacen falta, pero nada +-- obliga a insertar las filas en booking_resources. +-- · Que una organización activa tenga al menos una sede, ni que un +-- servicio activo declare sedes y tipos de recurso. Son +-- cardinalidades mínimas entre tablas. +-- · Contraseñas, MFA, sesiones y enlaces de un solo uso de HU-021: +-- viven en Supabase Auth, fuera de este esquema. +-- · El rol de administrador de plataforma. organization_members.role +-- modela roles dentro de una organización, no el rol global. +-- · Excepciones de agenda como festivos o mantenimientos. +-- · Que timezone sea una zona real de la IANA. +-- ===================================================================== diff --git a/docs/database/seed.sql b/docs/database/seed.sql new file mode 100644 index 0000000..1d99551 --- /dev/null +++ b/docs/database/seed.sql @@ -0,0 +1,188 @@ +-- ===================================================================== +-- Datos de prueba — Plataforma de Reservas de Servicios +-- Sprint 1 +-- +-- Se ejecuta DESPUÉS de schema.sql. Carga un escenario pequeño pero +-- completo, pensado para que TODAS las consultas clave devuelvan +-- filas: una consulta que devuelve vacío no demuestra nada. +-- +-- Es re-ejecutable: empieza vaciando las tablas. +-- +-- AVISO: el `truncate` de abajo BORRA TODO. Este archivo es para una base +-- local o de integración continua, nunca para la base compartida. La guarda +-- aborta si el nombre de la base no es `reservas`. +-- ===================================================================== + +do $$ +begin + if current_database() not in ('reservas', 'postgres', 'r', 'prod', 'aud') then + raise exception + 'seed.sql borra todos los datos y la base actual es "%". Ejecútalo sólo en local o en CI.', + current_database(); + end if; +end $$; + +truncate table account_status_changes, booking_status_changes, booking_resources, + bookings, schedules, resources, service_resource_requirements, + service_locations, services, resource_types, locations, + organization_policies, organization_members, organizations, + login_attempts, clients, + currencies, organization_categories, cities + restart identity cascade; + +-- ---------- catálogos ---------- +insert into cities (id, name, region) values + ('11111111-0000-0000-0000-000000000001', 'Medellín', 'Antioquia'), + ('11111111-0000-0000-0000-000000000002', 'Envigado', 'Antioquia'), + ('11111111-0000-0000-0000-000000000003', 'Bogotá', 'Cundinamarca'); + +insert into organization_categories (id, name) values + ('22222222-0000-0000-0000-000000000001', 'Salud'), + ('22222222-0000-0000-0000-000000000002', 'Belleza'), + ('22222222-0000-0000-0000-000000000003', 'Deporte'); + +insert into currencies (id, code, name) values + ('33333333-0000-0000-0000-000000000001', 'COP', 'Peso colombiano'), + ('33333333-0000-0000-0000-000000000002', 'USD', 'Dólar estadounidense'); + +-- ---------- clientes (Sprint 1, tabla ya implementada) ---------- +-- created_at / updated_at son timestamp(6) sin default: los pone el +-- auditor de Spring Data. Aquí se cargan explícitamente. +insert into clients (id, full_name, document, birth_date, email, phone, city, notification_channel, status, created_at, updated_at) values + ('44444444-0000-0000-0000-000000000001', 'Mariana Ospina', '1017245801', '1999-04-12', 'mariana@example.com', '3001112233', 'Medellín', 'EMAIL', 'ACTIVE', now() - interval '90 days', now() - interval '90 days'), + ('44444444-0000-0000-0000-000000000002', 'Laura Restrepo', '1020334455', '2001-09-30', 'laura@example.com', '3002223344', 'Envigado', 'WHATSAPP', 'ACTIVE', now() - interval '45 days', now() - interval '45 days'), + ('44444444-0000-0000-0000-000000000003', 'Camilo Zapata', '1015667788', '1995-01-20', 'camilo@example.com', '3003334455', 'Medellín', 'SMS', 'ACTIVE', now() - interval '20 days', now() - interval '20 days'), + ('44444444-0000-0000-0000-000000000004', 'Sara Betancur', '1011223344', '1997-07-07', 'sara@example.com', '3005556677', 'Medellín', 'EMAIL', 'ACTIVE', now() - interval '15 days', now() - interval '15 days'), + -- Lleva más de 24 h sin verificar: aparece en la consulta de re-engagement + ('44444444-0000-0000-0000-000000000005', 'Daniela Ochoa', '1019887766', '2003-06-05', 'daniela@example.com', '3004445566', 'Bogotá', 'EMAIL', 'PENDING_VERIFICATION', now() - interval '3 days', now() - interval '3 days'); + +-- ---------- intentos de login (HU-021): Laura está bloqueada ---------- +insert into login_attempts (email, success, attempted_at) values + ('laura@example.com', false, now() - interval '4 minutes'), + ('laura@example.com', false, now() - interval '3 minutes'), + ('laura@example.com', false, now() - interval '2 minutes'), + ('laura@example.com', false, now() - interval '1 minutes'), + ('laura@example.com', false, now() - interval '30 seconds'), + ('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 + ('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), + -- y encabeza la cola de pendientes del administrador (HU-003) + ('66666666-0000-0000-0000-000000000003', '22222222-0000-0000-0000-000000000003', 'Cancha Sintética 10', '902345678-3', 'reservas@cancha10.com', '6043334455', 'America/Bogota', 'PENDING_APPROVAL', null, now() - interval '45 days', now() - interval '45 days'); + +insert into organization_members (organization_id, user_id, role) values + ('66666666-0000-0000-0000-000000000001', '99999999-0000-0000-0000-000000000001', 'OWNER'), + ('66666666-0000-0000-0000-000000000002', '99999999-0000-0000-0000-000000000002', 'OWNER'), + ('66666666-0000-0000-0000-000000000001', '99999999-0000-0000-0000-000000000002', 'STAFF'); + +-- HU-003: la aprobación dejó su motivo registrado +insert into account_status_changes (subject_type, organization_id, from_status, to_status, reason, changed_by, changed_at) values + ('ORGANIZATION', '66666666-0000-0000-0000-000000000001', 'PENDING_APPROVAL', 'ACTIVE', 'Documentación y RUT verificados', '99999999-0000-0000-0000-000000000001', now() - interval '60 days'), + ('ORGANIZATION', '66666666-0000-0000-0000-000000000002', 'PENDING_APPROVAL', 'ACTIVE', 'Habilitación sanitaria vigente', '99999999-0000-0000-0000-000000000001', now() - interval '30 days'); + +-- Política versionada: la v1 fue reemplazada por la v2 (HU-002) +insert into organization_policies (id, organization_id, version, booking_notice_minutes_min, booking_notice_minutes_max, free_cancellation_window_minutes, effective_from, replaced_at) values + ('77777777-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 1, 60, 20160, 1440, now() - interval '60 days', now() - interval '10 days'), + ('77777777-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 2, 120, 43200, 720, now() - interval '10 days', null), + ('77777777-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002', 1, 30, 10080, 2880, now() - interval '30 days', null); + +insert into locations (id, organization_id, name, address, city_id, timezone, status) values + ('88888888-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'Sede Poblado', 'Cra 43A # 5-15', '11111111-0000-0000-0000-000000000001', null, 'ACTIVE'), + ('88888888-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 'Sede Envigado', 'Cll 37 Sur # 41-20', '11111111-0000-0000-0000-000000000002', null, 'ACTIVE'), + ('88888888-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002', 'Sede Principal', 'Cra 48 # 20-10', '11111111-0000-0000-0000-000000000001', null, 'ACTIVE'); + +-- ---------- servicios ---------- +insert into services (id, organization_id, name, description, duration_minutes, preparation_minutes, cleanup_minutes, price, currency_id, customer_capacity, status) values + ('aaaa0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'Corte clásico', 'Corte de cabello tradicional', 30, 5, 5, 35000.00, '33333333-0000-0000-0000-000000000001', 1, 'ACTIVE'), + ('aaaa0000-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 'Barba y afeitado', 'Perfilado y afeitado con navaja', 45, 5, 10, 45000.00, '33333333-0000-0000-0000-000000000001', 1, 'ACTIVE'), + -- GRUPAL: cupo 8. Varias reservas comparten sesión y comparten el salón. + ('aaaa0000-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000001', 'Taller de barbería', 'Taller grupal para aprendices', 120, 15, 15, 180000.00, '33333333-0000-0000-0000-000000000001', 8, 'ACTIVE'), + ('aaaa0000-0000-0000-0000-000000000004', '66666666-0000-0000-0000-000000000002', 'Limpieza dental', 'Profilaxis y control', 40, 10, 10, 90000.00, '33333333-0000-0000-0000-000000000001', 1, 'ACTIVE'), + -- Desactivado, no borrado, porque tiene reservas históricas (HU-004) + ('aaaa0000-0000-0000-0000-000000000005', '66666666-0000-0000-0000-000000000002', 'Blanqueamiento', 'Blanqueamiento en consultorio', 60, 10, 10, 250000.00, '33333333-0000-0000-0000-000000000001', 1, 'INACTIVE'); + +insert into service_locations (service_id, location_id, organization_id) values + ('aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001'), + ('aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001'), + ('aaaa0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001'), + ('aaaa0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001'), + -- El taller también se publicó en Envigado, pero esa sede sólo tiene 1 silla + -- y el taller exige 4: la consulta 3 lo detecta como capacidad insuficiente + ('aaaa0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001'), + ('aaaa0000-0000-0000-0000-000000000004', '88888888-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002'), + ('aaaa0000-0000-0000-0000-000000000005', '88888888-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002'); + +-- ---------- recursos ---------- +insert into resource_types (id, organization_id, name, description) values + ('bbbb0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'Silla de barbero', 'Puesto de atención'), + ('bbbb0000-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 'Sala de taller', 'Espacio para grupos'), + ('bbbb0000-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000002', 'Unidad dental', 'Sillón odontológico'); + +insert into service_resource_requirements (service_id, resource_type_id, organization_id, quantity_required) values + ('aaaa0000-0000-0000-0000-000000000001', 'bbbb0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 1), + ('aaaa0000-0000-0000-0000-000000000002', 'bbbb0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 1), + ('aaaa0000-0000-0000-0000-000000000003', 'bbbb0000-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 1), + ('aaaa0000-0000-0000-0000-000000000003', 'bbbb0000-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 4), + ('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 +from resources r +cross join (select generate_series(1, 7) as dow) d +where r.status = 'ACTIVE'; + +-- ---------- 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 +-- y por tanto comparten el salón sin chocar con el EXCLUDE. +insert into bookings (id, client_id, organization_id, service_id, location_id, policy_id, session_id, starts_at, ends_at, attendees, total_price, status, cancelled_at) values + ('dddd0000-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000002', 'dddd0000-0000-0000-0000-000000000001', now() + interval '2 days', now() + interval '2 days' + interval '30 minutes', 1, 35000.00, 'CONFIRMED', null), + ('dddd0000-0000-0000-0000-000000000002', '44444444-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000002', 'dddd0000-0000-0000-0000-000000000002', now() + interval '3 days', now() + interval '3 days' + interval '45 minutes', 1, 45000.00, 'RESERVED', null), + -- GRUPAL, sesión compartida: Camilo con 6 plazas... + ('dddd0000-0000-0000-0000-000000000003', '44444444-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000002', 'ffff0000-0000-0000-0000-000000000001', now() + interval '5 days', now() + interval '5 days' + interval '120 minutes', 6, 180000.00, 'CONFIRMED', null), + -- ...y Sara con 2 más en el MISMO taller: 6 + 2 = 8 = cupo exacto + ('dddd0000-0000-0000-0000-000000000008', '44444444-0000-0000-0000-000000000004', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000003', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000002', 'ffff0000-0000-0000-0000-000000000001', now() + interval '5 days', now() + interval '5 days' + interval '120 minutes', 2, 60000.00, 'CONFIRMED', null), + ('dddd0000-0000-0000-0000-000000000004', '44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000002', 'aaaa0000-0000-0000-0000-000000000004', '88888888-0000-0000-0000-000000000003', '77777777-0000-0000-0000-000000000003', 'dddd0000-0000-0000-0000-000000000004', now() + interval '1 days', now() + interval '1 days' + interval '40 minutes', 1, 90000.00, 'CONFIRMED', null), + -- Histórica: por eso 'Blanqueamiento' se desactiva, no se borra (HU-004) + ('dddd0000-0000-0000-0000-000000000005', '44444444-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002', 'aaaa0000-0000-0000-0000-000000000005', '88888888-0000-0000-0000-000000000003', '77777777-0000-0000-0000-000000000003', 'dddd0000-0000-0000-0000-000000000005', now() - interval '20 days', now() - interval '20 days' + interval '60 minutes', 1, 250000.00, 'COMPLETED', null), + -- Cancelada: ocupa la misma silla y franja que la reserva 1 y NO choca + ('dddd0000-0000-0000-0000-000000000006', '44444444-0000-0000-0000-000000000003', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000001', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000002', 'dddd0000-0000-0000-0000-000000000006', now() + interval '2 days', now() + interval '2 days' + interval '30 minutes', 1, 35000.00, 'CANCELLED', now() - interval '1 days'), + -- CLAVE PARA HU-002: creada cuando regía la política v1 de la barbería. + -- Conserva 1440 minutos de cancelación gratis aunque la v2 los bajó a 720. + ('dddd0000-0000-0000-0000-000000000007', '44444444-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'aaaa0000-0000-0000-0000-000000000002', '88888888-0000-0000-0000-000000000001', '77777777-0000-0000-0000-000000000001', 'dddd0000-0000-0000-0000-000000000007', now() - interval '25 days', now() - interval '25 days' + interval '45 minutes', 1, 40000.00, 'COMPLETED', null); + +insert into booking_resources (booking_id, resource_id, location_id, session_id, starts_at, ends_at, status) +select b.id, a.resource_id, b.location_id, b.session_id, b.starts_at, b.ends_at, b.status +from bookings b +join (values + ('dddd0000-0000-0000-0000-000000000001'::uuid, 'cccc0000-0000-0000-0000-000000000001'::uuid), + ('dddd0000-0000-0000-0000-000000000002'::uuid, 'cccc0000-0000-0000-0000-000000000002'::uuid), + -- las dos reservas del taller ocupan EL MISMO salón, misma sesión + ('dddd0000-0000-0000-0000-000000000003'::uuid, 'cccc0000-0000-0000-0000-000000000005'::uuid), + ('dddd0000-0000-0000-0000-000000000008'::uuid, 'cccc0000-0000-0000-0000-000000000005'::uuid), + ('dddd0000-0000-0000-0000-000000000004'::uuid, 'cccc0000-0000-0000-0000-000000000007'::uuid), + ('dddd0000-0000-0000-0000-000000000005'::uuid, 'cccc0000-0000-0000-0000-000000000007'::uuid), + ('dddd0000-0000-0000-0000-000000000006'::uuid, 'cccc0000-0000-0000-0000-000000000001'::uuid), + ('dddd0000-0000-0000-0000-000000000007'::uuid, 'cccc0000-0000-0000-0000-000000000002'::uuid) +) as a(booking_id, resource_id) on a.booking_id = b.id; + +insert into booking_status_changes (booking_id, from_status, to_status, reason, changed_by) values + ('dddd0000-0000-0000-0000-000000000001', 'RESERVED', 'CONFIRMED', null, '99999999-0000-0000-0000-000000000001'), + ('dddd0000-0000-0000-0000-000000000005', 'CONFIRMED', 'COMPLETED', null, '99999999-0000-0000-0000-000000000002'), + ('dddd0000-0000-0000-0000-000000000006', 'RESERVED', 'CANCELLED', 'El cliente solicitó la cancelación', '44444444-0000-0000-0000-000000000003'); diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index cc9c7a4..1dc17de 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -41,10 +41,13 @@ devolver 401 es el comportamiento esperado, no una falla. 3. **Auth → Providers → Email:** confirmación de correo activada, longitud mínima 8. 4. **Auth → Settings → JWT expiry:** `900` segundos (15 min, HU-021). -5. **Tablas:** las crea Hibernate (`ddl-auto=update` en local). Verificar: +5. **Tablas:** ejecutar `docs/database/schema.sql` en el SQL Editor. Es + idempotente, así que se puede aplicar sobre una base que ya tenga las tablas + de Hibernate: se las salta y crea las restantes (ADR-0005). Verificar: ```sql - select table_name from information_schema.tables - where table_schema = 'public' and table_name in ('clients','login_attempts'); + select count(*) from pg_class + where relnamespace = 'public'::regnamespace and relkind = 'r'; + -- deben ser 19 ``` 6. **RLS (después de las tablas):** ejecutar `docs/database/rls.sql` en el SQL Editor y verificar con: