adapt-authoring-content 3.4.0 → 3.5.1
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.
- package/adapt-authoring.json +4 -1
- package/docs/content-model.md +265 -0
- package/errors/errors.json +1 -1
- package/lib/ContentModule.js +63 -4
- package/lib/ContentTree.js +37 -0
- package/package.json +1 -1
- package/routes.json +12 -0
- package/tests/ContentModule.spec.js +121 -0
- package/tests/ContentTree.spec.js +79 -0
package/adapt-authoring.json
CHANGED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
| `isReachable(itemId)` | whether the `_parentId` chain ends at the course root |
|
|
79
|
+
| `getUnreachableItems()` | orphans — items whose chain never reaches the course |
|
|
80
|
+
| `getComponentNames()` | unique `_component` values in the course |
|
|
81
|
+
|
|
82
|
+
`getUnreachableItems` returns `[]` when the tree has no `course` node (reachability
|
|
83
|
+
is undecidable without a root).
|
|
84
|
+
|
|
85
|
+
The module builds a `ContentTree` internally for delete, clone, sort-order and
|
|
86
|
+
plugin-list maintenance — anywhere it needs the whole course in memory.
|
|
87
|
+
|
|
88
|
+
## Tree endpoint
|
|
89
|
+
|
|
90
|
+
`GET /api/content/tree/:_courseId` (`handleTree`, `routes.json`) returns a
|
|
91
|
+
lightweight projection of every item in a course for rendering tree/list views
|
|
92
|
+
without fetching full documents. Permission: `read:content`.
|
|
93
|
+
|
|
94
|
+
Projected fields (`treeFields` in `handleTree`):
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
_id, _parentId, _courseId, _type, _sortOrder, title, displayTitle,
|
|
98
|
+
_friendlyId, _component, _layout, _menu, _theme, _enabledPlugins,
|
|
99
|
+
_colorLabel, _language, heroImage, updatedAt
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each returned item also carries a `_children` array of child `_id`s, computed
|
|
103
|
+
from the in-memory tree.
|
|
104
|
+
|
|
105
|
+
Caching is via a **weak ETag**, not `Last-Modified` (despite the `routes.json`
|
|
106
|
+
OpenAPI `meta` still describing `If-Modified-Since`). `treeEtag`
|
|
107
|
+
(`lib/utils/treeEtag.js`) folds the course `updatedAt` and a hash of the
|
|
108
|
+
projected field list together, so the cache busts both when the course changes
|
|
109
|
+
*and* when the response shape changes — a field added to the projection won't
|
|
110
|
+
stay missing for unedited courses. If `If-None-Match` matches, the handler
|
|
111
|
+
returns `304`.
|
|
112
|
+
|
|
113
|
+
The course `updatedAt` is bumped on any descendant change via `touchCourse`,
|
|
114
|
+
tapped into `postInsertHook`/`postUpdateHook`/`postDeleteHook` (`init`). The
|
|
115
|
+
handler also enforces course-level access itself (owner / `_isShared` /
|
|
116
|
+
`_shareWithUsers` / shared group), returning `404` rather than `403` so it
|
|
117
|
+
doesn't leak existence; supers are exempt.
|
|
118
|
+
|
|
119
|
+
## CRUD, scaffold, clone, reorder
|
|
120
|
+
|
|
121
|
+
### insert
|
|
122
|
+
On insert (`insert`): `validateParent` runs first — a `_parentId` that doesn't
|
|
123
|
+
resolve to an existing item in the same course throws `INVALID_PARENT` (400),
|
|
124
|
+
which stops orphans being created by a delete/insert race or a stale client
|
|
125
|
+
reference. Then a `_friendlyId` is generated if absent, `_assetIds` is
|
|
126
|
+
computed if absent, and `super.insert` runs. A new `course` gets its `_courseId`
|
|
127
|
+
written back. Otherwise `updateSortOrder` and `updateEnabledPlugins` run unless
|
|
128
|
+
disabled via the `updateSortOrder: false` / `updateEnabledPlugins: false`
|
|
129
|
+
options. A duplicate-key error is rethrown as `DUPL_FRIENDLY_ID` (409).
|
|
130
|
+
`update` applies the same `validateParent` check when a write reparents an item
|
|
131
|
+
(`_parentId` present in the update data).
|
|
132
|
+
|
|
133
|
+
### insertRecursive
|
|
134
|
+
`POST /api/content/insertrecursive` (`handleInsertRecursive` → `insertRecursive`)
|
|
135
|
+
bootstraps a parent plus all required children in one call. With no `rootId` it
|
|
136
|
+
creates a course (+ config + a default page/article/block/text-component);
|
|
137
|
+
with a `rootId` it fills in the missing descendant types below that parent.
|
|
138
|
+
Defaults (titles, the default `adapt-contrib-text` component body) are pulled
|
|
139
|
+
from langpack strings via `req.translate('app.…')`. On any failure all
|
|
140
|
+
just-created items are rolled back. Sort-order/plugin side effects run once for
|
|
141
|
+
the topmost new item.
|
|
142
|
+
|
|
143
|
+
### clone
|
|
144
|
+
`POST /api/content/clone` (`handleClone` → `clone`) duplicates an item and all
|
|
145
|
+
descendants in a single bulk `insertMany`. It pre-generates new `ObjectId`s
|
|
146
|
+
(old→new map), remaps `_parentId`/`_courseId`, bulk-allocates friendly IDs per
|
|
147
|
+
type, then fires `preInsertHook`/`postInsertHook` per payload and
|
|
148
|
+
`preCloneHook`/`postCloneHook` per item. Cloning a `course` also clones its
|
|
149
|
+
`config`. `clone` accepts `_parentId` for re-homing; an invalid parent throws
|
|
150
|
+
`INVALID_PARENT`.
|
|
151
|
+
|
|
152
|
+
Clone-specific hooks (created in `init`):
|
|
153
|
+
|
|
154
|
+
- `preCloneHook` — mutable; invoked per source item before cloning.
|
|
155
|
+
- `postCloneHook` — invoked per item with `(originalItem, newDoc)`.
|
|
156
|
+
|
|
157
|
+
### delete
|
|
158
|
+
`delete` cascades: it builds the course tree, collects `getDescendants`, bulk-
|
|
159
|
+
deletes them via raw mongodb (avoiding per-item hook storms), deletes the target
|
|
160
|
+
via `super.delete` (to trigger delete middleware), then invokes `postDeleteHook`
|
|
161
|
+
once with the full descendants list. Deleting a `course` also removes its
|
|
162
|
+
`config` and its friendly-ID counters (`deleteCounters`). Sort-order and the
|
|
163
|
+
course plugin list are recalculated afterwards.
|
|
164
|
+
|
|
165
|
+
### Reordering — `_sortOrder`
|
|
166
|
+
Siblings under one parent are ordered by an integer `_sortOrder` starting at 1.
|
|
167
|
+
`updateSortOrder` re-fetches siblings sorted by `_sortOrder` and delegates to
|
|
168
|
+
`computeSortOrderOps` (`lib/utils/computeSortOrderOps.js`), which splices the
|
|
169
|
+
moved/inserted item to its target index and emits the minimal set of
|
|
170
|
+
`updateOne` ops to renumber. `course` and `config` (and any parentless item)
|
|
171
|
+
are exempt. On `update`, sort recalculation only runs when `_sortOrder` or
|
|
172
|
+
`_parentId` is in the update data.
|
|
173
|
+
|
|
174
|
+
## `_friendlyId`
|
|
175
|
+
|
|
176
|
+
A human-readable per-course identifier (`formatFriendlyId`,
|
|
177
|
+
`lib/utils/formatFriendlyId.js`):
|
|
178
|
+
|
|
179
|
+
- `course` → `course-<n>` (with `-<language>` suffix when `_language` is given)
|
|
180
|
+
- `config` → `config`
|
|
181
|
+
- everything else → `<first-letter-of-type>-<n>`, e.g. `p-3`, `b-12`, `c-40`
|
|
182
|
+
|
|
183
|
+
IDs are unique per course (enforced by the partial unique index above).
|
|
184
|
+
Sequence numbers come from an atomic counter collection
|
|
185
|
+
(`contentcounters`, keyed `{ _type, _courseId }`). `generateFriendlyIds`
|
|
186
|
+
reserves a range in one `$inc`, seeding the counter from existing content
|
|
187
|
+
(`findMaxSeq` → `parseMaxSeq`, which extracts the numeric part of existing IDs)
|
|
188
|
+
on first use.
|
|
189
|
+
|
|
190
|
+
## `_assetIds`
|
|
191
|
+
|
|
192
|
+
Each content document carries `_assetIds`: the unique IDs of assets it
|
|
193
|
+
references. The field is added by extending the `content` schema with
|
|
194
|
+
`contentassets` (`schema/contentassets.schema.json`; `editorOnly`, hidden in
|
|
195
|
+
the UI), wired in `init` via `jsonschema.extendSchema('content', 'contentassets')`.
|
|
196
|
+
|
|
197
|
+
`computeAssetIds` → `extractAssetIds` (`lib/utils/extractAssetIds.js`) walks the
|
|
198
|
+
item's built schema for `_backboneForms` `Asset`-type fields and collects their
|
|
199
|
+
non-URL values. `_assetIds` is computed on insert and **recomputed on every
|
|
200
|
+
update** from the full merged document (stored as `ObjectId`s to match query
|
|
201
|
+
coercion).
|
|
202
|
+
|
|
203
|
+
Two consumers:
|
|
204
|
+
|
|
205
|
+
- `assets.preDeleteHook` → `enforceAssetNotInUse`: refuses to delete an asset
|
|
206
|
+
still referenced by content, throwing `RESOURCE_IN_USE` with the affected
|
|
207
|
+
course titles.
|
|
208
|
+
- `POST /api/content/assetusage` (`handleAssetUsage`): returns a map of asset
|
|
209
|
+
`_id` → distinct-course count, via `buildAssetUsagePipeline`
|
|
210
|
+
(`lib/utils/buildAssetUsagePipeline.js`). An optional `assetIds` body array
|
|
211
|
+
scopes the counts; assets with no usage are omitted. Counting distinct
|
|
212
|
+
`_courseId` (`$addToSet`) means many references within one course count once.
|
|
213
|
+
|
|
214
|
+
## `_enabledPlugins`
|
|
215
|
+
|
|
216
|
+
The course `config` document holds `_enabledPlugins` — the list of content
|
|
217
|
+
plugins (components, the menu, the theme, extensions) in use. `updateEnabledPlugins`
|
|
218
|
+
recomputes it from the tree's component names plus `config._menu`/`config._theme`,
|
|
219
|
+
preserving existing extensions. When new plugins are added it re-validates the
|
|
220
|
+
affected content types (`menu`/`page` for `contentobject`-targeted schemas) to
|
|
221
|
+
apply their schema defaults. It runs on insert/clone and on updates that touch
|
|
222
|
+
`_component`, `_menu`, `_theme` or `_enabledPlugins`.
|
|
223
|
+
|
|
224
|
+
`getSchema` reads `config._enabledPlugins` for the course so it only merges the
|
|
225
|
+
schemas of enabled plugins; built schemas are cached per
|
|
226
|
+
`schemaName + enabledPlugins` key (`_schemaCache`), cleared on
|
|
227
|
+
`jsonschema.registerSchemasHook`.
|
|
228
|
+
|
|
229
|
+
## Build-time validation
|
|
230
|
+
|
|
231
|
+
When `adaptframework` is available, the module taps its `preBuildHook` with
|
|
232
|
+
`enforceNoEmptyContainers` (`init`). This builds a `ContentTree` for the course
|
|
233
|
+
(via the same flat `_courseId` query the build itself uses) and:
|
|
234
|
+
|
|
235
|
+
1. **Prunes orphans.** `getUnreachableItems()` returns items whose `_parentId`
|
|
236
|
+
chain never reaches the course root — left behind by an interrupted delete,
|
|
237
|
+
invisible in the editor (which only renders the reachable tree) yet still
|
|
238
|
+
carried into the build, where they break it. These are deleted and the count
|
|
239
|
+
is recorded on `build.prunedOrphans` so the caller can surface it; a `warn`
|
|
240
|
+
is logged with the pruned `_id`s.
|
|
241
|
+
2. **Blocks on genuine gaps.** Among the remaining reachable items, any
|
|
242
|
+
non-`component`, non-`config` container with no children (an empty
|
|
243
|
+
page/article/block, or empty menu/course) throws `EMPTY_CONTAINERS` (400).
|
|
244
|
+
The error data lists each offending item's `_id`, `_type`, `title` and
|
|
245
|
+
`_parentId`.
|
|
246
|
+
|
|
247
|
+
`content` depends on `adaptframework`'s hook (not vice-versa), so the tap is
|
|
248
|
+
deferred via `waitForModule(...).then(...)` rather than awaited in `init`.
|
|
249
|
+
|
|
250
|
+
## Configuration
|
|
251
|
+
|
|
252
|
+
`conf/config.schema.json` exposes only pagination options (inherited API
|
|
253
|
+
behaviour); there are no content-domain config keys:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"defaultPageSize": { "type": "number", "default": 500 },
|
|
258
|
+
"maxPageSize": { "type": "number", "default": 500 }
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Errors
|
|
263
|
+
|
|
264
|
+
`errors/errors.json`: `INVALID_PARENT` (400), `DUPL_FRIENDLY_ID` (409),
|
|
265
|
+
`RESOURCE_IN_USE` (400), `EMPTY_CONTAINERS` (400).
|
package/errors/errors.json
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"EMPTY_CONTAINERS": {
|
|
26
26
|
"data": {
|
|
27
|
-
"items": "The childless content items blocking the build"
|
|
27
|
+
"items": "The childless content items blocking the build (each with _id, _type, title and _parentId)"
|
|
28
28
|
},
|
|
29
29
|
"description": "Course cannot be built: one or more pages, articles or blocks have no content",
|
|
30
30
|
"statusCode": 400
|
package/lib/ContentModule.js
CHANGED
|
@@ -112,8 +112,11 @@ class ContentModule extends AbstractApiModule {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* preBuildHook observer:
|
|
116
|
-
*
|
|
115
|
+
* preBuildHook observer: prunes orphaned items (unreachable from the course root, left by an
|
|
116
|
+
* interrupted delete — invisible in the editor but build-breaking), then refuses the build when
|
|
117
|
+
* any reachable non-component container has no children (an empty page, article or block).
|
|
118
|
+
* Components are leaf nodes and config is exempt. Records the pruned count on `build` so the
|
|
119
|
+
* caller can surface it.
|
|
117
120
|
* @param {AdaptFrameworkBuild} build The build being run
|
|
118
121
|
* @return {Promise}
|
|
119
122
|
*/
|
|
@@ -124,10 +127,18 @@ class ContentModule extends AbstractApiModule {
|
|
|
124
127
|
{ validate: false },
|
|
125
128
|
{ projection: { _type: 1, _parentId: 1, title: 1, displayTitle: 1 } }
|
|
126
129
|
)
|
|
127
|
-
const
|
|
130
|
+
const tree = new ContentTree(items)
|
|
131
|
+
const orphans = tree.getUnreachableItems()
|
|
132
|
+
if (orphans.length) {
|
|
133
|
+
await this.mongodb.deleteMany(this.collectionName, { _id: { $in: orphans.map(o => o._id) } })
|
|
134
|
+
this.log('warn', `pruned ${orphans.length} orphaned content item(s) from course ${_courseId}: ${orphans.map(o => o._id).join(', ')}`)
|
|
135
|
+
build.prunedOrphans = (build.prunedOrphans ?? 0) + orphans.length
|
|
136
|
+
}
|
|
137
|
+
const orphanIds = new Set(orphans.map(o => o._id.toString()))
|
|
138
|
+
const empty = tree.getEmptyContainers().filter(i => !orphanIds.has(i._id.toString()))
|
|
128
139
|
if (!empty.length) return
|
|
129
140
|
throw this.app.errors.EMPTY_CONTAINERS.setData({
|
|
130
|
-
items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title }))
|
|
141
|
+
items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title, _parentId: i._parentId?.toString() }))
|
|
131
142
|
})
|
|
132
143
|
}
|
|
133
144
|
|
|
@@ -150,6 +161,32 @@ class ContentModule extends AbstractApiModule {
|
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Returns the courses that reference a given asset as `{ _id, title }` rows, for the asset sheet's "used in courses" list.
|
|
166
|
+
* @param {external:ExpressRequest} req
|
|
167
|
+
* @param {external:ExpressResponse} res
|
|
168
|
+
* @param {function} next
|
|
169
|
+
* @return {Promise}
|
|
170
|
+
*/
|
|
171
|
+
async handleAssetCourses (req, res, next) {
|
|
172
|
+
try {
|
|
173
|
+
const usedBy = await this.find(
|
|
174
|
+
{ _assetIds: req.apiData.query._id },
|
|
175
|
+
{ validate: false },
|
|
176
|
+
{ projection: { _courseId: 1 } }
|
|
177
|
+
)
|
|
178
|
+
const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
|
|
179
|
+
const courses = await this.find(
|
|
180
|
+
{ _type: 'course', _id: { $in: courseIds } },
|
|
181
|
+
{ validate: false },
|
|
182
|
+
{ projection: { title: 1, displayTitle: 1 } }
|
|
183
|
+
)
|
|
184
|
+
res.json(courses.map(c => ({ _id: c._id.toString(), title: c.displayTitle || c.title })))
|
|
185
|
+
} catch (e) {
|
|
186
|
+
next(e)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
/**
|
|
154
191
|
* "Unused assets" filter for the asset manager. The UI sends a `?unused`
|
|
155
192
|
* query-string flag (see adapt-authoring-ui Assets page); restrict the assets
|
|
@@ -299,8 +336,29 @@ class ContentModule extends AbstractApiModule {
|
|
|
299
336
|
return extractAssetIds(schema, doc)
|
|
300
337
|
}
|
|
301
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Throws INVALID_PARENT when a write sets a _parentId that does not resolve to an existing item
|
|
341
|
+
* in the same course. Prevents orphans (items unreachable from the course root) being created by
|
|
342
|
+
* a delete/insert race or a stale client reference. No-op when no _parentId is set (course/config
|
|
343
|
+
* roots, or an update that does not reparent).
|
|
344
|
+
* @param {Object} data The content data being written
|
|
345
|
+
* @return {Promise}
|
|
346
|
+
*/
|
|
347
|
+
async validateParent (data) {
|
|
348
|
+
if (!data._parentId) return
|
|
349
|
+
const parent = await this.findOne(
|
|
350
|
+
{ _id: data._parentId },
|
|
351
|
+
{ validate: false, throwOnMissing: false },
|
|
352
|
+
{ projection: { _id: 1, _courseId: 1 } }
|
|
353
|
+
)
|
|
354
|
+
if (!parent || (data._courseId && parent._courseId && parent._courseId.toString() !== data._courseId.toString())) {
|
|
355
|
+
throw this.app.errors.INVALID_PARENT.setData({ parentId: data._parentId.toString() })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
302
359
|
/** @override */
|
|
303
360
|
async insert (data, options = {}, mongoOptions = {}) {
|
|
361
|
+
await this.validateParent(data)
|
|
304
362
|
if (!data._friendlyId) {
|
|
305
363
|
const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
|
|
306
364
|
data._friendlyId = id
|
|
@@ -330,6 +388,7 @@ class ContentModule extends AbstractApiModule {
|
|
|
330
388
|
|
|
331
389
|
/** @override */
|
|
332
390
|
async update (query, data, options, mongoOptions) {
|
|
391
|
+
if ('_parentId' in data && data._parentId) await this.validateParent(data)
|
|
333
392
|
let doc
|
|
334
393
|
try {
|
|
335
394
|
doc = await super.update(query, data, options, mongoOptions)
|
package/lib/ContentTree.js
CHANGED
|
@@ -128,6 +128,43 @@ class ContentTree {
|
|
|
128
128
|
)
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Whether an item's _parentId chain terminates at the course root. Returns false
|
|
133
|
+
* when a link in the chain points at an item missing from the tree (e.g. a block
|
|
134
|
+
* whose parent article was deleted) or forms a cycle.
|
|
135
|
+
* @param {string|Object} itemId
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
isReachable (itemId) {
|
|
139
|
+
let current = this.byId.get(itemId.toString())
|
|
140
|
+
if (!current) return false
|
|
141
|
+
const seen = new Set()
|
|
142
|
+
while (current?._parentId) {
|
|
143
|
+
const id = current._id.toString()
|
|
144
|
+
if (seen.has(id)) return false
|
|
145
|
+
seen.add(id)
|
|
146
|
+
current = this.byId.get(current._parentId.toString())
|
|
147
|
+
if (!current) return false
|
|
148
|
+
}
|
|
149
|
+
return current === this.course
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Items whose _parentId chain does not reach the course root (orphans), excluding
|
|
154
|
+
* the course and config (childless roots). Orphans are invisible in the editor yet
|
|
155
|
+
* still returned by flat _courseId queries, so they break a build. Returns [] when
|
|
156
|
+
* the tree has no course node, since reachability cannot be determined.
|
|
157
|
+
* @returns {Array<Object>} Orphaned items
|
|
158
|
+
*/
|
|
159
|
+
getUnreachableItems () {
|
|
160
|
+
if (!this.course) return []
|
|
161
|
+
return this.items.filter(i =>
|
|
162
|
+
i._type !== 'course' &&
|
|
163
|
+
i._type !== 'config' &&
|
|
164
|
+
!this.isReachable(i._id)
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
/**
|
|
132
169
|
* O(1) — unique component names across the course
|
|
133
170
|
* @returns {Array<string>}
|
package/package.json
CHANGED
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
|
}
|
|
@@ -790,6 +790,127 @@ describe('ContentModule', () => {
|
|
|
790
790
|
})
|
|
791
791
|
})
|
|
792
792
|
|
|
793
|
+
describe('validateParent', () => {
|
|
794
|
+
const PARENT = '507f1f77bcf86cd799439011'
|
|
795
|
+
const COURSE = '507f1f77bcf86cd799439022'
|
|
796
|
+
const INVALID = Symbol('INVALID_PARENT')
|
|
797
|
+
|
|
798
|
+
function createInst (parentDoc) {
|
|
799
|
+
return {
|
|
800
|
+
findOne: mock.fn(async () => parentDoc),
|
|
801
|
+
app: { errors: { INVALID_PARENT: { setData: mock.fn(data => Object.assign(new Error('INVALID_PARENT'), { symbol: INVALID, data })) } } }
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const run = (inst, data) => ContentModule.prototype.validateParent.call(inst, data)
|
|
805
|
+
|
|
806
|
+
it('is a no-op when no _parentId is set', async () => {
|
|
807
|
+
const inst = createInst(null)
|
|
808
|
+
await run(inst, { _type: 'course' })
|
|
809
|
+
assert.equal(inst.findOne.mock.callCount(), 0)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('passes when the parent exists in the same course', async () => {
|
|
813
|
+
const inst = createInst({ _id: PARENT, _courseId: COURSE })
|
|
814
|
+
await run(inst, { _parentId: PARENT, _courseId: COURSE })
|
|
815
|
+
assert.equal(inst.app.errors.INVALID_PARENT.setData.mock.callCount(), 0)
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('throws INVALID_PARENT when the parent does not exist', async () => {
|
|
819
|
+
const inst = createInst(null)
|
|
820
|
+
await assert.rejects(() => run(inst, { _parentId: 'gone', _courseId: COURSE }),
|
|
821
|
+
e => e.symbol === INVALID && e.data.parentId === 'gone')
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('throws INVALID_PARENT when the parent belongs to another course', async () => {
|
|
825
|
+
const inst = createInst({ _id: PARENT, _courseId: 'othercourse' })
|
|
826
|
+
await assert.rejects(() => run(inst, { _parentId: PARENT, _courseId: COURSE }),
|
|
827
|
+
e => e.symbol === INVALID)
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('passes existence-only when data carries no _courseId to cross-check', async () => {
|
|
831
|
+
const inst = createInst({ _id: PARENT, _courseId: COURSE })
|
|
832
|
+
await run(inst, { _parentId: PARENT })
|
|
833
|
+
assert.equal(inst.app.errors.INVALID_PARENT.setData.mock.callCount(), 0)
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
describe('enforceNoEmptyContainers', () => {
|
|
838
|
+
const COURSE = '507f1f77bcf86cd799439011'
|
|
839
|
+
const EMPTY = Symbol('EMPTY_CONTAINERS')
|
|
840
|
+
|
|
841
|
+
function createInst (items) {
|
|
842
|
+
const deleteMany = mock.fn(async () => {})
|
|
843
|
+
const inst = {
|
|
844
|
+
collectionName: 'content',
|
|
845
|
+
find: mock.fn(async () => items),
|
|
846
|
+
mongodb: { deleteMany },
|
|
847
|
+
log: mock.fn(),
|
|
848
|
+
app: { errors: { EMPTY_CONTAINERS: { setData: mock.fn(data => Object.assign(new Error('EMPTY'), { symbol: EMPTY, data })) } } }
|
|
849
|
+
}
|
|
850
|
+
return { inst, deleteMany }
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const run = (inst, build = { courseId: COURSE }) =>
|
|
854
|
+
ContentModule.prototype.enforceNoEmptyContainers.call(inst, build)
|
|
855
|
+
|
|
856
|
+
it('passes silently for a fully connected, populated course', async () => {
|
|
857
|
+
const { inst, deleteMany } = createInst([
|
|
858
|
+
{ _id: 'c', _type: 'course' },
|
|
859
|
+
{ _id: 'p', _type: 'page', _parentId: 'c' },
|
|
860
|
+
{ _id: 'a', _type: 'article', _parentId: 'p' },
|
|
861
|
+
{ _id: 'b', _type: 'block', _parentId: 'a' },
|
|
862
|
+
{ _id: 'cmp', _type: 'component', _parentId: 'b', _component: 'adapt-contrib-text' }
|
|
863
|
+
])
|
|
864
|
+
await run(inst)
|
|
865
|
+
assert.equal(deleteMany.mock.callCount(), 0)
|
|
866
|
+
assert.equal(inst.app.errors.EMPTY_CONTAINERS.setData.mock.callCount(), 0)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('prunes orphans and does not block when no reachable container is empty', async () => {
|
|
870
|
+
const { inst, deleteMany } = createInst([
|
|
871
|
+
{ _id: 'c', _type: 'course' },
|
|
872
|
+
{ _id: 'p', _type: 'page', _parentId: 'c' },
|
|
873
|
+
{ _id: 'a', _type: 'article', _parentId: 'p' },
|
|
874
|
+
{ _id: 'b', _type: 'block', _parentId: 'a' },
|
|
875
|
+
{ _id: 'cmp', _type: 'component', _parentId: 'b', _component: 'adapt-contrib-text' },
|
|
876
|
+
{ _id: 'orphan', _type: 'block', _parentId: 'gone', title: 'Block title' }
|
|
877
|
+
])
|
|
878
|
+
const build = { courseId: COURSE }
|
|
879
|
+
await run(inst, build)
|
|
880
|
+
assert.equal(deleteMany.mock.callCount(), 1)
|
|
881
|
+
assert.deepEqual(deleteMany.mock.calls[0].arguments, ['content', { _id: { $in: ['orphan'] } }])
|
|
882
|
+
assert.equal(build.prunedOrphans, 1)
|
|
883
|
+
assert.equal(inst.app.errors.EMPTY_CONTAINERS.setData.mock.callCount(), 0)
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('blocks on a reachable empty container, surfacing _parentId', async () => {
|
|
887
|
+
const { inst, deleteMany } = createInst([
|
|
888
|
+
{ _id: 'c', _type: 'course' },
|
|
889
|
+
{ _id: 'p', _type: 'page', _parentId: 'c' },
|
|
890
|
+
{ _id: 'a', _type: 'article', _parentId: 'p', title: 'Empty article' }
|
|
891
|
+
])
|
|
892
|
+
await assert.rejects(() => run(inst), e =>
|
|
893
|
+
e.symbol === EMPTY &&
|
|
894
|
+
e.data.items.length === 1 &&
|
|
895
|
+
e.data.items[0]._id === 'a' &&
|
|
896
|
+
e.data.items[0]._parentId === 'p')
|
|
897
|
+
assert.equal(deleteMany.mock.callCount(), 0)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('prunes orphans and still blocks on a separate reachable empty', async () => {
|
|
901
|
+
const { inst, deleteMany } = createInst([
|
|
902
|
+
{ _id: 'c', _type: 'course' },
|
|
903
|
+
{ _id: 'p', _type: 'page', _parentId: 'c' },
|
|
904
|
+
{ _id: 'a', _type: 'article', _parentId: 'p', title: 'Empty article' },
|
|
905
|
+
{ _id: 'orphan', _type: 'block', _parentId: 'gone' }
|
|
906
|
+
])
|
|
907
|
+
await assert.rejects(() => run(inst), e =>
|
|
908
|
+
e.data.items.length === 1 && e.data.items[0]._id === 'a')
|
|
909
|
+
assert.equal(deleteMany.mock.callCount(), 1)
|
|
910
|
+
assert.deepEqual(deleteMany.mock.calls[0].arguments[1], { _id: { $in: ['orphan'] } })
|
|
911
|
+
})
|
|
912
|
+
})
|
|
913
|
+
|
|
793
914
|
describe('delete', () => {
|
|
794
915
|
const COURSE_OID = '507f1f77bcf86cd799439011'
|
|
795
916
|
const TARGET_OID = '507f1f77bcf86cd799439012'
|
|
@@ -201,6 +201,85 @@ describe('ContentTree', () => {
|
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
+
describe('isReachable', () => {
|
|
205
|
+
it('should return true for items whose chain reaches the course', () => {
|
|
206
|
+
const tree = new ContentTree(items)
|
|
207
|
+
assert.equal(tree.isReachable('id9'), true)
|
|
208
|
+
assert.equal(tree.isReachable('id5'), true)
|
|
209
|
+
assert.equal(tree.isReachable('id1'), true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should return false when a parent is missing from the tree', () => {
|
|
213
|
+
const tree = new ContentTree([
|
|
214
|
+
{ _id: makeId(1), _type: 'course' },
|
|
215
|
+
{ _id: makeId(2), _type: 'block', _parentId: makeId(99) }
|
|
216
|
+
])
|
|
217
|
+
assert.equal(tree.isReachable('id2'), false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should return false for non-existent ids', () => {
|
|
221
|
+
const tree = new ContentTree(items)
|
|
222
|
+
assert.equal(tree.isReachable('missing'), false)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should return false when there is no course root', () => {
|
|
226
|
+
const tree = new ContentTree([
|
|
227
|
+
{ _id: makeId(1), _type: 'page', _parentId: makeId(2) },
|
|
228
|
+
{ _id: makeId(2), _type: 'menu' }
|
|
229
|
+
])
|
|
230
|
+
assert.equal(tree.isReachable('id1'), false)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should not loop forever on a cycle', () => {
|
|
234
|
+
const tree = new ContentTree([
|
|
235
|
+
{ _id: makeId(1), _type: 'course' },
|
|
236
|
+
{ _id: makeId(2), _type: 'page', _parentId: makeId(3) },
|
|
237
|
+
{ _id: makeId(3), _type: 'article', _parentId: makeId(2) }
|
|
238
|
+
])
|
|
239
|
+
assert.equal(tree.isReachable('id2'), false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('getUnreachableItems', () => {
|
|
244
|
+
it('should return [] for a fully connected course', () => {
|
|
245
|
+
assert.deepEqual(new ContentTree(items).getUnreachableItems(), [])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should return an orphaned block whose parent article was deleted', () => {
|
|
249
|
+
const tree = new ContentTree([
|
|
250
|
+
{ _id: makeId(1), _type: 'course' },
|
|
251
|
+
{ _id: makeId(2), _type: 'page', _parentId: makeId(1) },
|
|
252
|
+
{ _id: makeId(3), _type: 'block', _parentId: makeId(99) }
|
|
253
|
+
])
|
|
254
|
+
const orphans = tree.getUnreachableItems()
|
|
255
|
+
assert.deepEqual(orphans.map(i => i._id.toString()), ['id3'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should return the whole orphaned subtree, not just childless items', () => {
|
|
259
|
+
const tree = new ContentTree([
|
|
260
|
+
{ _id: makeId(1), _type: 'course' },
|
|
261
|
+
{ _id: makeId(2), _type: 'article', _parentId: makeId(99) },
|
|
262
|
+
{ _id: makeId(3), _type: 'block', _parentId: makeId(2) }
|
|
263
|
+
])
|
|
264
|
+
assert.deepEqual(tree.getUnreachableItems().map(i => i._id.toString()).sort(), ['id2', 'id3'])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should never flag course or config', () => {
|
|
268
|
+
const tree = new ContentTree([
|
|
269
|
+
{ _id: makeId(1), _type: 'course' },
|
|
270
|
+
{ _id: makeId(2), _type: 'config', _courseId: 'c1' }
|
|
271
|
+
])
|
|
272
|
+
assert.deepEqual(tree.getUnreachableItems(), [])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should return [] when the tree has no course node', () => {
|
|
276
|
+
const tree = new ContentTree([
|
|
277
|
+
{ _id: makeId(1), _type: 'block', _parentId: makeId(99) }
|
|
278
|
+
])
|
|
279
|
+
assert.deepEqual(tree.getUnreachableItems(), [])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
204
283
|
describe('getComponentNames', () => {
|
|
205
284
|
it('should return unique component names', () => {
|
|
206
285
|
const tree = new ContentTree(items)
|