One login, three views: workers, trainers, and students share an app
At our test barn, our most common “edge case” turned out to be the most common case. The barn manager is also a trainer. A worker who mucks stalls also takes weekly lessons. The trainer's daughter rides in lesson programs and works on weekends. We had a roster of people whose job titles literally didn't fit in one column.
Our first version forced a choice — pick a primary role at signup, see the dashboard for that role, get told to ask the admin if you needed something else. It was wrong. Here's how we re-modeled it.
Primary role + extra roles
Each person has one OrgMember record per barn they belong to. That record has a primary role — typically owner, manager, employee, or worker — and an array of extra roles stored as JSON. The extras we use today are trainer and student, though there's nothing stopping us from adding more (vet, contractor, parent) later.
The primary role decides which dashboard you land on by default. The extras decide which additional sidebar links and routes you see. So a worker with the student extra role sees the worker dashboard and a “🏇 My Lessons” link in their sidebar — clicking it routes to the student portal at /lessons, which checks the same extras when deciding whether to render.
Authorization is one source of truth
Every protected route asks the same question: does this user's primary role match, OR does their extras include the right tag? The trainer dashboard at /trainer allows admins (primary role owner or manager) or users with trainer in extras. The student portal allows admins or users with student in extras. The lesson billing API uses the same check.
The reason it works cleanly is that authorization sits on the JWT session — extras are populated at login and travel with the request. We don't hit the database on every request to check “is this person a trainer?” — the answer is already in the token.
The migration that wasn't
When we built the lesson system, every WooCommerce student we imported needed the student role added. That was one line in the import script — we updated the OrgMember row's extras JSON with JSON.stringify([...current, "student"]) if it wasn't already present. Twenty-three rows updated, no schema change needed.
Same thing when we promote a worker to also handle lessons. We grant the trainer extra and that's it — they immediately see the trainer routes in the sidebar. No new account, no password reset, no “contact your admin”.
Why this matters
Modeling reality matters. People at a barn wear multiple hats — that's how horse operations have always worked. Software that pretends otherwise either forces people into duplicate accounts they forget, or limits the platform to a narrow slice of the operation. The role-overlay model means the platform can grow with someone's responsibilities instead of fighting them.
It's also fast to add. Want to support a vet who logs into the same app to record health events? Add a vet extra role and a route that checks for it. The auth, sidebar, and session plumbing are already there.