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,
}
LevelWho can seeWhat they seeCan run it
PrivateOwner + rostered shootersShooters see high-level drill info and their own scores only. Full definition visible to org members.Only within the owning org’s events
PublicAnyoneBrowsable in the org’s drill catalog (name, description, type — high-level info only). Full definition stays within the org.No
PublishedAnyoneSame 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 Private visibility (they own it)
  • The copy includes an optional source_drill_id referencing 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_id enables 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_id across 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 from
  • source_course_id — if courses become copyable
  • source_event_id — if events are templated

Always optional. Always means “where this was derived from.”