adapt-authoring-content/lib/ContentModule.js

import { AbstractApiModule } from 'adapt-authoring-api'
import { Hook, stringifyValues } from 'adapt-authoring-core'
import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
import { ObjectId } from 'mongodb'
import { ContentTree, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
/**
 * Module which handles course content
 * @memberof content
 * @extends {AbstractApiModule}
 */
class ContentModule extends AbstractApiModule {
  /** @override */
  async setValues () {
    await super.setValues()
    /** @ignore */ this.collectionName = this.schemaName = 'content'
    this.counterCollectionName = 'contentcounters'
  }

  /** @override */
  async init () {
    await super.init()
    /**
     * Hook invoked before content data is cloned
     * @type {Hook}
     */
    this.preCloneHook = new Hook({ mutable: true })
    /**
     * Hook invoked after content data is cloned
     * @type {Hook}
     */
    this.postCloneHook = new Hook()

    const [assets, authored, contentplugin, jsonschema, mongodb, tags] = await this.app.waitForModule('assets', 'authored', 'contentplugin', 'jsonschema', 'mongodb', 'tags')
    /** @ignore */ this.assets = assets
    /** @ignore */ this.contentplugin = contentplugin
    /** @ignore */ this.jsonschema = jsonschema
    /** @ignore */ this.mongodb = mongodb
    /** @ignore */ this.authored = authored
    /** @ignore */ this.tags = tags
    /** @ignore */ this._schemaCache = new Map()

    await authored.registerModule(this)
    await tags.registerModule(this)
    /**
     * we have to extend config specifically here because it doesn't use the default content schema
     */
    jsonschema.registerSchemasHook.tap(() => {
      this._schemaCache.clear()
      this.registerConfigSchemas()
      jsonschema.extendSchema('content', 'contentassets')
    })
    await this.registerConfigSchemas()
    jsonschema.extendSchema('content', 'contentassets')

    // bump course.updatedAt so tree endpoint If-Modified-Since invalidates
    this.postInsertHook.tap(this.touchCourse.bind(this))
    this.postUpdateHook.tap((_, doc) => this.touchCourse(doc))
    this.postDeleteHook.tap(this.touchCourse.bind(this))

    assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))

    await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
    await mongodb.setIndex(this.collectionName, { _parentId: 1 })
    await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
    await mongodb.setIndex(this.collectionName, { _assetIds: 1 })
    await mongodb.setIndex(this.collectionName, { _courseId: 1, _friendlyId: 1 }, {
      unique: true,
      partialFilterExpression: { _friendlyId: { $type: 'string', $gt: '' } }
    })
    await mongodb.setIndex(this.counterCollectionName, { _type: 1, _courseId: 1 }, { unique: true })
  }

  /**
   * Touches the parent course's updatedAt so the tree endpoint's If-Modified-Since check invalidates after any descendant content changes.
   * @param {Object} doc Content document that was inserted/updated/deleted
   * @return {Promise}
   */
  async touchCourse (doc) {
    if (!doc || doc._type === 'course' || !doc._courseId) return
    await this.mongodb.getCollection(this.collectionName).updateOne(
      { _id: parseObjectId(doc._courseId), _type: 'course' },
      { $set: { updatedAt: new Date() } }
    )
  }

  /**
   * Refuses asset deletion when the asset is referenced by content. Throws RESOURCE_IN_USE listing the affected course titles.
   * @param {Object} asset Asset document being deleted
   * @return {Promise}
   */
  async enforceAssetNotInUse (asset) {
    const usedBy = await this.find(
      { _assetIds: asset._id.toString() },
      { validate: false },
      { projection: { _courseId: 1 } }
    )
    if (!usedBy.length) return
    const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
    const courses = (await this.find(
      { _type: 'course', _id: { $in: courseIds } },
      { validate: false },
      { projection: { title: 1, displayTitle: 1 } }
    )).map(c => c.displayTitle || c.title)
    throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
  }

  /** @override */
  async getSchemaName (data) {
    const { contentplugin } = this
    let { _component, _id, _type } = data
    const defaultSchemaName = super.getSchemaName(data)

    if (_id && (!_type || !_component)) { // no explicit type, so look for record in the DB
      const item = await this.findOne({ _id }, { validate: false, throwOnMissing: false }, { projection: { _type: 1, _component: 1, _courseId: 1 } })
      if (item) {
        _type = item._type
        _component = item._component
        if (!data._courseId && item._courseId) data._courseId = item._courseId
      }
    }
    if (!_type && !_component) { // can't go any further, return default value
      return defaultSchemaName
    }
    if (_type !== 'component') {
      return contentTypeToSchemaName(_type)
    }
    const component = await contentplugin.findOne({ name: _component }, { validate: false, throwOnMissing: false })
    return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
  }

  /** @override */
  async getSchema (schemaName, data) {
    const { contentplugin, jsonschema } = this
    schemaName = await this.getSchemaName(data)
    const _courseId = data._courseId ??
      (data._id ? (await this.findOne({ _id: data._id }, { validate: false, throwOnMissing: false }, { projection: { _courseId: 1 } }))?._courseId : undefined)
    let enabledPluginSchemas = []
    if (_courseId) {
      const config = await this.findOne({ _type: 'config', _courseId }, { validate: false, throwOnMissing: false }, { projection: { _enabledPlugins: 1 } })
      const pluginList = config?._enabledPlugins ?? data?._enabledPlugins ?? []
      enabledPluginSchemas = pluginList.flatMap(p => contentplugin.getPluginSchemas(p))
    }
    const cacheKey = schemaName + ':' + enabledPluginSchemas.slice().sort().join(',')
    const cached = this._schemaCache.get(cacheKey)
    if (cached) return cached

    const schema = await jsonschema.getSchema(schemaName, {
      useCache: false,
      extensionFilter: s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
    })
    this._schemaCache.set(cacheKey, schema)
    return schema
  }

  /**
   * Adds config schema extensions
   */
  registerConfigSchemas () {
    this.jsonschema.extendSchema('config', this.authored.schemaName)
    this.jsonschema.extendSchema('config', this.tags.schemaExtensionName)
  }

  /**
   * Generates multiple unique friendly IDs for a given type in a single atomic counter increment.
   * @param {String} _type Content type (e.g. 'page', 'block', 'component')
   * @param {String} _courseId The course these items belong to
   * @param {Number} count Number of IDs to generate
   * @param {String} [_language] Language code (only used for courses)
   * @return {Promise<Array<String>>}
   */
  async generateFriendlyIds (_type, _courseId, count, _language) {
    if (count === 0) return []
    if (_type === 'config') return [formatFriendlyId(_type)]

    const counters = this.mongodb.getCollection(this.counterCollectionName)
    const query = { _type }
    if (_type !== 'course') {
      query._courseId = parseObjectId(_courseId)
    }
    // Seed the counter from existing content on first use
    const exists = await counters.findOne(query)
    if (!exists) {
      const maxSeq = await this.findMaxSeq(_type, _courseId)
      await counters.updateOne(query, { $setOnInsert: { seq: maxSeq } }, { upsert: true })
    }
    // Atomically reserve a range of sequence numbers
    const counter = await counters.findOneAndUpdate(
      query,
      { $inc: { seq: count } },
      { returnDocument: 'after' }
    )
    const startSeq = counter.seq - count + 1
    return Array.from({ length: count }, (_, i) => {
      return formatFriendlyId(_type, startSeq + i, _language)
    })
  }

  /**
   * Finds the current max sequence number from existing content (for counter seeding)
   * @param {String} _type
   * @param {String} _courseId
   * @return {Promise<Number>}
   */
  async findMaxSeq (_type, _courseId) {
    const collection = this.mongodb.getCollection(this.collectionName)
    const query = { _type, _friendlyId: { $exists: true, $ne: '' } }
    if (_type !== 'course') {
      query._courseId = parseObjectId(_courseId)
    }
    const docs = await collection.find(query, { projection: { _friendlyId: 1 } }).toArray()
    return parseMaxSeq(docs)
  }

  /**
   * Removes counter documents for deleted courses
   * @param {Array<String>} courseIds
   * @return {Promise}
   */
  async deleteCounters (courseIds) {
    const counters = this.mongodb.getCollection(this.counterCollectionName)
    const objectIds = courseIds.map(id => parseObjectId(id))
    await counters.deleteMany({ _courseId: { $in: objectIds } })
  }

  /**
   * Computes the _assetIds array for a content document
   * @param {Object} doc Full content document
   * @return {Promise<Array<String>>} Unique asset IDs found in the doc
   */
  async computeAssetIds (doc) {
    const schema = await this.getSchema(this.schemaName, doc)
    return extractAssetIds(schema, doc)
  }

  /** @override */
  async insert (data, options = {}, mongoOptions = {}) {
    if (!data._friendlyId) {
      const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
      data._friendlyId = id
    }
    if (!data._assetIds) {
      data._assetIds = await this.computeAssetIds(data)
    }
    let doc
    try {
      doc = await super.insert(data, options, mongoOptions)
    } catch (e) {
      if (e.code === this.app.errors.MONGO_DUPL_INDEX?.code) {
        throw this.app.errors.DUPL_FRIENDLY_ID.setData({ _friendlyId: data._friendlyId, _courseId: data._courseId })
      }
      throw e
    }

    if (doc._type === 'course') { // add the _courseId to a new course to make querying easier
      return this.update({ _id: doc._id }, { _courseId: doc._id.toString() })
    }
    await Promise.all([
      options.updateSortOrder !== false && this.updateSortOrder(doc, data, options, mongoOptions),
      options.updateEnabledPlugins !== false && this.updateEnabledPlugins(doc, {}, options, mongoOptions)
    ])
    return doc
  }

  /** @override */
  async update (query, data, options, mongoOptions) {
    let doc
    try {
      doc = await super.update(query, data, options, mongoOptions)
    } catch (e) {
      if (e.code === this.app.errors.MONGO_DUPL_INDEX?.code) {
        throw this.app.errors.DUPL_FRIENDLY_ID.setData({ _friendlyId: data._friendlyId, _courseId: data._courseId })
      }
      throw e
    }
    // Recompute _assetIds from the full merged document. Cast to ObjectId so
    // the stored array matches the canonical insert-path format (and mongodb
    // queries, which auto-convert 24-hex strings to ObjectId, can match it).
    const newAssetIds = (await this.computeAssetIds(doc)).map(id => parseObjectId(id))
    const oldAssetIds = doc._assetIds ?? []
    if (newAssetIds.length !== oldAssetIds.length ||
      !newAssetIds.every((id, i) => id.toString() === oldAssetIds[i]?.toString())) {
      const collection = this.mongodb.getCollection(this.collectionName)
      await collection.updateOne({ _id: doc._id }, { $set: { _assetIds: newAssetIds } })
      doc._assetIds = newAssetIds
    }
    const sortChanged = '_sortOrder' in data || '_parentId' in data
    const pluginsChanged = '_component' in data || '_menu' in data || '_theme' in data || '_enabledPlugins' in data
    await Promise.all([
      sortChanged && this.updateSortOrder(doc, data, options, mongoOptions),
      pluginsChanged && this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {}, options, mongoOptions)
    ])
    return doc
  }

  /** @override */
  async delete (query, options = {}, mongoOptions) {
    this.setDefaultOptions(options)

    const targetDoc = await this.findOne(query)
    // @note super.find to avoid hooks etc. for performance purposes
    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 } }))
    const descendants = tree.getDescendants(targetDoc._id)
    if (targetDoc._type === 'course' && tree.config) {
      descendants.push(tree.config)
    }

    const deletedIds = new Set([targetDoc, ...descendants].map(d => d._id.toString()))
    // bulk-delete descendants via raw mongodb to avoid per-item memory overhead and hook storms;
    // postDeleteHook is invoked once below with the full descendants list
    if (descendants.length > 0) {
      const mongodb = await this.app.waitForModule('mongodb')
      await mongodb.deleteMany(this.collectionName, { _id: { $in: descendants.map(d => d._id) } }, mongoOptions)
    }
    // delete target via super.delete to trigger deleteHook middleware (e.g. multilang)
    await super.delete({ _id: targetDoc._id }, options, mongoOptions)
    if (descendants.length > 0 && options.invokePostHook !== false) {
      await this.postDeleteHook.invoke(descendants)
    }
    const remainingTree = new ContentTree(tree.items.filter(i => !deletedIds.has(i._id.toString())))
    await Promise.all([
      options.updateEnabledPlugins !== false && this.updateEnabledPlugins(targetDoc, { tree: remainingTree }, options, mongoOptions),
      options.updateSortOrder !== false && this.updateSortOrder(targetDoc, undefined, options, mongoOptions),
      targetDoc._type === 'course' && this.deleteCounters([targetDoc._courseId])
    ])
    return [targetDoc, ...descendants]
  }

  /**
   * Creates a new parent content type, along with any necessary children
   * @param {external:ExpressRequest} req
   */
  async insertRecursive (req) {
    const rootId = req.apiData.query.rootId
    const createdBy = req.auth.user._id.toString()
    let childTypes = ['course', 'page', 'article', 'block', 'component']
    const defaultData = {
      page: { title: req.translate('app.newpagetitle') },
      article: { title: req.translate('app.newarticletitle') },
      block: { title: req.translate('app.newblocktitle') },
      component: {
        _component: 'adapt-contrib-text',
        _layout: 'full',
        title: req.translate('app.newtextcomponenttitle'),
        body: req.translate('app.newtextcomponentbody')
      }
    }
    const newItems = []
    let parent
    let parentIsNew = false
    try {
      // figure out which children need creating
      if (rootId === undefined) { // new course
        parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
        newItems.push(parent)
        childTypes.splice(0, 1, 'config')
        parentIsNew = true
      } else {
        parent = await this.findOne({ _id: rootId })
        // special case for menus
        req.body?._type === 'menu'
          ? childTypes.splice(0, 1, 'menu')
          : childTypes = childTypes.slice(childTypes.indexOf(parent._type) + 1)
      }
      for (const _type of childTypes) {
        const data = Object.assign({ _type, createdBy }, defaultData[_type])
        if (parent) {
          Object.assign(data, {
            _parentId: parent._id.toString(),
            _courseId: parent._courseId.toString()
          })
        }
        // Inner items are the first child of a parent we just created, so
        // _sortOrder is always 1. For the first iteration against an existing
        // parent we leave _sortOrder unset — updateSortOrder(topItem) places it.
        if (parentIsNew && _type !== 'config') data._sortOrder = 1
        const item = await this.insert(data, { updateSortOrder: false, updateEnabledPlugins: false })
        newItems.push(item)
        if (_type !== 'config') {
          parent = item
          parentIsNew = true
        }
      }
    } catch (e) {
      await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
      throw e
    }
    // run side effects once for the topmost new item
    const topItem = newItems[0]
    await Promise.all([
      this.updateSortOrder(topItem, topItem),
      this.updateEnabledPlugins(topItem, { forceUpdate: true })
    ])
    return topItem
  }

  /**
   * Clones a content item and all its descendants in a single bulk operation.
   * Pre-generates all _id values and friendly IDs, then inserts everything in parallel.
   * @param {String} userId The user performing the action
   * @param {String} _id ID of the object to clone
   * @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
   * @param {Object} customData Data to be applied to the root content item
   * @param {Object} options
   * @param {ContentTree} options.tree Pre-built tree to avoid a DB query
   * @param {Object} options.parent Pre-fetched parent doc to avoid redundant lookup
   * @return {Promise<Object>} The cloned root item
   */
  async clone (userId, _id, _parentId, customData = {}, options = {}) {
    let { tree, parent } = options

    const originalDoc = tree
      ? tree.getById(_id)
      : await this.findOne({ _id })
    if (!originalDoc) {
      throw this.app.errors.NOT_FOUND
        .setData({ type: 'content', id: _id })
    }

    if (!parent && _parentId) {
      parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } })
    }
    if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
      throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
    }
    if (!tree) {
      const sourceItems = await this.mongodb.find(this.collectionName, { _courseId: originalDoc._courseId })
      tree = new ContentTree(sourceItems)
    }

    // Collect all items to clone: root, config (if course clone), then all descendants
    const allItems = [originalDoc]
    if (originalDoc._type === 'course' && tree.config) {
      allItems.push(tree.config)
    }
    allItems.push(...tree.getDescendants(_id))

    if (options.invokePreHook !== false) {
      for (const item of allItems) await this.preCloneHook.invoke(item)
    }

    // Pre-generate ObjectIds for every item (old _id → new _id)
    const idMap = new Map()
    for (const item of allItems) {
      idMap.set(item._id.toString(), createObjectId())
    }

    const newCourseId = originalDoc._type === 'course'
      ? idMap.get(originalDoc._id.toString()).toString()
      : (parent?._type === 'course' ? parent._id.toString() : parent._courseId.toString())

    // Pre-allocate friendly IDs in bulk per type
    const typeCounts = new Map()
    for (const item of allItems) {
      if (item._type === 'course' || item._type === 'config') continue
      typeCounts.set(item._type, (typeCounts.get(item._type) ?? 0) + 1)
    }
    // type → { ids, next } — bundle the cursor with the array so the payload loop is O(n) (Array#shift would be O(n²))
    const friendlyIds = new Map()
    await Promise.all([...typeCounts].map(async ([_type, count]) => {
      friendlyIds.set(_type, { ids: await this.generateFriendlyIds(_type, newCourseId, count), next: 0 })
    }))

    // Pre-allocate sequential _trackingId for cloned blocks. Bulk insertMany
    // defeats SpoorTrackingModule's preInsertHook (which reads the current max
    // from the DB per-block), so without this every cloned block would get the
    // same id.
    const blockCount = typeCounts.get('block') ?? 0
    let nextTrackingId
    if (blockCount > 0) {
      const [{ _trackingId: maxTrackingId = 0 } = {}] = await this.find(
        { _courseId: newCourseId }, {}, { limit: 1, sort: [['_trackingId', -1]] }
      )
      nextTrackingId = maxTrackingId + 1
    }

    // Build all insert payloads with pre-mapped IDs and parent references
    const rootId = _id.toString()
    const payloads = allItems.map(item => {
      const oldId = item._id.toString()
      const newId = idMap.get(oldId)
      const isCourse = item._type === 'course'
      const isConfig = item._type === 'config'

      let newParentId
      if (oldId === rootId) newParentId = _parentId
      else if (isConfig) newParentId = undefined
      else newParentId = idMap.get(item._parentId?.toString())?.toString()

      let friendlyId
      if (isCourse) friendlyId = item._friendlyId
      else if (isConfig) friendlyId = formatFriendlyId('config')
      else {
        const queue = friendlyIds.get(item._type)
        friendlyId = queue?.ids[queue.next++]
      }

      return stringifyValues({
        ...item,
        _id: newId,
        _trackingId: item._type === 'block' ? nextTrackingId++ : undefined,
        _friendlyId: friendlyId,
        _courseId: isCourse ? newId.toString() : newCourseId,
        _parentId: newParentId,
        createdBy: userId,
        ...(oldId === rootId ? customData : {})
      })
    })

    // Fire preInsertHook on each payload (allows observer modules to set timestamps etc.)
    await Promise.all(payloads.map(payload =>
      this.preInsertHook.invoke(payload, { schemaName: contentTypeToSchemaName(payload._type), collectionName: this.collectionName }, {})
    ))

    // Convert known ID fields to ObjectId instances and bulk insert in a single round-trip
    const allNewIds = allItems.map(item => idMap.get(item._id.toString()))
    for (let i = 0; i < payloads.length; i++) {
      const payload = payloads[i]
      payload._id = allNewIds[i]
      if (payload._courseId) payload._courseId = new ObjectId(payload._courseId)
      if (payload._parentId) payload._parentId = new ObjectId(payload._parentId)
      if (payload.createdBy) payload.createdBy = new ObjectId(payload.createdBy)
    }

    const collection = this.mongodb.getCollection(this.collectionName)
    try {
      await collection.insertMany(payloads, { ordered: false })
    } catch (e) {
      await collection.deleteMany({ _id: { $in: allNewIds } }).catch(() => {})
      throw e
    }

    // payloads (post-convertObjectIds) are the stored documents — no find-back needed
    await Promise.all(payloads.map(doc => this.postInsertHook.invoke(doc)))

    if (options.invokePostHook !== false) {
      for (let i = 0; i < allItems.length; i++) {
        await this.postCloneHook.invoke(allItems[i], payloads[i])
      }
    }
    if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
      await this.updateEnabledPlugins(payloads[0])
    }

    return payloads[0]
  }

  /**
   * Recalculates the _sortOrder values for all content items affected by an update
   * @param {Object} item The existing item data
   * @param {Object} updateData The update data
   * @return {Promise}
   */
  async updateSortOrder (item, updateData, parentOptions, parentMongoOptions) {
    // some exceptions which don't need a _sortOrder
    if (item._type === 'config' || item._type === 'course' || !item._parentId) {
      return
    }
    const siblings = await super.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 }, projection: { _id: 1, _sortOrder: 1 } })
    const ops = computeSortOrderOps(siblings, updateData ? item : undefined)
    if (ops.length > 0) {
      const collection = this.mongodb.getCollection(this.collectionName)
      return collection.bulkWrite(ops, { ordered: false })
    }
  }

  /**
   * Maintains the list of plugins used in the current course
   * @param {Object} item The updated item
   * @param {Object} options
   * @param {Boolean} options.forceUpdate Forces an update of defaults regardless of whether the _enabledPlugins list has changed
   * @param {ContentTree} options.tree Pre-built tree to avoid redundant full-course fetch
   * @return {Promise}
   */
  async updateEnabledPlugins ({ _courseId, _type }, options = {}, parentOptions, parentMongoOptions) {
    // skip types that can never affect the plugin list (e.g. page, article).
    if (options.forceUpdate !== true && _type && _type !== 'component' && _type !== 'config') {
      return
    }
    const { contentplugin, jsonschema } = this
    const tree = options.tree ?? new ContentTree(await super.find({ _courseId }, {}, { projection: { _id: 1, _type: 1, _component: 1, _enabledPlugins: 1, _menu: 1, _theme: 1 } }))
    const config = tree.config

    if (!config) {
      return // can't continue if there's no config to update
    }
    const currentPlugins = new Set(config._enabledPlugins ?? [])
    const extensionNames = (await contentplugin.find({ type: 'extension' }, {}, { projection: { _id: 0, name: 1 } })).map(p => p.name)
    const componentNames = tree.getComponentNames()
    // generate unique list of used plugins
    const nextPlugins = new Set([
      ...[...currentPlugins].filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
      ...componentNames,
      config._menu,
      config._theme
    ].filter(Boolean))
    if (options.forceUpdate !== true &&
      currentPlugins.size === nextPlugins.size &&
      [...currentPlugins].every(p => nextPlugins.has(p))) {
      return // return early if the lists already match
    }
    // generate list of content types that need defaults applied for newly added plugins
    const newPluginSchemas = [...nextPlugins]
      .filter(p => options.forceUpdate || !currentPlugins.has(p))
      .flatMap(p => contentplugin.getPluginSchemas(p))

    const affectedTypes = new Set()
    for (const schemaName of newPluginSchemas) {
      const rawSchema = jsonschema.schemas[schemaName]?.raw
      const ref = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
      for (const t of (ref === 'contentobject' ? ['menu', 'page'] : [ref])) {
        if (t) affectedTypes.add(t)
      }
    }
    const _enabledPlugins = [...nextPlugins]
    // update config._enabledPlugins
    await super.update({ _courseId, _type: 'config' }, { _enabledPlugins }, parentOptions, parentMongoOptions)
    // empty update re-validates to apply new defaults; ignoreRequired because some plugins
    // declare top-level required properties with no default (e.g. adapt-contrib-glossary)
    if (affectedTypes.size > 0) {
      const toUpdate = await super.find({ _courseId, _type: { $in: [...affectedTypes] } }, {}, { projection: { _id: 1 } })
      return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {}, { ...parentOptions, ignoreRequired: true }, parentMongoOptions)))
    }
  }

  /**
   * Returns a lightweight projection of all content items for a course
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async handleTree (req, res, next) {
    try {
      const _courseId = req.apiData.query._courseId
      const course = await this.findOne(
        { _type: 'course', _courseId },
        { validate: false },
        { projection: { updatedAt: 1 } }
      )
      const lastModified = new Date(course.updatedAt)
      lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
      const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
      if (ifModifiedSince && lastModified <= ifModifiedSince) {
        return res.status(304).end()
      }
      const items = await this.find(
        { _courseId },
        { validate: false },
        { 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 } }
      )
      const tree = new ContentTree(items)
      res.set('Last-Modified', lastModified.toUTCString())
      res.json(items.map(item => ({
        ...item,
        _children: tree.getChildren(item._id).map(c => c._id)
      })))
    } catch (e) {
      return next(e)
    }
  }

  /**
   * Special request handler for bootstrapping a new content object with dummy content
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async handleInsertRecursive (req, res, next) {
    try {
      res.status(201).json(await this.insertRecursive(req))
    } catch (e) {
      return next(e)
    }
  }

  /**
   * Request handler for cloning content items
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   * @return {Promise} Resolves with the cloned data
   */
  async handleClone (req, res, next) {
    try {
      await this.requestHook.invoke(req)
      const { _id, _parentId } = req.body
      if (!_id) {
        throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _id })
      }
      const source = await this.findOne({ _id })
      await this.checkAccess(req, source)

      const customData = { ...req.body }
      delete customData._id
      delete customData._parentId

      const newData = await this.clone(req.auth.user._id, _id, _parentId, customData)
      res.status(201).json(newData)
    } catch (e) {
      return next(e)
    }
  }
}

export default ContentModule