Agrego carpeta del source sql de la Base de Datos

This commit is contained in:
Pablo
2026-08-22 19:10:49 -03:00
parent 11e36bd6c2
commit 88d724fbcf
94 changed files with 7820 additions and 0 deletions
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
+60
View File
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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$
;
@@ -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;
$$;
@@ -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;
$$;
@@ -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
)
$$;
@@ -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;
@@ -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;
$$;
@@ -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$;
@@ -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$
;
@@ -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;
$$;
@@ -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;
$$;
@@ -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$
;
@@ -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;
@@ -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$;
@@ -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$;
+179
View File
@@ -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$;
+272
View File
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
@@ -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;
@@ -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 = <mes>` 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;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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;
$$;
@@ -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;
@@ -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;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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$;
@@ -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;
@@ -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;
@@ -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$;