adapt-authoring-content 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "documentation": {
3
- "enable": true
3
+ "enable": true,
4
+ "manualPages": {
5
+ "content-model.md": "concepts"
6
+ }
4
7
  }
5
8
  }
@@ -0,0 +1,244 @@
1
+ # Content model
2
+
3
+ `adapt-authoring-content` stores every piece of authored course content as a flat
4
+ collection of documents (`collectionName = 'content'`). It extends
5
+ `AbstractApiModule`, so it inherits standard REST CRUD plus the access/hook
6
+ machinery, and layers on the tree structure, friendly IDs, asset indexing and
7
+ build-time validation described here.
8
+
9
+ Source: `lib/ContentModule.js`, `lib/ContentTree.js`, `lib/utils/*`.
10
+
11
+ ## Hierarchy
12
+
13
+ Content forms a tree whose nodes are distinguished by `_type`:
14
+
15
+ ```
16
+ course
17
+ ├── config (one per course, a childless sibling — not a tree child)
18
+ ├── menu (optional; a menu can nest pages/menus)
19
+ └── page
20
+ └── article
21
+ └── block
22
+ └── component (leaf node)
23
+ ```
24
+
25
+ The recursive scaffold order is hard-coded in `insertRecursive`
26
+ (`ContentModule.js`):
27
+
28
+ ```js
29
+ ['course', 'page', 'article', 'block', 'component']
30
+ ```
31
+
32
+ `menu` is inserted as a special case (when `req.body._type === 'menu'`); `config`
33
+ replaces `course` in the chain when a new course is created.
34
+
35
+ Every `_type` maps to a JSON schema via `contentTypeToSchemaName`
36
+ (`lib/utils/contentTypeToSchemaName.js`): `page` and `menu` both resolve to
37
+ `contentobject`; all other types map to a schema of the same name. A `component`
38
+ is special — its schema is derived from its plugin's `targetAttribute`
39
+ (see `getSchemaName`), e.g. `adapt-contrib-text` → `text-component`.
40
+
41
+ ## Linking fields
42
+
43
+ Items are joined by ID references, not nesting:
44
+
45
+ - `_parentId` — the immediate parent item. `config` has none; `course` has none.
46
+ - `_courseId` — the owning course. Set on every descendant. A `course` document
47
+ gets its own `_id` written back as `_courseId` after insert (see `insert`),
48
+ so a single `{ _courseId }` query returns the whole course incl. the course doc.
49
+
50
+ Indexes built in `init` reflect the common access paths:
51
+
52
+ ```js
53
+ { _courseId: 1, _parentId: 1, _type: 1 }
54
+ { _parentId: 1 }
55
+ { _type: 1, _courseId: 1 }
56
+ { _assetIds: 1 }
57
+ { _courseId: 1, _friendlyId: 1 } // unique, partial (string & non-empty)
58
+ ```
59
+
60
+ ## ContentTree
61
+
62
+ `lib/ContentTree.js` is a pure, DB-free structure over a flat array of items
63
+ (one course's worth). It builds O(1) lookup maps on construction (`byId`,
64
+ `byParent`, `byType`) and exposes `course`/`config` directly. It runs on both
65
+ server and client.
66
+
67
+ Key methods:
68
+
69
+ | Method | Returns |
70
+ | --- | --- |
71
+ | `getById(id)` | item, O(1) |
72
+ | `getChildren(parentId)` | direct children, O(1) |
73
+ | `getByType(type)` | all items of a type, O(1) |
74
+ | `getDescendants(rootId)` | all descendants (BFS), O(n) |
75
+ | `getAncestors(itemId)` | parent chain upward, O(depth) |
76
+ | `getSiblings(itemId)` | siblings excluding self |
77
+ | `getEmptyContainers()` | childless non-`component`/non-`config` items |
78
+ | `getComponentNames()` | unique `_component` values in the course |
79
+
80
+ The module builds a `ContentTree` internally for delete, clone, sort-order and
81
+ plugin-list maintenance — anywhere it needs the whole course in memory.
82
+
83
+ ## Tree endpoint
84
+
85
+ `GET /api/content/tree/:_courseId` (`handleTree`, `routes.json`) returns a
86
+ lightweight projection of every item in a course for rendering tree/list views
87
+ without fetching full documents. Permission: `read:content`.
88
+
89
+ Projected fields (`treeFields` in `handleTree`):
90
+
91
+ ```
92
+ _id, _parentId, _courseId, _type, _sortOrder, title, displayTitle,
93
+ _friendlyId, _component, _layout, _menu, _theme, _enabledPlugins,
94
+ _colorLabel, _language, heroImage, updatedAt
95
+ ```
96
+
97
+ Each returned item also carries a `_children` array of child `_id`s, computed
98
+ from the in-memory tree.
99
+
100
+ Caching is via a **weak ETag**, not `Last-Modified` (despite the `routes.json`
101
+ OpenAPI `meta` still describing `If-Modified-Since`). `treeEtag`
102
+ (`lib/utils/treeEtag.js`) folds the course `updatedAt` and a hash of the
103
+ projected field list together, so the cache busts both when the course changes
104
+ *and* when the response shape changes — a field added to the projection won't
105
+ stay missing for unedited courses. If `If-None-Match` matches, the handler
106
+ returns `304`.
107
+
108
+ The course `updatedAt` is bumped on any descendant change via `touchCourse`,
109
+ tapped into `postInsertHook`/`postUpdateHook`/`postDeleteHook` (`init`). The
110
+ handler also enforces course-level access itself (owner / `_isShared` /
111
+ `_shareWithUsers` / shared group), returning `404` rather than `403` so it
112
+ doesn't leak existence; supers are exempt.
113
+
114
+ ## CRUD, scaffold, clone, reorder
115
+
116
+ ### insert
117
+ On insert (`insert`): a `_friendlyId` is generated if absent, `_assetIds` is
118
+ computed if absent, then `super.insert` runs. A new `course` gets its `_courseId`
119
+ written back. Otherwise `updateSortOrder` and `updateEnabledPlugins` run unless
120
+ disabled via the `updateSortOrder: false` / `updateEnabledPlugins: false`
121
+ options. A duplicate-key error is rethrown as `DUPL_FRIENDLY_ID` (409).
122
+
123
+ ### insertRecursive
124
+ `POST /api/content/insertrecursive` (`handleInsertRecursive` → `insertRecursive`)
125
+ bootstraps a parent plus all required children in one call. With no `rootId` it
126
+ creates a course (+ config + a default page/article/block/text-component);
127
+ with a `rootId` it fills in the missing descendant types below that parent.
128
+ Defaults (titles, the default `adapt-contrib-text` component body) are pulled
129
+ from langpack strings via `req.translate('app.…')`. On any failure all
130
+ just-created items are rolled back. Sort-order/plugin side effects run once for
131
+ the topmost new item.
132
+
133
+ ### clone
134
+ `POST /api/content/clone` (`handleClone` → `clone`) duplicates an item and all
135
+ descendants in a single bulk `insertMany`. It pre-generates new `ObjectId`s
136
+ (old→new map), remaps `_parentId`/`_courseId`, bulk-allocates friendly IDs per
137
+ type, then fires `preInsertHook`/`postInsertHook` per payload and
138
+ `preCloneHook`/`postCloneHook` per item. Cloning a `course` also clones its
139
+ `config`. `clone` accepts `_parentId` for re-homing; an invalid parent throws
140
+ `INVALID_PARENT`.
141
+
142
+ Clone-specific hooks (created in `init`):
143
+
144
+ - `preCloneHook` — mutable; invoked per source item before cloning.
145
+ - `postCloneHook` — invoked per item with `(originalItem, newDoc)`.
146
+
147
+ ### delete
148
+ `delete` cascades: it builds the course tree, collects `getDescendants`, bulk-
149
+ deletes them via raw mongodb (avoiding per-item hook storms), deletes the target
150
+ via `super.delete` (to trigger delete middleware), then invokes `postDeleteHook`
151
+ once with the full descendants list. Deleting a `course` also removes its
152
+ `config` and its friendly-ID counters (`deleteCounters`). Sort-order and the
153
+ course plugin list are recalculated afterwards.
154
+
155
+ ### Reordering — `_sortOrder`
156
+ Siblings under one parent are ordered by an integer `_sortOrder` starting at 1.
157
+ `updateSortOrder` re-fetches siblings sorted by `_sortOrder` and delegates to
158
+ `computeSortOrderOps` (`lib/utils/computeSortOrderOps.js`), which splices the
159
+ moved/inserted item to its target index and emits the minimal set of
160
+ `updateOne` ops to renumber. `course` and `config` (and any parentless item)
161
+ are exempt. On `update`, sort recalculation only runs when `_sortOrder` or
162
+ `_parentId` is in the update data.
163
+
164
+ ## `_friendlyId`
165
+
166
+ A human-readable per-course identifier (`formatFriendlyId`,
167
+ `lib/utils/formatFriendlyId.js`):
168
+
169
+ - `course` → `course-<n>` (with `-<language>` suffix when `_language` is given)
170
+ - `config` → `config`
171
+ - everything else → `<first-letter-of-type>-<n>`, e.g. `p-3`, `b-12`, `c-40`
172
+
173
+ IDs are unique per course (enforced by the partial unique index above).
174
+ Sequence numbers come from an atomic counter collection
175
+ (`contentcounters`, keyed `{ _type, _courseId }`). `generateFriendlyIds`
176
+ reserves a range in one `$inc`, seeding the counter from existing content
177
+ (`findMaxSeq` → `parseMaxSeq`, which extracts the numeric part of existing IDs)
178
+ on first use.
179
+
180
+ ## `_assetIds`
181
+
182
+ Each content document carries `_assetIds`: the unique IDs of assets it
183
+ references. The field is added by extending the `content` schema with
184
+ `contentassets` (`schema/contentassets.schema.json`; `editorOnly`, hidden in
185
+ the UI), wired in `init` via `jsonschema.extendSchema('content', 'contentassets')`.
186
+
187
+ `computeAssetIds` → `extractAssetIds` (`lib/utils/extractAssetIds.js`) walks the
188
+ item's built schema for `_backboneForms` `Asset`-type fields and collects their
189
+ non-URL values. `_assetIds` is computed on insert and **recomputed on every
190
+ update** from the full merged document (stored as `ObjectId`s to match query
191
+ coercion).
192
+
193
+ Two consumers:
194
+
195
+ - `assets.preDeleteHook` → `enforceAssetNotInUse`: refuses to delete an asset
196
+ still referenced by content, throwing `RESOURCE_IN_USE` with the affected
197
+ course titles.
198
+ - `POST /api/content/assetusage` (`handleAssetUsage`): returns a map of asset
199
+ `_id` → distinct-course count, via `buildAssetUsagePipeline`
200
+ (`lib/utils/buildAssetUsagePipeline.js`). An optional `assetIds` body array
201
+ scopes the counts; assets with no usage are omitted. Counting distinct
202
+ `_courseId` (`$addToSet`) means many references within one course count once.
203
+
204
+ ## `_enabledPlugins`
205
+
206
+ The course `config` document holds `_enabledPlugins` — the list of content
207
+ plugins (components, the menu, the theme, extensions) in use. `updateEnabledPlugins`
208
+ recomputes it from the tree's component names plus `config._menu`/`config._theme`,
209
+ preserving existing extensions. When new plugins are added it re-validates the
210
+ affected content types (`menu`/`page` for `contentobject`-targeted schemas) to
211
+ apply their schema defaults. It runs on insert/clone and on updates that touch
212
+ `_component`, `_menu`, `_theme` or `_enabledPlugins`.
213
+
214
+ `getSchema` reads `config._enabledPlugins` for the course so it only merges the
215
+ schemas of enabled plugins; built schemas are cached per
216
+ `schemaName + enabledPlugins` key (`_schemaCache`), cleared on
217
+ `jsonschema.registerSchemasHook`.
218
+
219
+ ## Build-time validation
220
+
221
+ When `adaptframework` is available, the module taps its `preBuildHook` with
222
+ `enforceNoEmptyContainers` (`init`). This builds a `ContentTree` for the course
223
+ and calls `getEmptyContainers()`; if any non-`component`, non-`config` item has
224
+ no children (an empty page/article/block, or empty menu/course) it throws
225
+ `EMPTY_CONTAINERS` (400) listing the offending items, blocking the build.
226
+ `content` depends on `adaptframework`'s hook (not vice-versa), so the tap is
227
+ deferred via `waitForModule(...).then(...)` rather than awaited in `init`.
228
+
229
+ ## Configuration
230
+
231
+ `conf/config.schema.json` exposes only pagination options (inherited API
232
+ behaviour); there are no content-domain config keys:
233
+
234
+ ```json
235
+ {
236
+ "defaultPageSize": { "type": "number", "default": 500 },
237
+ "maxPageSize": { "type": "number", "default": 500 }
238
+ }
239
+ ```
240
+
241
+ ## Errors
242
+
243
+ `errors/errors.json`: `INVALID_PARENT` (400), `DUPL_FRIENDLY_ID` (409),
244
+ `RESOURCE_IN_USE` (400), `EMPTY_CONTAINERS` (400).
@@ -150,6 +150,32 @@ class ContentModule extends AbstractApiModule {
150
150
  }
