Skip to content

LMS Hierarchy Structure

Status: Design — authoritative for Phase 2 (data model) and Phase 7 (entry pagination) implementation.

This document describes the hierarchical content model of the LMS. All course structure is CMS-authored; the database only tracks learner progress against entries, never the structure itself.

Overview

The LMS organises learning content into a fixed three-level hierarchy:

Course
  └── Module
        └── Entry (lesson, quiz, assignment)

There is no nesting beyond Entry. The hierarchy is entirely sourced from edX CMS and fetched at runtime via src/lib/cms/client.ts under ISR (revalidate = 60). The DB has no courses, modules, or entries tables — only entry_progress to track what a learner has done.

Hierarchy Levels

Course

  • Top-level container for all learning content.
  • Identified by course_slug, which is unique within a tenant (tenant_id + course_slug is the composite key for all DB lookups).
  • Sourced entirely from edX CMS; never stored in the application database.
  • A course belongs to exactly one tenant; multi-tenant scoping is enforced on every query.

Module

  • Mid-level grouping within a course.
  • Identified by module_slug, unique within its course.
  • Carries a CMS-authored _order field used to sort modules ascending within their course.
  • Modules are the visual grouping unit in CourseSidebar (see toggleable-navigation.md). When a module has child pages, mapHierarchyToCourse also emits a module overview entry (the module's own CMS page) as the first entry in that module. Non-leaf folders below module level emit folder overview entries in depth-first order; the sidebar renders nested collapsible folder groups. Prev/next traversal still crosses module boundaries transparently.

Entry

  • The atomic learning unit; the smallest navigable item.
  • Identified by entry_slug, unique within its course (not globally — two different courses may share the same entry slug).
  • Carries a CMS-authored _order field used to sort entries ascending within their module.
  • Quiz entries sourced from the structured-quiz widget preserve this CMS _order through the LMS adapter (src/lib/cms/quiz-adapter.ts), so lesson and quiz sequencing follows one canonical ordering contract.
  • Carries a _type discriminator that determines how it is rendered and how it is completed.

Entry Types

Type Renderer Completion mechanism
lesson Portable Text body (RSC) Explicit "Mark complete" click by the learner
quiz QuizPlayer (client component) Pass (score ≥ threshold) or maxAttempts exhausted
assignment Stub — Phase 7+1 deferred Manual "Mark complete" click (placeholder behaviour)

Quiz internals (question types, scoring, retry policy) are out of scope here; see quiz-system.md. The pagination system only consumes the binary completion signal the quiz subsystem emits. Full entry lifecycle is specified in entry-pagination.md.

Slug Conventions

  • Course slug — unique within a tenant; used as the primary route segment (/learn/course/[courseSlug]). It is the lowercase CMS project acronym, with no whitespace.
  • Entry slug — unique within a course; used as the secondary route segment (/learn/course/[courseSlug]/entry/\[entrySlug\]). Same format rules apply.
  • Module slug — unique within a course; used as a data-testid prefix (sidebar-module-<moduleSlug>) and, when the module has child pages, as the overview entry slug at /learn/course/[courseSlug]/entry/[moduleSlug].
  • Folder slug — non-leaf nodes below module level also emit folder overview entries at /learn/course/[courseSlug]/entry/[folderSlug]. Deeper leaves record ancestor folder slugs on breadcrumb arrays for sidebar grouping and chrome.
  • Numeric IDs are never used in public-facing URLs. All routing relies on slugs.
  • DB lookups that touch course or entry data always use the composite key (tenant_id, course_slug) or (tenant_id, course_slug, entry_slug).

Ordering & Sequencing

CMS _order field

Both modules and entries carry an integer _order field authored in the CMS. Ordering is:

  • Entries: sorted ascending by _order within their module.
  • Modules: sorted ascending by _order within their course.

_order is 1-indexed per scope (first module in a course has _order: 1, first entry in a module has _order: 1). Gaps are allowed — do not assume consecutive integers.

Flattened sequence

For navigation purposes the hierarchy is collapsed into a single ordered list: all entries of module 1 in ascending _order, then all entries of module 2, and so on. This flattened sequence is the canonical path through a course and is the basis for prev/next resolution, resume logic, and gating checks.

The helper function getEntryPagination(entries, currentEntrySlug) in src/lib/course/pagination.ts operates on this flattened list and returns { previous: Entry | null, next: Entry | null }. Module boundaries are transparent to the caller.

CMS vs DB Boundary

What Where Cache policy
Course structure (Course, Module, Entry metadata, Portable Text body) edX CMS ISR revalidate = 60; tagged course:<slug>
Learner progress (completed_at, last_viewed_at) Kysely DB (entry_progress table) Per-request; never ISR
Enrollment state Kysely DB (enrollments table) Per-request; never ISR

CMS content is fetched via src/lib/cms/client.ts. All methods validate responses against Zod schemas in src/schemas/. Admin-triggered content invalidations call revalidateTag('course:' + courseSlug).

The entry_progress table schema is defined in implementation.md Phase 2 and reproduced in entry-pagination.md §Data Model.

TypeScript Shapes

Interfaces are defined in src/types/ (no inline structural types per root AGENTS.md / .cursor/rules). The minimal shapes that flow through the hierarchy:

interface Course {
  slug: string;
  title: string;
  modules: Module[];
}

interface Module {
  slug: string;
  title: string;
  _order: number;
  entries: Entry[];
}

interface Entry {
  slug: string;
  title: string;
  _order: number;
  _type: "lesson" | "quiz" | "assignment";
}

These are extended in the entry-pagination layer with per-learner progress fields (e.g., completed_at) and in the quiz layer with attempt state. The CMS client returns Course objects fully populated with nested Module[] and Entry[] arrays.