musora-content-services 2.158.2 → 2.159.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.
Files changed (54) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/.github/workflows/automated-testing.yml +21 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +21 -2
  5. package/jest.config.js +1 -4
  6. package/jest.integration.config.js +6 -0
  7. package/jest.live.config.js +1 -5
  8. package/package.json +5 -2
  9. package/src/contentTypeConfig.js +8 -5
  10. package/src/index.d.ts +2 -6
  11. package/src/index.js +2 -6
  12. package/src/services/content-org/learning-paths.ts +44 -39
  13. package/src/services/contentAggregator.js +1 -1
  14. package/src/services/contentProgress.js +216 -207
  15. package/src/services/offline/progress.ts +107 -27
  16. package/src/services/sanity.js +55 -64
  17. package/src/services/sync/models/ContentProgress.ts +50 -34
  18. package/src/services/sync/repositories/content-progress.ts +105 -92
  19. package/test/{unit → integration}/awards/award-exclusion-handling.test.ts +2 -2
  20. package/test/integration/content-progress/__mocks__/mocks.ts +104 -0
  21. package/test/integration/content-progress/contentProgress.test.ts +335 -0
  22. package/test/integration/content-progress/e2eOfflineProgress.test.ts +352 -0
  23. package/test/integration/content-progress/e2eProgress.test.ts +612 -0
  24. package/test/integration/content-progress/getters.test.ts +334 -0
  25. package/test/integration/content-progress/helpers.test.ts +263 -0
  26. package/test/integration/content-progress/offlineContentProgress.test.ts +226 -0
  27. package/test/integration/forums.test.ts +209 -0
  28. package/test/integration/initializeTestDB.ts +80 -0
  29. package/test/{unit → integration}/sync/fetch.test.ts +1 -1
  30. package/test/{unit → integration}/sync/repositories/content-likes.test.ts +1 -1
  31. package/test/{unit → integration}/sync/repositories/practices.test.ts +1 -1
  32. package/test/{unit → integration}/sync/repositories/progress.test.ts +1 -1
  33. package/test/{unit → integration}/sync/repositories/user-award-progress.test.ts +1 -1
  34. package/test/{unit → integration}/sync/store/cross-user-protection.test.ts +2 -2
  35. package/test/{unit → integration}/sync/store/store-idb.test.ts +2 -2
  36. package/test/{unit → integration}/sync/store/store.test.ts +2 -2
  37. package/test/unit/content-progress/bubbleTrickle.test.ts +322 -0
  38. package/test/unit/content-progress/helpers.test.ts +329 -0
  39. package/test/unit/content-progress/navigateTo.test.ts +381 -0
  40. package/test/unit/contentMetaData.test.ts +58 -0
  41. package/tools/generate-index.cjs +6 -3
  42. package/test/SKIPPED_TESTS.md +0 -151
  43. package/test/integration/content.test.js +0 -107
  44. package/test/integration/contentProgress.test.js +0 -73
  45. package/test/integration/forum.test.js +0 -16
  46. package/test/integration/sanityQueryService.test.js +0 -681
  47. package/test/unit/contentProgress.test.ts +0 -81
  48. /package/test/{unit → integration}/awards/internal/image-utils.test.ts +0 -0
  49. /package/test/{unit → integration}/infrastructure/FetchRequestExecutor.test.ts +0 -0
  50. /package/test/{unit → integration}/notifications.test.ts +0 -0
  51. /package/test/{unit → integration}/sync/adapters/idb-errors.test.ts +0 -0
  52. /package/test/{unit → integration}/sync/adapters/sqlite-errors.test.ts +0 -0
  53. /package/test/{unit → integration}/sync/repositories/user-award-progress.static.test.ts +0 -0
  54. /package/test/{unit → integration}/userActivity.test.ts +0 -0
@@ -1,16 +1,45 @@
1
- import { getHierarchy, getHierarchies } from './sanity.js'
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, onContentCompletedLearningPathActions } from './content-org/learning-paths.ts'
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 = ['_recordWatchSession', 'setStartedOrCompletedStatus', 'setStartedOrCompletedStatusMany', 'resetStatus']
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
- onlyIds = true,
338
- include = { aLaCarte: true, learningPaths: false },
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
- onlyIds = true,
346
- include = { aLaCarte: true, learningPaths: false },
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
- metadata = null,
361
- limit = null,
362
- include = { aLaCarte: true, learningPaths: false },
363
- onlyIds = true // need to be careful if allowing non-alacarte progress, because some content_ids can overlap
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
- metadata = null,
412
- limit = null,
413
- include = { aLaCarte: true, learningPaths: false },
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, {skipPush = false, skipBubbleTrickle = false} = {}) {
549
- collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
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
- export async function contentStatusReset(contentId, collection = null, {skipPush = false} = {}) {
558
- collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
559
- return resetStatus(contentId, collection, {skipPush})
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
- async function saveContentProgress(
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
- // filter out contentIds that are setting progress lower than existing
579
- const contentIdProgress = await getProgressDataByIds([contentId], collection)
580
- const currentProgress = contentIdProgress[contentId].progress
581
- if (progress <= currentProgress) {
582
- progress = currentProgress
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
- const exportIds = { [contentId]: progress }
592
- await duplicateProgressToALaCarte(exportIds, collection, {skipPush: true})
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
- // skip bubbling if progress hasnt changed, or if offline
608
- if (progress === currentProgress || isOffline) {
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
- const bubbledProgresses = await bubbleProgress(hierarchy, contentId, collection)
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
- for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
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
- if (progress === 100) await onContentCompletedLearningPathActions(contentId, collection)
660
+ await bubbleAndTrickleProgressesSafely(bubbledProgresses, collection, metadata, { accessedDirectly })
639
661
 
640
- for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
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(contentId, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false, skipBubbleTrickle = false } = {}) {
652
- const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
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
- if (!isOffline) {
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
- normalizeContentId(contentId),
662
- normalizeCollection(collection),
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, false)
707
+ await bubbleAndTrickleProgressesSafely(progresses, collection, metadata)
686
708
  }
687
709
 
688
- if (isLP) {
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, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
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 (!isOffline) {
711
- hierarchy = await getHierarchies(contentIds, collection)
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
- contents,
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
- ...trickleProgress(hierarchy, contentId, collection, progress),
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, false)
751
+ await bubbleAndTrickleProgressesSafely(progresses, collection, metadata)
742
752
 
743
- if (isLP) {
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, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
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
- let progresses = {
780
- ...trickleProgress(hierarchy, contentId, collection, progress),
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 bubbleAndTrickleProgressesSafely(progresses, collection, metadata, true)
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
- if (isLP) {
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
- async function duplicateProgressToALaCarte(progresses, collection, {skipPush = false} = {}) {
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 = filterGreaterThanProgress(filteredProgresses, externalProgresses)
824
+ filteredProgresses = filterOutNegativeProgress(filteredProgresses, externalProgresses)
805
825
 
806
- duplicateProgressForIds(filteredProgresses, skipPush)
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 filterGreaterThanProgress(progresses, external) {
823
- // overwrite if LP progress greater, unless LP progress was reset to 0
824
- return Object.entries(progresses).filter(([id, pct]) => {
825
- const extPct = external[id]?.progress
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, isResetAction) {
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
  }