Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)

This commit is contained in:
2026-04-30 10:09:44 +01:00
commit a0ccb8ca64
188 changed files with 16418 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
-- Migration: Add observations column
alter table public.reservations add column observations text;

View File

@@ -0,0 +1,15 @@
-- Migración 002: Añadir soporte multi-propiedad
-- PREREQUISITO: Backup/export de datos existentes antes de ejecutar.
-- Todos los registros existentes se asignan a 'los_dragos' por defecto.
-- Confirmar con el negocio si es correcto antes de ejecutar.
-- Crear tipo enum para propiedades
CREATE TYPE property_type AS ENUM ('los_dragos', 'la_esquinita');
-- Añadir columna property con default para registros existentes
ALTER TABLE public.reservations
ADD COLUMN property property_type NOT NULL DEFAULT 'los_dragos';
-- Eliminar el default una vez aplicado (queda el valor, pero nuevos registros deben especificarlo)
-- Comentar esta línea si se prefiere mantener el default permanentemente
ALTER TABLE public.reservations ALTER COLUMN property DROP DEFAULT;

View File

@@ -0,0 +1,23 @@
-- Migración 003: Añadir campos de precio para reservas Natur
-- Se usa un snapshot JSON para congelar el cálculo acordado en el momento de guardar.
-- El cálculo dinámico vive en packages/shared/src/utils/pricing.ts
-- Estas columnas son nullable — solo se populan para origin = 'Naturcalabacera'
ALTER TABLE public.reservations
ADD COLUMN igic_rate NUMERIC(5,4) DEFAULT 0.07,
ADD COLUMN pricing_snapshot JSONB;
-- Estructura del JSONB pricing_snapshot:
-- {
-- "basePrice": 1800, -- Canon base * noches
-- "extraPersonsFee": 120, -- Personas extra * tarifa * noches
-- "subtotal": 1920, -- basePrice + extraPersonsFee
-- "igicAmount": 134.4, -- subtotal * igicRate
-- "total": 2054.4, -- subtotal + igicAmount
-- "calculatedAt": "2026-04-10T12:00:00Z"
-- }
COMMENT ON COLUMN public.reservations.pricing_snapshot IS
'Snapshot congelado del cálculo de precio al guardar. Solo para origin=Naturcalabacera.';
COMMENT ON COLUMN public.reservations.igic_rate IS
'Tasa IGIC aplicada. Default 0.07 (7%). Configurable por reserva.';

View File

@@ -0,0 +1,29 @@
-- Migración 004: Tabla de contratos adjuntos a reservas
-- Diseño: tabla separada (no columnas en reservations) para soportar múltiples contratos por reserva
CREATE TABLE public.reservation_contracts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
reservation_id UUID NOT NULL REFERENCES public.reservations(id) ON DELETE CASCADE,
file_path TEXT NOT NULL, -- Path en Supabase Storage: contracts/{reservation_id}/{filename}
filename TEXT NOT NULL, -- Nombre visible del archivo
mime_type TEXT NOT NULL, -- application/pdf, image/jpeg, image/png
size_bytes INTEGER NOT NULL,
uploaded_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Índice para consultas por reserva
CREATE INDEX idx_reservation_contracts_reservation_id
ON public.reservation_contracts(reservation_id);
ALTER TABLE public.reservation_contracts ENABLE ROW LEVEL SECURITY;
-- Por ahora: staff puede gestionar contratos (RLS se refina en migración 009)
CREATE POLICY "Temp: authenticated can manage contracts"
ON public.reservation_contracts
FOR ALL TO authenticated
USING (true)
WITH CHECK (true);
COMMENT ON TABLE public.reservation_contracts IS
'Contratos adjuntos a reservas. Archivos almacenados en Supabase Storage bucket "contracts".';

View File

@@ -0,0 +1,43 @@
-- Migración 005: Sistema de roles de usuario
-- Roles definidos para ser extensibles sin reescribir lógica:
-- admin: acceso total
-- internal_staff: acceso completo, sin gestión de usuarios (futuro uso)
-- external_availability_viewer: solo ve ocupado/libre (Teneriffa)
CREATE TYPE user_role AS ENUM ('admin', 'internal_staff', 'external_availability_viewer');
CREATE TABLE public.user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'admin',
display_name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
-- Los usuarios pueden leer su propio perfil
CREATE POLICY "Users can read own profile"
ON public.user_profiles FOR SELECT TO authenticated
USING (auth.uid() = id);
-- Solo admin puede gestionar perfiles (INSERT/UPDATE/DELETE se hace via service_role en el API)
-- Función para obtener el rol del usuario actual (usada en otras policies RLS)
CREATE OR REPLACE FUNCTION public.get_user_role()
RETURNS user_role AS $$
SELECT role FROM public.user_profiles WHERE id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER STABLE;
COMMENT ON FUNCTION public.get_user_role IS
'Devuelve el rol del usuario autenticado actual. Usada en políticas RLS.';
-- ============================================================
-- ACCIÓN MANUAL REQUERIDA antes de ejecutar migración 009:
-- Insertar perfil admin para el usuario actual:
--
-- INSERT INTO public.user_profiles (id, email, role)
-- SELECT id, email, 'admin'
-- FROM auth.users
-- WHERE email = 'tu-email@ejemplo.com';
-- ============================================================