151
151
  }
152
152
 
153
+ /**
154
+ * Returns the courses that reference a given asset as `{ _id, title }` rows, for the asset sheet's "used in courses" list.
155
+ * @param {external:ExpressRequest} req
156
+ * @param {external:ExpressResponse} res
157
+ * @param {function} next
158
+ * @return {Promise}
159
+ */
160
+ async handleAssetCourses (req, res, next) {
161
+ try {
162
+ const usedBy = await this.find(
163
+ { _assetIds: req.apiData.query._id },
164
+ { validate: false },
165
+ { projection: { _courseId: 1 } }
166
+ )
167
+ const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
168
+ const courses = await this.find(
169
+ { _type: 'course', _id: { $in: courseIds } },
170
+ { validate: false },
171
+ { projection: { title: 1, displayTitle: 1 } }
172
+ )
173
+ res.json(courses.map(c => ({ _id: c._id.toString(), title: c.displayTitle || c.title })))
174
+ } catch (e) {
175
+ next(e)
176
+ }
177
+ }
178
+
153
179
  /**
154
180
  * "Unused assets" filter for the asset manager. The UI sends a `?unused`
155
181
  * query-string flag (see adapt-authoring-ui Assets page); restrict the assets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
package/routes.json CHANGED
@@ -133,6 +133,18 @@
133
133
  "responses": { "200": { "description": "Object mapping asset _id to the number of distinct courses referencing it; assets with no usage are omitted" } }
134
134
  }
135
135
  }
136
+ },
137
+ {
138
+ "route": "/assetusage/:_id",
139
+ "handlers": { "get": "handleAssetCourses" },
140
+ "permissions": { "get": ["read:${scope}"] },
141
+ "meta": {
142
+ "get": {
143
+ "summary": "List the courses that reference an asset",
144
+ "parameters": [{ "name": "_id", "in": "path", "description": "The asset _id", "required": true }],
145
+ "responses": { "200": { "description": "Array of { _id, title } for each course referencing the asset" } }
146
+ }
147
+ }
136
148
  }
137
149
  ]
138
150
  }