musora-content-services 2.89.0 → 2.92.3

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 (139) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Forums.html +2 -2
  4. package/docs/Gamification.html +2 -2
  5. package/docs/TestUser.html +2 -2
  6. package/docs/UserManagementSystem.html +2 -2
  7. package/docs/api_types.js.html +2 -2
  8. package/docs/config.js.html +2 -2
  9. package/docs/content-org_content-org.js.html +2 -2
  10. package/docs/content-org_guided-courses.ts.html +2 -2
  11. package/docs/content-org_learning-paths.ts.html +52 -40
  12. package/docs/content-org_playlists-types.js.html +2 -2
  13. package/docs/content-org_playlists.js.html +2 -2
  14. package/docs/content.js.html +2 -2
  15. package/docs/content_artist.ts.html +2 -2
  16. package/docs/content_genre.ts.html +2 -2
  17. package/docs/content_instructor.ts.html +2 -2
  18. package/docs/forums_categories.ts.html +2 -2
  19. package/docs/forums_forums.ts.html +2 -2
  20. package/docs/forums_posts.ts.html +2 -2
  21. package/docs/forums_threads.ts.html +2 -2
  22. package/docs/gamification_awards.ts.html +2 -2
  23. package/docs/gamification_gamification.js.html +2 -2
  24. package/docs/global.html +2 -2
  25. package/docs/index.html +2 -2
  26. package/docs/liveTesting.ts.html +2 -2
  27. package/docs/module-Accounts.html +2 -2
  28. package/docs/module-Artist.html +2 -2
  29. package/docs/module-Awards.html +2 -2
  30. package/docs/module-Config.html +2 -2
  31. package/docs/module-Content-Services-V2.html +2 -2
  32. package/docs/module-Forums.html +2 -2
  33. package/docs/module-Genre.html +2 -2
  34. package/docs/module-GuidedCourses.html +2 -2
  35. package/docs/module-Instructor.html +2 -2
  36. package/docs/module-Interests.html +2 -2
  37. package/docs/module-LearningPaths.html +269 -143
  38. package/docs/module-Onboarding.html +3 -3
  39. package/docs/module-Payments.html +2 -2
  40. package/docs/module-Permissions.html +2 -2
  41. package/docs/module-Playlists.html +2 -2
  42. package/docs/module-ProgressRow.html +2 -2
  43. package/docs/module-Railcontent-Services.html +34 -893
  44. package/docs/module-Sanity-Services.html +2 -2
  45. package/docs/module-Sessions.html +2 -2
  46. package/docs/module-UserActivity.html +70 -116
  47. package/docs/module-UserChat.html +2 -2
  48. package/docs/module-UserManagement.html +2 -2
  49. package/docs/module-UserMemberships.html +2 -2
  50. package/docs/module-UserNotifications.html +2 -2
  51. package/docs/module-UserProfile.html +2 -2
  52. package/docs/progress-row_method-card.js.html +3 -2
  53. package/docs/railcontent.js.html +14 -137
  54. package/docs/sanity.js.html +2 -2
  55. package/docs/userActivity.js.html +85 -150
  56. package/docs/user_account.ts.html +2 -2
  57. package/docs/user_chat.js.html +2 -2
  58. package/docs/user_interests.js.html +2 -2
  59. package/docs/user_management.js.html +2 -2
  60. package/docs/user_memberships.ts.html +2 -2
  61. package/docs/user_notifications.js.html +2 -2
  62. package/docs/user_onboarding.ts.html +10 -6
  63. package/docs/user_payments.ts.html +2 -2
  64. package/docs/user_permissions.js.html +2 -2
  65. package/docs/user_profile.js.html +2 -2
  66. package/docs/user_sessions.js.html +2 -2
  67. package/docs/user_types.js.html +2 -2
  68. package/docs/user_user-management-system.js.html +2 -2
  69. package/package.json +11 -3
  70. package/src/contentTypeConfig.js +6 -0
  71. package/src/index.d.ts +7 -31
  72. package/src/index.js +10 -34
  73. package/src/services/content-org/learning-paths.ts +31 -0
  74. package/src/services/contentAggregator.js +2 -2
  75. package/src/services/contentLikes.js +6 -39
  76. package/src/services/contentProgress.js +181 -479
  77. package/src/services/dataContext.js +0 -2
  78. package/src/services/progress-row/method-card.js +1 -0
  79. package/src/services/railcontent.js +12 -135
  80. package/src/services/sentry/.indexignore +0 -0
  81. package/src/services/sentry/index.ts +23 -0
  82. package/src/services/sync/.indexignore +0 -0
  83. package/src/services/sync/adapters/factory.ts +26 -0
  84. package/src/services/sync/adapters/lokijs.ts +1 -0
  85. package/src/services/sync/adapters/sqlite.ts +1 -0
  86. package/src/services/sync/concurrency-safety.ts +4 -0
  87. package/src/services/sync/context/index.ts +43 -0
  88. package/src/services/sync/context/providers/base.ts +4 -0
  89. package/src/services/sync/context/providers/connectivity.ts +14 -0
  90. package/src/services/sync/context/providers/durability.ts +5 -0
  91. package/src/services/sync/context/providers/index.ts +5 -0
  92. package/src/services/sync/context/providers/session.ts +8 -0
  93. package/src/services/sync/context/providers/tabs.ts +18 -0
  94. package/src/services/sync/context/providers/visibility.ts +14 -0
  95. package/src/services/sync/database/factory.ts +10 -0
  96. package/src/services/sync/errors/boundary.ts +45 -0
  97. package/src/services/sync/errors/index.ts +49 -0
  98. package/src/services/sync/fetch.ts +310 -0
  99. package/src/services/sync/index.ts +80 -0
  100. package/src/services/sync/manager.ts +139 -0
  101. package/src/services/sync/models/Base.ts +47 -0
  102. package/src/services/sync/models/ContentLike.ts +16 -0
  103. package/src/services/sync/models/ContentProgress.ts +69 -0
  104. package/src/services/sync/models/Practice.ts +72 -0
  105. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  106. package/src/services/sync/models/index.ts +4 -0
  107. package/src/services/sync/repositories/base.ts +247 -0
  108. package/src/services/sync/repositories/content-likes.ts +26 -0
  109. package/src/services/sync/repositories/content-progress.ts +160 -0
  110. package/src/services/sync/repositories/index.ts +4 -0
  111. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  112. package/src/services/sync/repositories/practices.ts +52 -0
  113. package/src/services/sync/repository-proxy.ts +48 -0
  114. package/src/services/sync/resolver.ts +84 -0
  115. package/src/services/sync/retry.ts +88 -0
  116. package/src/services/sync/run-scope.ts +30 -0
  117. package/src/services/sync/schema/index.ts +66 -0
  118. package/src/services/sync/serializers/index.ts +2 -0
  119. package/src/services/sync/serializers/model.ts +32 -0
  120. package/src/services/sync/serializers/raw.ts +21 -0
  121. package/src/services/sync/store/index.ts +779 -0
  122. package/src/services/sync/store/push-coalescer.ts +57 -0
  123. package/src/services/sync/store-configs.ts +41 -0
  124. package/src/services/sync/strategies/base.ts +21 -0
  125. package/src/services/sync/strategies/index.ts +12 -0
  126. package/src/services/sync/strategies/initial.ts +11 -0
  127. package/src/services/sync/strategies/polling.ts +54 -0
  128. package/src/services/sync/telemetry/index.ts +140 -0
  129. package/src/services/sync/telemetry/sampling.ts +91 -0
  130. package/src/services/sync/utils/event-emitter.ts +24 -0
  131. package/src/services/sync/utils/index.ts +1 -0
  132. package/src/services/sync/utils/throttle.ts +93 -0
  133. package/src/services/sync/utils/timers.ts +9 -0
  134. package/src/services/userActivity.js +83 -148
  135. package/test/contentProgress.test.js +6 -39
  136. package/test/live/contentProgressLive.test.js +2 -31
  137. package/tools/generate-index.cjs +10 -4
  138. package/.claude/settings.local.json +0 -8
  139. package/babel.config.cjs +0 -3
