Skip to main content
This page covers all backend functions for courses and shows how to use them in .ef page templates. See Courses — Overview for setup and enrollment.

Backend functions

All functions are available in <script scope="backend"> blocks and in .ef files.

getCourses(opts?)

Returns all courses for the brand, each with is_assigned and started for the current customer.
ParameterTypeDefaultDescription
optsobject{}Preferred: { assignedOnly, sort, preferredOrder }
// Preferred object style
var allCourses = getCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
var myCourses = getCourses({ assignedOnly: true });

// Legacy positional style (still supported for backward compatibility)
var allCoursesLegacy = getCourses(false);
var myCoursesLegacy = getCourses(true);
sort supports:
  • enrollment_oldest_unfinished_first (default)
  • title_asc
  • title_desc
  • preferred (when used, pass preferredOrder: ['slug-or-title', ...])

getCategoriesWithCourses(opts?)

Same as getCourses but grouped by category. Returns an array of { category_slug, category_name, courses[] }.
var categories = getCategoriesWithCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
// categories[0].category_name  → "Daily Practices"
// categories[0].courses        → [ { title, slug, is_assigned, ... } ]

// Legacy positional style still supported:
var categoriesLegacy = getCategoriesWithCourses(false);

getCoursesByCategorySlug(opts)

Returns courses in a single category. Useful for “Daily practices” or “Advanced” sections on a dashboard.
var daily = getCoursesByCategorySlug({
  category: 'daily-practices',
  assignedOnly: false,
  sort: 'enrollment_oldest_unfinished_first'
});

// Legacy positional style still supported:
var dailyLegacy = getCoursesByCategorySlug('daily-practices', false);

getCoursesForCustomer(opts?)

Returns only courses the current customer is enrolled in (same as getCourses({ assignedOnly: true })). Returns [] if no session or no enrollments.
var enrolled = getCoursesForCustomer({ sort: 'enrollment_oldest_unfinished_first' });

// Legacy style still supported:
var enrolledLegacy = getCoursesForCustomer();

getCourseForCustomer(opts)

Returns a single course with enrollment check — is_assigned and started are set for the current customer. Also returns the full modules[] and contents[] tree, plus progress_percent and completed_content_ids. Use this on the course detail page.
var course = getCourseForCustomer({ slug: 'tinnitus-recovery' });
// course.is_assigned  → true / false
// course.modules      → full module/lesson tree
// course.progress_percent → 0–100

// Legacy positional style still supported:
var courseLegacy = getCourseForCustomer('tinnitus-recovery');
Returns null if no course with that slug exists.

getCourseBySlug(slug)

Returns a single course without enrollment check. is_assigned and started will not be set. Use when you only need course metadata and don’t need to gate content.
var course = getCourseBySlug('tinnitus-recovery');

getCourseLesson(opts)

Returns the resolved current lesson payload for lesson-player pages, including currentLesson, prevLessonUrl, nextLessonUrl, and progress stats.
var lessonSeed = getCourseLesson({
  course: 'tinnitus-recovery',
  module: 12,
  lesson: 77
});

// Legacy positional style still supported:
var lessonSeedLegacy = getCourseLesson('tinnitus-recovery', 12, 77);

getCompletedLessonsCount(opts?)

Returns the total number of completed lessons across all enrolled courses, or for a specific course when a slug is provided.
ParameterTypeDefaultDescription
optsobjectundefinedOptional. Pass { course: 'slug' } to count only a specific course. Omit for all courses.
var totalDone = getCompletedLessonsCount();           // all courses
var courseDone = getCompletedLessonsCount({ course: 'tinnitus-recovery' }); // single course

// Legacy shorthand still supported:
var courseDoneLegacy = getCompletedLessonsCount('tinnitus-recovery');
Returns 0 if the customer has no enrollments or no completed lessons.

getCourseProgress(opts?)

Returns a structured progress summary with completed/total lesson counts per course and an overall total. Useful for dashboards.
ParameterTypeDefaultDescription
optsobjectundefinedOptional. Pass { course: 'slug' } to limit to a single course. Omit for all courses.
var progress = getCourseProgress();
// progress.completedLessons  → 12   (total across all courses)
// progress.totalLessons      → 48   (total across all courses)
// progress.courses           → [
//   { slug: 'tinnitus-recovery', title: '...', completedLessons: 5, totalLessons: 16, progressPercent: 31 },
//   { slug: 'cbt-program', title: '...', completedLessons: 7, totalLessons: 32, progressPercent: 22 }
// ]

var single = getCourseProgress({ course: 'tinnitus-recovery' });
// single.completedLessons → 5
// single.courses          → [ { slug: 'tinnitus-recovery', ... } ]

