Reservas-online

Turnigo — SaaS de reservas online (multi-tenant)

Plataforma multi-tenant de reservas para negocios locales, con tres piezas:

  1. Widget embebible — se inserta en la web del negocio vía <script> + iframe aislado, con branding por tenant. También funciona como página standalone (sirve tal cual como “enlace de reservas” en una ficha de Google Business Profile).
  2. Panel de negocio — agenda, KPIs, clientes, servicios, bloqueos, reportes y configuración.
  3. Panel super-admin — alta de negocios, usuarios de staff y activación de suscripciones (sin registro público).

Tipos de tenant (business_type):

Todo el backend vive en Supabase (Postgres + Auth + RLS + Edge Functions + Storage). Frontends en Cloudflare Workers (push a main → deploy automático). Ver CLAUDE.md para el flujo de trabajo y verificación.


Arquitectura y decisiones

Frontend: React + Vite + TypeScript + Tailwind (modo oscuro con darkMode: "class"). El widget se compila como app independiente para que su bundle sea pequeño y embebible. Monorepo con npm workspaces: packages/shared (cliente Supabase tipado, helpers de fecha/timezone) consumido por ambas apps.

El motor de disponibilidad vive en la base de datos (get_available_slots / get_available_dining_slots, PL/pgSQL) y es la única fuente de verdad: horario del negocio ∩ disponibilidad del servicio ∩ horario del profesional − reservas − bloqueos. La creación de reserva re-valida bajo un lock por negocio (pg_advisory_xact_lock) para evitar dobles reservas; en restaurante, además, una constraint EXCLUDE impide físicamente dos reservas solapadas en la misma mesa.

Aislamiento multi-tenant con RLS. Cada tabla filtra por business_id mediante is_business_member(); el super-admin (profiles.is_super_admin) tiene visión global. Los clientes finales no tienen cuenta: el widget usa RPCs SECURITY DEFINER (lectura) y la Edge Function create-booking con service_role (escritura).

Secretos por negocio (Resend, WhatsApp, Google OAuth, Gemini) en business_integrations, con RLS que impide leerlos desde el navegador: el panel solo ve un estado “configurada ✓” vía RPCs get_*_status/set_*; solo las Edge Functions los leen con service_role. Los secrets globales de plataforma son un fallback opcional.

Reglas online vs. staff: antelación mín/máx, límites de comensales, stock online y pacing solo aplican a p_channel='web'; las reservas manuales del staff siempre ven el aforo completo.


Estructura del proyecto

.
├── apps/
│   ├── widget/                 # Widget embebible (React/Vite) — public/embed.js carga el iframe
│   └── dashboard/              # Panel super-admin (src/admin) + panel de negocio (src/business)
├── packages/shared/            # Cliente Supabase tipado, tipos DB (database.types.ts), formato/timezone
├── supabase/
│   ├── migrations/             # Esquema + RLS + lógica (ver tabla abajo)
│   ├── seed.sql                # Datos demo
│   └── functions/              # Edge Functions (Deno); _shared/ = cors, email, whatsapp, google, gbpHours
│       ├── create-booking            # Público: crea reserva (citas o mesa) + email + export a Google
│       ├── send-confirmation-email   # Reenvía el email de confirmación
│       ├── whatsapp-reminders        # Cron horario: recordatorio 24h por WhatsApp
│       ├── request-reviews           # Cron horario: email de petición de reseña 1-3h post-visita
│       ├── notify-waitlist           # Panel: avisa por WhatsApp que hay mesa
│       ├── admin-create-business     # Super-admin: alta de negocio + staff
│       ├── admin-business-users      # Super-admin: añadir/quitar/cambiar rol de usuarios de un negocio
│       ├── google-oauth-start / google-oauth-callback / sync-google-event / sync-google-busy
│       │                             # Google Calendar por profesional (export de citas + import de ocupados cada 15 min)
│       ├── google-business-oauth-start / google-business-oauth-callback / sync-google-business-hours
│       │                             # Horario desde Google Business Profile (cron horario)
│       └── generate-client-ai-report # Psicólogo: informe de cliente con Gemini
└── memory/                     # Lecciones técnicas para el asistente (feedback-*.md)

