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.

Role: Founder & Full Stack Developer
Duration: 2025 - Present
Dask hero image

Screens

Public site of a dance academy on its own Dask subdomain

Tech Stack

React 18 TypeScript Vite Zustand TanStack Query Supabase PostgreSQL Cloudflare R2 Resend Astro Tailwind CSS

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

5
Academies onboarded

Each one on its own branded subdomain with its own public site

~10 h/week
Admin work saved

By automating charges, receipt validation and reminders

204
SQL security checks

177 access checks and 27 privilege-escalation attacks in the gate suite

Key Decisions

1

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.

2

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.

3

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.

4

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.

5

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.

6

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

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.

View all case studies