View File

@@ -0,0 +1,24 @@
-- Migración 006: Campos de auditoría en reservations
-- Trazabilidad mínima: quién creó/modificó y cuándo
ALTER TABLE public.reservations
ADD COLUMN created_by UUID REFERENCES auth.users(id),
ADD COLUMN updated_by UUID REFERENCES auth.users(id),
ADD COLUMN updated_at TIMESTAMPTZ;
-- Función para actualizar updated_at automáticamente en cada UPDATE
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_reservations_updated_at
BEFORE UPDATE ON public.reservations
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON COLUMN public.reservations.created_by IS 'UUID del usuario que creó la reserva.';
COMMENT ON COLUMN public.reservations.updated_by IS 'UUID del usuario que realizó el último cambio.';
COMMENT ON COLUMN public.reservations.updated_at IS 'Timestamp del último cambio (auto-actualizado por trigger).';

View File

@@ -0,0 +1,49 @@
-- Migración 007: Tabla de eventos de notificación
-- Fuente de verdad para el sistema de jobs. Garantiza idempotencia mediante UNIQUE constraint.
--
-- Tipos de evento soportados:
-- reservation.created — Teneriffa CRUD
-- reservation.updated — Teneriffa CRUD
-- reservation.cancelled — Teneriffa CRUD
-- reservation.reminder_24h — 24h antes check-in (Teneriffa + Los Dragos)
-- reservation.invoice_second_notice — 10 días antes check-in (Natur, ambas casas)
-- reservation.pool_heating_notice — 48h antes check-in (si has_pool_heating = true)
CREATE TABLE public.notification_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
reservation_id UUID REFERENCES public.reservations(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending': esperando ejecución
-- 'processing': siendo procesado por el runner (previene duplicados)
-- 'sent': enviado correctamente
-- 'failed': fallido después de max_retries intentos
attempts INTEGER DEFAULT 0 NOT NULL,
last_error TEXT,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- CONSTRAINT DE IDEMPOTENCIA:
-- Un mismo evento (mismo tipo + misma fecha programada) para una reserva
-- solo puede existir una vez. ON CONFLICT DO NOTHING previene duplicados.
CONSTRAINT uq_notification_event
UNIQUE(reservation_id, event_type, scheduled_for)
);
CREATE INDEX idx_notification_events_pending
ON public.notification_events(scheduled_for)
WHERE status = 'pending';
ALTER TABLE public.notification_events ENABLE ROW LEVEL SECURITY;
-- Solo admin puede ver eventos de notificación (se refina en migración 009)
CREATE POLICY "Temp: authenticated can read notifications"
ON public.notification_events FOR SELECT TO authenticated
USING (true);
COMMENT ON TABLE public.notification_events IS
'Eventos de notificación programados. El runner en apps/api los procesa periódicamente.';
COMMENT ON COLUMN public.notification_events.status IS
'pending → processing → sent | failed. El estado "processing" previene que múltiples instancias procesen el mismo evento.';

View File

@@ -0,0 +1,25 @@
-- Migración 008: Vista segura para usuarios con rol external_availability_viewer
--
-- Problema: RLS limita FILAS pero no COLUMNAS.
-- Los usuarios Teneriffa solo deben ver ocupado/libre, sin datos personales.
-- Solución: vista que expone únicamente los campos necesarios.
-- El frontend de Teneriffa consulta esta vista, no la tabla directa.
CREATE OR REPLACE VIEW public.reservations_availability AS
SELECT
id,
start_date,
end_date,
property,
'Ocupado' AS status -- Nunca expone el origen real ni el cliente
FROM public.reservations;
-- Dar acceso de SELECT a usuarios authenticated a la vista
-- La seguridad real se gestiona en el frontend con useUserRole()
-- y en la migración 009 mediante RLS en la tabla base.
GRANT SELECT ON public.reservations_availability TO authenticated;
COMMENT ON VIEW public.reservations_availability IS
'Vista de disponibilidad segura. Solo expone fechas y propiedad. '
'Usada por usuarios external_availability_viewer (Teneriffa). '
'No expone cliente, precios, notas ni datos personales.';

