Courses — Behavior
Lifecycle — course_configs.status (publish_status enum)
| From | To | Trigger |
|---|---|---|
| new | draft | CoursesService.create (courses.service.ts:59). Inserts programs row + course_configs row in one transaction. |
draft | published | POST /:id/publish — requires ≥ 1 course_workouts row (courses.service.ts:215). Stamps publishedAt. |
| `draft | published` | archived |
archived | published | POST /:id/publish again — clears archivedAt (courses.service.ts:226). |
published | cancelled | Enum 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.
| From | To | Trigger |
|---|---|---|
| new | active | Free course checkout — CourseCheckoutService.checkout (course-checkout.service.ts:128) inserts with initialStatus='active'. |
| new | pending | Paid course checkout — same call with initialStatus='pending'. |
pending | active | Payment webhook payment.completed (webhook-processing.service.ts:230). Resets accessRevokedAt=null. |
pending | revoked | Payment webhook payment.failed (webhook-processing.service.ts:350). Stamps accessRevokedAt. |
active | revoked | No automated path today — manual DB action if a refund requires it. |
revoked | pending | Re-purchase via ensureEntitlementForCheckout (reactivates the row with the new initial status). |
revoked | active | Re-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):
- Auth guard —
clerkIdrequired (the controller enforces this; service has a defensive re-check). - Resolve program — must exist,
course_configs.status='published'. - Resolve plan —
plans WHERE program_id=? AND type='course' AND is_active=true. 400 if missing. - Resolve buyer user — by
clerkId(storefront has already signed them in). - Branch A vs B:
- A: user has a membership in the seller’s org →
membershipId = membership.id. - B: no membership →
CoursesService.attachLeadForBuyercreates (or reuses) a lead withsource='course_purchase'and links it to the seller’s org viaorganization_leads.membershipId = null.
- A: user has a membership in the seller’s org →
ensureEntitlementForCheckout(courses.service.ts:863):- If existing entitlement is
status='active'→ throwCOURSE_ALREADY_OWNED. Controller converts to 409. - If existing is
pending | revoked→ reactivate the same row with the newinitialStatus. - If none → insert.
- If existing entitlement is
- Free path —
plan.priceInCents=0→ return{ paymentPageUrl: null, entitlementId }. Entitlement alreadyactive. - Paid path — call
PaymentService.createCourseHostedPaymentwithmetadata.courseEntitlementId. Returns{ paymentPageUrl }. Buyer redirected.
Webhook activation
WebhookProcessingService.handlePaymentCompleted (apps/api/src/payments/services/webhook-processing.service.ts:84).
When metadata.courseEntitlementId is present:
- Cross-org guard — joins
course_entitlements → programsand verifiesprograms.organization_id === orgId(the webhook URL’s path/query orgId). Mismatch → log + Sentry capturecourse-entitlement webhook cross-org mismatch; no mutation (webhook-processing.service.ts:215). - Looks up the payment txn by
providerTransactionId. - 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
| Step | Endpoint | Behaviour |
|---|---|---|
| Land on library | GET /me/courses | Lists active entitlements across all orgs. |
| Open a course | GET /me/courses/:id | Returns entitlement + dense day-by-day curriculum + completion ids. 403 if pending (payment in flight) or revoked / accessRevokedAt. |
| Start | POST /me/courses/:id/start { startDate? } | Stamps startDate (default today). Clears completedAt. |
| Read a workout | GET /me/courses/:id/workouts/:workoutId | Buyer must own the course; workout must be part of curriculum (link table check at courses.service.ts:690). |
| Mark complete | POST /me/courses/:id/workouts/:workoutId/complete | Inserts into course_workout_completions with ON CONFLICT DO NOTHING (idempotent). Re-derives entitlement.completed_at. |
| Unmark | DELETE …/complete | Deletes the completion row. Re-derives completed_at (always clears). |
| Restart | POST /me/courses/:id/restart | startDate=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
| Action | Endpoint | Behaviour |
|---|---|---|
| Create | POST /organizations/:orgId/courses | Creates program (deliveryMode='course', type='remote') + course_configs (status='draft'). |
| Edit metadata | PATCH /:id | Updates name/description/imageUrl on program + duration/preview on config. |
| Set day | POST /:id/days { dayOffset, workoutIds?, isRest? } | Replaces the day entirely (delete + insert). Validates dayOffset < durationDays and workouts belong to org. |
| Set price | PATCH /:id/price { priceInCents } | Upserts a plans row keyed on program_id with type='course'. Currency from org. |
| Publish | POST /:id/publish | Refuses if zero curriculum entries. Status → published, stamps publishedAt, clears archivedAt. |
| Archive | DELETE /:id | Status → archived, stamps archivedAt. Existing buyers retain access; storefront returns 404. |
| Per-buyer drill-down | GET /:id/enrollments | Returns active entitlements with completion counts, current day, last activity. |
Public storefront
GET /public/courses/:id (courses.controller.ts:267, service at findPublicById). @Public — unauthenticated.
| Branch | Behaviour |
|---|---|
| Course or config missing | 404 |
Status not published | 404 (Course not available) |
| No active plan | 404 (Course is not available for purchase) |
| Otherwise | Returns name, image, duration, workoutCount, price, preview workouts (per previewType), and seller-org branding (name, slug, logo). |
Permissions
| Action | Required role / state |
|---|---|
| Course CRUD, day curriculum, price, publish, archive | isStaffRole (owner |
| Per-buyer enrollments | Staff. |
| Public detail | Anonymous. |
| Checkout | Authenticated Clerk session (handler-level enforcement). |
Player routes (/me/courses/*) | Authenticated. Owner of the entitlement only. Org membership NOT required (decoupling is intentional). |
Idempotency
| Mechanism | Where |
|---|---|
Unique (user_id, program_id) on course_entitlements | courses.ts:145 |
ensureEntitlementForCheckout reactivates instead of inserting | courses.service.ts:881 |
Unique (entitlement_id, course_workout_id) on completions | courses.ts:204 |
INSERT … ON CONFLICT DO NOTHING on mark-complete | courses.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
| Trigger | Side effect |
|---|---|
| Course purchase (category B) | Lead created/reused, linked to seller org. |
| Course purchase (any) | payment_transactions row with metadata.courseEntitlementId. |
| Webhook payment success | Entitlement → 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 failure | Entitlement → revoked. No buyer email. |
| Restart course | All completion rows deleted. |
| Archive | Storefront returns 404; existing buyers unaffected. |
Edge cases
| Case | Behaviour |
|---|---|
| Buyer already owns the course | Checkout throws COURSE_ALREADY_OWNED → 409. Web app redirects to library. |
| Buyer abandons paid checkout | Entitlement stays pending. No cron sweeps these to revoked today (gap). Buyer can retry → ensureEntitlementForCheckout reactivates with pending. |
| Payment fails | Entitlement revoked. Buyer can re-purchase; reactivates row. |
| Refund issued after activation | No automated revoke. Manual SQL or future admin UI needed. |
| Buyer of org A probes workout id from org A’s library outside their course | getCourseWorkout (courses.service.ts:679) verifies the workout is in the curriculum — 404 otherwise. |
| Cross-org webhook abuse | Logged + Sentry-captured; mutation refused. |
| Restart a not-yet-started course | startDate set to today; restartCount=1. Identical to “start”. |
| Mark a rest-day as complete | requireCourseWorkout looks up by (programId, workoutId); rest days have workoutId=null and won’t match → 404. |
| Mark a workout that exists on multiple days | The 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. |