Agrego carpeta del source sql de la Base de Datos

This commit is contained in:
Pablo
2026-08-22 19:10:49 -03:00
parent 11e36bd6c2
commit 88d724fbcf
94 changed files with 7820 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
-- ============================================================================
-- Schemas del sistema
-- ============================================================================
-- En proyectos Supabase, los schemas `public` y `extensions` vienen
-- preexistentes. Sólo necesitamos crear `internal`, que es nuestro y
-- agrupa todo lo que no debe estar expuesto al cliente (helpers, tablas
-- de configuración, tabla de sesiones).
--
-- HARDENING DE `internal`
-- El schema no debe ser alcanzable por roles cliente. PostgREST no lo
-- expone (no está en `db.schemas`) y por default de PG ≥ 15 PUBLIC no
-- recibe USAGE en schemas creados por el usuario. Acá hacemos esa
-- garantía explícita y le sumamos un default-deny sobre EXECUTE de
-- funciones futuras: aunque alguien cree una función nueva en
-- `internal` sin acordarse del REVOKE en el archivo, PUBLIC nunca
-- recibe EXECUTE.
--
-- Las llamadas legítimas a `internal.*` ocurren desde:
-- - funciones `public.fc_*` SECURITY DEFINER (corren como owner y
-- no dependen de USAGE del rol cliente).
-- - pg_cron (corre como postgres).
-- - Edge Functions vía conexión directa con service_role, si en su
-- momento el pipeline lo necesitara (decisión de US-R17).
--
-- Cada función `internal.<x>` agrega además su propio REVOKE en su
-- archivo, como defensa en profundidad.
--
-- Todas las sentencias acá son idempotentes (REVOKE de privilegios no
-- otorgados es no-op; ALTER DEFAULT PRIVILEGES se sobrescribe).
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS internal;
REVOKE ALL ON SCHEMA internal FROM PUBLIC;
ALTER DEFAULT PRIVILEGES IN SCHEMA internal
REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
+30
View File
@@ -0,0 +1,30 @@
-- ============================================================================
-- Extensiones y tipos globales
-- ============================================================================
--
-- btree_gist es la base del EXCLUDE constraint
-- que evita solapamiento de horarios en public.horario_actividad.
--
-- internal.timerange es un tipo RANGE sobre TIME, usado por ese mismo
-- EXCLUDE constraint para representar el rango de horas de un horario. Vive en
-- internal (no en public) para que `supabase db reset` pueda limpiar dev: el reset
-- vacía public objeto por objeto y se traba con la función constructora del tipo,
-- pero borra los schemas no-public enteros con CASCADE.
-- pgcrypto es la que usamos para hashear contraseñas
-- pg_net le permite a nuestra Base de Datos hacer requests HTTP
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA extensions;
CREATE TYPE internal.timerange AS RANGE (
subtype = time without time zone,
multirange_type_name = internal.timemultirange
);
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pg_cron;
+29
View File
@@ -0,0 +1,29 @@
# `database/schema/` — espejo legible de la estructura
Estos `.sql` son la foto legible y actual de cómo está armada la base: los schemas, las
extensiones, los tipos y las 25 tablas, cada uno en su archivo. Sirven para **leer** cómo
es la base hoy de un vistazo, sin reconstruirla mentalmente leyendo toda la historia de
migraciones.
**No son lo que arma la base. No los corras.** Quien aplica los cambios son las migraciones
(`supabase/migrations/`). Estos archivos solo reflejan el resultado, y se mantienen **a
mano**: después de cada migración editás el `.sql` del objeto que tocaste para que muestre
el estado nuevo. Si te salteás ese paso, el archivo miente y deja de servir como referencia.
## Qué hay en este árbol
- `00_schemas.sql` — el schema `internal` y su blindaje (REVOKE / default-deny).
- `10_extensions_y_types.sql` — las extensiones y el tipo `internal.timerange`.
- `tables/` — las 25 tablas (`internal` + `public`), cada una en su `.sql`.
- `seeds/internal/app_config.sql` — el mirror de los valores del seed de
`internal.app_config` (el seed lo aplica la migración
`supabase/migrations/20260604155956_seed_app_config.sql`).
## Lo demás vive en `Documentation/`
- **Qué es esta representación y por qué existe** → `Documentation/EntornoDesarrollo.md`,
«Representación estática de la DB».
- **Cómo se opera** —cambiar una tabla o función, promover a prod, resetear dev, levantar un
entorno nuevo— → `Documentation/GuiaOperacionEntorno.md`.
- **Por qué se llegó a este modelo** —el orquestador y el `apply.sh` que existieron y se
borraron— → `Documentation/Historico/EntornoDesarrollo.md`, «De dos carriles a uno».
@@ -0,0 +1,80 @@
-- MIRROR — valores canónicos del seed (para leer qué expone el sistema).
-- El seed se APLICA por la migración:
-- supabase/migrations/20260604155956_seed_app_config.sql
-- Si cambiás el seed, hacelo en una migración y reflejá los valores acá.
-- Ver database/schema/README.md.
-- ============================================================================
-- seed: internal.app_config
-- ============================================================================
-- Pares clave/valor de configuración runtime. Cada clave es leída por las
-- funciones consumidoras vía internal.get_config_int(clave, default); si la
-- fila no existe, la función cae al default. Las filas de este seed existen
-- para que el operador pueda hacer override sin redeploy y para que la tabla
-- documente qué parámetros expone el sistema.
--
-- Convención:
-- - Una sección por dominio (retencion.*, pagos.*, etc.).
-- - El valor seedeado debe coincidir con el default que el consumidor pasa
-- a get_config_int. Si los dos divergen, el seed manda.
-- - ON CONFLICT DO NOTHING: idempotente y preserva overrides operativos.
-- Para reescribir un valor, hacer UPDATE explícito.
--
-- Stories asociadas:
-- - Documentation/Backlog/PoliticaRetencion.md US-R04 (sección retencion.*)
-- ============================================================================
-- ─── Retención operativa ────────────────────────────────────────────────────
-- Política: Documentation/PoliticaRetencion.md §3 (archivado) y §7 (higiene).
-- Unidad: meses. Consumidores: funciones de purga (US-R06..R09), validación
-- de registro de pagos (US-R10), y orquestación del pipeline mensual (US-R17).
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.pagos_meses', '12',
'Ventana operativa de pagos en meses. Al cumplirse, el pago migra al archivo XLSX y se borra de la base.'),
('retencion.turnos_meses', '2',
'Antigüedad máxima de turnos pasados antes de purgarse (CASCADE arrastra reservas asociadas).'),
('retencion.eventos_pagos_meses', '3',
'Antigüedad máxima de eventos de pagos (prefijo pago_) antes de purgarse.'),
('retencion.eventos_otros_meses', '2',
'Antigüedad máxima del resto de eventos antes de purgarse.'),
('retencion.dias_especiales_meses', '1',
'Antigüedad máxima de días especiales pasados antes de purgarse (CASCADE arrastra horario_actividad_especial).')
ON CONFLICT (clave) DO NOTHING;
-- ─── Retención: cap de seguridad de eventos ─────────────────────────────────
-- Tope duro al tamaño de public.eventos. Unidad: filas (conteo, no meses).
-- Consumidor: internal.log_evento (US-R12). No es una ventana temporal: es un
-- freno contra un bug que dispare eventos en loop y consuma la cuota de la base
-- antes de la próxima purga mensual. Al alcanzarse, log_evento rechaza el insert
-- con excepción (no FIFO): el rechazo hace fallar la función que dispara el
-- evento y corta la cascada, en vez de borrar eventos legítimos en silencio.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.eventos_cap', '50000',
'Tope de filas en public.eventos. Al alcanzarlo, internal.log_evento rechaza nuevos inserts con excepción (freno anti-loop, no FIFO).')
ON CONFLICT (clave) DO NOTHING;
-- ─── Pagos: ventanas operativas ─────────────────────────────────────────────
-- Claves ya consumidas por funciones existentes (fc_editar_pago, fc_anular_pago,
-- fc_reservar_turno) que hasta ahora venían cayendo al default por ausencia de
-- fila. Sembradas acá para que queden explícitas y editables sin redeploy.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('pagos.ventana_edicion_minutos', '30',
'Minutos desde el registro durante los cuales un pago admite edición o anulación sin disparar evento. Leída por fc_editar_pago y fc_anular_pago.'),
('pagos.umbral_morosidad_meses', '2',
'Cantidad de meses impagos a partir de los cuales se bloquea al cliente para reservar turnos. Leída por fc_reservar_turno.')
ON CONFLICT (clave) DO NOTHING;
-- ─── Retención: huérfanas resueltas (ventana de seguridad) ──────────────────
-- Unidad: días (no meses). Consumidor: internal.limpiar_huerfanas_resueltas.
-- Es el piso de gracia para deshacer un cambio de estado por error: una
-- huérfana en estado terminal (reubicado/resuelta) recién se vuelve elegible
-- para purgar cuando pasaron al menos N días desde su `resuelto_en`. La purga
-- real corre en la cadencia del cron mensual; en la práctica las terminales
-- viven entre N+1 y N+31 días.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.huerfanas_resueltas_dias', '14',
'Días de gracia mínimos que una reserva huérfana en estado terminal (reubicado/resuelta) debe tener desde su cambio de estado para ser elegible para purga.')
ON CONFLICT (clave) DO NOTHING;
@@ -0,0 +1,14 @@
-- ============================================================================
-- internal.app_config
-- ============================================================================
-- Configuración runtime del sistema. Pares clave/valor (ambos TEXT).
-- Lee internal.get_config_int y fc_obtener_config.
-- ============================================================================
CREATE TABLE internal.app_config (
clave text NOT NULL,
valor text NOT NULL,
descripcion text,
actualizado_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT app_config_pkey PRIMARY KEY (clave)
);
@@ -0,0 +1,72 @@
-- ============================================================================
-- internal.archivo_exports
-- ============================================================================
-- Bitácora de control del pipeline mensual de archivado
-- (Documentation/PoliticaRetencion.md §5.3). Cada fila registra un intento
-- de archivar una entidad para un mes objetivo: su resultado, cuándo
-- ocurrió y, si falló, el detalle del error.
--
-- Cumple tres roles:
-- - IDEMPOTENCIA: el unique parcial garantiza que no haya dos archivados
-- `ok` para el mismo (anio_mes_target, tipo). Si la corrida mensual se
-- repite (reintento, doble disparo del cron), el segundo `ok` choca y
-- no se duplica el archivado ni el DELETE posterior.
-- - REINTENTO: una fila `fallo` deja registrado el mes pendiente; la
-- corrida siguiente puede detectar que ese mes no tiene `ok` y
-- reprocesarlo (los meses pendientes tienen prioridad — §5.2).
-- - VISIBILIDAD: la vista admin (US-R21) lee esta tabla vía
-- public.fc_obtener_estado_archivo.
--
-- COLUMNAS
-- anio_mes_target DATE (día 1 del mes archivado). NULLABLE a propósito:
-- durante el primer año de vida del sistema ningún pago
-- cumple 12 meses, así que la corrida de pagos archiva
-- "nada" y registra `ok` con target NULL (§4.3, US-R17).
-- Los NULL son distintos entre sí en el unique parcial,
-- de modo que esas corridas vacías no se bloquean entre
-- ellas — son no-ops inofensivos.
-- tipo 'pagos' | 'agregado_reservas'. Las dos cosas que se
-- archivan (§3); cada una lleva su propia fila por mes.
-- ejecutado_en timestamptz del intento (default now()).
-- resultado 'ok' | 'fallo'.
-- detalle_error texto libre del error cuando resultado = 'fallo';
-- NULL en los `ok`.
--
-- AUTORIZACIÓN
-- Tabla en el schema `internal`, no alcanzable por roles cliente
-- (hardening en 00_schemas.sql). Escribe el pipeline (US-R17); lee la
-- fachada public.fc_obtener_estado_archivo (SECURITY DEFINER).
--
-- DEPENDE DE
-- Nada (sin FKs). Nivel 0 en apply.sh.
-- ============================================================================
CREATE TABLE internal.archivo_exports (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME internal.archivo_exports_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
anio_mes_target date,
tipo text NOT NULL,
ejecutado_en timestamp with time zone DEFAULT now() NOT NULL,
resultado text NOT NULL,
detalle_error text,
CONSTRAINT archivo_exports_pkey PRIMARY KEY (id),
CONSTRAINT archivo_exports_tipo_check
CHECK (tipo IN ('pagos', 'agregado_reservas')),
CONSTRAINT archivo_exports_resultado_check
CHECK (resultado IN ('ok', 'fallo'))
);
-- Idempotencia: a lo sumo un `ok` por (mes, tipo). Parcial sobre 'ok' para
-- que los reintentos fallidos no cuenten y puedan repetirse hasta lograrlo.
CREATE UNIQUE INDEX archivo_exports_ok_unico
ON internal.archivo_exports (anio_mes_target, tipo)
WHERE resultado = 'ok';
-- Para listar fallos recientes y barrer pendientes por mes.
CREATE INDEX idx_archivo_exports_target_tipo
ON internal.archivo_exports (anio_mes_target, tipo, ejecutado_en DESC);
ALTER TABLE internal.archivo_exports ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,21 @@
-- ============================================================================
-- internal.permisos
-- ============================================================================
-- Matriz de autorización. Cada fila es una acción posible del sistema y
-- el array de roles que la pueden ejecutar. La consulta canónica está en
-- internal.validate_permission.
-- ============================================================================
CREATE TABLE internal.permisos (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME internal.permisos_id_permiso_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
accion text NOT NULL,
roles_permitidos text[] NOT NULL,
CONSTRAINT permisos_pkey PRIMARY KEY (id),
CONSTRAINT permisos_accion_key UNIQUE (accion)
);
ALTER TABLE internal.permisos ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,31 @@
-- ============================================================================
-- internal.sesiones
-- ============================================================================
-- Sesiones activas. Cada usuario tiene a lo sumo una sesión vigente; el
-- trigger trg_clean_old_sessions (definido en triggers/auth/) lo garantiza
-- al insertar.
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE internal.sesiones (
id uuid DEFAULT gen_random_uuid() NOT NULL,
usuario_id uuid NOT NULL,
token uuid NOT NULL,
expires_at timestamp with time zone NOT NULL,
CONSTRAINT sessions_pkey PRIMARY KEY (id),
CONSTRAINT sessions_usuario_id_fkey
FOREIGN KEY (usuario_id) REFERENCES public.usuarios(id)
);
CREATE INDEX idx_sesiones_token
ON internal.sesiones USING btree (token);
CREATE INDEX idx_sesiones_token_expires
ON internal.sesiones USING btree (token, expires_at);
CREATE INDEX idx_sesiones_usuario_expires
ON internal.sesiones USING btree (usuario_id, expires_at);
ALTER TABLE internal.sesiones ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,31 @@
-- ============================================================================
-- public.actividades
-- ============================================================================
-- Entidades reservables del gimnasio (yoga, funcional, etc.). Los
-- turnos son instancias concretas de una actividad en una fecha+hora.
-- Cada actividad tiene duración, capacidad por defecto y un flag `libre`
-- que indica si se puede reservar sin plan que la incluya.
-- ============================================================================
CREATE TABLE public.actividades (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public.actividades_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
nombre text NOT NULL,
duracion smallint NOT NULL,
capacidad_por_defecto smallint NOT NULL,
libre boolean DEFAULT false,
activo boolean DEFAULT true,
CONSTRAINT actividades_pkey PRIMARY KEY (id),
CONSTRAINT actividades_nombre_key UNIQUE (nombre)
);
CREATE INDEX idx_actividades_activo
ON public.actividades USING btree (activo, nombre);
CREATE INDEX idx_actividades_libre
ON public.actividades USING btree (libre) WHERE (libre = true);
ALTER TABLE public.actividades ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,25 @@
-- ============================================================================
-- public.actividades_tipos_cuota
-- ============================================================================
-- Relación N:M entre tipos de cuota (planes) y actividades incluidas en
-- ese plan. PK compuesta.
--
-- DEPENDE DE
-- public.actividades, public.tipos_cuota
-- ============================================================================
CREATE TABLE public.actividades_tipos_cuota (
tipo_cuota_id uuid NOT NULL,
actividad_id integer NOT NULL,
CONSTRAINT actividades_tipos_cuota_pkey
PRIMARY KEY (tipo_cuota_id, actividad_id),
CONSTRAINT actividades_tipos_cuota_actividad_id_fkey
FOREIGN KEY (actividad_id) REFERENCES public.actividades(id),
CONSTRAINT actividades_tipos_cuota_tipo_cuota_id_fkey
FOREIGN KEY (tipo_cuota_id) REFERENCES public.tipos_cuota(id) ON DELETE CASCADE
);
CREATE INDEX idx_actividades_tipos_cuota_actividad
ON public.actividades_tipos_cuota USING btree (actividad_id, tipo_cuota_id);
ALTER TABLE public.actividades_tipos_cuota ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,34 @@
-- ============================================================================
-- public.avisos_pago
-- ============================================================================
-- Avisos enviados a clientes relacionados a sus pagos / deudas. Estado
-- limitado a 'enviado', 'bloqueado' o NULL.
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE public.avisos_pago (
id uuid DEFAULT gen_random_uuid() NOT NULL,
cliente_id uuid,
mensaje text,
estado character varying(20) DEFAULT NULL::character varying,
fecha_aviso timestamp with time zone DEFAULT now(),
CONSTRAINT avisos_pago_pkey PRIMARY KEY (id),
CONSTRAINT avisos_pago_estado_check
CHECK (((estado)::text = ANY (ARRAY[
('enviado'::character varying)::text,
('bloqueado'::character varying)::text,
(NULL::character varying)::text
]))),
CONSTRAINT avisos_pago_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id) ON DELETE CASCADE
);
CREATE INDEX idx_avisos_pago_cliente_id
ON public.avisos_pago USING btree (cliente_id);
CREATE INDEX idx_avisos_pago_estado_fecha
ON public.avisos_pago USING btree (estado, fecha_aviso DESC) WHERE (estado IS NOT NULL);
ALTER TABLE public.avisos_pago ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,27 @@
-- ============================================================================
-- public.dias_especiales
-- ============================================================================
-- Días con un régimen distinto al regular: cerrado (no se generan turnos)
-- o con horario diferente (los turnos vienen de horario_actividad_especial
-- en lugar de horario_actividad).
-- ============================================================================
CREATE TABLE public.dias_especiales (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public.dias_especiales_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
fecha date NOT NULL,
tipo character varying(25) NOT NULL,
motivo text,
CONSTRAINT dias_especiales_pkey PRIMARY KEY (id),
CONSTRAINT dias_especiales_fecha_key UNIQUE (fecha),
CONSTRAINT dias_especiales_tipo_check
CHECK (((tipo)::text = ANY (ARRAY[
('cerrado'::character varying)::text,
('horario_diferente'::character varying)::text
])))
);
ALTER TABLE public.dias_especiales ENABLE ROW LEVEL SECURITY;
+49
View File
@@ -0,0 +1,49 @@
-- ============================================================================
-- public.eventos
-- ============================================================================
-- Tabla polimórfica de eventos auditables. Cada fila representa una
-- acción que el sistema quiere preservar para auditoría: edición o
-- anulación de pagos, futuras acciones administrativas, etc.
--
-- - tabla_referencia + referencia_id: a qué fila apunta el evento.
-- - cliente_id: cliente afectado (puede ser NULL para eventos no
-- relacionados a un cliente puntual).
-- - actor_id: quién ejecutó la acción.
-- - valor_anterior / valor_actual: snapshots JSONB para reconstruir el
-- cambio (no necesariamente ambos llenos).
-- - descripcion: texto libre (ej. motivo de anulación).
--
-- El único entry point oficial para escribir acá es internal.log_evento.
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE public.eventos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tipo text NOT NULL,
descripcion text,
cliente_id uuid,
valor_anterior jsonb,
valor_actual jsonb,
fecha_evento timestamp with time zone DEFAULT now(),
referencia_id uuid,
tabla_referencia text,
actor_id uuid,
CONSTRAINT eventos_pkey PRIMARY KEY (id),
CONSTRAINT eventos_actor_id_fkey
FOREIGN KEY (actor_id) REFERENCES public.usuarios(id),
CONSTRAINT eventos_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id)
);
CREATE INDEX idx_eventos_cliente_fecha
ON public.eventos USING btree (cliente_id, fecha_evento DESC) WHERE (cliente_id IS NOT NULL);
CREATE INDEX idx_eventos_referencia
ON public.eventos USING btree (tabla_referencia, referencia_id) WHERE (referencia_id IS NOT NULL);
CREATE INDEX idx_eventos_tipo_fecha
ON public.eventos USING btree (tipo, fecha_evento DESC);
ALTER TABLE public.eventos ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,22 @@
-- ============================================================================
-- public.fotos_perfil
-- ============================================================================
-- Foto de perfil de un cliente. Una foto por cliente (UNIQUE cliente_id).
-- Si se elimina el cliente, su foto se borra en cascada.
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE public.fotos_perfil (
id uuid DEFAULT gen_random_uuid() NOT NULL,
cliente_id uuid,
url_foto text,
fecha_subida timestamp with time zone DEFAULT now(),
CONSTRAINT fotos_perfil_pkey PRIMARY KEY (id),
CONSTRAINT fotos_perfil_cliente_id_key UNIQUE (cliente_id),
CONSTRAINT fotos_perfil_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id) ON DELETE CASCADE
);
ALTER TABLE public.fotos_perfil ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,48 @@
-- ============================================================================
-- public.horario_actividad
-- ============================================================================
-- Plantilla horaria regular: para cada (dia_semana, actividad_id), define
-- los rangos horarios vigentes en una ventana de fechas (valido_desde,
-- valido_hasta). valido_hasta NULL = vigencia abierta hacia el futuro.
--
-- El EXCLUDE constraint garantiza que no haya solapamiento entre rangos
-- horarios de la misma actividad y día de la semana en vigencias que se
-- intersectan. Requiere la extensión btree_gist y el type internal.timerange
-- (definidos en 10_extensions_y_types.sql).
--
-- DEPENDE DE
-- public.actividades; extension btree_gist; type internal.timerange.
-- ============================================================================
CREATE TABLE public.horario_actividad (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public.horario_actividad_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
actividad_id integer NOT NULL,
dia_semana smallint NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fin time without time zone NOT NULL,
valido_desde date DEFAULT CURRENT_DATE NOT NULL,
valido_hasta date,
CONSTRAINT horario_actividad_pkey PRIMARY KEY (id),
CONSTRAINT horario_actividad_check
CHECK ((hora_inicio < hora_fin)),
CONSTRAINT horario_actividad_dia_semana_check
CHECK (((dia_semana >= 1) AND (dia_semana <= 7))),
CONSTRAINT horario_actividad_vigencia_check
CHECK (((valido_hasta IS NULL) OR (valido_desde <= valido_hasta))),
CONSTRAINT horario_actividad_actividad_id_fkey
FOREIGN KEY (actividad_id) REFERENCES public.actividades(id) ON DELETE CASCADE
);
ALTER TABLE public.horario_actividad
ADD CONSTRAINT horario_actividad_no_solapamiento EXCLUDE USING gist (
dia_semana WITH =,
actividad_id WITH =,
internal.timerange(hora_inicio, hora_fin) WITH &&,
daterange(valido_desde, COALESCE(valido_hasta, 'infinity'::date), '[]'::text) WITH &&
);
ALTER TABLE public.horario_actividad ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,31 @@
-- ============================================================================
-- public.horario_actividad_especial
-- ============================================================================
-- Rangos horarios para días marcados como horario_diferente en
-- dias_especiales. Sustituyen la plantilla regular para esa fecha
-- puntual.
--
-- DEPENDE DE
-- public.actividades, public.dias_especiales
-- ============================================================================
CREATE TABLE public.horario_actividad_especial (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public.horario_actividad_especial_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
dia_especial_id integer NOT NULL,
actividad_id integer NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fin time without time zone NOT NULL,
CONSTRAINT horario_actividad_especial_pkey PRIMARY KEY (id),
CONSTRAINT horario_actividad_especial_check_horas
CHECK ((hora_inicio < hora_fin)),
CONSTRAINT horario_actividad_especial_actividad_fkey
FOREIGN KEY (actividad_id) REFERENCES public.actividades(id) ON DELETE CASCADE,
CONSTRAINT horario_actividad_especial_dia_fkey
FOREIGN KEY (dia_especial_id) REFERENCES public.dias_especiales(id) ON DELETE CASCADE
);
ALTER TABLE public.horario_actividad_especial ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,23 @@
-- ============================================================================
-- public.metodos_pago
-- ============================================================================
-- Métodos de cobro disponibles (efectivo, transferencia, billetera digital,
-- etc.). Cada pago referencia uno acá. La baja es lógica via flag `activo`.
-- ============================================================================
CREATE TABLE public.metodos_pago (
id integer GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public.metodos_pago_id_seq
START WITH 1 INCREMENT BY 1
NO MINVALUE NO MAXVALUE CACHE 1
) NOT NULL,
descripcion character varying NOT NULL,
activo boolean DEFAULT true,
icono character varying,
CONSTRAINT metodos_pago_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_metodos_pago_activo
ON public.metodos_pago USING btree (activo) WHERE (activo = true);
ALTER TABLE public.metodos_pago ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,29 @@
-- ============================================================================
-- public.notificaciones
-- ============================================================================
-- Notificaciones dirigidas a un cliente (push, in-app, etc.). Conserva
-- flag de leída.
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE public.notificaciones (
id uuid DEFAULT gen_random_uuid() NOT NULL,
cliente_id uuid,
titulo character varying(200) NOT NULL,
mensaje text,
tipo character varying(50),
leida boolean DEFAULT false,
CONSTRAINT notificaciones_pkey PRIMARY KEY (id),
CONSTRAINT notificaciones_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id) ON DELETE CASCADE
);
CREATE INDEX idx_notificaciones_cliente_id
ON public.notificaciones USING btree (cliente_id);
CREATE INDEX idx_notificaciones_cliente_leida
ON public.notificaciones USING btree (cliente_id, leida, id DESC) WHERE (leida = false);
ALTER TABLE public.notificaciones ENABLE ROW LEVEL SECURITY;
+87
View File
@@ -0,0 +1,87 @@
-- ============================================================================
-- public.pagos
-- ============================================================================
-- Tabla central del módulo de pagos (ver Documentation/DominioPagos.md).
-- Cada fila es un cobro recibido de un cliente. Soporta auditoría de
-- creación, edición y anulación (anulación es soft-delete).
--
-- - tipo: discriminador del concepto del cobro. Hoy sólo se usa
-- 'cuota_mensual'; el dominio admite 'devolucion',
-- 'descuento_retroactivo', 'ajuste' pero no están implementados (ver
-- Documentation/DominioPagos.md §10 — decisión cerrada de NO modelar
-- correctivos por ahora).
-- - pagos_anulacion_coherente: si anulado_at se setea, anulado_por
-- también. motivo_anulacion es opcional.
-- - pagos_update_coherente: si updated_at se setea, updated_by también.
-- - pagos_campos_requeridos_por_tipo: para tipos distintos de 'ajuste',
-- metodo_id y anio_mes_pagado son obligatorios.
-- - pagos_anio_mes_pagado_check: anio_mes_pagado siempre es el día 1 del
-- mes.
--
-- DEPENDE DE
-- public.usuarios, public.metodos_pago, public.tipos_cuota
-- ============================================================================
CREATE TABLE public.pagos (
id uuid NOT NULL,
cliente_id uuid,
anio_mes_pagado date,
fecha_pago timestamp with time zone,
monto_total numeric,
detalle jsonb,
metodo_id smallint,
tipo_cuota_id uuid,
tipo text DEFAULT 'cuota_mensual'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid,
updated_at timestamp with time zone,
updated_by uuid,
anulado_at timestamp with time zone,
anulado_por uuid,
motivo_anulacion text,
CONSTRAINT pagos_pkey PRIMARY KEY (id),
CONSTRAINT pagos_tipo_check
CHECK ((tipo = ANY (ARRAY[
'cuota_mensual'::text,
'devolucion'::text,
'descuento_retroactivo'::text,
'ajuste'::text
]))),
CONSTRAINT pagos_anio_mes_pagado_check
CHECK ((anio_mes_pagado = (date_trunc('month'::text, (anio_mes_pagado)::timestamp with time zone))::date)),
CONSTRAINT pagos_campos_requeridos_por_tipo
CHECK (((tipo = 'ajuste'::text) OR ((metodo_id IS NOT NULL) AND (anio_mes_pagado IS NOT NULL)))),
CONSTRAINT pagos_anulacion_coherente
CHECK ((((anulado_at IS NULL) AND (anulado_por IS NULL) AND (motivo_anulacion IS NULL))
OR ((anulado_at IS NOT NULL) AND (anulado_por IS NOT NULL)))),
CONSTRAINT pagos_update_coherente
CHECK ((((updated_at IS NULL) AND (updated_by IS NULL))
OR ((updated_at IS NOT NULL) AND (updated_by IS NOT NULL)))),
CONSTRAINT pagos_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id),
CONSTRAINT pagos_metodo_id_fkey
FOREIGN KEY (metodo_id) REFERENCES public.metodos_pago(id),
CONSTRAINT pagos_tipo_cuota_id_fkey
FOREIGN KEY (tipo_cuota_id) REFERENCES public.tipos_cuota(id),
CONSTRAINT pagos_created_by_fkey
FOREIGN KEY (created_by) REFERENCES public.usuarios(id),
CONSTRAINT pagos_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES public.usuarios(id),
CONSTRAINT pagos_anulado_por_fkey
FOREIGN KEY (anulado_por) REFERENCES public.usuarios(id)
);
CREATE INDEX idx_pagos_cliente_no_anulado
ON public.pagos USING btree (cliente_id, anio_mes_pagado DESC) WHERE (anulado_at IS NULL);
CREATE INDEX idx_pagos_cuota_mensual_no_anulado
ON public.pagos USING btree (cliente_id, anio_mes_pagado DESC)
WHERE ((anulado_at IS NULL) AND (tipo = 'cuota_mensual'::text));
CREATE INDEX idx_pagos_metodo
ON public.pagos USING btree (metodo_id);
CREATE INDEX idx_pagos_periodo
ON public.pagos USING btree (anio_mes_pagado, fecha_pago);
ALTER TABLE public.pagos ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,29 @@
-- ============================================================================
-- public.reservas
-- ============================================================================
-- Reservas activas y canceladas. La cancelación es soft-delete:
-- cancelada=true + cancelada_en. UNIQUE (turno_id, cliente_id) evita
-- reservas duplicadas del mismo cliente en el mismo turno (incluso
-- aunque haya canceladas; el modelo no permite re-reservar tras
-- cancelar).
--
-- DEPENDE DE
-- public.turnos, public.usuarios
-- ============================================================================
CREATE TABLE public.reservas (
id uuid DEFAULT gen_random_uuid() NOT NULL,
turno_id uuid NOT NULL,
cliente_id uuid NOT NULL,
reservada_en timestamp with time zone DEFAULT now() NOT NULL,
cancelada boolean DEFAULT false NOT NULL,
cancelada_en timestamp with time zone,
CONSTRAINT reservas_pkey PRIMARY KEY (id),
CONSTRAINT reservas_turno_id_cliente_id_key UNIQUE (turno_id, cliente_id),
CONSTRAINT reservas_turno_id_fkey
FOREIGN KEY (turno_id) REFERENCES public.turnos(id) ON DELETE CASCADE,
CONSTRAINT reservas_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id) ON DELETE CASCADE
);
ALTER TABLE public.reservas ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,57 @@
-- ============================================================================
-- public.reservas_huerfanas
-- ============================================================================
-- Snapshot desconectado: cuando un turno con reservas activas se
-- elimina (por desactivación de actividad, cambio de plantilla, cierre
-- de día especial), las reservas vivas se rescatan acá. Conservan los
-- datos necesarios para reubicar al cliente: actividad_nombre, fecha,
-- hora original.
--
-- estado_resolucion ∈ {pendiente, reubicado, resuelta}.
-- pendiente: nadie hizo nada, requiere atención del operador (o del cliente).
-- reubicado: el operador la movió a un turno nuevo (fc_mover_reserva_huerfana).
-- resuelta: el operador la dio por cerrada sin reubicarla. Típicamente
-- después de notificar al cliente y delegarle la re-reserva;
-- también cubre "alta por error" u otros cierres manuales.
-- El estado es agnóstico al cómo se llegó: el tracking de
-- notificación efectiva vive en otro módulo.
--
-- resuelto_en: timestamp del último cambio a estado terminal. NULL si
-- estado_resolucion='pendiente', NOT NULL si reubicado/resuelta
-- (invariante reforzada por CHECK). Lo escriben
-- fc_mover_reserva_huerfana y fc_resolver_huerfana. La purga
-- (internal.limpiar_huerfanas_resueltas) exige que ya pasaron al menos
-- `retencion.huerfanas_resueltas_dias` (default 14) desde este
-- timestamp para considerar la fila elegible, de modo que el operador
-- tenga una ventana de gracia para deshacer un cambio de estado por
-- error (volverla a `pendiente` resetea resuelto_en a NULL y le
-- devuelve la inmunidad).
--
-- DEPENDE DE
-- public.usuarios
-- ============================================================================
CREATE TABLE public.reservas_huerfanas (
id uuid DEFAULT gen_random_uuid() NOT NULL,
cliente_id uuid NOT NULL,
actividad_nombre text NOT NULL,
fecha_original date NOT NULL,
hora_inicio_original time without time zone NOT NULL,
estado_resolucion character varying(20) DEFAULT 'pendiente'::character varying NOT NULL,
creada_en timestamp with time zone DEFAULT now() NOT NULL,
resuelto_en timestamp with time zone,
CONSTRAINT reservas_huerfanas_pkey PRIMARY KEY (id),
CONSTRAINT reservas_huerfanas_estado_check
CHECK (((estado_resolucion)::text = ANY (ARRAY[
('pendiente'::character varying)::text,
('reubicado'::character varying)::text,
('resuelta'::character varying)::text
]))),
CONSTRAINT reservas_huerfanas_resuelto_en_coherente
CHECK ((((estado_resolucion)::text = 'pendiente'::text) AND (resuelto_en IS NULL))
OR (((estado_resolucion)::text <> 'pendiente'::text) AND (resuelto_en IS NOT NULL))),
CONSTRAINT reservas_huerfanas_cliente_id_fkey
FOREIGN KEY (cliente_id) REFERENCES public.usuarios(id) ON DELETE CASCADE
);
ALTER TABLE public.reservas_huerfanas ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,24 @@
-- ============================================================================
-- public.tipos_cuota
-- ============================================================================
-- Planes que ofrece el gimnasio. Determinan precio, frecuencia semanal,
-- día sugerido de pago, recargo opcional y (vía actividades_tipos_cuota)
-- qué actividades incluye.
-- ============================================================================
CREATE TABLE public.tipos_cuota (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nombre character varying(100),
descripcion text,
dias_semana smallint NOT NULL,
precio numeric(10,2) NOT NULL,
parasocios boolean NOT NULL,
dia_de_pago smallint DEFAULT 10,
recargo numeric(10,2),
CONSTRAINT tipos_cuota_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_tipos_cuota_parasocios
ON public.tipos_cuota USING btree (parasocios, dias_semana);
ALTER TABLE public.tipos_cuota ENABLE ROW LEVEL SECURITY;
+33
View File
@@ -0,0 +1,33 @@
-- ============================================================================
-- public.turnos
-- ============================================================================
-- Instancias reservables generadas JIT (just-in-time) a partir de las
-- plantillas horarias. UNIQUE (actividad_id, fecha, hora_inicio) garantiza
-- que no se materialicen duplicados. El flag `es_especial` indica si el
-- turno proviene de horario_actividad_especial (TRUE) o de horario_actividad
-- (FALSE).
--
-- DEPENDE DE
-- public.actividades
-- ============================================================================
CREATE TABLE public.turnos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
actividad_id integer NOT NULL,
fecha date NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fin time without time zone NOT NULL,
capacidad_maxima smallint NOT NULL,
activo boolean DEFAULT true,
dia_semana smallint NOT NULL,
es_especial boolean DEFAULT false NOT NULL,
CONSTRAINT turnos_pkey PRIMARY KEY (id),
CONSTRAINT turnos_actividad_id_fecha_hora_inicio_key
UNIQUE (actividad_id, fecha, hora_inicio),
CONSTRAINT turnos_check_horas
CHECK ((hora_inicio < hora_fin)),
CONSTRAINT turnos_actividad_id_fkey
FOREIGN KEY (actividad_id) REFERENCES public.actividades(id) ON DELETE CASCADE
);
ALTER TABLE public.turnos ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,59 @@
-- ============================================================================
-- public.usuarios
-- ============================================================================
-- Tabla central de personas del sistema: clientes, admin y superadmin.
-- El rol se valida por constraint contra el set {cliente, admin, superadmin}.
-- El sexo se valida contra {Hombre, Mujer, Otro}. La contraseña se guarda
-- hasheada (bcrypt vía internal.create_password_hash). password_hash es
-- NULL para clientes sin contraseña asignada: no inician sesión en este
-- panel (usan el bot de WhatsApp).
--
-- DEPENDE DE
-- public.tipos_cuota (FK opcional a su plan por defecto).
-- ============================================================================
CREATE TABLE public.usuarios (
id uuid DEFAULT gen_random_uuid() NOT NULL,
dni character varying(20) NOT NULL,
nombre character varying(100) NOT NULL,
apellido character varying(100),
peso numeric(5,2),
altura integer,
rol text NOT NULL,
isactive boolean DEFAULT true,
fecha_creacion timestamp with time zone DEFAULT now(),
fecha_modificacion timestamp with time zone DEFAULT now(),
password_hash text,
mail character varying(50),
telefono character varying(20),
fuerza_max numeric(10,2),
sexo character varying(10) DEFAULT 'Hombre'::character varying,
tipo_cuota uuid,
CONSTRAINT usuarios_pkey PRIMARY KEY (id),
CONSTRAINT usuarios_dni_key UNIQUE (dni),
CONSTRAINT usuarios_rol_check
CHECK ((rol = ANY (ARRAY[
('cliente'::character varying)::text,
('admin'::character varying)::text,
('superadmin'::character varying)::text
]))),
CONSTRAINT nuevo_nombre_sexo_check
CHECK (((sexo)::text = ANY (ARRAY[
('Hombre'::character varying)::text,
('Mujer'::character varying)::text,
('Otro'::character varying)::text
]))),
CONSTRAINT fk_usuario_tipo_cuota
FOREIGN KEY (tipo_cuota) REFERENCES public.tipos_cuota(id) ON DELETE SET NULL
);
CREATE INDEX idx_usuarios_activo
ON public.usuarios USING btree (isactive, rol);
CREATE INDEX idx_usuarios_rol
ON public.usuarios USING btree (rol) WHERE (isactive = true);
CREATE INDEX idx_usuarios_tipo_cuota
ON public.usuarios USING btree (tipo_cuota) WHERE (tipo_cuota IS NOT NULL);
ALTER TABLE public.usuarios ENABLE ROW LEVEL SECURITY;