What is new here, in five lines
- Business rules live in Postgres, not in TypeScript. Roles, plans and limits are SQL functions — the very ones that enforce RLS — so the UI and the database cannot disagree about who may do what.
- A single Worker serves three products — the B2B panel, the B2C portal and the platform admin — branching on the
Hostheader at the edge. One bundle, one deployment, no duplicated domain logic. - Live tracking with photos: the owner watches their pet’s current step and the grooming photos as they happen, driven by the same state machine the groomer works from.
- Plans are data, not constants. Changing a plan’s price or limit needs no deployment, and the limits are enforced by a database trigger.
- No Node runtime: Next.js 16 on Cloudflare Workers via OpenNext, with the compatibility decisions written down where they hurt.
The problem it solves
A pet grooming salon has two problems generic appointment software does not cover: the day’s operation — who is washing which dog, what step it is on, how long is left — and the relationship with the owner — booking without friction, knowing how their pet is doing, keeping its health record. Petgoroo covers both faces from the same data.
Operations panel (staff)
- Calendar with drag-to-reschedule, day and week views, infinite navigation.
- Work table: each visit advances through per-service configurable steps (Wash → Dry → Cut), with a live clock and photos of the process.
- Catalogue of services, prices and durations.
- Electronic health record: vaccines, coat, notes and history per pet.
- Groomer’s mobile view (installable PWA) showing only their appointments for the day.
- Reports, payments, roles and permissions, team invitations.
Customer portal (owner)
- Public booking with no account, in three steps, from
petgoroo.com/{salon}/{location}. - Account with email OTP login, pets, history, cancel and reschedule.
- Live tracking of the visit: the current step and the photos the groomer takes, as it happens.
- Health record shareable by QR, portable between salons.
Platform
- Subscriptions through Mercado Pago, dynamic plans and limits enforced in the database.
- Automatic WhatsApp notices: reminder, “ready for pickup”, freed slot.
- Internal administration panel on its own subdomain.
Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, React 19, Turbopack) |
| Runtime | Cloudflare Workers via OpenNext — no Node server, no containers |
| Data and auth | Supabase (Postgres, Auth, Storage, Realtime), RLS on every table |
| UI | Tailwind v4 (inline config) + shadcn/ui + Radix |
| Client state | React Query + IndexedDB (persisted cache) |
| Integrations | Mercado Pago, Resend, zavu (WhatsApp/SMS), Gemini, Turnstile |
| Language | Strict TypeScript |
Size: ~416 TS/TSX files, ~61k lines, 112 SQL migrations, ~70 Postgres functions, 31 tables, 26 test suites.
The interesting architecture decisions
1. One Worker, three products, routing by host
The staff panel, the public portal and the platform admin are the same application; the edge middleware branches on the Host header:
petgoroo.com/{org}/{local} → public portal (B2C)
{org}.petgoroo.com/app/{local}/... → salon panel (B2B)
admin.petgoroo.com → platform admin
One deployment, one bundle, no duplicated domain logic. The cost is a middleware with explicit, well-tested host logic — host-context.ts has a suite of its own.
The non-obvious detail: when staff sign in at the apex, the auth cookies do not travel to the salon’s subdomain. There is a cross-host session bridge: the apex resolves which location the user belongs to, redirects to /auth/session-bridge on the subdomain with the tokens, and setSession() is called there before landing on the calendar. Three files, invisible to the user.
2. Business rules live in Postgres, not in TypeScript
This is the decision I learned the most from. Effective roles, the organisation’s plan, per-plan limits and per-customer overrides are not reimplemented in the app: they are SQL functions (effective_role(), org_plan_limits()) — the very same ones RLS and the triggers use.
Consequences:
- The UI and the database cannot disagree on “can this user do this?”.
- Plans are data — a
planstable — not constants in code: changing a price or a limit needs no deployment. - The access log (
policy_events) is written by a trigger, not by the app. An audit trail you can forget to write is worthless.
Permissions are asked by capability, never by comparing the role: can(ctx, "reports.view"), not role === "GROOMER". Adding a new role means editing one map, not hunting comparisons across the codebase.
3. Latency: the real problem was distance, not the query
The database is in us-west-2, the users are in Lima. Every trip to Postgres costs ~190 ms of network and ~2 ms of compute. Tenant resolution — does this location exist, does this user have access, which plan and which limits — was three chained queries: ~570 ms before any panel page could paint.
They were collapsed into a single RPC (get_local_context) that returns the whole context in one trip. Project rule ever since: if you need a new piece of tenant data, add it to the RPC, not another query.
A second case of the same pattern: signing the pet photo URLs was a Storage round trip chained after fetching the appointments (~250 ms), paid on every load and every poll. It is now cached for 45 minutes — signatures live an hour — but the cache key includes the user, deliberately: the bucket’s RLS is stricter than the appointments’, so a per-route cache would leak photos of pets whose access had already been revoked. The cache is optional in the function’s signature: forgetting it gives you the old slow behaviour, never an incorrect one.
4. Persisted cache: the criterion, not the technique
React Query + IndexedDB in the customer portal: reopening the PWA paints the last thing you saw instantly and revalidates in the background.
The valuable part was not implementing it but writing down when NOT to (CACHE-PATTERN.md). The signal is the bounce pattern — the user closes and reopens a view holding data they have already seen — not “this view needs fresh data”, which is Realtime’s job and complements rather than replaces it.
A concrete example of the distinction: /mi-cuenta/seguimiento, watching the visit in progress, always needs fresh data and still was not migrated — a visit lasts hours and you never see it the same way twice; there is nothing stale worth caching. The desktop panel was not migrated either. The rule is not “B2C yes, B2B no”: it is “long desktop session versus an app opened and closed in your pocket”, which is why the groomer’s view — B2B, mobile PWA — does qualify.
5. A public portal that does not expose the staff RLS
The portal’s anonymous reads and writes — availability, creating a booking — go through SECURITY DEFINER RPCs with a minimal surface: get_public_availability, create_public_booking. The staff panel’s RLS is left intact and there is not a single “for anonymous” policy to loosen. A logged-in customer gets their own RLS by user_id.
6. Realtime: the bug that raises no error
Because the browser client runs with autoRefreshToken: false — the cookies rotate in the middleware — the Supabase Realtime socket does not get the JWT on its own. You have to call realtime.setAuth(token) before subscribing; otherwise the subscription stays anon and RLS filters out every event silently: no error, nothing ever arrives. It is documented as a project gotcha alongside the other odd case — DELETEs do not reach filtered channels, covered by a 90 s backup poll.
7. Edge middleware: staying on the deprecated API, deliberately
Next 16 pushes you to migrate middleware.ts to proxy.ts. This project does not migrate, and why is written down: proxy.ts always compiles to the Node runtime, and the Cloudflare adapter calls process.exit(1) on Node middleware — literally, verified in the adapter’s source across two versions. Real support will arrive through the new Adapters API, still open. The deprecation warning is expected noise.
It is the kind of decision that costs you dearly if it is not written down: without the note, anyone — person or agent — runs the codemod Next offers and breaks the deployment.
What makes the product different
- Live tracking with photos. The owner opens the link and sees their pet’s current step and the grooming photos as it happens. It is the same state machine the groomer works from, not a separate feed someone has to keep fed.
- Per-service configurable steps. A plain wash and a breed cut do not share the same steps; the salon defines them in the catalogue (
services.steps) and that feeds the work table, the groomer’s view and what the owner sees, all at once. - A portable electronic health record. The record belongs to the pet, not the salon, and is shared by QR with the owner’s explicit consent.
- Dynamic plans with real limits. The cap on locations or appointments is not marketing copy: it is a Postgres trigger. And it takes per-customer overrides without touching code.
- Operational WhatsApp, not marketing. Appointment reminder, “ready for pickup”, freed slot when someone cancels — with quick replies that come back through a webhook into the appointment’s state.
Day-to-day engineering
- Versioned migrations (112) with one documented trap: the tool that applies them stamps its own timestamp, different from the local file’s; if you do not rename, the repo and the database diverge forever. It happened twice before it got written down.
- Tests where it hurts: money (Mercado Pago, plans, plan changes), time (booking slots, durations, the clock tick), host routing and redirects, webhooks. There are no UI tests for coverage’s sake; there are tests where a mistake is expensive.
- Documentation as part of the code.
CLAUDE.md,DESIGN.md,CACHE-PATTERN.md,AGENTS.md: the project was built largely with AI agents, and the context file is not a pretty README — it is the mechanism that stops the next session from repeating an already-solved mistake. Every gotcha above is in there with its date and its verification.
In one line
A real multi-tenant SaaS — three products, one edge Worker, business rules in the database rather than the app — where the interesting decisions are not the libraries but the constraints: transatlantic latency that forces you to collapse queries, RLS that forces you to think about caching per user, and a runtime without Node that forces you to stay on the API that actually compiles.