Dask
Dance academy management with a public site per academy
The platform where a dance academy manages enrolment, billing and student communication, and gets its own public site on its own subdomain. Multi-tenant on Supabase, with billing automated by pg_cron and access rules verified by 200+ SQL checks.
Screens
Tech Stack
Overview
Dance academies are businesses made of people: groups that change, students who come and go, monthly fees that need chasing. Almost all of them run on spreadsheets and chats, with payment receipts lost in WhatsApp, and the cost is silent: admin hours, payments that slip through, and awkward collection talks that wear down the relationship with the student. I dance salsa and bachata, so I knew this world from the inside. Dask started with the money, where it hurts most, and then grew to give every academy its own public site, with its own colours, on its own subdomain.
⚡ The Challenge
Money in a small academy is messy: cash, bank transfers and SINPE receipts sent as photos, partial payments, two-for-one promos. Every academy is a tenant that must never see another's data, and the same person can be a teacher in one academy and an admin in another.
✓ The Solution
Billing separates three things that are usually mixed: a charge is a debt, a payment report is a claim with a photo, and a transaction is actual money. Monthly charges are generated by a pg_cron job in pure SQL, idempotent per student and due date, with the billing day clamped for short months. A student uploads a receipt, compressed in the browser and stored in Cloudflare R2 through an Edge Function; an admin approves it, and a database trigger settles the charge once the paid transactions reach the amount. The order of the daily jobs is deliberate: generate at midnight, mark overdue at 06:00, send reminders at 08:00 Costa Rica time.
Impact & Results
Each one on its own branded subdomain with its own public site
By automating charges, receipt validation and reminders
177 access checks and 27 privilege-escalation attacks in the gate suite
Key Decisions
Charges, payment reports and transactions are three tables
A debt, a claim and money have different lifecycles. Keeping them apart is what makes partial payments, rejected receipts and advance payments possible without editing history.
Tenant id denormalised for fast RLS
academy_id is copied onto the six transactional tables and kept correct by a trigger, so policies filter on the row itself instead of joining to profiles.
RLS helpers return sets, not scalars
Once one person could hold several profiles, a helper that did LIMIT 1 on a column that was no longer unique started returning an arbitrary row, silently, in 47 of 76 production policies. I audited production read-only first, then rewrote all 76; dropping the old helpers at the end made the migration fail if any policy still used them.
Security gates written as attacks
There was no test suite, so I wrote SQL gates that run inside a transaction and roll back: checks for who can see or touch what, accounting invariants that must count zero violations, and 27 privilege-escalation attacks. They exist because, before adding fine-grained teacher permissions, I asked whether a student could simply become a teacher, and found two ways.
A cron that is not in a migration does not exist
Staging and production had drifted to completely different sets of scheduled jobs. All seven jobs now live in migrations and call Edge Functions through a single bridge function that reads its settings from the database.
One resolver for the subdomain
The app, its PWA manifest and the public Astro site all resolve the academy from the hostname with the same reserved-name list, which the database enforces too, so nobody can register admin.getdask.com. The public site resolves the tenant once in middleware, from a single view.
Technical Highlights
- ▸ React 18, TypeScript, Vite, react-router, Tailwind v4 and Radix; Zustand limited to four stores (auth, academy, errors, optimistic updates) by a written rule; 28 feature folders
- ▸ Supabase with 54 tables under row-level security, 223 policies, and SECURITY DEFINER helpers that filter soft-deleted and transferred profiles
- ▸ 14 Deno Edge Functions: receipt and flyer uploads to Cloudflare R2, transactional email through Resend, self-hosted Web Push with VAPID, a three-touch payment reminder
- ▸ 7 pg_cron jobs defined in migrations: monthly charges, overdue marking, reminders, session generation, and membership expiry and activation in a guaranteed order
- ▸ Integration tests with Vitest against a real Supabase, including a 42-case abuse suite, next to the SQL gates
- ▸ Public academy sites on Astro 5 with a CMS editor inside the app and a live preview; semantic-release with conventional commits
- ▸ Churn radar with three signals: emptying time slots, debtors, and students with consecutive absences
Key Learnings
An audit found that public signup accepted a role sent by the client. Never trust an identifier or a role that comes from the client; resolve it on the server.
An RPC that received the academy id as a parameter was a cross-tenant leak waiting to happen. It now resolves the academy from the session.
The app's service worker precache was hijacking the root path on tenant subdomains and serving the app shell instead of the academy's public site. Two products on one domain need an explicit contract about who owns which URL.
Squashing the schema to match production and deploying migrations from CI were the same decision: stop editing production by hand.
Documentation that admits where the product is wrong (orphan routes, unreachable states, known flow bugs) is more useful than documentation that only describes the happy path.