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

Courses — Behavior

Lifecycle — course_configs.status (publish_status enum)

FromToTrigger
newdraftCoursesService.create (courses.service.ts:59). Inserts programs row + course_configs row in one transaction.
draftpublishedPOST /:id/publish — requires ≥ 1 course_workouts row (courses.service.ts:215). Stamps publishedAt.
`draftpublished`archived
archivedpublishedPOST /:id/publish again — clears archivedAt (courses.service.ts:226).
publishedcancelledEnum value exists; not used by courses today (used by class_sessions).

Entitlement state machine — course_entitlements.status

course_entitlement_status enum (libs/db/src/lib/schema/enums.ts:142): pending | active | revoked.

FromToTrigger
newactiveFree course checkout — CourseCheckoutService.checkout (course-checkout.service.ts:128) inserts with initialStatus='active'.
newpendingPaid course checkout — same call with initialStatus='pending'.
pendingactivePayment webhook payment.completed (webhook-processing.service.ts:230). Resets accessRevokedAt=null.
pendingrevokedPayment webhook payment.failed (webhook-processing.service.ts:350). Stamps accessRevokedAt.
activerevokedNo automated path today — manual DB action if a refund requires it.
revokedpendingRe-purchase via ensureEntitlementForCheckout (reactivates the row with the new initial status).
revokedactiveRe-purchase succeeds (webhook flips it).

Unique constraint (user_id, program_id) (courses.ts:145) enforces “one entitlement per user per course” at the DB level. Re-purchase reactivates the same row rather than inserting a new one.

Purchase flow

CourseCheckoutService.checkout (apps/api/src/courses/course-checkout.service.ts:48):

  1. Auth guardclerkId required (the controller enforces this; service has a defensive re-check).
  2. Resolve program — must exist, course_configs.status='published'.
  3. Resolve planplans WHERE program_id=? AND type='course' AND is_active=true. 400 if missing.
  4. Resolve buyer user — by clerkId (storefront has already signed them in).
  5. Branch A vs B:
    • A: user has a membership in the seller’s org → membershipId = membership.id.
    • B: no membership → CoursesService.attachLeadForBuyer creates (or reuses) a lead with source='course_purchase' and links it to the seller’s org via organization_leads. membershipId = null.
  6. ensureEntitlementForCheckout (courses.service.ts:863):
    • If existing entitlement is status='active' → throw COURSE_ALREADY_OWNED. Controller converts to 409.
    • If existing is pending | revoked → reactivate the same row with the new initialStatus.
    • If none → insert.
  7. Free pathplan.priceInCents=0 → return { paymentPageUrl: null, entitlementId }. Entitlement already active.
  8. Paid path — call PaymentService.createCourseHostedPayment with metadata.courseEntitlementId. Returns { paymentPageUrl }. Buyer redirected.

Webhook activation

WebhookProcessingService.handlePaymentCompleted (apps/api/src/payments/services/webhook-processing.service.ts:84).

When metadata.courseEntitlementId is present:

  1. Cross-org guard — joins course_entitlements → programs and verifies programs.organization_id === orgId (the webhook URL’s path/query orgId). Mismatch → log + Sentry capture course-entitlement webhook cross-org mismatch; no mutation (webhook-processing.service.ts:215).
  2. Looks up the payment txn by providerTransactionId.
  3. Updates the entitlement: status='active', accessRevokedAt=null, sourcePaymentTransactionId=txn.id.

Failure path (handlePaymentFailed, webhook-processing.service.ts:332): same cross-org guard, then status='revoked', accessRevokedAt=now.

The pending → active flip is idempotent: replaying the webhook on an already-active row is a no-op for status; the txn link is refreshed in case it was missed the first time.

Player flow

StepEndpointBehaviour
Land on libraryGET /me/coursesLists active entitlements across all orgs.
Open a courseGET /me/courses/:idReturns entitlement + dense day-by-day curriculum + completion ids. 403 if pending (payment in flight) or revoked / accessRevokedAt.
StartPOST /me/courses/:id/start { startDate? }Stamps startDate (default today). Clears completedAt.
Read a workoutGET /me/courses/:id/workouts/:workoutIdBuyer must own the course; workout must be part of curriculum (link table check at courses.service.ts:690).
Mark completePOST /me/courses/:id/workouts/:workoutId/completeInserts into course_workout_completions with ON CONFLICT DO NOTHING (idempotent). Re-derives entitlement.completed_at.
UnmarkDELETE …/completeDeletes the completion row. Re-derives completed_at (always clears).
RestartPOST /me/courses/:id/restartstartDate=today, completedAt=null, restartCount++, all completions deleted.

