adapt-authoring-content 2.1.8 → 3.0.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,6 +1,8 @@
1
1
  import { AbstractApiModule } from 'adapt-authoring-api'
2
- import { getDescendants } from './utils.js'
3
2
  import { Hook, stringifyValues } from 'adapt-authoring-core'
3
+ import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
4
+ import { ObjectId } from 'mongodb'
5
+ import { ContentTree, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
4
6
  /**
5
7
  * Module which handles course content
6
8
  * @memberof content
@@ -11,6 +13,7 @@ class ContentModule extends AbstractApiModule {
11
13
  async setValues () {
12
14
  await super.setValues()
13
15
  /** @ignore */ this.collectionName = this.schemaName = 'content'
16
+ this.counterCollectionName = 'contentcounters'
14
17
  }
15
18
 
16
19
  /** @override */
@@ -27,80 +30,217 @@ class ContentModule extends AbstractApiModule {
27
30
  */
28
31
  this.postCloneHook = new Hook()
29
32
 
30
- const [authored, jsonschema, mongodb, tags] = await this.app.waitForModule('authored', 'jsonschema', 'mongodb', 'tags')
33
+ const [assets, authored, contentplugin, jsonschema, mongodb, tags] = await this.app.waitForModule('assets', 'authored', 'contentplugin', 'jsonschema', 'mongodb', 'tags')
34
+ /** @ignore */ this.assets = assets
35
+ /** @ignore */ this.contentplugin = contentplugin
36
+ /** @ignore */ this.jsonschema = jsonschema
37
+ /** @ignore */ this.mongodb = mongodb
38
+ /** @ignore */ this.authored = authored
39
+ /** @ignore */ this.tags = tags
40
+ /** @ignore */ this._schemaCache = new Map()
31
41
 
32
42
  await authored.registerModule(this)
33
43
  await tags.registerModule(this)
34
44
  /**
35
45
  * we have to extend config specifically here because it doesn't use the default content schema
36
46
  */
37
- jsonschema.registerSchemasHook.tap(this.registerConfigSchemas.bind(this))
47
+ jsonschema.registerSchemasHook.tap(() => {
48
+ this._schemaCache.clear()
49
+ this.registerConfigSchemas()
50
+ jsonschema.extendSchema('content', 'contentassets')
51
+ })
38
52
  await this.registerConfigSchemas()
53
+ jsonschema.extendSchema('content', 'contentassets')
54
+
55
+ // bump course.updatedAt so tree endpoint If-Modified-Since invalidates
56
+ this.postInsertHook.tap(this.touchCourse.bind(this))
57
+ this.postUpdateHook.tap((_, doc) => this.touchCourse(doc))
58
+ this.postDeleteHook.tap(this.touchCourse.bind(this))
59
+
60
+ assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
39
61
 
40
62
  await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
63
+ await mongodb.setIndex(this.collectionName, { _parentId: 1 })
64
+ await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
65
+ await mongodb.setIndex(this.collectionName, { _assetIds: 1 })
41
66
  await mongodb.setIndex(this.collectionName, { _courseId: 1, _friendlyId: 1 }, {
42
67
  unique: true,
43
68
  partialFilterExpression: { _friendlyId: { $type: 'string', $gt: '' } }
44
69
  })
70
+ await mongodb.setIndex(this.counterCollectionName, { _type: 1, _courseId: 1 }, { unique: true })
71
+ }
72
+
73
+ /**
74
+ * Touches the parent course's updatedAt so the tree endpoint's If-Modified-Since check invalidates after any descendant content changes.
75
+ * @param {Object} doc Content document that was inserted/updated/deleted
76
+ * @return {Promise}
77
+ */
78
+ async touchCourse (doc) {
79
+ if (!doc || doc._type === 'course' || !doc._courseId) return
80
+ await this.mongodb.getCollection(this.collectionName).updateOne(
81
+ { _id: parseObjectId(doc._courseId), _type: 'course' },
82
+ { $set: { updatedAt: new Date() } }
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Refuses asset deletion when the asset is referenced by content. Throws RESOURCE_IN_USE listing the affected course titles.
88
+ * @param {Object} asset Asset document being deleted
89
+ * @return {Promise}
90
+ */
91
+ async enforceAssetNotInUse (asset) {
92
+ const usedBy = await this.find(
93
+ { _assetIds: asset._id.toString() },
94
+ { validate: false },
95
+ { projection: { _courseId: 1 } }
96
+ )
97
+ if (!usedBy.length) return
98
+ const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
99
+ const courses = (await this.find(
100
+ { _type: 'course', _id: { $in: courseIds } },
101
+ { validate: false },
102
+ { projection: { title: 1, displayTitle: 1 } }
103
+ )).map(c => c.displayTitle || c.title)
104
+ throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
45
105
  }
46
106
 
47
107
  /** @override */
48
108
  async getSchemaName (data) {
49
- const contentplugin = await this.app.waitForModule('contentplugin')
109
+ const { contentplugin } = this
50
110
  let { _component, _id, _type } = data
51
111
  const defaultSchemaName = super.getSchemaName(data)
52
112
 
53
113
  if (_id && (!_type || !_component)) { // no explicit type, so look for record in the DB
54
- const [item] = await this.find({ _id }, { validate: false })
114
+ const item = await this.findOne({ _id }, { validate: false, throwOnMissing: false }, { projection: { _type: 1, _component: 1, _courseId: 1 } })
55
115
  if (item) {
56
116
  _type = item._type
57
117
  _component = item._component
118
+ if (!data._courseId && item._courseId) data._courseId = item._courseId
58
119
  }
59
120
  }
60
121
  if (!_type && !_component) { // can't go any further, return default value
61
122
  return defaultSchemaName
62
123
  }
63
124
  if (_type !== 'component') {
64
- return _type === 'page' || _type === 'menu' ? 'contentobject' : _type
125
+ return contentTypeToSchemaName(_type)
65
126
  }
66
- const component = await contentplugin.findOne({ name: _component }, { validate: false, strict: false })
127
+ const component = await contentplugin.findOne({ name: _component }, { validate: false, throwOnMissing: false })
67
128
  return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
68
129
  }
69
130
 
70
131
  /** @override */
71
132
  async getSchema (schemaName, data) {
72
- const jsonschema = await this.app.waitForModule('jsonschema')
73
- try { // try and determine a more specific schema
74
- schemaName = await this.getSchemaName(data)
75
- } catch (e) {}
76
- const contentplugin = await this.app.waitForModule('contentplugin')
133
+ const { contentplugin, jsonschema } = this
134
+ schemaName = await this.getSchemaName(data)
77
135
  const _courseId = data._courseId ??
78
- (data._id ? (await this.find({ _id: data._id }, { validate: false }))[0]?._courseId : undefined)
136
+ (data._id ? (await this.findOne({ _id: data._id }, { validate: false, throwOnMissing: false }, { projection: { _courseId: 1 } }))?._courseId : undefined)
79
137
  let enabledPluginSchemas = []
80
138
  if (_courseId) {
81
- try {
82
- const [config] = await this.find({ _type: 'config', _courseId }, { validate: false })
83
- const pluginList = config?._enabledPlugins ?? data?._enabledPlugins ?? []
84
- enabledPluginSchemas = pluginList.reduce((m, p) => [...m, ...contentplugin.getPluginSchemas(p)], [])
85
- } catch (e) {}
139
+ const config = await this.findOne({ _type: 'config', _courseId }, { validate: false, throwOnMissing: false }, { projection: { _enabledPlugins: 1 } })
140
+ const pluginList = config?._enabledPlugins ?? data?._enabledPlugins ?? []
141
+ enabledPluginSchemas = pluginList.flatMap(p => contentplugin.getPluginSchemas(p))
86
142
  }
87
- return jsonschema.getSchema(schemaName, {
143
+ const cacheKey = schemaName + ':' + enabledPluginSchemas.slice().sort().join(',')
144
+ const cached = this._schemaCache.get(cacheKey)
145
+ if (cached) return cached
146
+
147
+ const schema = await jsonschema.getSchema(schemaName, {
88
148
  useCache: false,
89
149
  extensionFilter: s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
90
150
  })
151
+ this._schemaCache.set(cacheKey, schema)
152
+ return schema
91
153
  }
92
154
 
93
155
  /**
94
156
  * Adds config schema extensions
95
157
  */
96
- async registerConfigSchemas () {
97
- const [authored, jsonschema, tags] = await this.app.waitForModule('authored', 'jsonschema', 'tags')
98
- jsonschema.extendSchema('config', authored.schemaName)
99
- jsonschema.extendSchema('config', tags.schemaExtensionName)
158
+ registerConfigSchemas () {
159
+ this.jsonschema.extendSchema('config', this.authored.schemaName)
160
+ this.jsonschema.extendSchema('config', this.tags.schemaExtensionName)
161
+ }
162
+
163
+ /**
164
+ * Generates multiple unique friendly IDs for a given type in a single atomic counter increment.
165
+ * @param {String} _type Content type (e.g. 'page', 'block', 'component')
166
+ * @param {String} _courseId The course these items belong to
167
+ * @param {Number} count Number of IDs to generate
168
+ * @param {String} [_language] Language code (only used for courses)
169
+ * @return {Promise<Array<String>>}
170
+ */
171
+ async generateFriendlyIds (_type, _courseId, count, _language) {
172
+ if (count === 0) return []
173
+ if (_type === 'config') return [formatFriendlyId(_type)]
174
+
175
+ const counters = this.mongodb.getCollection(this.counterCollectionName)
176
+ const query = { _type }
177
+ if (_type !== 'course') {
178
+ query._courseId = parseObjectId(_courseId)
179
+ }
180
+ // Seed the counter from existing content on first use
181
+ const exists = await counters.findOne(query)
182
+ if (!exists) {
183
+ const maxSeq = await this.findMaxSeq(_type, _courseId)
184
+ await counters.updateOne(query, { $setOnInsert: { seq: maxSeq } }, { upsert: true })
185
+ }
186
+ // Atomically reserve a range of sequence numbers
187
+ const counter = await counters.findOneAndUpdate(
188
+ query,
189
+ { $inc: { seq: count } },
190
+ { returnDocument: 'after' }
191
+ )
192
+ const startSeq = counter.seq - count + 1
193
+ return Array.from({ length: count }, (_, i) => {
194
+ return formatFriendlyId(_type, startSeq + i, _language)
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Finds the current max sequence number from existing content (for counter seeding)
200
+ * @param {String} _type
201
+ * @param {String} _courseId
202
+ * @return {Promise<Number>}
203
+ */
204
+ async findMaxSeq (_type, _courseId) {
205
+ const collection = this.mongodb.getCollection(this.collectionName)
206
+ const query = { _type, _friendlyId: { $exists: true, $ne: '' } }
207
+ if (_type !== 'course') {
208
+ query._courseId = parseObjectId(_courseId)
209
+ }
210
+ const docs = await collection.find(query, { projection: { _friendlyId: 1 } }).toArray()
211
+ return parseMaxSeq(docs)
212
+ }
213
+
214
+ /**
215
+ * Removes counter documents for deleted courses
216
+ * @param {Array<String>} courseIds
217
+ * @return {Promise}
218
+ */
219
+ async deleteCounters (courseIds) {
220
+ const counters = this.mongodb.getCollection(this.counterCollectionName)
221
+ const objectIds = courseIds.map(id => parseObjectId(id))
222
+ await counters.deleteMany({ _courseId: { $in: objectIds } })
223
+ }
224
+
225
+ /**
226
+ * Computes the _assetIds array for a content document
227
+ * @param {Object} doc Full content document
228
+ * @return {Promise<Array<String>>} Unique asset IDs found in the doc
229
+ */
230
+ async computeAssetIds (doc) {
231
+ const schema = await this.getSchema(this.schemaName, doc)
232
+ return extractAssetIds(schema, doc)
100
233
  }
101
234
 
102
235
  /** @override */
103
236
  async insert (data, options = {}, mongoOptions = {}) {
237
+ if (!data._friendlyId) {
238
+ const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
239
+ data._friendlyId = id
240
+ }
241
+ if (!data._assetIds) {
242
+ data._assetIds = await this.computeAssetIds(data)
243
+ }
104
244
  let doc
105
245
  try {
106
246
  doc = await super.insert(data, options, mongoOptions)
@@ -132,25 +272,41 @@ class ContentModule extends AbstractApiModule {
132
272
  }
133
273
  throw e
134
274
  }
275
+ // Recompute _assetIds from the full merged document. Cast to ObjectId so
276
+ // the stored array matches the canonical insert-path format (and mongodb
277
+ // queries, which auto-convert 24-hex strings to ObjectId, can match it).
278
+ const newAssetIds = (await this.computeAssetIds(doc)).map(id => parseObjectId(id))
279
+ const oldAssetIds = doc._assetIds ?? []
280
+ if (newAssetIds.length !== oldAssetIds.length ||
281
+ !newAssetIds.every((id, i) => id.toString() === oldAssetIds[i]?.toString())) {
282
+ const collection = this.mongodb.getCollection(this.collectionName)
283
+ await collection.updateOne({ _id: doc._id }, { $set: { _assetIds: newAssetIds } })
284
+ doc._assetIds = newAssetIds
285
+ }
286
+ const sortChanged = '_sortOrder' in data || '_parentId' in data
287
+ const pluginsChanged = '_component' in data || '_menu' in data || '_theme' in data || '_enabledPlugins' in data
135
288
  await Promise.all([
136
- this.updateSortOrder(doc, data, options, mongoOptions),
137
- this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {}, options, mongoOptions)
289
+ sortChanged && this.updateSortOrder(doc, data, options, mongoOptions),
290
+ pluginsChanged && this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {}, options, mongoOptions)
138
291
  ])
139
292
  return doc
140
293
  }
141
294
 
142
295
  /** @override */
143
- async delete (query, options, mongoOptions) {
296
+ async delete (query, options = {}, mongoOptions) {
144
297
  this.setDefaultOptions(options)
145
298
 
146
- const [targetDoc] = await this.find(query)
147
-
148
- if (!targetDoc) {
149
- throw this.app.errors.NOT_FOUND.setData({ type: options.schemaName, id: JSON.stringify(query) })
299
+ const targetDoc = await this.findOne(query)
300
+ // @note super.find to avoid hooks etc. for performance purposes
301
+ const tree = new ContentTree(await super.find({ _courseId: targetDoc._courseId }, {}, { projection: { _id: 1, _parentId: 1, _type: 1, _component: 1, _enabledPlugins: 1, _menu: 1, _theme: 1 } }))
302
+ const descendants = tree.getDescendants(targetDoc._id)
303
+ if (targetDoc._type === 'course' && tree.config) {
304
+ descendants.push(tree.config)
150
305
  }
151
- const descendants = await getDescendants(q => this.find(q), targetDoc)
152
306
 
153
- // bulk-delete descendants directly to avoid per-item memory overhead and hook storms
307
+ const deletedIds = new Set([targetDoc, ...descendants].map(d => d._id.toString()))
308
+ // bulk-delete descendants via raw mongodb to avoid per-item memory overhead and hook storms;
309
+ // postDeleteHook is invoked once below with the full descendants list
154
310
  if (descendants.length > 0) {
155
311
  const mongodb = await this.app.waitForModule('mongodb')
156
312
  await mongodb.deleteMany(this.collectionName, { _id: { $in: descendants.map(d => d._id) } }, mongoOptions)
@@ -160,9 +316,11 @@ class ContentModule extends AbstractApiModule {
160
316
  if (descendants.length > 0 && options.invokePostHook !== false) {
161
317
  await this.postDeleteHook.invoke(descendants)
162
318
  }
319
+ const remainingTree = new ContentTree(tree.items.filter(i => !deletedIds.has(i._id.toString())))
163
320
  await Promise.all([
164
- this.updateEnabledPlugins(targetDoc, {}, options, mongoOptions),
165
- this.updateSortOrder(targetDoc, undefined, options, mongoOptions)
321
+ options.updateEnabledPlugins !== false && this.updateEnabledPlugins(targetDoc, { tree: remainingTree }, options, mongoOptions),
322
+ options.updateSortOrder !== false && this.updateSortOrder(targetDoc, undefined, options, mongoOptions),
323
+ targetDoc._type === 'course' && this.deleteCounters([targetDoc._courseId])
166
324
  ])
167
325
  return [targetDoc, ...descendants]
168
326
  }
@@ -188,14 +346,16 @@ class ContentModule extends AbstractApiModule {
188
346
  }
189
347
  const newItems = []
190
348
  let parent
349
+ let parentIsNew = false
191
350
  try {
192
351
  // figure out which children need creating
193
352
  if (rootId === undefined) { // new course
194
353
  parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
195
354
  newItems.push(parent)
196
355
  childTypes.splice(0, 1, 'config')
356
+ parentIsNew = true
197
357
  } else {
198
- parent = (await this.find({ _id: rootId }))[0]
358
+ parent = await this.findOne({ _id: rootId })
199
359
  // special case for menus
200
360
  req.body?._type === 'menu'
201
361
  ? childTypes.splice(0, 1, 'menu')
@@ -209,68 +369,179 @@ class ContentModule extends AbstractApiModule {
209
369
  _courseId: parent._courseId.toString()
210
370
  })
211
371
  }
212
- const item = await this.insert(data)
372
+ // Inner items are the first child of a parent we just created, so
373
+ // _sortOrder is always 1. For the first iteration against an existing
374
+ // parent we leave _sortOrder unset — updateSortOrder(topItem) places it.
375
+ if (parentIsNew && _type !== 'config') data._sortOrder = 1
376
+ const item = await this.insert(data, { updateSortOrder: false, updateEnabledPlugins: false })
213
377
  newItems.push(item)
214
- if (_type !== 'config') parent = item
378
+ if (_type !== 'config') {
379
+ parent = item
380
+ parentIsNew = true
381
+ }
215
382
  }
216
383
  } catch (e) {
217
384
  await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
218
385
  throw e
219
386
  }
220
- // return the topmost new item
221
- return newItems[0]
387
+ // run side effects once for the topmost new item
388
+ const topItem = newItems[0]
389
+ await Promise.all([
390
+ this.updateSortOrder(topItem, topItem),
391
+ this.updateEnabledPlugins(topItem, { forceUpdate: true })
392
+ ])
393
+ return topItem
222
394
  }
223
395
 
224
396
  /**
225
- * Recursively clones a content item
397
+ * Clones a content item and all its descendants in a single bulk operation.
398
+ * Pre-generates all _id values and friendly IDs, then inserts everything in parallel.
226
399
  * @param {String} userId The user performing the action
227
400
  * @param {String} _id ID of the object to clone
228
401
  * @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
229
- * @param {Object} customData Data to be applied to the content item
230
- * @return {Promise}
402
+ * @param {Object} customData Data to be applied to the root content item
403
+ * @param {Object} options
404
+ * @param {ContentTree} options.tree Pre-built tree to avoid a DB query
405
+ * @param {Object} options.parent Pre-fetched parent doc to avoid redundant lookup
406
+ * @return {Promise<Object>} The cloned root item
231
407
  */
232
408
  async clone (userId, _id, _parentId, customData = {}, options = {}) {
233
- const [originalDoc] = await this.find({ _id })
409
+ let { tree, parent } = options
410
+
411
+ const originalDoc = tree
412
+ ? tree.getById(_id)
413
+ : await this.findOne({ _id })
234
414
  if (!originalDoc) {
235
415
  throw this.app.errors.NOT_FOUND
236
- .setData({ type: originalDoc?._type, id: _id })
416
+ .setData({ type: 'content', id: _id })
237
417
  }
238
- if (options.invokePreHook !== false) await this.preCloneHook.invoke(originalDoc)
239
-
240
- const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
241
418
 
419
+ if (!parent && _parentId) {
420
+ parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } })
421
+ }
242
422
  if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
243
423
  throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
244
424
  }
245
- const schemaName = originalDoc._type === 'menu' || originalDoc._type === 'page' ? 'contentobject' : originalDoc._type
246
- const payload = stringifyValues({
247
- ...originalDoc,
248
- _id: undefined,
249
- _trackingId: undefined,
250
- _friendlyId: originalDoc._type !== 'course' ? undefined : originalDoc._friendlyId,
251
- _courseId: parent?._type === 'course' ? parent?._id : parent?._courseId,
252
- _parentId,
253
- createdBy: userId,
254
- ...customData
425
+ if (!tree) {
426
+ const sourceItems = await this.mongodb.find(this.collectionName, { _courseId: originalDoc._courseId })
427
+ tree = new ContentTree(sourceItems)
428
+ }
429
+
430
+ // Collect all items to clone: root, config (if course clone), then all descendants
431
+ const allItems = [originalDoc]
432
+ if (originalDoc._type === 'course' && tree.config) {
433
+ allItems.push(tree.config)
434
+ }
435
+ allItems.push(...tree.getDescendants(_id))
436
+
437
+ if (options.invokePreHook !== false) {
438
+ for (const item of allItems) await this.preCloneHook.invoke(item)
439
+ }
440
+
441
+ // Pre-generate ObjectIds for every item (old _id → new _id)
442
+ const idMap = new Map()
443
+ for (const item of allItems) {
444
+ idMap.set(item._id.toString(), createObjectId())
445
+ }
446
+
447
+ const newCourseId = originalDoc._type === 'course'
448
+ ? idMap.get(originalDoc._id.toString()).toString()
449
+ : (parent?._type === 'course' ? parent._id.toString() : parent._courseId.toString())
450
+
451
+ // Pre-allocate friendly IDs in bulk per type
452
+ const typeCounts = new Map()
453
+ for (const item of allItems) {
454
+ if (item._type === 'course' || item._type === 'config') continue
455
+ typeCounts.set(item._type, (typeCounts.get(item._type) ?? 0) + 1)
456
+ }
457
+ // type → { ids, next } — bundle the cursor with the array so the payload loop is O(n) (Array#shift would be O(n²))
458
+ const friendlyIds = new Map()
459
+ await Promise.all([...typeCounts].map(async ([_type, count]) => {
460
+ friendlyIds.set(_type, { ids: await this.generateFriendlyIds(_type, newCourseId, count), next: 0 })
461
+ }))
462
+
463
+ // Pre-allocate sequential _trackingId for cloned blocks. Bulk insertMany
464
+ // defeats SpoorTrackingModule's preInsertHook (which reads the current max
465
+ // from the DB per-block), so without this every cloned block would get the
466
+ // same id.
467
+ const blockCount = typeCounts.get('block') ?? 0
468
+ let nextTrackingId
469
+ if (blockCount > 0) {
470
+ const [{ _trackingId: maxTrackingId = 0 } = {}] = await this.find(
471
+ { _courseId: newCourseId }, {}, { limit: 1, sort: [['_trackingId', -1]] }
472
+ )
473
+ nextTrackingId = maxTrackingId + 1
474
+ }
475
+
476
+ // Build all insert payloads with pre-mapped IDs and parent references
477
+ const rootId = _id.toString()
478
+ const payloads = allItems.map(item => {
479
+ const oldId = item._id.toString()
480
+ const newId = idMap.get(oldId)
481
+ const isCourse = item._type === 'course'
482
+ const isConfig = item._type === 'config'
483
+
484
+ let newParentId
485
+ if (oldId === rootId) newParentId = _parentId
486
+ else if (isConfig) newParentId = undefined
487
+ else newParentId = idMap.get(item._parentId?.toString())?.toString()
488
+
489
+ let friendlyId
490
+ if (isCourse) friendlyId = item._friendlyId
491
+ else if (isConfig) friendlyId = formatFriendlyId('config')
492
+ else {
493
+ const queue = friendlyIds.get(item._type)
494
+ friendlyId = queue?.ids[queue.next++]
495
+ }
496
+
497
+ return stringifyValues({
498
+ ...item,
499
+ _id: newId,
500
+ _trackingId: item._type === 'block' ? nextTrackingId++ : undefined,
501
+ _friendlyId: friendlyId,
502
+ _courseId: isCourse ? newId.toString() : newCourseId,
503
+ _parentId: newParentId,
504
+ createdBy: userId,
505
+ ...(oldId === rootId ? customData : {})
506
+ })
255
507
  })
256
- const newData = await this.insert(payload, { schemaName, validate: false })
257
-
258
- if (originalDoc._type === 'course') {
259
- const [config] = await this.find({ _type: 'config', _courseId: originalDoc._courseId })
260
- if (config) {
261
- await this.clone(userId, config._id, undefined, { _courseId: newData._id.toString() })
262
- delete payload._id
263
- delete payload._courseId
264
- await this.update({ _id: newData._id }, payload, { validate: false })
508
+
509
+ // Fire preInsertHook on each payload (allows observer modules to set timestamps etc.)
510
+ await Promise.all(payloads.map(payload =>
511
+ this.preInsertHook.invoke(payload, { schemaName: contentTypeToSchemaName(payload._type), collectionName: this.collectionName }, {})
512
+ ))
513
+
514
+ // Convert known ID fields to ObjectId instances and bulk insert in a single round-trip
515
+ const allNewIds = allItems.map(item => idMap.get(item._id.toString()))
516
+ for (let i = 0; i < payloads.length; i++) {
517
+ const payload = payloads[i]
518
+ payload._id = allNewIds[i]
519
+ if (payload._courseId) payload._courseId = new ObjectId(payload._courseId)
520
+ if (payload._parentId) payload._parentId = new ObjectId(payload._parentId)
521
+ if (payload.createdBy) payload.createdBy = new ObjectId(payload.createdBy)
522
+ }
523
+
524
+ const collection = this.mongodb.getCollection(this.collectionName)
525
+ try {
526
+ await collection.insertMany(payloads, { ordered: false })
527
+ } catch (e) {
528
+ await collection.deleteMany({ _id: { $in: allNewIds } }).catch(() => {})
529
+ throw e
530
+ }
531
+
532
+ // payloads (post-convertObjectIds) are the stored documents — no find-back needed
533
+ await Promise.all(payloads.map(doc => this.postInsertHook.invoke(doc)))
534
+
535
+ if (options.invokePostHook !== false) {
536
+ for (let i = 0; i < allItems.length; i++) {
537
+ await this.postCloneHook.invoke(allItems[i], payloads[i])
265
538
  }
266
539
  }
267
- const children = await this.find({ _parentId: _id })
268
- for (let i = 0; i < children.length; i++) {
269
- await this.clone(userId, children[i]._id, newData._id)
540
+ if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
541
+ await this.updateEnabledPlugins(payloads[0])
270
542
  }
271
- if (options.invokePostHook !== false) await this.postCloneHook.invoke(originalDoc, newData)
272
543
 
273
- return newData
544
+ return payloads[0]
274
545
  }
275
546
 
276
547
  /**
@@ -284,15 +555,12 @@ class ContentModule extends AbstractApiModule {
284
555
  if (item._type === 'config' || item._type === 'course' || !item._parentId) {
285
556
  return
286
557
  }
287
- const siblings = await this.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 } })
288
- if (updateData) {
289
- const newSO = item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
290
- siblings.splice(newSO, 0, item)
558
+ const siblings = await super.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 }, projection: { _id: 1, _sortOrder: 1 } })
559
+ const ops = computeSortOrderOps(siblings, updateData ? item : undefined)
560
+ if (ops.length > 0) {
561
+ const collection = this.mongodb.getCollection(this.collectionName)
562
+ return collection.bulkWrite(ops, { ordered: false })
291
563
  }
292
- return Promise.all(siblings.map(async (s, i) => {
293
- const _sortOrder = i + 1
294
- if (s._sortOrder !== _sortOrder) return super.update({ _id: s._id }, { _sortOrder }, parentOptions, parentMongoOptions)
295
- }))
296
564
  }
297
565
 
298
566
  /**
@@ -300,49 +568,93 @@ class ContentModule extends AbstractApiModule {
300
568
  * @param {Object} item The updated item
301
569
  * @param {Object} options
302
570
  * @param {Boolean} options.forceUpdate Forces an update of defaults regardless of whether the _enabledPlugins list has changed
571
+ * @param {ContentTree} options.tree Pre-built tree to avoid redundant full-course fetch
303
572
  * @return {Promise}
304
573
  */
305
- async updateEnabledPlugins ({ _courseId }, options = {}, parentOptions, parentMongoOptions) {
306
- const [contentplugin, jsonschema] = await this.app.waitForModule('contentplugin', 'jsonschema')
307
- const contentItems = await this.find({ _courseId })
308
- const config = contentItems.find(c => c._type === 'config')
574
+ async updateEnabledPlugins ({ _courseId, _type }, options = {}, parentOptions, parentMongoOptions) {
575
+ // skip types that can never affect the plugin list (e.g. page, article).
576
+ if (options.forceUpdate !== true && _type && _type !== 'component' && _type !== 'config') {
577
+ return
578
+ }
579
+ const { contentplugin, jsonschema } = this
580
+ const tree = options.tree ?? new ContentTree(await super.find({ _courseId }, {}, { projection: { _id: 1, _type: 1, _component: 1, _enabledPlugins: 1, _menu: 1, _theme: 1 } }))
581
+ const config = tree.config
309
582
 
310
583
  if (!config) {
311
584
  return // can't continue if there's no config to update
312
585
  }
313
- const extensionNames = (await contentplugin.find({ type: 'extension' })).map(p => p.name)
314
- const componentNames = (contentItems.filter(c => c._type === 'component')).map(c => c._component)
586
+ const currentPlugins = new Set(config._enabledPlugins ?? [])
587
+ const extensionNames = (await contentplugin.find({ type: 'extension' }, {}, { projection: { _id: 0, name: 1 } })).map(p => p.name)
588
+ const componentNames = tree.getComponentNames()
315
589
  // generate unique list of used plugins
316
- const _enabledPlugins = Array.from(new Set([
317
- ...config._enabledPlugins.filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
590
+ const nextPlugins = new Set([
591
+ ...[...currentPlugins].filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
318
592
  ...componentNames,
319
593
  config._menu,
320
594
  config._theme
321
- ]))
595
+ ].filter(Boolean))
322
596
  if (options.forceUpdate !== true &&
323
- config._enabledPlugins.length === _enabledPlugins.length &&
324
- config._enabledPlugins.every(p => _enabledPlugins.includes(p))) {
597
+ currentPlugins.size === nextPlugins.size &&
598
+ [...currentPlugins].every(p => nextPlugins.has(p))) {
325
599
  return // return early if the lists already match
326
600
  }
327
- // generate list of used content types which need defaults applied
328
- const types = _enabledPlugins
329
- .filter(p => options.forceUpdate || !config._enabledPlugins.includes(p))
330
- .reduce((m, p) => m.concat(contentplugin.getPluginSchemas(p)), [])
331
- .reduce((types, pluginSchemaName) => {
332
- const rawSchema = jsonschema.schemas[pluginSchemaName].raw
333
- const type = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
334
- return (type === 'contentobject' ? ['menu', 'page'] : [type]).reduce((m, t) => {
335
- if (t && !m.includes(t)) m.push(t)
336
- return m
337
- }, types)
338
- }, [])
601
+ // generate list of content types that need defaults applied for newly added plugins
602
+ const newPluginSchemas = [...nextPlugins]
603
+ .filter(p => options.forceUpdate || !currentPlugins.has(p))
604
+ .flatMap(p => contentplugin.getPluginSchemas(p))
605
+
606
+ const affectedTypes = new Set()
607
+ for (const schemaName of newPluginSchemas) {
608
+ const rawSchema = jsonschema.schemas[schemaName]?.raw
609
+ const ref = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
610
+ for (const t of (ref === 'contentobject' ? ['menu', 'page'] : [ref])) {
611
+ if (t) affectedTypes.add(t)
612
+ }
613
+ }
614
+ const _enabledPlugins = [...nextPlugins]
339
615
  // update config._enabledPlugins
340
616
  await super.update({ _courseId, _type: 'config' }, { _enabledPlugins }, parentOptions, parentMongoOptions)
341
- // update other affected content objects to ensure new defaults are applied
342
- // note: due to the complex data, each must be updated separately rather than using updateMany
343
- if (types.length > 0) {
344
- const toUpdate = await super.find({ _courseId, _type: { $in: types } }, {})
345
- return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {}, parentOptions, parentMongoOptions)))
617
+ // empty update re-validates to apply new defaults; ignoreRequired because some plugins
618
+ // declare top-level required properties with no default (e.g. adapt-contrib-glossary)
619
+ if (affectedTypes.size > 0) {
620
+ const toUpdate = await super.find({ _courseId, _type: { $in: [...affectedTypes] } }, {}, { projection: { _id: 1 } })
621
+ return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {}, { ...parentOptions, ignoreRequired: true }, parentMongoOptions)))
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Returns a lightweight projection of all content items for a course
627
+ * @param {external:ExpressRequest} req
628
+ * @param {external:ExpressResponse} res
629
+ * @param {Function} next
630
+ */
631
+ async handleTree (req, res, next) {
632
+ try {
633
+ const _courseId = req.apiData.query._courseId
634
+ const course = await this.findOne(
635
+ { _type: 'course', _courseId },
636
+ { validate: false },
637
+ { projection: { updatedAt: 1 } }
638
+ )
639
+ const lastModified = new Date(course.updatedAt)
640
+ lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
641
+ const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
642
+ if (ifModifiedSince && lastModified <= ifModifiedSince) {
643
+ return res.status(304).end()
644
+ }
645
+ const items = await this.find(
646
+ { _courseId },
647
+ { validate: false },
648
+ { projection: { _id: 1, _parentId: 1, _courseId: 1, _type: 1, _sortOrder: 1, title: 1, displayTitle: 1, _friendlyId: 1, _component: 1, _layout: 1, _menu: 1, _theme: 1, _enabledPlugins: 1, updatedAt: 1 } }
649
+ )
650
+ const tree = new ContentTree(items)
651
+ res.set('Last-Modified', lastModified.toUTCString())
652
+ res.json(items.map(item => ({
653
+ ...item,
654
+ _children: tree.getChildren(item._id).map(c => c._id)
655
+ })))
656
+ } catch (e) {
657
+ return next(e)
346
658
  }
347
659
  }
348
660
 
@@ -371,7 +683,10 @@ class ContentModule extends AbstractApiModule {
371
683
  try {
372
684
  await this.requestHook.invoke(req)
373
685
  const { _id, _parentId } = req.body
374
- const source = await this.findOne({ _id: req.body._id })
686
+ if (!_id) {
687
+ throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _id })
688
+ }
689
+ const source = await this.findOne({ _id })
375
690
  await this.checkAccess(req, source)
376
691
 
377
692
  const customData = { ...req.body }