Hiyve Components - v1.0.0
    Preparing search index...

    Module @hiyve/courses

    @hiyve/courses

    Framework-agnostic engine for structured courses. A provider — a coach, trainer, teacher or clinician — builds a course of sections, lessons and blocks and enrolls their own clients, who work through it at their own pace. The engine decides what each client can open (prerequisites, drip timers, provider approval), keeps locked lessons away from clients until they open, tracks progress across devices and scores quizzes. You choose where courses are kept: your own backend, memory, or Hiyve user files from the /hiyve entry.

    npm install @hiyve/courses
    
    import {
    addLesson,
    addSection,
    completeLesson,
    computeLessonStates,
    createCourse,
    createMemoryCourseBackend,
    createMemoryCourseStorage,
    createProgress,
    enrollClient,
    } from '@hiyve/courses';

    const backend = createMemoryCourseBackend();
    const provider = createMemoryCourseStorage({ userId: 'coach@example.com', backend });
    const client = createMemoryCourseStorage({ userId: 'client@example.com', backend });

    // Build a course: Week 1 is open at once; Week 2 opens seven days after enrollment.
    let course = createCourse({ title: 'Strength basics', ownerId: provider.userId });
    const week1 = addSection(course, { title: 'Week 1' });
    const warmUp = addLesson(week1.course, week1.section.id, { title: 'Warm-up' });
    course = warmUp.course;
    const week2 = addSection(course, { title: 'Week 2' });
    course = addLesson(week2.course, week2.section.id, {
    title: 'Progressions',
    access: { drip: { afterEnrollmentDays: 7 } },
    }).course;
    course = await provider.createCourse(course);

    // Enroll a client; lessons they can have now are opened to them.
    const { enrollment } = await enrollClient(provider, course, { userId: 'client@example.com', userName: 'Sam' });

    // The client works through the course.
    let progress = createProgress(enrollment);
    progress = completeLesson(progress, warmUp.lesson.id);
    progress = await client.saveProgress(progress);

    computeLessonStates(course, enrollment, progress);
    // { [lessonId]: { status: 'completed' | 'available' | 'locked' | 'awaiting-approval', reasons, … } }

    A course is a tree: Course → Section → Lesson → Block. The course holds the outline and each lesson's unlock rules; a lesson's blocks are kept separately (LessonContent) so they reach a client only once the lesson opens to them.

    Block type What it is Completes when
    'video', 'audio' Stored media (media: MediaRef) Played to the end, or past completeAt (default DEFAULT_MEDIA_COMPLETE_AT, 0.9) of media.durationSec — see isMediaWatched
    'file' A document or other stored file Your app marks it complete
    'rich-text' Formatted text (TipTap / ProseMirror JSON) Your app marks it complete
    'embed' A page from an allowed site — see isEmbedUrlAllowed Your app marks it complete
    'quiz' Questions, scored automatically or by the provider Submitted; with a passMark, once passed (a quiz with no right answers set has nothing to pass, so submitting completes it) — see isQuizAttemptComplete
    'live-session' A standing room (roomName) to join Your app marks it complete
    'submission' A prompt the client answers with a recording, photo, file or text (accepts, maxDurationSec), for the provider to review On the first submission
    'custom' A block type your app defines (kind, version, data) Your app marks it complete

    Blocks with required: true count toward completing their lesson.

    Every function returns a new object and leaves its input unchanged.

    Function Does
    createCourse(input) An empty course (NewCourseInput: title, ownerId, optional description, settings, status, id, now)
    isCoursePublished(course) Whether clients can be enrolled. A course's status (CourseStatus) is 'draft', 'published' or 'archived'; none means published
    addSection / updateSection / moveSection / removeSection Sections. Removing one removes its lessons.
    addLesson / updateLesson / moveLesson / removeLesson Lessons. Removing a lesson removes it from other lessons' prerequisites.
    createLessonContent(courseId, lessonId) Empty content for a lesson
    addBlock / updateBlock / moveBlock / removeBlock Blocks in a lesson. A block's id and type never change.
    listLessons(course) / findLesson(course, id) Every lesson in order / a lesson and where it sits (LessonLocation)
    effectivePrerequisites(course, id) The lessons a lesson needs, including the one before it in a 'sequential' course
    collectMediaRefs(content) / lessonFileIds(content) The stored files a lesson uses (lessonFileIds adds customFileIds)
    summarizeCourse(course) A CourseSummary for lists
    validateCourse(course) Problems that would lock a lesson forever or make no sense (CourseIssue[]): unknown or self prerequisites, prerequisite cycles, drip rules that can never fire, repeated ids
    createId(prefix?) A new unique id
    isEmbedUrlAllowed(url, origins?) Whether an embed URL is HTTPS and from an allowed site (default DEFAULT_ALLOWED_EMBED_ORIGINS: YouTube, Vimeo, Loom)
    toEmbedUrl(url) A YouTube, Vimeo or Loom share link as its embed URL

    Each lesson's access can require other lessons (prerequisites), carry a due date (due, a Schedule like drip's — it marks an unfinished lesson overdue and never locks anything), release on a timer (drip: { afterEnrollmentDays }, or { onDate } with an ISO 8601 moment carrying its offset, like '2026-10-14T09:00:00Z' — a date on its own, 'YYYY-MM-DD', means midnight UTC, the same moment wherever it is checked, and must be a real calendar date), and require the provider's approval once completed (requiresApproval). A course with settings.progression: 'sequential' also requires each lesson's predecessor.

    A lesson with a drip rule, one that needs a lesson requiring approval, or one that needs a lesson that is itself hard-locked (however far down the chain) is hard-locked: its content and files are not readable by the client until it opens. A lesson hard-locked only because of the lesson it needs opens together with that lesson, then stays locked on screen until its prerequisites are met. Every other lesson is opened at enrollment and locked on screen only, so it opens the moment its prerequisites are met.

    Function Does
    computeLessonStates(course, enrollment, progress, now?) Every lesson's LessonState: status (LessonStatus), why it is locked (reasons: LockReason[]), whether it is hardLocked and granted, and the provider's latest approval
    planAccessGrants(course, enrollment, progress, now?) The lessons to open to the client now (PlannedGrant[])
    nextDripAt(course, enrollment, now?) When the next drip lesson is due, so your app can check again then
    isHardLocked(course, lessonId) Whether a lesson's content is withheld until it opens
    lessonDueAt(lesson, enrolledAt) / dueStatus(lesson, enrolledAt, completed, now?) / overdueLessons(course, enrollment, progress, now?) When a lesson is due for a client, whether it is 'due' or 'overdue', and the unfinished lessons past their date
    scheduleAt(rule, enrolledAt) A Schedule (days after enrollment, or a date) as a moment
    dripAvailableAt(rule, enrolledAt) When a drip rule fires (epoch ms), or null if it never can
    isCourseComplete(course, enrollment, progress) Every lesson done, with approvals where needed
    applyGrants(enrollment, grants, now?) Record lessons as opened
    decideLesson(enrollment, lessonId, { approved, note?, completedAt? }, now?) Record the provider's decision on a lesson that needs approval. Pass the client's completedAt for the completion being decided, so a resubmission is recognised whatever the two devices' clocks say
    setEnrollmentStatus(enrollment, status, now?) Pause, resume or remove

    listEnrollmentOverviews(courseId) on the storage returns an EnrollmentOverview per client — status, what is open, approval decisions and completed lessons — without loading each client's progress, so provider screens stay fast with many clients.

    Function Does
    computeOverviewStates(course, overview, now?) Every lesson's state for that client
    lessonsAwaitingApproval(course, overview, now?) Completed lessons waiting for the provider
    planOverviewGrants(course, overview, now?) Lessons due to open for that client
    planOverviewRevocations(course, overview, now?) Lessons open to that client whose gate has closed again
    buildEnrollmentOverview(enrollment, progress) An overview from full documents (for implementing storage)
    enrollmentFromOverview(overview) / progressFromOverview(overview) The documents an overview describes, for computing states (not for saving)

    LockReason is one of prerequisite (with lessonIds), approval (completed lessons awaiting approval), drip (with availableAt), paused, removed, or access-pending — the rules are met but the provider's app hasn't opened the lesson yet.

    Drip lessons and lessons behind an approval open when the provider's app runs syncAccess (or syncCourseAccess) — on enrollment, after an approval, after a course edit, and from time to time while it is open. A server of your own can open them on time by implementing grantLessonAccess in your CourseStorage.

    Time arguments (TimeInput) accept a Date, epoch milliseconds or an ISO 8601 string.

    The client writes their own Progress; the provider reads it.

    Function Does
    createProgress(enrollment) Empty progress
    updateBlockProgress(progress, blockId, update, now?) Record a position, completion, quiz attempt or custom block result (BlockProgressUpdate). Blocks stay complete once complete.
    completeLesson(progress, lessonId, now?) Record a lesson completed. Completing it again after the provider sends it back puts it back in front of them.
    setLastVisited(progress, lessonId, blockId?, now?) Where to resume
    isBlockComplete(progress, blockId) Whether a block is complete
    isMediaWatched(block, maxPosition) Whether enough of a video or audio block has played
    lessonCompletedAt(lessonId, enrollment, progress) When a lesson counts as completed: the provider's manual completion, or the client's own unless it predates a reset
    applyProgressReset(progress, enrollment, now?) The client's progress cleared for the provider's reset (replies kept), or the same object when there is nothing to apply
    isQuizAttemptComplete(quiz, attempt) Whether an attempt completes its quiz block
    quizAttemptsLeft(quiz, blockProgress) How many more tries an auto-scored quiz with maxAttempts allows (null when unlimited). The client's own device keeps the count, so the limit is a courtesy, not a guarantee: two devices used offline could each take the last try
    computeLessonCompletion(content, progress) Required blocks done (LessonCompletion)
    summarizeProgress(course, progress) Lessons done and a percentage (CourseProgressSummary)
    mergeProgress(a, b) Merge copies saved on two devices without losing anything
    mergeEnrollment(a, b) Merge copies of an enrollment saved by the provider on two devices

    Questions are single-choice, multi-choice, short-text (matched ignoring case and extra spaces), number (with an optional tolerance), long-text and scale; the last two are never scored.

    A quiz with scoring: 'auto' is scored on submission, and its right answers travel with the lesson — a determined client can read them. With scoring: 'review' the provider scores it and the right answers never reach clients: save lessons with saveLesson, which keeps them in the course's private answers (CourseKeys).

    Function Does
    scoreQuiz(quiz, answers, key?) A QuizScore: score (0–1, or null when nothing is scorable), correct, scorable, passed, per-question results
    unansweredQuestions(quiz, answers) Questions still without an answer
    extractQuizKey(quiz) / stripQuizKey(quiz) / applyQuizKey(quiz, key) Take right answers off a quiz, or put them back (e.g. to edit a 'review' quiz)
    questionKey(question) One question's right answer
    normalizeAnswerText(text) Text as short answers are compared

    A client answers a submission block as often as they like; every Submission is kept. The provider comments — pinned to a moment in a recording, a page or an area of a photo (CommentAnchor), with text and/or an attached recording or file — and gives a status from their own list (ReviewStatus). The client replies. The client's submissions and replies live in their progress; the provider's comments and statuses live in the enrollment, so each document keeps one writer. Files cross over: the client's recordings become readable by the provider when progress is saved, and the provider's attachments become readable by the client when the enrollment is saved.

    Function Does
    addSubmission(progress, blockId, input, now?) Record a submission (SubmissionInput: lessonId, kind, media?, text?); completes the block
    addReply(progress, submissionId, input, authorId, now?) The client's reply (CommentInput: text?, media?, anchor?)
    addReviewComment(enrollment, submissionId, input, authorId, now?) The provider's comment
    setReviewStatus(enrollment, submissionId, status, now?) Give a status (AppliedReviewStatus records when)
    submissionThread(enrollment, progress, submissionId) Every comment on a submission, both sides, oldest first
    addLessonComment(progress, lessonId, input, authorId, now?) / addLessonReply(enrollment, lessonId, input, authorId, now?) / lessonThread(enrollment, progress, lessonId) A client's questions and notes on a lesson (in their progress), the provider's replies (in the enrollment), and the two threaded by time
    summarizeLessonComments(progress) / summarizeLessonReplies(enrollment) / unansweredLessonComments(overview) Per-lesson counts for overviews (CommentSummary), and the lessons where the client wrote after the provider last replied
    listSubmissions / latestSubmission(progress, blockId) / findSubmission(progress, submissionId) Look submissions up
    unreviewedSubmissions(overview) Latest submissions without a status yet, for a review queue
    submissionFileIds(progress) / reviewFileIds(enrollment) The files each side shares (for implementing storage)
    reviewSubmission(storage, course, enrollment, progress, input, now?) Comment and/or give a status in one step (ReviewInput → ReviewResult). A status whose effect is 'approve' or 'send-back' also decides a completed lesson that needs approval, with the comment as the note
    loadReviewStatuses(storage) / saveReviewStatuses(storage, statuses) The provider's statuses (default DEFAULT_REVIEW_STATUSES: Approved, Needs another try, Reviewed)
    replyToLesson(storage, enrollment, lessonId, input, now?) Reply to a client's question on a lesson; an attached file becomes readable by them

    Lessons for one client. A provider can add lessons for one client only and hide course lessons from them. Both live in that client's enrollment (clientLessons, hiddenLessons), so no one else is affected; the unlock rules, progress and overviews all work on the client's own version of the course.

    Function Does
    courseForEnrollment(course, enrollment) The course as one client sees it (safe to apply more than once)
    addLessonForClient(storage, course, enrollment, input, now?) Add a lesson for one client (title, sectionId, afterLessonId?, access?, blocks?, and customFileIds? for files your custom blocks use), save its content and open it if due (ClientLessonResult)
    updateLessonForClient / removeLessonForClient Change one, or remove it and its content
    setLessonHiddenForClient(storage, course, enrollment, lessonId, hidden, now?) Hide a course lesson from one client, or show it again
    addClientLesson / updateClientLesson / removeClientLesson / setLessonHidden / isClientLesson The same changes on an enrollment, without saving (ClientLesson)

    Duplicates and templates. duplicateCourse(storage, courseId, { title?, isTemplate? }) copies a course — outline, lessons and private answers, not enrollments — sharing its stored files, as a draft; deleting either keeps files the other still uses. A course with isTemplate: true is a starting point rather than a course clients take. Your app can also supply ready-made outlines as plain data (CourseTemplate, with TemplateSections and TemplateLessons whose prerequisites name other lessons' keys): createCourseFromTemplate(storage, template, { title? }) saves one as a new course, and instantiateTemplate(template, { ownerId }) makes the documents without saving.

    Falling behind. behindReason(course, overview, now?, { inactiveDays?, startGraceDays? }) says whether a client is behind — 'overdue' (a lesson's due date has passed; overdueLessonIds lists them), 'not-started' (enrolled a while ago, not begun) or 'inactive' (no activity for a while, lessons left) — or returns null (BehindReason, BehindOptions). Reminders are your app's: check overdueLessons on a schedule and send them however you like.

    Everything is saved through a CourseStorage. Each document has one writer: the provider writes the course, its lessons, its private answers and every enrollment; the client writes only their progress. Saves return the document as saved, with revision increased. A save that would overwrite a newer copy rejects with a CourseStorageError whose code is 'conflict'; progress and enrollments are merged instead.

    Export Is
    CourseStorage The contract to implement for your own backend
    createMemoryCourseStorage({ userId, backend?, now? }) In-memory storage for tests, demos and prototypes, with the same access rules as a real backend
    createMemoryCourseBackend() A backend several users' memory storages share (MemoryCourseBackend)
    CourseStorageError, isCourseStorageError(error, code?) Errors, with code: 'not-found', 'forbidden', 'conflict', 'not-ready', 'invalid' or 'network' (CourseStorageErrorCode)
    CourseStorageChange What subscribe reports

    deleteLessonContent(courseId, lessonId) removes one lesson's content. Besides the documents above, a provider has private review settings (ProviderSettings): loadProviderSettings() and saveProviderSettings(settings).

    Each runs the engine against a storage and leaves the stored documents consistent with the unlock rules.

    Function Does
    enrollClient(storage, course, client, now?) Enroll a client and open what they can have now. Refuses ('forbidden') for a draft or archived course
    syncAccess(storage, course, enrollment, progress, now?) Open every lesson the client should have now (SyncAccessResult)
    syncCourseAccess(storage, course, now?) Do that for every client in a course, loading a client's documents only when something is due to open or to be taken back (CourseAccessGrant[], each with granted and revoked)
    decideLessonApproval(storage, course, enrollment, progress, lessonId, decision, now?) Approve or send back a lesson, then open what it unlocks
    grantLessonsManually(storage, enrollment, lessonIds, now?) Open lessons whatever their rules say
    markLessonComplete(storage, course, enrollment, progress, lessonId, { note? }, now?) Mark a lesson complete for a client by hand — it counts as done and approved — and open what that unlocks
    resetClientProgress(storage, enrollment, now?) Start a client over: what they did before stops counting, approvals and manual completions are cleared, and their app clears its progress document next time (applyProgressReset). Lessons already open stay open
    setEnrollmentPaused(storage, course, enrollment, paused, now?) Pause, or resume and open what came due
    revokeLessonAccess (storage) Takes lessons, and the files only they use, back from a client. syncAccess calls it for lessons whose timer or approval gate has closed again (after a reset, or a tightened rule); pausing takes every open lesson back until the client is resumed; hiding a lesson takes it back
    removeClient(storage, enrollment, now?) Stop the client reading the course. Enrolling them again restores what they had.
    saveLesson(storage, content) Save a lesson with 'review' quiz answers kept private (SaveLessonResult)
    saveCourseAndSync(storage, course, { previous?, now?, onSaved? }) Save a course and open newly added lessons to clients who should have them. onSaved receives the saved course before lessons are opened, so a failure while opening them (which rejects) doesn't hide that the course was saved
    loadOrCreateProgress(storage, enrollment) The client's progress, or a fresh one

    @hiyve/courses/hiyve keeps courses in Hiyve user files, in a /Courses folder, each shared with exactly the people allowed to read it:

    Document Belongs to Readable by
    Course outline Provider Every enrolled client
    Lesson, and the files its blocks use Provider Clients the lesson has opened to
    Private quiz answers Provider Nobody else
    Enrollment, and files attached to review comments Provider That client
    Progress, and the files in the client's submissions and replies Client The provider
    Review settings Provider Nobody else
    import { createHiyveCourseStorage } from '@hiyve/courses/hiyve';

    const storage = createHiyveCourseStorage({
    store, // your HiyveStore
    userId: 'coach@example.com',
    getFileClient: () => store.getSlice('client').client,
    });
    Option Type Description
    store HiyveCourseFileStore Your HiyveStore
    userId string The signed-in user
    getFileClient () => Pick<FileClient, 'modifyFile'> | null The connected client, or null while not connected
    isProviderTrusted (providerId: string) => boolean Which providers this user accepts courses from, by cleaned user id (cleanUserId from @hiyve/core). Everyone by default — which lets anyone who can share a file with the user put a course in front of them, and leaves two providers' courses of the same id indistinguishable (that read is refused). Set it in any app where that matters
    location string Folder for course documents. Default DEFAULT_HIYVE_COURSES_LOCATION ('/Courses')
    fetch typeof fetch Used to read documents. Default: the global fetch
    now () => number Clock in epoch ms

    The result is a HiyveCourseStorage: a CourseStorage plus canWrite() and refresh(). Creating documents, sharing and reading work without a room. Changing a saved document needs a connected client — a room or a no-video room; without one, such saves reject with code: 'not-ready' and canWrite() is false. subscribe reports { type: 'resync' } whenever the user's file list changes. HIYVE_COURSE_RESOURCE_TYPES lists the resource types course documents carry, for filtering them out of a file manager.

    Export Value
    defaultCourseSettings / mergeCourseSettings(settings?) { progression: 'free' }
    DEFAULT_MEDIA_COMPLETE_AT 0.9
    DEFAULT_ALLOWED_EMBED_ORIGINS https://www.youtube.com, https://www.youtube-nocookie.com, https://player.vimeo.com, https://www.loom.com
    DEFAULT_REVIEW_STATUSES Approved (approves), Needs another try (sends back), Reviewed (label only)
    COURSE_SCHEMA_VERSION 1

    Course, CourseStatus, CourseSettings, CourseProgression, CourseSummary, Section, LessonSummary, AccessRule, DripRule, Schedule, LessonContent, Block, BlockBase, BlockType, VideoBlock, AudioBlock, FileBlock, RichTextBlock, RichTextDoc, EmbedBlock, LiveSessionBlock, QuizBlock, CustomBlock, MediaRef, Question, QuestionKind, SingleChoiceQuestion, MultiChoiceQuestion, ShortTextQuestion, NumberQuestion, LongTextQuestion, ScaleQuestion, ChoiceOption, QuizScoring, QuizAnswer, QuizAnswers, QuizAttempt, QuestionKey, QuizKey, CourseKeys, ClientLesson, CourseTemplate, TemplateSection, TemplateLesson, ClientLessonResult, SubmissionBlock, SubmissionKind, Submission, SubmissionSummary, SubmissionComment, CommentSummary, CommentAnchor, CommentInput, SubmissionInput, ReviewStatus, ReviewTone, AppliedReviewStatus, SubmissionReview, ProviderSettings, ReviewInput, ReviewResult, ClientRef, Enrollment, EnrollmentOverview, EnrollmentStatus, EnrollmentSummary, GrantReason, LessonGrant, LessonApproval, ManualCompletion, Progress, BlockProgress, BlockResult, LessonProgress, LastVisited, CourseSchemaVersion.

    • Any JavaScript environment with fetch and File (modern browsers, Node 20+).
    • @hiyve/core — only for the /hiyve entry.

    Modules

    @hiyve/courses
    @hiyve/courses/hiyve