Migraciones

Archivo Contenido
0001_core_schema.sql Extensiones, enums y todas las tablas
0002_rls_policies.sql Funciones de rol + políticas RLS + triggers de guarda (guard_business_update)
0003_logic_and_rpcs.sql Motor de disponibilidad, localizador, RPCs públicas, creación transaccional
0004_security_hardening.sql search_path fijo + revocar create_public_booking a anon
0005_storage_logos.sql Bucket público logos
0006_restaurant_logic.sql Motor de aforo de restaurante (dining_shifts, get_available_dining_slots, create_public_dining_booking)
0007_business_integrations.sql Credenciales de email/WhatsApp por negocio (RLS + RPCs)
0008_customer_phone_normalization.sql Clientes agrupados por teléfono normalizado (phone_norm, índice único parcial)
0009_dining_tables.sql (+0009b, 0009c) Zonas y mesas físicas, duración por nº de comensales, asignación best-fit + override manual
0010_add_pendiente_status.sql Estado pendiente (confirmación manual)
0011_dining_settings.sql (+0011b) dining_settings (antelación, comensales online, confirmación manual), pacing/stock online/doblar mesa/limpieza por franja, dining_table_combos
0012 / 0013 Estado sentada; canal walkin
0014_walkins_and_realtime.sql dining_assign_table (motor de asignación reutilizable), create_walkin_booking, notas de cliente, Realtime en bookings
0015_waitlist_and_reviews.sql Lista de espera, enlace de reseña y plantilla de WhatsApp por negocio, review_requests_log
0016_import_customer_rpc.sql import_customer: upsert por teléfono normalizado (importación CSV y alta manual)
0017 / 0018 / 0019 Servicios↔profesionales N:N (service_professionals), professionals.color, selección de profesional en RPCs (0019 corrige una regresión de 0018 en el upsert de clientes)
0020_max_advance_days.sql businesses.max_advance_days + p_channel en RPCs de citas
0021 / 0022 Google Calendar: professional_google_accounts, bookings.google_event_id, blocks.source; cron sync-google-busy cada 15 min
0023_public_available_days.sql get_available_days / get_available_dining_days (el widget oculta días sin huecos)
0024_email_custom_messages.sql businesses.confirmation_email_message / review_email_message
0025_reminder_review_cron.sql Crons whatsapp-reminders-hourly y request-reviews-hourly
0026_google_business_profile_hours_sync.sql business_google_profile_accounts, replace_business_hours_from_sync (solo service_role), cron sync-google-business-hours-hourly (minuto 5)
0027_business_type_psicologo.sql Valor psicologo en business_type
0028_fix_sync_google_busy_cron_secret.sql El cron de sync-google-busy pasa a usar CRON_SECRET
0029 / 0030 Psicólogo: customers.nif, client_sessions (una fila por sesión: objetivo, notas, seguimiento, tareas_pautas), client_ai_reports, business_integrations.gemini_api_key + RPCs get_gemini_status/set_gemini_key

Los cron jobs se crean con el secreto como placeholder REPLACE_WITH_SECRET; en producción está el valor real embebido en cron.job.


Variables de entorno