View File

@@ -0,0 +1,82 @@
-- Migración 009: Políticas RLS de producción
--
-- ⚠️ PREREQUISITO OBLIGATORIO antes de ejecutar:
-- Insertar perfil admin para TU usuario actual. Si no lo haces,
-- quedarás bloqueado y no podrás acceder a los datos.
--
-- Ejecuta PRIMERO (reemplaza el email):
-- INSERT INTO public.user_profiles (id, email, role)
-- SELECT id, email, 'admin' FROM auth.users
-- WHERE email = 'tu-email@ejemplo.com'
-- ON CONFLICT (id) DO NOTHING;
--
-- Verifica que tienes acceso ANTES de continuar.
-- ────────────────────────────────────────────────────────────
-- TABLA: reservations
-- ────────────────────────────────────────────────────────────
-- Eliminar políticas dev-mode abiertas
DROP POLICY IF EXISTS "Enable read access for all users" ON public.reservations;
DROP POLICY IF EXISTS "Enable insert for all users" ON public.reservations;
DROP POLICY IF EXISTS "Enable update for all users" ON public.reservations;
DROP POLICY IF EXISTS "Enable delete for all users" ON public.reservations;
-- Admin e internal_staff: acceso completo a la tabla
CREATE POLICY "Staff: full read"
ON public.reservations FOR SELECT TO authenticated
USING (public.get_user_role() IN ('admin', 'internal_staff'));
CREATE POLICY "Staff: insert"
ON public.reservations FOR INSERT TO authenticated
WITH CHECK (public.get_user_role() IN ('admin', 'internal_staff'));
CREATE POLICY "Staff: update"
ON public.reservations FOR UPDATE TO authenticated
USING (public.get_user_role() IN ('admin', 'internal_staff'))
WITH CHECK (public.get_user_role() IN ('admin', 'internal_staff'));
CREATE POLICY "Staff: delete"
ON public.reservations FOR DELETE TO authenticated
USING (public.get_user_role() IN ('admin', 'internal_staff'));
-- external_availability_viewer: SIN acceso directo a la tabla reservations.
-- Solo accede a la vista reservations_availability (migración 008).
-- ────────────────────────────────────────────────────────────
-- TABLA: reservation_contracts
-- ────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS "Temp: authenticated can manage contracts" ON public.reservation_contracts;
CREATE POLICY "Staff: manage contracts"
ON public.reservation_contracts FOR ALL TO authenticated
USING (public.get_user_role() IN ('admin', 'internal_staff'))
WITH CHECK (public.get_user_role() IN ('admin', 'internal_staff'));
-- ────────────────────────────────────────────────────────────
-- TABLA: notification_events
-- ────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS "Temp: authenticated can read notifications" ON public.notification_events;
CREATE POLICY "Admin: read notifications"
ON public.notification_events FOR SELECT TO authenticated
USING (public.get_user_role() = 'admin');
-- El API usa service_role para INSERT/UPDATE en notification_events (bypasea RLS).
-- ────────────────────────────────────────────────────────────
-- TABLA: user_profiles
-- ────────────────────────────────────────────────────────────
-- La policy existente "Users can read own profile" se mantiene.
-- El API usa service_role para gestionar perfiles.
-- ────────────────────────────────────────────────────────────
-- REVOCAR acceso anónimo a todas las tablas
-- ────────────────────────────────────────────────────────────
REVOKE ALL ON public.reservations FROM anon;
REVOKE ALL ON public.reservation_contracts FROM anon;
REVOKE ALL ON public.notification_events FROM anon;
REVOKE ALL ON public.user_profiles FROM anon;

View File

@@ -0,0 +1,13 @@
-- Migración 010: Soporte para múltiples tipos de documento (contrato, factura, otros)
-- La tabla reservation_contracts pasa a ser un repositorio genérico de documentos
-- de reserva. La columna document_type clasifica cada archivo.
ALTER TABLE natur_reservas.reservation_contracts
ADD COLUMN IF NOT EXISTS document_type TEXT NOT NULL DEFAULT 'contract'
CHECK (document_type IN ('contract', 'invoice', 'other'));
CREATE INDEX IF NOT EXISTS idx_reservation_contracts_doc_type
ON natur_reservas.reservation_contracts(reservation_id, document_type);
COMMENT ON COLUMN natur_reservas.reservation_contracts.document_type IS
'Tipo de documento: contract (contrato firmado), invoice (factura), other.';