diff --git a/database/backup/dump.sh b/database/backup/dump.sh new file mode 100644 index 0000000..1d14fd3 --- /dev/null +++ b/database/backup/dump.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# database/backup/dump.sh +# +# Dump + comprimir + cifrar + subir a Drive. Implementa US-B04 de +# Documentation/Backlog/Backups.md. +# +# Falla ruidoso a propósito (invariante 3 del backlog): `set -e` corta el +# script ante el primer error, así el job de GitHub Actions que lo llama +# queda en rojo y GitHub avisa por mail. No hay try/catch que trague nada. +# +# Variables de entorno requeridas: +# DATABASE_URL connection string del SESSION POOLER de Supabase. +# El transaction pooler rompe pg_dump (ver US-B03). +# BACKUP_PASSPHRASE passphrase simétrica de cifrado (gpg). +# RCLONE_REMOTE_NAME nombre del remote configurado en rclone.conf +# (ej: "gdrive"). +# GDRIVE_FOLDER_ID ID de la carpeta de Drive destino (no el nombre). +# +# Alcance del dump (invariante 6): esquemas public + internal únicamente. +# NUNCA auth ni el resto de los esquemas internos de Supabase — por eso se +# usa --schema explícito en vez de un dump completo. + +: "${DATABASE_URL:?Falta DATABASE_URL (session pooler de Supabase)}" +: "${BACKUP_PASSPHRASE:?Falta BACKUP_PASSPHRASE}" +: "${RCLONE_REMOTE_NAME:?Falta RCLONE_REMOTE_NAME (ej: gdrive)}" +: "${GDRIVE_FOLDER_ID:?Falta GDRIVE_FOLDER_ID}" + +FECHA="$(date -u +%Y-%m-%d)" +BASE="soma-backup-${FECHA}" +SQL="${BASE}.sql" +GZ="${SQL}.gz" +ENC="${GZ}.gpg" + +cleanup() { + rm -f "$SQL" "$GZ" "$ENC" +} +trap cleanup EXIT + +echo "== Dump de public + internal (sin auth) -> ${SQL} ==" +pg_dump "$DATABASE_URL" --schema=public --schema=internal --file="$SQL" + +echo "== Comprimiendo -> ${GZ} ==" +gzip -f "$SQL" + +echo "== Cifrando (AES256 simétrico) -> ${ENC} ==" +gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$BACKUP_PASSPHRASE" \ + --symmetric --cipher-algo AES256 \ + --output "$ENC" "$GZ" + +echo "== Subiendo ${ENC} a ${RCLONE_REMOTE_NAME}: (carpeta ${GDRIVE_FOLDER_ID}) ==" +rclone --drive-root-folder-id="$GDRIVE_FOLDER_ID" copy "$ENC" "${RCLONE_REMOTE_NAME}:" + +echo "OK: ${ENC} subido y verificado." diff --git a/database/backup/rotate.sh b/database/backup/rotate.sh new file mode 100644 index 0000000..100ebea --- /dev/null +++ b/database/backup/rotate.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# database/backup/rotate.sh +# +# Rotación sin estado de los backups en Drive. Implementa US-B05 de +# Documentation/Backlog/Backups.md §3. +# +# Esquema de retención (recalculado desde cero en cada corrida, sin leer +# ni escribir ningún registro de "qué es ancla" — invariante 5): +# - Los 3 backups más recientes, sin importar la fecha. +# - Un ancla mensual por cada uno de los últimos 6 meses cerrados (el +# backup más nuevo de ese mes). +# Todo lo que no entra en ese conjunto se borra. +# +# Invariante 4: solo se tocan archivos que matchean exactamente el patrón +# soma-backup-AAAA-MM-DD.sql.gz.gpg dentro de la carpeta indicada. Nunca +# se borra nada más. +# +# Variables de entorno requeridas: +# RCLONE_REMOTE_NAME nombre del remote configurado en rclone.conf. +# GDRIVE_FOLDER_ID ID de la carpeta de Drive a rotar. + +: "${RCLONE_REMOTE_NAME:?Falta RCLONE_REMOTE_NAME (ej: gdrive)}" +: "${GDRIVE_FOLDER_ID:?Falta GDRIVE_FOLDER_ID}" + +REMOTE="${RCLONE_REMOTE_NAME}:" +PATTERN='^soma-backup-([0-9]{4}-[0-9]{2}-[0-9]{2})\.sql\.gz\.gpg$' + +mapfile -t files < <(rclone --drive-root-folder-id="$GDRIVE_FOLDER_ID" lsf "$REMOTE" --files-only) + +declare -A file_by_date=() +dates=() +for f in "${files[@]}"; do + if [[ "$f" =~ $PATTERN ]]; then + d="${BASH_REMATCH[1]}" + file_by_date["$d"]="$f" + dates+=("$d") + fi +done + +if [ "${#dates[@]}" -eq 0 ]; then + echo "No hay backups con el patrón esperado en ${REMOTE} (carpeta ${GDRIVE_FOLDER_ID}). Nada que rotar." + exit 0 +fi + +# Fechas ordenadas descendente (más nueva primero). +IFS=$'\n' sorted=($(printf '%s\n' "${dates[@]}" | sort -r)); unset IFS + +declare -A keep=() + +# Los 3 backups más recientes. +for i in 0 1 2; do + d="${sorted[$i]:-}" + [ -n "$d" ] && keep["$d"]=1 +done + +# Ancla mensual de cada uno de los últimos 6 meses cerrados (el actual no +# cuenta: todavía no cerró). +hoy_mes_uno="$(date -u +%Y-%m-01)" +for i in 1 2 3 4 5 6; do + ym="$(date -u -d "${hoy_mes_uno} -${i} month" +%Y-%m)" + for d in "${sorted[@]}"; do + if [[ "$d" == "${ym}"* ]]; then + keep["$d"]=1 # sorted desc: el primer match del mes es el más nuevo + break + fi + done +done + +echo "== Conservar (${#keep[@]} de ${#sorted[@]}) ==" +for d in "${sorted[@]}"; do + [ -n "${keep[$d]:-}" ] && echo " ${file_by_date[$d]}" +done + +echo "== Borrar ==" +borrados=0 +for d in "${sorted[@]}"; do + if [ -z "${keep[$d]:-}" ]; then + echo " ${file_by_date[$d]}" + rclone --drive-root-folder-id="$GDRIVE_FOLDER_ID" deletefile "${REMOTE}${file_by_date[$d]}" + borrados=$((borrados + 1)) + fi +done + +echo "OK: ${borrados} archivo(s) borrado(s), ${#keep[@]} conservado(s)." diff --git a/database/functions/actividades/fc_eliminar_actividad.sql b/database/functions/actividades/fc_eliminar_actividad.sql new file mode 100644 index 0000000..a27b400 --- /dev/null +++ b/database/functions/actividades/fc_eliminar_actividad.sql @@ -0,0 +1,168 @@ +-- ============================================================================ +-- fc_eliminar_actividad +-- ============================================================================ +-- PROPÓSITO +-- Borrado físico de una actividad. Operación **destructiva en cascada**: +-- borra plantillas horarias (regulares y de días especiales), todos los +-- turnos pasados y futuros de la actividad, y todas las reservas +-- históricas y futuras asociadas a esos turnos. La pérdida es irreversible. +-- +-- Si la actividad está incluida en uno o más planes (tipos_cuota), el +-- borrado se rechaza — esas FK no cascadean. El operador debe primero +-- sacar la actividad de los planes (o desactivarla si quiere conservar +-- las asociaciones). +-- +-- DOMINIO +-- El dominio de horarios (Documentation/DominioHorarios.md §7.2) describe +-- sólo dos operaciones de baja para una actividad: **suspender** y +-- **restablecer**. La eliminación física no está en §7.2. Vive acá +-- como vía operativa del operador, sostenida por §9.6 (libertad +-- operativa con advertencia): la UI muestra la advertencia previa, el +-- operador asume la consecuencia y la función ejecuta. +-- +-- §11 exige que los cambios estructurales sean reconstruibles. Esta +-- función lo cubre vía evento `actividad_eliminada` con un payload +-- **minimalista** alineado con la política del sistema: los eventos +-- guardan lo que puede volver a ser necesario operativamente, no +-- estadística histórica. Por eso el snapshot es sólo `{id, nombre}` (lo +-- mínimo para identificar qué se borró) y los conteos cuentan sólo +-- compromisos vivos rotos (`turnos_futuros`, `reservas_vivas`). Los +-- pasados no se cuentan: la política de retención los iba a descartar +-- de todas formas. Los detalles de la actividad (duración, capacidad, +-- etc.) no se conservan: el dominio no admite "deshacer eliminación"; +-- si vuelve a ser necesaria, el operador la crea de cero. +-- +-- No mirar a esta función como la contraparte de "suspender" — son dos +-- cosas distintas. Suspender es reversible y preserva historia; +-- eliminar es terminal y la destruye. Para suspender, usar +-- `fc_modificar_actividad` con `{activo: false}`. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_actividad_id INT ID de la actividad a eliminar. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'eliminar_actividad'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Sesión inválida o usuario no encontrado.' +-- - 'No se puede eliminar la actividad X porque está incluida en uno o +-- más planes. Sacala de los planes primero, o desactivala si querés +-- conservarla en el sistema.' +-- +-- RETORNA +-- BOOL TRUE si eliminó la actividad, FALSE si no encontró ninguna fila +-- con ese ID. El frontend arma el preview previo a la confirmación con +-- las consultas existentes (turnos/reservas filtrados por actividad). +-- +-- EFECTOS SECUNDARIOS +-- - DELETE en actividades. Cascadea a horario_actividad, +-- horario_actividad_especial, turnos, y de ahí a reservas. +-- - INSERT en eventos: tipo 'actividad_eliminada', valor_anterior con +-- `{actividad: {id, nombre}, destruido: {turnos_futuros, reservas_vivas}}`, +-- valor_actual NULL. referencia_id queda NULL porque actividades.id +-- es INT y eventos.referencia_id es UUID; el id va embebido en el +-- snapshot. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_eliminar_actividad( + p_token UUID, + p_actividad_id INT +) +RETURNS BOOL +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_actor_id UUID; + v_actividad_id INT; + v_actividad_nombre TEXT; + v_count_turnos_futuros INT; + v_count_reservas_vivas INT; +BEGIN + ------------------------------------------------------------ + -- 1. Permisos + actor + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'eliminar_actividad'); + + SELECT s.usuario_id + INTO v_actor_id + FROM internal.sesiones s + WHERE s.token = p_token; + + IF v_actor_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + ------------------------------------------------------------ + -- 2. Identidad mínima de la actividad (debe existir para continuar) + ------------------------------------------------------------ + SELECT a.id, a.nombre + INTO v_actividad_id, v_actividad_nombre + FROM actividades a + WHERE a.id = p_actividad_id; + + IF v_actividad_id IS NULL THEN + -- No existe: nada que borrar, nada que loggear. + RETURN FALSE; + END IF; + + ------------------------------------------------------------ + -- 3. Pre-conteo de compromisos vivos (lo único que importa + -- para reconstrucción operativa; los pasados igual iban + -- a desaparecer por retención). + ------------------------------------------------------------ + SELECT COUNT(*)::INT INTO v_count_turnos_futuros + FROM turnos + WHERE actividad_id = p_actividad_id + AND fecha >= CURRENT_DATE; + + SELECT COUNT(*)::INT INTO v_count_reservas_vivas + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE t.actividad_id = p_actividad_id + AND t.fecha >= CURRENT_DATE + AND r.cancelada = false; + + ------------------------------------------------------------ + -- 4. Borrado físico (cascadea por FK) + ------------------------------------------------------------ + BEGIN + DELETE FROM actividades a WHERE a.id = p_actividad_id; + EXCEPTION + WHEN foreign_key_violation THEN + -- El único origen esperado es actividades_tipos_cuota (no cascadea). + RAISE EXCEPTION 'No se puede eliminar la actividad % porque está incluida en uno o más planes. Sacala de los planes primero, o desactivala si querés conservarla en el sistema.', p_actividad_id; + END; + + ------------------------------------------------------------ + -- 5. Trazabilidad del cambio estructural destructivo (§11) + -- Si log_evento fallara, la transacción entera revierte + -- (el DELETE no queda firme sin trazabilidad). + ------------------------------------------------------------ + PERFORM internal.log_evento( + p_tipo => 'actividad_eliminada', + p_tabla => 'actividades', + p_referencia_id => NULL, + p_cliente_id => NULL, + p_actor_id => v_actor_id, + p_valor_anterior => jsonb_build_object( + 'actividad', jsonb_build_object( + 'id', v_actividad_id, + 'nombre', v_actividad_nombre + ), + 'destruido', jsonb_build_object( + 'turnos_futuros', v_count_turnos_futuros, + 'reservas_vivas', v_count_reservas_vivas + ) + ), + p_valor_actual => NULL, + p_descripcion => NULL + ); + + RETURN TRUE; +END; +$function$; diff --git a/database/functions/actividades/fc_insertar_actividad.sql b/database/functions/actividades/fc_insertar_actividad.sql new file mode 100644 index 0000000..9b7dc34 --- /dev/null +++ b/database/functions/actividades/fc_insertar_actividad.sql @@ -0,0 +1,110 @@ +-- ============================================================================ +-- fc_insertar_actividad +-- ============================================================================ +-- PROPÓSITO +-- Crea una actividad nueva (entidad reservable: yoga, funcional, etc.). +-- +-- DOMINIO +-- Alta de una actividad (Documentation/DominioHorarios.md §7.2 — el +-- agrupamiento operativo, no la disciplina física). Es un acto del +-- operador sobre el catálogo: no produce reservas huérfanas por sí +-- misma, pero ingresa al alcance de los **cambios estructurales** (§8, +-- §11) — abre nuevas opciones reservables que después la plantilla +-- horaria podrá referenciar. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_datos JSONB con el payload. Claves: +-- nombre TEXT (obligatorio) no vacío. +-- duracion SMALLINT (obligatorio) en minutos, > 0. +-- capacidad_por_defecto SMALLINT (obligatorio) > 0. +-- libre BOOLEAN (opcional, default FALSE) si la +-- actividad es "libre" (sin turnos). +-- activo BOOLEAN (opcional, default TRUE). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'crear_actividad'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El nombre de la actividad es obligatorio.' +-- - 'La duración es obligatoria y debe ser mayor a 0.' +-- - 'La capacidad por defecto es obligatoria y debe ser mayor a 0.' +-- +-- RETORNA +-- JSONB con la actividad recién creada: {id, nombre, duracion, +-- capacidad_por_defecto, libre, activo, status: 'success'}. +-- +-- EFECTOS SECUNDARIOS +-- INSERT en actividades. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_insertar_actividad( + p_token UUID, + p_datos JSONB +) +RETURNS JSONB -- Cambiado a JSONB para evitar errores de posición de columnas +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Modifica datos +AS $function$ +DECLARE + v_actividad_insertada RECORD; +BEGIN + ------------------------------------------------------------ + -- Checklist #3: Validar permisos (Centralizado) + ------------------------------------------------------------ + -- Acción sugerida: 'crear_actividad' + PERFORM internal.validate_permission(p_token, 'crear_actividad'); + + ------------------------------------------------------------ + -- 1. Validaciones de Entrada + ------------------------------------------------------------ + -- Validamos que los campos obligatorios vengan y NO sean nulos + IF (p_datos->>'nombre') IS NULL OR length(p_datos->>'nombre') = 0 THEN + RAISE EXCEPTION 'El nombre de la actividad es obligatorio.'; + END IF; + + IF (p_datos->>'duracion') IS NULL OR (p_datos->>'duracion')::INT <= 0 THEN + RAISE EXCEPTION 'La duración es obligatoria y debe ser mayor a 0.'; + END IF; + + IF (p_datos->>'capacidad_por_defecto') IS NULL OR (p_datos->>'capacidad_por_defecto')::INT <= 0 THEN + RAISE EXCEPTION 'La capacidad por defecto es obligatoria y debe ser mayor a 0.'; + END IF; + + ------------------------------------------------------------ + -- 2. Insertar directo (Sin variables intermedias) + ------------------------------------------------------------ + INSERT INTO actividades ( + nombre, + duracion, + capacidad_por_defecto, + libre, + activo + ) + VALUES ( + p_datos->>'nombre', + (p_datos->>'duracion')::SMALLINT, + (p_datos->>'capacidad_por_defecto')::SMALLINT, + COALESCE((p_datos->>'libre')::BOOLEAN, FALSE), -- Default FALSE si no viene + COALESCE((p_datos->>'activo')::BOOLEAN, TRUE) -- Default TRUE si no viene + ) + RETURNING * INTO v_actividad_insertada; -- Capturamos todo el registro + + ------------------------------------------------------------ + -- 3. Retorno JSONB Seguro + ------------------------------------------------------------ + -- Al usar keys explícitas, nunca más tendrás el error de columnas intercambiadas + RETURN jsonb_build_object( + 'id', v_actividad_insertada.id, + 'nombre', v_actividad_insertada.nombre, + 'duracion', v_actividad_insertada.duracion, + 'capacidad_por_defecto', v_actividad_insertada.capacidad_por_defecto, + 'libre', v_actividad_insertada.libre, + 'activo', v_actividad_insertada.activo, + 'status', 'success' + ); + +END; +$function$; diff --git a/database/functions/actividades/fc_modificar_actividad.sql b/database/functions/actividades/fc_modificar_actividad.sql new file mode 100644 index 0000000..95dcdbb --- /dev/null +++ b/database/functions/actividades/fc_modificar_actividad.sql @@ -0,0 +1,180 @@ +-- ============================================================================ +-- fc_modificar_actividad +-- ============================================================================ +-- PROPÓSITO +-- Modifica los campos editables de una actividad existente. Semántica +-- PATCH: sólo se actualizan las claves presentes en p_datos. +-- +-- DOMINIO +-- Modifica una actividad (Documentation/DominioHorarios.md §7.2). Esta +-- función es el único punto de entrada para las dos operaciones nombradas +-- del dominio sobre el ciclo de vida de una actividad: +-- +-- - **Suspender** (`activo: true → false`). Cambio estructural (§11). Las +-- consecuencias materializadas — borrar turnos futuros, rescatar +-- reservas vivas como huérfanas, cerrar plantillas horarias — las +-- ejecuta el trigger trg_desactivar_actividad / internal.cerrar_actividad, +-- no esta función. Acá sólo flippeamos el flag y registramos el evento +-- de trazabilidad. +-- +-- - **Restablecer** (`activo: false → true`). Cambio estructural (§11). El +-- dominio adopta la semántica "el operador define el horario nuevo": el +-- restablecimiento no recrea plantillas ni resucita las que se cerraron +-- al suspender. La actividad vuelve a ser ofrecible, y el operador +-- crea las plantillas que correspondan (típicamente anunciándolas con +-- anticipación). Las asociaciones a tipos_cuota nunca se tocan al +-- suspender, así que vuelven a habilitar acceso automáticamente. +-- +-- Cualquier otro cambio (nombre, duración, capacidad, libre) es metadata +-- operativa, no produce evento ni dispara trigger. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_actividad_id INT (obligatorio) ID de la actividad. +-- p_datos JSONB con los campos a actualizar (todos opcionales): +-- nombre, duracion, capacidad_por_defecto, libre, activo. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_actividades'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No existe la actividad con id X' +-- - 'Sesión inválida o usuario no encontrado.' +-- +-- RETORNA +-- JSONB con la actividad ya actualizada: {id, nombre, duracion, +-- capacidad_por_defecto, libre, activo, status: 'success'}. +-- +-- EFECTOS SECUNDARIOS +-- - UPDATE en actividades. +-- - Si `activo` cambió, dispara trg_desactivar_actividad en el caso +-- TRUE→FALSE (efectos materializados; ver internal.cerrar_actividad). +-- - Si `activo` cambió, INSERT en eventos con tipo 'actividad_suspendida' +-- o 'actividad_restablecida' (trazabilidad §11). referencia_id queda +-- NULL porque actividades.id es INT y eventos.referencia_id es UUID; +-- el id va embebido en los snapshots. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_modificar_actividad( + p_token UUID, + p_actividad_id INT, -- ID explícito + p_datos JSONB +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Modifica datos +AS $function$ +DECLARE + v_actor_id UUID; + v_activo_anterior BOOLEAN; + v_nombre_anterior TEXT; + v_actividad_actualizada RECORD; + v_tipo_evento TEXT; +BEGIN + ------------------------------------------------------------ + -- Checklist #3: Validar permisos (Centralizado) + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'modificar_actividades'); + + SELECT s.usuario_id + INTO v_actor_id + FROM internal.sesiones s + WHERE s.token = p_token; + + IF v_actor_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + ------------------------------------------------------------ + -- Snapshot previo (necesario para detectar flip de `activo` + -- y para el valor_anterior del evento de trazabilidad). + ------------------------------------------------------------ + SELECT a.activo, a.nombre + INTO v_activo_anterior, v_nombre_anterior + FROM actividades a + WHERE a.id = p_actividad_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'No existe la actividad con id %', p_actividad_id; + END IF; + + ------------------------------------------------------------ + -- 1. UPDATE Dinámico y Seguro + ------------------------------------------------------------ + -- Patrón CASE WHEN ? para permitir actualizaciones parciales reales. + UPDATE actividades + SET + nombre = CASE + WHEN p_datos ? 'nombre' THEN p_datos->>'nombre' + ELSE nombre + END, + + duracion = CASE + WHEN p_datos ? 'duracion' THEN (p_datos->>'duracion')::SMALLINT + ELSE duracion + END, + + capacidad_por_defecto = CASE + WHEN p_datos ? 'capacidad_por_defecto' THEN (p_datos->>'capacidad_por_defecto')::SMALLINT + ELSE capacidad_por_defecto + END, + + libre = CASE + WHEN p_datos ? 'libre' THEN (p_datos->>'libre')::BOOLEAN + ELSE libre + END, + + activo = CASE + WHEN p_datos ? 'activo' THEN (p_datos->>'activo')::BOOLEAN + ELSE activo + END + WHERE id = p_actividad_id + RETURNING * INTO v_actividad_actualizada; + + ------------------------------------------------------------ + -- 2. Trazabilidad del cambio estructural (§11) + -- Sólo si el flag `activo` efectivamente cambió. + ------------------------------------------------------------ + IF v_activo_anterior IS DISTINCT FROM v_actividad_actualizada.activo THEN + v_tipo_evento := CASE + WHEN v_actividad_actualizada.activo = FALSE THEN 'actividad_suspendida' + ELSE 'actividad_restablecida' + END; + + PERFORM internal.log_evento( + p_tipo => v_tipo_evento, + p_tabla => 'actividades', + p_referencia_id => NULL, + p_cliente_id => NULL, + p_actor_id => v_actor_id, + p_valor_anterior => jsonb_build_object( + 'actividad_id', p_actividad_id, + 'nombre', v_nombre_anterior, + 'activo', v_activo_anterior + ), + p_valor_actual => jsonb_build_object( + 'actividad_id', v_actividad_actualizada.id, + 'nombre', v_actividad_actualizada.nombre, + 'activo', v_actividad_actualizada.activo + ), + p_descripcion => NULL + ); + END IF; + + ------------------------------------------------------------ + -- 3. Retorno JSONB + ------------------------------------------------------------ + RETURN jsonb_build_object( + 'id', v_actividad_actualizada.id, + 'nombre', v_actividad_actualizada.nombre, + 'duracion', v_actividad_actualizada.duracion, + 'capacidad_por_defecto', v_actividad_actualizada.capacidad_por_defecto, + 'libre', v_actividad_actualizada.libre, + 'activo', v_actividad_actualizada.activo, + 'status', 'success' + ); + +END; +$function$; diff --git a/database/functions/actividades/fc_obtener_actividades.sql b/database/functions/actividades/fc_obtener_actividades.sql new file mode 100644 index 0000000..ff38159 --- /dev/null +++ b/database/functions/actividades/fc_obtener_actividades.sql @@ -0,0 +1,64 @@ +-- ============================================================================ +-- fc_obtener_actividades +-- ============================================================================ +-- PROPÓSITO +-- Devuelve las actividades configuradas. Permite filtrar por estado activo. +-- +-- DOMINIO +-- Lista el catálogo de actividades (Documentation/DominioHorarios.md §7.2). +-- La actividad es el agrupamiento operativo del uso del gimnasio (no la +-- disciplina física). El filtro `activo` se corresponde con la noción de +-- **suspensión / restablecimiento** del dominio: una actividad inactiva +-- "sigue existiendo conceptualmente, simplemente deja de ofrecerse". +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_solo_activas BOOLEAN (opcional, default NULL). Si NULL trae todas; si +-- TRUE sólo las activas; si FALSE sólo las inactivas. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_actividades'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío) con las filas completas de actividades +-- ordenadas por nombre. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_actividades( + p_token UUID, + p_solo_activas BOOLEAN DEFAULT NULL -- Opcional: útil para dropdowns +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE -- Checklist #2: Solo lee datos +AS $$ +DECLARE + -- Sin variables extra +BEGIN + ------------------------------------------------------------ + -- Checklist #3: Validar permisos + ------------------------------------------------------------ + -- Acción sugerida: 'ver_actividades' + PERFORM internal.validate_permission(p_token, 'ver_actividades'); + + ------------------------------------------------------------ + -- Retornar JSONB estandarizado + ------------------------------------------------------------ + RETURN ( + SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.nombre), '[]'::jsonb) + FROM actividades a + WHERE + -- Filtro opcional: Si p_solo_activas es NULL, trae todo. + -- Si es TRUE, solo trae las activas = true. + (p_solo_activas IS NULL OR a.activo = p_solo_activas) + ); +END; +$$; diff --git a/database/functions/actividades/fc_obtener_actividades_tipo_cuota.sql b/database/functions/actividades/fc_obtener_actividades_tipo_cuota.sql new file mode 100644 index 0000000..093f0ed --- /dev/null +++ b/database/functions/actividades/fc_obtener_actividades_tipo_cuota.sql @@ -0,0 +1,61 @@ +-- ============================================================================ +-- fc_obtener_actividades_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Devuelve las actividades incluidas en un tipo de cuota (plan). Útil para +-- mostrar el detalle de un plan o para validar si el cliente que tiene +-- plan X puede reservar un turno de actividad Y. +-- +-- DOMINIO +-- Sirve a la consulta "qué actividades habilita un plan" +-- (Documentation/DominioHorarios.md §7.2 actividad + §7.7 plan). Es la +-- fuente con la que se materializa la primera condición de §9.1: "su plan +-- está vigente y habilita la actividad del bloque". +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_tipo_cuota_id UUID ID del plan a consultar. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_actividades'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío) de actividades asociadas: cada ítem +-- trae {id, nombre, duracion, activo}, ordenado por nombre. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_actividades_tipo_cuota( + p_token UUID, + p_tipo_cuota_id UUID +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public +STABLE +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_actividades'); + + RETURN ( + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'duracion', a.duracion, + 'activo', a.activo + ) + ORDER BY a.nombre ASC + ), '[]'::jsonb) + FROM public.actividades_tipos_cuota atc + JOIN public.actividades a ON atc.actividad_id = a.id + WHERE atc.tipo_cuota_id = p_tipo_cuota_id + ); +END; +$$; diff --git a/database/functions/actividades/internal/cerrar_actividad.sql b/database/functions/actividades/internal/cerrar_actividad.sql new file mode 100644 index 0000000..65c44fb --- /dev/null +++ b/database/functions/actividades/internal/cerrar_actividad.sql @@ -0,0 +1,89 @@ +-- ============================================================================ +-- internal.cerrar_actividad +-- ============================================================================ +-- PROPÓSITO +-- Trigger function: cuando una actividad pasa de activo=true a activo=false, +-- limpia el estado derivado: rescata reservas vivas como reservas huérfanas, +-- borra los turnos futuros y cierra las plantillas horarias asociadas. +-- +-- El trigger que la invoca (trg_desactivar_actividad, AFTER UPDATE OF activo +-- ON actividades, WHEN OLD.activo = TRUE AND NEW.activo = FALSE) NO se +-- versiona en este archivo. Vive como parte del DDL (por ahora en +-- shift_functions/utility_functions.sql). +-- +-- PARÁMETROS +-- Implícitos del trigger (OLD.id, OLD.nombre, NEW). +-- +-- AUTORIZACIÓN +-- No aplica (trigger function). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- trigger (la fila NEW). +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en reservas_huerfanas para cada reserva activa sobre un turno +-- futuro de la actividad desactivada. +-- - DELETE de turnos futuros de la actividad. +-- - DELETE de plantillas horarias (horario_actividad) cuya vigencia +-- empieza hoy o en el futuro. +-- - UPDATE de plantillas horarias vigentes: cierra su vigencia ayer +-- (valido_hasta = CURRENT_DATE - 1). +-- - Las plantillas históricas (ya vencidas) no se tocan. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.cerrar_actividad() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +AS $$ +BEGIN + -- El trigger se filtra con WHEN, así que cuando entra acá ya sabemos que + -- la actividad pasó de activo=true a activo=false. + + ------------------------------------------------------------ + -- 1. Rescatar reservas activas sobre turnos futuros + ------------------------------------------------------------ + INSERT INTO reservas_huerfanas + (cliente_id, actividad_nombre, fecha_original, hora_inicio_original) + SELECT r.cliente_id, OLD.nombre, t.fecha, t.hora_inicio + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE t.actividad_id = OLD.id + AND t.fecha >= CURRENT_DATE + AND r.cancelada = false; + + ------------------------------------------------------------ + -- 2. Borrar turnos futuros (los pasados quedan para histórico/reportes) + ------------------------------------------------------------ + DELETE FROM turnos + WHERE actividad_id = OLD.id + AND fecha >= CURRENT_DATE; + + ------------------------------------------------------------ + -- 3. Limpiar plantillas según su posición temporal + ------------------------------------------------------------ + + -- 3.A Plantillas que empiezan hoy o en el futuro: borrarlas. + -- Si la cerráramos en CURRENT_DATE-1 quedaría un intervalo vacío + -- o invertido (valido_desde > valido_hasta). + DELETE FROM horario_actividad + WHERE actividad_id = OLD.id + AND valido_desde >= CURRENT_DATE; + + -- 3.B Plantillas que ya están rigiendo (valido_desde en el pasado y + -- vigencia abierta o futura): cerrarlas en CURRENT_DATE - 1. + UPDATE horario_actividad + SET valido_hasta = CURRENT_DATE - 1 + WHERE actividad_id = OLD.id + AND valido_desde < CURRENT_DATE + AND (valido_hasta IS NULL OR valido_hasta >= CURRENT_DATE); + + -- 3.C Plantillas históricas (valido_hasta < CURRENT_DATE): NO se tocan. + + RETURN NEW; +END; +$$; diff --git a/database/functions/auth/fc_eliminar_sesion.sql b/database/functions/auth/fc_eliminar_sesion.sql new file mode 100644 index 0000000..315dafc --- /dev/null +++ b/database/functions/auth/fc_eliminar_sesion.sql @@ -0,0 +1,34 @@ +-- ============================================================================ +-- fc_eliminar_sesion +-- ============================================================================ +-- PROPÓSITO +-- Cierra una sesión activa eliminando su registro de internal.sesiones. +-- Equivalente a "logout". +-- +-- PARÁMETROS +-- p_token UUID token de la sesión a cerrar. +-- +-- AUTORIZACIÓN +-- No valida permiso (cualquiera con un token válido puede cerrarlo). +-- Si p_token no existe, no hace nada (no rebota). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno. +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- DELETE en internal.sesiones de la fila con ese token. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_eliminar_sesion(p_token UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + DELETE FROM internal.sesiones s WHERE s.token = p_token; +END; +$$; diff --git a/database/functions/auth/fc_ingresar.sql b/database/functions/auth/fc_ingresar.sql new file mode 100644 index 0000000..f400b6c --- /dev/null +++ b/database/functions/auth/fc_ingresar.sql @@ -0,0 +1,60 @@ +-- ============================================================================ +-- fc_ingresar +-- ============================================================================ +-- PROPÓSITO +-- Inicia sesión validando credenciales DNI + contraseña. Si las +-- credenciales son correctas, crea una nueva sesión con vencimiento a +-- 10 días y devuelve el token y el rol del usuario. +-- +-- PARÁMETROS +-- dni_input TEXT DNI del usuario. +-- password_plain_input TEXT contraseña en texto plano. +-- +-- AUTORIZACIÓN +-- Pública: no requiere token previo (es la entrada al sistema). +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Usuario o contraseña inválidos.' (mismo mensaje para usuario +-- inexistente y para contraseña mal — evita oracles de existencia). +-- +-- RETORNA +-- TABLE (token UUID, rol TEXT) con la sesión creada y el rol del usuario. +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en internal.sesiones (token + expires_at = now() + 10 días). +-- - El trigger trg_clean_old_sessions borra otras sesiones del mismo +-- usuario al insertar (cada usuario tiene una sola sesión activa). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_ingresar(dni_input TEXT, password_plain_input TEXT) +RETURNS TABLE (token UUID, rol TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = extensions, public +AS $$ +DECLARE + v_user_id UUID; + v_user_rol TEXT; + v_session_token UUID; + v_stored_password_hash TEXT; +BEGIN + SELECT u.id, u.rol, u.password_hash + INTO v_user_id, v_user_rol, v_stored_password_hash + FROM public.usuarios u + WHERE u.dni = dni_input; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Usuario o contraseña inválidos.'; + END IF; + + IF v_stored_password_hash IS NULL OR v_stored_password_hash <> crypt(password_plain_input, v_stored_password_hash) THEN + RAISE EXCEPTION 'Usuario o contraseña inválidos.'; + END IF; + + v_session_token := gen_random_uuid(); + INSERT INTO internal.sesiones (usuario_id, token, expires_at) + VALUES(v_user_id, v_session_token, NOW() + INTERVAL '10 days'); + + RETURN QUERY SELECT v_session_token, v_user_rol; +END; +$$; diff --git a/database/functions/auth/fc_iniciar_sesion_por_token.sql b/database/functions/auth/fc_iniciar_sesion_por_token.sql new file mode 100644 index 0000000..86c01cb --- /dev/null +++ b/database/functions/auth/fc_iniciar_sesion_por_token.sql @@ -0,0 +1,55 @@ +-- ============================================================================ +-- fc_iniciar_sesion_por_token +-- ============================================================================ +-- PROPÓSITO +-- Reanuda una sesión existente a partir del token guardado localmente. +-- Si el token es válido (existe y no expiró), extiende su vencimiento +-- otros 10 días desde ahora y devuelve el rol del usuario. +-- Es el equivalente de fc_ingresar pero usando el token persistido en +-- lugar de credenciales — reemplaza a fc_validar_token en el flujo de +-- startup de la app, renovando el TTL en lugar de solo validarlo. +-- +-- PARÁMETROS +-- p_token UUID token de sesión guardado localmente. +-- +-- AUTORIZACIÓN +-- Pública: recibe el token como única prueba de identidad. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Sesión inválida o expirada.' si el token no existe o ya venció. +-- +-- RETORNA +-- TEXT con el rol del usuario (misma forma que fc_validar_token). +-- +-- EFECTOS SECUNDARIOS +-- - UPDATE en internal.sesiones: extends expires_at = now() + 10 días. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_iniciar_sesion_por_token(p_token UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_rol TEXT; +BEGIN + -- Validar token, extender el TTL y obtener el rol en una sola sentencia. + -- El UPDATE solo afecta filas cuyo token existe y no venció; el JOIN con + -- usuarios provee el rol vía RETURNING. Si no matchea nada (token + -- inexistente, expirado o sesión sin usuario), NOT FOUND dispara el error. + UPDATE internal.sesiones s + SET expires_at = NOW() + INTERVAL '10 days' + FROM public.usuarios u + WHERE s.token = p_token + AND s.expires_at > NOW() + AND u.id = s.usuario_id + RETURNING u.rol INTO v_rol; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Sesión inválida o expirada.'; + END IF; + + RETURN v_rol; +END; +$$; diff --git a/database/functions/auth/internal/clean_expired_sessions.sql b/database/functions/auth/internal/clean_expired_sessions.sql new file mode 100644 index 0000000..b81b416 --- /dev/null +++ b/database/functions/auth/internal/clean_expired_sessions.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- internal.clean_expired_sessions +-- ============================================================================ +-- PROPÓSITO +-- Elimina todas las sesiones cuya fecha de expiración ya pasó. Pensada +-- para ejecutarse periódicamente por cron. +-- +-- El job de cron que la dispara (ten_day_session_cleanup_job, días +-- 1, 11 y 21 de cada mes a las 00:00) se versiona en +-- cron/auth/ten_day_session_cleanup_job.sql. +-- +-- PARÁMETROS +-- Ninguno. +-- +-- AUTORIZACIÓN +-- No aplica (función interna invocada por cron). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno. +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- DELETE en internal.sesiones de las filas con expires_at < NOW(). +-- ============================================================================ + +create OR REPLACE function internal.clean_expired_sessions() returns void + security definer + SET search_path = internal, pg_catalog + language plpgsql +as +$$ +BEGIN + -- Elimina todas las sesiones cuya fecha de expiración es anterior a la actual. + DELETE FROM internal.sesiones + WHERE expires_at < NOW(); +END; +$$; diff --git a/database/functions/auth/internal/clean_old_sessions_on_new_login.sql b/database/functions/auth/internal/clean_old_sessions_on_new_login.sql new file mode 100644 index 0000000..e29ce75 --- /dev/null +++ b/database/functions/auth/internal/clean_old_sessions_on_new_login.sql @@ -0,0 +1,44 @@ +-- ============================================================================ +-- internal.clean_old_sessions_on_new_login +-- ============================================================================ +-- PROPÓSITO +-- Trigger function: al insertar una nueva sesión, elimina cualquier otra +-- sesión previa del mismo usuario. Garantiza que cada usuario tiene a lo +-- sumo una sesión activa. +-- +-- El trigger que la invoca (trg_clean_old_sessions, BEFORE INSERT on +-- internal.sesiones) se versiona en +-- triggers/auth/trg_clean_old_sessions.sql. +-- +-- PARÁMETROS +-- Implícitos del trigger (NEW.usuario_id, NEW.token). +-- +-- AUTORIZACIÓN +-- No aplica (es trigger). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno. +-- +-- RETORNA +-- trigger (la fila NEW para continuar el INSERT). +-- +-- EFECTOS SECUNDARIOS +-- DELETE en internal.sesiones de cualquier sesión del mismo usuario con +-- token distinto al recién insertado. +-- ============================================================================ + +create OR REPLACE function internal.clean_old_sessions_on_new_login() returns trigger + security definer + set search_path = internal, pg_catalog + language plpgsql +as +$$ +BEGIN + -- Esto asegura que solo la sesión actual (la que se está creando) permanezca. + DELETE FROM internal.sesiones + WHERE usuario_id = NEW.usuario_id + AND token IS DISTINCT FROM NEW.token; + + RETURN NEW; +END; +$$; diff --git a/database/functions/auth/internal/create_password_hash.sql b/database/functions/auth/internal/create_password_hash.sql new file mode 100644 index 0000000..f225162 --- /dev/null +++ b/database/functions/auth/internal/create_password_hash.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- internal.create_password_hash +-- ============================================================================ +-- PROPÓSITO +-- Devuelve el hash bcrypt de una contraseña en texto plano. Usa +-- crypt() + gen_salt('bf') del módulo pgcrypto, que vive en el schema +-- extensions. +-- +-- Helper interno usado por internal.fc_insertar_usuario (vía +-- public.fc_insertar_usuario) y por funciones futuras de cambio de +-- contraseña. +-- +-- PARÁMETROS +-- p_password_plain TEXT contraseña en claro. +-- +-- AUTORIZACIÓN +-- Función interna, sin validación propia. Pensada para ser llamada por +-- otras funciones server-side, no por el cliente directamente. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- TEXT con el hash bcrypt. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.create_password_hash(p_password_plain TEXT) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = extensions, public +AS $$ +BEGIN + RETURN crypt(p_password_plain, gen_salt('bf'::text)); +END; +$$; diff --git a/database/functions/auth/internal/validar_token.sql b/database/functions/auth/internal/validar_token.sql new file mode 100644 index 0000000..6627370 --- /dev/null +++ b/database/functions/auth/internal/validar_token.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- internal.validar_token +-- ============================================================================ +-- PROPÓSITO +-- Dado un token de sesión, devuelve el rol del usuario asociado si la +-- sesión está vigente. Si el token no existe o ya expiró, devuelve NULL. +-- Helper interno usado por internal.validate_permission y por funciones +-- públicas que necesitan saber el rol del actor. +-- +-- PARÁMETROS +-- p_token UUID token de sesión. +-- +-- AUTORIZACIÓN +-- Función interna, sin validación propia. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno. +-- +-- RETORNA +-- TEXT con el rol del usuario, o NULL si el token es inválido o expiró. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.validar_token(p_token uuid) + RETURNS text + LANGUAGE plpgsql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_rol TEXT; +BEGIN + SELECT u.rol + FROM public.usuarios u + JOIN internal.sesiones s ON s.usuario_id = u.id + AND s.token = p_token + AND s.expires_at > NOW() + INTO v_rol; +-- Retorna el rol si el token es válido, de lo contrario, retorna NULL +RETURN v_rol; +END; +$function$ +; diff --git a/database/functions/auth/internal/validate_permission.sql b/database/functions/auth/internal/validate_permission.sql new file mode 100644 index 0000000..b728dbb --- /dev/null +++ b/database/functions/auth/internal/validate_permission.sql @@ -0,0 +1,62 @@ +-- ============================================================================ +-- internal.validate_permission +-- ============================================================================ +-- PROPÓSITO +-- Valida que el portador del token tenga permiso para ejecutar una acción +-- dada. Rebota con excepción si el token es inválido, si la acción no +-- está registrada, o si el rol del usuario no está autorizado. +-- +-- Es la pieza central del control de acceso: toda función pública (fc_*) +-- la invoca al inicio. +-- +-- PARÁMETROS +-- p_token UUID token de sesión del actor. +-- p_accion TEXT nombre de la acción a validar (clave en internal.permisos). +-- +-- AUTORIZACIÓN +-- Función interna; ella misma es el validador de autorización. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Token de sesión inválido o expirado' +-- - 'Error interno: acción no reconocida: X' +-- - 'No tienes internal.permisos para realizar esta acción' +-- (NOTA: el texto del error contiene "internal.permisos" por un error +-- histórico de redacción; se refiere a permisos en general.) +-- +-- RETORNA +-- void si el actor tiene permiso. Si no, rebota antes de retornar. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.validate_permission(p_token UUID, p_accion TEXT) + RETURNS void + LANGUAGE plpgsql + STABLE + SECURITY DEFINER + SET search_path TO public +AS $$ +DECLARE + v_rol TEXT; + v_roles_permitidos TEXT[]; +BEGIN + SELECT internal.validar_token(p_token) INTO v_rol; + + IF v_rol IS NULL THEN + RAISE EXCEPTION 'Token de sesión inválido o expirado'; + END IF; + + SELECT roles_permitidos INTO v_roles_permitidos + FROM internal.permisos + WHERE accion = p_accion; + + IF v_roles_permitidos IS NULL THEN + RAISE EXCEPTION 'Error interno: acción no reconocida: %', p_accion; + END IF; + + IF v_rol <> ALL(v_roles_permitidos) THEN + RAISE EXCEPTION 'No tienes internal.permisos para realizar esta acción'; + END IF; +END; +$$; diff --git a/database/functions/config/fc_obtener_config.sql b/database/functions/config/fc_obtener_config.sql new file mode 100644 index 0000000..7b67b14 --- /dev/null +++ b/database/functions/config/fc_obtener_config.sql @@ -0,0 +1,50 @@ +-- ============================================================================ +-- fc_obtener_config +-- ============================================================================ +-- PROPÓSITO +-- Devuelve la configuración runtime del sistema, como objeto JSON con +-- todas las parejas clave→valor de internal.app_config. +-- +-- Ejemplo de uso: el frontend la consulta al iniciar sesión para +-- conocer parámetros como la ventana de edición de pagos, defaults +-- visuales, etc. +-- +-- PARÁMETROS +-- p_token UUID (opcional, default NULL) sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_configuraciones_generales'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB objeto con todas las pares clave→valor de internal.app_config. +-- Si la tabla está vacía, retorna '{}' (no NULL). +-- +-- EFECTOS SECUNDARIOS +-- Ninguno. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_config(p_token uuid DEFAULT NULL) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_resultado jsonb; +BEGIN + + PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales'); + + -- Agrupamos todas las filas en un único objeto JSON + SELECT jsonb_object_agg(clave, valor) + INTO v_resultado + FROM internal.app_config; + + -- COALESCE garantiza que si la tabla está vacía, devolvemos un JSON vacío '{}' + -- en lugar de NULL, evitando que el frontend lance excepciones (NullReference). + RETURN COALESCE(v_resultado, '{}'::jsonb); +END; +$$; diff --git a/database/functions/config/internal/get_config_int.sql b/database/functions/config/internal/get_config_int.sql new file mode 100644 index 0000000..4ee57e8 --- /dev/null +++ b/database/functions/config/internal/get_config_int.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- internal.get_config_int +-- ============================================================================ +-- PROPÓSITO +-- Lee un valor entero de internal.app_config por clave. Si la clave no +-- existe, devuelve el default provisto. Helper para que las funciones +-- públicas tomen parámetros operacionales (ventanas, límites, etc.) +-- desde la tabla de configuración runtime sin tener que hardcodear. +-- +-- PARÁMETROS +-- p_clave TEXT nombre de la clave en internal.app_config. +-- p_default INTEGER valor a devolver si la clave no existe. +-- +-- AUTORIZACIÓN +-- Función interna sin validación propia. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. Si el valor en la tabla no es casteable a int, falla +-- por error de cast de PostgreSQL. +-- +-- RETORNA +-- INTEGER con el valor de la clave o p_default si no existe. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.get_config_int(p_clave text, p_default integer) +RETURNS integer +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT COALESCE( + (SELECT valor::int FROM internal.app_config WHERE clave = p_clave), + p_default + ) +$$; diff --git a/database/functions/eventos/internal/limpiar_eventos_antiguos.sql b/database/functions/eventos/internal/limpiar_eventos_antiguos.sql new file mode 100644 index 0000000..5d36dff --- /dev/null +++ b/database/functions/eventos/internal/limpiar_eventos_antiguos.sql @@ -0,0 +1,113 @@ +-- ============================================================================ +-- internal.limpiar_eventos_antiguos +-- ============================================================================ +-- PROPÓSITO +-- Higiene operativa: elimina filas de public.eventos cuya fecha +-- esté fuera de la ventana de retención correspondiente a su tipo. +-- No archiva: los eventos no son información que valga conservar +-- más allá del operativo (ver Documentation/PoliticaRetencion.md +-- §4.2 — el archivo registra cobros, no su historia de correcciones). +-- +-- DOMINIO +-- Implementa la higiene de eventos descrita en +-- Documentation/PoliticaRetencion.md §7. Dos ventanas distintas +-- porque los eventos de pagos viven más tiempo en la base que el +-- resto: una corrección o anulación de pago suele auditarse más +-- tarde que el cambio que la motivó. Tres meses es el horizonte +-- suficiente para ese seguimiento; el resto (acciones admin sobre +-- actividades, horarios, etc.) no requiere esa cola. +-- +-- REGLA DE DISCRIMINACIÓN +-- Evento de pagos sii `tipo LIKE 'pago_%'`. Lo determina el +-- prefijo, no una tabla aparte ni un campo nuevo. Quien inserta +-- en public.eventos elige el `tipo` y respeta el prefijo cuando +-- corresponda; hoy lo cumplen `pago_editado` y `pago_anulado` +-- (insertados por fc_editar_pago y fc_anular_pago vía +-- internal.log_evento). Cualquier evento futuro relacionado a +-- pagos debe seguir esta convención para heredar la ventana más +-- larga. +-- +-- PARÁMETROS +-- Ninguno. Las dos ventanas en meses se leen de internal.app_config: +-- - `retencion.eventos_pagos_meses` (default 3). +-- - `retencion.eventos_otros_meses` (default 2). +-- +-- AUTORIZACIÓN +-- No valida permiso propio. El schema `internal` no es alcanzable +-- por roles cliente (ver hardening en +-- `database/schema/00_schemas.sql`). Callers legítimos: pg_cron y +-- fachadas `public.fc_*` SECURITY DEFINER que orquesten el pipeline. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB con corte y eliminados desglosados por grupo (pagos / otros) +-- más un total agregado, útil para inspección de operación. +-- +-- EFECTOS SECUNDARIOS +-- - DELETE de eventos `pago_*` con `fecha_evento < now() - ventana_pagos`. +-- - DELETE de eventos no-`pago_*` con `fecha_evento < now() - ventana_otros`. +-- - **No** registra evento propio (vía internal.log_evento o de otro +-- modo). Hacerlo crearía un loop trivial: cada corrida generaría +-- un evento que la próxima corrida vería viejo y volvería a +-- producir otro. La purga es deliberadamente silenciosa; la +-- visibilidad se garantiza por el contador devuelto en el JSONB +-- y, eventualmente, por la vista admin del pipeline (US-R21). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.limpiar_eventos_antiguos() +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $$ +DECLARE + v_meses_pagos INT; + v_meses_otros INT; + v_corte_pagos TIMESTAMPTZ; + v_corte_otros TIMESTAMPTZ; + v_eliminados_pagos INT; + v_eliminados_otros INT; +BEGIN + v_meses_pagos := internal.get_config_int('retencion.eventos_pagos_meses', 3); + v_meses_otros := internal.get_config_int('retencion.eventos_otros_meses', 2); + + v_corte_pagos := now() - (v_meses_pagos * INTERVAL '1 month'); + v_corte_otros := now() - (v_meses_otros * INTERVAL '1 month'); + + DELETE FROM eventos + WHERE tipo LIKE 'pago_%' + AND fecha_evento < v_corte_pagos; + GET DIAGNOSTICS v_eliminados_pagos = ROW_COUNT; + + DELETE FROM eventos + WHERE tipo NOT LIKE 'pago_%' + AND fecha_evento < v_corte_otros; + GET DIAGNOSTICS v_eliminados_otros = ROW_COUNT; + + RETURN jsonb_build_object( + 'status', 'success', + 'corte_pagos', v_corte_pagos, + 'corte_otros', v_corte_otros, + 'meses_pagos', v_meses_pagos, + 'meses_otros', v_meses_otros, + 'eliminados_pagos', v_eliminados_pagos, + 'eliminados_otros', v_eliminados_otros, + 'eliminados_total', v_eliminados_pagos + v_eliminados_otros, + 'mensaje', format( + 'Eliminados %s eventos pago_* anteriores a %s y %s eventos no-pago anteriores a %s.', + v_eliminados_pagos, v_corte_pagos, + v_eliminados_otros, v_corte_otros + ) + ); +END; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.limpiar_eventos_antiguos() + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/eventos/internal/log_evento.sql b/database/functions/eventos/internal/log_evento.sql new file mode 100644 index 0000000..1e8712c --- /dev/null +++ b/database/functions/eventos/internal/log_evento.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- internal.log_evento +-- ============================================================================ +-- PROPÓSITO +-- Inserta una fila en public.eventos. Single entry point para que +-- cualquier función pública registre un evento auditable (ediciones, +-- anulaciones, acciones administrativas) sin tocar la tabla +-- directamente. +-- +-- Hoy lo usan fc_editar_pago y fc_anular_pago para registrar +-- pago_editado y pago_anulado. +-- +-- CAP DE SEGURIDAD (US-R12) +-- Antes de insertar, chequea el conteo vigente de public.eventos +-- contra `retencion.eventos_cap` (default 50000). Si registrar el +-- evento haría que la tabla alcance o supere el cap, RECHAZA con +-- excepción explícita en vez de insertar. No es FIFO: el rechazo +-- hace fallar la función que dispara el evento y corta la cascada, +-- de modo que un bug que dispare eventos en loop se vuelve ruidoso +-- en vez de comerse la cuota de la base en silencio. El cap es un +-- freno de emergencia, no la vía normal de control de tamaño (eso +-- lo hace la purga mensual, internal.limpiar_eventos_antiguos). +-- +-- PARÁMETROS +-- p_tipo TEXT discriminador del evento (ej. 'pago_editado'). +-- p_tabla TEXT nombre de la tabla referenciada (ej. 'pagos'). +-- p_referencia_id UUID ID de la fila referenciada en esa tabla. +-- p_cliente_id UUID cliente afectado (para eventos sobre clientes). +-- p_actor_id UUID usuario que ejecutó la acción. +-- p_valor_anterior JSONB snapshot del estado anterior (opcional). +-- p_valor_actual JSONB snapshot del estado nuevo (opcional). +-- p_descripcion TEXT (opcional, default NULL) descripción libre / +-- motivo. +-- +-- AUTORIZACIÓN +-- Función interna sin validación propia. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No se puede registrar el evento ...: public.eventos alcanzó su +-- cap de seguridad ...' (US-R12). +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- INSERT en public.eventos. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.log_evento( + p_tipo text, + p_tabla text, + p_referencia_id uuid, + p_cliente_id uuid, + p_actor_id uuid, + p_valor_anterior jsonb, + p_valor_actual jsonb, + p_descripcion text DEFAULT NULL::text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_cap INT; + v_count INT; +BEGIN + -- Cap de seguridad (US-R12): freno anti-loop. Rechazamos el insert + -- cuyo conteo resultante igualaría o superaría el cap, de modo que el + -- cap es el valor que el conteo no debe alcanzar. Bug ruidoso > bug + -- silencioso: que falle la cascada y nos enteremos. + v_cap := internal.get_config_int('retencion.eventos_cap', 50000); + SELECT count(*) INTO v_count FROM public.eventos; + IF v_count + 1 >= v_cap THEN + RAISE EXCEPTION + 'No se puede registrar el evento "%": public.eventos alcanzó su cap de seguridad (% filas). Suele indicar un bug que dispara eventos en loop; revisar la causa antes de subir retencion.eventos_cap en internal.app_config.', + p_tipo, v_cap; + END IF; + + INSERT INTO public.eventos ( + tipo, tabla_referencia, referencia_id, + cliente_id, actor_id, + valor_anterior, valor_actual, descripcion + ) VALUES ( + p_tipo, p_tabla, p_referencia_id, + p_cliente_id, p_actor_id, + p_valor_anterior, p_valor_actual, p_descripcion + ); +END; +$$; diff --git a/database/functions/horarios/fc_eliminar_dia_especial.sql b/database/functions/horarios/fc_eliminar_dia_especial.sql new file mode 100644 index 0000000..1f52d83 --- /dev/null +++ b/database/functions/horarios/fc_eliminar_dia_especial.sql @@ -0,0 +1,164 @@ +-- ============================================================================ +-- fc_eliminar_dia_especial +-- ============================================================================ +-- PROPÓSITO +-- Elimina la planificación especial de una fecha y restaura los turnos a +-- la plantilla regular vigente para esa fecha. Las reservas activas +-- sobre turnos especiales que no calzan con la plantilla regular se +-- rescatan como reservas huérfanas. +-- +-- DOMINIO +-- Implementa la operación **"restablecer una fecha al horario regular"** +-- descrita en Documentation/DominioHorarios.md §7.4 ("Un día especial +-- también puede ser restablecido"). Es un cambio estructural §8/§11. +-- +-- Como puede romper reservas existentes (los bloques especiales no +-- compatibles con la plantilla regular vigente quedan sin destino), +-- genera **reservas huérfanas** §7.8: el "match suave" del cuerpo elige +-- qué turnos sobreviven (los compatibles con la plantilla regular) y +-- cuáles caen junto con sus reservas. A partir de esas huérfanas, otro +-- flujo se encarga de la notificación al cliente afectado (§7.9 y §6). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_fecha DATE fecha del día especial a eliminar. Debe ser hoy o +-- posterior. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_horarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No se pueden alterar horarios en fechas pasadas.' +-- - 'No existe un día especial para la fecha X.' +-- +-- RETORNA +-- JSONB con el estado actualizado de la fecha (mismo formato que un día +-- en fc_obtener_horarios). +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en reservas_huerfanas para las reservas que no calzan con la +-- plantilla regular vigente para esa fecha. +-- - DELETE selectivo de turnos especiales que no calzan con la regular. +-- - UPDATE de los turnos que sí calzan: es_especial = false. +-- - INSERT (con ON CONFLICT DO NOTHING) de los turnos faltantes para +-- materializar la plantilla regular en la fecha. +-- - DELETE del registro en dias_especiales (CASCADE limpia +-- horario_actividad_especial asociado). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_eliminar_dia_especial( + p_token UUID, + p_fecha DATE +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_dia_especial_id INT; + v_dia_semana SMALLINT; + v_bloque RECORD; + v_hora_iter TIME; +BEGIN + PERFORM internal.validate_permission(p_token, 'modificar_horarios'); + + IF p_fecha < CURRENT_DATE THEN + RAISE EXCEPTION 'No se pueden alterar horarios en fechas pasadas.'; + END IF; + + SELECT id INTO v_dia_especial_id + FROM dias_especiales + WHERE fecha = p_fecha; + + IF v_dia_especial_id IS NULL THEN + RAISE EXCEPTION 'No existe un día especial para la fecha %.', p_fecha; + END IF; + + v_dia_semana := EXTRACT(ISODOW FROM p_fecha)::SMALLINT; + + ------------------------------------------------------------ + -- 1. Match suave contra la plantilla REGULAR vigente para esta fecha + -- (no contra el JSON: la plantilla regular es la referencia) + ------------------------------------------------------------ + + -- 1.A Rescate de huérfanas: reservas activas sobre turnos que NO calzan + -- con ningún rango de la plantilla regular para esta fecha. + INSERT INTO reservas_huerfanas + (cliente_id, actividad_nombre, fecha_original, hora_inicio_original) + SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + JOIN actividades a ON t.actividad_id = a.id + WHERE t.fecha = p_fecha + AND r.cancelada = false + AND NOT EXISTS ( + SELECT 1 FROM horario_actividad ha + WHERE ha.dia_semana = v_dia_semana + AND ha.actividad_id = t.actividad_id + AND ha.valido_desde <= p_fecha + AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha) + AND t.hora_inicio >= ha.hora_inicio + AND t.hora_fin <= ha.hora_fin + ); + + -- 1.B Borrado selectivo de turnos que no calzan con la plantilla regular. + DELETE FROM turnos t + WHERE t.fecha = p_fecha + AND NOT EXISTS ( + SELECT 1 FROM horario_actividad ha + WHERE ha.dia_semana = v_dia_semana + AND ha.actividad_id = t.actividad_id + AND ha.valido_desde <= p_fecha + AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha) + AND t.hora_inicio >= ha.hora_inicio + AND t.hora_fin <= ha.hora_fin + ); + + -- 1.C Para los turnos que SÍ calzaron, marcar es_especial=false: + -- ya no estamos bajo régimen especial, son turnos regulares ahora. + UPDATE turnos + SET es_especial = false + WHERE fecha = p_fecha + AND es_especial = true; + + -- 1.D Parcheo: materializar los turnos regulares que falten. + FOR v_bloque IN ( + SELECT ha.actividad_id AS act_id, + ha.hora_inicio AS h_in, + ha.hora_fin AS h_out, + a.duracion, a.capacidad_por_defecto + FROM horario_actividad ha + JOIN actividades a ON ha.actividad_id = a.id + WHERE ha.dia_semana = v_dia_semana + AND ha.valido_desde <= p_fecha + AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha) + AND a.activo = true + ) LOOP + v_hora_iter := v_bloque.h_in; + WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP + INSERT INTO turnos + (actividad_id, fecha, hora_inicio, hora_fin, + capacidad_maxima, es_especial, dia_semana) + VALUES ( + v_bloque.act_id, p_fecha, v_hora_iter, + v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL, + v_bloque.capacidad_por_defecto, false, v_dia_semana + ) ON CONFLICT DO NOTHING; + v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL; + END LOOP; + END LOOP; + + ------------------------------------------------------------ + -- 2. Borrar el día especial (CASCADE limpia horario_actividad_especial) + ------------------------------------------------------------ + DELETE FROM dias_especiales WHERE id = v_dia_especial_id; + + ------------------------------------------------------------ + -- 3. Devolver el estado actualizado de la fecha + ------------------------------------------------------------ + RETURN public.fc_obtener_horarios(p_token, p_fecha, 1::smallint); +END; +$function$; diff --git a/database/functions/horarios/fc_insertar_horario_con_actividades.sql b/database/functions/horarios/fc_insertar_horario_con_actividades.sql new file mode 100644 index 0000000..663d439 --- /dev/null +++ b/database/functions/horarios/fc_insertar_horario_con_actividades.sql @@ -0,0 +1,89 @@ +-- ============================================================================ +-- fc_insertar_horario_con_actividades +-- ============================================================================ +-- PROPÓSITO +-- Facade unificada para insertar/actualizar horarios. El frontend llama a +-- esta función sin importar si quiere insertar un horario regular o un +-- horario especial de un día puntual; la función enruta al helper +-- internal correspondiente y devuelve el estado final consolidado. +-- +-- DOMINIO +-- Edición de la estructura del horario: modifica el **horario regular** +-- (Documentation/DominioHorarios.md §7.1) o establece/edita un **día +-- especial** (§7.4). Es un **cambio estructural** en términos de §8 y +-- §11: potencialmente produce **reservas huérfanas** (§7.8) en cadena, +-- y por eso queda sujeto a trazabilidad obligada (§11). +-- +-- El parámetro `alcance` materializa la decisión del operador sobre cómo +-- convive su edición con las planificaciones futuras ya programadas +-- (ver §7.1: el dominio admite que existan planificaciones programadas +-- para el futuro y la edición del horario debe poder pisarlas o +-- respetarlas). Los valores 'indefinido', 'hasta_proximo' y 'hasta' +-- son la implementación concreta de ese alcance. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_datos JSONB con el payload. Claves: +-- fecha DATE (obligatorio) fecha de referencia (la del +-- día especial, o un día representativo del +-- dia_semana para el regular). +-- es_especial BOOLEAN (opcional, default false) si true → especial, +-- si false → regular. +-- valido_desde DATE (sólo para regular, opcional). Default = fecha. +-- alcance OBJECT (sólo para regular). { tipo, fecha? } con +-- tipo en {'indefinido', 'hasta_proximo', 'hasta'}. +-- motivo TEXT (sólo para especial). +-- rangos ARRAY de {actividad_id, hora_inicio, hora_fin}. +-- Para especial: si no se manda o está vacío, +-- el día queda como 'cerrado'. +-- El parseo y las reglas específicas viven en los helpers internal. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_horarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El campo fecha es obligatorio en el nodo raíz del JSON.' +-- - Más los errores propios de los helpers (ver internal.upsert_horario_*). +-- +-- RETORNA +-- JSONB con el estado de la fecha tras la operación, en el mismo formato +-- que un único día de fc_obtener_horarios. +-- +-- EFECTOS SECUNDARIOS +-- Delega a internal.upsert_horario_regular o internal.upsert_horario_especial. +-- ============================================================================ + +-- FACADE para que el frontend llame a la misma función sin importar qué carajo quiere insertar + CREATE OR REPLACE FUNCTION public.fc_insertar_horario_con_actividades(p_token uuid, p_datos jsonb) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' + SET timezone = 'America/Argentina/Buenos_Aires' +AS $function$ +DECLARE + v_fecha DATE; + v_es_especial BOOLEAN; + v_fecha_retorno DATE; +BEGIN + PERFORM internal.validate_permission(p_token, 'modificar_horarios'); + + IF (p_datos->>'fecha') IS NULL THEN + RAISE EXCEPTION 'El campo fecha es obligatorio en el nodo raíz del JSON.'; + END IF; + + v_fecha := (p_datos->>'fecha')::DATE; + v_es_especial := COALESCE((p_datos->>'es_especial')::BOOLEAN, false); + + -- Enrutamiento interno + IF v_es_especial THEN + v_fecha_retorno := internal.upsert_horario_especial(p_datos); + ELSE + v_fecha_retorno := internal.upsert_horario_regular(p_datos); + END IF; + + -- Retornamos el estado final usando la función unificada de lectura + RETURN public.fc_obtener_horarios(p_token, v_fecha_retorno, 1::smallint); +END; +$function$ +; diff --git a/database/functions/horarios/fc_obtener_horarios.sql b/database/functions/horarios/fc_obtener_horarios.sql new file mode 100644 index 0000000..651f9d9 --- /dev/null +++ b/database/functions/horarios/fc_obtener_horarios.sql @@ -0,0 +1,166 @@ +-- ============================================================================ +-- fc_obtener_horarios +-- ============================================================================ +-- PROPÓSITO +-- Devuelve la "agenda" de un rango de días (1 a 31): para cada día +-- indica si es normal, día con horario diferente, o día cerrado, y trae +-- sus rangos horarios con la actividad asociada. +-- +-- Para días normales, también incluye la vigencia (valido_desde, +-- valido_hasta) de la plantilla regular vigente, lo que permite al +-- frontend mostrar contexto temporal de la planificación. +-- +-- DOMINIO +-- Lectura del estado de la oferta del gimnasio para un rango de días +-- (Documentation/DominioHorarios.md §7.1 horario regular + §7.4 día +-- especial). Es la fuente con la que se construye la "grilla del +-- gimnasio" que §13 le exige a la interfaz mostrar al operador. Los tres +-- estados ('normal', 'horario_diferente', 'cerrado') son la +-- materialización de los conceptos §7.1 y §7.4 en términos JSON. No +-- produce efectos; lee el estado actual. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_fecha_inicio DATE primer día del rango. +-- p_cantidad_dias SMALLINT (default 7) cantidad de días a incluir (1..31). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_horarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'p_cantidad_dias debe estar entre 1 y 31.' +-- +-- RETORNA +-- JSONB objeto con clave = fecha (YYYY-MM-DD), valor = objeto día. +-- Cada día tiene: dia_semana, tipo ('normal' | 'horario_diferente' | +-- 'cerrado'), motivo (sólo en especiales), horarios (array de rangos +-- con actividad), y valido_desde/valido_hasta (sólo en 'normal'). +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_horarios( + p_token UUID, + p_fecha_inicio DATE, + p_cantidad_dias SMALLINT DEFAULT 7 +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public +STABLE +LANGUAGE plpgsql +AS $$ +DECLARE + v_fecha DATE; + v_dia_offset INT; + v_dia_semana SMALLINT; + v_resultado JSONB := '{}'::JSONB; + v_dia_json JSONB; + v_especial RECORD; + v_vigencia RECORD; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_horarios'); + + IF p_cantidad_dias < 1 OR p_cantidad_dias > 31 THEN + RAISE EXCEPTION 'p_cantidad_dias debe estar entre 1 y 31.'; + END IF; + + FOR v_dia_offset IN 0..(p_cantidad_dias - 1) LOOP + v_fecha := p_fecha_inicio + v_dia_offset; + v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT; + + SELECT id, tipo, motivo + INTO v_especial + FROM dias_especiales + WHERE fecha = v_fecha; + + IF FOUND AND v_especial.tipo = 'cerrado' THEN + ---------------------------------------------------- + -- CASO 1: Día cerrado + ---------------------------------------------------- + v_dia_json := jsonb_build_object( + 'dia_semana', v_dia_semana, + 'tipo', 'cerrado', + 'motivo', v_especial.motivo, + 'horarios', '[]'::JSONB + ); + + ELSIF FOUND AND v_especial.tipo = 'horario_diferente' THEN + ---------------------------------------------------- + -- CASO 2: Día con horario especial (override puntual) + ---------------------------------------------------- + v_dia_json := jsonb_build_object( + 'dia_semana', v_dia_semana, + 'tipo', 'horario_diferente', + 'motivo', v_especial.motivo, + 'horarios', ( + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', hae.id, + 'hora_inicio', to_char(hae.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(hae.hora_fin, 'HH24:MI'), + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'duracion', a.duracion, + 'capacidad', a.capacidad_por_defecto + ) + ) ORDER BY hae.hora_inicio ASC + ), '[]'::JSONB) + FROM horario_actividad_especial hae + JOIN actividades a ON hae.actividad_id = a.id + WHERE hae.dia_especial_id = v_especial.id + AND a.activo = true + ) + ); + + ELSE + ---------------------------------------------------- + -- CASO 3: Día normal: plantilla regular vigente esa fecha + -- Adicionalmente devolvemos la vigencia para que el + -- frontend pueda renderizar contexto temporal. + ---------------------------------------------------- + SELECT valido_desde, valido_hasta + INTO v_vigencia + FROM horario_actividad + WHERE dia_semana = v_dia_semana + AND valido_desde <= v_fecha + AND (valido_hasta IS NULL OR valido_hasta >= v_fecha) + LIMIT 1; -- Todas las filas de la plantilla vigente comparten la misma vigencia. + + v_dia_json := jsonb_build_object( + 'dia_semana', v_dia_semana, + 'tipo', 'normal', + 'valido_desde', v_vigencia.valido_desde::TEXT, + 'valido_hasta', v_vigencia.valido_hasta::TEXT, + 'horarios', ( + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', ha.id, + 'hora_inicio', to_char(ha.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(ha.hora_fin, 'HH24:MI'), + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'duracion', a.duracion, + 'capacidad', a.capacidad_por_defecto + ) + ) ORDER BY ha.hora_inicio ASC + ), '[]'::JSONB) + FROM horario_actividad ha + JOIN actividades a ON ha.actividad_id = a.id + WHERE ha.dia_semana = v_dia_semana + AND a.activo = true + AND ha.valido_desde <= v_fecha + AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= v_fecha) + ) + ); + END IF; + + v_resultado := v_resultado || jsonb_build_object(v_fecha::TEXT, v_dia_json); + END LOOP; + + RETURN v_resultado; +END; +$$; diff --git a/database/functions/horarios/fc_obtener_horarios_especiales.sql b/database/functions/horarios/fc_obtener_horarios_especiales.sql new file mode 100644 index 0000000..dee0265 --- /dev/null +++ b/database/functions/horarios/fc_obtener_horarios_especiales.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- fc_obtener_horarios_especiales +-- ============================================================================ +-- PROPÓSITO +-- Devuelve los días con horario especial (sea cerrado o con horario +-- diferente) configurados. Opcionalmente filtra por una fecha puntual. +-- Pensada para que el frontend liste y muestre el detalle de los días +-- especiales programados. +-- +-- DOMINIO +-- Consulta del catálogo de **días especiales** programados +-- (Documentation/DominioHorarios.md §7.4). Sirve a la vista de +-- administración de excepciones del horario regular. No describe el +-- detalle del estado de cada fecha en su grilla diaria (eso es trabajo +-- de fc_obtener_horarios): acá lo que se trae es el listado de las +-- excepciones como tales. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_fecha DATE (opcional, default NULL) si se pasa, sólo retorna el +-- día especial para esa fecha. NULL = todos. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_horarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío) de días especiales. Cada ítem: +-- { fecha, tipo, motivo, rangos_actividades: [{id, hora_inicio, +-- hora_fin, actividad}] }. Ordenado por fecha descendente. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_horarios_especiales( + p_token UUID, + p_fecha DATE DEFAULT NULL +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public +STABLE +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_horarios'); + + RETURN ( + SELECT COALESCE(jsonb_agg(dia_completo ORDER BY dia_completo->>'fecha' DESC), '[]'::jsonb) + FROM ( + SELECT + de.fecha, + de.tipo, + de.motivo, + -- Si el día es especial (no cerrado), adjuntamos sus actividades + COALESCE( + (SELECT jsonb_agg( + jsonb_build_object( + 'id', hae.id, + 'hora_inicio', to_char(hae.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(hae.hora_fin, 'HH24:MI'), + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'duracion', a.duracion + ) + ) ORDER BY hae.hora_inicio ASC + ) + FROM public.horario_actividad_especial hae + JOIN public.actividades a ON hae.actividad_id = a.id + WHERE hae.dia_especial_id = de.id AND a.activo = true), + '[]'::jsonb + ) as rangos_actividades + FROM public.dias_especiales de + WHERE (p_fecha IS NULL OR de.fecha = p_fecha) + ) AS dia_completo + ); +END; +$$; diff --git a/database/functions/horarios/fc_obtener_planificaciones_futuras.sql b/database/functions/horarios/fc_obtener_planificaciones_futuras.sql new file mode 100644 index 0000000..8bfb382 --- /dev/null +++ b/database/functions/horarios/fc_obtener_planificaciones_futuras.sql @@ -0,0 +1,110 @@ +-- ============================================================================ +-- fc_obtener_planificaciones_futuras +-- ============================================================================ +-- PROPÓSITO +-- Devuelve las plantillas horarias regulares programadas hacia el futuro +-- para un día de la semana específico, dentro de un horizonte temporal +-- (default 6 meses). Cada plantilla es un grupo de rangos horarios con +-- vigencia común (valido_desde, valido_hasta). +-- +-- DOMINIO +-- Sirve a la noción del dominio de **cambio de horario programado** +-- (Documentation/DominioHorarios.md §7.1: "el operador puede dejar +-- programado un cambio para que entre en vigencia desde una fecha +-- futura"). La función habilita al frontend a mostrarle al operador el +-- mapa de planificaciones futuras antes de editar un horario, para que +-- pueda decidir el alcance de su edición (pisar las planificaciones +-- futuras, o respetar la más próxima como tope de la suya). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_dia_semana SMALLINT día de la semana ISO (1=Lunes, 7=Domingo). +-- p_desde DATE fecha de referencia; trae plantillas con +-- valido_desde estrictamente posterior a esta. +-- p_meses INT (default 6) horizonte en meses desde p_desde. +-- Rango válido 1..24. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_horarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'p_dia_semana debe estar entre 1 y 7 (recibido: X).' +-- - 'p_meses debe estar entre 1 y 24 (recibido: X).' +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: { valido_desde, valido_hasta, +-- rangos: [{id, hora_inicio, hora_fin, actividad}] }. Ordenado por +-- valido_desde ascendente. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_planificaciones_futuras(p_token uuid, p_dia_semana smallint, p_desde date, p_meses integer DEFAULT 6) + RETURNS jsonb + LANGUAGE plpgsql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_resultado JSONB; + v_hasta DATE; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_horarios'); + + IF p_dia_semana < 1 OR p_dia_semana > 7 THEN + RAISE EXCEPTION 'p_dia_semana debe estar entre 1 y 7 (recibido: %).', p_dia_semana; + END IF; + + IF p_meses < 1 OR p_meses > 24 THEN + RAISE EXCEPTION 'p_meses debe estar entre 1 y 24 (recibido: %).', p_meses; + END IF; + + v_hasta := p_desde + (p_meses || ' months')::INTERVAL; + + -- Agrupamos por (valido_desde, valido_hasta): cada grupo es UNA plantilla + -- futura con sus N rangos horarios. Solo traemos plantillas cuyo + -- valido_desde es estrictamente posterior a p_desde y cae dentro del + -- horizonte (p_desde + p_meses). + WITH plantillas AS ( + SELECT + ha.valido_desde, + ha.valido_hasta, + jsonb_agg( + jsonb_build_object( + 'id', ha.id, + 'hora_inicio', to_char(ha.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(ha.hora_fin, 'HH24:MI'), + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'duracion', a.duracion, + 'capacidad', a.capacidad_por_defecto + ) + ) ORDER BY ha.hora_inicio ASC, a.nombre ASC + ) AS rangos + FROM horario_actividad ha + JOIN actividades a ON ha.actividad_id = a.id + WHERE ha.dia_semana = p_dia_semana + AND ha.valido_desde > p_desde + AND ha.valido_desde <= v_hasta + AND a.activo = true + GROUP BY ha.valido_desde, ha.valido_hasta + ) + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'valido_desde', valido_desde::TEXT, + 'valido_hasta', valido_hasta::TEXT, + 'rangos', rangos + ) ORDER BY valido_desde ASC + ), + '[]'::JSONB + ) + INTO v_resultado + FROM plantillas; + + RETURN v_resultado; +END; +$function$ +; diff --git a/database/functions/horarios/internal/limpiar_dias_especiales_antiguos.sql b/database/functions/horarios/internal/limpiar_dias_especiales_antiguos.sql new file mode 100644 index 0000000..14d061d --- /dev/null +++ b/database/functions/horarios/internal/limpiar_dias_especiales_antiguos.sql @@ -0,0 +1,79 @@ +-- ============================================================================ +-- internal.limpiar_dias_especiales_antiguos +-- ============================================================================ +-- PROPÓSITO +-- Higiene operativa: elimina filas de public.dias_especiales cuya +-- `fecha` esté fuera de la ventana de retención. Un día especial es +-- una excepción puntual al régimen regular (cerrado u horario +-- diferente); una vez pasado, no tiene valor operativo ni se archiva. +-- +-- DOMINIO +-- Implementa la higiene de días especiales descrita en +-- Documentation/PoliticaRetencion.md §7. La ventana es corta (1 mes +-- por default) porque un día especial sólo importa mientras condiciona +-- la generación de turnos; vencido el mes, ya no afecta nada vigente. +-- +-- PARÁMETROS +-- Ninguno. La ventana en meses se lee de `internal.app_config` con la +-- clave `retencion.dias_especiales_meses` (default 1). El ajuste +-- operativo se hace en la tabla, sin redeploy. +-- +-- AUTORIZACIÓN +-- No valida permiso propio. El schema `internal` no es alcanzable por +-- roles cliente (ver hardening en `database/schema/00_schemas.sql`). +-- Callers legítimos: pg_cron y fachadas `public.fc_*` SECURITY DEFINER +-- que orquesten el pipeline mensual (US-R17). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB con {status, fecha_corte, meses_conservados, +-- dias_eliminados, mensaje}. +-- +-- EFECTOS SECUNDARIOS +-- DELETE de dias_especiales con fecha < (CURRENT_DATE - ventana). Las +-- filas de horario_actividad_especial asociadas caen por CASCADE +-- (horario_actividad_especial_dia_fkey ON DELETE CASCADE). No registra +-- evento. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.limpiar_dias_especiales_antiguos() +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $$ +DECLARE + v_meses INT; + v_fecha_limite DATE; + v_eliminados INT; +BEGIN + v_meses := internal.get_config_int('retencion.dias_especiales_meses', 1); + v_fecha_limite := CURRENT_DATE - (v_meses * INTERVAL '1 month'); + + DELETE FROM dias_especiales + WHERE fecha < v_fecha_limite; + + GET DIAGNOSTICS v_eliminados = ROW_COUNT; + + RETURN jsonb_build_object( + 'status', 'success', + 'fecha_corte', v_fecha_limite, + 'meses_conservados', v_meses, + 'dias_eliminados', v_eliminados, + 'mensaje', format( + 'Se eliminaron %s días especiales anteriores a %s (rangos horarios asociados eliminados por CASCADE).', + v_eliminados, v_fecha_limite + ) + ); +END; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.limpiar_dias_especiales_antiguos() + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/horarios/internal/upsert_horario_especial.sql b/database/functions/horarios/internal/upsert_horario_especial.sql new file mode 100644 index 0000000..6fbe615 --- /dev/null +++ b/database/functions/horarios/internal/upsert_horario_especial.sql @@ -0,0 +1,144 @@ +-- ============================================================================ +-- internal.upsert_horario_especial +-- ============================================================================ +-- PROPÓSITO +-- Helper interno invocado por fc_insertar_horario_con_actividades cuando +-- el payload corresponde a un día con horario especial (override puntual +-- o cierre). Crea/actualiza el registro en dias_especiales y materializa +-- o limpia los turnos en consecuencia. +-- +-- Si rangos[] viene vacío o no viene → el día queda como 'cerrado'. +-- Si trae rangos → el día queda como 'horario_diferente'. +-- +-- PARÁMETROS +-- p_datos JSONB con: fecha (obligatorio), motivo?, rangos[]?. +-- +-- AUTORIZACIÓN +-- No valida permiso propio (lo hizo la fachada antes de invocar). +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No se pueden alterar horarios en fechas pasadas.' +-- +-- RETORNA +-- DATE = la fecha del día especial. +-- +-- EFECTOS SECUNDARIOS +-- - UPSERT en dias_especiales (ON CONFLICT (fecha) DO UPDATE). +-- - DELETE de horario_actividad_especial previo (reemplazo). +-- - Si tipo='horario_diferente': INSERT de los rangos nuevos en +-- horario_actividad_especial. +-- - Si la fecha tenía turnos materializados: +-- - tipo='cerrado': rescata TODAS las reservas activas como huérfanas +-- y borra todos los turnos del día. +-- - tipo='horario_diferente': rescate selectivo + delete selectivo + +-- parcheo JIT (mismo patrón que upsert_horario_regular). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.upsert_horario_especial( + p_datos JSONB +) +RETURNS DATE +LANGUAGE plpgsql +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_fecha DATE; + v_dia_semana SMALLINT; + v_tipo VARCHAR(25); + v_motivo TEXT; + v_dia_especial_id INT; + v_turnos_generados BOOLEAN; + v_bloque RECORD; + v_hora_iter TIME; +BEGIN + v_fecha := (p_datos->>'fecha')::DATE; + v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT; + + + IF v_fecha < (now() AT TIME ZONE 'America/Argentina/Buenos_Aires')::DATE THEN + RAISE EXCEPTION 'No se pueden alterar horarios en fechas pasadas.'; + END IF; + + v_motivo := p_datos->>'motivo'; + + IF (p_datos->'rangos') IS NULL OR jsonb_typeof(p_datos->'rangos') <> 'array' OR jsonb_array_length(p_datos->'rangos') = 0 THEN + v_tipo := 'cerrado'; + ELSE + v_tipo := 'horario_diferente'; + END IF; + + -- 1. Upsert del día especial + INSERT INTO dias_especiales (fecha, tipo, motivo) + VALUES (v_fecha, v_tipo, v_motivo) + ON CONFLICT (fecha) DO UPDATE + SET tipo = EXCLUDED.tipo, motivo = EXCLUDED.motivo + RETURNING id INTO v_dia_especial_id; + + DELETE FROM horario_actividad_especial WHERE dia_especial_id = v_dia_especial_id; + + IF v_tipo = 'horario_diferente' THEN + INSERT INTO horario_actividad_especial (dia_especial_id, actividad_id, hora_inicio, hora_fin) + SELECT DISTINCT v_dia_especial_id, (rango->>'actividad_id')::INT, (rango->>'hora_inicio')::TIME, (rango->>'hora_fin')::TIME + FROM jsonb_array_elements(p_datos->'rangos') AS rango; + END IF; + + -- 2. Análisis del estado JIT + SELECT EXISTS (SELECT 1 FROM turnos WHERE fecha = v_fecha) INTO v_turnos_generados; + + IF v_turnos_generados THEN + IF v_tipo = 'cerrado' THEN + -- Rescate total + INSERT INTO reservas_huerfanas (cliente_id, actividad_nombre, fecha_original, hora_inicio_original) + SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio + FROM reservas r JOIN turnos t ON r.turno_id = t.id JOIN actividades a ON t.actividad_id = a.id + WHERE t.fecha = v_fecha AND r.cancelada = false; + + -- Borrado total + DELETE FROM turnos WHERE fecha = v_fecha; + ELSE + -- Rescate parcial (Match Suave) + INSERT INTO reservas_huerfanas (cliente_id, actividad_nombre, fecha_original, hora_inicio_original) + SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio + FROM reservas r JOIN turnos t ON r.turno_id = t.id JOIN actividades a ON t.actividad_id = a.id + WHERE t.fecha = v_fecha + AND r.cancelada = false + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng + WHERE (rng->>'actividad_id')::INT = t.actividad_id + AND t.hora_inicio >= (rng->>'hora_inicio')::TIME + AND t.hora_fin <= (rng->>'hora_fin')::TIME + ); + + -- Borrado parcial + DELETE FROM turnos t + WHERE t.fecha = v_fecha + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng + WHERE (rng->>'actividad_id')::INT = t.actividad_id + AND t.hora_inicio >= (rng->>'hora_inicio')::TIME + AND t.hora_fin <= (rng->>'hora_fin')::TIME + ); + + -- Parcheo JIT + FOR v_bloque IN ( + SELECT (r->>'actividad_id')::INT AS act_id, (r->>'hora_inicio')::TIME AS h_in, (r->>'hora_fin')::TIME AS h_out, + a.duracion, a.capacidad_por_defecto + FROM jsonb_array_elements(p_datos->'rangos') AS r + JOIN actividades a ON a.id = (r->>'actividad_id')::INT + ) LOOP + v_hora_iter := v_bloque.h_in; + WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP + INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin, capacidad_maxima, es_especial, dia_semana) + VALUES (v_bloque.act_id, v_fecha, v_hora_iter, v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL, v_bloque.capacidad_por_defecto, true, v_dia_semana) + ON CONFLICT DO NOTHING; + v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL; + END LOOP; + END LOOP; + END IF; + END IF; + + RETURN v_fecha; +END; +$function$; diff --git a/database/functions/horarios/internal/upsert_horario_regular.sql b/database/functions/horarios/internal/upsert_horario_regular.sql new file mode 100644 index 0000000..2c8ecde --- /dev/null +++ b/database/functions/horarios/internal/upsert_horario_regular.sql @@ -0,0 +1,228 @@ +-- ============================================================================ +-- internal.upsert_horario_regular +-- ============================================================================ +-- PROPÓSITO +-- Helper interno invocado por fc_insertar_horario_con_actividades cuando +-- el payload corresponde a un horario regular (semanal). Reemplaza / +-- crea la plantilla horaria del día de la semana para una vigencia +-- declarada, cierra plantillas previas que se solapan y sincroniza los +-- turnos JIT ya materializados. +-- +-- PARÁMETROS +-- p_datos JSONB con (todos los campos parseados acá; ver fachada para +-- descripción completa): fecha, valido_desde?, alcance.{tipo, +-- fecha?}, rangos[]. +-- +-- AUTORIZACIÓN +-- No valida permiso propio (lo hizo la fachada antes de invocar). +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'valido_desde no puede ser una fecha pasada.' +-- - 'alcance.tipo inválido: X. Debe ser ''indefinido'', ''hasta_proximo'' +-- o ''hasta''.' +-- - 'alcance.fecha es obligatorio cuando alcance.tipo = ''hasta''.' +-- - 'alcance.fecha (X) no puede ser anterior a valido_desde (Y).' +-- - 'Conflicto: existe una planificación con valido_desde = X dentro +-- del intervalo [Y, Z]. Modificá el alcance o eliminá la planificación +-- previamente.' +-- +-- RETORNA +-- DATE = valido_desde efectivamente aplicado. +-- +-- EFECTOS SECUNDARIOS +-- - Según el alcance: borra plantillas futuras del mismo día_semana, o +-- deja vigencia abierta, o cierra contra la próxima futura. +-- - DELETE de plantilla con valido_desde exactamente igual (reemplazo). +-- - UPDATE de plantillas anteriores que se solapan: valido_hasta = +-- valido_desde - 1. +-- - INSERT de los rangos nuevos en horario_actividad. +-- - Para las fechas afectadas con turnos ya materializados: rescate de +-- huérfanas + delete selectivo + parcheo JIT (mismo patrón que usan +-- fc_eliminar_dia_especial e internal.cerrar_actividad). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.upsert_horario_regular( + p_datos JSONB +) +RETURNS DATE +LANGUAGE plpgsql +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_fecha DATE; + v_dia_semana SMALLINT; + v_valido_desde DATE; + v_alcance_tipo TEXT; + v_alcance_fecha DATE; + v_valido_hasta_nuevo DATE; + v_fecha_conflicto DATE; + v_fechas_afectadas DATE[]; + v_f DATE; + v_bloque RECORD; + v_hora_iter TIME; +BEGIN + ------------------------------------------------------------ + -- 0. Parseo y validación de entrada + ------------------------------------------------------------ + v_fecha := (p_datos->>'fecha')::DATE; + v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT; + v_valido_desde := COALESCE((p_datos->>'valido_desde')::DATE, v_fecha); + + IF v_valido_desde < (now() AT TIME ZONE 'America/Argentina/Buenos_Aires')::DATE THEN + RAISE EXCEPTION 'valido_desde no puede ser una fecha pasada.'; + END IF; + + v_alcance_tipo := COALESCE(p_datos->'alcance'->>'tipo', 'hasta_proximo'); + + IF v_alcance_tipo NOT IN ('indefinido', 'hasta_proximo', 'hasta') THEN + RAISE EXCEPTION + 'alcance.tipo inválido: %. Debe ser ''indefinido'', ''hasta_proximo'' o ''hasta''.', + v_alcance_tipo; + END IF; + + IF v_alcance_tipo = 'hasta' THEN + v_alcance_fecha := (p_datos->'alcance'->>'fecha')::DATE; + IF v_alcance_fecha IS NULL THEN + RAISE EXCEPTION 'alcance.fecha es obligatorio cuando alcance.tipo = ''hasta''.'; + END IF; + IF v_alcance_fecha < v_valido_desde THEN + RAISE EXCEPTION + 'alcance.fecha (%) no puede ser anterior a valido_desde (%).', + v_alcance_fecha, v_valido_desde; + END IF; + END IF; + + ------------------------------------------------------------ + -- 1. Resolver el valido_hasta del nuevo intervalo según el alcance + ------------------------------------------------------------ + IF v_alcance_tipo = 'indefinido' THEN + -- Purga todas las plantillas futuras del mismo dia_semana + DELETE FROM horario_actividad + WHERE dia_semana = v_dia_semana + AND valido_desde > v_valido_desde; + v_valido_hasta_nuevo := NULL; + + ELSIF v_alcance_tipo = 'hasta_proximo' THEN + -- Cerrar antes del próximo futuro, o quedar abierto si no hay + SELECT MIN(valido_desde) - 1 + INTO v_valido_hasta_nuevo + FROM horario_actividad + WHERE dia_semana = v_dia_semana + AND valido_desde > v_valido_desde; + + ELSE -- 'hasta' + -- Validar que no haya futuros dentro del intervalo solicitado + SELECT MIN(valido_desde) + INTO v_fecha_conflicto + FROM horario_actividad + WHERE dia_semana = v_dia_semana + AND valido_desde > v_valido_desde + AND valido_desde <= v_alcance_fecha; + + IF v_fecha_conflicto IS NOT NULL THEN + RAISE EXCEPTION + 'Conflicto: existe una planificación con valido_desde = % dentro del intervalo [%, %]. Modificá el alcance o eliminá la planificación previamente.', + v_fecha_conflicto, v_valido_desde, v_alcance_fecha; + END IF; + v_valido_hasta_nuevo := v_alcance_fecha; + END IF; + + ------------------------------------------------------------ + -- 2. Limpieza estructural en horario_actividad + ------------------------------------------------------------ + -- 2.A Colisión exacta: si ya existía una plantilla con el mismo valido_desde, + -- la borramos para reemplazarla con los rangos nuevos. + DELETE FROM horario_actividad + WHERE dia_semana = v_dia_semana + AND valido_desde = v_valido_desde; + + -- 2.B Cerrar la(s) plantilla(s) anterior(es) que se solapen con el nuevo desde. + UPDATE horario_actividad + SET valido_hasta = v_valido_desde - 1 + WHERE dia_semana = v_dia_semana + AND valido_desde < v_valido_desde + AND (valido_hasta IS NULL OR valido_hasta >= v_valido_desde); + + -- 2.C Insertar los rangos nuevos con la vigencia ya resuelta. + INSERT INTO horario_actividad + (dia_semana, actividad_id, hora_inicio, hora_fin, valido_desde, valido_hasta) + SELECT DISTINCT + v_dia_semana, + (rango->>'actividad_id')::INT, + (rango->>'hora_inicio')::TIME, + (rango->>'hora_fin')::TIME, + v_valido_desde, + v_valido_hasta_nuevo + FROM jsonb_array_elements(p_datos->'rangos') AS rango; + + ------------------------------------------------------------ + -- 3. Match suave sobre turnos JIT ya materializados + -- Acotado al intervalo efectivo del nuevo (fix del bug viejo: + -- no tocar turnos que pertenezcan a futuros que se respetan). + ------------------------------------------------------------ + SELECT array_agg(DISTINCT fecha) + INTO v_fechas_afectadas + FROM turnos + WHERE dia_semana = v_dia_semana + AND fecha >= v_valido_desde + AND fecha <= COALESCE(v_valido_hasta_nuevo, 'infinity'::date); + + IF v_fechas_afectadas IS NOT NULL THEN + -- 3.A Rescate de huérfanas + INSERT INTO reservas_huerfanas + (cliente_id, actividad_nombre, fecha_original, hora_inicio_original) + SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + JOIN actividades a ON t.actividad_id = a.id + WHERE t.fecha = ANY(v_fechas_afectadas) + AND r.cancelada = false + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng + WHERE (rng->>'actividad_id')::INT = t.actividad_id + AND t.hora_inicio >= (rng->>'hora_inicio')::TIME + AND t.hora_fin <= (rng->>'hora_fin')::TIME + ); + + -- 3.B Borrado selectivo de turnos que ya no calzan con los rangos nuevos + DELETE FROM turnos t + WHERE t.fecha = ANY(v_fechas_afectadas) + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng + WHERE (rng->>'actividad_id')::INT = t.actividad_id + AND t.hora_inicio >= (rng->>'hora_inicio')::TIME + AND t.hora_fin <= (rng->>'hora_fin')::TIME + ); + + -- 3.C Parcheo JIT iterativo para esas fechas + FOREACH v_f IN ARRAY v_fechas_afectadas LOOP + FOR v_bloque IN ( + SELECT (r->>'actividad_id')::INT AS act_id, + (r->>'hora_inicio')::TIME AS h_in, + (r->>'hora_fin')::TIME AS h_out, + a.duracion, a.capacidad_por_defecto + FROM jsonb_array_elements(p_datos->'rangos') AS r + JOIN actividades a ON a.id = (r->>'actividad_id')::INT + ) LOOP + v_hora_iter := v_bloque.h_in; + WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP + INSERT INTO turnos + (actividad_id, fecha, hora_inicio, hora_fin, + capacidad_maxima, es_especial, dia_semana) + VALUES ( + v_bloque.act_id, v_f, v_hora_iter, + v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL, + v_bloque.capacidad_por_defecto, false, v_dia_semana + ) + ON CONFLICT DO NOTHING; + v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL; + END LOOP; + END LOOP; + END LOOP; + END IF; + + RETURN v_valido_desde; +END; +$function$; diff --git a/database/functions/pagos/fc_anular_pago.sql b/database/functions/pagos/fc_anular_pago.sql new file mode 100644 index 0000000..40e6021 --- /dev/null +++ b/database/functions/pagos/fc_anular_pago.sql @@ -0,0 +1,179 @@ +-- ============================================================================ +-- fc_anular_pago +-- ============================================================================ +-- PROPÓSITO +-- Anula un pago previamente registrado (soft-delete). El pago sigue +-- existiendo y consultable, marcado con quién y cuándo lo anuló, y un +-- motivo opcional. Genera un evento auditable. +-- +-- DOMINIO +-- Implementa la operación "anular" descrita en +-- Documentation/DominioPagos.md §5.4 y §8. La anulación es terminal: +-- un pago anulado no se desanula ni se vuelve a corregir; si la +-- situación cambia se registra un pago nuevo (§5.4). Por ser +-- transparente (visible + auditable), el rol con autoridad plena puede +-- anular incluso fuera de la ventana de corrección (§9). +-- +-- ARCHIVADO (US-R11) +-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable +-- (DominioPagos §14): se rechaza la anulación antes de cualquier otra +-- regla, incluso para la autoridad plena. El criterio lo resuelve +-- internal.pagos_mes_archivado (consulta internal.archivo_exports). +-- +-- PARÁMETROS +-- p_id UUID del pago a anular. +-- p_motivo TEXT motivo libre. NULL, cadena vacía o sólo whitespace se +-- persisten como NULL para no contaminar el campo. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'agregar_pagos'. +-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y +-- estar dentro de la ventana de corrección. +-- - Con 'gestionar_cualquier_pago': sin restricción de ownership ni +-- ventana. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Sesión inválida o usuario no encontrado.' +-- - 'No se encontró el pago.' +-- - 'Pago archivado. No se puede modificar.' (US-R11) +-- - 'Este pago ya estaba anulado.' +-- - 'No tenés permiso para anular este pago.' +-- - 'La ventana de anulación de este pago ya venció.' +-- +-- RETORNA +-- JSONB con el pago ya anulado, mismo shape que un ítem de +-- fc_obtener_pagos. +-- +-- EFECTOS SECUNDARIOS +-- - Setea anulado_at, anulado_por y motivo_anulacion del pago. +-- - Registra evento 'pago_anulado' en public.eventos con un snapshot +-- mínimo del pago (tipo, monto, mes, método, plan) como valor_anterior +-- y el motivo como descripción. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_anular_pago( + p_id UUID, + p_motivo TEXT, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE +AS $function$ +DECLARE + v_actor_id UUID; + v_actor_rol TEXT; + v_has_gestion BOOLEAN; + v_ventana_minutos INT; + v_pago public.pagos%ROWTYPE; + v_motivo_limpio TEXT; + v_snapshot_min JSONB; +BEGIN + PERFORM internal.validate_permission(p_token, 'agregar_pagos'); + + SELECT s.usuario_id, u.rol + INTO v_actor_id, v_actor_rol + FROM internal.sesiones s + JOIN usuarios u ON u.id = s.usuario_id + WHERE s.token = p_token; + + IF v_actor_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + SELECT EXISTS ( + SELECT 1 FROM internal.permisos + WHERE accion = 'gestionar_cualquier_pago' + AND v_actor_rol = ANY(roles_permitidos) + ) INTO v_has_gestion; + + SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'No se encontró el pago.'; + END IF; + + -- Pago archivado: inmutable (US-R11, DominioPagos §14). Bloquea incluso a + -- la autoridad plena, que normalmente puede anular fuera de ventana: una + -- vez en el archivo, anular descoordinaría base y XLSX. Se chequea antes + -- que el resto porque es la condición de inmutabilidad más fuerte. + IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN + RAISE EXCEPTION 'Pago archivado. No se puede modificar.'; + END IF; + + IF v_pago.anulado_at IS NOT NULL THEN + RAISE EXCEPTION 'Este pago ya estaba anulado.'; + END IF; + + IF NOT v_has_gestion THEN + IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN + RAISE EXCEPTION 'No tenés permiso para anular este pago.'; + END IF; + v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30); + IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN + RAISE EXCEPTION 'La ventana de anulación de este pago ya venció.'; + END IF; + END IF; + + v_motivo_limpio := NULLIF(trim(p_motivo), ''); + + UPDATE public.pagos + SET anulado_at = now(), + anulado_por = v_actor_id, + motivo_anulacion = v_motivo_limpio + WHERE id = p_id; + + v_snapshot_min := jsonb_build_object( + 'tipo', v_pago.tipo, + 'monto_total', v_pago.monto_total, + 'anio_mes_pagado', v_pago.anio_mes_pagado, + 'metodo_id', v_pago.metodo_id, + 'tipo_cuota_id', v_pago.tipo_cuota_id + ); + + PERFORM internal.log_evento( + p_tipo => 'pago_anulado', + p_tabla => 'pagos', + p_referencia_id => p_id, + p_cliente_id => v_pago.cliente_id, + p_actor_id => v_actor_id, + p_valor_anterior => v_snapshot_min, + p_valor_actual => NULL, + p_descripcion => v_motivo_limpio + ); + + RETURN ( + SELECT jsonb_build_object( + 'id', p.id, + 'tipo', p.tipo, + 'anio_mes_pagado', p.anio_mes_pagado, + 'fecha_pago', p.fecha_pago, + 'monto_total', p.monto_total, + 'detalle', p.detalle, + 'metodo', m.descripcion, + 'created_at', p.created_at, + 'created_by', p.created_by, + 'created_by_nombre', cu.nombre, + 'updated_at', p.updated_at, + 'updated_by_nombre', uu.nombre, + 'anulado_at', p.anulado_at, + 'anulado_por_nombre', au.nombre, + 'motivo_anulacion', p.motivo_anulacion, + 'cliente', jsonb_build_object( + 'nombre', cl.nombre, + 'apellido', cl.apellido, + 'dni', cl.dni + ) + ) + FROM public.pagos p + JOIN public.usuarios cl ON p.cliente_id = cl.id + LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id + LEFT JOIN public.usuarios cu ON p.created_by = cu.id + LEFT JOIN public.usuarios uu ON p.updated_by = uu.id + LEFT JOIN public.usuarios au ON p.anulado_por = au.id + WHERE p.id = p_id + ); +END; +$function$; diff --git a/database/functions/pagos/fc_editar_pago.sql b/database/functions/pagos/fc_editar_pago.sql new file mode 100644 index 0000000..36fa6d0 --- /dev/null +++ b/database/functions/pagos/fc_editar_pago.sql @@ -0,0 +1,272 @@ +-- ============================================================================ +-- fc_editar_pago +-- ============================================================================ +-- PROPÓSITO +-- Corrige un pago previamente registrado, dentro de la ventana de +-- corrección. Genera un evento auditable con los cambios reales producidos. +-- +-- DOMINIO +-- Implementa la operación "corregir" descrita en +-- Documentation/DominioPagos.md §5.3 y §8. La ventana aplica a TODOS los +-- actores (incluso al rol con autoridad plena): pasada la ventana, la +-- única vía para enmendar un pago es anular y registrar uno nuevo (§9). +-- +-- ARCHIVADO (US-R11) +-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable +-- (DominioPagos §14): se rechaza la edición antes de cualquier otra +-- regla. El criterio de "archivado" lo resuelve +-- internal.pagos_mes_archivado (consulta internal.archivo_exports). +-- +-- PARÁMETROS +-- p_id UUID del pago a editar. +-- p_datos JSONB con los campos a actualizar. Semántica PATCH: sólo se +-- modifican las claves presentes en el JSON. Editables: +-- metodo_id, anio_mes_pagado, fecha_pago, monto_total, +-- detalle, tipo_cuota_id. cliente_id y tipo NO son editables. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'agregar_pagos'. +-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y estar +-- dentro de la ventana de corrección. +-- - Con 'gestionar_cualquier_pago': sin restricción de ownership, pero +-- igualmente dentro de la ventana. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Sesión inválida o usuario no encontrado.' +-- - 'No se encontró el pago.' +-- - 'Pago archivado. No se puede modificar.' (US-R11) +-- - 'No se puede editar un pago anulado.' +-- - 'Sólo se pueden editar pagos de tipo cuota_mensual' +-- - 'No tenés permiso para editar este pago.' +-- - 'La ventana de edición de este pago ya venció.' +-- - 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.' +-- - 'tipo no es editable. Anulá el pago y cargá uno nuevo.' +-- - 'metodo_id no puede ser nulo en un pago de cuota_mensual.' +-- - 'El método de pago ID X no existe o no está activo.' +-- - 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.' +-- - 'El monto total es inválido para un pago de cuota_mensual.' +-- - 'El tipo de cuota ID X no existe.' +-- +-- RETORNA +-- JSONB con el pago actualizado, mismo shape que un ítem de +-- fc_obtener_pagos. +-- +-- EFECTOS SECUNDARIOS +-- - UPDATE de la fila en public.pagos, setea updated_at = now() y +-- updated_by = actor. +-- - Registra evento 'pago_editado' en public.eventos con el diff campo +-- a campo, SÓLO si hubo cambios reales. Las ediciones no-op no generan +-- evento. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_editar_pago( + p_id UUID, + p_datos JSONB, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE +AS $function$ +DECLARE + v_actor_id UUID; + v_actor_rol TEXT; + v_has_gestion BOOLEAN; + v_ventana_minutos INT; + v_pago public.pagos%ROWTYPE; + v_after public.pagos%ROWTYPE; + v_diff_anterior JSONB := '{}'::jsonb; + v_diff_actual JSONB := '{}'::jsonb; + v_new_metodo_id SMALLINT; + v_new_anio_mes DATE; + v_new_tipo_cuota UUID; + v_new_monto NUMERIC; +BEGIN + PERFORM internal.validate_permission(p_token, 'agregar_pagos'); + + SELECT s.usuario_id, u.rol + INTO v_actor_id, v_actor_rol + FROM internal.sesiones s + JOIN usuarios u ON u.id = s.usuario_id + WHERE s.token = p_token; + + IF v_actor_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + SELECT EXISTS ( + SELECT 1 FROM internal.permisos + WHERE accion = 'gestionar_cualquier_pago' + AND v_actor_rol = ANY(roles_permitidos) + ) INTO v_has_gestion; + + SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'No se encontró el pago.'; + END IF; + + -- Pago archivado: inmutable (US-R11, DominioPagos §14). Se chequea antes + -- que cualquier otra regla porque es la condición de inmutabilidad más + -- fuerte: si el mes ya viajó al archivo, no hay edición posible para + -- nadie. (Normalmente el pipeline ya borró la fila, pero el guard cubre + -- la ventana entre marcar 'ok' y el DELETE, y deja la regla explícita.) + IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN + RAISE EXCEPTION 'Pago archivado. No se puede modificar.'; + END IF; + + IF v_pago.anulado_at IS NOT NULL THEN + RAISE EXCEPTION 'No se puede editar un pago anulado.'; + END IF; + + IF v_pago.tipo <> 'cuota_mensual' THEN + RAISE EXCEPTION 'Sólo se pueden editar pagos de tipo cuota_mensual'; + END IF; + + IF NOT v_has_gestion THEN + IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN + RAISE EXCEPTION 'No tenés permiso para editar este pago.'; + END IF; + END IF; + + v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30); + IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN + RAISE EXCEPTION 'La ventana de edición de este pago ya venció.'; + END IF; + + IF p_datos ? 'cliente_id' THEN + RAISE EXCEPTION 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.'; + END IF; + IF p_datos ? 'tipo' THEN + RAISE EXCEPTION 'tipo no es editable. Anulá el pago y cargá uno nuevo.'; + END IF; + + IF p_datos ? 'metodo_id' THEN + v_new_metodo_id := (p_datos->>'metodo_id')::SMALLINT; + IF v_new_metodo_id IS NULL THEN + RAISE EXCEPTION 'metodo_id no puede ser nulo en un pago de cuota_mensual.'; + END IF; + PERFORM 1 FROM metodos_pago WHERE id = v_new_metodo_id AND activo = true; + IF NOT FOUND THEN + RAISE EXCEPTION 'El método de pago ID % no existe o no está activo.', v_new_metodo_id; + END IF; + END IF; + + IF p_datos ? 'anio_mes_pagado' THEN + v_new_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE; + IF v_new_anio_mes IS NULL THEN + RAISE EXCEPTION 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.'; + END IF; + END IF; + + IF p_datos ? 'monto_total' THEN + v_new_monto := (p_datos->>'monto_total')::NUMERIC; + IF v_new_monto IS NULL OR v_new_monto < 0 THEN + RAISE EXCEPTION 'El monto total es inválido para un pago de cuota_mensual.'; + END IF; + END IF; + + IF p_datos ? 'tipo_cuota_id' THEN + IF (p_datos->>'tipo_cuota_id') IS NOT NULL THEN + v_new_tipo_cuota := (p_datos->>'tipo_cuota_id')::UUID; + PERFORM 1 FROM tipos_cuota WHERE id = v_new_tipo_cuota; + IF NOT FOUND THEN + RAISE EXCEPTION 'El tipo de cuota ID % no existe.', v_new_tipo_cuota; + END IF; + ELSE + v_new_tipo_cuota := NULL; + END IF; + END IF; + + UPDATE public.pagos p + SET metodo_id = CASE WHEN p_datos ? 'metodo_id' + THEN v_new_metodo_id ELSE p.metodo_id END, + anio_mes_pagado = CASE WHEN p_datos ? 'anio_mes_pagado' + THEN v_new_anio_mes ELSE p.anio_mes_pagado END, + fecha_pago = CASE WHEN p_datos ? 'fecha_pago' + THEN (p_datos->>'fecha_pago')::TIMESTAMPTZ + ELSE p.fecha_pago END, + monto_total = CASE WHEN p_datos ? 'monto_total' + THEN v_new_monto ELSE p.monto_total END, + detalle = CASE WHEN p_datos ? 'detalle' + THEN p_datos->'detalle' ELSE p.detalle END, + tipo_cuota_id = CASE WHEN p_datos ? 'tipo_cuota_id' + THEN v_new_tipo_cuota ELSE p.tipo_cuota_id END, + updated_at = now(), + updated_by = v_actor_id + WHERE p.id = p_id + RETURNING * INTO v_after; + + IF v_pago.metodo_id IS DISTINCT FROM v_after.metodo_id THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('metodo_id', v_pago.metodo_id); + v_diff_actual := v_diff_actual || jsonb_build_object('metodo_id', v_after.metodo_id); + END IF; + IF v_pago.anio_mes_pagado IS DISTINCT FROM v_after.anio_mes_pagado THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('anio_mes_pagado', v_pago.anio_mes_pagado); + v_diff_actual := v_diff_actual || jsonb_build_object('anio_mes_pagado', v_after.anio_mes_pagado); + END IF; + IF v_pago.fecha_pago IS DISTINCT FROM v_after.fecha_pago THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('fecha_pago', v_pago.fecha_pago); + v_diff_actual := v_diff_actual || jsonb_build_object('fecha_pago', v_after.fecha_pago); + END IF; + IF v_pago.monto_total IS DISTINCT FROM v_after.monto_total THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('monto_total', v_pago.monto_total); + v_diff_actual := v_diff_actual || jsonb_build_object('monto_total', v_after.monto_total); + END IF; + IF v_pago.detalle IS DISTINCT FROM v_after.detalle THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('detalle', v_pago.detalle); + v_diff_actual := v_diff_actual || jsonb_build_object('detalle', v_after.detalle); + END IF; + IF v_pago.tipo_cuota_id IS DISTINCT FROM v_after.tipo_cuota_id THEN + v_diff_anterior := v_diff_anterior || jsonb_build_object('tipo_cuota_id', v_pago.tipo_cuota_id); + v_diff_actual := v_diff_actual || jsonb_build_object('tipo_cuota_id', v_after.tipo_cuota_id); + END IF; + + IF v_diff_anterior <> '{}'::jsonb THEN + PERFORM internal.log_evento( + p_tipo => 'pago_editado', + p_tabla => 'pagos', + p_referencia_id => p_id, + p_cliente_id => v_after.cliente_id, + p_actor_id => v_actor_id, + p_valor_anterior => v_diff_anterior, + p_valor_actual => v_diff_actual, + p_descripcion => NULL + ); + END IF; + + RETURN ( + SELECT jsonb_build_object( + 'id', p.id, + 'tipo', p.tipo, + 'anio_mes_pagado', p.anio_mes_pagado, + 'fecha_pago', p.fecha_pago, + 'monto_total', p.monto_total, + 'detalle', p.detalle, + 'metodo', m.descripcion, + 'created_at', p.created_at, + 'created_by', p.created_by, + 'created_by_nombre', cu.nombre, + 'updated_at', p.updated_at, + 'updated_by_nombre', uu.nombre, + 'anulado_at', p.anulado_at, + 'anulado_por_nombre', au.nombre, + 'motivo_anulacion', p.motivo_anulacion, + 'cliente', jsonb_build_object( + 'nombre', cl.nombre, + 'apellido', cl.apellido, + 'dni', cl.dni + ) + ) + FROM public.pagos p + JOIN public.usuarios cl ON p.cliente_id = cl.id + LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id + LEFT JOIN public.usuarios cu ON p.created_by = cu.id + LEFT JOIN public.usuarios uu ON p.updated_by = uu.id + LEFT JOIN public.usuarios au ON p.anulado_por = au.id + WHERE p.id = p_id + ); +END; +$function$; diff --git a/database/functions/pagos/fc_insertar_pago.sql b/database/functions/pagos/fc_insertar_pago.sql new file mode 100644 index 0000000..f0183fc --- /dev/null +++ b/database/functions/pagos/fc_insertar_pago.sql @@ -0,0 +1,210 @@ +-- ============================================================================ +-- fc_insertar_pago +-- ============================================================================ +-- PROPÓSITO +-- Registra un pago nuevo de tipo cuota_mensual para un cliente identificado +-- por DNI. Opcionalmente actualiza el plan por defecto del cliente. Como +-- efecto colateral, activa al cliente. +-- +-- DOMINIO +-- Implementa la operación "registrar" descrita en +-- Documentation/DominioPagos.md §7. Hoy sólo soporta el concepto +-- cuota_mensual (§5.1); el dominio admite otros conceptos en el futuro, +-- pero la función los rechaza explícitamente por ahora. +-- +-- RETENCIÓN (US-R10) +-- Rechaza pagos cuyo `anio_mes_pagado` cae por debajo de la ventana +-- operativa de pagos (`retencion.pagos_meses`, default 12). Sin esto +-- se podrían crear pagos que el pipeline mensual archivaría y +-- purgaría a los pocos días (Documentation/PoliticaRetencion.md +-- §4, §7). La validación es sólo del borde inferior; los meses +-- futuros no se restringen acá. +-- +-- PARÁMETROS +-- p_datos JSONB con el payload del pago. Claves: +-- dni TEXT (obligatorio) DNI del cliente. +-- metodo_id SMALLINT (obligatorio) método de pago activo. +-- anio_mes_pagado DATE (obligatorio) mes que cubre el pago. +-- Se trunca al día 1 del mes. +-- fecha_pago TIMESTAMPTZ (opcional) fecha declarada del +-- cobro. Default: now() en zona +-- America/Argentina/Buenos_Aires. +-- monto_total NUMERIC (obligatorio) ≥ 0. +-- tipo_cuota_id UUID (opcional) plan que paga el cliente. +-- set_default_tipo_cuota BOOLEAN (opcional, default false). Si true y +-- tipo_cuota_id no nulo, actualiza el +-- plan por defecto del cliente. +-- detalle JSONB (opcional, default '{}'). Si se pasa +-- tipo_cuota_id, se enriquece con +-- cuota_nombre, cuota_precio, +-- cuota_dias_semana (snapshot del plan +-- al momento del cobro). +-- tipo TEXT (opcional) debe ser 'cuota_mensual' +-- si se pasa; otros valores se rechazan. +-- p_token UUID de sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'agregar_pagos'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Tipo de pago no soportado' +-- - 'Sesión inválida o usuario no encontrado.' +-- - 'No existe un usuario con el DNI X' +-- - 'El campo metodo_id es obligatorio' +-- - 'El método de pago ID X no existe o no está activo' +-- - 'El tipo de cuota ID X no existe' +-- - 'El monto total es inválido.' +-- - 'No se puede registrar un pago para el mes X: está fuera de la +-- ventana de retención de pagos (N meses). ...' (US-R10) +-- +-- RETORNA +-- JSONB con {id, cliente_id, anio_mes_pagado, fecha_pago, monto_total, +-- status: 'success'}. +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en public.pagos con created_by = actor, tipo = 'cuota_mensual'. +-- - SIEMPRE activa al cliente (usuarios.isactive = true), independiente +-- de set_default_tipo_cuota. +-- - Si set_default_tipo_cuota = true Y tipo_cuota_id no nulo: actualiza +-- usuarios.tipo_cuota del cliente. +-- - NO genera evento en public.eventos. La trazabilidad de creación está +-- cubierta por created_at + created_by en la propia fila. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_insertar_pago( + p_datos JSONB, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_actor_id UUID; + v_cliente_id UUID; + v_metodo_id SMALLINT; + v_anio_mes DATE; + v_fecha_pago TIMESTAMPTZ; + v_monto NUMERIC; + v_detalle JSONB; + v_tipo_cuota_id UUID; + v_set_default BOOLEAN; + v_info_cuota JSONB; + v_pago_insertado RECORD; + v_meses_retencion INT; + v_mes_limite DATE; +BEGIN + PERFORM internal.validate_permission(p_token, 'agregar_pagos'); + + -- Actualmente la app sólo crea pagos de tipo 'cuota_mensual'. + -- En un futuro, se podrían registrar pagos de otras cosas (nutricionista, pago anualizado, o cosas así) + -- y las tablas ya lo soportan, sólo se cambiaría esta función. + + IF p_datos ? 'tipo' AND (p_datos->>'tipo') IS DISTINCT FROM 'cuota_mensual' THEN + RAISE EXCEPTION 'Tipo de pago no soportado'; + END IF; + + -- Autor del registro de pago, lo queremos para auditoría (created_by). + SELECT usuario_id INTO v_actor_id FROM internal.sesiones WHERE token = p_token; + IF v_actor_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + SELECT id INTO v_cliente_id FROM usuarios WHERE dni = (p_datos->>'dni'); + IF NOT FOUND THEN + RAISE EXCEPTION 'No existe un usuario con el DNI %', p_datos->>'dni'; + END IF; + + v_metodo_id := (p_datos->>'metodo_id')::SMALLINT; + IF v_metodo_id IS NULL THEN + RAISE EXCEPTION 'El campo metodo_id es obligatorio'; + END IF; + + PERFORM 1 FROM metodos_pago WHERE id = v_metodo_id AND activo = true; + IF NOT FOUND THEN + RAISE EXCEPTION 'El método de pago ID % no existe o no está activo', v_metodo_id; + END IF; + + v_tipo_cuota_id := (p_datos->>'tipo_cuota_id')::UUID; + v_set_default := COALESCE((p_datos->>'set_default_tipo_cuota')::BOOLEAN, false); + v_detalle := COALESCE(p_datos->'detalle', '{}'::jsonb); + + IF v_tipo_cuota_id IS NOT NULL THEN + SELECT jsonb_build_object( + 'cuota_nombre', nombre, + 'cuota_precio', precio, + 'cuota_dias_semana', dias_semana + ) INTO v_info_cuota + FROM tipos_cuota + WHERE id = v_tipo_cuota_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'El tipo de cuota ID % no existe', v_tipo_cuota_id; + END IF; + + v_detalle := v_detalle || v_info_cuota; + END IF; + + v_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE; + v_fecha_pago := COALESCE((p_datos->>'fecha_pago')::TIMESTAMPTZ, NOW()); + v_monto := (p_datos->>'monto_total')::NUMERIC; + + -- cuota_mensual: monto positivo o cero. Los correctivos (que aún no se + -- pueden crear acá) podrán llevar negativo cuando tengan sus funciones. + IF v_monto IS NULL OR v_monto < 0 THEN + RAISE EXCEPTION 'El monto total es inválido.'; + END IF; + + -- Ventana de retención (US-R10): no permitir registrar un pago cuyo mes + -- objetivo ya quedó fuera de la ventana operativa de pagos. De aceptarse, + -- el pipeline mensual lo archivaría y purgaría casi de inmediato (ver + -- Documentation/PoliticaRetencion.md §4, §7). Sólo se valida el borde + -- inferior: los meses futuros (pago adelantado) no son un problema de + -- retención y quedan deliberadamente fuera de esta validación. + v_meses_retencion := internal.get_config_int('retencion.pagos_meses', 12); + v_mes_limite := date_trunc('month', CURRENT_DATE)::DATE + - (v_meses_retencion * INTERVAL '1 month'); + IF v_anio_mes < v_mes_limite THEN + RAISE EXCEPTION + 'No se puede registrar un pago para el mes %: está fuera de la ventana de retención de pagos (% meses). El mes más antiguo admitido es %.', + to_char(v_anio_mes, 'YYYY-MM'), v_meses_retencion, to_char(v_mes_limite, 'YYYY-MM'); + END IF; + + INSERT INTO public.pagos ( + id, cliente_id, anio_mes_pagado, fecha_pago, monto_total, + metodo_id, detalle, tipo_cuota_id, tipo, created_by + ) VALUES ( + gen_random_uuid(), v_cliente_id, v_anio_mes, v_fecha_pago, v_monto, + v_metodo_id, v_detalle, v_tipo_cuota_id, 'cuota_mensual', v_actor_id + ) + RETURNING id, anio_mes_pagado, fecha_pago, monto_total, detalle + INTO v_pago_insertado; + + + IF v_set_default AND v_tipo_cuota_id IS NOT NULL THEN + UPDATE usuarios + SET tipo_cuota = v_tipo_cuota_id, + isactive = true + WHERE id = v_cliente_id; + ELSE + UPDATE usuarios + SET isactive = true + WHERE id = v_cliente_id; + END IF; + + -- No loggeamos creación en eventos (decisión de scope): created_at/created_by + -- ya cubren la trazabilidad de la inserción. + + RETURN jsonb_build_object( + 'id', v_pago_insertado.id, + 'cliente_id', v_cliente_id, + 'anio_mes_pagado', v_pago_insertado.anio_mes_pagado, + 'fecha_pago', v_pago_insertado.fecha_pago, + 'monto_total', v_pago_insertado.monto_total, + 'status', 'success' + ); +END; +$function$; diff --git a/database/functions/pagos/fc_obtener_mis_pagos.sql b/database/functions/pagos/fc_obtener_mis_pagos.sql new file mode 100644 index 0000000..ffc5534 --- /dev/null +++ b/database/functions/pagos/fc_obtener_mis_pagos.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- fc_obtener_mis_pagos +-- ============================================================================ +-- PROPÓSITO +-- Devuelve los pagos del cliente autenticado, paginados. +-- +-- DOMINIO +-- Versión "self-service" de la consulta descrita en +-- Documentation/DominioPagos.md §7. Incluye los pagos anulados del propio +-- cliente: él pudo haberlos visto cuando estaban vigentes, así que también +-- debe poder consultar el rastro de la anulación +-- (Documentation/DominioPagos.md §12). +-- +-- PARÁMETROS +-- p_token UUID sesión del cliente. +-- p_pagina INT (default 1) página, base 1. +-- p_cantidad INT (default 20) ítems por página. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_mis_pagos'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Sesión inválida o usuario no encontrado.' +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado, +-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de +-- auditoría reducida (created_at, created_by, updated_at, +-- updated_by_nombre, anulado_at, anulado_por_nombre) y motivo_anulacion. +-- NO incluye el objeto cliente porque es siempre el del token. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_mis_pagos( + p_token UUID, + p_pagina INT DEFAULT 1, + p_cantidad INT DEFAULT 20 +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +DECLARE + v_user_id UUID; + v_offset INT; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_mis_pagos'); + + SELECT usuario_id INTO v_user_id FROM internal.sesiones WHERE token = p_token; + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.'; + END IF; + + v_offset := (p_pagina - 1) * p_cantidad; + + RETURN ( + SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb) + FROM ( + SELECT jsonb_build_object( + 'id', p.id, + 'tipo', p.tipo, + 'anio_mes_pagado', p.anio_mes_pagado, + 'fecha_pago', p.fecha_pago, + 'monto_total', p.monto_total, + 'detalle', p.detalle, + 'metodo', m.descripcion, + 'created_at', p.created_at, + 'created_by', p.created_by, + 'updated_at', p.updated_at, + 'updated_by_nombre', uu.nombre, + 'anulado_at', p.anulado_at, + 'anulado_por_nombre', au.nombre, + 'motivo_anulacion', p.motivo_anulacion + ) AS pago_json + FROM public.pagos p + LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id + LEFT JOIN public.usuarios uu ON p.updated_by = uu.id + LEFT JOIN public.usuarios au ON p.anulado_por = au.id + WHERE p.cliente_id = v_user_id + ORDER BY p.fecha_pago DESC + LIMIT p_cantidad + OFFSET v_offset + ) t + ); +END; +$function$; diff --git a/database/functions/pagos/fc_obtener_pagos.sql b/database/functions/pagos/fc_obtener_pagos.sql new file mode 100644 index 0000000..e33a602 --- /dev/null +++ b/database/functions/pagos/fc_obtener_pagos.sql @@ -0,0 +1,106 @@ +-- ============================================================================ +-- fc_obtener_pagos +-- ============================================================================ +-- PROPÓSITO +-- Devuelve un lote paginado de pagos, filtrable por cliente. +-- Opcionalmente incluye los pagos anulados. +-- +-- DOMINIO +-- Sirve a la operación "consultar pagos" descrita en +-- Documentation/DominioPagos.md §7. Cada ítem trae la metadata necesaria +-- para que la UI pueda distinguir pagos vigentes de anulados, identificar +-- autoría de registro / corrección / anulación y mostrar motivo (§12). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_dni TEXT (opcional) DNI del cliente para filtrar. +-- NULL = todos los clientes. +-- p_pagina INT (default 1) página, base 1. +-- p_cantidad INT (default 50) ítems por página. +-- p_incluir_anulados BOOLEAN (default FALSE) si TRUE, incluye pagos +-- anulados en el resultado. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_pagos'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No existe un usuario con el DNI X' cuando p_dni se pasa y no +-- coincide con ningún cliente. +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado, +-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de +-- auditoría (created/updated/anulado: at + by_nombre), motivo_anulacion +-- y un objeto cliente {nombre, apellido, dni}. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_pagos( + p_token UUID, + p_dni TEXT DEFAULT NULL, + p_pagina INT DEFAULT 1, + p_cantidad INT DEFAULT 50, + p_incluir_anulados BOOLEAN DEFAULT FALSE +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +DECLARE + v_cliente_id UUID := NULL; + v_offset INT; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_pagos'); + v_offset := (p_pagina - 1) * p_cantidad; + + IF p_dni IS NOT NULL THEN + SELECT id INTO v_cliente_id FROM usuarios WHERE dni = p_dni; + IF NOT FOUND THEN + RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni; + END IF; + END IF; + + RETURN ( + SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb) + FROM ( + SELECT jsonb_build_object( + 'id', p.id, + 'tipo', p.tipo, + 'anio_mes_pagado', p.anio_mes_pagado, + 'fecha_pago', p.fecha_pago, + 'monto_total', p.monto_total, + 'detalle', p.detalle, + 'metodo', m.descripcion, + 'created_at', p.created_at, + 'created_by', p.created_by, + 'created_by_nombre', cu.nombre, + 'updated_at', p.updated_at, + 'updated_by_nombre', uu.nombre, + 'anulado_at', p.anulado_at, + 'anulado_por_nombre', au.nombre, + 'motivo_anulacion', p.motivo_anulacion, + 'cliente', jsonb_build_object( + 'nombre', cl.nombre, + 'apellido', cl.apellido, + 'dni', cl.dni + ) + ) AS pago_json + FROM public.pagos p + JOIN public.usuarios cl ON p.cliente_id = cl.id + LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id + LEFT JOIN public.usuarios cu ON p.created_by = cu.id + LEFT JOIN public.usuarios uu ON p.updated_by = uu.id + LEFT JOIN public.usuarios au ON p.anulado_por = au.id + WHERE (v_cliente_id IS NULL OR p.cliente_id = v_cliente_id) + AND (p_incluir_anulados OR p.anulado_at IS NULL) + ORDER BY p.fecha_pago DESC + LIMIT p_cantidad + OFFSET v_offset + ) t + ); +END; +$function$; diff --git a/database/functions/pagos/metodos_pago/fc_modificar_metodos_pago.sql b/database/functions/pagos/metodos_pago/fc_modificar_metodos_pago.sql new file mode 100644 index 0000000..c3083a3 --- /dev/null +++ b/database/functions/pagos/metodos_pago/fc_modificar_metodos_pago.sql @@ -0,0 +1,96 @@ +-- ============================================================================ +-- fc_modificar_metodos_pago +-- ============================================================================ +-- PROPÓSITO +-- Modifica los campos editables de un método de pago existente. Semántica +-- PATCH: sólo se actualizan las claves presentes en p_datos. +-- +-- DOMINIO +-- Mantenimiento de los métodos de pago como configuración del módulo. +-- No crea ni elimina; el alta lógica/baja lógica se maneja con el flag +-- `activo` del propio método. +-- +-- PARÁMETROS +-- p_datos JSONB con: +-- id INTEGER (obligatorio) ID del método a modificar. +-- descripcion TEXT (opcional) nuevo nombre / etiqueta. +-- activo BOOLEAN (opcional) baja lógica (false) o reactivación +-- (true). +-- icono TEXT (opcional) identificador del ícono asociado. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_metodos_pago'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Debe indicar el ID del tipo de cuota a modificar.' +-- (NOTA: el mensaje refiere a "tipo de cuota" por un error histórico; +-- en realidad valida que se pase el ID del método de pago.) +-- - 'El método de pago con ID X no existe.' +-- +-- RETORNA +-- JSONB con la fila completa del método de pago tras la modificación. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE sobre metodos_pago. No genera evento. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_modificar_metodos_pago( + p_datos JSONB, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Modifica datos +AS $function$ +DECLARE + v_resultado JSONB; + v_id INTEGER; +BEGIN + -- ----------------------------------------------------------- + -- Checklist #3: Validar permisos + -- ----------------------------------------------------------- + PERFORM internal.validate_permission(p_token, 'modificar_metodos_pago'); + + + -- 3. Validar que venga el ID para saber qué modificar + IF NOT p_datos ? 'id' OR p_datos->>'id' IS NULL THEN + RAISE EXCEPTION 'Debe indicar el ID del tipo de cuota a modificar.'; + END IF; + + v_id := (p_datos->>'id')::INTEGER; + -- ----------------------------------------------------------- + -- 1. UPDATE Dinámico y Seguro + -- ----------------------------------------------------------- + UPDATE metodos_pago + SET + descripcion = CASE + WHEN p_datos ? 'descripcion' THEN p_datos->>'descripcion' + ELSE descripcion + END, + + activo = CASE + WHEN p_datos ? 'activo' THEN (p_datos->>'activo')::BOOL + ELSE activo + END, + + -- BUG CORREGIDO: Ahora si no viene el icono, no lo tocamos. + icono = CASE + WHEN p_datos ? 'icono' THEN p_datos->>'icono' + ELSE icono + END + WHERE id = v_id + RETURNING to_jsonb(metodos_pago.*) INTO v_resultado; -- Retorno atómico + + -- ----------------------------------------------------------- + -- 2. Verificar si se actualizó algo + -- ----------------------------------------------------------- + IF v_resultado IS NULL THEN + RAISE EXCEPTION 'El método de pago con ID % no existe.', v_id; + END IF; + + RETURN v_resultado; +END; +$function$; diff --git a/database/functions/pagos/metodos_pago/fc_obtener_metodos_pago.sql b/database/functions/pagos/metodos_pago/fc_obtener_metodos_pago.sql new file mode 100644 index 0000000..ce4b2be --- /dev/null +++ b/database/functions/pagos/metodos_pago/fc_obtener_metodos_pago.sql @@ -0,0 +1,55 @@ +-- ============================================================================ +-- fc_obtener_metodos_pago +-- ============================================================================ +-- PROPÓSITO +-- Devuelve todos los métodos de pago configurados (activos e inactivos). +-- +-- DOMINIO +-- Los métodos de pago (efectivo, transferencia, billetera digital, etc.) +-- son entidades de configuración del módulo de pagos. Se consultan al +-- registrar o corregir un pago para que el responsable elija cómo se cobró +-- (Documentation/DominioPagos.md §5.1). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_metodos_pago'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array de filas completas de metodos_pago, ordenadas por id. +-- Cada ítem: id, descripcion, activo, icono. Array vacío si no hay +-- registros. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_obtener_metodos_pago(p_token UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +DECLARE + -- No necesitamos variables extra si usamos internal.validate_permission +BEGIN + -- 1. Validar permisos (Checklist #3) + -- Asumimos acción 'ver_configuracion' o 'ver_metodos_pago' + -- Esto te permite mañana darle permiso al 'cliente' sin tocar este código. + PERFORM internal.validate_permission(p_token, 'ver_metodos_pago'); + + -- 2. Retorno directo y sin vueltas + RETURN ( + SELECT COALESCE( + jsonb_agg(to_jsonb(mp) ORDER BY mp.id), + '[]'::jsonb + ) + FROM public.metodos_pago mp +); +END; +$function$; diff --git a/database/functions/retencion/fc_obtener_estado_archivo.sql b/database/functions/retencion/fc_obtener_estado_archivo.sql new file mode 100644 index 0000000..e8b004e --- /dev/null +++ b/database/functions/retencion/fc_obtener_estado_archivo.sql @@ -0,0 +1,91 @@ +-- ============================================================================ +-- fc_obtener_estado_archivo +-- ============================================================================ +-- PROPÓSITO +-- Devuelve el estado del pipeline mensual de archivado leyendo +-- internal.archivo_exports, en un formato apto para la vista admin +-- (US-R21). Es la fachada de lectura de la bitácora de control: el +-- operador no escribe ni dispara el pipeline, sólo lo observa. +-- +-- DOMINIO +-- Implementa la "visibilidad" de Documentation/PoliticaRetencion.md +-- §5.4. Reporta: +-- - ultimo_ok: el archivado exitoso más reciente por cada tipo +-- ('pagos', 'agregado_reservas'), con su mes objetivo y cuándo +-- se ejecutó. +-- - fallos_recientes: hasta 20 intentos `fallo`, más nuevo primero, +-- con su detalle_error para diagnóstico. +-- +-- NO calcula "meses pendientes" (qué mes debería haberse archivado y no +-- se hizo): esa detección depende de la lógica de cadencia del pipeline +-- (qué mes cumple 12, qué mes recién cerró) y vive en US-R17/US-R21, no +-- acá. Esta función reporta lo que la tabla contiene, no lo que falta. +-- +-- PARÁMETROS +-- p_token UUID de sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_configuraciones_generales'. Se reusa ese permiso +-- (admin/superadmin) en vez de crear uno nuevo: el estado del pipeline es +-- información operativa del mismo tenor que la config general, y evitar +-- una acción nueva en internal.permisos mantiene esta entrega +-- autocontenida. Si más adelante el monitoreo (US-R21/US-R22) justifica +-- un permiso propio (ej. 'ver_estado_sistema'), migrar acá es trivial. +-- +-- ERRORES (RAISE EXCEPTION) +-- - Los que propague internal.validate_permission (token / permiso). +-- +-- RETORNA +-- JSONB { status, ultimo_ok, fallos_recientes }. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE, sólo lectura). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_estado_archivo(p_token UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +STABLE +AS $$ +DECLARE + v_ultimo_ok JSONB; + v_fallos_recientes JSONB; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales'); + + -- Último `ok` por tipo: DISTINCT ON (tipo) ordenando por fecha desc. + SELECT COALESCE(jsonb_object_agg(s.tipo, s.info), '{}'::jsonb) + INTO v_ultimo_ok + FROM ( + SELECT DISTINCT ON (tipo) + tipo, + jsonb_build_object( + 'anio_mes_target', anio_mes_target, + 'ejecutado_en', ejecutado_en + ) AS info + FROM internal.archivo_exports + WHERE resultado = 'ok' + ORDER BY tipo, ejecutado_en DESC + ) s; + + -- Fallos recientes (hasta 20), más nuevo primero. + SELECT COALESCE(jsonb_agg(to_jsonb(f) ORDER BY f.ejecutado_en DESC), '[]'::jsonb) + INTO v_fallos_recientes + FROM ( + SELECT anio_mes_target, tipo, ejecutado_en, detalle_error + FROM internal.archivo_exports + WHERE resultado = 'fallo' + ORDER BY ejecutado_en DESC + LIMIT 20 + ) f; + + RETURN jsonb_build_object( + 'status', 'success', + 'ultimo_ok', v_ultimo_ok, + 'fallos_recientes', v_fallos_recientes + ); +END; +$$; diff --git a/database/functions/retencion/fc_obtener_tamano_base.sql b/database/functions/retencion/fc_obtener_tamano_base.sql new file mode 100644 index 0000000..8796ba5 --- /dev/null +++ b/database/functions/retencion/fc_obtener_tamano_base.sql @@ -0,0 +1,89 @@ +-- ============================================================================ +-- fc_obtener_tamano_base +-- ============================================================================ +-- PROPÓSITO +-- Termómetro de la base: devuelve, por tabla, la cantidad de filas y el +-- espacio que ocupa (tabla + índices + toast), más el tamaño total de la +-- base. Sirve para anticipar el momento en que nos acercamos al cap de +-- 500 MB del plan Free (Documentation/PoliticaRetencion.md §2, §5.5), +-- que es la razón de ser de toda la política de retención. +-- +-- DOMINIO +-- Implementa el "dashboard de tamaño de BD" del backlog (US-R22). Es la +-- última línea de defensa que menciona §5.5: si el pipeline mensual +-- fallara en silencio, los datos viejos se acumularían y esta función lo +-- haría visible antes de chocar el límite. +-- +-- El nombre diverge del `fc_estado_retencion` sugerido en el backlog (que +-- admitía "o similar"): "tamaño base" describe mejor qué reporta y no se +-- confunde con fc_obtener_estado_archivo (estado del pipeline). +-- +-- PARÁMETROS +-- p_token UUID de sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_configuraciones_generales' (admin/superadmin), +-- el mismo que el resto de las lecturas de estado del sistema. SECURITY +-- DEFINER para poder leer pg_catalog y contar tablas del schema internal. +-- +-- ERRORES (RAISE EXCEPTION) +-- - Los que propague internal.validate_permission (token / permiso). +-- +-- RETORNA +-- JSONB { status, db_total_bytes, db_total_tamano, tablas: [ {schema, +-- tabla, filas, bytes, tamano} ] }. `tablas` viene ordenado por tamaño +-- descendente (la más grande primero — la candidata a vigilar). +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (sólo lectura). El count(*) por tabla es exacto; a la escala +-- del sistema (≤200 usuarios, tablas chicas) es despreciable. Si la base +-- creciera mucho, conviene pasar a la estimación de pg_class.reltuples. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_tamano_base(p_token UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ +DECLARE + v_tablas JSONB := '[]'::jsonb; + v_rec RECORD; + v_filas BIGINT; + v_total BIGINT; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales'); + + FOR v_rec IN + SELECT n.nspname AS schema_name, + c.relname AS table_name, + pg_total_relation_size(c.oid) AS bytes + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname IN ('public', 'internal') + ORDER BY pg_total_relation_size(c.oid) DESC, n.nspname, c.relname + LOOP + EXECUTE format('SELECT count(*) FROM %I.%I', v_rec.schema_name, v_rec.table_name) + INTO v_filas; + + v_tablas := v_tablas || jsonb_build_object( + 'schema', v_rec.schema_name, + 'tabla', v_rec.table_name, + 'filas', v_filas, + 'bytes', v_rec.bytes, + 'tamano', pg_size_pretty(v_rec.bytes) + ); + END LOOP; + + v_total := pg_database_size(current_database()); + + RETURN jsonb_build_object( + 'status', 'success', + 'db_total_bytes', v_total, + 'db_total_tamano', pg_size_pretty(v_total), + 'tablas', v_tablas + ); +END; +$$; diff --git a/database/functions/retencion/internal/agregado_reservas_mensual.sql b/database/functions/retencion/internal/agregado_reservas_mensual.sql new file mode 100644 index 0000000..2abf21f --- /dev/null +++ b/database/functions/retencion/internal/agregado_reservas_mensual.sql @@ -0,0 +1,79 @@ +-- ============================================================================ +-- internal.agregado_reservas_mensual +-- ============================================================================ +-- PROPÓSITO +-- Calcula, para un mes dado, la concurrencia por actividad: cuántas +-- reservas hubo en total y cuántas se cancelaron. Es la fuente de datos +-- de la hoja ACTIVIDAD_MENSUAL del archivo histórico +-- (Documentation/PoliticaRetencion.md §4.1). +-- +-- DOMINIO +-- El sistema descarta los turnos pasados (higiene, 2 meses) pero la +-- concurrencia mensual sí vale conservar agregada. Esta función produce +-- ese agregado ANTES de que el pipeline purgue los turnos del mes +-- (§5.2): el orden importa, porque una vez borrados los turnos las +-- reservas caen por CASCADE y el dato se pierde. +-- +-- Sólo aparecen actividades con al menos una reserva en el mes (el JOIN +-- contra reservas las filtra naturalmente). Una actividad sin reservas +-- no aporta fila: su concurrencia fue cero y no hay nada que archivar. +-- +-- "totales" incluye las canceladas; "canceladas" es el subconjunto soft- +-- deleted (reservas.cancelada = true). La asistencia neta se deriva +-- restando, pero se archivan los dos números crudos para no perder +-- información. +-- +-- PARÁMETROS +-- p_anio_mes DATE. Cualquier día del mes objetivo; se trunca al día 1. +-- El mes se delimita con un rango semiabierto [inicio, inicio+1mes) sobre +-- turnos.fecha. +-- +-- AUTORIZACIÓN +-- No valida permiso propio. El schema `internal` no es alcanzable por +-- roles cliente (ver hardening en `database/schema/00_schemas.sql`). +-- Caller legítimo: el pipeline mensual (US-R17). Read-only: no muta +-- estado; la persistencia ocurre cuando la Edge Function appendea al +-- XLSX. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- SETOF filas (anio_mes, actividad_nombre, reservas_totales, +-- reservas_canceladas). Vacío si el mes no tuvo reservas. `anio_mes` se +-- devuelve como TEXTO 'YYYY-MM-DD' (misma razón que en pagos_del_mes: el +-- pipeline lo usa como clave de reemplazo idempotente en el XLSX). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.agregado_reservas_mensual(p_anio_mes DATE) +RETURNS TABLE ( + anio_mes TEXT, + actividad_nombre TEXT, + reservas_totales INTEGER, + reservas_canceladas INTEGER +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +STABLE +AS $$ + SELECT + to_char(date_trunc('month', p_anio_mes), 'YYYY-MM-DD') AS anio_mes, + a.nombre AS actividad_nombre, + count(r.id)::int AS reservas_totales, + count(r.id) FILTER (WHERE r.cancelada)::int AS reservas_canceladas + FROM reservas r + JOIN turnos t ON t.id = r.turno_id + JOIN actividades a ON a.id = t.actividad_id + WHERE t.fecha >= date_trunc('month', p_anio_mes)::date + AND t.fecha < (date_trunc('month', p_anio_mes) + INTERVAL '1 month')::date + GROUP BY a.nombre + ORDER BY a.nombre; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.agregado_reservas_mensual(DATE) + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/retencion/internal/pagos_del_mes.sql b/database/functions/retencion/internal/pagos_del_mes.sql new file mode 100644 index 0000000..170cb9d --- /dev/null +++ b/database/functions/retencion/internal/pagos_del_mes.sql @@ -0,0 +1,95 @@ +-- ============================================================================ +-- internal.pagos_del_mes +-- ============================================================================ +-- PROPÓSITO +-- Devuelve el detalle de los pagos cobrados en un mes dado, ya con la +-- forma de la hoja PAGOS del archivo histórico +-- (Documentation/PoliticaRetencion.md §4.1). Es la fuente que el +-- pipeline mensual (US-R17) appendea al XLSX antes de borrar esos pagos +-- de la base. +-- +-- DOMINIO +-- El archivo conserva "una fila por pago realmente cobrado" (§4.1). +-- Por eso: +-- - Se EXCLUYEN los pagos anulados (§4.2): una anulación se manifiesta +-- como ausencia de fila, no como fila marcada. anulado_at IS NULL. +-- - El "plan" sale del snapshot guardado en detalle->>'cuota_nombre' +-- al momento del cobro (lo escribe fc_insertar_pago), con fallback al +-- nombre vigente en tipos_cuota. El snapshot es lo correcto para un +-- archivo: preserva el nombre que tenía el plan cuando se cobró, +-- aunque después se renombre o se borre. +-- - El "metodo" y "registrado_por" se resuelven por join; sus tablas +-- (metodos_pago, usuarios) no se purgan, así que el dato sigue vivo. +-- +-- NOTA sobre tipos: hoy todos los pagos son 'cuota_mensual' (los +-- correctivos —devolucion/ajuste— son una decisión cerrada de NO +-- modelar, DominioPagos §10). Si alguna vez se implementan, habrá que +-- revisar qué cuenta como "cobro real" para el archivo; por ahora el +-- único filtro necesario es el de anulados. +-- +-- PARÁMETROS +-- p_anio_mes DATE. Cualquier día del mes objetivo; se compara contra +-- pagos.anio_mes_pagado, que ya está truncado al día 1 por constraint. +-- +-- AUTORIZACIÓN +-- No valida permiso propio. Schema `internal` no alcanzable por roles +-- cliente (hardening en 00_schemas.sql). Caller legítimo: el pipeline +-- mensual (US-R17), vía conexión PG directa. Read-only. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- SETOF filas con las columnas de la hoja PAGOS, en el orden del §4.1. +-- Vacío si el mes no tuvo pagos cobrados (caso normal del primer año, +-- §4.3). +-- +-- Las columnas de fecha se devuelven como TEXTO estable +-- (`fecha_pago` = 'YYYY-MM-DD HH24:MI', `anio_mes_pagado` = 'YYYY-MM-DD'). +-- Es deliberado: el pipeline (US-R17) usa `anio_mes_pagado` como clave +-- para "reemplazar las filas de este mes" en el XLSX de forma idempotente, +-- y un texto fijo se compara sin la ambigüedad de las celdas-fecha de +-- Excel. Ordena lexicográfico = cronológico. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.pagos_del_mes(p_anio_mes DATE) +RETURNS TABLE ( + cliente_nombre TEXT, + cliente_dni TEXT, + fecha_pago TEXT, + anio_mes_pagado TEXT, + monto NUMERIC, + metodo TEXT, + plan TEXT, + registrado_por TEXT +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +STABLE +AS $$ + SELECT + trim(cl.nombre || ' ' || COALESCE(cl.apellido, '')) AS cliente_nombre, + cl.dni AS cliente_dni, + to_char(p.fecha_pago, 'YYYY-MM-DD HH24:MI') AS fecha_pago, + to_char(p.anio_mes_pagado, 'YYYY-MM-DD') AS anio_mes_pagado, + p.monto_total AS monto, + m.descripcion AS metodo, + COALESCE(p.detalle->>'cuota_nombre', tc.nombre) AS plan, + ru.nombre AS registrado_por + FROM pagos p + JOIN usuarios cl ON cl.id = p.cliente_id + LEFT JOIN metodos_pago m ON m.id = p.metodo_id + LEFT JOIN tipos_cuota tc ON tc.id = p.tipo_cuota_id + LEFT JOIN usuarios ru ON ru.id = p.created_by + WHERE p.anio_mes_pagado = date_trunc('month', p_anio_mes)::date + AND p.anulado_at IS NULL + ORDER BY cliente_nombre, p.fecha_pago; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.pagos_del_mes(DATE) + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/retencion/internal/pagos_mes_archivado.sql b/database/functions/retencion/internal/pagos_mes_archivado.sql new file mode 100644 index 0000000..fcdc745 --- /dev/null +++ b/database/functions/retencion/internal/pagos_mes_archivado.sql @@ -0,0 +1,61 @@ +-- ============================================================================ +-- internal.pagos_mes_archivado +-- ============================================================================ +-- PROPÓSITO +-- Predicado de dominio: ¿los pagos del mes dado ya fueron archivados al +-- XLSX histórico? Centraliza la definición de "mes archivado" para que +-- los guards de pagos (fc_editar_pago, fc_anular_pago — US-R11) y +-- cualquier futuro consumidor consulten un único criterio en vez de +-- reimplementar el EXISTS y arriesgar divergencias. +-- +-- DOMINIO +-- Un mes de pagos está archivado sii existe un registro `ok` de archivado +-- para ese mes en internal.archivo_exports (tipo = 'pagos'). Ver +-- Documentation/PoliticaRetencion.md §5.3 y DominioPagos.md §14. +-- +-- Sutilezas que el helper encierra: +-- - Sólo cuenta resultado = 'ok'. Un intento `fallo` NO archiva: el +-- mes sigue editable hasta que el archivado tenga éxito. +-- - El target se compara truncado al día 1, igual que se guarda. +-- - Las corridas vacías del primer año escriben `ok` con +-- anio_mes_target NULL (US-R17); como `NULL = ` nunca es TRUE, +-- esas filas no marcan ningún mes como archivado. Correcto: no +-- archivaron nada. +-- +-- PARÁMETROS +-- p_anio_mes DATE. El mes a consultar (cualquier día; se trunca). +-- +-- AUTORIZACIÓN +-- No valida permiso propio. Schema `internal` no alcanzable por roles +-- cliente (hardening en 00_schemas.sql). Lo invocan fachadas públicas +-- SECURITY DEFINER (fc_editar_pago, fc_anular_pago) ya autorizadas. +-- +-- RETORNA +-- boolean. TRUE si el mes está archivado; FALSE si no (incluye p_anio_mes +-- NULL). +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE, sólo lectura). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.pagos_mes_archivado(p_anio_mes DATE) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM internal.archivo_exports + WHERE tipo = 'pagos' + AND resultado = 'ok' + AND anio_mes_target = date_trunc('month', p_anio_mes)::date + ); +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.pagos_mes_archivado(DATE) + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/tipos_cuota/fc_eliminar_tipo_cuota.sql b/database/functions/tipos_cuota/fc_eliminar_tipo_cuota.sql new file mode 100644 index 0000000..bf87297 --- /dev/null +++ b/database/functions/tipos_cuota/fc_eliminar_tipo_cuota.sql @@ -0,0 +1,79 @@ +-- ============================================================================ +-- fc_eliminar_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Elimina un tipo de cuota (DELETE real, no soft). Apoyado en constraints: +-- las asociaciones a actividades se borran en CASCADE, y los usuarios que +-- tenían este plan como default quedan con plan NULL (SET NULL). +-- +-- DOMINIO +-- Baja definitiva de un plan de la configuración del gimnasio. A +-- diferencia de los pagos (que se anulan, nunca se borran), los tipos de +-- cuota son configuración: eliminarlos no destruye información de +-- pagos pasados, porque cada pago capturó el snapshot del plan en su +-- detalle al momento del cobro (Documentation/DominioPagos.md §5.1). +-- Sin embargo, los pagos históricos pierden el FK al tipo de cuota +-- (queda como referencia colgante o se anula según el DDL — depende del +-- constraint configurado en tablas). +-- +-- PARÁMETROS +-- p_id UUID ID del tipo de cuota a eliminar. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'eliminar_tipo_cuota'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El tipo de cuota con ID X no existe.' +-- +-- RETORNA +-- BOOL TRUE en caso de éxito. +-- +-- EFECTOS SECUNDARIOS +-- - DELETE de la fila en tipos_cuota. +-- - DELETE en cascada de las asociaciones en actividades_tipos_cuota. +-- - SET NULL en usuarios.tipo_cuota para los usuarios que tenían este +-- plan como default. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_eliminar_tipo_cuota( + p_id UUID, + p_token UUID +) +RETURNS BOOL +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Modifica datos +AS $function$ +DECLARE + v_id_borrado UUID; +BEGIN + -- ----------------------------------------------------------- + -- Checklist #3: Validar permisos (Centralizado) + -- ----------------------------------------------------------- + PERFORM internal.validate_permission(p_token, 'eliminar_tipo_cuota'); + + -- ----------------------------------------------------------- + -- 1. DELETE + RETURNING + -- ----------------------------------------------------------- + -- Aprovechamos tus constraints: + -- 1. Borra relaciones en 'actividades_tipos_cuota' (CASCADE) + -- 2. Pone en NULL el campo 'tipo_cuota' en 'usuarios' (SET NULL) + DELETE FROM tipos_cuota + WHERE id = p_id + RETURNING id INTO v_id_borrado; + + -- ----------------------------------------------------------- + -- 2. Validación de existencia + -- ----------------------------------------------------------- + IF v_id_borrado IS NULL THEN + RAISE EXCEPTION 'El tipo de cuota con ID % no existe.', p_id; + END IF; + + -- ----------------------------------------------------------- + -- 3. Retorno Informativo + -- ----------------------------------------------------------- + RETURN TRUE; +END; +$function$; diff --git a/database/functions/tipos_cuota/fc_insertar_tipo_cuota.sql b/database/functions/tipos_cuota/fc_insertar_tipo_cuota.sql new file mode 100644 index 0000000..8ed4511 --- /dev/null +++ b/database/functions/tipos_cuota/fc_insertar_tipo_cuota.sql @@ -0,0 +1,118 @@ +-- ============================================================================ +-- fc_insertar_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Crea un tipo de cuota (plan) nuevo y, opcionalmente, le asocia un +-- conjunto inicial de actividades. +-- +-- DOMINIO +-- Alta de un plan ofrecido por el gimnasio +-- (Documentation/DominioPagos.md §5.1). Los planes existentes son +-- referenciados por pagos y por usuarios (como plan por defecto), por lo +-- que se trata de configuración: no se "anulan" como los pagos, pero su +-- eliminación tiene reglas (ver fc_eliminar_tipo_cuota). +-- +-- PARÁMETROS +-- p_datos JSONB con el payload del tipo de cuota. Claves: +-- nombre TEXT (obligatorio). +-- descripcion TEXT (opcional). +-- dias_semana SMALLINT (obligatorio) cantidad / bitmask de días +-- de la semana incluidos. +-- precio NUMERIC (opcional, default 0). +-- para_socios BOOLEAN (opcional, default FALSE). NOTA: el payload +-- usa "para_socios" pero la columna en la +-- tabla se llama "parasocios". +-- dia_de_pago SMALLINT (obligatorio) día sugerido del mes para el +-- pago. +-- recargo NUMERIC (opcional, default NULL) monto extra +-- aplicable si se paga después del día sugerido. +-- actividades_ids JSONB array de INT (opcional) IDs de las actividades +-- incluidas en el plan. +-- p_token UUID de sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'agregar_tipo_cuota'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El campo actividades_ids debe ser un array JSON' si se pasa pero no +-- es un array. +-- +-- RETORNA +-- JSONB array con un único elemento: el tipo de cuota recién creado, con +-- el mismo shape que fc_obtener_tipo_cuota (delega en esa función para +-- armar el resultado). +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en tipos_cuota. +-- - Si actividades_ids no es vacío: INSERTs en actividades_tipos_cuota. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_insertar_tipo_cuota( + p_datos JSONB, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Escribe datos +AS $function$ +DECLARE + v_tipo_id UUID; +BEGIN + ---------------------------------------------------------------------- + -- Checklist #3: Validación de permisos (Centralizada) + ---------------------------------------------------------------------- + -- Asumimos la acción 'crear_tipo_cuota' + PERFORM internal.validate_permission(p_token, 'agregar_tipo_cuota'); + + ---------------------------------------------------------------------- + -- 1. Inserción del Padre (Tipos Cuota) + ---------------------------------------------------------------------- + INSERT INTO tipos_cuota ( + -- No insertamos ID manual, dejamos que el default gen_random_uuid() actúe + nombre, + descripcion, + dias_semana, + precio, + parasocios, + dia_de_pago, + recargo + ) VALUES ( + p_datos->>'nombre', + p_datos->>'descripcion', + (p_datos->>'dias_semana')::SMALLINT, + COALESCE((p_datos->>'precio')::NUMERIC, 0), + COALESCE((p_datos->>'para_socios')::BOOLEAN, FALSE), + (p_datos->>'dia_de_pago')::SMALLINT, -- Cast a SMALLINT según DDL + (p_datos->>'recargo')::NUMERIC -- NULL si no viene, correcto + ) + RETURNING id INTO v_tipo_id; -- Capturamos el ID generado + + ---------------------------------------------------------------------- + -- 2. Inserción Masiva de Actividades (Sin Bucle) + ---------------------------------------------------------------------- + IF p_datos ? 'actividades_ids' THEN + + IF jsonb_typeof(p_datos->'actividades_ids') <> 'array' THEN + RAISE EXCEPTION 'El campo actividades_ids debe ser un array JSON'; + END IF; + + -- MAGIA SQL: Insertamos todo de un golpe desglosando el array + INSERT INTO actividades_tipos_cuota (tipo_cuota_id, actividad_id) + SELECT + v_tipo_id, + (actividad_json)::INT + FROM jsonb_array_elements_text(p_datos->'actividades_ids') AS actividad_json; + + END IF; + + ---------------------------------------------------------------------- + -- 3. Retorno Reutilizando Lógica + ---------------------------------------------------------------------- + -- Aprovechamos que ya optimizamos fc_obtener_tipo_cuota para devolver + -- el objeto completo con sus actividades ya formateadas. + -- Nota: Esto devuelve un array de 1 elemento [{}], es consistente. + RETURN fc_obtener_tipo_cuota(p_token, v_tipo_id); +END; +$function$; diff --git a/database/functions/tipos_cuota/fc_modificar_tipo_cuota.sql b/database/functions/tipos_cuota/fc_modificar_tipo_cuota.sql new file mode 100644 index 0000000..9f638c5 --- /dev/null +++ b/database/functions/tipos_cuota/fc_modificar_tipo_cuota.sql @@ -0,0 +1,120 @@ +-- ============================================================================ +-- fc_modificar_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Modifica un tipo de cuota existente. Semántica PATCH para los campos +-- escalares; el array de actividades, si se pasa, REEMPLAZA al conjunto +-- anterior (no merge). +-- +-- DOMINIO +-- Mantenimiento de los planes ofrecidos. La modificación del precio o +-- de las actividades NO afecta retroactivamente a los pagos ya +-- registrados: cada pago captura un snapshot del plan en su detalle al +-- momento del cobro (Documentation/DominioPagos.md §5.1). +-- +-- PARÁMETROS +-- p_id UUID (obligatorio) ID del tipo de cuota a modificar. +-- p_datos JSONB con los campos a actualizar. Sólo se modifican las claves +-- presentes. Mismos nombres que fc_insertar_tipo_cuota. +-- Caso especial: +-- - actividades_ids: si está presente, REEMPLAZA la lista +-- actual (borra todo y reinserta). Pasar [] vacía la +-- asociación; no pasar la clave deja el set intacto. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_tipos_cuota'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El tipo de cuota con ID X no existe.' +-- - 'El campo actividades_ids debe ser un array JSON' si se pasa pero +-- no es un array. +-- +-- RETORNA +-- JSONB (objeto único, no array) con el tipo de cuota tras la +-- modificación, mismo shape que un ítem de fc_obtener_tipo_cuota. +-- +-- EFECTOS SECUNDARIOS +-- - UPDATE en tipos_cuota. +-- - Si actividades_ids está presente: DELETE de las filas previas en +-- actividades_tipos_cuota para este tipo de cuota + INSERTs nuevos. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_modificar_tipo_cuota( + p_id UUID, -- Recibimos el ID explícitamente (Mejor práctica) + p_datos JSONB, + p_token UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE -- Checklist #2: Modifica datos masivamente +AS $function$ +DECLARE + -- No necesitamos variables extra +BEGIN + ------------------------------------------------------------------------- + -- Checklist #3: Validar permisos (Centralizado) + ------------------------------------------------------------------------- + -- Acción sugerida: 'modificar_tipo_cuota' + PERFORM internal.validate_permission(p_token, 'modificar_tipos_cuota'); + + ------------------------------------------------------------------------- + -- 1. Validaciones previas + ------------------------------------------------------------------------- + -- Verificamos existencia del registro padre antes de intentar nada + PERFORM 1 FROM tipos_cuota WHERE id = p_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'El tipo de cuota con ID % no existe.', p_id; + END IF; + + ------------------------------------------------------------------------- + -- 2. UPDATE dinámico del Padre (Tipos Cuota) + ------------------------------------------------------------------------- + -- Usamos tu lógica de CASE WHEN ? para respetar nulos vs no enviados + UPDATE tipos_cuota + SET + nombre = CASE WHEN p_datos ? 'nombre' THEN p_datos->>'nombre' ELSE nombre END, + descripcion = CASE WHEN p_datos ? 'descripcion' THEN p_datos->>'descripcion' ELSE descripcion END, + dias_semana = CASE WHEN p_datos ? 'dias_semana' THEN (p_datos->>'dias_semana')::SMALLINT ELSE dias_semana END, + precio = CASE WHEN p_datos ? 'precio' THEN (p_datos->>'precio')::NUMERIC ELSE precio END, + parasocios = CASE WHEN p_datos ? 'para_socios' THEN (p_datos->>'para_socios')::BOOL ELSE parasocios END, + dia_de_pago = CASE WHEN p_datos ? 'dia_de_pago' THEN (p_datos->>'dia_de_pago')::SMALLINT ELSE dia_de_pago END, -- Cast a SMALLINT + recargo = CASE WHEN p_datos ? 'recargo' THEN (p_datos->>'recargo')::NUMERIC ELSE recargo END + WHERE id = p_id; + + ------------------------------------------------------------------------- + -- 3. Actualización de Relaciones (Actividades) - SET BASED + ------------------------------------------------------------------------- + -- Solo entramos si la key existe. Si mandan [] borra todo. Si no la mandan, no se toca. + IF p_datos ? 'actividades_ids' THEN + + IF jsonb_typeof(p_datos->'actividades_ids') <> 'array' THEN + RAISE EXCEPTION 'El campo actividades_ids debe ser un array JSON'; + END IF; + + -- 3.1 Borrado masivo (Limpiar la casa) + DELETE FROM actividades_tipos_cuota + WHERE tipo_cuota_id = p_id; + + -- 3.2 Inserción masiva (Sin bucle FOR) + -- Desglosamos el JSON array en filas y las insertamos de golpe + INSERT INTO actividades_tipos_cuota (tipo_cuota_id, actividad_id) + SELECT + p_id, + (actividad_json)::INT + FROM jsonb_array_elements_text(p_datos->'actividades_ids') AS actividad_json; + + END IF; + + ------------------------------------------------------------------------- + -- 4. Retorno reutilizando la función de lectura + ------------------------------------------------------------------------- + -- Como fc_obtener_tipo_cuota devuelve un array [{}], extraemos el elemento 0 + RETURN ( + SELECT jsonb_array_element(fc_obtener_tipo_cuota(p_token, p_id), 0) + ); + +END; +$function$; diff --git a/database/functions/tipos_cuota/fc_obtener_tipo_cuota.sql b/database/functions/tipos_cuota/fc_obtener_tipo_cuota.sql new file mode 100644 index 0000000..72d9065 --- /dev/null +++ b/database/functions/tipos_cuota/fc_obtener_tipo_cuota.sql @@ -0,0 +1,89 @@ +-- ============================================================================ +-- fc_obtener_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Devuelve los tipos de cuota (planes) configurados. Si se pasa p_id, sólo +-- retorna ese. +-- +-- DOMINIO +-- Los tipos de cuota representan los "planes" que el gimnasio ofrece a sus +-- clientes (Documentation/DominioPagos.md §5.1): definen precio, frecuencia +-- semanal, día sugerido de pago, recargo por mora opcional, y el conjunto +-- de actividades que el cliente puede usar. Cada pago de cuota_mensual +-- referencia un tipo de cuota al momento del cobro. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_id UUID (opcional, default NULL) si se pasa, filtra al tipo de +-- cuota con ese ID. Si es NULL, devuelve todos. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_tipos_cuota'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: id, nombre, descripcion, +-- dias_semana, precio, parasocios, dia_de_pago, recargo y un array +-- actividades_ids con los IDs de actividades asociadas. Ordenado por +-- nombre ascendente. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_obtener_tipo_cuota( + p_token UUID, + p_id UUID DEFAULT NULL +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public +STABLE -- Checklist #2: Solo lectura +AS $function$ +DECLARE + -- No hacen falta variables extra +BEGIN + ------------------------------------------------------------ + -- Checklist #3: Validar permisos (Centralizado) + ------------------------------------------------------------ + -- Asumimos la acción 'ver_tipos_cuota' + PERFORM internal.validate_permission(p_token, 'ver_tipos_cuota'); + + ------------------------------------------------------------ + -- Checklist #4: Lógica unificada y eficiente (Sin N+1) + ------------------------------------------------------------ + RETURN ( + SELECT COALESCE(jsonb_agg(cuota_json), '[]'::jsonb) + FROM ( + SELECT jsonb_build_object( + 'id', t.id, + 'nombre', t.nombre, + 'descripcion', t.descripcion, + 'dias_semana', t.dias_semana, + 'precio', t.precio, + 'parasocios', t.parasocios, + 'dia_de_pago', t.dia_de_pago, + 'recargo', t.recargo, + + -- OPTIMIZACIÓN CRÍTICA: + -- En lugar de llamar a una función externa que valide token N veces, + -- hacemos una subquery directa a la tabla intermedia que me pasaste. + 'actividades_ids', ( + SELECT COALESCE(jsonb_agg(atc.actividad_id), '[]'::jsonb) + FROM public.actividades_tipos_cuota atc + WHERE atc.tipo_cuota_id = t.id + ) + + -- Opcional: Si en el futuro quieres devolver los objetos completos (id, nombre) + -- en lugar de solo los IDs, cambiarías la subquery de arriba por un JOIN + -- a la tabla 'actividades'. + ) AS cuota_json + FROM public.tipos_cuota t + WHERE (p_id IS NULL OR t.id = p_id) + ORDER BY t.nombre ASC + ) result + ); +END; +$function$ LANGUAGE plpgsql; diff --git a/database/functions/tipos_cuota/fc_obtener_usuario_tipo_cuota.sql b/database/functions/tipos_cuota/fc_obtener_usuario_tipo_cuota.sql new file mode 100644 index 0000000..57a472f --- /dev/null +++ b/database/functions/tipos_cuota/fc_obtener_usuario_tipo_cuota.sql @@ -0,0 +1,113 @@ +-- ============================================================================ +-- fc_obtener_usuario_tipo_cuota +-- ============================================================================ +-- PROPÓSITO +-- Devuelve la asociación usuario → tipo de cuota: para cada usuario, qué +-- plan tiene asignado (o NULL si no tiene). Filtra por DNI si se pasa. +-- +-- DOMINIO +-- Vista cruzada entre usuarios y tipos de cuota. Cada cliente puede tener +-- asociado un plan por defecto (Documentation/DominioPagos.md §5.1), que +-- es el que se usa al registrar un pago si no se especifica otro. +-- +-- NOTA HISTÓRICA +-- El comentario original sobre esta función dice "SI NO SE USA, BORRAR". +-- Antes de eliminarla, revisar usos en el frontend y en otras funciones. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_dni TEXT (opcional, default NULL) si se pasa, devuelve sólo el +-- usuario con ese DNI. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_usuarios_cuotas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No existe un usuario con el DNI X' cuando p_dni se pasa y no +-- coincide con ningún usuario. +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: { cliente: {id, nombre, +-- apellido, dni}, cuota: {id, nombre, descripcion, precio, dias_semana, +-- dia_de_pago, parasocios} | NULL }. La cuota es NULL cuando el usuario +-- no tiene plan asignado. Ordenado por apellido, nombre. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_obtener_usuario_tipo_cuota( + p_token UUID, + p_dni TEXT DEFAULT NULL -- Opcional +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public +STABLE +LANGUAGE plpgsql +AS $function$ +DECLARE + v_cliente_id UUID := NULL; +BEGIN + -- ------------------------------------------------------------ + -- Checklist #3: Validar permisos + -- ------------------------------------------------------------ + -- Acción sugerida: 'ver_usuarios_cuotas' + PERFORM internal.validate_permission(p_token, 'ver_usuarios_cuotas'); + + -- ------------------------------------------------------------ + -- 1. Resolver ID si viene DNI + -- ------------------------------------------------------------ + IF p_dni IS NOT NULL THEN + SELECT id INTO v_cliente_id + FROM usuarios + WHERE dni = p_dni; + + IF NOT FOUND THEN + RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni; + END IF; + END IF; + + -- ------------------------------------------------------------ + -- 2. Consulta Unificada y Estructurada + -- ------------------------------------------------------------ + RETURN ( + SELECT COALESCE(jsonb_agg(fila_json), '[]'::jsonb) + FROM ( + SELECT jsonb_build_object( + -- Objeto CLIENTE + 'cliente', jsonb_build_object( + 'id', u.id, + 'nombre', u.nombre, + 'apellido', u.apellido, + 'dni', u.dni + ), + -- Objeto CUOTA (Puede ser NULL si el usuario no tiene plan) + 'cuota', CASE WHEN t.id IS NULL THEN NULL ELSE + jsonb_build_object( + 'id', t.id, + 'nombre', t.nombre, + 'descripcion', t.descripcion, + 'precio', t.precio, + 'dias_semana', t.dias_semana, + 'dia_de_pago', t.dia_de_pago, + 'parasocios', t.parasocios + ) + END + ) AS fila_json + + -- CORRECCIÓN DE JOIN: + -- Partimos de usuarios, porque queremos saber qué cuota tienen ELLOS. + FROM public.usuarios u + LEFT JOIN public.tipos_cuota t ON u.tipo_cuota = t.id + + WHERE + -- Filtro inteligente: Todos o Uno + (v_cliente_id IS NULL OR u.id = v_cliente_id) + + -- Ordenar por apellido para que la lista sea legible + ORDER BY u.apellido, u.nombre + ) c + ); +END; +$function$; diff --git a/database/functions/turnos/fc_cancelar_reserva.sql b/database/functions/turnos/fc_cancelar_reserva.sql new file mode 100644 index 0000000..e02465f --- /dev/null +++ b/database/functions/turnos/fc_cancelar_reserva.sql @@ -0,0 +1,105 @@ +-- ============================================================================ +-- fc_cancelar_reserva +-- ============================================================================ +-- PROPÓSITO +-- Cancela (soft-delete) una reserva. Aplica la regla de anticipación +-- mínima: por defecto el cliente debe cancelar con al menos 24 horas +-- de anticipación. Sólo el propio cliente puede cancelar su reserva +-- por esta vía. +-- +-- DOMINIO +-- Implementa la operación **"cancelar"** ejecutada por el cliente +-- (Documentation/DominioHorarios.md §7.5 reserva, §9.2 reglas para +-- cancelar). El parámetro `p_horas_anticipacion` materializa la +-- **ventana de cancelación** del dominio (§9.2: "un valor global, único +-- para todo el gimnasio, decidido por el operador"). El default 24h se +-- asume cuando el caller no lo pasa; el dominio espera que el operador +-- decida el valor real y se aplique consistentemente. +-- +-- Pasada la ventana, el dominio describe la reserva como "firme desde +-- el lado del cliente": esta función rechaza la cancelación. El +-- bypasseo para el operador vive en `fc_cancelar_reserva_admin` (§9.6). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (cliente). +-- p_cliente_id UUID ID del cliente dueño de la reserva. +-- p_reserva_id UUID ID de la reserva a cancelar. +-- p_horas_anticipacion INT (default 24) horas mínimas de anticipación. +-- Parametrizable porque es regla de negocio, +-- no crítica. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'reservar_turno' (mismo que reserva: el cliente +-- gestiona sus propias reservas). +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La reserva no existe, no te pertenece o ya fue cancelada.' +-- - 'No podés cancelar la reserva con menos de X horas de anticipación.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje}. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE en reservas: setea cancelada=true y cancelada_en=now(). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_cancelar_reserva( + p_token UUID, + p_cliente_id UUID, + p_reserva_id UUID, + p_horas_anticipacion INT DEFAULT 24 -- Horas límite para cancelar, está como parámetro porque no es crítico +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_turno_timestamp TIMESTAMP; +BEGIN + ------------------------------------------------------------ + -- 1. Autenticación + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'reservar_turno'); + + ------------------------------------------------------------ + -- 2. Validar Existencia y Propiedad + ------------------------------------------------------------ + -- Buscamos la reserva y calculamos el Timestamp exacto del turno + SELECT (t.fecha + t.hora_inicio)::TIMESTAMP + INTO v_turno_timestamp + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE r.id = p_reserva_id + AND r.cliente_id = p_cliente_id -- ¡Vital para que no cancelen reservas ajenas! + AND r.cancelada = false; + + IF NOT FOUND THEN + RAISE EXCEPTION 'La reserva no existe, no te pertenece o ya fue cancelada.'; + END IF; + + ------------------------------------------------------------ + -- 3. Validar Anticipación (La regla de las X horas) + ------------------------------------------------------------ + -- LOCALTIMESTAMP nos da la fecha y hora actual del servidor + IF v_turno_timestamp - LOCALTIMESTAMP < (p_horas_anticipacion || ' hours')::INTERVAL THEN + RAISE EXCEPTION 'No podés cancelar la reserva con menos de % horas de anticipación.', p_horas_anticipacion; + END IF; + + ------------------------------------------------------------ + -- 4. Ejecutar Cancelación (Soft Delete) + ------------------------------------------------------------ + UPDATE reservas + SET cancelada = true, + cancelada_en = now() + WHERE id = p_reserva_id; + + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'Tu reserva fue cancelada con éxito.' + ); + +END; +$function$; diff --git a/database/functions/turnos/fc_cancelar_reserva_admin.sql b/database/functions/turnos/fc_cancelar_reserva_admin.sql new file mode 100644 index 0000000..dd5c954 --- /dev/null +++ b/database/functions/turnos/fc_cancelar_reserva_admin.sql @@ -0,0 +1,76 @@ +-- ============================================================================ +-- fc_cancelar_reserva_admin +-- ============================================================================ +-- PROPÓSITO +-- Versión administrativa de cancelar reserva. Bypassea las validaciones +-- de propiedad y de horas de anticipación; sólo verifica que la reserva +-- exista y no esté ya cancelada. +-- +-- DOMINIO +-- Implementa la operación **"cancelar"** cuando la ejecuta el operador +-- (Documentation/DominioHorarios.md §4, §9.2, §9.6). Materializa +-- directamente la regla del dominio: "el operador puede cancelar +-- cualquier reserva, en cualquier momento, sin restricciones". +-- Bypassea pertenencia (no exige ser el cliente dueño) y ventana de +-- cancelación. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (admin). +-- p_reserva_id UUID ID de la reserva a cancelar. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'gestionar_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La reserva no existe o ya se encuentra cancelada.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje}. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE en reservas: setea cancelada=true y cancelada_en=now(). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_cancelar_reserva_admin( + p_token UUID, + p_reserva_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +BEGIN + ------------------------------------------------------------ + -- 1. Autenticación Elevada + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'gestionar_reservas'); + + ------------------------------------------------------------ + -- 2. Validar Existencia + ------------------------------------------------------------ + IF NOT EXISTS (SELECT 1 FROM reservas WHERE id = p_reserva_id AND cancelada = false) THEN + RAISE EXCEPTION 'La reserva no existe o ya se encuentra cancelada.'; + END IF; + + -- OMITIDAS intencionalmente (Bypass Administrativo): + -- ✖ Validación de Propiedad (cliente_id) + -- ✖ Validación de Horas de Anticipación + + ------------------------------------------------------------ + -- 3. Ejecutar Cancelación (Soft Delete) + ------------------------------------------------------------ + UPDATE reservas + SET cancelada = true, + cancelada_en = now() + WHERE id = p_reserva_id; + + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'La reserva fue cancelada administrativamente.' + ); + +END; +$function$; diff --git a/database/functions/turnos/fc_mover_reserva_huerfana.sql b/database/functions/turnos/fc_mover_reserva_huerfana.sql new file mode 100644 index 0000000..1411f40 --- /dev/null +++ b/database/functions/turnos/fc_mover_reserva_huerfana.sql @@ -0,0 +1,157 @@ +-- ============================================================================ +-- fc_mover_reserva_huerfana +-- ============================================================================ +-- PROPÓSITO +-- Reubica una reserva huérfana en un turno nuevo: crea la reserva real +-- y marca la huérfana como 'reubicado'. Atómica: bloquea la huérfana +-- y el turno para evitar dobles reubicaciones y carreras concurrentes. +-- +-- DOMINIO +-- Implementa una de las dos resoluciones que el dominio admite para una +-- reserva huérfana (Documentation/DominioHorarios.md §7.8): la +-- **reasignación por el operador**, donde el operador le crea al +-- cliente la nueva reserva en lugar de esperar a que el cliente la haga +-- por su cuenta. El dominio §7.8 lo describe como "opcional caso a +-- caso, no es una obligación". +-- +-- El correlato de notificación al cliente (§7.9) cambia según el +-- resultado: si la huérfana sigue pendiente, el aviso es "tu reserva se +-- cayó, podés reservar de nuevo"; si la reasignaste con esta función, +-- el aviso pasa a ser "te reasignamos tu turno a esto otro". Esa +-- distinción la maneja el caller; esta función sólo mueve la huérfana +-- a estado 'reubicado'. +-- +-- NOTA DE COHERENCIA. Reasignar es uno de los actos del operador que +-- §9.6 cubre ("al reservar, cancelar o **asignar** en nombre de un +-- cliente"). Por eso esta función no bloquea por cupo del bloque: si +-- el destino está lleno, la ocupación queda por encima de +-- `capacidad_maxima` y se ve naturalmente en la grilla. La UI muestra +-- la advertencia antes de invocar. El chequeo de "mismo cliente, mismo +-- turno" sí se mantiene — eso es integridad, no regla operativa. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_huerfana_id UUID ID de la reserva huérfana (debe estar 'pendiente'). +-- p_turno_id UUID ID del turno destino (debe estar activo). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'gestionar_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La reserva huérfana no existe.' +-- - 'La reserva huérfana ya fue resuelta (estado: X).' +-- - 'El turno no existe, fue cancelado o no está disponible.' +-- - 'El cliente ya tiene una reserva activa en este turno.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}. +-- +-- EFECTOS SECUNDARIOS +-- - INSERT en reservas (cancelada=false, reservada_en=now()). Puede +-- dejar el turno con ocupacion > capacidad_maxima si el operador +-- reubica a un bloque lleno. +-- - UPDATE en reservas_huerfanas: estado_resolucion='reubicado', +-- resuelto_en=now() (arranca la ventana de gracia previa a purga; +-- ver internal.limpiar_huerfanas_resueltas y la clave +-- `retencion.huerfanas_resueltas_dias`). +-- ============================================================================ + +-- Para hacer una nueva reserva en base a una reserva huérfana + +CREATE OR REPLACE FUNCTION public.fc_mover_reserva_huerfana( + p_token UUID, + p_huerfana_id UUID, + p_turno_id UUID + ) + RETURNS JSONB + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + VOLATILE + AS $function$ + DECLARE + v_cliente_id UUID; + v_estado_actual VARCHAR(20); + v_fecha_turno DATE; + v_hora_inicio TIME; + v_nueva_reserva_id UUID; + BEGIN + ------------------------------------------------------------ + -- 1. Autenticación + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'gestionar_reservas'); + + ------------------------------------------------------------ + -- 2. Verificar que la huérfana existe y sigue pendiente + ------------------------------------------------------------ + SELECT cliente_id, estado_resolucion + INTO v_cliente_id, v_estado_actual + FROM reservas_huerfanas + WHERE id = p_huerfana_id + FOR UPDATE; -- evita doble reubicación concurrente + + IF NOT FOUND THEN + RAISE EXCEPTION 'La reserva huérfana no existe.'; + END IF; + + IF v_estado_actual <> 'pendiente' THEN + RAISE EXCEPTION 'La reserva huérfana ya fue resuelta (estado: %).', v_estado_actual; + END IF; + + ------------------------------------------------------------ + -- 3. Bloquear turno (integridad: existe y está activo) + ------------------------------------------------------------ + -- El lock evita carreras concurrentes en la huérfana/turno aunque + -- ya no chequeamos cupo (§9.6: reasignar es un acto del operador + -- y el cupo del bloque es advertencia, no bloqueo; la UI lo muestra + -- antes de invocar). + SELECT t.fecha, t.hora_inicio + INTO v_fecha_turno, v_hora_inicio + FROM turnos t + WHERE t.id = p_turno_id AND t.activo = true + FOR NO KEY UPDATE OF t; + + IF NOT FOUND THEN + RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.'; + END IF; + + ------------------------------------------------------------ + -- 4. Evitar reserva duplicada del mismo cliente en ese turno + -- (integridad, no regla operativa) + ------------------------------------------------------------ + IF EXISTS ( + SELECT 1 FROM reservas + WHERE turno_id = p_turno_id + AND cliente_id = v_cliente_id + AND cancelada = false + ) THEN + RAISE EXCEPTION 'El cliente ya tiene una reserva activa en este turno.'; + END IF; + + ------------------------------------------------------------ + -- 5. Insertar reserva + ------------------------------------------------------------ + INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada) + VALUES (p_turno_id, v_cliente_id, now(), false) + RETURNING id INTO v_nueva_reserva_id; + + ------------------------------------------------------------ + -- 6. Marcar huérfana como reubicada + ------------------------------------------------------------ + UPDATE reservas_huerfanas + SET estado_resolucion = 'reubicado', + resuelto_en = now() + WHERE id = p_huerfana_id; + + ------------------------------------------------------------ + -- 7. Retorno + ------------------------------------------------------------ + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'Reserva huérfana reubicada con éxito.', + 'reserva_id', v_nueva_reserva_id, + 'fecha', v_fecha_turno, + 'hora_inicio', to_char(v_hora_inicio, 'HH24:MI') + ); + END; + $function$; diff --git a/database/functions/turnos/fc_obtener_estado_cupo.sql b/database/functions/turnos/fc_obtener_estado_cupo.sql new file mode 100644 index 0000000..8a0e175 --- /dev/null +++ b/database/functions/turnos/fc_obtener_estado_cupo.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- fc_obtener_estado_cupo +-- ============================================================================ +-- PROPÓSITO +-- Devuelve el estado del cupo semanal de un cliente para la semana en +-- la que cae la fecha dada: cuántas reservas activas usó y cuántas le +-- quedan según el límite de su plan. +-- +-- DOMINIO +-- Materializa la consulta del **cupo semanal del plan** +-- (Documentation/DominioHorarios.md §7.7) de un cliente para la semana +-- en que cae una fecha. Es la fuente que sostiene la condición §9.1 +-- "el cliente no debe haber agotado su cupo semanal del plan". +-- +-- El cupo se contabiliza sobre **todas** las reservas activas del +-- cliente en la semana, sin distinguir su origen — eso es coherente con +-- §7.5: "una reserva es indistinguible por su origen; ocupa cupo... de +-- la misma manera". +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_cliente_id UUID ID del cliente. +-- p_fecha DATE fecha dentro de la semana a evaluar. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB con {usados, disponibles, limite_total, tiene_plan}. +-- - Si el cliente no tiene plan activo: {usados: 0, disponibles: 0, +-- limite_total: 0, tiene_plan: false}. +-- - Si tiene plan: valores reales basados en tipos_cuota.dias_semana. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_estado_cupo( + p_token UUID, + p_cliente_id UUID, + p_fecha DATE +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +DECLARE + v_limite_semanal INT; + v_reservas_usadas INT; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_reservas'); + + -- 1. Identificar el límite del plan del cliente + SELECT tc.dias_semana INTO v_limite_semanal + FROM usuarios u + LEFT JOIN tipos_cuota tc ON u.tipo_cuota = tc.id + WHERE u.id = p_cliente_id AND u.isactive = true; + + IF v_limite_semanal IS NULL THEN + -- El cliente no tiene un plan activo asignado + RETURN jsonb_build_object( + 'usados', 0, + 'disponibles', 0, + 'limite_total', 0, + 'tiene_plan', false + ); + END IF; + + -- 2. Contar instancias de reserva activa en el bloque semanal + SELECT COUNT(*)::INT INTO v_reservas_usadas + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE r.cliente_id = p_cliente_id + AND r.cancelada = false + AND date_trunc('week', t.fecha) = date_trunc('week', p_fecha); + + -- 3. Retornar el cálculo + RETURN jsonb_build_object( + 'usados', v_reservas_usadas, + 'disponibles', GREATEST(0, v_limite_semanal - v_reservas_usadas), + 'limite_total', v_limite_semanal, + 'tiene_plan', true + ); +END; +$function$; diff --git a/database/functions/turnos/fc_obtener_reservas_cliente.sql b/database/functions/turnos/fc_obtener_reservas_cliente.sql new file mode 100644 index 0000000..dcfe0f2 --- /dev/null +++ b/database/functions/turnos/fc_obtener_reservas_cliente.sql @@ -0,0 +1,95 @@ +-- ============================================================================ +-- fc_obtener_reservas_cliente +-- ============================================================================ +-- PROPÓSITO +-- Devuelve las reservas de un cliente con datos de turno y actividad. +-- Por defecto excluye canceladas; opcionalmente las incluye. Permite +-- filtrar por fecha mínima. +-- +-- DOMINIO +-- Consulta de las **reservas** de un cliente +-- (Documentation/DominioHorarios.md §7.5). Es la fuente para la vista +-- que §13 le exige a la interfaz: "que el cliente vea sus reservas +-- activas". También sirve al operador como vista del compromiso vigente +-- de un cliente concreto. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_cliente_id UUID ID del cliente. +-- p_incluir_canceladas BOOLEAN (default false) si true, también trae +-- las reservas canceladas. +-- p_fecha_desde DATE (opcional) trae sólo reservas cuyo turno +-- tiene fecha >= a esta. NULL = todas. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_reservas_propias'. Se asume que el sistema de +-- permisos maneja la dualidad admin / cliente sobre sí mismo; si no, +-- conviene reforzar acá con un IF explícito (ver comentario en la +-- función). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: { reserva_id, turno_id, +-- fecha, hora_inicio, hora_fin, cancelada, reservada_en, actividad: +-- {id, nombre, libre} }. Ordenado por fecha y hora descendentes. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_cliente( + p_token UUID, + p_cliente_id UUID, + p_incluir_canceladas BOOLEAN DEFAULT false, + p_fecha_desde DATE DEFAULT NULL +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +BEGIN + ------------------------------------------------------------ + -- 1. Autenticación y Permisos + ------------------------------------------------------------ + -- Aquí debe validarse que el p_token corresponda a un admin (Juani) + -- o que el p_token corresponda exactamente al mismo p_cliente_id (el usuario viendo su propia app). + -- Asumiremos que internal.validate_permission ya maneja esta dualidad, + -- o bien se puede agregar un bloque IF explícito si tu lógica de tokens lo requiere. + PERFORM internal.validate_permission(p_token, 'ver_reservas_propias'); + + ------------------------------------------------------------ + -- 2. Consulta y armado del JSON + ------------------------------------------------------------ + RETURN COALESCE(( + SELECT jsonb_agg( + jsonb_build_object( + 'reserva_id', r.id, + 'turno_id', t.id, + 'fecha', t.fecha, + 'hora_inicio', to_char(t.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(t.hora_fin, 'HH24:MI'), + 'cancelada', r.cancelada, + 'reservada_en', r.reservada_en, + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'libre', a.libre + ) + ) ORDER BY t.fecha DESC, t.hora_inicio DESC + ) + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + JOIN actividades a ON t.actividad_id = a.id + WHERE r.cliente_id = p_cliente_id + -- Filtro condicional para canceladas + AND (p_incluir_canceladas = true OR r.cancelada = false) + -- Filtro condicional de fecha + AND (p_fecha_desde IS NULL OR t.fecha >= p_fecha_desde) + ), '[]'::jsonb); + +END; +$function$; diff --git a/database/functions/turnos/fc_obtener_reservas_huerfanas.sql b/database/functions/turnos/fc_obtener_reservas_huerfanas.sql new file mode 100644 index 0000000..ed475c5 --- /dev/null +++ b/database/functions/turnos/fc_obtener_reservas_huerfanas.sql @@ -0,0 +1,84 @@ +-- ============================================================================ +-- fc_obtener_reservas_huerfanas +-- ============================================================================ +-- PROPÓSITO +-- Devuelve las reservas huérfanas: aquellas que fueron rescatadas +-- automáticamente cuando un turno dejó de existir (por desactivación +-- de actividad, eliminación de día especial, cambio de plantilla +-- horaria, etc.). Permite filtrar por estado y por fecha de creación. +-- +-- DOMINIO +-- Consulta del catálogo de **reservas huérfanas** +-- (Documentation/DominioHorarios.md §7.8). Sirve a lo que §13 le pide a +-- la interfaz: "que el operador vea las reservas huérfanas pendientes y +-- las resuelva si lo desea (reasignándolas), o las deje para que el +-- cliente las resuelva por su cuenta". +-- +-- El parámetro `p_creada_desde` habilita el caso "ver las huérfanas +-- que se generaron a raíz de la operación que acabo de hacer": al +-- pasarlo el timestamp previo a un cambio estructural, el caller +-- obtiene la cosecha de huérfanas producida por su edición. Sirve para +-- el aviso post-operación que §13 espera ("la interfaz le muestra al +-- operador qué clientes quedaron afectados"). +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_estado VARCHAR (opcional, default 'pendiente') filtra +-- por estado de resolución. NULL = todos. +-- Valores válidos: 'pendiente', +-- 'reubicado', 'resuelta'. +-- p_creada_desde TIMESTAMPTZ (opcional) trae sólo huérfanas creadas +-- en o después de esta fecha. NULL = todas. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'gestionar_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: { huerfana_id, cliente_id, +-- nombre, apellido, telefono, actividad_nombre, fecha_original, +-- hora_inicio_original, estado_resolucion, creada_en }. Ordenado por +-- creada_en descendente. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_huerfanas( + p_token UUID, + p_estado VARCHAR DEFAULT 'pendiente', + p_creada_desde TIMESTAMPTZ DEFAULT NULL +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +BEGIN + PERFORM internal.validate_permission(p_token, 'gestionar_reservas'); + + RETURN COALESCE(( + SELECT jsonb_agg( + jsonb_build_object( + 'huerfana_id', rh.id, + 'cliente_id', u.id, + 'nombre', u.nombre, + 'apellido', u.apellido, + 'telefono', u.telefono, + 'actividad_nombre', rh.actividad_nombre, + 'fecha_original', rh.fecha_original, + 'hora_inicio_original', to_char(rh.hora_inicio_original, 'HH24:MI'), + 'estado_resolucion', rh.estado_resolucion, + 'creada_en', rh.creada_en + ) ORDER BY rh.creada_en DESC + ) + FROM reservas_huerfanas rh + JOIN usuarios u ON rh.cliente_id = u.id + WHERE (rh.estado_resolucion = p_estado OR p_estado IS NULL) + AND (p_creada_desde IS NULL OR rh.creada_en >= p_creada_desde) + ), '[]'::jsonb); +END; +$function$; diff --git a/database/functions/turnos/fc_obtener_reservas_turno.sql b/database/functions/turnos/fc_obtener_reservas_turno.sql new file mode 100644 index 0000000..331e9c9 --- /dev/null +++ b/database/functions/turnos/fc_obtener_reservas_turno.sql @@ -0,0 +1,62 @@ +-- ============================================================================ +-- fc_obtener_reservas_turno +-- ============================================================================ +-- PROPÓSITO +-- Devuelve la lista de reservas asociadas a un turno, incluyendo +-- canceladas, con datos básicos del cliente. +-- +-- DOMINIO +-- Lectura del estado de un **bloque** desde el lado del operador +-- (Documentation/DominioHorarios.md §7.3 + §7.5). Es la fuente con la +-- que se materializa lo que §13 le pide a la interfaz: "que el operador +-- vea quién está anotado a qué bloque". +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_turno_id UUID ID del turno. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: { reserva_id, cliente_id, +-- nombre, apellido, cancelada, reservada_en }. Ordenado por reservada_en +-- ascendente. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_turno( + p_token UUID, + p_turno_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +STABLE +AS $function$ +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_reservas'); + + RETURN COALESCE(( + SELECT jsonb_agg( + jsonb_build_object( + 'reserva_id', r.id, + 'cliente_id', u.id, + 'nombre', u.nombre, + 'apellido', u.apellido, + 'cancelada', r.cancelada, + 'reservada_en', r.reservada_en + ) ORDER BY r.reservada_en ASC + ) + FROM reservas r + JOIN usuarios u ON r.cliente_id = u.id + WHERE r.turno_id = p_turno_id + ), '[]'::jsonb); +END; +$function$; diff --git a/database/functions/turnos/fc_obtener_turnos.sql b/database/functions/turnos/fc_obtener_turnos.sql new file mode 100644 index 0000000..bd42383 --- /dev/null +++ b/database/functions/turnos/fc_obtener_turnos.sql @@ -0,0 +1,209 @@ +-- ============================================================================ +-- fc_obtener_turnos +-- ============================================================================ +-- PROPÓSITO +-- Facade para obtener los turnos de un rango de días. Si los turnos no +-- están materializados todavía (estrategia JIT), los genera primero a +-- partir de la plantilla horaria (regular o especial) y después devuelve +-- el estado. Para días marcados como 'cerrado' no genera turnos. +-- +-- DOMINIO +-- Devuelve los **bloques reservables** (Documentation/DominioHorarios.md +-- §7.3) para un rango de días, con su ocupación. Es lo que la grilla del +-- gimnasio (§13) muestra al operador en términos de "qué turnos hay y +-- cuántos lugares quedan". +-- +-- La materialización JIT — generar turnos a partir de la plantilla la +-- primera vez que se consulta una fecha — es un **detalle de +-- implementación**. El dominio sólo conoce "bloques ofrecidos según la +-- plantilla vigente para esa fecha"; cuándo se persisten en la tabla no +-- le importa. La consecuencia práctica para el caller: esta función +-- está marcada VOLATILE aunque conceptualmente sea de lectura. +-- +-- Para días marcados como 'cerrado' (§7.4) no genera bloques: el día +-- especial sobrescribe la oferta a vacío. +-- +-- RETENCIÓN (US-R13) +-- La generación JIT se inhibe para fechas anteriores a la ventana de +-- purga de turnos (`retencion.turnos_meses`, default 2). Así una +-- consulta a una fecha vieja no re-materializa turnos que el pipeline +-- ya purgó (Documentation/PoliticaRetencion.md §7). La lectura no se +-- filtra: turnos viejos aún no purgados se siguen devolviendo. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_fecha_inicio DATE primer día del rango. +-- p_cantidad_dias SMALLINT (default 7) cantidad de días (1..31). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_turnos'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'p_cantidad_dias debe estar entre 1 y 31.' +-- +-- RETORNA +-- JSONB objeto con clave = fecha (YYYY-MM-DD), valor = { dia_semana, +-- estado: 'normal' | 'horario_diferente' | 'cerrado', turnos: [{id, +-- hora_inicio, hora_fin, capacidad_maxima, ocupacion, actividad}] }. +-- Si el día está cerrado, turnos = []. +-- +-- EFECTOS SECUNDARIOS +-- Marcada VOLATILE porque puede generar turnos JIT (INSERTs en turnos) +-- en fechas que aún no tienen materializados. +-- ============================================================================ + +-- FACADE para que el frontend obtenga los turnos, si hace falta los genera +CREATE OR REPLACE FUNCTION public.fc_obtener_turnos( + p_token UUID, + p_fecha_inicio DATE, + p_cantidad_dias SMALLINT DEFAULT 7 +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE +AS $function$ +DECLARE + v_fecha DATE; + v_dia_offset INT; + v_dia_semana SMALLINT; + v_resultado JSONB := '{}'::JSONB; + v_turnos_json JSONB; + v_especial RECORD; + v_bloque RECORD; + v_hora_iter TIME; + v_cutoff DATE; +BEGIN + PERFORM internal.validate_permission(p_token, 'ver_turnos'); + + IF p_cantidad_dias < 1 OR p_cantidad_dias > 31 THEN + RAISE EXCEPTION 'p_cantidad_dias debe estar entre 1 y 31.'; + END IF; + + -- Auditoría de retención (US-R13): no re-materializar turnos en fechas + -- ya fuera de la ventana de purga. Sin este corte, consultar una fecha + -- vieja regeneraría JIT turnos que internal.limpiar_turnos_antiguos + -- acaba de borrar — fabricando datos que el sistema considera purgables + -- (Documentation/PoliticaRetencion.md §7). Usamos la MISMA clave que el + -- purge (`retencion.turnos_meses`) para que el corte de lectura y el de + -- borrado nunca diverjan. La fase de LECTURA no se toca: si quedaran + -- turnos viejos sin purgar, se siguen mostrando; sólo no se generan. + v_cutoff := CURRENT_DATE - (internal.get_config_int('retencion.turnos_meses', 2) * INTERVAL '1 month'); + + FOR v_dia_offset IN 0..(p_cantidad_dias - 1) LOOP + v_fecha := p_fecha_inicio + v_dia_offset; + v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT; + + -- Leemos el estado del día especial UNA sola vez. + SELECT id, tipo INTO v_especial + FROM dias_especiales + WHERE fecha = v_fecha; + -- A partir de acá NO chequeamos FOUND: usamos v_especial.id IS NULL/NOT NULL. + + ------------------------------------------------------------ + -- FASE A: GENERACIÓN JIT + -- Bajo concurrencia, dos invocaciones simultáneas a la misma + -- fecha pueden pasar ambas el NOT EXISTS (READ COMMITTED no ve + -- inserts no commiteados) y entrar al bloque de generación. La + -- UNIQUE constraint `turnos_actividad_id_fecha_hora_inicio_key` + -- garantiza que el segundo INSERT colisione, y el ON CONFLICT + -- DO NOTHING lo descarta silenciosamente. Resultado: una sola + -- fila por turno, sin error visible al caller. + ------------------------------------------------------------ + IF v_fecha >= v_cutoff AND NOT EXISTS (SELECT 1 FROM turnos WHERE fecha = v_fecha) THEN + + IF v_especial.id IS NOT NULL AND v_especial.tipo = 'cerrado' THEN + -- Día explícitamente cerrado: no-op + NULL; + + ELSIF v_especial.id IS NOT NULL AND v_especial.tipo = 'horario_diferente' THEN + FOR v_bloque IN ( + SELECT hae.actividad_id, hae.hora_inicio, hae.hora_fin, + a.duracion, a.capacidad_por_defecto + FROM horario_actividad_especial hae + JOIN actividades a ON hae.actividad_id = a.id + WHERE hae.dia_especial_id = v_especial.id AND a.activo = true + ) LOOP + v_hora_iter := v_bloque.hora_inicio; + WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.hora_fin LOOP + INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin, + capacidad_maxima, es_especial, dia_semana) + VALUES ( + v_bloque.actividad_id, v_fecha, v_hora_iter, + v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL, + v_bloque.capacidad_por_defecto, true, v_dia_semana + ) ON CONFLICT DO NOTHING; + v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL; + END LOOP; + END LOOP; + + ELSE + -- Día normal: plantilla regular vigente + FOR v_bloque IN ( + SELECT ha.actividad_id, ha.hora_inicio, ha.hora_fin, + a.duracion, a.capacidad_por_defecto + FROM horario_actividad ha + JOIN actividades a ON ha.actividad_id = a.id + WHERE ha.dia_semana = v_dia_semana + AND ha.valido_desde <= v_fecha + AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= v_fecha) + AND a.activo = true + ) LOOP + v_hora_iter := v_bloque.hora_inicio; + WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.hora_fin LOOP + INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin, + capacidad_maxima, es_especial, dia_semana) + VALUES ( + v_bloque.actividad_id, v_fecha, v_hora_iter, + v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL, + v_bloque.capacidad_por_defecto, false, v_dia_semana + ) ON CONFLICT DO NOTHING; + v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL; + END LOOP; + END LOOP; + END IF; + END IF; + + ------------------------------------------------------------ + -- FASE B: LECTURA + ------------------------------------------------------------ + IF v_especial.id IS NOT NULL AND v_especial.tipo = 'cerrado' THEN + v_turnos_json := jsonb_build_object( + 'dia_semana', v_dia_semana, + 'estado', 'cerrado', + 'turnos', '[]'::JSONB + ); + ELSE + v_turnos_json := jsonb_build_object( + 'dia_semana', v_dia_semana, + 'estado', COALESCE(v_especial.tipo, 'normal'), + 'turnos', ( + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', t.id, + 'hora_inicio', to_char(t.hora_inicio, 'HH24:MI'), + 'hora_fin', to_char(t.hora_fin, 'HH24:MI'), + 'capacidad_maxima', t.capacidad_maxima, + 'ocupacion', (SELECT COUNT(*)::INT FROM reservas r + WHERE r.turno_id = t.id AND r.cancelada = false), + 'actividad', jsonb_build_object( + 'id', a.id, + 'nombre', a.nombre, + 'libre', a.libre + ) + ) ORDER BY t.hora_inicio ASC + ), '[]'::JSONB) + FROM turnos t + JOIN actividades a ON t.actividad_id = a.id + WHERE t.fecha = v_fecha AND t.activo = true + ) + ); + END IF; + + v_resultado := v_resultado || jsonb_build_object(v_fecha::TEXT, v_turnos_json); + END LOOP; + + RETURN v_resultado; +END; +$function$; diff --git a/database/functions/turnos/fc_reservar_turno.sql b/database/functions/turnos/fc_reservar_turno.sql new file mode 100644 index 0000000..879829f --- /dev/null +++ b/database/functions/turnos/fc_reservar_turno.sql @@ -0,0 +1,251 @@ +-- ============================================================================ +-- fc_reservar_turno +-- ============================================================================ +-- PROPÓSITO +-- Reserva un turno a nombre de un cliente. Aplica todas las validaciones +-- de negocio: el cliente debe estar al día con los pagos, el turno debe +-- pertenecer a una actividad incluida en su plan (salvo que sea libre), +-- debe respetar el límite semanal, no debe solaparse con otra reserva +-- suya, y el turno debe tener cupo. Pensada para que el cliente la +-- invoque desde la app. +-- +-- DOMINIO +-- Implementa la operación **"reservar"** ejecutada por el cliente +-- (Documentation/DominioHorarios.md §7.5 reserva, §8 gestión de +-- reservas). Aplica las reglas operativas de §9.1: plan vigente, +-- actividad habilitada por el plan, cupo del bloque, morosidad, cupo +-- semanal, no solape horario con otra reserva del cliente. +-- +-- El chequeo de no solape se hace sobre la misma fecha + intervalos +-- semiabiertos: dos reservas chocan si `existente.hora_inicio < +-- nuevo.hora_fin AND nuevo.hora_inicio < existente.hora_fin`. Esto +-- permite que el cliente reserve dos actividades distintas el mismo +-- día siempre que no se pisen físicamente (caso típico: Pilates a la +-- mañana, Funcional a la tarde). +-- +-- NOTAS DE COHERENCIA frente al dominio: +-- - El umbral de **morosidad** §9.3 es decisión del operador: se lee +-- desde internal.app_config con la clave 'pagos.umbral_morosidad_meses' +-- (cantidad mínima de meses impagos para considerar moroso). Default 2 +-- si la clave no está cargada todavía. Cuando exista la UI para que +-- Juani lo configure, va a escribir esa misma key. +-- - La medición del impago se hace como "meses transcurridos desde el +-- último anio_mes_pagado de un pago de tipo cuota_mensual no anulado, +-- o desde la fecha de alta del cliente si nunca pagó". +-- - El sistema no implementa todavía el concepto de **turno fijo** +-- (§7.6); esta función trata cada reserva como individual. +-- - Para el flujo del operador anotando en nombre del cliente con +-- libertad de saltar reglas (§9.6), ver fc_reservar_turno_admin. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (típicamente el cliente). +-- p_cliente_id UUID ID del cliente para el que se reserva. +-- p_turno_id UUID ID del turno. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'reservar_turno'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El turno no existe, fue cancelado o no está disponible.' +-- - 'No podés reservar un turno de una fecha pasada.' +-- - 'El cliente no existe o se encuentra inactivo.' +-- - 'Tenés una deuda de X meses. Regularizá tu situación de pago para +-- poder reservar.' +-- - 'Ya tenés una reserva ese día en un horario que se superpone con +-- este (HH:MM - HH:MM).' +-- - 'No tenés un plan activo asignado para reservar esta actividad.' +-- - 'Tu plan actual no incluye esta actividad.' +-- - 'Ya alcanzaste tu límite de X reservas para esta semana.' +-- - 'Lo sentimos, este turno ya no tiene lugares disponibles.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}. +-- +-- EFECTOS SECUNDARIOS +-- INSERT en reservas con cancelada=false y reservada_en=now(). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_reservar_turno( + p_token UUID, + p_cliente_id UUID, + p_turno_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_fecha_turno DATE; + v_hora_inicio TIME; + v_hora_fin TIME; + v_actividad_id INT; + v_capacidad_maxima SMALLINT; + v_actividad_libre BOOLEAN; + + v_conflicto_hora_inicio TIME; + v_conflicto_hora_fin TIME; + + v_tipo_cuota_id UUID; + v_limite_semanal INT; + v_fecha_creacion DATE; + + v_ultimo_mes_pagado DATE; + v_meses_deuda INT; + v_umbral_morosidad INT; + + v_reservas_semana INT; + v_ocupacion_actual INT; + + v_nueva_reserva_id UUID; +BEGIN + ------------------------------------------------------------ + -- 1. Autenticación y Permisos + ------------------------------------------------------------ + PERFORM internal.validate_permission(p_token, 'reservar_turno'); + + ------------------------------------------------------------ + -- 2. Bloqueo Transaccional del Turno (Anti-Overbooking) + ------------------------------------------------------------ + SELECT t.fecha, t.hora_inicio, t.hora_fin, t.actividad_id, t.capacidad_maxima, a.libre + INTO v_fecha_turno, v_hora_inicio, v_hora_fin, v_actividad_id, v_capacidad_maxima, v_actividad_libre + FROM turnos t + JOIN actividades a ON t.actividad_id = a.id + WHERE t.id = p_turno_id AND t.activo = true + FOR NO KEY UPDATE OF t; + + IF NOT FOUND THEN + RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.'; + END IF; + + IF v_fecha_turno < CURRENT_DATE THEN + RAISE EXCEPTION 'No podés reservar un turno de una fecha pasada.'; + END IF; + + ------------------------------------------------------------ + -- 3. Obtener Datos del Cliente y su Cuota + ------------------------------------------------------------ + SELECT u.tipo_cuota, u.fecha_creacion::DATE, tc.dias_semana + INTO v_tipo_cuota_id, v_fecha_creacion, v_limite_semanal + FROM usuarios u + LEFT JOIN tipos_cuota tc ON u.tipo_cuota = tc.id + WHERE u.id = p_cliente_id AND u.isactive = true; + + IF NOT FOUND THEN + RAISE EXCEPTION 'El cliente no existe o se encuentra inactivo.'; + END IF; + + ------------------------------------------------------------ + -- 4. Validación de Deuda (umbral configurable, §9.3) + ------------------------------------------------------------ + -- Filtros: + -- tipo = 'cuota_mensual' → ignora correctivos (no representan meses pagados). + -- anulado_at IS NULL → un pago anulado no cuenta como pagado. + SELECT MAX(anio_mes_pagado) INTO v_ultimo_mes_pagado + FROM pagos + WHERE cliente_id = p_cliente_id + AND tipo = 'cuota_mensual' + AND anulado_at IS NULL; + + -- Si nunca pagó (o todos sus cuota_mensual están anulados), calculamos + -- la deuda desde el mes en que se creó el usuario. + v_ultimo_mes_pagado := COALESCE(v_ultimo_mes_pagado, date_trunc('month', v_fecha_creacion)::DATE); + + v_meses_deuda := (EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM v_ultimo_mes_pagado)) * 12 + + (EXTRACT(MONTH FROM CURRENT_DATE) - EXTRACT(MONTH FROM v_ultimo_mes_pagado)); + + v_umbral_morosidad := internal.get_config_int('pagos.umbral_morosidad_meses', 2); + + IF v_meses_deuda >= v_umbral_morosidad THEN + RAISE EXCEPTION 'Tenés una deuda de % meses. Regularizá tu situación de pago para poder reservar.', v_meses_deuda; + END IF; + + ------------------------------------------------------------ + -- 5. Validación: No Solape Horario con Otra Reserva (§9.1) + ------------------------------------------------------------ + -- Misma fecha + intervalos semiabiertos solapados. + -- Dos turnos consecutivos (hora_fin de uno == hora_inicio del otro) + -- NO se consideran solapados. + SELECT t.hora_inicio, t.hora_fin + INTO v_conflicto_hora_inicio, v_conflicto_hora_fin + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE r.cliente_id = p_cliente_id + AND r.cancelada = false + AND t.fecha = v_fecha_turno + AND t.hora_inicio < v_hora_fin + AND v_hora_inicio < t.hora_fin + LIMIT 1; + + IF FOUND THEN + RAISE EXCEPTION 'Ya tenés una reserva ese día en un horario que se superpone con este (% - %).', + to_char(v_conflicto_hora_inicio, 'HH24:MI'), + to_char(v_conflicto_hora_fin, 'HH24:MI'); + END IF; + + ------------------------------------------------------------ + -- 6. Validación de Plan y Actividad Permitida + ------------------------------------------------------------ + IF NOT v_actividad_libre THEN + IF v_tipo_cuota_id IS NULL THEN + RAISE EXCEPTION 'No tenés un plan activo asignado para reservar esta actividad.'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM actividades_tipos_cuota + WHERE tipo_cuota_id = v_tipo_cuota_id AND actividad_id = v_actividad_id + ) THEN + RAISE EXCEPTION 'Tu plan actual no incluye esta actividad.'; + END IF; + END IF; + + ------------------------------------------------------------ + -- 7. Validación: Límite Semanal de la Cuota + ------------------------------------------------------------ + IF NOT v_actividad_libre THEN + SELECT COUNT(*)::INT INTO v_reservas_semana + FROM reservas r + JOIN turnos t ON r.turno_id = t.id + WHERE r.cliente_id = p_cliente_id + AND r.cancelada = false + AND date_trunc('week', t.fecha) = date_trunc('week', v_fecha_turno); + + IF v_reservas_semana >= COALESCE(v_limite_semanal, 0) THEN + RAISE EXCEPTION 'Ya alcanzaste tu límite de % reservas para esta semana.', v_limite_semanal; + END IF; + END IF; + + ------------------------------------------------------------ + -- 8. Validación de Cupo / Capacidad del Turno + ------------------------------------------------------------ + SELECT COUNT(*)::INT INTO v_ocupacion_actual + FROM reservas + WHERE turno_id = p_turno_id AND cancelada = false; + + IF v_ocupacion_actual >= v_capacidad_maxima THEN + RAISE EXCEPTION 'Lo sentimos, este turno ya no tiene lugares disponibles.'; + END IF; + + ------------------------------------------------------------ + -- 9. Inserción de la Reserva + ------------------------------------------------------------ + INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada) + VALUES (p_turno_id, p_cliente_id, now(), false) + RETURNING id INTO v_nueva_reserva_id; + + ------------------------------------------------------------ + -- 10. Retorno Exitoso + ------------------------------------------------------------ + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'Turno reservado con éxito.', + 'reserva_id', v_nueva_reserva_id, + 'fecha', v_fecha_turno, + 'hora_inicio', to_char(v_hora_inicio, 'HH24:MI') + ); + +END; +$function$; diff --git a/database/functions/turnos/fc_reservar_turno_admin.sql b/database/functions/turnos/fc_reservar_turno_admin.sql new file mode 100644 index 0000000..6cbc238 --- /dev/null +++ b/database/functions/turnos/fc_reservar_turno_admin.sql @@ -0,0 +1,131 @@ +-- ============================================================================ +-- fc_reservar_turno_admin +-- ============================================================================ +-- PROPÓSITO +-- Versión administrativa de reservar: Juani u otro admin reserva un +-- turno a nombre de un cliente. Bypassea todas las validaciones de +-- negocio (deuda, plan, límite semanal, no-solape-horario, cupo del +-- bloque) pero mantiene las de integridad (turno existe y activo, +-- cliente existe). +-- +-- DOMINIO +-- Implementa la operación **"reservar"** cuando es el operador quien +-- anota a un cliente (Documentation/DominioHorarios.md §4 operador, §8 +-- gestión de reservas, §9.6 libertad del operador). Es la +-- materialización del principio del dominio "el operador puede saltarse +-- cualquier regla operativa al reservar en nombre de un cliente": +-- bypassea morosidad, plan, cupo semanal, regla de no solape horario +-- y cupo del bloque. +-- +-- NOTA DE COHERENCIA. El dominio §9.6 dice que las advertencias son +-- "informativas, no bloqueantes". Esta función va un paso más allá: ni +-- siquiera devuelve advertencias (la UI las muestra antes de invocar). +-- Las validaciones de **integridad** que sí mantiene (turno existe y +-- activo, cliente existe) no son reglas operativas sino consistencia +-- de la base. +-- +-- El cupo del bloque se trata como advertencia, no como bloqueo: si el +-- admin sobrecupa, la `ocupacion` queda por encima de `capacidad_maxima` +-- en el turno. Eso es consistente con §9.6 ("este bloque está lleno" +-- está listado como advertencia informativa) y deja `capacidad_maxima` +-- como dato estable de configuración, no como output mutado por la +-- actividad operativa. Para crear bloques ad-hoc o ajustar capacidad +-- deliberadamente, sigue existiendo `fc_upsert_turno`. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (admin). +-- p_cliente_id UUID ID del cliente para el que se reserva. +-- p_turno_id UUID ID del turno. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'gestionar_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El turno no existe, fue cancelado o no está disponible.' +-- - 'El cliente seleccionado no existe en la base de datos.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}. +-- +-- EFECTOS SECUNDARIOS +-- INSERT en reservas con cancelada=false y reservada_en=now(). Puede +-- dejar el turno con ocupacion > capacidad_maxima si el admin sobrecupa. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_reservar_turno_admin( + p_token UUID, + p_cliente_id UUID, + p_turno_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $function$ +DECLARE + v_fecha_turno DATE; + v_hora_inicio TIME; + v_nueva_reserva_id UUID; +BEGIN + ------------------------------------------------------------ + -- 1. Autenticación (Permiso elevado) + ------------------------------------------------------------ + -- A diferencia del cliente, exigimos un rol administrativo + PERFORM internal.validate_permission(p_token, 'gestionar_reservas'); + + ------------------------------------------------------------ + -- 2. Bloqueo Transaccional del Turno (Integridad) + ------------------------------------------------------------ + -- Bloqueamos la fila para evitar carreras con otras reservas + -- simultáneas (aunque acá no chequeemos cupo, mantener el lock + -- evita inconsistencias en lecturas concurrentes de ocupación). + SELECT t.fecha, t.hora_inicio + INTO v_fecha_turno, v_hora_inicio + FROM turnos t + WHERE t.id = p_turno_id AND t.activo = true + FOR NO KEY UPDATE OF t; + + IF NOT FOUND THEN + RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.'; + END IF; + + -- A Juani le dejamos reservar en el pasado si necesita cargar un histórico que se olvidó + -- Así que NO validamos si v_fecha_turno < CURRENT_DATE + + ------------------------------------------------------------ + -- 3. Validación Mínima de Cliente + ------------------------------------------------------------ + IF NOT EXISTS (SELECT 1 FROM usuarios WHERE id = p_cliente_id) THEN + RAISE EXCEPTION 'El cliente seleccionado no existe en la base de datos.'; + END IF; + + -- OMITIDAS intencionalmente (Bypass Administrativo §9.6): + -- ✖ Validación de Deuda + -- ✖ Validación de Tipo de Cuota (Plan) + -- ✖ Validación de Límite Semanal + -- ✖ Validación de No Solape Horario + -- ✖ Validación de Cupo del Bloque + -- (la UI muestra todas estas como advertencias antes de invocar) + + ------------------------------------------------------------ + -- 4. Inserción de la Reserva + ------------------------------------------------------------ + INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada) + VALUES (p_turno_id, p_cliente_id, now(), false) + RETURNING id INTO v_nueva_reserva_id; + + ------------------------------------------------------------ + -- 5. Retorno Exitoso + ------------------------------------------------------------ + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'Reserva administrativa creada con éxito.', + 'reserva_id', v_nueva_reserva_id, + 'fecha', v_fecha_turno, + 'hora_inicio', to_char(v_hora_inicio, 'HH24:MI') + ); + +END; +$function$; diff --git a/database/functions/turnos/fc_resolver_huerfana.sql b/database/functions/turnos/fc_resolver_huerfana.sql new file mode 100644 index 0000000..59dafaa --- /dev/null +++ b/database/functions/turnos/fc_resolver_huerfana.sql @@ -0,0 +1,96 @@ +-- ============================================================================ +-- fc_resolver_huerfana +-- ============================================================================ +-- PROPÓSITO +-- Cambia el estado de una reserva huérfana a uno distinto de +-- 'pendiente'. Útil para que el operador la dé por cerrada sin +-- reubicarla (típicamente después de notificar al cliente y delegarle +-- la re-reserva). +-- +-- Para reubicar una huérfana en un turno nuevo, usar +-- fc_mover_reserva_huerfana. +-- +-- DOMINIO +-- Cambio puntual de estado de una **reserva huérfana** +-- (Documentation/DominioHorarios.md §7.8). El dominio reconoce dos +-- resoluciones conceptuales: re-reserva por el cliente (transparente, +-- no la dispara el operador) y reasignación por el operador. La +-- implementación operativa usa tres estados: +-- - 'pendiente': estado inicial, no resuelta. Requiere atención. +-- - 'reubicado': la reasignación por operador ya ocurrió (la setea +-- fc_mover_reserva_huerfana, no esta función). +-- - 'resuelta': el operador la dio por cerrada sin reubicarla. +-- Típicamente después de notificar al cliente para que se ocupe él; +-- también cubre cierres manuales por alta-por-error u otros motivos. +-- Sostenido por §9.6 (libertad operativa del operador): cerrar +-- manualmente una huérfana es legítimo aunque el dominio no lo +-- describa como una de sus dos resoluciones. +-- +-- Esta función acepta los tres valores como nuevo estado, incluyendo +-- transiciones "hacia atrás" (p.ej. 'resuelta' → 'pendiente' como +-- deshacer). La libertad operativa del operador aplica. +-- +-- NOTA. El tracking de "se le notificó al cliente" no vive en este +-- estado: ese registro vivirá en el módulo de notificaciones (tabla +-- notificaciones) cuando se implemente la integración para huérfanas. +-- 'resuelta' es agnóstica al cómo se llegó. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_huerfana_id UUID ID de la reserva huérfana. +-- p_nuevo_estado VARCHAR nuevo estado. Debe ser 'pendiente', +-- 'reubicado' o 'resuelta'. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'gestionar_reservas'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Estado no válido. Debe ser pendiente, reubicado o resuelta.' +-- - 'No se encontró el registro de reserva huérfana.' +-- +-- RETORNA +-- JSONB con {status: 'success', mensaje}. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE en reservas_huerfanas: setea estado_resolucion y resuelto_en. +-- Toda transición a estado terminal (reubicado/resuelta) setea +-- resuelto_en=now() y arranca la ventana de gracia previa a purga +-- (ver internal.limpiar_huerfanas_resueltas y la clave +-- `retencion.huerfanas_resueltas_dias`). Volver a 'pendiente' resetea +-- resuelto_en a NULL, devolviéndole la inmunidad. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_resolver_huerfana( + p_token UUID, + p_huerfana_id UUID, + p_nuevo_estado VARCHAR +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE +AS $function$ +BEGIN + PERFORM internal.validate_permission(p_token, 'gestionar_reservas'); + + -- Validación estricta del dominio de estados permitidos + IF p_nuevo_estado NOT IN ('pendiente', 'reubicado', 'resuelta') THEN + RAISE EXCEPTION 'Estado no válido. Debe ser pendiente, reubicado o resuelta.'; + END IF; + + UPDATE reservas_huerfanas + SET estado_resolucion = p_nuevo_estado, + resuelto_en = CASE WHEN p_nuevo_estado = 'pendiente' THEN NULL ELSE now() END + WHERE id = p_huerfana_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'No se encontró el registro de reserva huérfana.'; + END IF; + + RETURN jsonb_build_object( + 'status', 'success', + 'mensaje', 'Estado actualizado a ' || p_nuevo_estado + ); +END; +$function$; diff --git a/database/functions/turnos/fc_upsert_turno.sql b/database/functions/turnos/fc_upsert_turno.sql new file mode 100644 index 0000000..b62fb66 --- /dev/null +++ b/database/functions/turnos/fc_upsert_turno.sql @@ -0,0 +1,119 @@ +-- ============================================================================ +-- fc_upsert_turno +-- ============================================================================ +-- PROPÓSITO +-- Crea un turno nuevo o actualiza uno existente (UPSERT). Pensada como +-- workaround para que Juani pueda meter un turno sobre la marcha +-- (sumar uno extra, ampliar capacidad, etc.) sin pasar por la +-- plantilla horaria. Por default marca el turno como especial. +-- +-- DOMINIO +-- Vía de escape del operador sobre la oferta de **bloques** +-- (Documentation/DominioHorarios.md §7.3) sin pasar por la plantilla. +-- Es la materialización de la libertad operativa §9.6 aplicada al +-- catálogo de turnos: el operador puede meter un bloque ad hoc, sumar +-- capacidad a uno existente, reactivar uno borrado lógicamente. +-- +-- La única regla que esta función NO permite saltar: reducir la +-- capacidad por debajo de la ocupación actual. Eso preservaría +-- coherencia con el principio rector §6: bajar el cupo por debajo de +-- los ya anotados convertiría reservas vivas en huérfanas sin que el +-- operador haya manifestado intención de hacerlo. Para esos casos, el +-- operador primero cancela reservas (fc_cancelar_reserva_admin) y +-- recién después achica el cupo. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_actividad_id INT ID de la actividad. +-- p_fecha DATE día del turno. +-- p_hora_inicio TIME hora de inicio. +-- p_hora_fin TIME hora de fin. +-- p_capacidad_maxima SMALLINT capacidad del turno. +-- p_es_especial BOOLEAN (default true) si false, el turno se trata +-- como regular (forma parte de la plantilla). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_turnos'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La hora de inicio debe ser anterior a la hora de fin.' +-- - 'No podés reducir la capacidad máxima a X porque ya hay Y clientes +-- anotados.' +-- +-- RETORNA +-- UUID del turno (nuevo o existente actualizado). +-- +-- EFECTOS SECUNDARIOS +-- - Si existe turno con (actividad_id, fecha, hora_inicio): UPDATE de +-- hora_fin, capacidad_maxima, es_especial y reactivación +-- (activo=true) por si estaba en borrado lógico. +-- - Si no existe: INSERT con dia_semana derivado de la fecha. +-- ============================================================================ + +-- Insertar o actualizar un turno +-- Esta función es una herramienta para que Juani tenga un workaround de agregar un turno sobre la marcha + +CREATE OR REPLACE FUNCTION public.fc_upsert_turno( + p_token UUID, + p_actividad_id INT, + p_fecha DATE, + p_hora_inicio TIME, + p_hora_fin TIME, + p_capacidad_maxima SMALLINT, + p_es_especial BOOLEAN DEFAULT true +) +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +VOLATILE +AS $$ +DECLARE + v_turno_id UUID; + v_ocupacion INT; +BEGIN + PERFORM internal.validate_permission(p_token, 'modificar_turnos'); + + -- Validar coherencia de horas + IF p_hora_inicio >= p_hora_fin THEN + RAISE EXCEPTION 'La hora de inicio debe ser anterior a la hora de fin.'; + END IF; + + -- Buscamos si el turno ya existe físicamente + SELECT id INTO v_turno_id + FROM turnos + WHERE actividad_id = p_actividad_id AND fecha = p_fecha AND hora_inicio = p_hora_inicio; + + IF FOUND THEN + -- Validación de negocio: no achicar la capacidad por debajo de los anotados + SELECT count(*)::INT INTO v_ocupacion + FROM reservas + WHERE turno_id = v_turno_id AND cancelada = false; + + IF p_capacidad_maxima < v_ocupacion THEN + RAISE EXCEPTION 'No podés reducir la capacidad máxima a % porque ya hay % clientes anotados.', p_capacidad_maxima, v_ocupacion; + END IF; + + UPDATE turnos SET + hora_fin = p_hora_fin, + capacidad_maxima = p_capacidad_maxima, + es_especial = p_es_especial, + activo = true -- Reactivamos por si estaba en borrado lógico + WHERE id = v_turno_id; + + ELSE + -- Es un turno nuevo, lo insertamos + INSERT INTO turnos ( + actividad_id, fecha, hora_inicio, hora_fin, + capacidad_maxima, es_especial, dia_semana, activo + ) + VALUES ( + p_actividad_id, p_fecha, p_hora_inicio, p_hora_fin, + p_capacidad_maxima, p_es_especial, EXTRACT(ISODOW FROM p_fecha)::SMALLINT, true + ) + RETURNING id INTO v_turno_id; + END IF; + + RETURN v_turno_id; +END; +$$; diff --git a/database/functions/turnos/internal/limpiar_huerfanas_resueltas.sql b/database/functions/turnos/internal/limpiar_huerfanas_resueltas.sql new file mode 100644 index 0000000..aee6165 --- /dev/null +++ b/database/functions/turnos/internal/limpiar_huerfanas_resueltas.sql @@ -0,0 +1,101 @@ +-- ============================================================================ +-- internal.limpiar_huerfanas_resueltas +-- ============================================================================ +-- PROPÓSITO +-- Higiene operativa: elimina filas de public.reservas_huerfanas que ya +-- pasaron a estado terminal (reubicado / resuelta) Y cumplieron la +-- ventana de gracia configurada. Una huérfana es un snapshot +-- desconectado que existe sólo para que el operador (o el cliente) +-- reubique o cierre la reserva rescatada; una vez resuelta y pasada la +-- gracia, cumplió su función y se descarta. +-- +-- DOMINIO +-- Implementa la higiene de huérfanas descrita en +-- Documentation/PoliticaRetencion.md §7. El criterio combina ESTADO y +-- ANTIGÜEDAD del cambio de estado: +-- - `pendiente` nunca se purga (sigue siendo trabajo no hecho), por +-- más vieja que sea. +-- - Una terminal recién es elegible cuando pasaron al menos +-- `retencion.huerfanas_resueltas_dias` (default 14) desde su +-- `resuelto_en`. +-- La ventana es el piso de seguridad para que el operador pueda +-- revertir un cambio de estado por error: cambiar la fila otra vez a +-- `pendiente` resetea resuelto_en a NULL y le devuelve la inmunidad. +-- +-- CRITERIO DE PURGA +-- Se elimina toda fila con `estado_resolucion <> 'pendiente'` Y +-- `resuelto_en < now() - INTERVAL N días`. Se expresa por negación +-- de `pendiente` (no enumerando los terminales) para que cualquier +-- estado terminal que se agregue en el futuro herede la purga +-- automáticamente, mientras `pendiente` siga siendo el único estado +-- protegido. El `IS NOT NULL` extra en resuelto_en es defensa en +-- profundidad: el CHECK de la tabla ya garantiza el invariante, pero +-- si una migración o un fix de datos dejara una fila inconsistente +-- no la queremos arrastrar. +-- +-- INTERACCIÓN CON LA CADENCIA MENSUAL +-- Esta función la dispara el pipeline mensual (US-R17) el día 1 a +-- las 03:00 AR, junto con el resto de las purgas. La ventana de N +-- días es el PISO de vida; el techo lo pone la cadencia del cron. +-- En la práctica, una terminal vive entre ~N+1 y ~N+31 días según +-- en qué momento del mes se haya marcado. La ventana cubre el caso +-- patológico de "resuelta a las 23:59 del 31, purgada a las 03:00 +-- del 1" que existía cuando el criterio era sólo de estado. +-- +-- PARÁMETROS +-- Ninguno. Lee `retencion.huerfanas_resueltas_dias` (default 14). +-- +-- AUTORIZACIÓN +-- No valida permiso propio. El schema `internal` no es alcanzable por +-- roles cliente (ver hardening en `database/schema/00_schemas.sql`). +-- Callers legítimos: pg_cron y fachadas `public.fc_*` SECURITY DEFINER +-- que orquesten el pipeline mensual (US-R17). +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB con {status, huerfanas_eliminadas, dias_gracia, mensaje}. +-- +-- EFECTOS SECUNDARIOS +-- DELETE de reservas_huerfanas elegibles. No registra evento. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.limpiar_huerfanas_resueltas() +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $$ +DECLARE + v_dias_gracia INT; + v_eliminadas INT; +BEGIN + v_dias_gracia := internal.get_config_int('retencion.huerfanas_resueltas_dias', 14); + + DELETE FROM reservas_huerfanas + WHERE estado_resolucion <> 'pendiente' + AND resuelto_en IS NOT NULL + AND resuelto_en < now() - (v_dias_gracia * INTERVAL '1 day'); + + GET DIAGNOSTICS v_eliminadas = ROW_COUNT; + + RETURN jsonb_build_object( + 'status', 'success', + 'huerfanas_eliminadas', v_eliminadas, + 'dias_gracia', v_dias_gracia, + 'mensaje', format( + 'Se eliminaron %s huérfanas resueltas/reubicadas con más de %s días desde su cambio de estado (las pendientes y las en gracia se conservan).', + v_eliminadas, v_dias_gracia + ) + ); +END; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.limpiar_huerfanas_resueltas() + FROM PUBLIC, anon, authenticated; diff --git a/database/functions/turnos/internal/limpiar_turnos_antiguos.sql b/database/functions/turnos/internal/limpiar_turnos_antiguos.sql new file mode 100644 index 0000000..d01eb1a --- /dev/null +++ b/database/functions/turnos/internal/limpiar_turnos_antiguos.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- internal.limpiar_turnos_antiguos +-- ============================================================================ +-- PROPÓSITO +-- Higiene operativa: elimina turnos cuya `fecha` esté fuera de la +-- ventana de retención. La purga es incondicional: borra todos los +-- turnos pasados la ventana, hayan tenido reservas o no. Las reservas +-- vinculadas caen por CASCADE (reservas_turno_id_fkey ON DELETE +-- CASCADE). +-- +-- DOMINIO +-- Implementa la higiene de turnos descrita en +-- Documentation/PoliticaRetencion.md §7. El sistema es operativo, no +-- archivo histórico: lo que pasó la ventana se descarta. La +-- concurrencia mensual que sí vale conservar se archiva agregada en +-- el XLSX (§3, §4.1), no en la base. +-- +-- El diseño anterior preservaba turnos con reservas como "histórico". +-- Ese trade-off quedó obsoleto: con la política operativa + archivo, +-- el histórico vive en el XLSX agregado y la base no carga ese rol. +-- La excepción se retira; los turnos pasados se purgan parejo. +-- +-- PARÁMETROS +-- Ninguno. La ventana en meses se lee de `internal.app_config` con la +-- clave `retencion.turnos_meses` (default 2). El ajuste operativo se +-- hace en la tabla, sin redeploy. +-- +-- AUTORIZACIÓN +-- No valida permiso propio. El schema `internal` no es alcanzable por +-- roles cliente (ver hardening en `database/schema/00_schemas.sql`). +-- Los callers legítimos son `pg_cron` (corre como postgres) y, si en +-- algún momento aplica, una fachada pública `SECURITY DEFINER` que +-- orqueste el pipeline mensual (US-R17). Ninguna ruta de cliente +-- llega acá. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. +-- +-- RETORNA +-- JSONB con {status, fecha_corte, meses_conservados, turnos_eliminados, +-- mensaje}. Las reservas eliminadas por CASCADE no se cuentan: la +-- métrica que importa para el archivo (totales y cancelaciones por +-- actividad/mes) se calcula en otra función antes del DELETE. +-- +-- EFECTOS SECUNDARIOS +-- DELETE de turnos con fecha < (CURRENT_DATE - ventana). Las reservas +-- asociadas caen por CASCADE. No registra evento. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION internal.limpiar_turnos_antiguos() +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +SET timezone = 'America/Argentina/Buenos_Aires' +VOLATILE +AS $$ +DECLARE + v_meses INT; + v_fecha_limite DATE; + v_eliminados INT; +BEGIN + v_meses := internal.get_config_int('retencion.turnos_meses', 2); + v_fecha_limite := CURRENT_DATE - (v_meses * INTERVAL '1 month'); + + DELETE FROM turnos + WHERE fecha < v_fecha_limite; + + GET DIAGNOSTICS v_eliminados = ROW_COUNT; + + RETURN jsonb_build_object( + 'status', 'success', + 'fecha_corte', v_fecha_limite, + 'meses_conservados', v_meses, + 'turnos_eliminados', v_eliminados, + 'mensaje', format( + 'Se eliminaron %s turnos anteriores a %s (reservas asociadas eliminadas por CASCADE).', + v_eliminados, v_fecha_limite + ) + ); +END; +$$; + +-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql) +-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá +-- por si en algún futuro alguien afloja las defensas a nivel schema. +REVOKE ALL ON FUNCTION internal.limpiar_turnos_antiguos() + FROM PUBLIC, anon, authenticated; + + diff --git a/database/functions/usuarios/fc_cambiar_propia_contrasena.sql b/database/functions/usuarios/fc_cambiar_propia_contrasena.sql new file mode 100644 index 0000000..b3c7fd3 --- /dev/null +++ b/database/functions/usuarios/fc_cambiar_propia_contrasena.sql @@ -0,0 +1,69 @@ +-- ============================================================================ +-- fc_cambiar_propia_contrasena +-- ============================================================================ +-- PROPÓSITO +-- Permite a un admin o superadmin cambiar su propia contraseña, +-- confirmando primero la contraseña actual. Autoservicio: no recibe el id +-- de ningún otro usuario, solo opera sobre el dueño del token. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (también identifica de quién +-- es la contraseña a cambiar). +-- p_password_actual TEXT contraseña actual en texto plano, para +-- confirmar identidad antes de cambiarla. +-- p_password_nueva TEXT contraseña nueva en texto plano (mínimo 8 +-- caracteres; se valida también en el cliente, +-- pero el servidor es la última línea). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'cambiar_propia_contrasena'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La contraseña actual es incorrecta.' +-- - 'La contraseña nueva debe tener al menos 8 caracteres.' +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE de password_hash en public.usuarios, fila del propio actor. +-- fecha_modificacion no se toca (no es un cambio de "perfil"). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_cambiar_propia_contrasena( + p_token UUID, + p_password_actual TEXT, + p_password_nueva TEXT +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO extensions, public +AS $function$ +DECLARE + v_user_id UUID; + v_stored_password_hash TEXT; +BEGIN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'cambiar_propia_contrasena'); + + IF p_password_nueva IS NULL OR length(p_password_nueva) < 8 THEN + RAISE EXCEPTION 'La contraseña nueva debe tener al menos 8 caracteres.'; + END IF; + + SELECT usuario_id INTO v_user_id FROM internal.sesiones WHERE token = p_token; + + SELECT password_hash INTO v_stored_password_hash + FROM public.usuarios + WHERE id = v_user_id + FOR UPDATE; + + IF v_stored_password_hash IS NULL + OR v_stored_password_hash <> crypt(p_password_actual, v_stored_password_hash) THEN + RAISE EXCEPTION 'La contraseña actual es incorrecta.'; + END IF; + + UPDATE public.usuarios + SET password_hash = internal.create_password_hash(p_password_nueva) + WHERE id = v_user_id; +END; +$function$; diff --git a/database/functions/usuarios/fc_eliminar_usuario.sql b/database/functions/usuarios/fc_eliminar_usuario.sql new file mode 100644 index 0000000..beaca60 --- /dev/null +++ b/database/functions/usuarios/fc_eliminar_usuario.sql @@ -0,0 +1,59 @@ +-- ============================================================================ +-- fc_eliminar_usuario +-- ============================================================================ +-- PROPÓSITO +-- Elimina un usuario (DELETE real). Si tiene registros asociados (pagos, +-- historial, etc.) y un FK lo impide, atrapa la violación y devuelve un +-- mensaje amigable al frontend. +-- +-- PARÁMETROS +-- p_dni TEXT DNI del usuario a eliminar. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'eliminar_usuarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'No se puede eliminar el usuario X porque tiene registros asociados +-- (pagos, historial, etc). Primero debe archivar o borrar esos datos.' +-- - 'No existe un usuario con el DNI X' +-- +-- RETORNA +-- BOOL TRUE en caso de éxito. +-- +-- EFECTOS SECUNDARIOS +-- DELETE de la fila en public.usuarios. Las eliminaciones en cascada +-- dependen de los FKs definidos en las tablas hijas. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_eliminar_usuario(p_dni text, p_token uuid) + RETURNS bool + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO public +AS $function$ +DECLARE + v_id_eliminado UUID; + + BEGIN + + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'eliminar_usuarios'); + + BEGIN + DELETE FROM public.usuarios + WHERE dni = p_dni + RETURNING id INTO v_id_eliminado; + + EXCEPTION + WHEN foreign_key_violation THEN + -- Este mensaje es mucho más amigable que el error nativo de Postgres + RAISE EXCEPTION 'No se puede eliminar el usuario % porque tiene registros asociados (pagos, historial, etc). Primero debe archivar o borrar esos datos.', p_dni; + END; + + -- 3. Validar si encontró al usuario + IF v_id_eliminado IS NULL THEN + RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni; + END IF; + RETURN TRUE; +END; +$function$; diff --git a/database/functions/usuarios/fc_insertar_usuario.sql b/database/functions/usuarios/fc_insertar_usuario.sql new file mode 100644 index 0000000..e10f15e --- /dev/null +++ b/database/functions/usuarios/fc_insertar_usuario.sql @@ -0,0 +1,143 @@ +-- ============================================================================ +-- fc_insertar_usuario +-- ============================================================================ +-- PROPÓSITO +-- Crea un usuario nuevo. Si el rol es 'admin' o 'superadmin', exige un +-- permiso adicional sobre el del actor. Hashea la contraseña recibida en +-- texto plano antes de persistir. +-- +-- PARÁMETROS +-- p_datos JSONB con el payload del usuario. Claves: +-- dni TEXT (obligatorio) +-- nombre TEXT (obligatorio) +-- apellido TEXT (opcional) +-- mail TEXT (opcional) +-- telefono TEXT (opcional) +-- peso DECIMAL (opcional) +-- altura INTEGER (opcional) +-- fuerza_max DECIMAL (opcional) +-- sexo TEXT (opcional, default 'Hombre') +-- rol TEXT (obligatorio) p.ej. 'cliente', 'admin', 'superadmin'. +-- password TEXT (obligatorio si rol != 'cliente'; opcional si +-- rol = 'cliente') en texto plano; se hashea +-- internamente. +-- isActive BOOLEAN (obligatorio). +-- tipo_cuota UUID (opcional) plan asociado. Si se pasa debe existir +-- en tipos_cuota. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_usuarios'. Si rol = 'superadmin' exige además +-- 'agregar_superadmin'. Si rol = 'admin' exige además 'agregar_admin'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'El tipo_cuota X no existe.' +-- - 'La contraseña es obligatoria' (si rol != 'cliente' y no se mandó) +-- - 'Error al generar el hash de la contraseña' +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- INSERT en public.usuarios con password_hash calculado. Si rol = 'cliente' +-- y no se mandó password, password_hash se inserta NULL (los clientes no +-- inician sesión en este panel, usan el bot de WhatsApp). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_insertar_usuario(p_datos jsonb, p_token uuid) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO extensions, public +AS $function$ +DECLARE + v_password_hash TEXT; + v_rol TEXT; + + -- Campos del nuevo usuario + new_dni TEXT; + new_nombre TEXT; + new_apellido TEXT; + new_mail TEXT; + new_telefono TEXT; + new_peso DECIMAL(5,2); + new_altura INTEGER; + new_fuerza_max DECIMAL(10,2); + new_sexo TEXT; + new_rol TEXT; + new_password TEXT; + new_isactive BOOL; + new_tipo_cuota UUID; + +BEGIN + -- 1. Validar el token de sesión y obtener el rol del usuario que llama. + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_usuarios'); + + -- 2. Validar que no esté intentando crear un admin o superadmin si no puede + + new_rol := p_datos->>'rol'; + + IF new_rol = 'superadmin' THEN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'agregar_superadmin'); + ELSIF new_rol = 'admin' THEN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'agregar_admin'); + END IF; + + -- 2.5 Extraer campos del JSONB + new_dni := p_datos->>'dni'; + new_nombre := p_datos->>'nombre'; + new_apellido := p_datos->>'apellido'; + new_mail := p_datos->>'mail'; + new_telefono := p_datos->>'telefono'; + new_peso := (p_datos->>'peso')::DECIMAL; + new_altura := (p_datos->>'altura')::INTEGER; + new_fuerza_max := (p_datos->>'fuerza_max')::DECIMAL; + new_sexo := COALESCE(p_datos->>'sexo', 'Hombre'); + + new_password := p_datos->>'password'; + new_isactive := (p_datos->>'isActive')::BOOLEAN; + + -- CORRECCIÓN 1: Validar que exista la key Y que NO sea null antes de buscar en la BD + IF p_datos ? 'tipo_cuota' AND (p_datos->>'tipo_cuota') IS NOT NULL THEN + new_tipo_cuota := (p_datos->>'tipo_cuota')::UUID; + + -- Validar que exista el tipo_cuota en la tabla padre + IF NOT EXISTS ( + SELECT 1 FROM tipos_cuota + WHERE id = new_tipo_cuota + ) THEN + RAISE EXCEPTION 'El tipo_cuota % no existe.', new_tipo_cuota; + END IF; + ELSE + -- Si no viene o es null, asignamos NULL explícitamente + new_tipo_cuota := NULL; + END IF; + + -- La contraseña es obligatoria salvo para clientes: no necesitan loguearse + -- en este panel (usan el bot de WhatsApp). Si es cliente y no la mandan, + -- password_hash queda NULL. + IF new_password IS NULL AND new_rol <> 'cliente' THEN + RAISE EXCEPTION 'La contraseña es obligatoria'; + END IF; + + -- 3. Hashear la contraseña solo si vino (clientes pueden no traerla) + IF new_password IS NOT NULL THEN + SELECT internal.create_password_hash(new_password) INTO v_password_hash; + IF v_password_hash IS NULL THEN + RAISE EXCEPTION 'Error al generar el hash de la contraseña'; + END IF; + ELSE + v_password_hash := NULL; + END IF; + + -- 4. Insertar el nuevo usuario + -- CORRECCIÓN 2: Agregué 'tipo_cuota' y 'new_tipo_cuota' al INSERT + INSERT INTO public.usuarios ( + dni, nombre, apellido, mail, telefono, peso, altura, fuerza_max, + sexo, rol, password_hash, isactive, tipo_cuota + ) VALUES ( + new_dni, new_nombre, new_apellido, new_mail, new_telefono, new_peso, new_altura, new_fuerza_max, + new_sexo, new_rol, v_password_hash, new_isactive, new_tipo_cuota + ); +END; +$function$; diff --git a/database/functions/usuarios/fc_modificar_estado_usuario.sql b/database/functions/usuarios/fc_modificar_estado_usuario.sql new file mode 100644 index 0000000..40ee453 --- /dev/null +++ b/database/functions/usuarios/fc_modificar_estado_usuario.sql @@ -0,0 +1,41 @@ +-- ============================================================================ +-- fc_modificar_estado_usuario +-- ============================================================================ +-- PROPÓSITO +-- Activa o desactiva un usuario (toggle de isactive). Pensado para "dar +-- de baja" sin perder el registro. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_id UUID ID del usuario. +-- p_estado BOOLEAN nuevo valor de isactive. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'desactivar_usuarios'. (El mismo permiso cubre +-- activación y desactivación.) +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio. Si p_id no existe, el UPDATE no afecta filas y la +-- función igualmente termina sin error. +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE de isactive en public.usuarios. NO toca fecha_modificacion. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_modificar_estado_usuario(p_token uuid, p_id uuid, p_estado boolean) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO public +AS $function$ +BEGIN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'desactivar_usuarios'); + + update public.usuarios + set isactive = p_estado + where id = p_id; +END; +$function$; diff --git a/database/functions/usuarios/fc_modificar_rol_usuario.sql b/database/functions/usuarios/fc_modificar_rol_usuario.sql new file mode 100644 index 0000000..d2c4af2 --- /dev/null +++ b/database/functions/usuarios/fc_modificar_rol_usuario.sql @@ -0,0 +1,44 @@ +-- ============================================================================ +-- fc_modificar_rol_usuario +-- ============================================================================ +-- PROPÓSITO +-- Cambia el rol de un usuario y actualiza su fecha_modificacion. +-- NO valida que p_nuevo_rol sea un valor permitido; el constraint de +-- tabla es la última línea de defensa. +-- +-- PARÁMETROS +-- p_uid UUID ID del usuario a modificar. +-- p_nuevo_rol TEXT nuevo valor de rol. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_rol_usuarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso o constraint de tabla). +-- Si p_uid no existe, no hace nada (no rebota). +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE en public.usuarios: setea rol y fecha_modificacion = now(). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_modificar_rol_usuario(p_uid uuid, p_nuevo_rol text, p_token uuid) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public +AS $function$ +DECLARE +BEGIN +-- Exigir el permiso específico para esta acción + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_rol_usuarios'); + + UPDATE public.usuarios + SET rol = p_nuevo_rol, + fecha_modificacion = now() + WHERE id = p_uid; +END; +$function$; diff --git a/database/functions/usuarios/fc_modificar_usuario.sql b/database/functions/usuarios/fc_modificar_usuario.sql new file mode 100644 index 0000000..16e81a9 --- /dev/null +++ b/database/functions/usuarios/fc_modificar_usuario.sql @@ -0,0 +1,141 @@ +-- ============================================================================ +-- fc_modificar_usuario +-- ============================================================================ +-- PROPÓSITO +-- Modifica los datos de un usuario existente. Semántica PATCH: sólo se +-- actualizan las claves presentes en p_datos. Identifica al usuario por +-- id; si no viene, usa dni como fallback. +-- +-- PARÁMETROS +-- p_datos JSONB con los campos a actualizar. Para identificar al usuario: +-- id UUID (preferido) +-- dni TEXT (fallback si no se pasa id) +-- Campos editables (todos opcionales): +-- nombre, apellido, peso, altura, mail, telefono, fuerza_max, sexo, +-- tipo_cuota, rol, password. +-- Caso especial - rol: +-- Si se pasa 'rol' y es distinto al actual, se delega a +-- fc_modificar_rol_usuario (que valida un permiso adicional). Tras la +-- delegación, la key 'rol' se elimina del UPDATE principal para evitar +-- escribir dos veces el mismo campo. +-- Caso especial - tipo_cuota: +-- Si se pasa la key con valor NULL, el plan se desasocia (queda NULL). +-- Si no se pasa la key, no se toca. +-- Caso especial - password: +-- Opcional; solo superadmin puede setearla (permiso dedicado). Si se +-- omite o viene vacía, no se toca la contraseña existente. +-- p_token UUID sesión del actor. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'modificar_usuarios'. Si se cambia el rol, además +-- requiere 'modificar_rol_usuarios' (impuesto por la función delegada). +-- Si se pasa 'password' no vacía, además requiere +-- 'resetear_contrasena_usuario'. +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'Se requiere un ID o un DNI para identificar al usuario.' +-- - 'Usuario no encontrado con los datos proporcionados.' +-- - 'El tipo_cuota X no existe.' +-- - 'Error al generar el hash de la contraseña' +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- - UPDATE en public.usuarios con fecha_modificacion = now(). +-- - Si hubo cambio de rol, además UPDATE adicional vía +-- fc_modificar_rol_usuario. +-- - Si vino 'password' no vacía, además actualiza password_hash. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_modificar_usuario(p_datos jsonb, p_token uuid) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO extensions, public +AS $function$ +DECLARE + v_isActive_new BOOLEAN := NULL; + v_rol_actual TEXT; + v_uid UUID; + v_password_hash TEXT; +BEGIN + + -- Validar token + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_usuarios'); + + -- Evaluamos primero si el JSON trae un ID válido + IF p_datos ? 'id' AND p_datos->>'id' IS NOT NULL THEN + SELECT u.id, u.rol INTO v_uid, v_rol_actual + FROM public.usuarios u + WHERE u.id = (p_datos->>'id')::UUID + FOR UPDATE; + + -- Si no hay ID, intentamos usar el DNI (fallback) + ELSIF p_datos ? 'dni' AND p_datos->>'dni' IS NOT NULL THEN + SELECT u.id, u.rol INTO v_uid, v_rol_actual + FROM public.usuarios u + WHERE u.dni = p_datos->>'dni' + FOR UPDATE; + + -- Si no mandaron ni ID ni DNI, no sabemos a quién modificar + ELSE + RAISE EXCEPTION 'Se requiere un ID o un DNI para identificar al usuario.'; + END IF; + + -- Si hicimos la búsqueda pero no encontramos coincidencias + IF NOT FOUND THEN + RAISE EXCEPTION 'Usuario no encontrado con los datos proporcionados.'; + END IF; + + -- Validar existencia del tipo_cuota SOLO si viene en el JSON y NO es null + IF p_datos ? 'tipo_cuota' AND (p_datos->>'tipo_cuota') IS NOT NULL THEN + IF NOT EXISTS ( + SELECT 1 FROM tipos_cuota + WHERE id = (p_datos->>'tipo_cuota')::UUID + ) THEN + RAISE EXCEPTION 'El tipo_cuota % no existe.', p_datos->>'tipo_cuota'; + END IF; + END IF; + + -- Validar si viene un rol en el JSON y si es distinto al que ya está en la DB + IF p_datos ? 'rol' AND (p_datos->>'rol') IS DISTINCT FROM v_rol_actual THEN + -- Delegamos la responsabilidad a la nueva función + PERFORM public.fc_modificar_rol_usuario(v_uid, p_datos->>'rol', p_token); + + -- Si llega hasta acá (no tiró excepción), eliminamos la key 'rol' del json + -- para que el UPDATE de abajo no lo sobreescriba innecesariamente. + p_datos := p_datos - 'rol'; + END IF; + + -- Password opcional: solo si viene la key Y no es null/vacía, se re-hashea + -- y se agrega al UPDATE. Acción sensible -> permiso dedicado, no alcanza + -- con 'modificar_usuarios' (mismo criterio que ya se usa para 'rol'). + IF p_datos ? 'password' AND COALESCE(p_datos->>'password', '') <> '' THEN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'resetear_contrasena_usuario'); + v_password_hash := internal.create_password_hash(p_datos->>'password'); + IF v_password_hash IS NULL THEN + RAISE EXCEPTION 'Error al generar el hash de la contraseña'; + END IF; + ELSE + v_password_hash := NULL; + END IF; + + UPDATE public.usuarios + SET + nombre = CASE WHEN p_datos ? 'nombre' THEN p_datos->>'nombre' ELSE nombre END, + apellido = CASE WHEN p_datos ? 'apellido' THEN p_datos->>'apellido' ELSE apellido END, + peso = CASE WHEN p_datos ? 'peso' THEN (p_datos->>'peso')::DECIMAL ELSE peso END, + altura = CASE WHEN p_datos ? 'altura' THEN (p_datos->>'altura')::DECIMAL ELSE altura END, + mail = CASE WHEN p_datos ? 'mail' THEN p_datos->>'mail' ELSE mail END, + telefono = CASE WHEN p_datos ? 'telefono' THEN p_datos->>'telefono' ELSE telefono END, + fuerza_max= CASE WHEN p_datos ? 'fuerza_max' THEN (p_datos->>'fuerza_max')::DECIMAL ELSE fuerza_max END, + sexo = CASE WHEN p_datos ? 'sexo' THEN p_datos->>'sexo' ELSE sexo END, + -- Esto ahora permite cambiar el tipo de cuota a NULL si mandas null, o cambiarlo a otro ID + tipo_cuota= CASE WHEN p_datos ? 'tipo_cuota' THEN (p_datos->>'tipo_cuota')::UUID ELSE tipo_cuota END, + password_hash = CASE WHEN v_password_hash IS NOT NULL THEN v_password_hash ELSE password_hash END, + fecha_modificacion = now() + WHERE id = v_uid; + +END; +$function$; diff --git a/database/functions/usuarios/fc_obtener_usuario_por_token.sql b/database/functions/usuarios/fc_obtener_usuario_por_token.sql new file mode 100644 index 0000000..01e2cf6 --- /dev/null +++ b/database/functions/usuarios/fc_obtener_usuario_por_token.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- fc_obtener_usuario_por_token +-- ============================================================================ +-- PROPÓSITO +-- Devuelve los datos del usuario asociado a una sesión vigente identificada +-- por p_user_token. El segundo parámetro (p_token) es la sesión del actor +-- que realiza la consulta y se usa sólo para autorizar. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor que llama (autorización). +-- p_user_token UUID token de la sesión del usuario que se quiere consultar. +-- La sesión debe estar vigente (expires_at > NOW()). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_usuarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB objeto único con los datos del usuario (id, dni, nombre, apellido, +-- mail, telefono, peso, altura, sexo, fuerza_max, isActive, rol, +-- tipo_cuota, fecha_creacion, fecha_modificacion). Si p_user_token no +-- corresponde a una sesión vigente, retorna NULL. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_obtener_usuario_por_token(p_token UUID, p_user_token UUID) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public + STABLE +AS $$ +DECLARE +BEGIN + + PERFORM internal.validate_permission(p_token, 'ver_usuarios'); + + RETURN ( + SELECT jsonb_build_object( + 'id', u.id, + 'dni', u.dni, + 'nombre', u.nombre, + 'mail', u.mail, + 'peso', u.peso, + 'sexo', u.sexo, + 'altura', u.altura, + 'apellido', u.apellido, + 'fecha_creacion', u.fecha_creacion, + 'fecha_modificacion', u.fecha_modificacion, + 'fuerza_max', u.fuerza_max, + 'isActive', u.isactive, + 'rol', u.rol, + 'telefono', u.telefono, + 'tipo_cuota', u.tipo_cuota + ) + FROM public.usuarios u + JOIN internal.sesiones s ON s.usuario_id = u.id + AND s.token = p_user_token + AND s.expires_at > NOW() + ); +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/usuarios/fc_obtener_usuarios.sql b/database/functions/usuarios/fc_obtener_usuarios.sql new file mode 100644 index 0000000..985801f --- /dev/null +++ b/database/functions/usuarios/fc_obtener_usuarios.sql @@ -0,0 +1,79 @@ +-- ============================================================================ +-- fc_obtener_usuarios +-- ============================================================================ +-- PROPÓSITO +-- Devuelve un lote paginado de usuarios. Opcionalmente filtra por DNI. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor. +-- p_dni TEXT (opcional, default NULL) si se pasa, sólo retorna el +-- usuario con ese DNI. NULL = todos. +-- p_pagina INT (default 1) página, base 1. +-- p_cantidad INT (default 50) ítems por página. +-- +-- AUTORIZACIÓN +-- Requiere permiso 'ver_usuarios'. +-- +-- ERRORES (RAISE EXCEPTION) +-- Ninguno propio (sólo los del validador de permiso). +-- +-- RETORNA +-- JSONB array (puede ser vacío). Cada ítem: id, dni, nombre, apellido, +-- peso, altura, fecha_creacion, fecha_modificacion, telefono, fuerza_max, +-- sexo, tipo_cuota, mail, rol, isActive. Ordenado por nombre, apellido. +-- +-- EFECTOS SECUNDARIOS +-- Ninguno (STABLE). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION fc_obtener_usuarios( + p_token UUID, + p_dni TEXT DEFAULT NULL, + p_pagina INT DEFAULT 1, -- Página actual (empieza en 1) + p_cantidad INT DEFAULT 50 -- Cuántos traer (default 30) +) +RETURNS JSONB +SECURITY DEFINER +SET search_path = public + STABLE +AS $$ +DECLARE + v_offset INT; +BEGIN + -- 1. Validar permisos + PERFORM internal.validate_permission(p_token, 'ver_usuarios'); + + -- 2. Calcular desde dónde empezar a leer (OFFSET) + -- Si página es 1, offset es 0. Si es 2, offset es 20, etc. + v_offset := (p_pagina - 1) * p_cantidad; + + -- 3. Consulta corregida: Paginación dentro, JSON fuera + RETURN ( + SELECT COALESCE(jsonb_agg(t.usuario_json), '[]'::jsonb) + FROM ( + SELECT jsonb_build_object( + 'id', u.id, + 'dni', u.dni, + 'nombre', u.nombre, + 'apellido', u.apellido, + 'peso', u.peso, + 'altura', u.altura, + 'fecha_creacion', u.fecha_creacion, + 'fecha_modificacion', u.fecha_modificacion, + 'telefono', u.telefono, + 'fuerza_max', u.fuerza_max, + 'sexo', u.sexo, + 'tipo_cuota', u.tipo_cuota, + 'mail', u.mail, + 'rol', u.rol, + 'isActive', u.isactive + ) AS usuario_json + FROM public.usuarios u + WHERE (p_dni IS NULL OR u.dni = p_dni) + ORDER BY u.nombre, u.apellido -- Vital para que el OFFSET sea consistente + LIMIT p_cantidad + OFFSET v_offset + ) t -- 't' es la subconsulta con solo 50 filas + ); +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/usuarios/fc_resetear_contrasena_usuario.sql b/database/functions/usuarios/fc_resetear_contrasena_usuario.sql new file mode 100644 index 0000000..6de8f75 --- /dev/null +++ b/database/functions/usuarios/fc_resetear_contrasena_usuario.sql @@ -0,0 +1,66 @@ +-- ============================================================================ +-- fc_resetear_contrasena_usuario +-- ============================================================================ +-- PROPÓSITO +-- Permite al superadmin fijar una contraseña nueva para cualquier usuario +-- no-cliente (admin o superadmin), sin pedir ni validar la contraseña +-- actual del usuario objetivo. Reset administrativo sin restricción. +-- +-- PARÁMETROS +-- p_token UUID sesión del actor (debe ser superadmin). +-- p_usuario_id UUID id del usuario objetivo (admin o superadmin). +-- p_password_nueva TEXT contraseña nueva en texto plano (mínimo 8 +-- caracteres). +-- +-- AUTORIZACIÓN +-- Requiere permiso 'resetear_contrasena_usuario' (solo superadmin). +-- +-- ERRORES (RAISE EXCEPTION) +-- - 'La contraseña nueva debe tener al menos 8 caracteres.' +-- - 'Usuario no encontrado.' +-- - 'No se puede resetear la contraseña de un cliente: no inician sesión en este panel.' +-- +-- RETORNA +-- void. +-- +-- EFECTOS SECUNDARIOS +-- UPDATE de password_hash en public.usuarios, fila de p_usuario_id. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fc_resetear_contrasena_usuario( + p_token UUID, + p_usuario_id UUID, + p_password_nueva TEXT +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO extensions, public +AS $function$ +DECLARE + v_rol_objetivo TEXT; +BEGIN + PERFORM internal.validate_permission(p_token := p_token, p_accion := 'resetear_contrasena_usuario'); + + IF p_password_nueva IS NULL OR length(p_password_nueva) < 8 THEN + RAISE EXCEPTION 'La contraseña nueva debe tener al menos 8 caracteres.'; + END IF; + + SELECT rol INTO v_rol_objetivo + FROM public.usuarios + WHERE id = p_usuario_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Usuario no encontrado.'; + END IF; + + IF v_rol_objetivo = 'cliente' THEN + RAISE EXCEPTION 'No se puede resetear la contraseña de un cliente: no inician sesión en este panel.'; + END IF; + + UPDATE public.usuarios + SET password_hash = internal.create_password_hash(p_password_nueva) + WHERE id = p_usuario_id; +END; +$function$; diff --git a/database/schema/00_schemas.sql b/database/schema/00_schemas.sql new file mode 100644 index 0000000..4143583 --- /dev/null +++ b/database/schema/00_schemas.sql @@ -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.` 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; diff --git a/database/schema/10_extensions_y_types.sql b/database/schema/10_extensions_y_types.sql new file mode 100644 index 0000000..5db0abc --- /dev/null +++ b/database/schema/10_extensions_y_types.sql @@ -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; diff --git a/database/schema/README.md b/database/schema/README.md new file mode 100644 index 0000000..fed7fb6 --- /dev/null +++ b/database/schema/README.md @@ -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». diff --git a/database/schema/seeds/internal/app_config.sql b/database/schema/seeds/internal/app_config.sql new file mode 100644 index 0000000..2eba03b --- /dev/null +++ b/database/schema/seeds/internal/app_config.sql @@ -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; diff --git a/database/schema/tables/internal/app_config.sql b/database/schema/tables/internal/app_config.sql new file mode 100644 index 0000000..bd30e08 --- /dev/null +++ b/database/schema/tables/internal/app_config.sql @@ -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) +); diff --git a/database/schema/tables/internal/archivo_exports.sql b/database/schema/tables/internal/archivo_exports.sql new file mode 100644 index 0000000..929fab0 --- /dev/null +++ b/database/schema/tables/internal/archivo_exports.sql @@ -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; diff --git a/database/schema/tables/internal/permisos.sql b/database/schema/tables/internal/permisos.sql new file mode 100644 index 0000000..0baaa73 --- /dev/null +++ b/database/schema/tables/internal/permisos.sql @@ -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; diff --git a/database/schema/tables/internal/sesiones.sql b/database/schema/tables/internal/sesiones.sql new file mode 100644 index 0000000..271d17d --- /dev/null +++ b/database/schema/tables/internal/sesiones.sql @@ -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; diff --git a/database/schema/tables/public/actividades.sql b/database/schema/tables/public/actividades.sql new file mode 100644 index 0000000..43eee33 --- /dev/null +++ b/database/schema/tables/public/actividades.sql @@ -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; diff --git a/database/schema/tables/public/actividades_tipos_cuota.sql b/database/schema/tables/public/actividades_tipos_cuota.sql new file mode 100644 index 0000000..59f6e97 --- /dev/null +++ b/database/schema/tables/public/actividades_tipos_cuota.sql @@ -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; diff --git a/database/schema/tables/public/avisos_pago.sql b/database/schema/tables/public/avisos_pago.sql new file mode 100644 index 0000000..f697051 --- /dev/null +++ b/database/schema/tables/public/avisos_pago.sql @@ -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; diff --git a/database/schema/tables/public/dias_especiales.sql b/database/schema/tables/public/dias_especiales.sql new file mode 100644 index 0000000..c6e6d0c --- /dev/null +++ b/database/schema/tables/public/dias_especiales.sql @@ -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; diff --git a/database/schema/tables/public/eventos.sql b/database/schema/tables/public/eventos.sql new file mode 100644 index 0000000..9d06bbf --- /dev/null +++ b/database/schema/tables/public/eventos.sql @@ -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; diff --git a/database/schema/tables/public/fotos_perfil.sql b/database/schema/tables/public/fotos_perfil.sql new file mode 100644 index 0000000..520ef9d --- /dev/null +++ b/database/schema/tables/public/fotos_perfil.sql @@ -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; diff --git a/database/schema/tables/public/horario_actividad.sql b/database/schema/tables/public/horario_actividad.sql new file mode 100644 index 0000000..1d0b5f6 --- /dev/null +++ b/database/schema/tables/public/horario_actividad.sql @@ -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; diff --git a/database/schema/tables/public/horario_actividad_especial.sql b/database/schema/tables/public/horario_actividad_especial.sql new file mode 100644 index 0000000..0d11c6b --- /dev/null +++ b/database/schema/tables/public/horario_actividad_especial.sql @@ -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; diff --git a/database/schema/tables/public/metodos_pago.sql b/database/schema/tables/public/metodos_pago.sql new file mode 100644 index 0000000..51f528c --- /dev/null +++ b/database/schema/tables/public/metodos_pago.sql @@ -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; diff --git a/database/schema/tables/public/notificaciones.sql b/database/schema/tables/public/notificaciones.sql new file mode 100644 index 0000000..0b8fafb --- /dev/null +++ b/database/schema/tables/public/notificaciones.sql @@ -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; diff --git a/database/schema/tables/public/pagos.sql b/database/schema/tables/public/pagos.sql new file mode 100644 index 0000000..cec04ca --- /dev/null +++ b/database/schema/tables/public/pagos.sql @@ -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; diff --git a/database/schema/tables/public/reservas.sql b/database/schema/tables/public/reservas.sql new file mode 100644 index 0000000..311e723 --- /dev/null +++ b/database/schema/tables/public/reservas.sql @@ -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; diff --git a/database/schema/tables/public/reservas_huerfanas.sql b/database/schema/tables/public/reservas_huerfanas.sql new file mode 100644 index 0000000..e8ddc17 --- /dev/null +++ b/database/schema/tables/public/reservas_huerfanas.sql @@ -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; diff --git a/database/schema/tables/public/tipos_cuota.sql b/database/schema/tables/public/tipos_cuota.sql new file mode 100644 index 0000000..24df912 --- /dev/null +++ b/database/schema/tables/public/tipos_cuota.sql @@ -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; diff --git a/database/schema/tables/public/turnos.sql b/database/schema/tables/public/turnos.sql new file mode 100644 index 0000000..cd91712 --- /dev/null +++ b/database/schema/tables/public/turnos.sql @@ -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; diff --git a/database/schema/tables/public/usuarios.sql b/database/schema/tables/public/usuarios.sql new file mode 100644 index 0000000..ef5987e --- /dev/null +++ b/database/schema/tables/public/usuarios.sql @@ -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; diff --git a/database/triggers/actividades/trg_desactivar_actividad.sql b/database/triggers/actividades/trg_desactivar_actividad.sql new file mode 100644 index 0000000..e2b1104 --- /dev/null +++ b/database/triggers/actividades/trg_desactivar_actividad.sql @@ -0,0 +1,19 @@ +-- ============================================================================ +-- trg_desactivar_actividad +-- ============================================================================ +-- Trigger AFTER UPDATE OF activo sobre actividades, filtrado por la +-- transición activo=TRUE → activo=FALSE. Cuando una actividad se +-- desactiva, dispara internal.cerrar_actividad() que rescata reservas +-- vivas como huérfanas, borra los turnos futuros y cierra las plantillas +-- horarias asociadas. +-- +-- DEPENDE DE +-- functions/actividades/internal/cerrar_actividad.sql +-- (la función debe existir antes de aplicar este trigger). +-- ============================================================================ + +CREATE OR REPLACE TRIGGER trg_desactivar_actividad +AFTER UPDATE OF activo ON actividades +FOR EACH ROW +WHEN (OLD.activo = TRUE AND NEW.activo = FALSE) +EXECUTE FUNCTION internal.cerrar_actividad(); diff --git a/database/triggers/auth/trg_clean_old_sessions.sql b/database/triggers/auth/trg_clean_old_sessions.sql new file mode 100644 index 0000000..024536e --- /dev/null +++ b/database/triggers/auth/trg_clean_old_sessions.sql @@ -0,0 +1,18 @@ +-- ============================================================================ +-- trg_clean_old_sessions +-- ============================================================================ +-- Trigger BEFORE INSERT sobre internal.sesiones. Antes de cada nueva +-- inserción dispara internal.clean_old_sessions_on_new_login(), que borra +-- cualquier sesión previa del mismo usuario. Garantiza una sola sesión +-- activa por usuario. +-- +-- DEPENDE DE +-- functions/auth/internal/clean_old_sessions_on_new_login.sql +-- (la función debe existir antes de aplicar este trigger). +-- ============================================================================ + +create OR REPLACE trigger trg_clean_old_sessions + before insert + on internal.sesiones + for each row +execute procedure internal.clean_old_sessions_on_new_login();