Copia .env.example a .env. Frontend (VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_WIDGET_URL) se expone al navegador; los valores de producción están en apps/*/.env.production.

Secrets de Edge Functions (Supabase → Edge Functions → Manage secrets; SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY los inyecta Supabase):

Variable Uso
CRON_SECRET Cabecera x-cron-secret de todos los crons (whatsapp-reminders, request-reviews, sync-google-busy, sync-google-business-hours)
GOOGLE_STATE_SECRET Firma HMAC del state de los flujos OAuth de Google
DASHBOARD_URL Redirección al panel tras el callback de OAuth
GOOGLE_SYNC_CRON_SECRET Legado (sustituido por CRON_SECRET en 0028)
RESEND_API_KEY, EMAIL_FROM, WIDGET_URL Fallback global de email si el negocio no tiene su propia clave
WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_TEMPLATE_NAME, WHATSAPP_TEMPLATE_LANG Fallback global de WhatsApp

Puesta en marcha desde cero (otro proyecto Supabase)

npm install
supabase login && supabase link --project-ref TU_PROJECT_REF
supabase db push                                  # migraciones
supabase db execute --file supabase/seed.sql      # datos demo (opcional)
supabase functions deploy                         # todas las funciones

create-booking, los crons (whatsapp-reminders, request-reviews, sync-google-busy, sync-google-business-hours) y los callbacks OAuth (google-oauth-callback, google-business-oauth-callback) van con --no-verify-jwt: son endpoints públicos con su propia validación. Después, configurar los secrets de arriba y sustituir REPLACE_WITH_SECRET en los cron jobs (cron.alter_job o re-ejecutar el cron.schedule de las migraciones 0022/0025/0026/0028 con el valor real).

Marcar una cuenta como super-admin:

update public.profiles set is_super_admin = true where id = (select id from auth.users where email = 'tu@email.com');

Local

npm run dev:dashboard   # http://localhost:5173
npm run dev:widget      # http://localhost:5174  (demo: /demo-host.html)

Solo para desarrollo; la verificación se hace contra Cloudflare (ver CLAUDE.md).

Despliegue (Cloudflare Workers)

Workers Builds está conectado al repo github.com/lodesiko12/Reservas-online: cada push a main construye y despliega turnigo-panel (apps/dashboard) y turnigo-widget (apps/widget) según sus wrangler.jsonc. Las variables VITE_* se leen de apps/*/.env.production. Las Edge Functions no pasan por Cloudflare: se despliegan con el CLI/MCP de Supabase.


Funcionalidades

Comunes (todos los tipos)

Citas

Todo gateado por business.type === 'psicologo' en panel y Edge Function.

Restaurante

Autónomo (2026-09-28)

Sin reserva online ni widget; mini-CRM propio, un único usuario por negocio (apps/dashboard/src/business/crm/).


Seguridad y RLS


Datos de demostración

seed.sql crea Restaurante La Plaza (restaurante-la-plaza, staff@restaurante.test / Restaurante1234!, con zonas Interior/Terraza y 10 mesas) y Barbería El Corte (barberia-demo, staff@barberia.test / Barberia1234!) más un super-admin admin@reservas.test / Admin1234!.

En producción solo existe el restaurante: la barbería y admin@reservas.test se borraron. El único super-admin es la cuenta real del dueño. Para probar citas/psicólogo se usa un negocio real (ver CLAUDE.md).


Pendientes

Lista única. Si retomas el proyecto, empieza por aquí.

Bloqueados por algo externo

Pendiente Bloqueado por Al desbloquear
Huella bancaria y prepago (Stripe) El negocio necesita cuenta de Stripe (vale test) SetupIntent (huella) / PaymentIntent (prepago) + aviso legal de política de cancelación antes de confirmar
Resumen de reseñas con IA Aprobación “Basic API Access” de Google Business Profile API Leer reseñas vía GBP API, resumir, mostrar en Reportes
Horario desde Google Business Profile operativo (código desplegado, inerte) Misma aprobación de Google: ficha verificada 60+ días, web propia enlazada, solicitante owner/manager; sin plazo Conectar en Configuración → Horario en Google Business Profile y comprobar la siguiente pasada del cron (minuto 5)
Google Calendar operativo de punta a punta Ningún negocio ha puesto aún su Client ID/Secret en Configuración → Integraciones Conectar un profesional → exportar cita → ver ocupados importados
Ver una generación exitosa del Informe de IA Gemini devolviendo 503/429 en todos los intentos hasta ahora (capacidad de Google) Confirmar contenido generado + fila en client_ai_reports + visualización en la pestaña Informe

Decisión pendiente del usuario

Auditoría de seguridad (2026-09-26) — falta terminar

Detalle completo en memory/project-reservas-saas.md. Ya hecho: 2 fallos de autorización reales corregidos, validación (zod) en create-booking + widget + la mayoría del panel, rate limiting en las 8 Edge Functions con datos de usuario (create-booking, send-confirmation-email, generate-client-ai-report, notify-waitlist, google-oauth-start, google-business-oauth-start, admin-business-users, admin-create-business), dependencias vulnerables actualizadas (react-router-dom, jspdf). Queda:

Sin priorizar (el usuario dijo que no hace falta todavía)

Mejoras menores