adapt-authoring-content stores every piece of authored course content as a flat
collection of documents (collectionName = 'content'). It extends
AbstractApiModule, so it inherits standard REST CRUD plus the access/hook
machinery, and layers on the tree structure, friendly IDs, asset indexing and
build-time validation described here.
Source: lib/ContentModule.js, lib/ContentTree.js, lib/utils/*.
Content forms a tree whose nodes are distinguished by _type:
course
├── config (one per course, a childless sibling — not a tree child)
├── menu (optional; a menu can nest pages/menus)
└── page
└── article
└── block
└── component (leaf node)
The recursive scaffold order is hard-coded in insertRecursive
(ContentModule.js):
['course', 'page', 'article', 'block', 'component']menu is inserted as a special case (when req.body._type === 'menu'); config
replaces course in the chain when a new course is created.
Every _type maps to a JSON schema via contentTypeToSchemaName
(lib/utils/contentTypeToSchemaName.js): page and menu both resolve to
contentobject; all other types map to a schema of the same name. A component
is special — its schema is derived from its plugin's targetAttribute
(see getSchemaName), e.g. adapt-contrib-text → text-component.
Items are joined by ID references, not nesting:
_parentId— the immediate parent item.confighas none;coursehas none._courseId— the owning course. Set on every descendant. Acoursedocument gets its own_idwritten back as_courseIdafter insert (seeinsert), so a single{ _courseId }query returns the whole course incl. the course doc.
Indexes built in init reflect the common access paths:
{ _courseId: 1, _parentId: 1, _type: 1 }
{ _parentId: 1 }
{ _type: 1, _courseId: 1 }
{ _assetIds: 1 }
{ _courseId: 1, _friendlyId: 1 } // unique, partial (string & non-empty)lib/ContentTree.js is a pure, DB-free structure over a flat array of items
(one course's worth). It builds O(1) lookup maps on construction (byId,
byParent, byType) and exposes course/config directly. It runs on both
server and client.
Key methods:
| Method | Returns |
|---|---|
getById(id) |
item, O(1) |
getChildren(parentId) |
direct children, O(1) |
getByType(type) |
all items of a type, O(1) |
getDescendants(rootId) |
all descendants (BFS), O(n) |
getAncestors(itemId) |
parent chain upward, O(depth) |
getSiblings(itemId) |
siblings excluding self |
getEmptyContainers() |
childless non-component/non-config items |
isReachable(itemId) |
whether the _parentId chain ends at the course root |
getUnreachableItems() |
orphans — items whose chain never reaches the course |
getComponentNames() |
unique _component values in the course |
getUnreachableItems returns [] when the tree has no course node (reachability
is undecidable without a root).
The module builds a ContentTree internally for delete, clone, sort-order and
plugin-list maintenance — anywhere it needs the whole course in memory.
GET /api/content/tree/:_courseId (handleTree, routes.json) returns a
lightweight projection of every item in a course for rendering tree/list views
without fetching full documents. Permission: read:content.
Projected fields (treeFields in handleTree):
_id, _parentId, _courseId, _type, _sortOrder, title, displayTitle,
_friendlyId, _component, _layout, _menu, _theme, _enabledPlugins,
_colorLabel, _language, heroImage, _summary, _assetIds, updatedAt
Each returned item also carries a _children array of child _ids, computed
from the in-memory tree. _assetIds (see below) is included so tree consumers
can tell which nodes reference a given asset without fetching full documents.
Caching is via a weak ETag, not Last-Modified (despite the routes.json
OpenAPI meta still describing If-Modified-Since). treeEtag
(lib/utils/treeEtag.js) folds the course updatedAt and a hash of the
projected field list together, so the cache busts both when the course changes
and when the response shape changes — a field added to the projection won't
stay missing for unedited courses. If If-None-Match matches, the handler
returns 304.
The course updatedAt is bumped on any descendant change via touchCourse,
tapped into postInsertHook/postUpdateHook/postDeleteHook (init). The
handler also enforces course-level access itself (owner / _isShared /
_shareWithUsers / shared group), returning 404 rather than 403 so it
doesn't leak existence; supers are exempt.
On insert (insert): validateParent runs first — a _parentId that doesn't
resolve to an existing item in the same course throws INVALID_PARENT (400),
which stops orphans being created by a delete/insert race or a stale client
reference. Then a _friendlyId is generated if absent, _assetIds is
computed if absent, and super.insert runs. A new course gets its _courseId
written back. Otherwise updateSortOrder and updateEnabledPlugins run unless
disabled via the updateSortOrder: false / updateEnabledPlugins: false
options. A duplicate-key error is rethrown as DUPL_FRIENDLY_ID (409).
update applies the same validateParent check when a write reparents an item
(_parentId present in the update data).
POST /api/content/insertrecursive (handleInsertRecursive → insertRecursive)
bootstraps a parent plus all required children in one call. With no rootId it
creates a course (+ config + a default page/article/block/text-component);
with a rootId it fills in the missing descendant types below that parent.
Defaults (titles, the default adapt-contrib-text component body) are pulled
from langpack strings via req.translate('app.…'). On any failure all
just-created items are rolled back. Sort-order/plugin side effects run once for
the topmost new item.
POST /api/content/clone (handleClone → clone) duplicates an item and all
descendants in a single bulk insertMany. It pre-generates new ObjectIds
(old→new map), remaps _parentId/_courseId, bulk-allocates friendly IDs per
type, then fires preInsertHook/postInsertHook per payload and
preCloneHook/postCloneHook per item. Cloning a course also clones its
config. clone accepts _parentId for re-homing; an invalid parent throws
INVALID_PARENT.
Clone-specific hooks (created in init):
preCloneHook— mutable; invoked per source item before cloning.postCloneHook— invoked per item with(originalItem, newDoc).
delete cascades: it builds the course tree, collects getDescendants, bulk-
deletes them via raw mongodb (avoiding per-item hook storms), deletes the target
via super.delete (to trigger delete middleware), then invokes postDeleteHook
once with the full descendants list. Deleting a course also removes its
config and its friendly-ID counters (deleteCounters). Sort-order and the
course plugin list are recalculated afterwards.
Siblings under one parent are ordered by an integer _sortOrder starting at 1.
updateSortOrder re-fetches siblings sorted by _sortOrder and delegates to
computeSortOrderOps (lib/utils/computeSortOrderOps.js), which splices the
moved/inserted item to its target index and emits the minimal set of
updateOne ops to renumber. course and config (and any parentless item)
are exempt. On update, sort recalculation only runs when _sortOrder or
_parentId is in the update data.
A human-readable per-course identifier (formatFriendlyId,
lib/utils/formatFriendlyId.js):
course→course-<n>(with-<language>suffix when_languageis given)config→config- everything else →
<first-letter-of-type>-<n>, e.g.p-3,b-12,c-40
IDs are unique per course (enforced by the partial unique index above).
Sequence numbers come from an atomic counter collection
(contentcounters, keyed { _type, _courseId }). generateFriendlyIds
reserves a range in one $inc, seeding the counter from existing content
(findMaxSeq → parseMaxSeq, which extracts the numeric part of existing IDs)
on first use.
Each content document carries _assetIds: the unique IDs of assets it
references. The field is added by extending the content schema with
contentassets (schema/contentassets.schema.json; editorOnly, hidden in
the UI), wired in init via jsonschema.extendSchema('content', 'contentassets').
computeAssetIds → extractAssetIds (lib/utils/extractAssetIds.js) walks the
item's built schema for _backboneForms Asset-type fields and collects their
non-URL values. _assetIds is computed on insert and recomputed on every
update from the full merged document (stored as ObjectIds to match query
coercion).
Two consumers:
assets.preDeleteHook→enforceAssetNotInUse: refuses to delete an asset still referenced by content, throwingRESOURCE_IN_USEwith the affected course titles.POST /api/content/assetusage(handleAssetUsage): returns a map of asset_id→ distinct-course count, viabuildAssetUsagePipeline(lib/utils/buildAssetUsagePipeline.js). An optionalassetIdsbody array scopes the counts; assets with no usage are omitted. Counting distinct_courseId($addToSet) means many references within one course count once.
Each content document carries _summary: a display-only array of "cells" that the
authoring UI renders as a compact second line under the item's title on the course
structure page. It is computed by computeSummary → extractSummary
(lib/utils/extractSummary.js) on insert and recomputed on every update from the
full merged document, and stored on the doc (it is never queried, so a plain value
comparison skips no-op writes). Every type gets one except config, which has no
display card.
The fields summarised are resolved by resolveSummaryFields
(lib/utils/resolveSummaryFields.js) with this precedence:
summaryFieldsconfig override — an optional map of_componentname → ordered selectors (lib/ContentModule.jsreadsgetConfig('summaryFields')).- Schema annotations — fields tagged
_adapt.summaryin the built schema, ordered by the optional_adapt.summary.order. - Default —
['body', 'instruction']. Containers carry no summary annotations, so they fall through to this; a component without annotations does too.
Each selector becomes one cell via reduceToCell
(kind ∈ text | asset | collection | boolean | number), carrying the source
field key (its leaf selector segment) so consumers can style a specific field —
the UI uses field === 'instruction' to label the instruction apart from the body.
Selectors whose value is missing/empty are dropped, so a doc without instruction
simply shows only its body.
Migration migrations/3.9.0.js backfills _summary on existing container
documents (the default body/instruction cells). Existing components keep their
already-correct stored summary and pick up the instruction default on next edit —
recomputing them would need the schema module, which migrations run without.
The course config document holds _enabledPlugins — the list of content
plugins (components, the menu, the theme, extensions) in use. updateEnabledPlugins
recomputes it from the tree's component names plus config._menu/config._theme,
preserving existing extensions. When new plugins are added it re-validates the
affected content types (menu/page for contentobject-targeted schemas) to
apply their schema defaults. It runs on insert/clone and on updates that touch
_component, _menu, _theme or _enabledPlugins.
getSchema resolves the enabled-plugin set for the course from its config
document (looked up by _courseId) so it only merges the schemas of enabled
plugins; built schemas are cached per schemaName + enabledPlugins key
(_schemaCache), cleared on jsonschema.registerSchemasHook.
When the course has no persisted config, getSchema falls back to an
_enabledPlugins array supplied directly on the data, letting a caller define the
plugin schema set explicitly — e.g. validating content against a specific set via
POST /validate before the course/config is persisted. Resolution is gated on
_courseId being present, and a persisted config._enabledPlugins always takes
precedence over a supplied one.
When adaptframework is available, the module taps its preBuildHook with
enforceNoEmptyContainers (init). This builds a ContentTree for the course
(via the same flat _courseId query the build itself uses) and:
- Prunes orphans.
getUnreachableItems()returns items whose_parentIdchain never reaches the course root — left behind by an interrupted delete, invisible in the editor (which only renders the reachable tree) yet still carried into the build, where they break it. These are deleted and the count is recorded onbuild.prunedOrphansso the caller can surface it; awarnis logged with the pruned_ids. - Blocks on genuine gaps. Among the remaining reachable items, any
non-
component, non-configcontainer with no children (an empty page/article/block, or empty menu/course) throwsEMPTY_CONTAINERS(400). The error data lists each offending item's_id,_type,titleand_parentId.
content depends on adaptframework's hook (not vice-versa), so the tap is
deferred via waitForModule(...).then(...) rather than awaited in init.
conf/config.schema.json exposes only pagination options (inherited API
behaviour); there are no content-domain config keys:
{
"defaultPageSize": { "type": "number", "default": 500 },
"maxPageSize": { "type": "number", "default": 500 }
}errors/errors.json: INVALID_PARENT (400), DUPL_FRIENDLY_ID (409),
RESOURCE_IN_USE (400), EMPTY_CONTAINERS (400).