@@ -1,54 +1,23 @@
1
- import {
2
- fetchContentProgress,
3
- postContentComplete,
4
- postContentReset,
5
- postContentStart,
6
- postRecordWatchSession,
7
- } from './railcontent.js'
8
- import { DataContext, ContentProgressVersionKey } from './dataContext.js'
9
- import {
10
- fetchHierarchy,
11
- fetchMethodV2StructureFromId
12
- } from './sanity.js'
13
- import { recordUserPractice, findIncompleteLesson } from './userActivity'
1
+ import { fetchHierarchy } from './sanity.js'
2
+ import { db } from './sync'
3
+ import { STATE } from './sync/models/ContentProgress'
4
+ import { trackUserPractice, findIncompleteLesson } from './userActivity'
14
5
  import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
15
6
 
16
- const STATE_STARTED = 'started'
17
- const STATE_COMPLETED = 'completed'
18
- const DATA_KEY_STATUS = 's'
19
- const DATA_KEY_PROGRESS = 'p'
20
- const DATA_KEY_RESUME_TIME = 't'
21
- const DATA_KEY_LAST_UPDATED_TIME = 'u'
22
- const DATA_KEY_BRAND = 'b'
23
- const DATA_KEY_COLLECTION = 'c'
24
- const DATA_CONTENT_ID = 'i'
7
+ const STATE_STARTED = STATE.STARTED
8
+ const STATE_COMPLETED = STATE.COMPLETED
9
+ const MAX_DEPTH = 3
25
10
 
26
- export let dataContext = new DataContext(ContentProgressVersionKey, fetchContentProgress)
27
-
28
- let sessionData = []
29
-
30
- export async function getProgressPercentage(contentId, collection = null) {
31
- return getById(contentId, collection, DATA_KEY_PROGRESS, 0)
32
- }
33
-
34
- export async function getProgressPercentageByIds(contentIds, collection = null) {
35
- return getByIds(contentIds, collection, DATA_KEY_PROGRESS, 0)
36
- }
37
-
38
- export async function getProgressState(contentId, collection = null) {
39
- return getById(contentId, collection, DATA_KEY_STATUS, '')
11
+ export async function getProgressState(contentId) {
12
+ return getById(contentId, 'state', '')
40
13
  }
41
14
 
42
15
  export async function getProgressStateByIds(contentIds, collection = null) {
43
- return getByIds(contentIds, collection, DATA_KEY_STATUS, '')
44
- }
45
-
46
- export async function getResumeTimeSeconds(contentId, collection = null) {
47
- return getById(contentId, collection, DATA_KEY_RESUME_TIME, 0)
16
+ return getByIds(contentIds, collection, 'state', '')
48
17
  }
49
18
 
50
19
  export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
51
- return getByIds(contentIds, collection, DATA_KEY_RESUME_TIME, 0)
20
+ return getByIds(contentIds, collection, 'resume_time_seconds', 0)
52
21
  }
