Agrego carpeta de supabase

This commit is contained in:
Pablo
2026-08-22 19:11:07 -03:00
parent 88d724fbcf
commit a9dfba2cb6
10 changed files with 8218 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
-- ============================================================================
-- internal.entorno — tabla de identidad del entorno
-- ============================================================================
-- Migración delta (Carril 1). La base declara si es 'dev' o 'prod'. Es la piedra
-- angular de los guardarraíles (ver Documentation/EntornoDesarrollo.md §F): toda
-- herramienta que vaya a escribir consulta esta fila antes de operar, y frena si
-- la base no se sabe a sí misma.
--
-- DISEÑO
-- - Fila ÚNICA forzada: id boolean PK DEFAULT true + CHECK (id). Solo el valor
-- true satisface el CHECK, y al ser PK no puede repetirse → a lo sumo una
-- fila, jamás dos identidades en conflicto.
-- - valor ∈ {'dev','prod'} por CHECK. No hay tercer estado válido.
--
-- FAIL-SAFE (deliberado)
-- La tabla NO se seedea desde el repo (esta migración no trae INSERT). Una base
-- recién clonada nace con la tabla VACÍA. Tabla vacía = identidad desconocida =
-- la herramienta se niega a escribir. La identidad se setea a mano por entorno
-- como paso explícito post-clon (US-E11 y el checklist de promoción US-E16).
-- ============================================================================
CREATE TABLE IF NOT EXISTS internal.entorno (
id boolean DEFAULT true NOT NULL,
valor text NOT NULL,
CONSTRAINT entorno_pkey PRIMARY KEY (id),
CONSTRAINT entorno_fila_unica CHECK (id),
CONSTRAINT entorno_valor_valido CHECK (valor IN ('dev', 'prod'))
);
ALTER TABLE internal.entorno ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,82 @@
-- ============================================================================
-- seed: internal.app_config (Carril 1 — migración de datos)
-- ============================================================================
-- Pares clave/valor de configuración runtime. Cada clave es leída por las
-- funciones consumidoras vía internal.get_config_int(clave, default); si la
-- fila no existe, la función cae al default. Las filas de este seed existen
-- para que el operador pueda hacer override sin redeploy y para que la tabla
-- documente qué parámetros expone el sistema.
--
-- ORIGEN: estos 9 valores se verificaron contra prod (xkmaysctkjsytgmxrkyo) el
-- 2026-06-04 y coinciden exactos — no hay override operativo en prod, el seed
-- es fiel a la realidad. Migración trasladada desde el archivo histórico
-- database/schema/seeds/internal/app_config.sql al Carril 1.
--
-- Convención:
-- - Una sección por dominio (retencion.*, pagos.*, etc.).
-- - El valor seedeado debe coincidir con el default que el consumidor pasa
-- a get_config_int. Si los dos divergen, el seed manda.
-- - ON CONFLICT DO NOTHING: idempotente y preserva overrides operativos.
-- Promover esta migración a prod (que ya tiene las filas) es un no-op.
-- Para reescribir un valor, hacer UPDATE explícito.
--
-- Stories asociadas:
-- - Documentation/Backlog/PoliticaRetencion.md US-R04 (sección retencion.*)
-- - Documentation/Backlog/EntornoDesarrollo.md US-E09 (criterio de seed)
-- ============================================================================
-- ─── Retención operativa ────────────────────────────────────────────────────
-- Política: Documentation/PoliticaRetencion.md §3 (archivado) y §7 (higiene).
-- Unidad: meses. Consumidores: funciones de purga (US-R06..R09), validación
-- de registro de pagos (US-R10), y orquestación del pipeline mensual (US-R17).
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.pagos_meses', '12',
'Ventana operativa de pagos en meses. Al cumplirse, el pago migra al archivo XLSX y se borra de la base.'),
('retencion.turnos_meses', '2',
'Antigüedad máxima de turnos pasados antes de purgarse (CASCADE arrastra reservas asociadas).'),
('retencion.eventos_pagos_meses', '3',
'Antigüedad máxima de eventos de pagos (prefijo pago_) antes de purgarse.'),
('retencion.eventos_otros_meses', '2',
'Antigüedad máxima del resto de eventos antes de purgarse.'),
('retencion.dias_especiales_meses', '1',
'Antigüedad máxima de días especiales pasados antes de purgarse (CASCADE arrastra horario_actividad_especial).')
ON CONFLICT (clave) DO NOTHING;
-- ─── Retención: cap de seguridad de eventos ─────────────────────────────────
-- Tope duro al tamaño de public.eventos. Unidad: filas (conteo, no meses).
-- Consumidor: internal.log_evento (US-R12). No es una ventana temporal: es un
-- freno contra un bug que dispare eventos en loop y consuma la cuota de la base
-- antes de la próxima purga mensual. Al alcanzarse, log_evento rechaza el insert
-- con excepción (no FIFO): el rechazo hace fallar la función que dispara el
-- evento y corta la cascada, en vez de borrar eventos legítimos en silencio.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.eventos_cap', '50000',
'Tope de filas en public.eventos. Al alcanzarlo, internal.log_evento rechaza nuevos inserts con excepción (freno anti-loop, no FIFO).')
ON CONFLICT (clave) DO NOTHING;
-- ─── Pagos: ventanas operativas ─────────────────────────────────────────────
-- Claves ya consumidas por funciones existentes (fc_editar_pago, fc_anular_pago,
-- fc_reservar_turno) que hasta ahora venían cayendo al default por ausencia de
-- fila. Sembradas acá para que queden explícitas y editables sin redeploy.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('pagos.ventana_edicion_minutos', '30',
'Minutos desde el registro durante los cuales un pago admite edición o anulación sin disparar evento. Leída por fc_editar_pago y fc_anular_pago.'),
('pagos.umbral_morosidad_meses', '2',
'Cantidad de meses impagos a partir de los cuales se bloquea al cliente para reservar turnos. Leída por fc_reservar_turno.')
ON CONFLICT (clave) DO NOTHING;
-- ─── Retención: huérfanas resueltas (ventana de seguridad) ──────────────────
-- Unidad: días (no meses). Consumidor: internal.limpiar_huerfanas_resueltas.
-- Es el piso de gracia para deshacer un cambio de estado por error: una
-- huérfana en estado terminal (reubicado/resuelta) recién se vuelve elegible
-- para purgar cuando pasaron al menos N días desde su `resuelto_en`. La purga
-- real corre en la cadencia del cron mensual; en la práctica las terminales
-- viven entre N+1 y N+31 días.
INSERT INTO internal.app_config (clave, valor, descripcion) VALUES
('retencion.huerfanas_resueltas_dias', '14',
'Días de gracia mínimos que una reserva huérfana en estado terminal (reubicado/resuelta) debe tener desde su cambio de estado para ser elegible para purga.')
ON CONFLICT (clave) DO NOTHING;
@@ -0,0 +1,30 @@
-- Mueve el range type timerange (y su multirange) de public a internal.
--
-- Motivo: `supabase db reset` vacía el schema public objeto por objeto y no puede
-- dropear la función constructora que Postgres genera para un range type, así que el
-- reset se traba. En cambio borra los schemas no-public enteros con DROP SCHEMA
-- CASCADE, donde el tipo y su constructor se van juntos. Con timerange en internal,
-- el reset de dev funciona.
--
-- Es seguro: ninguna función referencia el tipo; el único objeto que lo usa es la
-- constraint de no-solapamiento de horario_actividad, que se recrea acá apuntando a
-- internal.timerange. No cambia ningún dato.
ALTER TABLE "public"."horario_actividad"
DROP CONSTRAINT "horario_actividad_no_solapamiento";
DROP TYPE "public"."timerange" CASCADE; -- arrastra public.timemultirange
CREATE TYPE "internal"."timerange" AS RANGE (
subtype = time without time zone,
multirange_type_name = "internal"."timemultirange"
);
ALTER TABLE "public"."horario_actividad"
ADD CONSTRAINT "horario_actividad_no_solapamiento"
EXCLUDE USING "gist" (
"dia_semana" WITH =,
"actividad_id" WITH =,
"internal"."timerange"("hora_inicio", "hora_fin") WITH &&,
"daterange"("valido_desde", COALESCE("valido_hasta", 'infinity'::"date"), '[]'::"text") WITH &&
);
@@ -0,0 +1,38 @@
-- Setup del módulo de retención: las extensiones del cron y el job programado.
--
-- Va como migración para que un `supabase db reset` reconstruya también el cron, no
-- solo el esquema. Antes esto se montaba a mano por entorno; al ponerlo acá, dev se
-- recupera entero después de un reset sin pasos manuales.
--
-- Lo que sigue siendo manual y NO puede ir acá:
-- - El deploy del Edge Function `pipeline-mensual` (es otra superficie, no vive en
-- la base).
-- - Los secretos del Vault (`pipeline_mensual_url`, `pipeline_mensual_auth`). Son
-- secretos; además sobreviven al reset, así que solo hay que cargarlos a mano en
-- un proyecto nuevo o si se borran. El job los lee en runtime, no al programarse.
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pg_cron";
-- Idempotente: si el job ya existe (prod, o una re-aplicación), lo saca antes de
-- volver a programarlo. Si no existe, no devuelve filas y no hace nada.
SELECT cron.unschedule(jobid) FROM cron.job WHERE jobname = 'pipeline_mensual_job';
SELECT cron.schedule(
'pipeline_mensual_job',
'0 6 1 * *', -- 06:00 UTC = 03:00 AM Argentina (UTC-3, sin DST). Día 1.
$job$
SELECT net.http_post(
url := (SELECT decrypted_secret FROM vault.decrypted_secrets
WHERE name = 'pipeline_mensual_url'),
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || (SELECT decrypted_secret
FROM vault.decrypted_secrets
WHERE name = 'pipeline_mensual_auth')
),
body := '{}'::jsonb,
timeout_milliseconds := 300000
);
$job$
);
@@ -0,0 +1,29 @@
-- Despliega fc_iniciar_sesion_por_token: reanuda la sesión desde el token guardado,
-- extiende su TTL +10 días y devuelve el rol. Reemplaza a fc_validar_token en el
-- arranque de la app. Función nueva (nombre nuevo) → CREATE OR REPLACE pelado, sin DROP.
-- Fuente canónica: database/functions/auth/fc_iniciar_sesion_por_token.sql
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
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,304 @@
-- Feature "cambio de contraseña": password_hash pasa a ser opcional (los
-- clientes no inician sesión en este panel, usan el bot de WhatsApp), por lo
-- que fc_insertar_usuario ya no exige password cuando rol = 'cliente'.
-- fc_modificar_usuario aprende a aceptar un campo opcional 'password' en el
-- PATCH (gateado por el permiso dedicado resetear_contrasena_usuario). Se
-- agregan dos funciones nuevas: fc_cambiar_propia_contrasena (autoservicio,
-- admin/superadmin cambian la suya confirmando la actual) y
-- fc_resetear_contrasena_usuario (reset administrativo, solo superadmin,
-- sobre cualquier no-cliente, sin pedir la actual del objetivo).
-- Fuentes canónicas:
-- database/schema/tables/public/usuarios.sql
-- database/functions/usuarios/fc_insertar_usuario.sql
-- database/functions/usuarios/fc_modificar_usuario.sql
-- database/functions/usuarios/fc_cambiar_propia_contrasena.sql (nuevo)
-- database/functions/usuarios/fc_resetear_contrasena_usuario.sql (nuevo)
-- 1. password_hash ya no es NOT NULL
ALTER TABLE public.usuarios ALTER COLUMN password_hash DROP NOT NULL;
-- 2. Permisos nuevos.
-- Ids explícitos 43/44 a propósito: en el db reset esta migración corre ANTES
-- del seed, que trae los permisos 1..42 con ids fijos. Si dejáramos que el id
-- lo genere la identity, en una base recién creada agarraría 1 y 2 y el seed
-- chocaría (permisos_pkey). En prod, donde ya existen 1..42, 43/44 quedan libres.
INSERT INTO internal.permisos OVERRIDING SYSTEM VALUE VALUES
(43, 'cambiar_propia_contrasena', ARRAY['superadmin','admin']),
(44, 'resetear_contrasena_usuario', ARRAY['superadmin']);
SELECT setval('internal.permisos_id_permiso_seq', 44, true);
-- 3. fc_insertar_usuario: password obligatorio salvo para rol 'cliente'.
-- Misma firma -> CREATE OR REPLACE sin DROP.
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$;
-- 4. fc_modificar_usuario: acepta password opcional en el PATCH, gateado por
-- el permiso dedicado resetear_contrasena_usuario. Misma firma -> CREATE
-- OR REPLACE sin DROP.
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$;
-- 5. fc_cambiar_propia_contrasena: autoservicio (admin/superadmin cambian la
-- suya, confirmando la actual). Función nueva.
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$;
-- 6. fc_resetear_contrasena_usuario: reset administrativo (solo superadmin,
-- sobre cualquier no-cliente, sin pedir la actual del objetivo). Función
-- nueva.
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$;