Courses
On-demand digital courses with day-by-day curriculum, a public storefront, an entitlement-based player, and a Clerk email-code checkout flow.
What & why
A course is a programs row with delivery_mode='course' plus a 1:1 course_configs row carrying the course-shaped metadata (duration, preview policy, publish status). The curriculum is a denormalised list of course_workouts (day-by-day, multiple workouts per day, explicit rest days). Buyers get a course_entitlements row keyed on user_id (not membership) so a course follows the human across orgs and survives churn.
Persona impact:
| Persona | Capability |
|---|---|
| Trainer (owner/admin/coach) | Create / publish / archive courses, edit curriculum, set price, view per-buyer progress. |
| Buyer (any Clerk user) | Browse public storefront, complete checkout via Clerk email-code, access library cross-org, follow day-by-day curriculum, restart. |
| Org owner | Tier-gated behind courses feature (Pro+). Sees course buyers in the leads list for category-B sales. |
Capabilities
- Day-by-day curriculum with rest days.
course_workouts(program_id, day_offset, sort_order)— multiple workouts per day, ordered. - Preview policies:
none | first_day | first_week | specific_workout. Public storefront surfaces the preview. - Publish lifecycle:
draft → published → archived. Archived courses retain access for existing buyers. - Entitlements: 1:1 with
(user_id, program_id). Statuspending → active | revoked. Free courses jump straight toactive; paid courses are activated by the payment webhook. - Two buyer categories:
- A — buyer is already a member of the seller org.
course_entitlements.membership_idset. - B — buyer is not a member. A lead row is created with
source='course_purchase'.membership_idstays null.
- A — buyer is already a member of the seller org.
- Player UX:
startDateset when the buyer hits “Start”. Daynderived fromfloor((today - startDate) / 1d). Restart rewritesstartDateand clears completions. - Idempotent completions:
course_workout_completionsunique on(entitlement_id, course_workout_id). Mark =INSERT … ON CONFLICT DO NOTHING; unmark =DELETE. Course-level “completed” is derived (every non-rest curriculum row has a completion). - Trainer drill-down: per-buyer progress (
listEnrollments), per-day completion stats (loadDayCompletionStats). - Buyer library is org-agnostic:
GET /me/coursesandGET /me/courses/:idignore the active organization.
Platform fee (status)
0% today. FitKit takes no cut of course purchases — the gym keeps 100%. The course plans row has the same shape as a subscription plan, payment flows through the same gym-owned provider config, and money lands in the gym’s account.
FIT-147 / FIT-148 is the open commercial work to introduce a percentage cut on courses. When implemented, it will likely:
- Re-route the course checkout through the FitKit platform terminal (not the gym’s provider), or
- Add a
platform_fee_in_centscolumn onpayment_transactionsand split out a payout to the gym, or - Apply at the invoicing layer rather than the gateway.
Until then, course pricing should never be quoted to the gym with a fee assumption.
Tier gate
@RequiresFeature('courses') on every trainer-side CRUD route (apps/api/src/courses/courses.controller.ts:46, :198, :212, :226, :240). The courses feature is Pro+ (see platform-billing/README.md). Lite gyms cannot author or publish courses, but they can be buyers of someone else’s course (the buyer side is unrestricted — me/courses/* and the public storefront are not tier-gated).
Capabilities — concrete
| Capability | Where |
|---|---|
| Create course | POST /organizations/:orgId/courses |
| List org courses | GET /organizations/:orgId/courses |
| Set day curriculum | POST /organizations/:orgId/courses/:id/days |
| Set price | PATCH /organizations/:orgId/courses/:id/price |
| Publish | POST /organizations/:orgId/courses/:id/publish (requires at least one curriculum entry) |
| Archive | DELETE /organizations/:orgId/courses/:id (existing buyers retain access) |
| Public detail | GET /public/courses/:id |
| Checkout | POST /public/courses/:id/checkout (@Public — Clerk session enforced in handler) |
| My library | GET /me/courses |
| My entitlement | GET /me/courses/:id |
| Start course | POST /me/courses/:id/start { startDate? } |
| Restart | POST /me/courses/:id/restart |
| Read workout | GET /me/courses/:id/workouts/:workoutId |
| Mark complete | POST /me/courses/:id/workouts/:workoutId/complete |
| Unmark | DELETE /me/courses/:id/workouts/:workoutId/complete |
| Per-buyer progress | GET /organizations/:orgId/courses/:id/enrollments |
Related features
payments/— checkout usesPaymentService.createCourseHostedPayment; webhook activates the entitlement.subscriptions-plans/— course-type plans share theplanstable withplan.type='course'andplan.program_idset.programs/(scheduling/) — the underlyingprogramsrow.workouts/— curriculum workouts come from the org’s workout library.leads-crm/— category-B buyers become leads (source='course_purchase').minisites/— public storefront is rendered from the minisite domain.
Status
Production. Email-code Clerk checkout in production. Free + paid courses both supported. Course revenue share is the open commercial question.
Gaps
- FIT-147 / FIT-148 — platform fee on courses (currently 0%).
- No mid-course refund flow specifically modelled. Refunds go through
payments/; revoking the entitlement is handled inwebhook-processing.service.ts:353only for failed payments. A successful charge refunded later does NOT auto-revoke the entitlement (the gym must manually do so, no UI exists). - Cross-org entitlement display — the player works, but the dashboard shell (“active org”) doesn’t know which org the buyer should see “as” for a course. UX hack: header reads
entitlement.organization.namedirectly. - No course-level “lifetime access” alternatives.
accessPolicycolumn exists with'lifetime'only (courses.ts:31); future policies (duration_x_2,fixed_window) are not implemented. - Buyer with no email cannot purchase.
attachLeadForBuyerrequiresuser.email(courses.service.ts:942). Clerk session always has one in practice. - Republish doesn’t notify previous buyers. Archive → re-publish is silent.