// Legacy shorthand still supported:
var singleLegacy = getCourseProgress('tinnitus-recovery');

request.route_params

For dynamic slug pages (e.g. guided-course/{course}.ef), the slug is available via request.route_params.course.

Course list page

Show all courses, sorted so enrolled courses appear first. Use is_assigned and started to drive the CTA label.
<script scope="backend">
  if (!is_customer) {
    redirect('/members-login');
  }

  var allCourses = getCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });

  // Sort: enrolled first, then among enrolled put started (Continue) before not started
  allCourses = allCourses.slice().sort(function(a, b) {
    if (a.is_assigned !== b.is_assigned) return b.is_assigned - a.is_assigned;
    return b.started - a.started;
  });

  setVariable('courses', allCourses);
  setVariable('courseCount', allCourses.length);
</script>

@if(var.courseCount gt 0)
<div class="course-grid">
  @foreach(c in var.courses)
  <div class="course-card">
    @if(c.image_url)
    <img src="{{ c.image_url }}" alt="{{ c.title }}" />
    @endif

    <h3>{{ c.title }}</h3>
    <p>{{ c.description }}</p>
    <p>{{ c.module_count }} modules · {{ c.lesson_count }} lessons</p>

    @if(c.is_assigned eq false)
    <a href="/buy/{{ c.slug }}-access" class="btn-secondary">Get access</a>
    @elseif(c.started)
    <a href="/course/{{ c.slug }}" class="btn-primary">Continue</a>
    @else
    <a href="/course/{{ c.slug }}" class="btn-primary">Start Course</a>
    @endif
  </div>
  @endforeach
</div>
@else
<p>No courses available yet.</p>
@endif

With category tabs

<script scope="backend">
  if (!is_customer) { redirect('/members-login'); }

  var categories = getCategoriesWithCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });

  // Sort courses within each category: assigned first
  categories = categories.map(function(cat) {
    var sorted = (cat.courses || []).slice().sort(function(a, b) {
      if (a.is_assigned !== b.is_assigned) return b.is_assigned - a.is_assigned;
      return b.started - a.started;
    });
    return { category_slug: cat.category_slug, category_name: cat.category_name, courses: sorted };
  });

  setVariable('categories', categories);
</script>

@foreach(cat in var.categories)
<section data-category="{{ cat.category_slug }}">
  <h2>{{ cat.category_name }}</h2>
  <div class="course-grid">
    @foreach(c in cat.courses)
    <div class="course-card">
      <h3><a href="/course/{{ c.slug }}">{{ c.title }}</a></h3>
      <span>
        @if(c.is_assigned eq false)
          Get access
        @elseif(c.started)
          Continue
        @else
          Start Course
        @endif
      </span>
    </div>
    @endforeach
  </div>
</section>
@endforeach

Course detail page

Resolve the slug from the URL, check enrollment with getCourseForCustomer, and gate content.
<script scope="backend">
  if (!is_customer) {
    redirect('/members-login');
  }

  // For a dynamic page named guided-course/{course}.ef:
  var slug = request.route_params.course;

  // For a fixed page reading slug from query string:
  // var slug = request.query.course;

  var course = getCourseForCustomer({ slug: slug });

  if (!course) {
    redirect('/courses');
  }

  setVariable('course',       course);
  setVariable('isAssigned',   course.is_assigned || false);
  setVariable('courseStarted', course.started || false);

  if (course.is_assigned) {
    // Track that the customer has visited this course (optional)
    setSessionItem('course_visited_' + slug, '1');
  }
</script>

@if(var.course)

  <!-- Hero -->
  <div class="course-hero">
    @if(var.course.cover)
    <img src="{{ var.course.cover }}" alt="{{ var.course.title }}" />
    @endif
    <h1>{{ var.course.title }}</h1>
    @if(var.course.instructor_name)
    <p>by {{ var.course.instructor_name }}</p>
    @endif
  </div>

  <!-- Stats -->
  <div class="course-stats">
    <span>{{ var.course.module_count }} modules</span>
    <span>{{ var.course.total_lesson_count }} lessons</span>
  </div>

  @if(var.isAssigned)

  <!-- Progress -->
  <div class="progress-bar" style="width: {{ var.course.progress_percent }}%"></div>
  <p>{{ var.course.progress_percent }}% complete</p>

  <!-- Modules and lessons -->
  @foreach(mod in var.course.modules)
  <div class="module" id="module-{{ mod.id }}">
    <h2>{{ mod.order }}. {{ mod.title }}</h2>
    <div class="lessons">
      @foreach(lesson in mod.contents)
      <div class="lesson-row"
           data-content-id="{{ lesson.id }}"
           data-content-url="{{ lesson.content_url }}">
        <span class="lesson-title">{{ lesson.title }}</span>
        @if(lesson.type == 'video')
        <span class="badge">Video</span>
        @elseif(lesson.type == 'audio')
        <span class="badge">Audio</span>
        @endif
      </div>
      @endforeach
    </div>
  </div>
  @endforeach

  @else

  <!-- Not enrolled — show CTA -->
  <div class="access-gate">
    <p>You don't have access to this course yet.</p>
    <a href="/courses" class="btn">Browse all courses</a>
  </div>

  @endif