Day n is derived: currentDayOffset = floor((today - startDate) / 1d). The entitlement response carries completedCourseWorkoutIds (LIBRARY workout ids, not link-table ids) so the client can flip cards with completed.has(workout.workoutId) directly.

Trainer-side flows

ActionEndpointBehaviour
CreatePOST /organizations/:orgId/coursesCreates program (deliveryMode='course', type='remote') + course_configs (status='draft').
Edit metadataPATCH /:idUpdates name/description/imageUrl on program + duration/preview on config.
Set dayPOST /:id/days { dayOffset, workoutIds?, isRest? }Replaces the day entirely (delete + insert). Validates dayOffset < durationDays and workouts belong to org.
Set pricePATCH /:id/price { priceInCents }Upserts a plans row keyed on program_id with type='course'. Currency from org.
PublishPOST /:id/publishRefuses if zero curriculum entries. Status → published, stamps publishedAt, clears archivedAt.
ArchiveDELETE /:idStatus → archived, stamps archivedAt. Existing buyers retain access; storefront returns 404.
Per-buyer drill-downGET /:id/enrollmentsReturns active entitlements with completion counts, current day, last activity.

Public storefront

GET /public/courses/:id (courses.controller.ts:267, service at findPublicById). @Public — unauthenticated.

BranchBehaviour
Course or config missing404
Status not published404 (Course not available)
No active plan404 (Course is not available for purchase)
OtherwiseReturns name, image, duration, workoutCount, price, preview workouts (per previewType), and seller-org branding (name, slug, logo).

Permissions

ActionRequired role / state
Course CRUD, day curriculum, price, publish, archiveisStaffRole (owner
Per-buyer enrollmentsStaff.
Public detailAnonymous.
CheckoutAuthenticated Clerk session (handler-level enforcement).
Player routes (/me/courses/*)Authenticated. Owner of the entitlement only. Org membership NOT required (decoupling is intentional).

Idempotency

MechanismWhere
Unique (user_id, program_id) on course_entitlementscourses.ts:145
ensureEntitlementForCheckout reactivates instead of insertingcourses.service.ts:881
Unique (entitlement_id, course_workout_id) on completionscourses.ts:204
INSERT … ON CONFLICT DO NOTHING on mark-completecourses.service.ts:580
Cross-org webhook guard (drops mismatches with Sentry capture)webhook-processing.service.ts:215, :362
Webhook short-circuit on already-active entitlement (status update is unconditional but a no-op-equivalent — only side effect is refreshing the txn link)webhook-processing.service.ts:240

Side effects

TriggerSide effect
Course purchase (category B)Lead created/reused, linked to seller org.
Course purchase (any)payment_transactions row with metadata.courseEntitlementId.
Webhook payment successEntitlement → active; member email receipt fires via the standard sendPaymentReceipt (payment.service.ts:783) when membershipId is set. Category-B buyers do not receive a receipt today because the receipt path requires membershipId. Gap.
Webhook payment failureEntitlement → revoked. No buyer email.
Restart courseAll completion rows deleted.
ArchiveStorefront returns 404; existing buyers unaffected.

Edge cases

CaseBehaviour
Buyer already owns the courseCheckout throws COURSE_ALREADY_OWNED → 409. Web app redirects to library.
Buyer abandons paid checkoutEntitlement stays pending. No cron sweeps these to revoked today (gap). Buyer can retry → ensureEntitlementForCheckout reactivates with pending.
Payment failsEntitlement revoked. Buyer can re-purchase; reactivates row.
Refund issued after activationNo automated revoke. Manual SQL or future admin UI needed.
Buyer of org A probes workout id from org A’s library outside their coursegetCourseWorkout (courses.service.ts:679) verifies the workout is in the curriculum — 404 otherwise.
Cross-org webhook abuseLogged + Sentry-captured; mutation refused.
Restart a not-yet-started coursestartDate set to today; restartCount=1. Identical to “start”.
Mark a rest-day as completerequireCourseWorkout looks up by (programId, workoutId); rest days have workoutId=null and won’t match → 404.
Mark a workout that exists on multiple daysThe link row is found by (programId, workoutId); only the first match wins (findFirst). Currently the curriculum allows the same workout on multiple days; this can mark only one of them. Gap — see courses.service.ts:623.
Mid-course publish change (e.g. swap a day’s workouts)Live buyers see the new content on next page load. No versioning.