An internal tool that keeps EIN records, licenses, and insurance policies (and the documents behind them) for 20 business entities in one place. It flags anything about to expire, tracks monthly cash sheets, and lets each staff member see only the businesses they're responsible for.
Live app · Case study · v1 requirements
How this was made: I defined what the business needed, wrote the v1 requirements, and tested the tool against how staff actually work. Claude, an AI assistant, wrote and ran the code. Coding isn't my specialty; my background is business operations and analytics.
- One dashboard for every business a person can access, with alerts for licenses and insurance expiring within 30 and 60 days
- Documents on file: photos and PDFs of EIN letters, licenses, and insurance certificates, uploaded straight from a phone camera
- Monthly cash sheets for each business, with an automatic email listing any that are missing
- The right people see the right businesses: admins see everything; everyone else only sees the businesses they've been granted
- A full history of every change, recorded automatically
- Free to run on free hosting tiers
The rest of this README is the technical reference: features in detail, the access model, and how to set up and run the app.
- Login — Supabase Auth (email/password), with self-service password
reset (
/forgot-password→ emailed link →/reset-password) and an admin-triggered "Send reset link" per user in the Admin panel. - Dashboard — entities you have access to, plus an alert list of licenses/insurance policies expiring within 60 days (color-coded at the 30-day mark).
- Entity detail — EIN records, licenses, and insurance policies, each with a document you can view (private storage, served via short-lived signed URLs) or upload.
- Document upload — the file input uses
capture="environment", so on a phone it offers the camera directly, not just the file picker. - Admin panel — create entities, create staff logins, set roles, grant/revoke per-entity access, delete a staff login, and delete an entity (type-to-confirm, removes its records and documents too).
- Audit log (
/admin/audit-log) — every create/edit/delete on entities, EIN records, licenses, and insurance policies, logged automatically by a database trigger rather than app code remembering to call something. Shows who, what, which entity, and when, newest first. - Monthly cash sheets — a section on each entity's detail page for uploading a monthly cash sheet (Excel, CSV, PDF, or photo), one per entity/month. Editors (on entities they have access to) and admins can upload; only admins can delete. Admins get two dashboard alert sections — entities missing last month's cash sheet, and cash sheets uploaded in the last 14 days — plus an email for each (upload confirmations immediately, the missing-sheets digest via a monthly Vercel Cron job). See "Enable cash sheet email notifications" below to configure the emails.
Three roles in profiles.role:
| Role | Sees | Writes |
|---|---|---|
admin |
every entity | everything — entities, records, documents, staff access grants, roles |
editor |
only entities granted via user_entity_access |
EIN records, licenses, insurance policies, and their documents — but only on entities they're granted, and never entities themselves or access grants |
viewer |
only entities granted via user_entity_access |
nothing |
A couple of things worth being explicit about:
- Entity creation and access grants are admin-only, deliberately. Who can see which business, and whether a business exists at all, are structural decisions — an editor fixing a typo on a license shouldn't also be able to grant themselves access to a different entity.
- RLS is the actual enforcement, not the UI. The UI hides controls a
role can't use, but every write is re-checked at the database via the
*_writepolicies inschema.sqlandstorage.sql— an editor's request for an entity they're not granted is rejected there regardless of what the UI shows. - The audit log can't be written to directly, by anyone.
audit_loghas a select policy but no insert/update/delete policy at all — the only way a row gets created is the trigger function inaudit_log.sql, which runs as a security-definer function and so bypasses RLS. Reading it follows the same scoping as everything else: admins see every entry, everyone else only entries for entities they're granted. - If you applied an older copy of this schema before editor write access
existed, run
supabase/editor_permissions.sqlonce to patch it in — see the setup steps below.
Stack: Next.js (App Router) + Supabase (Postgres, Auth, Storage), free tier.
-
Go to supabase.com, create a free project.
-
In the SQL Editor, run these files from
supabase/in order:schema.sql— tables, RLS policiesstorage.sql— the privatedocumentsstorage bucket and its RLS policiestriggers.sql— auto-creates aprofilesrow (defaulting toviewer) whenever someone signs upaudit_log.sql— theaudit_logtable and the triggers that log every create/edit/delete on entities, EIN records, licenses, and insurance policies automaticallyeditor_permissions.sql— only needed if you already applied an older copy ofschema.sql/storage.sqlbefore the editor-role write policies existed. On a fresh project this is redundant (schema.sql/storage.sql already include it) — skip it.free_text_business_type.sql— same deal: only needed if you already applied an olderschema.sqlwherebusiness_typewas still restricted to a fixed list. Fresh project: skip it.monthly_cash_sheets.sql— themonthly_cash_sheetstable, its RLS policies, and the audit trigger for it. Depends onhas_entity_access()fromschema.sql— if this fails withfunction has_entity_access(uuid) does not exist, run #8 first, then retry this one.rls_helper_functions.sql— only needed ifis_admin(),is_editor(), orhas_entity_access()are missing (schema.sql already defines them on a fresh project — skip it unless you hit a "function ... does not exist" error). Safe to run any number of times.
Every file above registers itself in a
_migrationstable once it runs, so you never have to guess what's already been applied to a given database — check with:select * from _migrations order by applied_at;
Missing a row you expect? Run that file. A file not in this list (or an ad-hoc fix given to you outside these files) won't show up here — this only tracks the files in
supabase/. -
In Project Settings → API Keys, copy the Project URL, the Publishable key, and the Secret key — the "Publishable and secret API keys" tab, not "Legacy" (legacy anon/service_role JWT keys are disabled on this project).
cp .env.local.example .env.localFill in the three values from step 1.3. SUPABASE_SECRET_KEY is
server-only (used by the Admin panel to create staff logins) — never
expose it to the browser.
npm install
npm run devOpen http://localhost:3000. You'll be redirected
to /login.
There's no public sign-up page (staff accounts are provisioned by an admin) — so the very first admin has to be created directly in Supabase:
- In the Supabase dashboard, go to Authentication → Users → Add user, and
create yourself an account (email + password). This fires the trigger
from
triggers.sql, giving you aprofilesrow withrole = 'viewer'. - In the SQL Editor, promote yourself:
update public.profiles set role = 'admin' where id = (select id from auth.users where email = 'you@example.com');
- Sign in at
/login. You'll see the Admin tab — from there you can add entities, create logins for the rest of the staff, and grant them access to specific entities.
"Forgot your password?" on the login page, and the "Send reset link"
button next to each user in the Admin panel, both call Supabase's
resetPasswordForEmail — but Supabase will silently ignore the redirect
target unless it's on an allowlist:
- Supabase dashboard → Authentication → URL Configuration → Redirect
URLs → add
https://your-deployed-domain.com/reset-password(andhttp://localhost:3000/reset-passwordtoo, if you want this to work locally). - That's it — no code change needed. The app builds the redirect URL
itself at request time from the domain it's actually running on
(
lib/site-url.ts), so this is the only manual step.
Without this, clicking the emailed link redirects to Supabase's default
Site URL instead of the app's /reset-password page, and the reset
silently fails to reach the right place.
The app sends its own emails for two things — a confirmation whenever a
cash sheet is uploaded, and a monthly digest of entities missing one —
using plain SMTP via nodemailer, separate from Supabase Auth's own
SMTP settings above (those only cover Supabase's own emails).
- Add
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD, andEMAIL_FROMto.env.local(and to Vercel's project settings for production). The simplest option is to reuse the same Gmail account + App Password you already set up in step 5 — see Google's guide if you haven't generated an App Password yet. Any other SMTP provider works too. - Add
CRON_SECRET— any random string (openssl rand -hex 32) — to both.env.localand Vercel's project settings. Vercel Cron automatically sends it as a Bearer token when it calls the route below, which is what stops anyone else from triggering it. vercel.jsonalready schedules/api/cron/missing-cash-sheetsto run at 9am UTC on the 1st of each month (0 9 1 * *) — edit that cron expression if you want a different day/time. Vercel Cron only runs on a deployed project, notnext dev.
If these env vars are missing, cash sheet uploads and deletes still work fine — only the emails are skipped (a failed/misconfigured send is logged server-side but never blocks the upload itself).
Push to GitHub and import the repo on Vercel (free
tier). Add the same environment variables from .env.local in the
Vercel project settings, then deploy. vercel.json registers the
monthly cash-sheets cron job automatically — no extra Vercel
configuration needed beyond setting CRON_SECRET.
Every push and pull request runs type-checking, linting, and a full
production build (.github/workflows/ci.yml) — so a build that's
actually broken shows up as a red X on GitHub before it ever reaches
Vercel, instead of being discovered live. It doesn't touch Supabase or
run any tests against real data; it's a build-health check, not a
correctness check.
app/ Next.js App Router pages
login/ Public login page
(app)/ Everything behind auth (layout enforces it)
dashboard/ Entity list + expiration/cash-sheet alerts
entities/[id]/ Entity detail: records + document upload
admin/ Entity/user management
api/cron/ Vercel Cron routes (missing cash sheets digest)
lib/
supabase/ Browser/server Supabase clients + middleware
actions/ Server Actions (mutations)
database.types.ts Hand-written types matching schema.sql
email.ts nodemailer SMTP helper for app-sent notifications
notifications.ts Admin email notifications (cash sheets)
components/ UI components
supabase/ SQL to run in the Supabase SQL editor
vercel.json Vercel Cron schedule
proxy.ts Auth-aware middleware (session refresh + route guard)