adapt-authoring-content 2.1.7 → 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,30 +272,55 @@ 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
- await Promise.all([...descendants, targetDoc].map(d => {
154
- return super.delete({ _id: d._id }, options, mongoOptions)
155
- }))
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
310
+ if (descendants.length > 0) {
311
+ const mongodb = await this.app.waitForModule('mongodb')
312
+ await mongodb.deleteMany(this.collectionName, { _id: { $in: descendants.map(d => d._id) } }, mongoOptions)
313
+ }
314
+ // delete target via super.delete to trigger deleteHook middleware (e.g. multilang)
315
+ await super.delete({ _id: targetDoc._id }, options, mongoOptions)
316
+ if (descendants.length > 0 && options.invokePostHook !== false) {
317
+ await this.postDeleteHook.invoke(descendants)
318
+ }
319
+ const remainingTree = new ContentTree(tree.items.filter(i => !deletedIds.has(i._id.toString())))
156
320
  await Promise.all([
157
- this.updateEnabledPlugins(targetDoc, {}, options, mongoOptions),
158
- 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])
159
324
  ])
160
325
  return [targetDoc, ...descendants]
161
326
  }
@@ -181,14 +346,16 @@ class ContentModule extends AbstractApiModule {
181
346
  }
182
347
  const newItems = []
183
348
  let parent
349
+ let parentIsNew = false
184
350
  try {
185
351
  // figure out which children need creating
186
352
  if (rootId === undefined) { // new course
187
353
  parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
188
354
  newItems.push(parent)
189
355
  childTypes.splice(0, 1, 'config')
356
+ parentIsNew = true
190
357
  } else {
191
- parent = (await this.find({ _id: rootId }))[0]
358
+ parent = await this.findOne({ _id: rootId })
192
359
  // special case for menus
193
360
  req.body?._type === 'menu'
194
361
  ? childTypes.splice(0, 1, 'menu')
@@ -202,68 +369,179 @@ class ContentModule extends AbstractApiModule {
202
369
  _courseId: parent._courseId.toString()
203
370
  })
204
371
  }
205
- 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 })
206
377
  newItems.push(item)
207
- if (_type !== 'config') parent = item
378
+ if (_type !== 'config') {
379
+ parent = item
380
+ parentIsNew = true
381
+ }
208
382
  }
209
383
  } catch (e) {
210
384
  await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
211
385
  throw e
212
386
  }
213
- // return the topmost new item
214
- 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
215
394
  }
216
395
 
217
396
  /**
218
- * 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.
219
399
  * @param {String} userId The user performing the action
220
400
  * @param {String} _id ID of the object to clone
221
401
  * @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
222
- * @param {Object} customData Data to be applied to the content item
223
- * @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
224
407
  */
225
408
  async clone (userId, _id, _parentId, customData = {}, options = {}) {
226
- 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 })
227
414
  if (!originalDoc) {
228
415
  throw this.app.errors.NOT_FOUND
229
- .setData({ type: originalDoc?._type, id: _id })
416
+ .setData({ type: 'content', id: _id })
230
417
  }
231
- if (options.invokePreHook !== false) await this.preCloneHook.invoke(originalDoc)
232
-
233
- const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
234
418
 
419
+ if (!parent && _parentId) {
420
+ parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } })
421
+ }
235
422
  if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
236
423
  throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
237
424
  }
238
- const schemaName = originalDoc._type === 'menu' || originalDoc._type === 'page' ? 'contentobject' : originalDoc._type
239
- const payload = stringifyValues({
240
- ...originalDoc,
241
- _id: undefined,
242
- _trackingId: undefined,
243
- _friendlyId: originalDoc._type !== 'course' ? undefined : originalDoc._friendlyId,
244
- _courseId: parent?._type === 'course' ? parent?._id : parent?._courseId,
245
- _parentId,
246
- createdBy: userId,
247
- ...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
+ })
248
507
  })
249
- const newData = await this.insert(payload, { schemaName, validate: false })
250
-
251
- if (originalDoc._type === 'course') {
252
- const [config] = await this.find({ _type: 'config', _courseId: originalDoc._courseId })
253
- if (config) {
254
- await this.clone(userId, config._id, undefined, { _courseId: newData._id.toString() })
255
- delete payload._id
256
- delete payload._courseId
257
- 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])
258
538
  }