@else
<p>Course not found. <a href="/courses">Back to courses</a></p>
@endif

Auth wrapper for course pages

Use the auth wrapper pattern (@extends) so the login check and common data load happen once across all course pages:
{{-- members-courses-layout.ef --}}
<script scope="backend">
  if (!is_customer) {
    redirect('/members-login?next=' + encodeURIComponent(request.path));
  }
</script>
@block("content") @endblock
Then each course page:
{{-- course-list.ef --}}
@extends("members-courses-layout")
@block("content")

<script scope="backend">
  var courses = getCourses({ assignedOnly: false });
  setVariable('courses', courses);
</script>

@foreach(c in var.courses)
  <p>{{ c.title }} — @if(c.is_assigned)Enrolled @else Get access @endif</p>
@endforeach

@endblock

Checking access to a specific product

If you want to gate course access by checking whether the customer bought a specific product (rather than relying on enrollment), you can cross-reference with getOrders():
<script scope="backend">
  if (!is_customer) { redirect('/members-login'); }

  var course = getCourseForCustomer({ slug: request.route_params.course });
  if (!course) { redirect('/courses'); }

  // Fallback: check order history for a product by name
  if (!course.is_assigned) {
    var orders = getOrders('newest', 100);
    for (var i = 0; i < orders.length; i++) {
      for (var j = 0; j < orders[i].products.length; j++) {
        if (orders[i].products[j].name === 'Course Access Pass') {
          course.is_assigned = true;
        }
      }
    }
  }

  setVariable('course', course);
  setVariable('isAssigned', course.is_assigned);
</script>
Prefer the enrollment-based check (getCourseForCustomer) — it is more reliable and does not require iterating order history. Use the order-history fallback only if enrollment is not being set up via product linking.

Lesson types and content

Each lesson in mod.contents has a type field:
typeContent fields
'video'content_url (direct video URL) or content_embed (iframe HTML)
'audio'content_url (audio file URL)
'text'content (HTML body)
Additional fields on each lesson:
FieldDescription
titleLesson title
descriptionShort description shown below the player
content_underAdditional text shown under video/audio (e.g. transcript excerpt)
materialsArray of { name, url } — downloadable files
key_takeawaysArray of strings — bullet-point summary

Progress API

All progress routes are prefixed with /api/course/courses/:courseId/. Authentication is via the active course session (cookie). courseId, moduleId, and contentId are numeric IDs.

Mark a lesson complete

// POST /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/complete
fetch(`/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/complete`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({})
});
Returns { success: true, message, stats: { completed_lessons, total_lessons, completion_percent } }.

Save general progress

courseId, moduleId, and contentId go in the URL for the lesson-specific routes. The general saveProgress route keeps them in the body — useful when you want to upsert a progress record with a specific completed value (e.g. un-completing).
// POST /api/course/courses/:courseId/progress
fetch(`/api/course/courses/${courseId}/progress`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    module_id: moduleId,      // required
    content_id: contentId,    // required
    completed: true,          // boolean
    progress_percent: 100,    // 0–100
    video_position: 0,        // seconds
    video_duration: 0         // seconds
  })
});

Save video position

// POST /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/video-progress
fetch(`/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/video-progress`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    video_position: 142,    // seconds elapsed
    video_duration: 600,    // total duration in seconds
    progress_percent: 24    // 0–100; auto-completes lesson at ≥ 90%
  })
});

Get all progress for a course

// GET /api/course/courses/:courseId/progress
const res = await fetch(`/api/course/courses/${courseId}/progress`);
const { progress, stats } = await res.json();
// progress → array of per-lesson progress objects
// stats    → { completed_lessons, total_lessons, completion_percent, last_accessed_at, ... }

Get progress for a single lesson

// GET /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/progress
const res = await fetch(
  `/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/progress`
);
const { progress } = await res.json();
// progress → { completed, progress_percent, video_position, video_duration } or null

Get course stats

// GET /api/course/courses/:courseId/stats
const res = await fetch(`/api/course/courses/${courseId}/stats`);
const { stats } = await res.json();
The page receives the initial completed IDs via course.completed_content_ids. Use this array to mark lessons visually on load, then update the UI optimistically when the API call succeeds.