musora-content-services 2.158.3 → 2.160.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/.github/workflows/automated-testing.yml +21 -1
- package/CHANGELOG.md +16 -0
- package/README.md +21 -2
- package/jest.config.js +1 -4
- package/jest.integration.config.js +6 -0
- package/jest.live.config.js +1 -5
- package/package.json +5 -2
- package/src/contentTypeConfig.js +8 -5
- package/src/index.d.ts +2 -6
- package/src/index.js +2 -6
- package/src/services/content-org/learning-paths.ts +44 -39
- package/src/services/contentProgress.js +216 -207
- package/src/services/offline/progress.ts +107 -27
- package/src/services/sanity.js +55 -64
- package/src/services/sync/manager.ts +5 -0
- package/src/services/sync/models/ContentProgress.ts +50 -34
- package/src/services/sync/repositories/content-progress.ts +105 -92
- package/src/services/sync/stale-record-cleanup.ts +66 -0
- package/test/{unit → integration}/awards/award-exclusion-handling.test.ts +2 -2
- package/test/integration/content-progress/__mocks__/mocks.ts +104 -0
- package/test/integration/content-progress/contentProgress.test.ts +335 -0
- package/test/integration/content-progress/e2eOfflineProgress.test.ts +352 -0
- package/test/integration/content-progress/e2eProgress.test.ts +612 -0
- package/test/integration/content-progress/getters.test.ts +334 -0
- package/test/integration/content-progress/helpers.test.ts +263 -0
- package/test/integration/content-progress/offlineContentProgress.test.ts +226 -0
- package/test/integration/forums.test.ts +209 -0
- package/test/integration/initializeTestDB.ts +80 -0
- package/test/{unit → integration}/sync/fetch.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/content-likes.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/practices.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/progress.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/user-award-progress.test.ts +1 -1
- package/test/{unit → integration}/sync/store/cross-user-protection.test.ts +2 -2
- package/test/{unit → integration}/sync/store/store-idb.test.ts +2 -2
- package/test/{unit → integration}/sync/store/store.test.ts +2 -2
- package/test/unit/content-progress/bubbleTrickle.test.ts +322 -0
- package/test/unit/content-progress/helpers.test.ts +329 -0
- package/test/unit/content-progress/navigateTo.test.ts +381 -0
- package/test/unit/contentMetaData.test.ts +58 -0
- package/test/unit/sync/stale-record-cleanup.test.ts +107 -0
- package/tools/generate-index.cjs +6 -3
- package/test/SKIPPED_TESTS.md +0 -151
- package/test/integration/content.test.js +0 -107
- package/test/integration/contentProgress.test.js +0 -73
- package/test/integration/forum.test.js +0 -16
- package/test/integration/sanityQueryService.test.js +0 -681
- package/test/unit/contentProgress.test.ts +0 -81
- /package/test/{unit → integration}/awards/internal/image-utils.test.ts +0 -0
- /package/test/{unit → integration}/infrastructure/FetchRequestExecutor.test.ts +0 -0
- /package/test/{unit → integration}/notifications.test.ts +0 -0
- /package/test/{unit → integration}/sync/adapters/idb-errors.test.ts +0 -0
- /package/test/{unit → integration}/sync/adapters/sqlite-errors.test.ts +0 -0
- /package/test/{unit → integration}/sync/repositories/user-award-progress.static.test.ts +0 -0
- /package/test/{unit → integration}/userActivity.test.ts +0 -0
|
@@ -1,16 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getHierarchies, getHierarchy } from './sanity.js'
|
|
2
2
|
import { db } from './sync'
|
|
3
3
|
import { COLLECTION_ID_SELF, COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
|
|
4
4
|
import { trackUserPractice } from './userActivity'
|
|
5
5
|
import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
|
|
6
|
-
import { getDailySession,
|
|
6
|
+
import { getDailySession, onLearningPathCompletedActions } from './content-org/learning-paths.ts'
|
|
7
|
+
import { duplicateProgressToALaCarteOffline } from './offline/progress.ts'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Exported functions that are excluded from index generation.
|
|
10
11
|
*
|
|
11
12
|
* @type {string[]}
|
|
12
13
|
*/
|
|
13
|
-
const excludeFromGeneratedIndex = [
|
|
14
|
+
const excludeFromGeneratedIndex = [
|
|
15
|
+
'_getAllStartedOrCompleted',
|
|
16
|
+
'_recordWatchSession',
|
|
17
|
+
'averageProgressesFor',
|
|
18
|
+
'bubbleAndTrickleProgressesSafely',
|
|
19
|
+
'bubbleProgress',
|
|
20
|
+
'buildNavigateTo',
|
|
21
|
+
'computeBubbleTrickleProgresses',
|
|
22
|
+
'duplicateProgressForIds',
|
|
23
|
+
'duplicateProgressToALaCarte',
|
|
24
|
+
'filterOutLearningPathsForDuplication',
|
|
25
|
+
'filterOutNegativeProgress',
|
|
26
|
+
'findIncompleteLesson',
|
|
27
|
+
'getAncestorAndSiblingIds',
|
|
28
|
+
'getById',
|
|
29
|
+
'getByIds',
|
|
30
|
+
'getByRecordIds',
|
|
31
|
+
'getChildrenToDepth',
|
|
32
|
+
'handleLearningPathProgressActions',
|
|
33
|
+
'normalizeCollection',
|
|
34
|
+
'normalizeContentId',
|
|
35
|
+
'normalizeContentIds',
|
|
36
|
+
'resetStatus',
|
|
37
|
+
'saveContentProgress',
|
|
38
|
+
'setStartedOrCompletedStatus',
|
|
39
|
+
'setStartedOrCompletedStatusMany',
|
|
40
|
+
'trackProgress',
|
|
41
|
+
'trickleProgress',
|
|
42
|
+
]
|
|
14
43
|
|
|
15
44
|
const STATE_STARTED = STATE.STARTED
|
|
16
45
|
const STATE_COMPLETED = STATE.COMPLETED
|
|
@@ -34,7 +63,7 @@ export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
|
|
|
34
63
|
normalizeContentIds(contentIds),
|
|
35
64
|
normalizeCollection(collection),
|
|
36
65
|
'resume_time_seconds',
|
|
37
|
-
0
|
|
66
|
+
0,
|
|
38
67
|
)
|
|
39
68
|
}
|
|
40
69
|
|
|
@@ -79,6 +108,7 @@ export async function getNavigateToForMethod(data) {
|
|
|
79
108
|
return incompleteId ? findChildById(content.children, incompleteId) : null
|
|
80
109
|
}
|
|
81
110
|
|
|
111
|
+
// todo(BEHSTP-325): consider de-nesting this logic with early returns, for code clarity.
|
|
82
112
|
// does not support passing in 'method-v2' type yet
|
|
83
113
|
if (content.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
84
114
|
let navigateTo = null
|
|
@@ -91,7 +121,7 @@ export async function getNavigateToForMethod(data) {
|
|
|
91
121
|
navigateTo = await getFirstOrIncompleteChild(content, collection)
|
|
92
122
|
}
|
|
93
123
|
|
|
94
|
-
navigateToData[content.id] =buildNavigateTo(navigateTo, null, collection)
|
|
124
|
+
navigateToData[content.id] = buildNavigateTo(navigateTo, null, collection)
|
|
95
125
|
|
|
96
126
|
} else {
|
|
97
127
|
navigateToData[content.id] = null
|
|
@@ -110,6 +140,7 @@ export async function getNavigateTo(data) {
|
|
|
110
140
|
// Skip null/undefined entries (can happen when GROQ dereference doesn't match filter)
|
|
111
141
|
if (!content) continue
|
|
112
142
|
|
|
143
|
+
// todo(BEHSTP-325): consider de-nesting this logic with early returns, for code clarity.
|
|
113
144
|
//only calculate nextLesson if needed, based on content type
|
|
114
145
|
if (!getNextLessonLessonParentTypes.includes(content.type) || !content.children) {
|
|
115
146
|
navigateToData[content.id] = null
|
|
@@ -143,6 +174,7 @@ export async function getNavigateTo(data) {
|
|
|
143
174
|
const lastInteractedStatus = childrenStates.get(lastInteracted)
|
|
144
175
|
|
|
145
176
|
if (['course', 'skill-pack', 'song-tutorial'].includes(content.type)) {
|
|
177
|
+
// todo(BEHSTP-325): remove if/else and make findIncompleteLesson able to return current lesson if `started`
|
|
146
178
|
if (lastInteractedStatus === STATE_STARTED) {
|
|
147
179
|
// send to last interacted
|
|
148
180
|
navigateToData[content.id] = buildNavigateTo(
|
|
@@ -188,7 +220,7 @@ export async function getNavigateTo(data) {
|
|
|
188
220
|
return navigateToData
|
|
189
221
|
}
|
|
190
222
|
|
|
191
|
-
function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
|
|
223
|
+
export function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
|
|
192
224
|
const isMap = progressOnItems instanceof Map
|
|
193
225
|
const ids = isMap ? Array.from(progressOnItems.keys()) : Object.keys(progressOnItems).map(Number)
|
|
194
226
|
const getProgress = (id) => isMap ? progressOnItems.get(id) : progressOnItems[id]
|
|
@@ -210,7 +242,7 @@ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
|
|
|
210
242
|
return ids[0]
|
|
211
243
|
}
|
|
212
244
|
|
|
213
|
-
function buildNavigateTo(content, child = null, collection = null) {
|
|
245
|
+
export function buildNavigateTo(content, child = null, collection = null) {
|
|
214
246
|
if (!content) {
|
|
215
247
|
return null
|
|
216
248
|
}
|
|
@@ -251,7 +283,7 @@ export async function getProgressDataByIds(contentIds, collection) {
|
|
|
251
283
|
progress: 0,
|
|
252
284
|
status: '',
|
|
253
285
|
},
|
|
254
|
-
])
|
|
286
|
+
]),
|
|
255
287
|
)
|
|
256
288
|
|
|
257
289
|
await db.contentProgress.getSomeProgressByContentIds(normalizeContentIds(contentIds), normalizeCollection(collection)).then((r) => {
|
|
@@ -303,14 +335,14 @@ export async function getProgressDataByRecordIds(ids) {
|
|
|
303
335
|
return progress
|
|
304
336
|
}
|
|
305
337
|
|
|
306
|
-
async function getById(contentId, collection, dataKey, defaultValue) {
|
|
338
|
+
export async function getById(contentId, collection, dataKey, defaultValue) {
|
|
307
339
|
if (!contentId) return defaultValue
|
|
308
340
|
return db.contentProgress
|
|
309
341
|
.getOneProgressByContentId(contentId, collection)
|
|
310
342
|
.then((r) => r.data?.[dataKey] ?? defaultValue)
|
|
311
343
|
}
|
|
312
344
|
|
|
313
|
-
async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
345
|
+
export async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
314
346
|
if (contentIds.length === 0) return new Map()
|
|
315
347
|
|
|
316
348
|
const progress = new Map(contentIds.map((id) => [id, defaultValue]))
|
|
@@ -322,7 +354,7 @@ async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
|
322
354
|
return progress
|
|
323
355
|
}
|
|
324
356
|
|
|
325
|
-
async function getByRecordIds(ids, dataKey, defaultValue) {
|
|
357
|
+
export async function getByRecordIds(ids, dataKey, defaultValue) {
|
|
326
358
|
const progress = Object.fromEntries(ids.map(id => [id, defaultValue]))
|
|
327
359
|
|
|
328
360
|
await db.contentProgress.getSomeProgressByRecordIds(ids).then(r => {
|
|
@@ -334,19 +366,19 @@ async function getByRecordIds(ids, dataKey, defaultValue) {
|
|
|
334
366
|
}
|
|
335
367
|
|
|
336
368
|
export async function getAllStarted(limit = null, {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
} = {}
|
|
369
|
+
onlyIds = true,
|
|
370
|
+
include = { aLaCarte: true, learningPaths: false },
|
|
371
|
+
} = {},
|
|
340
372
|
) {
|
|
341
|
-
return db.contentProgress.started(limit, {onlyIds, include})
|
|
373
|
+
return db.contentProgress.started(limit, { onlyIds, include })
|
|
342
374
|
}
|
|
343
375
|
|
|
344
376
|
export async function getAllCompleted(limit = null, {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
} = {}
|
|
377
|
+
onlyIds = true,
|
|
378
|
+
include = { aLaCarte: true, learningPaths: false },
|
|
379
|
+
} = {},
|
|
348
380
|
) {
|
|
349
|
-
return db.contentProgress.completed(limit, {onlyIds, include})
|
|
381
|
+
return db.contentProgress.completed(limit, { onlyIds, include })
|
|
350
382
|
}
|
|
351
383
|
|
|
352
384
|
export async function getAllCompletedByIds(contentIds) {
|
|
@@ -357,15 +389,15 @@ export async function getAllCompletedByIds(contentIds) {
|
|
|
357
389
|
* Fetches content **IDs** for items that were started or completed.
|
|
358
390
|
*/
|
|
359
391
|
export async function getAllStartedOrCompleted({
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
} = {}) {
|
|
392
|
+
metadata = null,
|
|
393
|
+
limit = null,
|
|
394
|
+
include = { aLaCarte: true, learningPaths: false },
|
|
395
|
+
onlyIds = true, // need to be careful if allowing non-alacarte progress, because some content_ids can overlap
|
|
396
|
+
} = {}) {
|
|
365
397
|
const data = await _getAllStartedOrCompleted({
|
|
366
398
|
metadata,
|
|
367
399
|
limit,
|
|
368
|
-
include
|
|
400
|
+
include,
|
|
369
401
|
})
|
|
370
402
|
return onlyIds
|
|
371
403
|
? data.map(rec => rec.content_id)
|
|
@@ -407,11 +439,11 @@ export async function getStartedOrCompletedProgressOnly({ brand = undefined } =
|
|
|
407
439
|
* @returns {Promise<any[]>}
|
|
408
440
|
* @private
|
|
409
441
|
*/
|
|
410
|
-
async function _getAllStartedOrCompleted({
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
} = {}) {
|
|
442
|
+
export async function _getAllStartedOrCompleted({
|
|
443
|
+
metadata = null,
|
|
444
|
+
limit = null,
|
|
445
|
+
include = { aLaCarte: true, learningPaths: false },
|
|
446
|
+
} = {}) {
|
|
415
447
|
const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
|
|
416
448
|
const baseFilters = {
|
|
417
449
|
updatedAfter: agoInSeconds,
|
|
@@ -491,7 +523,7 @@ export async function _recordWatchSession(
|
|
|
491
523
|
isLivestream = false,
|
|
492
524
|
isOffline = false,
|
|
493
525
|
hierarchy = null,
|
|
494
|
-
} = {}
|
|
526
|
+
} = {},
|
|
495
527
|
) {
|
|
496
528
|
contentId = normalizeContentId(contentId)
|
|
497
529
|
collection = normalizeCollection(collection)
|
|
@@ -512,7 +544,7 @@ async function trackPractice(contentId, secondsPlayed, details = {}) {
|
|
|
512
544
|
return trackUserPractice(contentId, secondsPlayed, details)
|
|
513
545
|
}
|
|
514
546
|
|
|
515
|
-
async function trackProgress(
|
|
547
|
+
export async function trackProgress(
|
|
516
548
|
contentId,
|
|
517
549
|
collection,
|
|
518
550
|
currentSeconds,
|
|
@@ -523,7 +555,7 @@ async function trackProgress(
|
|
|
523
555
|
) {
|
|
524
556
|
const progress = Math.max(1, Math.min(
|
|
525
557
|
99,
|
|
526
|
-
Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds)) * 100)
|
|
558
|
+
Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds)) * 100),
|
|
527
559
|
))
|
|
528
560
|
|
|
529
561
|
if (isLivestream) {
|
|
@@ -535,31 +567,36 @@ async function trackProgress(
|
|
|
535
567
|
}
|
|
536
568
|
|
|
537
569
|
export async function contentStatusCompleted(contentId, collection = null) {
|
|
538
|
-
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
570
|
+
collection = collection ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
539
571
|
return setStartedOrCompletedStatus(contentId, collection, true)
|
|
540
572
|
}
|
|
541
573
|
|
|
542
574
|
export async function contentStatusCompletedMany(contentIds, collection = null) {
|
|
543
|
-
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
575
|
+
collection = collection ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
544
576
|
return setStartedOrCompletedStatusMany(contentIds, collection, true)
|
|
545
577
|
}
|
|
546
578
|
|
|
547
579
|
// skipBubbleTrickle is only for starting enrolled GC's as a hack to get them into the progress row.
|
|
548
|
-
export async function contentStatusStarted(contentId, collection = null, {
|
|
549
|
-
|
|
580
|
+
export async function contentStatusStarted(contentId, collection = null, {
|
|
581
|
+
skipPush = false,
|
|
582
|
+
skipBubbleTrickle = false,
|
|
583
|
+
} = {}) {
|
|
584
|
+
collection = collection ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
550
585
|
return setStartedOrCompletedStatus(
|
|
551
586
|
normalizeContentId(contentId),
|
|
552
587
|
normalizeCollection(collection),
|
|
553
588
|
false,
|
|
554
|
-
{skipPush, skipBubbleTrickle}
|
|
589
|
+
{ skipPush, skipBubbleTrickle },
|
|
555
590
|
)
|
|
556
591
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
592
|
+
|
|
593
|
+
export async function contentStatusReset(contentId, collection = null, { skipPush = false } = {}) {
|
|
594
|
+
collection = collection ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
595
|
+
return resetStatus(contentId, collection, { skipPush })
|
|
560
596
|
}
|
|
561
597
|
|
|
562
|
-
|
|
598
|
+
// does not have an offline variant because it's too deeply nested within the watch session flow.
|
|
599
|
+
export async function saveContentProgress(
|
|
563
600
|
contentId,
|
|
564
601
|
collection,
|
|
565
602
|
progress,
|
|
@@ -569,17 +606,18 @@ async function saveContentProgress(
|
|
|
569
606
|
hierarchy = null,
|
|
570
607
|
skipPush = false,
|
|
571
608
|
accessedDirectly = true,
|
|
572
|
-
} = {}
|
|
609
|
+
} = {},
|
|
573
610
|
) {
|
|
574
|
-
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
575
|
-
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
611
|
+
collection = collection ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
576
612
|
const isPlaylist = collection?.type === COLLECTION_TYPE.PLAYLIST
|
|
577
613
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
614
|
+
let allProgresses = {}
|
|
615
|
+
allProgresses[contentId] = progress
|
|
616
|
+
|
|
617
|
+
const existingProgress = await getProgressDataByIds(Object.keys(allProgresses), collection)
|
|
618
|
+
allProgresses = filterOutNegativeProgress(allProgresses, existingProgress)
|
|
619
|
+
if (Object.keys(allProgresses).length === 0) {
|
|
620
|
+
return
|
|
583
621
|
}
|
|
584
622
|
|
|
585
623
|
if (!isOffline) {
|
|
@@ -588,8 +626,12 @@ async function saveContentProgress(
|
|
|
588
626
|
const metadata = hierarchy.metadata || {}
|
|
589
627
|
|
|
590
628
|
if (isPlaylist) {
|
|
591
|
-
|
|
592
|
-
|
|
629
|
+
if (isOffline) {
|
|
630
|
+
await duplicateProgressToALaCarteOffline(allProgresses, metadata, collection)
|
|
631
|
+
} else {
|
|
632
|
+
await duplicateProgressToALaCarte(allProgresses, collection)
|
|
633
|
+
}
|
|
634
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
593
635
|
return
|
|
594
636
|
}
|
|
595
637
|
|
|
@@ -599,262 +641,225 @@ async function saveContentProgress(
|
|
|
599
641
|
progress,
|
|
600
642
|
metadata[contentId],
|
|
601
643
|
currentSeconds,
|
|
602
|
-
{skipPush: true, accessedDirectly}
|
|
644
|
+
{ skipPush: true, accessedDirectly },
|
|
603
645
|
)
|
|
604
|
-
// note - previous implementation explicitly did not trickle progress to children here
|
|
605
|
-
// (only to siblings/parents via le bubbles)
|
|
606
646
|
|
|
607
|
-
|
|
608
|
-
|
|
647
|
+
if (isOffline) {
|
|
648
|
+
await duplicateProgressToALaCarteOffline(allProgresses, metadata, collection)
|
|
649
|
+
|
|
609
650
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
610
651
|
return response
|
|
611
652
|
}
|
|
612
653
|
|
|
613
|
-
|
|
654
|
+
let bubbledProgresses = await computeBubbleTrickleProgresses(contentId, progress, collection, hierarchy, { trickle: false })
|
|
655
|
+
Object.assign(allProgresses, bubbledProgresses)
|
|
614
656
|
|
|
615
|
-
// filter out contentIds that are setting progress lower than existing
|
|
616
657
|
const existingProgresses = await getProgressDataByIds(Object.keys(bubbledProgresses), collection)
|
|
617
|
-
|
|
618
|
-
if (bubbledProgress < existingProgresses[bubbledContentId].progress) {
|
|
619
|
-
delete bubbledProgresses[bubbledContentId]
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (Object.keys(bubbledProgresses).length > 0) {
|
|
624
|
-
await db.contentProgress.recordProgressMany(
|
|
625
|
-
bubbledProgresses,
|
|
626
|
-
normalizeCollection(collection),
|
|
627
|
-
metadata,
|
|
628
|
-
{skipPush: true, accessedDirectly})
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// there are problems if we allow downloading LPs, since we require 2 different hierarchies for this.
|
|
632
|
-
if (isLP) {
|
|
633
|
-
let exportIds = bubbledProgresses
|
|
634
|
-
exportIds[contentId] = progress
|
|
635
|
-
await duplicateProgressToALaCarte(exportIds, collection, {skipPush: true})
|
|
636
|
-
}
|
|
658
|
+
bubbledProgresses = filterOutNegativeProgress(bubbledProgresses, existingProgresses)
|
|
637
659
|
|
|
638
|
-
|
|
660
|
+
await bubbleAndTrickleProgressesSafely(bubbledProgresses, collection, metadata, { accessedDirectly })
|
|
639
661
|
|
|
640
|
-
|
|
641
|
-
if (bubbledProgress === 100) {
|
|
642
|
-
await onContentCompletedLearningPathActions(Number(bubbledContentId), collection)
|
|
643
|
-
}
|
|
644
|
-
}
|
|
662
|
+
await handleLearningPathProgressActions(allProgresses, collection)
|
|
645
663
|
|
|
646
664
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
647
665
|
|
|
648
666
|
return response
|
|
649
667
|
}
|
|
650
668
|
|
|
651
|
-
export async function setStartedOrCompletedStatus(
|
|
652
|
-
|
|
669
|
+
export async function setStartedOrCompletedStatus(
|
|
670
|
+
contentId,
|
|
671
|
+
collection,
|
|
672
|
+
isCompleted,
|
|
673
|
+
{
|
|
674
|
+
skipPush = false,
|
|
675
|
+
skipBubbleTrickle = false,
|
|
676
|
+
} = {},
|
|
677
|
+
) {
|
|
678
|
+
contentId = normalizeContentId(contentId)
|
|
679
|
+
collection = normalizeCollection(collection) ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
680
|
+
const isPlaylist = collection?.type === COLLECTION_TYPE.PLAYLIST
|
|
653
681
|
|
|
654
|
-
|
|
655
|
-
hierarchy = await getHierarchy(contentId, collection)
|
|
656
|
-
}
|
|
682
|
+
const hierarchy = await getHierarchy(contentId, collection)
|
|
657
683
|
const metadata = hierarchy.metadata || {}
|
|
658
684
|
|
|
659
685
|
const progress = isCompleted ? 100 : 0
|
|
686
|
+
let allProgresses = { [contentId]: progress }
|
|
687
|
+
|
|
688
|
+
if (isPlaylist) {
|
|
689
|
+
await duplicateProgressToALaCarte(allProgresses, collection)
|
|
690
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status')
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
|
|
660
694
|
const response = await db.contentProgress.recordProgress(
|
|
661
|
-
|
|
662
|
-
|
|
695
|
+
contentId,
|
|
696
|
+
collection,
|
|
663
697
|
progress,
|
|
664
698
|
metadata[contentId],
|
|
665
699
|
null,
|
|
666
|
-
{skipPush: true}
|
|
700
|
+
{ skipPush: true },
|
|
667
701
|
)
|
|
668
702
|
|
|
669
|
-
// skip bubbling if offline
|
|
670
|
-
if (isOffline) {
|
|
671
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
672
|
-
return response
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
let allProgresses = {}
|
|
676
|
-
allProgresses[contentId] = progress
|
|
677
|
-
|
|
678
703
|
if (!skipBubbleTrickle) {
|
|
679
|
-
let progresses =
|
|
680
|
-
...trickleProgress(hierarchy, contentId, collection, progress),
|
|
681
|
-
...await bubbleProgress(hierarchy, contentId, collection)
|
|
682
|
-
}
|
|
704
|
+
let progresses = await computeBubbleTrickleProgresses(contentId, progress, collection, hierarchy)
|
|
683
705
|
Object.assign(allProgresses, progresses)
|
|
684
706
|
|
|
685
|
-
await bubbleAndTrickleProgressesSafely(progresses, collection, metadata
|
|
707
|
+
await bubbleAndTrickleProgressesSafely(progresses, collection, metadata)
|
|
686
708
|
}
|
|
687
709
|
|
|
688
|
-
|
|
689
|
-
await duplicateProgressToALaCarte(allProgresses, collection, {skipPush: true})
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
for (const [id, prog] of Object.entries(allProgresses)) {
|
|
693
|
-
if (prog === 100) {
|
|
694
|
-
await onContentCompletedLearningPathActions(Number(id), collection)
|
|
695
|
-
}
|
|
696
|
-
}
|
|
710
|
+
await handleLearningPathProgressActions(allProgresses, collection)
|
|
697
711
|
|
|
698
712
|
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status')
|
|
699
713
|
|
|
700
714
|
return response
|
|
701
715
|
}
|
|
702
716
|
|
|
703
|
-
export async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {
|
|
717
|
+
export async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, { skipPush = false } = {}) {
|
|
704
718
|
contentIds = normalizeContentIds(contentIds)
|
|
705
|
-
collection = normalizeCollection(collection)
|
|
719
|
+
collection = normalizeCollection(collection) ?? { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF }
|
|
720
|
+
const isPlaylist = collection?.type === COLLECTION_TYPE.PLAYLIST
|
|
721
|
+
|
|
722
|
+
const hierarchies = await getHierarchies(contentIds, collection)
|
|
723
|
+
// need to get all metadata into one object
|
|
724
|
+
const metadata = Object.assign({}, ...Object.values(hierarchies).map(h => h.metadata))
|
|
706
725
|
|
|
707
|
-
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
708
726
|
const progress = isCompleted ? 100 : 0
|
|
727
|
+
let allProgresses = Object.fromEntries(contentIds.map(id => [id, progress]))
|
|
709
728
|
|
|
710
|
-
if (
|
|
711
|
-
|
|
729
|
+
if (isPlaylist) {
|
|
730
|
+
await duplicateProgressToALaCarte(allProgresses, collection)
|
|
731
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status-many')
|
|
732
|
+
return
|
|
712
733
|
}
|
|
713
|
-
const metadata = hierarchy.metadata || {}
|
|
714
734
|
|
|
715
|
-
const contents = Object.fromEntries(contentIds.map((id) => [id, progress]))
|
|
716
735
|
const response = await db.contentProgress.recordProgressMany(
|
|
717
|
-
|
|
736
|
+
allProgresses,
|
|
718
737
|
normalizeCollection(collection),
|
|
719
738
|
metadata,
|
|
720
|
-
{skipPush: true}
|
|
739
|
+
{ skipPush: true },
|
|
721
740
|
)
|
|
722
741
|
|
|
723
|
-
// skip bubbling if offline
|
|
724
|
-
if (isOffline) {
|
|
725
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
726
|
-
return response
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
let allProgresses = Object.fromEntries(contentIds.map(id => [id, progress]))
|
|
730
|
-
|
|
731
742
|
let progresses = {}
|
|
732
743
|
for (const contentId of contentIds) {
|
|
733
744
|
progresses = {
|
|
734
745
|
...progresses,
|
|
735
|
-
...
|
|
736
|
-
...(await bubbleProgress(hierarchy, contentId, collection)),
|
|
746
|
+
...await computeBubbleTrickleProgresses(contentId, progress, collection, hierarchies[contentId]),
|
|
737
747
|
}
|
|
738
748
|
}
|
|
739
749
|
Object.assign(allProgresses, progresses)
|
|
740
750
|
|
|
741
|
-
await bubbleAndTrickleProgressesSafely(progresses, collection, metadata
|
|
751
|
+
await bubbleAndTrickleProgressesSafely(progresses, collection, metadata)
|
|
742
752
|
|
|
743
|
-
|
|
744
|
-
await duplicateProgressToALaCarte(allProgresses, collection, {skipPush: true})
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
for (const [id, prog] of Object.entries(allProgresses)) {
|
|
748
|
-
if (prog === 100) {
|
|
749
|
-
await onContentCompletedLearningPathActions(Number(id), collection)
|
|
750
|
-
}
|
|
751
|
-
}
|
|
753
|
+
await handleLearningPathProgressActions(allProgresses, collection)
|
|
752
754
|
|
|
753
755
|
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status-many')
|
|
754
756
|
|
|
755
757
|
return response
|
|
756
758
|
}
|
|
757
759
|
|
|
758
|
-
export async function resetStatus(contentId, collection = null, {
|
|
760
|
+
export async function resetStatus(contentId, collection = null, { skipPush = false } = {}) {
|
|
759
761
|
contentId = normalizeContentId(contentId)
|
|
760
762
|
collection = normalizeCollection(collection)
|
|
761
763
|
|
|
762
|
-
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
763
|
-
|
|
764
764
|
const progress = 0
|
|
765
|
-
const response = await db.contentProgress.eraseProgress(normalizeContentId(contentId), normalizeCollection(collection), {skipPush: true})
|
|
766
|
-
|
|
767
|
-
// skip bubbling if offline
|
|
768
|
-
if (isOffline) {
|
|
769
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
770
|
-
return response
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
hierarchy = await getHierarchy(contentId, collection)
|
|
774
|
-
const metadata = hierarchy.metadata || {}
|
|
765
|
+
const response = await db.contentProgress.eraseProgress(normalizeContentId(contentId), normalizeCollection(collection), { skipPush: true })
|
|
775
766
|
|
|
776
767
|
let allProgresses = {}
|
|
777
768
|
allProgresses[contentId] = progress
|
|
778
769
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
...await bubbleProgress(hierarchy, contentId, collection)
|
|
782
|
-
}
|
|
783
|
-
Object.assign(allProgresses, progresses)
|
|
770
|
+
const hierarchy = await getHierarchy(contentId, collection)
|
|
771
|
+
const metadata = hierarchy.metadata || {}
|
|
784
772
|
|
|
785
|
-
await
|
|
773
|
+
let progresses = await computeBubbleTrickleProgresses(contentId, progress, collection, hierarchy)
|
|
774
|
+
Object.assign(allProgresses, progresses)
|
|
786
775
|
|
|
776
|
+
await bubbleAndTrickleProgressesSafely(progresses, collection, metadata, { isResetAction: true })
|
|
787
777
|
|
|
788
|
-
|
|
789
|
-
await duplicateProgressToALaCarte(allProgresses, collection, {skipPush: true})
|
|
790
|
-
}
|
|
778
|
+
await handleLearningPathProgressActions(allProgresses, collection)
|
|
791
779
|
|
|
792
780
|
if (!skipPush) db.contentProgress.requestPushUnsynced('reset-status')
|
|
793
781
|
|
|
794
782
|
return response
|
|
795
783
|
}
|
|
796
784
|
|
|
797
|
-
|
|
785
|
+
export function filterOutNegativeProgress(progresses, existingProgresses) {
|
|
786
|
+
return Object.fromEntries(
|
|
787
|
+
Object.entries(progresses).filter(
|
|
788
|
+
([id, progress]) => progress >= (existingProgresses[id]?.progress ?? 0),
|
|
789
|
+
),
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export async function computeBubbleTrickleProgresses(contentId, progress, collection, hierarchy, {
|
|
794
|
+
bubble = true,
|
|
795
|
+
trickle = true,
|
|
796
|
+
} = {}) {
|
|
797
|
+
return {
|
|
798
|
+
...trickle ? trickleProgress(hierarchy, contentId, collection, progress) : {},
|
|
799
|
+
...bubble ? (await bubbleProgress(hierarchy, contentId, collection)) : {},
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function handleLearningPathProgressActions(progresses, collection) {
|
|
804
|
+
if (collection?.type !== COLLECTION_TYPE.LEARNING_PATH) {
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
await duplicateProgressToALaCarte(progresses, collection)
|
|
809
|
+
|
|
810
|
+
for (const [id, prog] of Object.entries(progresses)) {
|
|
811
|
+
if (prog === 100 && Number(id) === collection?.id) {
|
|
812
|
+
await onLearningPathCompletedActions(Number(id))
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export async function duplicateProgressToALaCarte(progresses, collection) {
|
|
798
818
|
|
|
799
819
|
// a-la-cart LPs not set up.
|
|
800
820
|
let filteredProgresses = filterOutLearningPathsForDuplication(progresses, collection)
|
|
801
821
|
|
|
802
822
|
const externalProgresses = await getProgressDataByIds(Object.keys(filteredProgresses), null)
|
|
803
823
|
|
|
804
|
-
filteredProgresses =
|
|
824
|
+
filteredProgresses = filterOutNegativeProgress(filteredProgresses, externalProgresses)
|
|
805
825
|
|
|
806
|
-
duplicateProgressForIds(filteredProgresses
|
|
826
|
+
await duplicateProgressForIds(filteredProgresses)
|
|
807
827
|
}
|
|
808
828
|
|
|
809
|
-
function filterOutLearningPathsForDuplication(progresses, collection) {
|
|
829
|
+
export function filterOutLearningPathsForDuplication(progresses, collection) {
|
|
810
830
|
return Object.fromEntries(
|
|
811
831
|
Object.entries(progresses).filter(([id]) => {
|
|
812
832
|
if (collection.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
813
833
|
// dont want progress on a-la-carte LPs (not supported)
|
|
814
|
-
return id !== collection.id
|
|
834
|
+
return (+id) !== collection.id
|
|
815
835
|
} else {
|
|
816
836
|
return true
|
|
817
837
|
}
|
|
818
|
-
})
|
|
838
|
+
}),
|
|
819
839
|
)
|
|
820
840
|
}
|
|
821
841
|
|
|
822
|
-
function
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
return (pct !== 0)
|
|
827
|
-
? pct > extPct
|
|
828
|
-
: false
|
|
829
|
-
})
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
async function duplicateProgressForIds(ids, skipPush) {
|
|
833
|
-
ids.forEach(([id, pct], index) => {
|
|
834
|
-
let skip = true
|
|
835
|
-
if (index === ids.length - 1) {
|
|
836
|
-
// only allow push on last call, to group into one push
|
|
837
|
-
skip = skipPush
|
|
838
|
-
}
|
|
839
|
-
saveContentProgress(parseInt(id), null, pct, null, {skipPush: skip, accessedDirectly: false})
|
|
840
|
-
})
|
|
842
|
+
export async function duplicateProgressForIds(entries) {
|
|
843
|
+
return Promise.all(Object.entries(entries).map(([id, pct]) => {
|
|
844
|
+
return saveContentProgress(parseInt(id), null, pct, null, { skipPush: true, accessedDirectly: false })
|
|
845
|
+
}))
|
|
841
846
|
}
|
|
842
847
|
|
|
843
848
|
|
|
844
849
|
// agnostic to collection - makes returned data structure simpler,
|
|
845
850
|
// as long as callers remember to pass collection where needed
|
|
846
|
-
function trickleProgress(hierarchy, contentId, _collection, progress) {
|
|
851
|
+
export function trickleProgress(hierarchy, contentId, _collection, progress) {
|
|
847
852
|
const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
|
|
848
853
|
return Object.fromEntries(descendantIds.map((id) => [id, progress]))
|
|
849
854
|
}
|
|
850
855
|
|
|
851
|
-
async function bubbleProgress(hierarchy, contentId, collection = null) {
|
|
856
|
+
export async function bubbleProgress(hierarchy, contentId, collection = null) {
|
|
852
857
|
const ids = getAncestorAndSiblingIds(hierarchy, contentId)
|
|
853
858
|
const progresses = await getByIds(ids, collection, 'progress_percent', 0)
|
|
854
859
|
return averageProgressesFor(hierarchy, contentId, progresses)
|
|
855
860
|
}
|
|
856
861
|
|
|
857
|
-
function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
|
|
862
|
+
export function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
|
|
858
863
|
if (depth > MAX_DEPTH) return []
|
|
859
864
|
|
|
860
865
|
const parentId = hierarchy?.parents?.[contentId]
|
|
@@ -878,7 +883,7 @@ function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
|
|
|
878
883
|
|
|
879
884
|
// doesn't accept collection - assumes progresses are already filtered appropriately
|
|
880
885
|
// caller would do well to remember this, i doth say
|
|
881
|
-
function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
|
|
886
|
+
export function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
|
|
882
887
|
if (depth > MAX_DEPTH) return {}
|
|
883
888
|
|
|
884
889
|
const parentId = hierarchy?.parents?.[contentId]
|
|
@@ -898,7 +903,7 @@ function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
|
|
|
898
903
|
}
|
|
899
904
|
}
|
|
900
905
|
|
|
901
|
-
function getChildrenToDepth(parentId, hierarchy, depth = 1) {
|
|
906
|
+
export function getChildrenToDepth(parentId, hierarchy, depth = 1) {
|
|
902
907
|
let childIds = hierarchy.children[parentId] ?? []
|
|
903
908
|
let allChildrenIds = childIds
|
|
904
909
|
childIds.forEach((id) => {
|
|
@@ -907,43 +912,47 @@ function getChildrenToDepth(parentId, hierarchy, depth = 1) {
|
|
|
907
912
|
return allChildrenIds
|
|
908
913
|
}
|
|
909
914
|
|
|
910
|
-
async function bubbleAndTrickleProgressesSafely(progresses, collection, metadata,
|
|
915
|
+
export async function bubbleAndTrickleProgressesSafely(progresses, collection, metadata, {
|
|
916
|
+
isResetAction = false,
|
|
917
|
+
accessedDirectly = true,
|
|
918
|
+
} = {}) {
|
|
911
919
|
let eraseProgresses = {}
|
|
912
920
|
if (isResetAction) {
|
|
913
921
|
eraseProgresses = Object.fromEntries(
|
|
914
|
-
Object.entries(progresses).filter(([_, pct]) => pct === 0)
|
|
922
|
+
Object.entries(progresses).filter(([_, pct]) => pct === 0),
|
|
915
923
|
)
|
|
916
924
|
progresses = Object.fromEntries(
|
|
917
|
-
Object.entries(progresses).filter(([_, pct]) => pct > 0)
|
|
925
|
+
Object.entries(progresses).filter(([_, pct]) => pct > 0),
|
|
918
926
|
)
|
|
919
927
|
}
|
|
920
928
|
|
|
921
929
|
if (Object.keys(progresses).length > 0) {
|
|
930
|
+
// we allow regression for bubbling so parents can have progress lowered (for eg, if children are reset)
|
|
922
931
|
await db.contentProgress.recordProgressMany(
|
|
923
932
|
progresses,
|
|
924
933
|
normalizeCollection(collection),
|
|
925
934
|
metadata,
|
|
926
|
-
{skipPush: true}
|
|
935
|
+
{ skipPush: true, accessedDirectly, allowRegression: true },
|
|
927
936
|
)
|
|
928
937
|
}
|
|
929
938
|
if (Object.keys(eraseProgresses).length > 0) {
|
|
930
939
|
const eraseIds = Object.keys(eraseProgresses).map(Number)
|
|
931
|
-
await db.contentProgress.eraseProgressMany(normalizeContentIds(eraseIds), normalizeCollection(collection), {skipPush: true})
|
|
940
|
+
await db.contentProgress.eraseProgressMany(normalizeContentIds(eraseIds), normalizeCollection(collection), { skipPush: true })
|
|
932
941
|
}
|
|
933
942
|
}
|
|
934
943
|
|
|
935
|
-
function normalizeContentId(contentId) {
|
|
944
|
+
export function normalizeContentId(contentId) {
|
|
936
945
|
if (typeof contentId === 'string' && isNaN(+contentId)) {
|
|
937
946
|
throw new Error(`Invalid content id: ${contentId}`)
|
|
938
947
|
}
|
|
939
948
|
return typeof contentId === 'string' ? +contentId : contentId
|
|
940
949
|
}
|
|
941
950
|
|
|
942
|
-
function normalizeContentIds(contentIds) {
|
|
951
|
+
export function normalizeContentIds(contentIds) {
|
|
943
952
|
return contentIds.map((id) => normalizeContentId(id))
|
|
944
953
|
}
|
|
945
954
|
|
|
946
|
-
function normalizeCollection(collection) {
|
|
955
|
+
export function normalizeCollection(collection) {
|
|
947
956
|
if (!collection) return null
|
|
948
957
|
|
|
949
958
|
if (!Object.values(COLLECTION_TYPE).includes(collection.type)) {
|
|
@@ -985,7 +994,7 @@ export function extractFromRecordId(recordId) {
|
|
|
985
994
|
contentId,
|
|
986
995
|
collection: {
|
|
987
996
|
type: collectionType || COLLECTION_TYPE.SELF,
|
|
988
|
-
id: collectionId || COLLECTION_ID_SELF
|
|
989
|
-
}
|
|
997
|
+
id: collectionId || COLLECTION_ID_SELF,
|
|
998
|
+
},
|
|
990
999
|
}
|
|
991
1000
|
}
|