Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesCoursesCourses — Data Model

Courses — Data Model

Schema: libs/db/src/lib/schema/courses.ts. Parent rows in scheduling.ts (programs) and payments.ts (plans).

course_configs (courses.ts:24)

1:1 with programs where delivery_mode='course'. Primary key is program_id (foreign-key to programs.id with ON DELETE CASCADE).

ColumnTypeNotes
program_iduuid PK / FKCascade delete from programs.
duration_daysintegerCourse length.
access_policyvarchar(50)'lifetime' only today. Kept varchar so future policies (duration_x_2, fixed_window) ship without an enum migration.
preview_typecourse_preview_type enum`none
preview_workout_iduuid FK → workouts.idRequired when preview_type='specific_workout' (service-level check).
statuspublish_status enum`draft
published_at, archived_attimestamptz

course_workouts (courses.ts:61)

Day-by-day curriculum. Multiple rows per (program_id, day_offset) ordered by sort_order. Rest days have workout_id=null and is_rest=true.

ColumnTypeNotes
iduuid PK
program_iduuid FKCascade.
day_offsetinteger0-indexed. Service enforces < durationDays.
sort_orderintegerWithin a day.
workout_iduuid FK → workouts.idNull for rest days.
is_restboolDefault false.

Constraints:

  • UNIQUE (program_id, day_offset, sort_order) — no duplicate positions.
  • INDEX course_workouts_program_day_idx (program_id, day_offset).

setDay replaces a full day (delete all rows for (programId, dayOffset) then insert the new list).

course_entitlements (courses.ts:111)

Buyer ↔ course ownership. Decoupled from membershipuser_id is the anchor.

ColumnTypeNotes
iduuid PK
user_iduuid FK → users.id (not null)The human. Cross-org-safe.
membership_iduuid FK → memberships.idNull when buyer isn’t a member of the seller’s org (category B).
program_iduuid FK → programs.id
statuscourse_entitlement_status enum`pending
start_datedate (string mode)Null until buyer hits “Start”.
completed_attimestamptzRe-derived after every mark/unmark.
restart_countintegerDefault 0. Incremented by restart.
source_payment_transaction_iduuid FK → payment_transactions.idBound by the webhook on activation.
access_revoked_attimestamptzStamped when entitlement is revoked.

Constraints / indexes:

  • UNIQUE course_entitlements_user_program_unique (user_id, program_id) — DB-level block on duplicate purchase.
  • INDEX course_entitlements_user_idx (user_id) — library reads.
  • INDEX course_entitlements_membership_idx (membership_id) — trainer-side reports.
  • INDEX course_entitlements_program_idx (program_id) — enrollments list.

course_workout_completions (courses.ts:183)

Buyer’s “I did this” log. Workout-level (one row per workout completed).

ColumnTypeNotes
iduuid PK
entitlement_iduuid FKCascade.
course_workout_iduuid FKCascade.
completed_attimestamptzDefault now.
notestextOptional buyer note.

Constraints:

  • UNIQUE course_workout_completions_entitlement_workout_unique (entitlement_id, course_workout_id) — makes mark-complete trivially idempotent.

Relationships

programs (delivery_mode='course') 1───1 course_configs (PK = program_id) programs 1───* course_workouts programs 0───1 plans (type='course', program_id FK) programs 1───* course_entitlements users 1───* course_entitlements (the anchor) memberships 0───* course_entitlements (nullable membership_id) payment_txns 0───1 course_entitlements (source_payment_transaction_id) course_entitlements 1───* course_workout_completions course_workouts 1───* course_workout_completions

Multi-org isolation

  • The seller-org is reached via programs.organization_id. Every trainer-side route requires requireMembership(orgId, clerkId) and isStaffRole (courses.service.ts:64 etc.).
  • Buyer routes (/me/courses/*) are org-agnostic by design — content follows the user. They authorise via entitlement ownership (requireEntitlement(userId, programId)).
  • The payment webhook joins course_entitlements → programs and rejects any URL-orgId that doesn’t match programs.organization_id (webhook-processing.service.ts:215). This is the only place where cross-org confusion could be exploited.

Money columns

  • plans.price_in_cents — cents. Inherits currency from the org’s currency column at insert/update time (courses.service.ts:382).
  • payment_transactions.amount_in_cents — cents.

No money columns live in the courses schema directly.

Plan ↔ course binding

  • One plans row per course, by convention. plans.program_id is the FK; plans.type='course'.
  • courses.service.setPrice (courses.service.ts:339) upserts on (program_id, type='course').
  • A course with no plan, or with plan.is_active=false, returns 404 on the public storefront (courses.service.ts:773).

Lifecycle vs visibility

Course statusPublic storefrontExisting buyersNew purchases
draft404n/a (no buyers)Blocked (POST /checkout checks status='published')
published200AccessAllowed
archived404Access retainedBlocked
cancelledn/a (not used for courses)n/an/a