adapt-authoring-content 3.0.5 → 3.1.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.
- package/lib/ContentModule.js +20 -15
- package/lib/utils/buildAssetUsagePipeline.js +29 -0
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/routes.json +23 -0
- package/tests/ContentModule.spec.js +12 -15
- package/tests/utils-buildAssetUsagePipeline.spec.js +48 -0
package/lib/ContentModule.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AbstractApiModule } from 'adapt-authoring-api'
|
|
|
2
2
|
import { Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
3
|
import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
4
|
import { ObjectId } from 'mongodb'
|
|
5
|
-
import { ContentTree, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
5
|
+
import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
6
6
|
/**
|
|
7
7
|
* Module which handles course content
|
|
8
8
|
* @memberof content
|
|
@@ -104,6 +104,25 @@ class ContentModule extends AbstractApiModule {
|
|
|
104
104
|
throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Returns a map of asset _id to the number of distinct courses each asset is referenced by.
|
|
109
|
+
* Reads the indexed `_assetIds` field. Accepts an optional `assetIds` array in the request body to
|
|
110
|
+
* scope the counts (e.g. the page of assets shown in the UI); assets with no usage are omitted.
|
|
111
|
+
* @param {external:ExpressRequest} req
|
|
112
|
+
* @param {external:ExpressResponse} res
|
|
113
|
+
* @param {function} next
|
|
114
|
+
* @return {Promise}
|
|
115
|
+
*/
|
|
116
|
+
async handleAssetUsage (req, res, next) {
|
|
117
|
+
try {
|
|
118
|
+
const assetIds = Array.isArray(req.body?.assetIds) ? req.body.assetIds.map(id => parseObjectId(id)) : undefined
|
|
119
|
+
const results = await this.mongodb.getCollection(this.collectionName).aggregate(buildAssetUsagePipeline(assetIds)).toArray()
|
|
120
|
+
res.json(Object.fromEntries(results.map(r => [r._id.toString(), r.courseCount])))
|
|
121
|
+
} catch (e) {
|
|
122
|
+
next(e)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
/** @override */
|
|
108
127
|
async getSchemaName (data) {
|
|
109
128
|
const { contentplugin } = this
|
|
@@ -460,19 +479,6 @@ class ContentModule extends AbstractApiModule {
|
|
|
460
479
|
friendlyIds.set(_type, { ids: await this.generateFriendlyIds(_type, newCourseId, count), next: 0 })
|
|
461
480
|
}))
|
|
462
481
|
|
|
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
482
|
// Build all insert payloads with pre-mapped IDs and parent references
|
|
477
483
|
const rootId = _id.toString()
|
|
478
484
|
const payloads = allItems.map(item => {
|
|
@@ -497,7 +503,6 @@ class ContentModule extends AbstractApiModule {
|
|
|
497
503
|
return stringifyValues({
|
|
498
504
|
...item,
|
|
499
505
|
_id: newId,
|
|
500
|
-
_trackingId: item._type === 'block' ? nextTrackingId++ : undefined,
|
|
501
506
|
_friendlyId: friendlyId,
|
|
502
507
|
_courseId: isCourse ? newId.toString() : newCourseId,
|
|
503
508
|
_parentId: newParentId,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the aggregation pipeline that counts, per asset, how many distinct courses reference it.
|
|
3
|
+
*
|
|
4
|
+
* Operates on the content collection's indexed `_assetIds` field (maintained on every content
|
|
5
|
+
* insert/update). The leading `$match` lets the query use the `_assetIds` index when scoped; the
|
|
6
|
+
* post-`$unwind` `$match` discards the other asset ids carried by matched documents so only the
|
|
7
|
+
* requested assets remain. Counting distinct `_courseId` via `$addToSet` (rather than documents)
|
|
8
|
+
* means an asset referenced by many content items within one course still counts as one course.
|
|
9
|
+
*
|
|
10
|
+
* Pure helper extracted from {@link ContentModule#handleAssetUsage} so it can be unit-tested without
|
|
11
|
+
* booting the app. Asset ids must already be coerced to ObjectId — `getCollection().aggregate()` is
|
|
12
|
+
* the raw driver and does not normalise ObjectId strings the way the module query layer does.
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} [assetIds] Asset ObjectIds to scope the counts to. Omit/empty to count all assets.
|
|
15
|
+
* @returns {Array<Object>} Aggregation pipeline producing `{ _id: <assetId>, courseCount }` rows
|
|
16
|
+
* @memberof content
|
|
17
|
+
*/
|
|
18
|
+
export default function buildAssetUsagePipeline (assetIds) {
|
|
19
|
+
const match = Array.isArray(assetIds) && assetIds.length > 0
|
|
20
|
+
? { $match: { _assetIds: { $in: assetIds } } }
|
|
21
|
+
: null
|
|
22
|
+
return [
|
|
23
|
+
...(match ? [match] : []),
|
|
24
|
+
{ $unwind: '$_assetIds' },
|
|
25
|
+
...(match ? [match] : []),
|
|
26
|
+
{ $group: { _id: '$_assetIds', courses: { $addToSet: '$_courseId' } } },
|
|
27
|
+
{ $project: { courseCount: { $size: '$courses' } } }
|
|
28
|
+
]
|
|
29
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as ContentTree } from './ContentTree.js'
|
|
2
|
+
export { default as buildAssetUsagePipeline } from './utils/buildAssetUsagePipeline.js'
|
|
2
3
|
export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
|
|
3
4
|
export { extractAssetIds } from './utils/extractAssetIds.js'
|
|
4
5
|
export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
|
package/package.json
CHANGED
package/routes.json
CHANGED
|
@@ -110,6 +110,29 @@
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"route": "/assetusage",
|
|
116
|
+
"handlers": { "post": "handleAssetUsage" },
|
|
117
|
+
"permissions": { "post": ["read:${scope}"] },
|
|
118
|
+
"meta": {
|
|
119
|
+
"post": {
|
|
120
|
+
"summary": "Count how many distinct courses each asset is used in",
|
|
121
|
+
"requestBody": {
|
|
122
|
+
"content": {
|
|
123
|
+
"application/json": {
|
|
124
|
+
"schema": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"assetIds": { "type": "array", "items": { "type": "string" }, "description": "Optional list of asset _ids to scope the counts to. Omit to count all assets." }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"responses": { "200": { "description": "Object mapping asset _id to the number of distinct courses referencing it; assets with no usage are omitted" } }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
113
136
|
}
|
|
114
137
|
]
|
|
115
138
|
}
|
|
@@ -517,15 +517,14 @@ describe('ContentModule', () => {
|
|
|
517
517
|
assert.ok(inst.postInsertHook.invoke.mock.callCount() > 0)
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
-
it('should
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
520
|
+
it('should delegate _trackingId assignment to preInsertHook (clone no longer allocates them)', async () => {
|
|
521
|
+
// Tracking IDs are owned by the spoortracking module, which taps preInsertHook. clone must
|
|
522
|
+
// fire that hook once per payload (so each cloned block can be assigned an id) and must not
|
|
523
|
+
// assign _trackingId itself. With no observer attached here, payloads pass through untouched.
|
|
524
524
|
const BLOCK2_OID = '507f1f77bcf86cd79943a001'
|
|
525
525
|
const BLOCK3_OID = '507f1f77bcf86cd79943a002'
|
|
526
526
|
|
|
527
527
|
const { inst, mongodb } = createCloneInstance()
|
|
528
|
-
inst.find = mock.fn(async () => [{ _trackingId: 7 }]) // existing max in course
|
|
529
528
|
|
|
530
529
|
const items = [
|
|
531
530
|
{ _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
|
|
@@ -539,18 +538,16 @@ describe('ContentModule', () => {
|
|
|
539
538
|
const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
|
|
540
539
|
await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, {}, { tree, parent })
|
|
541
540
|
|
|
541
|
+
// preInsertHook fired once per cloned payload — the seam the spoortracking observer uses.
|
|
542
|
+
// Cloning the page clones page + article + 3 blocks (5 items); the course is the source.
|
|
543
|
+
assert.equal(inst.preInsertHook.invoke.mock.callCount(), 5)
|
|
544
|
+
// every payload passed to the hook is a block/page/etc, and a block payload is present
|
|
545
|
+
const hookedTypes = inst.preInsertHook.invoke.mock.calls.map(c => c.arguments[0]._type)
|
|
546
|
+
assert.ok(hookedTypes.includes('block'))
|
|
547
|
+
// clone itself assigned nothing — block payloads still carry the (now irrelevant) source ids
|
|
542
548
|
const inserted = mongodb.collection.insertMany.mock.calls[0].arguments[0]
|
|
543
549
|
const blockTrackingIds = inserted.filter(d => d._type === 'block').map(d => d._trackingId)
|
|
544
|
-
assert.
|
|
545
|
-
for (const id of blockTrackingIds) {
|
|
546
|
-
assert.equal(typeof id, 'number', `block cloned without numeric _trackingId (got ${id})`)
|
|
547
|
-
}
|
|
548
|
-
// all distinct
|
|
549
|
-
assert.equal(new Set(blockTrackingIds).size, blockTrackingIds.length, 'duplicate _trackingId among cloned blocks')
|
|
550
|
-
// and continuing past the existing max
|
|
551
|
-
for (const id of blockTrackingIds) {
|
|
552
|
-
assert.ok(id > 7, `expected _trackingId > existing max (7), got ${id}`)
|
|
553
|
-
}
|
|
550
|
+
assert.deepEqual(blockTrackingIds.sort(), [5, 6, 7])
|
|
554
551
|
})
|
|
555
552
|
})
|
|
556
553
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import buildAssetUsagePipeline from '../lib/utils/buildAssetUsagePipeline.js'
|
|
4
|
+
|
|
5
|
+
const GROUP = { $group: { _id: '$_assetIds', courses: { $addToSet: '$_courseId' } } }
|
|
6
|
+
const PROJECT = { $project: { courseCount: { $size: '$courses' } } }
|
|
7
|
+
|
|
8
|
+
describe('buildAssetUsagePipeline', () => {
|
|
9
|
+
for (const [name, input] of [
|
|
10
|
+
['no argument', undefined],
|
|
11
|
+
['null', null],
|
|
12
|
+
['empty array', []]
|
|
13
|
+
]) {
|
|
14
|
+
it(`counts all assets when given ${name} (no $match, single $unwind)`, () => {
|
|
15
|
+
const pipeline = buildAssetUsagePipeline(input)
|
|
16
|
+
assert.deepEqual(pipeline, [
|
|
17
|
+
{ $unwind: '$_assetIds' },
|
|
18
|
+
GROUP,
|
|
19
|
+
PROJECT
|
|
20
|
+
])
|
|
21
|
+
assert.equal(pipeline.filter(s => s.$match).length, 0)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('scopes with a $match before and after $unwind when asset ids are given', () => {
|
|
26
|
+
const ids = ['id-a', 'id-b']
|
|
27
|
+
const pipeline = buildAssetUsagePipeline(ids)
|
|
28
|
+
const expectedMatch = { $match: { _assetIds: { $in: ids } } }
|
|
29
|
+
assert.deepEqual(pipeline, [
|
|
30
|
+
expectedMatch,
|
|
31
|
+
{ $unwind: '$_assetIds' },
|
|
32
|
+
expectedMatch,
|
|
33
|
+
GROUP,
|
|
34
|
+
PROJECT
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('places the pre-$unwind $match first so the _assetIds index can be used', () => {
|
|
39
|
+
const pipeline = buildAssetUsagePipeline(['id-a'])
|
|
40
|
+
assert.ok(pipeline[0].$match, 'first stage should be a $match')
|
|
41
|
+
assert.equal(pipeline[1].$unwind, '$_assetIds')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('counts distinct courses (uses $addToSet on _courseId, not a document count)', () => {
|
|
45
|
+
const group = buildAssetUsagePipeline().find(s => s.$group)
|
|
46
|
+
assert.deepEqual(group.$group.courses, { $addToSet: '$_courseId' })
|
|
47
|
+
})
|
|
48
|
+
})
|