259
539
  }
260
- const children = await this.find({ _parentId: _id })
261
- for (let i = 0; i < children.length; i++) {
262
- await this.clone(userId, children[i]._id, newData._id)
540
+ if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
541
+ await this.updateEnabledPlugins(payloads[0])
263
542
  }
264
- if (options.invokePostHook !== false) await this.postCloneHook.invoke(originalDoc, newData)
265
543
 
266
- return newData
544
+ return payloads[0]
267
545
  }
268
546
 
269
547
  /**
@@ -277,15 +555,12 @@ class ContentModule extends AbstractApiModule {
277
555
  if (item._type === 'config' || item._type === 'course' || !item._parentId) {
278
556
  return
279
557
  }
280
- const siblings = await this.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 } })
281
- if (updateData) {
282
- const newSO = item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
283
- 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 })
284
563
  }
285
- return Promise.all(siblings.map(async (s, i) => {
286
- const _sortOrder = i + 1
287
- if (s._sortOrder !== _sortOrder) return super.update({ _id: s._id }, { _sortOrder }, parentOptions, parentMongoOptions)
288
- }))
289
564
  }
290
565
 
291
566
  /**
@@ -293,49 +568,93 @@ class ContentModule extends AbstractApiModule {
293
568
  * @param {Object} item The updated item
294
569
  * @param {Object} options
295
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
296
572
  * @return {Promise}
297
573
  */
298
- async updateEnabledPlugins ({ _courseId }, options = {}, parentOptions, parentMongoOptions) {
299
- const [contentplugin, jsonschema] = await this.app.waitForModule('contentplugin', 'jsonschema')
300
- const contentItems = await this.find({ _courseId })
301
- 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
302
582
 
303
583
  if (!config) {
304
584
  return // can't continue if there's no config to update
305
585
  }
306
- const extensionNames = (await contentplugin.find({ type: 'extension' })).map(p => p.name)
307
- 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()
308
589
  // generate unique list of used plugins
309
- const _enabledPlugins = Array.from(new Set([
310
- ...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
311
592
  ...componentNames,
312
593
  config._menu,
313
594
  config._theme
314
- ]))
595
+ ].filter(Boolean))
315
596
  if (options.forceUpdate !== true &&
316
- config._enabledPlugins.length === _enabledPlugins.length &&
317
- config._enabledPlugins.every(p => _enabledPlugins.includes(p))) {
597
+ currentPlugins.size === nextPlugins.size &&
598
+ [...currentPlugins].every(p => nextPlugins.has(p))) {
318
599
  return // return early if the lists already match
319
600
  }
320
- // generate list of used content types which need defaults applied
321
- const types = _enabledPlugins
322
- .filter(p => options.forceUpdate || !config._enabledPlugins.includes(p))
323
- .reduce((m, p) => m.concat(contentplugin.getPluginSchemas(p)), [])
324
- .reduce((types, pluginSchemaName) => {
325
- const rawSchema = jsonschema.schemas[pluginSchemaName].raw
326
- const type = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
327
- return (type === 'contentobject' ? ['menu', 'page'] : [type]).reduce((m, t) => {
328
- if (t && !m.includes(t)) m.push(t)
329
- return m
330
- }, types)
331
- }, [])
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]
332
615
  // update config._enabledPlugins
333
616
  await super.update({ _courseId, _type: 'config' }, { _enabledPlugins }, parentOptions, parentMongoOptions)
334
- // update other affected content objects to ensure new defaults are applied
335
- // note: due to the complex data, each must be updated separately rather than using updateMany
336
- if (types.length > 0) {
337
- const toUpdate = await super.find({ _courseId, _type: { $in: types } }, {})
338
- 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)
339
658
  }
340
659
  }
341
660
 
@@ -364,7 +683,10 @@ class ContentModule extends AbstractApiModule {
364
683
  try {
365
684
  await this.requestHook.invoke(req)
366
685
  const { _id, _parentId } = req.body
367
- 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 })
368
690
  await this.checkAccess(req, source)
369
691
 
370
692
  const customData = { ...req.body }