# @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.

## Installation

```bash
npm install @hiyve/courses
```

## Quick start

```ts
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, … } }
```

## Courses

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.

### Building

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 |

## Unlock rules

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 |

### Enrollment overviews

`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.

## Progress

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 |

## Quizzes

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 |

## Submissions and review

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 |

## Personalizing, templates and duplicates

**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 `TemplateSection`s and `TemplateLesson`s whose prerequisites name other lessons' `key`s): `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.

## Storage

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)`.

### Workflows

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 user files

`@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 |

```ts
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.

## Defaults

| 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` |

## Types

`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`.

## Requirements

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