Multi-Tenant Model
Org Boundaries
Data is scoped to organizations. Every drill, event, roster, and score belongs to an org (or to a user’s personal context, which behaves like a single-user org).
- Users can belong to multiple orgs — a shooter may train with a church security team, take a class from a private instructor, and track personal drills solo.
- Within an org, admins and instructors see only that org’s data.
- A shooter sees their own scores across all orgs in their personal profile — it’s their performance history regardless of who ran the event.
Drill Visibility
Drills have a visibility level that controls who can see and use them:
enum DrillVisibility {
Private,
Public,
Published,
}
| Level | Who can see | What they see | Can run it |
|---|---|---|---|
| Private | Owner + rostered shooters | Shooters see high-level drill info and their own scores only. Full definition visible to org members. | Only within the owning org’s events |
| Public | Anyone | Browsable in the org’s drill catalog (name, description, type — high-level info only). Full definition stays within the org. | No |
| Published | Anyone | Same as Public, plus a “Copy & Run” option. | Yes — creates a private copy under the shooter’s account |
Copying Published Drills
When a shooter copies a Published drill:
- A new drill is created under their account with
Privatevisibility (they own it) - The copy includes an optional
source_drill_idreferencing the original - The copy is fully independent — no structural dependency on the original org’s data
- The UI can show provenance (“Based on T.REX’s Bill Drill”) when the source is still Public or Published
- If the source drill is set to Private or deleted, the UI shows “Original drill no longer available” — nothing breaks
Visibility Changes After Scores Exist
An org can change a drill’s visibility at any time:
- Published → Private: Existing copies that shooters already made remain intact (they own those). New users can no longer find or copy the drill.
- Public → Private: The drill disappears from the public catalog. Rostered shooters still see their scores.
- Any direction is safe because scores embed drill metadata and copies are independent.
Score Portability
Scores embed drill metadata at creation time:
Score {
drill_id // internal reference for in-context navigation
drill_name // snapshot — "Bill Drill"
drill_type // snapshot — "Timed"
...score fields...
}
This means:
- A shooter can always show their score card (“Bill Drill — Timed — 2.3s — 95%”) regardless of drill visibility
- The
drill_idenables navigation to the full drill definition — but access is gated by the drill’s visibility level - If the drill is deleted or set to Private, the score still displays correctly from its embedded metadata
Tenant Isolation
SaaS (Multi-Tenant)
All orgs share one Postgres database with row-level isolation:
- Every row carries an
org_id - All queries are filtered by org context
- A user’s cross-org personal view queries by
user_idacross org boundaries (read-only on their own scores)
White-Label / On-Prem (Single-Tenant)
Separate deployment — own database, own server. The org_id column still exists in the schema for consistency, but there is only one org.
FK Naming Pattern
Optional foreign keys that reference a source/origin entity follow the pattern source_<entity>_id:
source_drill_id— the drill this was copied fromsource_course_id— if courses become copyablesource_event_id— if events are templated
Always optional. Always means “where this was derived from.”