Lo innovador, en cinco líneas
- Las reglas de negocio viven en Postgres, no en TypeScript. Roles, planes y límites son funciones SQL — las mismas que aplican la RLS — así que la UI y la base no pueden discrepar sobre quién puede hacer qué.
- Un solo Worker sirve tres productos —panel B2B, portal B2C y admin de plataforma— ramificando por cabecera
Hosten el edge. Un bundle, un despliegue, cero duplicación de dominio. - Seguimiento en vivo con fotos: el dueño ve el paso actual de su mascota y las fotos del baño mientras ocurre, desde la misma máquina de estados con la que trabaja el bañador.
- Los planes son datos, no constantes. Cambiar el precio o el límite de un plan no requiere desplegar, y los límites los aplica un trigger en la base.
- Sin runtime de Node: Next.js 16 sobre Cloudflare Workers vía OpenNext, con las decisiones de compatibilidad documentadas donde duelen.
Qué resuelve
Una peluquería de mascotas tiene dos problemas que el software genérico de citas no cubre: la operación del día —quién baña a qué perro, en qué paso va, cuánto falta— y la relación con el dueño —reservar sin fricción, saber cómo va su mascota, tener su historial de salud—. Petgoroo cubre las dos caras con el mismo dato.
Panel de operación (staff)
- Calendario con drag-to-reschedule, vista día/semana y navegación infinita.
- Mesa de trabajo: cada visita avanza por pasos configurables por servicio (Baño → Secado → Corte), con reloj en vivo y fotos del proceso.
- Catálogo de servicios, precios y duraciones.
- Historia de Salud Electrónica: vacunas, pelaje, notas y evolución por mascota.
- Vista móvil del bañador (PWA instalable) con solo sus citas del día.
- Reportes, cobros, roles y permisos, invitaciones de equipo.
Portal del cliente (dueño)
- Reserva pública sin cuenta, en tres pasos, desde
petgoroo.com/{salón}/{sede}. - Cuenta con login por email OTP, mascotas, historial, cancelar y reprogramar.
- Seguimiento en vivo de la visita: el paso actual y las fotos que el bañador va tomando, mientras sucede.
- Historia de salud compartible por QR, portable entre salones.
Plataforma
- Suscripciones con Mercado Pago, planes dinámicos y límites aplicados en la base.
- Avisos automáticos por WhatsApp: recordatorio, “ya puedes recoger”, liberación de cupo.
- Panel interno de administración en su propio subdominio.
Stack
| Capa | Elección |
|---|---|
| Framework | Next.js 16 (App Router, React 19, Turbopack) |
| Runtime | Cloudflare Workers vía OpenNext — sin servidor Node, sin contenedores |
| Datos y auth | Supabase (Postgres, Auth, Storage, Realtime), RLS en todas las tablas |
| UI | Tailwind v4 (config inline) + shadcn/ui + Radix |
| Estado cliente | React Query + IndexedDB (caché persistida) |
| Integraciones | Mercado Pago, Resend, zavu (WhatsApp/SMS), Gemini, Turnstile |
| Lenguaje | TypeScript estricto |
Tamaño: ~416 archivos TS/TSX, ~61k líneas, 112 migraciones SQL, ~70 funciones Postgres, 31 tablas, 26 suites de tests.
Decisiones de arquitectura interesantes
1. Un solo Worker, tres productos, ruteo por host
Panel de staff, portal público y admin de plataforma son la misma aplicación; el middleware en el edge ramifica por cabecera Host:
petgoroo.com/{org}/{local} → portal público (B2C)
{org}.petgoroo.com/app/{local}/... → panel del salón (B2B)
admin.petgoroo.com → admin de plataforma
Un despliegue, un bundle, cero duplicación de dominio. El coste es un middleware con lógica de host explícita y bien testeada — host-context.ts tiene su propia suite.
El detalle no obvio: cuando el staff inicia sesión en el apex, las cookies de auth no viajan al subdominio del salón. Hay un puente de sesión cross-host: el apex resuelve a qué sede pertenece el usuario, redirige a /auth/session-bridge en el subdominio con los tokens, y ahí se llama setSession() antes de aterrizar en el calendario. Tres archivos, transparente para el usuario.
2. Las reglas de negocio viven en Postgres, no en TypeScript
Es la decisión de la que más aprendí. Roles efectivos, plan de la organización, límites por plan y overrides por cliente no se reimplementan en la app: son funciones SQL (effective_role(), org_plan_limits()) que son las mismas que aplican la RLS y los triggers.
Consecuencias:
- Es imposible que la UI y la base discrepen sobre “¿este usuario puede hacer esto?”.
- Los planes son datos —una tabla
plans—, no constantes en el código: cambiar precio o límite no requiere despliegue. - La bitácora de accesos (
policy_events) la escribe un trigger, no la app. Un registro de auditoría que se puede olvidar de escribir no sirve.
Los permisos se preguntan por capacidad, nunca comparando el rol: can(ctx, "reports.view"), no role === "BANADOR". Agregar un rol nuevo es editar un mapa, no cazar comparaciones por todo el código.
3. Latencia: el problema real era la distancia, no la query
La base está en us-west-2 y los usuarios en Lima. Cada viaje a Postgres cuesta ~190 ms de red y ~2 ms de cómputo. La resolución de tenant —¿existe la sede?, ¿este usuario tiene acceso?, ¿qué plan y qué límites?— eran tres consultas encadenadas: ~570 ms antes de pintar cualquier página del panel.
Se colapsaron en un solo RPC (get_local_context) que devuelve todo el contexto en un viaje. Regla del proyecto desde entonces: si necesitas un dato nuevo del tenant, lo agregas al RPC, no una consulta extra.
Segundo caso del mismo patrón: firmar las URLs de las fotos de mascotas era un viaje a Storage encadenado después de traer las citas (~250 ms), pagado en cada carga y en cada sondeo. Se cacheó 45 minutos —las firmas viven una hora—, pero la clave de caché incluye al usuario, a propósito: la RLS del bucket es más estricta que la de las citas, así que una caché por ruta dejaría ver fotos de mascotas cuyo acceso ya se revocó. La caché es opcional en la firma de la función: olvidarla da el comportamiento lento de antes, nunca uno incorrecto.
4. Caché persistida: el criterio, no la técnica
React Query + IndexedDB en el portal del cliente: reabrir la PWA pinta lo último visto al instante y revalida en segundo plano.
Lo valioso no fue implementarlo sino escribir cuándo NO aplicarlo (CACHE-PATTERN.md). La señal es el patrón de rebote —el usuario cierra y reabre la vista con datos que ya vio—, no “esta vista necesita datos frescos”, que es Realtime y es complementario, no sustituto.
Ejemplo concreto de la distinción: /mi-cuenta/seguimiento, ver la visita en curso, necesita datos siempre frescos y aun así no se migró — una visita dura horas y nunca la vuelves a ver igual; no hay nada viejo que valga la pena cachear. El panel de escritorio tampoco se migró. La regla no es “B2C sí, B2B no”: es “sesión larga de escritorio contra app que se abre y cierra en el bolsillo”, y por eso la vista del bañador —B2B, PWA móvil— sí califica.
5. Portal público sin exponer la RLS de staff
Las lecturas y escrituras anónimas del portal —disponibilidad, crear reserva— pasan por RPCs SECURITY DEFINER con superficie mínima: get_public_availability, create_public_booking. La RLS del panel de staff queda intacta y no hay una sola política “para anónimos” que relajar. Cliente logueado, RLS propia por user_id.
6. Realtime: el bug que no da error
Como el cliente del navegador corre con autoRefreshToken: false —las cookies rotan en el middleware—, el socket de Supabase Realtime no recibe el JWT solo. Hay que llamar realtime.setAuth(token) antes de suscribir; si no, la suscripción queda como anon y la RLS filtra todos los eventos en silencio: no hay error, simplemente nunca llega nada. Está documentado como gotcha del proyecto junto con el otro caso raro — los DELETE no llegan a canales filtrados, y los cubre un poll de respaldo de 90 s.
7. Middleware en el edge: quedarse en la API deprecada, a propósito
Next 16 empuja a migrar middleware.ts a proxy.ts. En este proyecto no se migra, y está documentado por qué: proxy.ts compila siempre a runtime Node, y el adaptador de Cloudflare hace process.exit(1) con middleware de Node — literal, verificado en el código del adaptador en dos versiones. El soporte real llegará por la nueva Adapters API, todavía abierta. El warning de deprecación es ruido esperado.
Es el tipo de decisión que se paga cara si no queda escrita: sin la nota, cualquiera —persona o agente— corre el codemod que Next ofrece y rompe el despliegue.
Lo que hace distinto al producto
- Seguimiento en vivo con fotos. El dueño abre el enlace y ve el paso actual de su mascota y las fotos del baño mientras ocurre. Es la misma máquina de estados que usa el bañador para trabajar, no un feed aparte que alguien tenga que alimentar.
- Pasos configurables por servicio. Un baño simple y un corte de raza no tienen los mismos pasos; el salón los define en el catálogo (
services.steps) y eso alimenta a la vez la mesa de trabajo, la vista del bañador y lo que ve el dueño. - Historia de Salud Electrónica portable. El expediente pertenece a la mascota, no al salón, y se comparte por QR con consentimiento explícito del dueño.
- Planes dinámicos con límites reales. El límite de sedes o de citas no es un texto en la landing: es un trigger en Postgres. Y admite overrides por cliente sin tocar código.
- WhatsApp operativo, no marketing. Recordatorio de cita, aviso de “ya puedes recoger”, liberación de cupo cuando alguien cancela — con quick replies que vuelven por webhook al estado de la cita.
Ingeniería del día a día
- Migraciones versionadas (112) con una trampa documentada: la herramienta que las aplica sella su propio timestamp, distinto al del archivo local; si no se renombra, el repo y la base divergen para siempre. Pasó dos veces antes de escribirlo.
- Tests donde duele: dinero (Mercado Pago, planes, cambios de plan), tiempo (slots de reserva, duraciones, tick de reloj), ruteo por host y redirecciones, webhooks. No hay tests de UI por cobertura; hay tests donde un error es caro.
- Documentación como parte del código.
CLAUDE.md,DESIGN.md,CACHE-PATTERN.md,AGENTS.md: el proyecto se desarrolló en buena parte con agentes de IA, y el archivo de contexto no es un README bonito — es el mecanismo que evita que la siguiente sesión repita un error ya resuelto. Cada gotcha de arriba está ahí con su fecha y su verificación.
En una línea
Un SaaS multi-tenant real —tres productos, un Worker en el edge, reglas de negocio en la base de datos y no en la app— donde las decisiones interesantes no son las librerías elegidas sino las restricciones: latencia transatlántica que obliga a colapsar consultas, RLS que obliga a pensar la caché por usuario, y un runtime sin Node que obliga a quedarse en la API que sí compila.