53
22
 
54
23
  export async function getNavigateTo(data, collection = null) {
@@ -82,7 +51,7 @@ export async function getNavigateTo(data, collection = null) {
82
51
  const contentState = await getProgressState(content.id, collection)
83
52
  if (contentState !== STATE_STARTED) {
84
53
  const firstChild = validChildren[0]
85
- let lastInteractedChildNavToData = await getNavigateTo([firstChild])
54
+ let lastInteractedChildNavToData = await getNavigateTo([firstChild], collection)
86
55
  lastInteractedChildNavToData = lastInteractedChildNavToData[firstChild.id] ?? null
87
56
  navigateToData[content.id] = buildNavigateTo(firstChild, lastInteractedChildNavToData, collection) //no G-child for LP
88
57
  } else {
@@ -109,7 +78,7 @@ export async function getNavigateTo(data, collection = null) {
109
78
  if (childrenStates[lastInteractedChildId] === STATE_COMPLETED) {
110
79
  // TODO: packs have an extra situation where we need to jump to the next course if all lessons in the last engaged course are completed
111
80
  }
112
- let lastInteractedChildNavToData = await getNavigateTo(firstChildren)
81
+ let lastInteractedChildNavToData = await getNavigateTo(firstChildren, collection)
113
82
  lastInteractedChildNavToData = lastInteractedChildNavToData[lastInteractedChildId]
114
83
  navigateToData[content.id] = buildNavigateTo(
115
84
  children.get(lastInteractedChildId),
@@ -147,154 +116,89 @@ function buildNavigateTo(content, child = null, collection = null) {
147
116
  * @returns {Promise<number>}
148
117
  */
149
118
  export async function getLastInteractedOf(contentIds, collection = null) {
150
- const data = await getByIds(contentIds, collection, DATA_KEY_LAST_UPDATED_TIME, 0)
151
- const sorted = Object.keys(data)
152
- .map(function (key) {
153
- return parseInt(key)
154
- })
155
- .sort(function (a, b) {
156
- let v1 = data[a]
157
- let v2 = data[b]
158
- if (v1 > v2) return -1
159
- else if (v1 < v2) return 1
160
- return 0
119
+ return db.contentProgress.mostRecentlyUpdatedId(contentIds, collection).then(r => r.data ? parseInt(r.data) : undefined)
120
+ }
121
+
122
+ export async function getProgressDataByIds(contentIds, collection) {
123
+ const progress = Object.fromEntries(contentIds.map(id => [id, {
124
+ last_update: 0,
125
+ progress: 0,
126
+ status: '',
127
+ }]))
128
+
129
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
130
+ r.data.forEach(p => {
131
+ progress[p.content_id] = {
132
+ last_update: p.updated_at,
133
+ progress: p.progress_percent,
134
+ status: p.state,
135
+ }
161
136
  })
162
-
163
- return sorted[0]
164
- }
165
-
166
- export async function getProgressDateByIds(contentIds, collection = null) {
167
- let data = await dataContext.getData()
168
- let progress = {}
169
- contentIds?.forEach((id) => {
170
- const key = generateRecordKey(id, collection)
171
- progress[id] = {
172
- last_update: data[key]?.[DATA_KEY_LAST_UPDATED_TIME] ?? 0,
173
- progress: data[key]?.[DATA_KEY_PROGRESS] ?? 0,
174
- status: data[key]?.[DATA_KEY_STATUS] ?? '',
175
- }
176
137
  })
138
+
177
139
  return progress
178
140
  }
179
141
 
180
- async function getById(contentId, collection, dataKey, defaultValue) {
181
- let data = await dataContext.getData()
182
- const contentKey = generateRecordKey(contentId, collection)
183
- return data[contentKey]?.[dataKey] ?? defaultValue
142
+ async function getById(contentId, dataKey, defaultValue) {
143
+ if (!contentId) return defaultValue
144
+ return db.contentProgress.getOneProgressByContentId(contentId).then(r => r.data?.[dataKey] ?? defaultValue)
184
145
  }
185
146
 
186
147
  async function getByIds(contentIds, collection, dataKey, defaultValue) {
187
- let data = await dataContext.getData()
188
- let progress = {}
189
- contentIds?.forEach((id) => (progress[id] = data[generateRecordKey(id, collection)]?.[dataKey] ?? defaultValue))
148
+ const progress = Object.fromEntries(contentIds.map(id => [id, defaultValue]))
149
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
150
+ r.data.forEach(p => {
151
+ progress[p.content_id] = p[dataKey] ?? defaultValue
152
+ })
153
+ })
190
154
  return progress
191
155
  }
192
156
 
193
- export async function getAllStarted(limit = null, collection = null) {
194
- const data = await dataContext.getData()
195
-
196
- let ids = Object.keys(data)
197
- .filter(function (id) {
198
- const key = generateRecordKey(id, collection)
199
- return data[key][DATA_KEY_STATUS] === STATE_STARTED
200
- })
201
- .map(function (id) {
202
- return parseInt(id)
203
- })
204
- .sort(function (a, b) {
205
- let v1 = data[a][DATA_KEY_LAST_UPDATED_TIME]
206
- let v2 = data[b][DATA_KEY_LAST_UPDATED_TIME]
207
- if (v1 > v2) return -1
208
- else if (v1 < v2) return 1
209
- return 0
210
- })
211
- if (limit) {
212
- ids = ids.slice(0, limit)
213
- }
214
- return ids
157
+ export async function getAllStarted(limit = null) {
158
+ return db.contentProgress.startedIds(limit).then(r => r.data.map(id => parseInt(id)))
215
159
  }
216
160
 
217
- export async function getAllCompleted(limit = null, collection = null) {
218
- const data = await dataContext.getData()
161
+ export async function getAllCompleted(limit = null) {
162
+ return db.contentProgress.completedIds(limit).then(r => r.data.map(id => parseInt(id)))
163
+ }
219
164
 
220
- let ids = Object.keys(data)
221
- .filter(function (id) {
222
- const key = generateRecordKey(id, collection)
223
- return data[key][DATA_KEY_STATUS] === STATE_COMPLETED
224
- })
225
- .map(function (id) {
226
- return parseInt(id)
227
- })
228
- .sort(function (a, b) {
229
- let v1 = data[a][DATA_KEY_LAST_UPDATED_TIME]
230
- let v2 = data[b][DATA_KEY_LAST_UPDATED_TIME]
231
- if (v1 > v2) return -1
232
- else if (v1 < v2) return 1
233
- return 0
234
- })
235
- if (limit) {
236
- ids = ids.slice(0, limit)
237
- }
238
- return ids
165
+ /**
166
+ *
167
+ * @param {array} contentIds List of content children within learning path
168
+ * @param {object} collection Learning path object
169
+ * @returns {Promise<array>} Filtered list of contentIds that are completed
170
+ */
171
+ export async function getAllCompletedByIds(contentIds, collection) {
172
+ // TODO - implement collection filtering
173
+ return db.contentProgress.queryAllIds(
174
+ Q.whereIn('content_id', contentIds),
175
+ Q.where('state', STATE_COMPLETED)
176
+ )
239
177
  }
240
178
 
241
179
  export async function getAllStartedOrCompleted({
242
- limit = null,
243
180
  onlyIds = true,
244
181
  brand = null,
245
- excludedIds = [],
246
- collection = null,
182
+ limit = null
247
183
  } = {}) {
248
- const data = await dataContext.getData()
249
- const oneMonthAgoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
250
-
251
- const excludedSet = new Set(excludedIds.map((id) => parseInt(id))) // ensure IDs are numbers
252
- let filtered = Object.entries(data)
253
- .filter(([key, item]) => {
254
- const isRelevantStatus =
255
- item[DATA_KEY_STATUS] === STATE_STARTED || item[DATA_KEY_STATUS] === STATE_COMPLETED
256
- const isRecent = item[DATA_KEY_LAST_UPDATED_TIME] >= oneMonthAgoInSeconds
257
- const isCorrectBrand = !brand || !item.b || item.b === brand
258
- const isNotExcluded = !excludedSet.has(extractContentIdFromRecordKey(key))
259
- const matchesCollection =
260
- (!collection && !item[DATA_KEY_COLLECTION]) ||
261
- (item[DATA_KEY_COLLECTION]?.type === collection?.type &&
262
- item[DATA_KEY_COLLECTION]?.id === collection?.id)
263
- return matchesCollection && isRelevantStatus && isCorrectBrand && isNotExcluded
264
- })
265
- .sort(([, a], [, b]) => {
266
- const v1 = a[DATA_KEY_LAST_UPDATED_TIME]
267
- const v2 = b[DATA_KEY_LAST_UPDATED_TIME]
268
- if (v1 > v2) return -1
269
- else if (v1 < v2) return 1
270
- return 0
271
- })
272
- //maps to content_id
273
- .reduce((acc, [key, item]) => {
274
- const newKey = extractContentIdFromRecordKey(key)
275
- acc[newKey] = item
276
- return acc
277
- }, {})
278
-
279
- if (limit) {
280
- filtered = Object.fromEntries(Object.entries(filtered).slice(0, limit))
184
+ const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
185
+ const filters = {
186
+ brand: brand ?? undefined,
187
+ updatedAfter: agoInSeconds,
188
+ limit: limit ?? undefined,
281
189
  }
282
190
 
283
191
  if (onlyIds) {
284
- return Object.entries(filtered).map(([key, data]) => parseInt(key))
192
+ return db.contentProgress.startedOrCompletedIds(filters).then(r => r.data.map(id => parseInt(id)))
285
193
  } else {
286
- const progress = {}
287
- Object.entries(filtered).forEach(([key, item]) => {
288
- const id = parseInt(key)
289
- progress[id] = {
290
- last_update: item?.[DATA_KEY_LAST_UPDATED_TIME] ?? 0,
291
- progress: item?.[DATA_KEY_PROGRESS] ?? 0,
292
- status: item?.[DATA_KEY_STATUS] ?? '',
293
- collection: item?.[DATA_KEY_COLLECTION],
294
- brand: item?.b ?? '',
295
- }
194
+ return db.contentProgress.startedOrCompleted(filters).then(r => {
195
+ return Object.fromEntries(r.data.map(p => [p.content_id, {
196
+ last_update: p.updated_at,
197
+ progress: p.progress_percent,
198
+ status: p.state,
199
+ brand: p.content_brand,
200
+ }]))
296
201
  })
297
- return progress
298
202
  }
299
203
  }
300
204
 
@@ -315,371 +219,169 @@ export async function getAllStartedOrCompleted({
315
219
  * const progressMap = await getStartedOrCompletedProgressOnly({ brand: 'drumeo' });
316
220
  * console.log(progressMap[123]); // => 52
317
221
  */
318
- export async function getStartedOrCompletedProgressOnly({
319
- brand = null,
320
- collection = null
321
- } = {}) {
322
- const data = await dataContext.getData()
323
- const result = {}
324
-
325
- Object.entries(data).forEach(([key, item]) => {
326
- const id = extractContentIdFromRecordKey(key)
327
- const isRelevantStatus =
328
- item[DATA_KEY_STATUS] === STATE_STARTED || item[DATA_KEY_STATUS] === STATE_COMPLETED
329
- const isCorrectBrand = !brand || item.b === brand
330
- const matchesCollection =
331
- (!collection && !item[DATA_KEY_COLLECTION]) ||
332
- (item[DATA_KEY_COLLECTION]?.type === collection?.type &&
333
- item[DATA_KEY_COLLECTION]?.id === collection?.id)
334
-
335
- if (matchesCollection && isRelevantStatus && isCorrectBrand) {
336
- result[id] = item?.[DATA_KEY_PROGRESS] ?? 0
337
- }
222
+ export async function getStartedOrCompletedProgressOnly({ brand = undefined } = {}) {
223
+ return db.contentProgress.startedOrCompleted({ brand: brand }).then(r => {
224
+ return Object.fromEntries(r.data.map(p => [p.content_id, p.progress_percent]))
338
225
  })
339
-
340
- return result
341
- }
342
-
343
- export async function contentStatusCompleted(contentId, collection = null) {
344
- return await dataContext.update(
345
- async function (localContext) {
346
- let hierarchy = await getContentHierarchy(contentId, collection)
347
- completeStatusInLocalContext(localContext, contentId, hierarchy, collection)
348
- },
349
- async function () {
350
- return postContentComplete(contentId, collection)
351
- }
352
- )
353
- }
354
- export async function contentStatusStarted(contentId, collection = null) {
355
- return await dataContext.update(
356
- async function (localContext) {
357
- let hierarchy = await getContentHierarchy(contentId, collection)
358
- startStatusInLocalContext(localContext, contentId, hierarchy, collection)
359
- },
360
- async function () {
361
- return postContentStart(contentId, collection)
362
- }
363
- )
364
- }
365
-
366
- function saveContentProgress(localContext, contentId, progress, currentSeconds, hierarchy, collection = null) {
367
- if (progress === 100) {
368
- completeStatusInLocalContext(localContext, contentId, hierarchy, collection)
369
- return
370
- }
371
-
372
- const key = generateRecordKey(contentId, collection)
373
- let data = localContext.data[key] ?? {}
374
- const currentProgress = data[DATA_KEY_STATUS]
375
- if (!currentProgress || currentProgress !== STATE_COMPLETED) {
376
- data[DATA_KEY_PROGRESS] = progress
377
- data[DATA_KEY_STATUS] = STATE_STARTED
378
- }
379
- data[DATA_KEY_RESUME_TIME] = currentSeconds
380
- data[DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
381
- localContext.data[key] = data
382
-
383
- bubbleProgress(hierarchy, contentId, localContext)
384
- }
385
-
386
- function completeStatusInLocalContext(localContext, contentId, hierarchy, collection = null) {
387
- setStartedOrCompletedStatusInLocalContext(localContext, contentId, true, hierarchy, collection)
388
- }
389
-
390
- function startStatusInLocalContext(localContext, contentId, hierarchy, collection = null) {
391
- setStartedOrCompletedStatusInLocalContext(localContext, contentId, false, hierarchy, collection)
392
- }
393
-
394
- function setStartedOrCompletedStatusInLocalContext(
395
- localContext,
396
- contentId,
397
- isCompleted,
398
- hierarchy,
399
- collection = null
400
- ) {
401
- const key = generateRecordKey(contentId, collection)
402
- let data = localContext.data[key] ?? {}
403
- data[DATA_KEY_PROGRESS] = isCompleted ? 100 : 0
404
- data[DATA_KEY_STATUS] = isCompleted ? STATE_COMPLETED : STATE_STARTED
405
- data[DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
406
- data[DATA_KEY_COLLECTION] = collection
407
- data[DATA_CONTENT_ID] = contentId
408
-
409
- localContext.data[key] = data
410
-
411
- if (!hierarchy) return
412
-
413
- if (collection && collection.type === 'learning-path-v2') {
414
- bubbleOrTrickleLearningPathProgress(hierarchy, contentId, localContext, isCompleted, collection)
415
- return
416
- }
417
-
418
- let children = hierarchy.children[contentId] ?? []
419
- for (let i = 0; i < children.length; i++) {
420
- let childId = children[i]
421
- setStartedOrCompletedStatusInLocalContext(localContext, childId, isCompleted, hierarchy)
422
- }
423
- bubbleProgress(hierarchy, contentId, localContext)
424
- }
425
-
426
- function getChildrenToDepth(parentId, hierarchy, depth = 1) {
427
- let childIds = hierarchy.children[parentId] ?? []
428
- let allChildrenIds = childIds
429
- childIds.forEach((id) => {
430
- allChildrenIds = allChildrenIds.concat(getChildrenToDepth(id, hierarchy, depth - 1))
431
- })
432
- return allChildrenIds
433
- }
434
-
435
- export async function contentStatusReset(contentId, collection = null) {
436
- await dataContext.update(
437
- async function (localContext) {
438
- let hierarchy = await getContentHierarchy(contentId, collection)
439
- resetStatusInLocalContext(localContext, contentId, hierarchy, collection)
440
- },
441
- async function () {
442
- return postContentReset(contentId, collection)
443
- }
444
- )
445
- }
446
-
447
- function resetStatusInLocalContext(localContext, contentId, hierarchy, collection = null) {
448
- let keys = []
449
-
450
- console.log('all', [localContext, contentId, hierarchy, collection])
451
- keys.push(generateRecordKey(contentId, collection))
452
-
453
- let allChildIds
454
- let learningPathId = null
455
- let childrenIds = []
456
- if (collection && collection.type === 'learning-path-v2') {
457
- [learningPathId, childrenIds] = findLearningPathAndChildren(hierarchy, contentId)
458
- allChildIds = (learningPathId === contentId) ? childrenIds : []
459
- } else {
460
- allChildIds = getChildrenToDepth(contentId, hierarchy, 5)
461
- }
462
-
463
- allChildIds.forEach((id) => {
464
- keys.push(generateRecordKey(id, collection))
465
- })
466
-
467
- keys.forEach((key) => {
468
- const index = Object.keys(localContext.data).indexOf(key.toString())
469
- if (index > -1) {
470
- // only splice array when item is found
471
- delete localContext.data[key]
472
- }
473
- })
474
- if (collection && collection.type === 'learning-path-v2') { // manual bubbling for LP
475
- if (learningPathId !== contentId) {
476
- bubbleLearningPathProgress(hierarchy, contentId, localContext, collection)
477
- }
478
- } else {
479
- bubbleProgress(hierarchy, contentId, localContext)
480
- }
481
226
  }
482
227
 
483
228
  /**
484
229
  * Record watch session
485
230
  * @return {string} sessionId - provide in future calls to update progress
486
231
  * @param {int} contentId
487
- * @param {string} mediaType - options are video, assignment, practice
488
- * @param {string} mediaCategory - options are youtube, vimeo, soundslice, play-alongs
489
232
  * @param {int} mediaLengthSeconds
490
233
  * @param {int} currentSeconds
491
234
  * @param {int} secondsPlayed
492
235
  * @param {string} sessionId - This function records a sessionId to pass into future updates to progress on the same video
493
236
  * @param {int} instrumentId - enum value of instrument id
494
237
  * @param {int} categoryId - enum value of category id
495
- * @param {object|null} collection - optional collection info { type: 'learning-path-v2', id: 123 }
496
238
  */
497
- // NOTE: have not set up collection because its not super important for testing and this will change soon with watermelon
498
239
  export async function recordWatchSession(
499
240
  contentId,
500
- mediaType,
501
- mediaCategory,
241
+ collection = null,
502
242
  mediaLengthSeconds,
503
243
  currentSeconds,
504
244
  secondsPlayed,
505
- sessionId = null,
245
+ prevSession = null,
506
246
  instrumentId = null,
507
- categoryId = null,
508
- collection = null
247
+ categoryId = null
509
248
  ) {
510
- if (collection && collection.type === 'learning-path-v2') {
511
- console.log('Learning Path lesson watch sessions are not set up yet without watermelon')
512
- return sessionId
513
- }
249
+ const [session] = await Promise.all([
250
+ trackPractice(contentId, secondsPlayed, prevSession, { instrumentId, categoryId }),
251
+ trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds),
252
+ ])
514
253
 
515
- let mediaTypeId = getMediaTypeId(mediaType, mediaCategory)
516
- let updateLocalProgress = mediaTypeId === 1 || mediaTypeId === 2 //only update for video playback
517
- if (!sessionId) {
518
- sessionId = uuidv4()
519
- }
254
+ return session
255
+ }
520
256
 
521
- try {
522
- //TODO: Good enough for Alpha, Refine in reliability improvements
523
- sessionData[sessionId] = sessionData[sessionId] || {}
524
- const secondsSinceLastUpdate = Math.ceil(
525
- secondsPlayed - (sessionData[sessionId][contentId] ?? 0)
526
- )
527
- await recordUserPractice({
528
- content_id: contentId,
529
- duration_seconds: secondsSinceLastUpdate,
530
- instrument_id: instrumentId,
531
- })
532
- } catch (error) {
533
- console.error('Failed to record user practice:', error)
534
- }
535
- sessionData[sessionId][contentId] = secondsPlayed
257
+ async function trackPractice(contentId, secondsPlayed, prevSession, details = {}) {
258
+ const session = prevSession || new Map()
536
259
 
537
- await dataContext.update(
538
- async function (localContext) {
539
- if (contentId && updateLocalProgress) {
540
- if (mediaLengthSeconds <= 0) {
541
- return
542
- }
543
- let progress = Math.min(
544
- 99,
545
- Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds ?? 0)) * 100)
546
- )
547
- let hierarchy = await fetchHierarchy(contentId)
548
- saveContentProgress(localContext, contentId, progress, currentSeconds, hierarchy)
549
- }
550
- },
551
- async function () {
552
- return postRecordWatchSession(
553
- contentId,
554
- mediaTypeId,
555
- mediaLengthSeconds,
556
- currentSeconds,
557
- secondsPlayed,
558
- sessionId
559
- )
560
- }
260
+ const secondsSinceLastUpdate = Math.ceil(
261
+ secondsPlayed - (session.get(contentId) ?? 0)
561
262
  )
562
- return sessionId
563
- }
263
+ session.set(contentId, secondsPlayed)
564
264
 
565
- function getMediaTypeId(mediaType, mediaCategory) {
566
- switch (`${mediaType}_${mediaCategory}`) {
567
- case 'video_youtube':
568
- return 1
569
- case 'video_vimeo':
570
- return 2
571
- case 'assignment_soundslice':
572
- return 3
573
- case 'practice_play-alongs':
574
- return 4
575
- case 'video_soundslice':
576
- return 3
577
- default:
578
- return 5
579
- }
265
+ await trackUserPractice(contentId, secondsSinceLastUpdate, details)
266
+
267
+ return session
580
268
  }
581
269
 
582
- function uuidv4() {
583
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
584
- var r = (Math.random() * 16) | 0,
585
- v = c === 'x' ? r : (r & 0x3) | 0x8
586
- return v.toString(16)
587
- })
270
+ async function trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds) {
271
+ const progress = Math.min(
272
+ 99,
273
+ Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds)) * 100)
274
+ )
275
+ return saveContentProgress(contentId, collection, progress, currentSeconds)
588
276
  }
589
277
 
590
- function bubbleProgress(hierarchy, contentId, localContext) {
591
- let parentId = hierarchy?.parents?.[contentId]
592
- if (!parentId) return
593
- let data = localContext.data[parentId] ?? {}
594
- let childProgress = hierarchy?.children?.[parentId]?.map(function (childId) {
595
- return localContext.data[childId]?.[DATA_KEY_PROGRESS] ?? 0
596
- })
597
- let progress = Math.round(childProgress.reduce((a, b) => a + b, 0) / childProgress.length)
598
- const brand = localContext.data[contentId]?.[DATA_KEY_BRAND] ?? null
599
- data[DATA_KEY_PROGRESS] = progress
600
- data[DATA_KEY_STATUS] = progress === 100 ? STATE_COMPLETED : STATE_STARTED
601
- data[DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
602
- data[DATA_KEY_BRAND] = brand
603
- localContext.data[parentId] = data
604
- bubbleProgress(hierarchy, parentId, localContext)
278
+ export async function contentStatusCompleted(contentId, collection = null) {
279
+ return setStartedOrCompletedStatus(contentId, collection, true)
280
+ }
281
+ export async function contentStatusStarted(contentId, collection = null) {
282
+ return setStartedOrCompletedStatus(contentId, collection, false)
283
+ }
284
+ export async function contentStatusReset(contentId, collection = null) {
285
+ return resetStatus(contentId, collection)
605
286
  }
606
287
 
607
- function bubbleLearningPathProgress(hierarchy, contentId, localContext, collection) {
608
- const [parentId, childrenIds] = findLearningPathAndChildren(hierarchy, contentId)
288
+ async function saveContentProgress(contentId, collection, progress, currentSeconds) {
289
+ const response = await db.contentProgress.recordProgressRemotely(contentId, collection, progress, currentSeconds)
609
290
 
610
- if (!parentId || parentId === contentId) return
291
+ // note - previous implementation explicitly did not trickle progress to children here
292
+ // (only to siblings/parents via le bubbles)
611
293
 
612
- const parentKey = generateRecordKey(parentId, collection)
613
- let data = localContext.data[parentKey] ?? {}
294
+ const bubbledProgresses = bubbleProgress(await fetchHierarchy(contentId), contentId, collection)
295
+ await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
614
296
 
615
- let childProgress = childrenIds.map(function (childId) {
616
- const childKey = generateRecordKey(childId, collection)
617
- return localContext.data[childKey]?.[DATA_KEY_PROGRESS] ?? 0
618
- })
619
- let progress = Math.round(childProgress.reduce((a, b) => a + b, 0) / childProgress.length)
297
+ return response
298
+ }
620
299
 
621
- const contentKey = generateRecordKey(contentId, collection)
622
- const brand = localContext.data[contentKey]?.[DATA_KEY_BRAND] ?? null
300
+ async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
301
+ const progress = isCompleted ? 100 : 0
302
+ // we explicitly pessimistically await a remote push here
303
+ // because awards may be generated (on server) on completion
304
+ // which we would want to toast the user about *in band*
305
+ const response = await db.contentProgress.recordProgressRemotely(contentId, collection, progress)
623
306
 
624
- data[DATA_KEY_PROGRESS] = progress
625
- data[DATA_KEY_STATUS] = progress === 100 ? STATE_COMPLETED : STATE_STARTED
626
- data[DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
627
- data[DATA_KEY_BRAND] = brand
628
- data[DATA_KEY_COLLECTION] = collection
629
- data[DATA_CONTENT_ID] = parentId
307
+ if (response.pushStatus === 'success') {
308
+ const hierarchy = await fetchHierarchy(contentId)
630
309
 
631
- localContext.data[parentKey] = data
310
+ await Promise.all([
311
+ db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, progress), collection),
312
+ bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
313
+ ])
314
+ }
315
+
316
+ return response
632
317
  }
633
318
 
634
- function generateRecordKey(contentId, collection) {
635
- return collection ? `${contentId}:${collection.type}:${collection.id}` : contentId
319
+ async function resetStatus(contentId, collection = null) {
320
+ const response = await db.contentProgress.eraseProgress(contentId, collection)
321
+ const hierarchy = await fetchHierarchy(contentId)
322
+
323
+ await Promise.all([
324
+ db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, 0), collection),
325
+ bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
326
+ ])
327
+
328
+ return response
636
329
  }
637
330
 
638
- function extractContentIdFromRecordKey(key) {
639
- return parseInt(key.split(':')[0])
331
+ // agnostic to collection - makes returned data structure simpler,
332
+ // as long as callers remember to pass collection where needed
333
+ function trickleProgress(hierarchy, contentId, _collection, progress) {
334
+ const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
335
+ return Object.fromEntries(descendantIds.map(id => [id, progress]))
640
336
  }
641
337
 
642
- async function getContentHierarchy(contentId, collection = null) {
643
- if (collection && collection.type === 'learning-path-v2') {
644
- return fetchMethodV2StructureFromId(contentId)
645
- }
646
- return await fetchHierarchy(contentId)
338
+ async function bubbleProgress(hierarchy, contentId, collection = null) {
339
+ const ids = getAncestorAndSiblingIds(hierarchy, contentId)
340
+ const progresses = await getByIds(ids, collection, 'progress_percent', 0)
341
+ return averageProgressesFor(hierarchy, contentId, progresses)
647
342
  }
648
343
 
649
- function findLearningPathAndChildren(data, contentId) {
650
- let learningPathId = null
651
- let children = []
344
+ function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
345
+ if (depth > MAX_DEPTH) return []
652
346
 
653
- if (!data?.learningPaths) return [ learningPathId, children ]
347
+ const parentId = hierarchy?.parents?.[contentId]
348
+ if (!parentId) return []
654
349
 
655
- for (const lp of data.learningPaths) {
656
- if (lp.id === contentId) {
657
- learningPathId = contentId
658
- children = lp.children ?? []
659
- break
660
- }
661
- if (Array.isArray(lp.children) && lp.children.includes(contentId)) {
662
- learningPathId = lp.id
663
- children = lp.children ?? []
664
- break
665
- }
350
+ if (parentId === contentId) {
351
+ console.error('Circular dependency detected for contentId', contentId)
352
+ return []
666
353
  }
667
354
 
668
- return [learningPathId, children]
355
+ return [
356
+ ...(hierarchy?.children?.[parentId] ?? []),
357
+ ...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1)
358
+ ]
669
359
  }
670
360
 
671
- function bubbleOrTrickleLearningPathProgress(hierarchy, contentId, localContext, isCompleted, collection) {
672
- const [parentId, childrenIds] = findLearningPathAndChildren(hierarchy, contentId)
361
+ // doesn't accept collection - assumes progresses are already filtered appropriately
362
+ // caller would do well to remember this, i doth say
363
+ function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
364
+ if (depth > MAX_DEPTH) return {}
673
365
 
674
- if (parentId !== contentId) { // if contentId is not a learning path
675
- bubbleLearningPathProgress(hierarchy, contentId, localContext, collection)
676
- return
677
- }
366
+ const parentId = hierarchy?.parents?.[contentId]
367
+ if (!parentId) return {}
678
368
 
679
- if (childrenIds) {
680
- for (let i = 0; i < childrenIds.length; i++) {
681
- let childId = childrenIds[i]
682
- setStartedOrCompletedStatusInLocalContext(localContext, childId, isCompleted, null, collection)
683
- }
369
+ const parentChildProgress = hierarchy?.children?.[parentId]?.map(childId => {
370
+ return progressData[childId] ?? 0
371
+ })
372
+ const avgParentProgress = parentChildProgress.length > 0 ? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length) : 0
373
+
374
+ return {
375
+ ...averageProgressesFor(hierarchy, parentId, progressData, depth + 1),
376
+ [parentId]: avgParentProgress,
684
377
  }
685
378
  }
379
+
380
+ function getChildrenToDepth(parentId, hierarchy, depth = 1) {
381
+ let childIds = hierarchy.children[parentId] ?? []
382
+ let allChildrenIds = childIds
383
+ childIds.forEach((id) => {
384
+ allChildrenIds = allChildrenIds.concat(getChildrenToDepth(id, hierarchy, depth - 1))
385
+ })
386
+ return allChildrenIds
387
+ }