musora-content-services 2.95.4 → 2.96.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/.claude/settings.local.json +9 -0
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/index.d.ts +7 -1
- package/src/index.js +7 -1
- package/src/lib/sanity/query.ts +5 -5
- package/src/services/content/artist.ts +2 -2
- package/src/services/content/genre.ts +2 -3
- package/src/services/content-org/learning-paths.ts +66 -31
- package/src/services/contentProgress.js +144 -61
- package/src/services/progress-events.js +53 -1
- package/src/services/sync/manager.ts +32 -16
- package/src/services/sync/store/index.ts +1 -1
- package/test/initializeTests.js +4 -2
- package/test/learningPaths.test.js +59 -10
- package/test/sync/adapter.ts +41 -0
- package/test/sync/initialize-sync-manager.js +104 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.96.0](https://github.com/railroadmedia/musora-content-services/compare/v2.95.5...v2.96.0) (2025-12-09)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* handle learning path complete logic ([#628](https://github.com/railroadmedia/musora-content-services/issues/628)) ([e5266d2](https://github.com/railroadmedia/musora-content-services/commit/e5266d2219597fc295dab97cec7b7ce8ac80a705))
|
|
11
|
+
|
|
12
|
+
### [2.95.5](https://github.com/railroadmedia/musora-content-services/compare/v2.95.4...v2.95.5) (2025-12-08)
|
|
13
|
+
|
|
5
14
|
### [2.95.4](https://github.com/railroadmedia/musora-content-services/compare/v2.95.3...v2.95.4) (2025-12-08)
|
|
6
15
|
|
|
7
16
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
getEnrichedLearningPath,
|
|
53
53
|
getLearningPathLessonsByIds,
|
|
54
54
|
mapContentToParent,
|
|
55
|
+
onContentCompletedLearningPathListener,
|
|
55
56
|
resetAllLearningPaths,
|
|
56
57
|
startLearningPath,
|
|
57
58
|
updateDailySession
|
|
@@ -193,7 +194,9 @@ import {
|
|
|
193
194
|
} from './services/liveTesting.ts';
|
|
194
195
|
|
|
195
196
|
import {
|
|
197
|
+
emitContentCompleted,
|
|
196
198
|
emitProgressSaved,
|
|
199
|
+
onContentCompleted,
|
|
197
200
|
onProgressSaved
|
|
198
201
|
} from './services/progress-events.js';
|
|
199
202
|
|
|
@@ -423,7 +426,7 @@ import {
|
|
|
423
426
|
} from './services/userActivity.js';
|
|
424
427
|
|
|
425
428
|
import {
|
|
426
|
-
default as EventsAPI
|
|
429
|
+
default as EventsAPI
|
|
427
430
|
} from './services/eventsAPI';
|
|
428
431
|
|
|
429
432
|
declare module 'musora-content-services' {
|
|
@@ -468,6 +471,7 @@ declare module 'musora-content-services' {
|
|
|
468
471
|
deleteUserActivity,
|
|
469
472
|
duplicatePlaylist,
|
|
470
473
|
editComment,
|
|
474
|
+
emitContentCompleted,
|
|
471
475
|
emitProgressSaved,
|
|
472
476
|
enrollUserInGuidedCourse,
|
|
473
477
|
extractSanityUrl,
|
|
@@ -651,6 +655,8 @@ declare module 'musora-content-services' {
|
|
|
651
655
|
markNotificationAsUnread,
|
|
652
656
|
markThreadAsRead,
|
|
653
657
|
numberOfActiveUsers,
|
|
658
|
+
onContentCompleted,
|
|
659
|
+
onContentCompletedLearningPathListener,
|
|
654
660
|
onProgressSaved,
|
|
655
661
|
openComment,
|
|
656
662
|
otherStats,
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*** This file was generated automatically. To recreate, please run `npm run build-index`. ***/
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
default as EventsAPI
|
|
4
|
+
default as EventsAPI
|
|
5
5
|
} from './services/eventsAPI';
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
getEnrichedLearningPath,
|
|
57
57
|
getLearningPathLessonsByIds,
|
|
58
58
|
mapContentToParent,
|
|
59
|
+
onContentCompletedLearningPathListener,
|
|
59
60
|
resetAllLearningPaths,
|
|
60
61
|
startLearningPath,
|
|
61
62
|
updateDailySession
|
|
@@ -197,7 +198,9 @@ import {
|
|
|
197
198
|
} from './services/liveTesting.ts';
|
|
198
199
|
|
|
199
200
|
import {
|
|
201
|
+
emitContentCompleted,
|
|
200
202
|
emitProgressSaved,
|
|
203
|
+
onContentCompleted,
|
|
201
204
|
onProgressSaved
|
|
202
205
|
} from './services/progress-events.js';
|
|
203
206
|
|
|
@@ -467,6 +470,7 @@ export {
|
|
|
467
470
|
deleteUserActivity,
|
|
468
471
|
duplicatePlaylist,
|
|
469
472
|
editComment,
|
|
473
|
+
emitContentCompleted,
|
|
470
474
|
emitProgressSaved,
|
|
471
475
|
enrollUserInGuidedCourse,
|
|
472
476
|
extractSanityUrl,
|
|
@@ -650,6 +654,8 @@ export {
|
|
|
650
654
|
markNotificationAsUnread,
|
|
651
655
|
markThreadAsRead,
|
|
652
656
|
numberOfActiveUsers,
|
|
657
|
+
onContentCompleted,
|
|
658
|
+
onContentCompletedLearningPathListener,
|
|
653
659
|
onProgressSaved,
|
|
654
660
|
openComment,
|
|
655
661
|
otherStats,
|
package/src/lib/sanity/query.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Monoid } from '../ads/monoid'
|
|
2
|
-
import { Semigroup } from '../ads/semigroup'
|
|
3
2
|
|
|
4
3
|
export interface BuildQueryOptions {
|
|
5
4
|
sort?: string
|
|
@@ -50,8 +49,9 @@ const slice: Monoid<string> = {
|
|
|
50
49
|
concat: (a, b) => b || a,
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
const project:
|
|
54
|
-
|
|
52
|
+
const project: Monoid<string> = {
|
|
53
|
+
empty: '',
|
|
54
|
+
concat: (a, b) => (!a ? b : !b ? a : `${a}, ${b}`),
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export const query = (): QueryBuilder => {
|
|
@@ -59,7 +59,7 @@ export const query = (): QueryBuilder => {
|
|
|
59
59
|
filter: and.empty,
|
|
60
60
|
ordering: order.empty,
|
|
61
61
|
slice: slice.empty,
|
|
62
|
-
projection:
|
|
62
|
+
projection: project.empty,
|
|
63
63
|
postFilter: and.empty,
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -113,7 +113,7 @@ export const query = (): QueryBuilder => {
|
|
|
113
113
|
*[${filter}]
|
|
114
114
|
${ordering}
|
|
115
115
|
${slice}
|
|
116
|
-
{ ${projection} }
|
|
116
|
+
${projection.length > 0 ? `{ ${projection} }` : ''}
|
|
117
117
|
${state.postFilter ? `[${state.postFilter}]` : ''}
|
|
118
118
|
`.trim()
|
|
119
119
|
},
|
|
@@ -55,7 +55,7 @@ export async function fetchArtists(
|
|
|
55
55
|
|
|
56
56
|
const q = `{
|
|
57
57
|
"data": ${data},
|
|
58
|
-
"total": ${total}
|
|
58
|
+
"total": count(${total})
|
|
59
59
|
}`
|
|
60
60
|
|
|
61
61
|
return fetchSanity(q, true, { processNeedAccess: false, processPageType: false })
|
|
@@ -162,7 +162,7 @@ export async function fetchArtistLessons(
|
|
|
162
162
|
|
|
163
163
|
const q = `{
|
|
164
164
|
"data": ${data},
|
|
165
|
-
"total": ${total}
|
|
165
|
+
"total": count(${total})
|
|
166
166
|
}`
|
|
167
167
|
|
|
168
168
|
return fetchSanity(q, true, { processNeedAccess: false, processPageType: false })
|
|
@@ -55,7 +55,7 @@ export async function fetchGenres(
|
|
|
55
55
|
|
|
56
56
|
const q = `{
|
|
57
57
|
"data": ${data},
|
|
58
|
-
"total": ${total}
|
|
58
|
+
"total": count(${total})
|
|
59
59
|
}`
|
|
60
60
|
|
|
61
61
|
return fetchSanity(q, true, { processNeedAccess: false, processPageType: false })
|
|
@@ -151,7 +151,6 @@ export async function fetchGenreLessons(
|
|
|
151
151
|
sort = getSortOrder(sort, brand)
|
|
152
152
|
const data = query()
|
|
153
153
|
.and(filterWithRestrictions)
|
|
154
|
-
.and(`brand == ${brand}`)
|
|
155
154
|
.order(sort)
|
|
156
155
|
.slice(offset, offset + limit)
|
|
157
156
|
.select(...(fieldsString ? [fieldsString] : []))
|
|
@@ -161,7 +160,7 @@ export async function fetchGenreLessons(
|
|
|
161
160
|
|
|
162
161
|
const q = `{
|
|
163
162
|
"data": ${data},
|
|
164
|
-
"total": ${total}
|
|
163
|
+
"total": count(${total})
|
|
165
164
|
}`
|
|
166
165
|
|
|
167
166
|
return fetchSanity(q, true, { processNeedAccess: false, processPageType: false })
|
|
@@ -12,31 +12,31 @@ import {
|
|
|
12
12
|
getAllCompletedByIds,
|
|
13
13
|
getProgressState,
|
|
14
14
|
} from '../contentProgress.js'
|
|
15
|
-
import { COLLECTION_TYPE, STATE } from
|
|
16
|
-
import { SyncWriteDTO } from
|
|
17
|
-
import { ContentProgress } from
|
|
15
|
+
import { COLLECTION_TYPE, STATE } from '../sync/models/ContentProgress'
|
|
16
|
+
import { SyncWriteDTO } from '../sync'
|
|
17
|
+
import { ContentProgress } from '../sync/models'
|
|
18
18
|
|
|
19
19
|
const BASE_PATH: string = `/api/content-org`
|
|
20
20
|
const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
|
|
21
21
|
|
|
22
22
|
interface ActiveLearningPathResponse {
|
|
23
|
-
user_id: number
|
|
24
|
-
brand: string
|
|
25
|
-
active_learning_path_id: number
|
|
23
|
+
user_id: number
|
|
24
|
+
brand: string
|
|
25
|
+
active_learning_path_id: number
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
interface DailySessionResponse {
|
|
29
|
-
user_id: number
|
|
30
|
-
brand: string
|
|
29
|
+
user_id: number
|
|
30
|
+
brand: string
|
|
31
31
|
user_date: string
|
|
32
|
-
daily_session: DailySession[]
|
|
33
|
-
active_learning_path_id: number
|
|
34
|
-
active_learning_path_created_at: string
|
|
32
|
+
daily_session: DailySession[]
|
|
33
|
+
active_learning_path_id: number
|
|
34
|
+
active_learning_path_created_at: string
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
interface DailySession {
|
|
38
|
-
content_ids: number[]
|
|
39
|
-
learning_path_id: number
|
|
38
|
+
content_ids: number[]
|
|
39
|
+
learning_path_id: number
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -47,7 +47,7 @@ interface DailySession {
|
|
|
47
47
|
export async function getDailySession(brand: string, userDate: Date) {
|
|
48
48
|
const stringDate = userDate.toISOString().split('T')[0]
|
|
49
49
|
const url: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${stringDate}`
|
|
50
|
-
return await fetchHandler(url, 'GET', null, null) as DailySessionResponse
|
|
50
|
+
return (await fetchHandler(url, 'GET', null, null)) as DailySessionResponse
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
@@ -64,7 +64,7 @@ export async function updateDailySession(
|
|
|
64
64
|
const stringDate = userDate.toISOString().split('T')[0]
|
|
65
65
|
const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
|
|
66
66
|
const body = { brand: brand, userDate: stringDate, keepFirstLearningPath: keepFirstLearningPath }
|
|
67
|
-
return await fetchHandler(url, 'POST', null, body) as DailySessionResponse
|
|
67
|
+
return (await fetchHandler(url, 'POST', null, body)) as DailySessionResponse
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
@@ -73,7 +73,7 @@ export async function updateDailySession(
|
|
|
73
73
|
*/
|
|
74
74
|
export async function getActivePath(brand: string) {
|
|
75
75
|
const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
|
|
76
|
-
return await fetchHandler(url, 'GET', null, null) as ActiveLearningPathResponse
|
|
76
|
+
return (await fetchHandler(url, 'GET', null, null)) as ActiveLearningPathResponse
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
@@ -84,7 +84,7 @@ export async function getActivePath(brand: string) {
|
|
|
84
84
|
export async function startLearningPath(brand: string, learningPathId: number) {
|
|
85
85
|
const url: string = `${LEARNING_PATHS_PATH}/active-path/set`
|
|
86
86
|
const body = { brand: brand, learning_path_id: learningPathId }
|
|
87
|
-
return await fetchHandler(url, 'POST', null, body) as ActiveLearningPathResponse
|
|
87
|
+
return (await fetchHandler(url, 'POST', null, body)) as ActiveLearningPathResponse
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
@@ -227,8 +227,6 @@ export async function fetchLearningPathLessons(
|
|
|
227
227
|
}))
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
|
|
231
|
-
|
|
232
230
|
return {
|
|
233
231
|
...learningPath,
|
|
234
232
|
is_active_learning_path: isActiveLearningPath,
|
|
@@ -250,14 +248,16 @@ export async function fetchLearningPathLessons(
|
|
|
250
248
|
* @param {number[]} contentIds The array of content IDs within the learning path
|
|
251
249
|
* @returns {Promise<number[]>} Array with completed content IDs
|
|
252
250
|
*/
|
|
253
|
-
export async function fetchLearningPathProgressCheckLessons(
|
|
251
|
+
export async function fetchLearningPathProgressCheckLessons(
|
|
252
|
+
contentIds: number[]
|
|
253
|
+
): Promise<number[]> {
|
|
254
254
|
let query = await getAllCompletedByIds(contentIds)
|
|
255
|
-
let completedProgress = query.data.map(progress => progress.content_id)
|
|
256
|
-
return contentIds.filter(contentId => completedProgress.includes(contentId))
|
|
255
|
+
let completedProgress = query.data.map((progress) => progress.content_id)
|
|
256
|
+
return contentIds.filter((contentId) => completedProgress.includes(contentId))
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
interface completeMethodIntroVideo {
|
|
260
|
-
intro_video_response: SyncWriteDTO<ContentProgress, any> | null
|
|
260
|
+
intro_video_response: SyncWriteDTO<ContentProgress, any> | null
|
|
261
261
|
active_path_response: ActiveLearningPathResponse
|
|
262
262
|
}
|
|
263
263
|
/**
|
|
@@ -268,7 +268,10 @@ interface completeMethodIntroVideo {
|
|
|
268
268
|
* @returns {Promise<Object|null>} response.intro_video_response - The intro video completion response or null if already completed.
|
|
269
269
|
* @returns {Promise<Object>} response.active_path_response - The set active learning path response.
|
|
270
270
|
*/
|
|
271
|
-
export async function completeMethodIntroVideo(
|
|
271
|
+
export async function completeMethodIntroVideo(
|
|
272
|
+
introVideoId: number,
|
|
273
|
+
brand: string
|
|
274
|
+
): Promise<completeMethodIntroVideo> {
|
|
272
275
|
let response = {} as completeMethodIntroVideo
|
|
273
276
|
|
|
274
277
|
response.intro_video_response = await completeIfNotCompleted(introVideoId)
|
|
@@ -278,13 +281,12 @@ export async function completeMethodIntroVideo(introVideoId: number, brand: stri
|
|
|
278
281
|
|
|
279
282
|
response.active_path_response = await startLearningPath(brand, learningPathId)
|
|
280
283
|
|
|
281
|
-
|
|
282
284
|
return response
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
interface completeLearningPathIntroVideo {
|
|
286
|
-
intro_video_response: SyncWriteDTO<ContentProgress, any> | null
|
|
287
|
-
learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null
|
|
288
|
+
intro_video_response: SyncWriteDTO<ContentProgress, any> | null
|
|
289
|
+
learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null
|
|
288
290
|
lesson_import_response: SyncWriteDTO<ContentProgress, any> | null
|
|
289
291
|
}
|
|
290
292
|
/**
|
|
@@ -297,7 +299,11 @@ interface completeLearningPathIntroVideo {
|
|
|
297
299
|
* @returns {Promise<void>} response.learning_path_reset_response - The reset learning path response.
|
|
298
300
|
* @returns {Promise<Object[]>} response.lesson_import_response - The responses for completing each content_id within the learning path.
|
|
299
301
|
*/
|
|
300
|
-
export async function completeLearningPathIntroVideo(
|
|
302
|
+
export async function completeLearningPathIntroVideo(
|
|
303
|
+
introVideoId: number,
|
|
304
|
+
learningPathId: number,
|
|
305
|
+
lessonsToImport: number[] | null
|
|
306
|
+
) {
|
|
301
307
|
let response = {} as completeLearningPathIntroVideo
|
|
302
308
|
|
|
303
309
|
response.intro_video_response = await completeIfNotCompleted(introVideoId)
|
|
@@ -306,7 +312,6 @@ export async function completeLearningPathIntroVideo(introVideoId: number, learn
|
|
|
306
312
|
|
|
307
313
|
if (!lessonsToImport) {
|
|
308
314
|
response.learning_path_reset_response = await contentStatusReset(learningPathId, collection)
|
|
309
|
-
|
|
310
315
|
} else {
|
|
311
316
|
response.lesson_import_response = await contentsStatusCompleted(lessonsToImport, collection)
|
|
312
317
|
}
|
|
@@ -314,9 +319,39 @@ export async function completeLearningPathIntroVideo(introVideoId: number, learn
|
|
|
314
319
|
return response
|
|
315
320
|
}
|
|
316
321
|
|
|
317
|
-
|
|
318
|
-
|
|
322
|
+
async function completeIfNotCompleted(
|
|
323
|
+
contentId: number
|
|
324
|
+
): Promise<SyncWriteDTO<ContentProgress, any> | null> {
|
|
319
325
|
const introVideoStatus = await getProgressState(contentId)
|
|
320
326
|
|
|
321
327
|
return introVideoStatus !== 'completed' ? await contentStatusCompleted(contentId) : null
|
|
322
328
|
}
|
|
329
|
+
|
|
330
|
+
export async function onContentCompletedLearningPathListener(event) {
|
|
331
|
+
console.log('if')
|
|
332
|
+
if (event?.collection?.type !== 'learning-path-v2') return
|
|
333
|
+
if (event.contentId !== event?.collection?.id) return
|
|
334
|
+
const learningPathId = event.contentId
|
|
335
|
+
const learningPath = await getEnrichedLearningPath(learningPathId)
|
|
336
|
+
console.log('LP', learningPath)
|
|
337
|
+
const brand = learningPath.brand
|
|
338
|
+
const activeLearningPath = await getActivePath(brand)
|
|
339
|
+
console.log('Active LP', activeLearningPath)
|
|
340
|
+
if (activeLearningPath.active_learning_path_id !== learningPathId) return
|
|
341
|
+
const method = await fetchMethodV2Structure(brand)
|
|
342
|
+
console.log('Method', method)
|
|
343
|
+
const currentIndex = method.learning_paths.findIndex((lp) => lp.id === learningPathId)
|
|
344
|
+
if (currentIndex === -1) {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const nextLearningPath = method.learning_paths[currentIndex + 1]
|
|
348
|
+
console.log('Next LP', nextLearningPath)
|
|
349
|
+
if (!nextLearningPath) {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await startLearningPath(brand, nextLearningPath.id)
|
|
354
|
+
const nextLearningPathData = await getEnrichedLearningPath(nextLearningPath.id)
|
|
355
|
+
console.log('Next LP Data', nextLearningPathData)
|
|
356
|
+
await contentStatusReset(nextLearningPathData.intro_video.id)
|
|
357
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { fetchHierarchy, fetchLearningPathHierarchy } from './sanity.js'
|
|
2
2
|
import { db } from './sync'
|
|
3
|
-
import {COLLECTION_TYPE, STATE} from './sync/models/ContentProgress'
|
|
3
|
+
import { COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
|
|
4
4
|
import { trackUserPractice, findIncompleteLesson } from './userActivity'
|
|
5
5
|
import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
|
|
6
|
+
import { emitContentCompleted } from './progress-events'
|
|
6
7
|
|
|
7
8
|
const STATE_STARTED = STATE.STARTED
|
|
8
9
|
const STATE_COMPLETED = STATE.COMPLETED
|
|
@@ -17,7 +18,12 @@ export async function getProgressStateByIds(contentIds, collection = null) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
|
|
20
|
-
return getByIds(
|
|
21
|
+
return getByIds(
|
|
22
|
+
normalizeContentIds(contentIds),
|
|
23
|
+
normalizeCollection(collection),
|
|
24
|
+
'resume_time_seconds',
|
|
25
|
+
0
|
|
26
|
+
)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export async function getNavigateTo(data, collection = null) {
|
|
@@ -54,23 +60,45 @@ export async function getNavigateTo(data, collection = null) {
|
|
|
54
60
|
const firstChild = validChildren[0]
|
|
55
61
|
let lastInteractedChildNavToData = await getNavigateTo([firstChild], collection)
|
|
56
62
|
lastInteractedChildNavToData = lastInteractedChildNavToData[firstChild.id] ?? null
|
|
57
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
63
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
64
|
+
firstChild,
|
|
65
|
+
lastInteractedChildNavToData,
|
|
66
|
+
collection
|
|
67
|
+
) //no G-child for LP
|
|
58
68
|
} else {
|
|
59
69
|
const childrenStates = await getProgressStateByIds(childrenIds, collection)
|
|
60
70
|
const lastInteracted = await getLastInteractedOf(childrenIds, collection)
|
|
61
71
|
const lastInteractedStatus = childrenStates[lastInteracted]
|
|
62
72
|
|
|
63
73
|
if (['course', 'pack-bundle', 'skill-pack'].includes(content.type)) {
|
|
64
|
-
if (lastInteractedStatus === STATE_STARTED) {
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
if (lastInteractedStatus === STATE_STARTED) {
|
|
75
|
+
// send to last interacted
|
|
76
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
77
|
+
children.get(lastInteracted),
|
|
78
|
+
null,
|
|
79
|
+
collection
|
|
80
|
+
)
|
|
81
|
+
} else {
|
|
82
|
+
// send to first incomplete after last interacted
|
|
67
83
|
let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
|
|
68
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
84
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
85
|
+
children.get(incompleteChild),
|
|
86
|
+
null,
|
|
87
|
+
collection
|
|
88
|
+
)
|
|
69
89
|
}
|
|
70
|
-
} else if (
|
|
90
|
+
} else if (
|
|
91
|
+
['song-tutorial', 'guided-course', COLLECTION_TYPE.LEARNING_PATH].includes(content.type)
|
|
92
|
+
) {
|
|
93
|
+
// send to first incomplete
|
|
71
94
|
let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
|
|
72
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
73
|
-
|
|
95
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
96
|
+
children.get(incompleteChild),
|
|
97
|
+
null,
|
|
98
|
+
collection
|
|
99
|
+
)
|
|
100
|
+
} else if (twoDepthContentTypes.includes(content.type)) {
|
|
101
|
+
// send to navigateTo child of last interacted child
|
|
74
102
|
const firstChildren = content.children ?? []
|
|
75
103
|
const lastInteractedChildId = await getLastInteractedOf(
|
|
76
104
|
firstChildren.map((child) => child.id),
|
|
@@ -117,21 +145,28 @@ function buildNavigateTo(content, child = null, collection = null) {
|
|
|
117
145
|
* @returns {Promise<number>}
|
|
118
146
|
*/
|
|
119
147
|
export async function getLastInteractedOf(contentIds, collection = null) {
|
|
120
|
-
return db.contentProgress
|
|
148
|
+
return db.contentProgress
|
|
149
|
+
.mostRecentlyUpdatedId(normalizeContentIds(contentIds), normalizeCollection(collection))
|
|
150
|
+
.then((r) => (r.data ? parseInt(r.data) : undefined))
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
export async function getProgressDataByIds(contentIds, collection) {
|
|
124
154
|
contentIds = normalizeContentIds(contentIds)
|
|
125
155
|
collection = normalizeCollection(collection)
|
|
126
156
|
|
|
127
|
-
const progress = Object.fromEntries(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const progress = Object.fromEntries(
|
|
158
|
+
contentIds.map((id) => [
|
|
159
|
+
id,
|
|
160
|
+
{
|
|
161
|
+
last_update: 0,
|
|
162
|
+
progress: 0,
|
|
163
|
+
status: '',
|
|
164
|
+
},
|
|
165
|
+
])
|
|
166
|
+
)
|
|
132
167
|
|
|
133
|
-
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
|
|
134
|
-
r.data.forEach(p => {
|
|
168
|
+
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
|
|
169
|
+
r.data.forEach((p) => {
|
|
135
170
|
progress[p.content_id] = {
|
|
136
171
|
last_update: p.updated_at,
|
|
137
172
|
progress: p.progress_percent,
|
|
@@ -145,15 +180,17 @@ export async function getProgressDataByIds(contentIds, collection) {
|
|
|
145
180
|
|
|
146
181
|
async function getById(contentId, dataKey, defaultValue) {
|
|
147
182
|
if (!contentId) return defaultValue
|
|
148
|
-
return db.contentProgress
|
|
183
|
+
return db.contentProgress
|
|
184
|
+
.getOneProgressByContentId(contentId)
|
|
185
|
+
.then((r) => r.data?.[dataKey] ?? defaultValue)
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
152
189
|
if (contentIds.length === 0) return {}
|
|
153
190
|
|
|
154
|
-
const progress = Object.fromEntries(contentIds.map(id => [id, defaultValue]))
|
|
155
|
-
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
|
|
156
|
-
r.data.forEach(p => {
|
|
191
|
+
const progress = Object.fromEntries(contentIds.map((id) => [id, defaultValue]))
|
|
192
|
+
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
|
|
193
|
+
r.data.forEach((p) => {
|
|
157
194
|
progress[p.content_id] = p[dataKey] ?? defaultValue
|
|
158
195
|
})
|
|
159
196
|
})
|
|
@@ -161,11 +198,11 @@ async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
export async function getAllStarted(limit = null) {
|
|
164
|
-
return db.contentProgress.startedIds(limit).then(r => r.data.map(id => parseInt(id)))
|
|
201
|
+
return db.contentProgress.startedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
export async function getAllCompleted(limit = null) {
|
|
168
|
-
return db.contentProgress.completedIds(limit).then(r => r.data.map(id => parseInt(id)))
|
|
205
|
+
return db.contentProgress.completedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
export async function getAllCompletedByIds(contentIds) {
|
|
@@ -175,7 +212,7 @@ export async function getAllCompletedByIds(contentIds) {
|
|
|
175
212
|
export async function getAllStartedOrCompleted({
|
|
176
213
|
onlyIds = true,
|
|
177
214
|
brand = null,
|
|
178
|
-
limit = null
|
|
215
|
+
limit = null,
|
|
179
216
|
} = {}) {
|
|
180
217
|
const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
|
|
181
218
|
const filters = {
|
|
@@ -185,15 +222,22 @@ export async function getAllStartedOrCompleted({
|
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
if (onlyIds) {
|
|
188
|
-
return db.contentProgress
|
|
225
|
+
return db.contentProgress
|
|
226
|
+
.startedOrCompletedIds(filters)
|
|
227
|
+
.then((r) => r.data.map((id) => parseInt(id)))
|
|
189
228
|
} else {
|
|
190
|
-
return db.contentProgress.startedOrCompleted(filters).then(r => {
|
|
191
|
-
return Object.fromEntries(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
229
|
+
return db.contentProgress.startedOrCompleted(filters).then((r) => {
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
r.data.map((p) => [
|
|
232
|
+
p.content_id,
|
|
233
|
+
{
|
|
234
|
+
last_update: p.updated_at,
|
|
235
|
+
progress: p.progress_percent,
|
|
236
|
+
status: p.state,
|
|
237
|
+
brand: p.content_brand,
|
|
238
|
+
},
|
|
239
|
+
])
|
|
240
|
+
)
|
|
197
241
|
})
|
|
198
242
|
}
|
|
199
243
|
}
|
|
@@ -216,8 +260,8 @@ export async function getAllStartedOrCompleted({
|
|
|
216
260
|
* console.log(progressMap[123]); // => 52
|
|
217
261
|
*/
|
|
218
262
|
export async function getStartedOrCompletedProgressOnly({ brand = undefined } = {}) {
|
|
219
|
-
return db.contentProgress.startedOrCompleted({ brand: brand }).then(r => {
|
|
220
|
-
return Object.fromEntries(r.data.map(p => [p.content_id, p.progress_percent]))
|
|
263
|
+
return db.contentProgress.startedOrCompleted({ brand: brand }).then((r) => {
|
|
264
|
+
return Object.fromEntries(r.data.map((p) => [p.content_id, p.progress_percent]))
|
|
221
265
|
})
|
|
222
266
|
}
|
|
223
267
|
|
|
@@ -256,9 +300,7 @@ export async function recordWatchSession(
|
|
|
256
300
|
async function trackPractice(contentId, secondsPlayed, prevSession, details = {}) {
|
|
257
301
|
const session = prevSession || new Map()
|
|
258
302
|
|
|
259
|
-
const secondsSinceLastUpdate = Math.ceil(
|
|
260
|
-
secondsPlayed - (session.get(contentId) ?? 0)
|
|
261
|
-
)
|
|
303
|
+
const secondsSinceLastUpdate = Math.ceil(secondsPlayed - (session.get(contentId) ?? 0))
|
|
262
304
|
session.set(contentId, secondsPlayed)
|
|
263
305
|
|
|
264
306
|
await trackUserPractice(contentId, secondsSinceLastUpdate, details)
|
|
@@ -274,43 +316,78 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
|
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
export async function contentStatusCompleted(contentId, collection = null) {
|
|
277
|
-
return setStartedOrCompletedStatus(
|
|
319
|
+
return setStartedOrCompletedStatus(
|
|
320
|
+
normalizeContentId(contentId),
|
|
321
|
+
normalizeCollection(collection),
|
|
322
|
+
true
|
|
323
|
+
)
|
|
278
324
|
}
|
|
279
325
|
|
|
280
326
|
export async function contentsStatusCompleted(contentIds, collection = null) {
|
|
281
|
-
return setStartedOrCompletedStatuses(
|
|
327
|
+
return setStartedOrCompletedStatuses(
|
|
328
|
+
normalizeContentIds(contentIds),
|
|
329
|
+
normalizeCollection(collection),
|
|
330
|
+
true
|
|
331
|
+
)
|
|
282
332
|
}
|
|
283
333
|
|
|
284
334
|
export async function contentStatusStarted(contentId, collection = null) {
|
|
285
|
-
return setStartedOrCompletedStatus(
|
|
335
|
+
return setStartedOrCompletedStatus(
|
|
336
|
+
normalizeContentId(contentId),
|
|
337
|
+
normalizeCollection(collection),
|
|
338
|
+
false
|
|
339
|
+
)
|
|
286
340
|
}
|
|
287
341
|
export async function contentStatusReset(contentId, collection = null) {
|
|
288
342
|
return resetStatus(contentId, collection)
|
|
289
343
|
}
|
|
290
344
|
|
|
291
345
|
async function saveContentProgress(contentId, collection, progress, currentSeconds) {
|
|
292
|
-
const response = await db.contentProgress.recordProgress(
|
|
346
|
+
const response = await db.contentProgress.recordProgress(
|
|
347
|
+
contentId,
|
|
348
|
+
collection,
|
|
349
|
+
progress,
|
|
350
|
+
currentSeconds
|
|
351
|
+
)
|
|
352
|
+
if (progress === 100) emitContentCompleted(contentId, collection)
|
|
293
353
|
|
|
294
354
|
// note - previous implementation explicitly did not trickle progress to children here
|
|
295
355
|
// (only to siblings/parents via le bubbles)
|
|
296
356
|
|
|
297
|
-
const bubbledProgresses = await bubbleProgress(
|
|
357
|
+
const bubbledProgresses = await bubbleProgress(
|
|
358
|
+
await getHierarchy(contentId, collection),
|
|
359
|
+
contentId,
|
|
360
|
+
collection
|
|
361
|
+
)
|
|
298
362
|
await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
299
363
|
|
|
364
|
+
for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
|
|
365
|
+
if (bubbledProgress === 100) {
|
|
366
|
+
emitContentCompleted(Number(bubbledContentId), collection)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
300
369
|
return response
|
|
301
370
|
}
|
|
302
371
|
|
|
303
372
|
async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
|
|
304
373
|
const progress = isCompleted ? 100 : 0
|
|
305
374
|
const response = await db.contentProgress.recordProgress(contentId, collection, progress)
|
|
306
|
-
|
|
375
|
+
if (progress === 100) emitContentCompleted(contentId, collection)
|
|
307
376
|
const hierarchy = await getHierarchy(contentId, collection)
|
|
308
|
-
|
|
309
377
|
await Promise.all([
|
|
310
|
-
db.contentProgress.recordProgressesTentative(
|
|
311
|
-
|
|
378
|
+
db.contentProgress.recordProgressesTentative(
|
|
379
|
+
trickleProgress(hierarchy, contentId, collection, progress),
|
|
380
|
+
collection
|
|
381
|
+
),
|
|
382
|
+
bubbleProgress(hierarchy, contentId, collection).then(async (bubbledProgresses) => {
|
|
383
|
+
await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
384
|
+
for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
|
|
385
|
+
if (bubbledProgress === 100) {
|
|
386
|
+
emitContentCompleted(Number(bubbledContentId), collection)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
312
390
|
])
|
|
313
|
-
|
|
314
391
|
return response
|
|
315
392
|
}
|
|
316
393
|
|
|
@@ -337,13 +414,11 @@ async function setStartedOrCompletedStatuses(contentIds, collection, isCompleted
|
|
|
337
414
|
ids = {
|
|
338
415
|
...ids,
|
|
339
416
|
...trickleProgress(hierarchy, contentId, collection, progress),
|
|
340
|
-
...await bubbleProgress(hierarchy, contentId, collection)
|
|
417
|
+
...(await bubbleProgress(hierarchy, contentId, collection)),
|
|
341
418
|
}
|
|
342
419
|
}
|
|
343
420
|
|
|
344
|
-
await Promise.all([
|
|
345
|
-
db.contentProgress.recordProgressesTentative(ids, collection),
|
|
346
|
-
]);
|
|
421
|
+
await Promise.all([db.contentProgress.recordProgressesTentative(ids, collection)])
|
|
347
422
|
|
|
348
423
|
return response
|
|
349
424
|
}
|
|
@@ -353,8 +428,13 @@ async function resetStatus(contentId, collection = null) {
|
|
|
353
428
|
const hierarchy = await getHierarchy(contentId, collection)
|
|
354
429
|
|
|
355
430
|
await Promise.all([
|
|
356
|
-
db.contentProgress.recordProgressesTentative(
|
|
357
|
-
|
|
431
|
+
db.contentProgress.recordProgressesTentative(
|
|
432
|
+
trickleProgress(hierarchy, contentId, collection, 0),
|
|
433
|
+
collection
|
|
434
|
+
),
|
|
435
|
+
bubbleProgress(hierarchy, contentId, collection).then((bubbledProgresses) =>
|
|
436
|
+
db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
437
|
+
),
|
|
358
438
|
])
|
|
359
439
|
|
|
360
440
|
return response
|
|
@@ -364,10 +444,10 @@ async function resetStatus(contentId, collection = null) {
|
|
|
364
444
|
// as long as callers remember to pass collection where needed
|
|
365
445
|
function trickleProgress(hierarchy, contentId, _collection, progress) {
|
|
366
446
|
const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
|
|
367
|
-
return Object.fromEntries(descendantIds.map(id => [id, progress]))
|
|
447
|
+
return Object.fromEntries(descendantIds.map((id) => [id, progress]))
|
|
368
448
|
}
|
|
369
449
|
|
|
370
|
-
async function bubbleProgress(hierarchy, contentId, collection = null)
|
|
450
|
+
async function bubbleProgress(hierarchy, contentId, collection = null) {
|
|
371
451
|
const ids = getAncestorAndSiblingIds(hierarchy, contentId)
|
|
372
452
|
const progresses = await getByIds(ids, collection, 'progress_percent', 0)
|
|
373
453
|
return averageProgressesFor(hierarchy, contentId, progresses)
|
|
@@ -386,7 +466,7 @@ function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
|
|
|
386
466
|
|
|
387
467
|
return [
|
|
388
468
|
...(hierarchy?.children?.[parentId] ?? []),
|
|
389
|
-
...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1)
|
|
469
|
+
...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1),
|
|
390
470
|
]
|
|
391
471
|
}
|
|
392
472
|
|
|
@@ -398,10 +478,13 @@ function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
|
|
|
398
478
|
const parentId = hierarchy?.parents?.[contentId]
|
|
399
479
|
if (!parentId) return {}
|
|
400
480
|
|
|
401
|
-
const parentChildProgress = hierarchy?.children?.[parentId]?.map(childId => {
|
|
481
|
+
const parentChildProgress = hierarchy?.children?.[parentId]?.map((childId) => {
|
|
402
482
|
return progressData[childId] ?? 0
|
|
403
483
|
})
|
|
404
|
-
const avgParentProgress =
|
|
484
|
+
const avgParentProgress =
|
|
485
|
+
parentChildProgress.length > 0
|
|
486
|
+
? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length)
|
|
487
|
+
: 0
|
|
405
488
|
|
|
406
489
|
return {
|
|
407
490
|
...averageProgressesFor(hierarchy, parentId, progressData, depth + 1),
|
|
@@ -426,7 +509,7 @@ function normalizeContentId(contentId) {
|
|
|
426
509
|
}
|
|
427
510
|
|
|
428
511
|
function normalizeContentIds(contentIds) {
|
|
429
|
-
return contentIds.map(id => normalizeContentId(id))
|
|
512
|
+
return contentIds.map((id) => normalizeContentId(id))
|
|
430
513
|
}
|
|
431
514
|
|
|
432
515
|
function normalizeCollection(collection) {
|
|
@@ -435,7 +518,7 @@ function normalizeCollection(collection) {
|
|
|
435
518
|
if (!Object.values(COLLECTION_TYPE).includes(collection.type)) {
|
|
436
519
|
throw new Error(`Invalid collection type: ${collection.type}`)
|
|
437
520
|
}
|
|
438
|
-
|
|
521
|
+
|
|
439
522
|
if (typeof collection.id === 'string' && isNaN(+collection.id)) {
|
|
440
523
|
throw new Error(`Invalid collection id: ${collection.id}`)
|
|
441
524
|
}
|
|
@@ -48,7 +48,7 @@ export function onProgressSaved(listener) {
|
|
|
48
48
|
* @returns {void}
|
|
49
49
|
*/
|
|
50
50
|
export function emitProgressSaved(event) {
|
|
51
|
-
listeners.forEach(listener => {
|
|
51
|
+
listeners.forEach((listener) => {
|
|
52
52
|
try {
|
|
53
53
|
listener(event)
|
|
54
54
|
} catch (error) {
|
|
@@ -56,3 +56,55 @@ export function emitProgressSaved(event) {
|
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} ContentCompletedEvent
|
|
62
|
+
* @property {number} contentId - Railcontent ID of the completed content item
|
|
63
|
+
* @property {Object|null} collection - Collection context information
|
|
64
|
+
* @property {string} collection.type - Collection type (learning-path, guided-course, etc.)
|
|
65
|
+
* @property {number} collection.id - Collection ID
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @callback ContentCompletedListener
|
|
70
|
+
* @param {ContentCompletedEvent} event - The content completion event data
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
const completedListeners = new Set()
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {ContentCompletedListener} listener - Function called when content is completed
|
|
77
|
+
* @returns {function(): void} Cleanup function to unregister the listener
|
|
78
|
+
*
|
|
79
|
+
* @example Listen for content completion
|
|
80
|
+
* const cleanup = onContentCompleted((event) => {
|
|
81
|
+
* console.log(`Content ${event.contentId} completed!`)
|
|
82
|
+
* if (event.collection) {
|
|
83
|
+
* console.log(`Within ${event.collection.type}: ${event.collection.id}`)
|
|
84
|
+
* checkCollectionProgress(event.collection.id)
|
|
85
|
+
* }
|
|
86
|
+
* })
|
|
87
|
+
*
|
|
88
|
+
* // Later, when no longer needed:
|
|
89
|
+
* cleanup()
|
|
90
|
+
*/
|
|
91
|
+
export function onContentCompleted(listener) {
|
|
92
|
+
completedListeners.add(listener)
|
|
93
|
+
return () => completedListeners.delete(listener)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {number} contentId - The ID of the completed content item
|
|
98
|
+
* @param {Object|null} collection - Collection context information
|
|
99
|
+
* @returns {void}
|
|
100
|
+
*/
|
|
101
|
+
export function emitContentCompleted(contentId, collection) {
|
|
102
|
+
const event = { contentId: contentId, collection: collection }
|
|
103
|
+
completedListeners.forEach((listener) => {
|
|
104
|
+
try {
|
|
105
|
+
listener(event)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error in contentCompleted listener:', error)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
@@ -14,6 +14,9 @@ import { inBoundary } from './errors/boundary'
|
|
|
14
14
|
import createStoresFromConfig from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
16
|
|
|
17
|
+
import { onProgressSaved, onContentCompleted } from '../progress-events'
|
|
18
|
+
import { onContentCompletedLearningPathListener } from '../content-org/learning-paths'
|
|
19
|
+
|
|
17
20
|
export default class SyncManager {
|
|
18
21
|
private static counter = 0
|
|
19
22
|
private static instance: SyncManager | null = null
|
|
@@ -30,6 +33,10 @@ export default class SyncManager {
|
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
public static getInstanceOrNull() {
|
|
37
|
+
return SyncManager.instance
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
public static getInstance(): SyncManager {
|
|
34
41
|
if (!SyncManager.instance) {
|
|
35
42
|
throw new SyncError('SyncManager not initialized')
|
|
@@ -72,17 +79,26 @@ export default class SyncManager {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
|
|
75
|
-
return new SyncStore<TModel>(
|
|
82
|
+
return new SyncStore<TModel>(
|
|
83
|
+
config,
|
|
84
|
+
this.context,
|
|
85
|
+
this.database,
|
|
86
|
+
this.retry,
|
|
87
|
+
this.runScope,
|
|
88
|
+
this.telemetry
|
|
89
|
+
)
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
|
|
79
|
-
return Object.fromEntries(
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
stores.map((store) => {
|
|
95
|
+
return [store.model.table, store]
|
|
96
|
+
})
|
|
97
|
+
) as Record<string, SyncStore<TModel>>
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
storesForModels(models: ModelClass[]) {
|
|
85
|
-
return models.map(model => this.storesRegistry[model.table])
|
|
101
|
+
return models.map((model) => this.storesRegistry[model.table])
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
createStrategy<T extends SyncStrategy, U extends any[]>(
|
|
@@ -96,11 +112,8 @@ export default class SyncManager {
|
|
|
96
112
|
this.strategyMap.push({ stores, strategies })
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
protectStores(
|
|
100
|
-
|
|
101
|
-
mechanisms: SyncConcurrencySafetyMechanism[]
|
|
102
|
-
) {
|
|
103
|
-
const teardowns = mechanisms.map(mechanism => mechanism(this.context, stores))
|
|
115
|
+
protectStores(stores: SyncStore<any>[], mechanisms: SyncConcurrencySafetyMechanism[]) {
|
|
116
|
+
const teardowns = mechanisms.map((mechanism) => mechanism(this.context, stores))
|
|
104
117
|
this.safetyMap.push({ stores, mechanisms: teardowns })
|
|
105
118
|
}
|
|
106
119
|
|
|
@@ -111,9 +124,9 @@ export default class SyncManager {
|
|
|
111
124
|
this.retry.start()
|
|
112
125
|
|
|
113
126
|
this.strategyMap.forEach(({ stores, strategies }) => {
|
|
114
|
-
strategies.forEach(strategy => {
|
|
115
|
-
stores.forEach(store => {
|
|
116
|
-
strategy.onTrigger(store, reason => {
|
|
127
|
+
strategies.forEach((strategy) => {
|
|
128
|
+
stores.forEach((store) => {
|
|
129
|
+
strategy.onTrigger(store, (reason) => {
|
|
117
130
|
store.requestSync(reason)
|
|
118
131
|
})
|
|
119
132
|
})
|
|
@@ -121,15 +134,18 @@ export default class SyncManager {
|
|
|
121
134
|
})
|
|
122
135
|
})
|
|
123
136
|
|
|
124
|
-
contentProgressObserver.start(this.database).catch(error => {
|
|
137
|
+
contentProgressObserver.start(this.database).catch((error) => {
|
|
125
138
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
126
139
|
})
|
|
140
|
+
onContentCompleted(onContentCompletedLearningPathListener)
|
|
127
141
|
|
|
128
142
|
const teardown = async () => {
|
|
129
143
|
this.telemetry.debug('[SyncManager] Tearing down')
|
|
130
144
|
this.runScope.abort()
|
|
131
|
-
this.strategyMap.forEach(({ strategies }) =>
|
|
132
|
-
|
|
145
|
+
this.strategyMap.forEach(({ strategies }) =>
|
|
146
|
+
strategies.forEach((strategy) => strategy.stop())
|
|
147
|
+
)
|
|
148
|
+
this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach((mechanism) => mechanism()))
|
|
133
149
|
contentProgressObserver.stop()
|
|
134
150
|
this.retry.stop()
|
|
135
151
|
this.context.stop()
|
|
@@ -270,7 +270,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
270
270
|
if (recreate) {
|
|
271
271
|
return this.collection.prepareCreate(record => {
|
|
272
272
|
record._raw.id = id
|
|
273
|
-
record._raw.created_at = recreate.created_at
|
|
273
|
+
record._raw.created_at = recreate.created_at as EpochMs
|
|
274
274
|
record._raw.updated_at = this.generateTimestamp()
|
|
275
275
|
record._raw._status = 'created'
|
|
276
276
|
builder(record)
|
package/test/initializeTests.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { globalConfig, initializeService } from '../src'
|
|
2
2
|
import { LocalStorageMock } from './localStorageMock'
|
|
3
|
+
import { initializeSyncManager } from './sync/initialize-sync-manager'
|
|
3
4
|
const railContentModule = require('../src/services/railcontent.js')
|
|
4
5
|
let token = null
|
|
5
6
|
let userId = process.env.RAILCONTENT_USER_ID ?? null
|
|
@@ -43,7 +44,7 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
|
|
|
43
44
|
baseUrl: process.env.RAILCONTENT_BASE_URL || 'https://test.musora.com',
|
|
44
45
|
token: token,
|
|
45
46
|
userId: userId,
|
|
46
|
-
authToken: token
|
|
47
|
+
authToken: token,
|
|
47
48
|
},
|
|
48
49
|
sessionConfig: { token: token, userId: userId, authToken: token },
|
|
49
50
|
baseUrl: process.env.RAILCONTENT_BASE_URL,
|
|
@@ -51,9 +52,10 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
|
|
|
51
52
|
isMA: true,
|
|
52
53
|
}
|
|
53
54
|
initializeService(config)
|
|
54
|
-
|
|
55
55
|
// Mock user permissions
|
|
56
56
|
let permissionsMock = jest.spyOn(railContentModule, 'fetchUserPermissionsData')
|
|
57
57
|
let permissionsData = { permissions: [108, 91, 92], isAdmin: isAdmin }
|
|
58
58
|
permissionsMock.mockImplementation(() => permissionsData)
|
|
59
|
+
|
|
60
|
+
initializeSyncManager(userId)
|
|
59
61
|
}
|
|
@@ -1,21 +1,70 @@
|
|
|
1
1
|
import { initializeTestService } from './initializeTests.js'
|
|
2
2
|
import {
|
|
3
3
|
fetchLearningPathLessons,
|
|
4
|
-
|
|
4
|
+
getEnrichedLearningPath,
|
|
5
|
+
startLearningPath,
|
|
6
|
+
resetAllLearningPaths,
|
|
7
|
+
getActivePath,
|
|
5
8
|
} from '../src/services/content-org/learning-paths.ts'
|
|
6
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
contentStatusCompleted,
|
|
11
|
+
contentStatusReset,
|
|
12
|
+
getProgressDataByIds,
|
|
13
|
+
} from '../src/services/contentProgress.js'
|
|
7
14
|
describe('learning-paths', function () {
|
|
8
15
|
beforeEach(async () => {
|
|
9
16
|
await initializeTestService(true)
|
|
10
17
|
})
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
test('getlearningPathLessonsTestNew', async () => {
|
|
16
|
-
await contentStatusCompleted(417105)
|
|
17
|
-
const userDate = new Date('2025-10-31')
|
|
18
|
-
const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
19
|
-
console.log(results)
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
// Flush all pending promises
|
|
21
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
20
22
|
})
|
|
23
|
+
|
|
24
|
+
// test('getLearningPathsV2Test', async () => {
|
|
25
|
+
// const results = await getEnrichedLearningPath(417140)
|
|
26
|
+
// })
|
|
27
|
+
// test('getlearningPathLessonsTestNew', async () => {
|
|
28
|
+
// await contentStatusCompleted(417105)
|
|
29
|
+
// const userDate = new Date('2025-10-31')
|
|
30
|
+
// const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
31
|
+
// console.log(results)
|
|
32
|
+
// })
|
|
33
|
+
// test('getlearningPathLessonsTestNew', async () => {
|
|
34
|
+
// await contentStatusCompleted(417105)
|
|
35
|
+
// const userDate = new Date('2025-10-31')
|
|
36
|
+
// const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
37
|
+
// console.log(results)
|
|
38
|
+
// })
|
|
39
|
+
|
|
40
|
+
test('learningPathCompletion', async () => {
|
|
41
|
+
const learningPathId = 435527
|
|
42
|
+
await contentStatusReset(learningPathId)
|
|
43
|
+
await resetAllLearningPaths()
|
|
44
|
+
await startLearningPath('drumeo', learningPathId)
|
|
45
|
+
const collection = { type: 'learning-path-v2', id: learningPathId }
|
|
46
|
+
const learningPath = await getEnrichedLearningPath(learningPathId)
|
|
47
|
+
|
|
48
|
+
// Complete each child one by one
|
|
49
|
+
for (const child of learningPath.children) {
|
|
50
|
+
await contentStatusReset(child.id)
|
|
51
|
+
await contentStatusCompleted(child.id, collection)
|
|
52
|
+
|
|
53
|
+
// Check child status
|
|
54
|
+
const childProgress = await getProgressDataByIds([child.id], collection)
|
|
55
|
+
|
|
56
|
+
// Check parent status after each child
|
|
57
|
+
const parentProgress = await getProgressDataByIds([learningPathId], collection)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Final check - parent should be completed
|
|
61
|
+
const finalParentProgress = await getProgressDataByIds([learningPathId], collection)
|
|
62
|
+
console.log('\n--- Final parent progress:', finalParentProgress)
|
|
63
|
+
expect(finalParentProgress[learningPathId]?.status).toBe('completed')
|
|
64
|
+
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
66
|
+
|
|
67
|
+
const activePath = await getActivePath('drumeo')
|
|
68
|
+
expect(activePath.active_learning_path_id).toBe(435563)
|
|
69
|
+
}, 15000)
|
|
21
70
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
2
|
+
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
3
|
+
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
4
|
+
|
|
5
|
+
export default function syncStoreAdapter(userId: string, bus: SyncAdapterEventBus) {
|
|
6
|
+
return adapterFactory(LokiJSAdapter, `user:${userId}`, {
|
|
7
|
+
useWebWorker: false,
|
|
8
|
+
useIncrementalIndexedDB: true,
|
|
9
|
+
extraLokiOptions: {
|
|
10
|
+
autosave: true,
|
|
11
|
+
autosaveInterval: 300, // increase for better performance at cost of potential data loss on tab close/crash
|
|
12
|
+
},
|
|
13
|
+
onQuotaExceededError: () => {
|
|
14
|
+
// Browser ran out of disk space or possibly in incognito mode
|
|
15
|
+
// called ONLY at startup
|
|
16
|
+
// ideal place to trigger banner (?) to user when also offline?
|
|
17
|
+
// (so that the non-customizable browser default onbeforeunload confirmation (in offline-unload-warning.ts) has context and makes sense)
|
|
18
|
+
bus.emit('quotaExceededError')
|
|
19
|
+
},
|
|
20
|
+
onSetUpError: () => {
|
|
21
|
+
// TODO - Database failed to load -- offer the user to reload the app or log out
|
|
22
|
+
},
|
|
23
|
+
extraIncrementalIDBOptions: {
|
|
24
|
+
lazyCollections: ['content_like'],
|
|
25
|
+
onDidOverwrite: () => {
|
|
26
|
+
// Called when this adapter is forced to overwrite contents of IndexedDB.
|
|
27
|
+
// This happens if there's another open tab of the same app that's making changes.
|
|
28
|
+
// this scenario is handled-ish in `idb-clobber-avoidance`
|
|
29
|
+
},
|
|
30
|
+
onversionchange: () => {
|
|
31
|
+
// no-op
|
|
32
|
+
// indexeddb was deleted in another browser tab (user logged out), so we must make sure we delete
|
|
33
|
+
// in-memory db in this tab as well,
|
|
34
|
+
// but we rely on sync manager setup/teardown to `unsafeResetDatabase` and redirect for this,
|
|
35
|
+
// though reloading the page might be useful as well
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SyncAdapterEventBus extends EventEmitter<{ quotaExceededError: [] }> {}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { SyncManager, SyncContext } from '../../src/services/sync/index'
|
|
2
|
+
import {
|
|
3
|
+
BaseSessionProvider,
|
|
4
|
+
BaseConnectivityProvider,
|
|
5
|
+
BaseDurabilityProvider,
|
|
6
|
+
BaseTabsProvider,
|
|
7
|
+
BaseVisibilityProvider,
|
|
8
|
+
} from '../../src/services/sync/context/providers/'
|
|
9
|
+
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
10
|
+
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
11
|
+
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
12
|
+
import { InitialStrategy, PollingStrategy } from '../../src/services/sync/strategies/index'
|
|
13
|
+
import { SyncTelemetry, SentryLike } from '../../src/services/sync/telemetry/index'
|
|
14
|
+
import {
|
|
15
|
+
ContentLike,
|
|
16
|
+
ContentProgress,
|
|
17
|
+
Practice,
|
|
18
|
+
PracticeDayNote,
|
|
19
|
+
} from '../../src/services/sync/models/index'
|
|
20
|
+
import syncDatabaseFactory from '../../src/services/sync/database/factory'
|
|
21
|
+
|
|
22
|
+
import syncAdapter, { SyncAdapterEventBus } from './adapter'
|
|
23
|
+
|
|
24
|
+
export function initializeSyncManager(userId) {
|
|
25
|
+
if (SyncManager.getInstanceOrNull()) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
const dummySentry = {
|
|
29
|
+
captureException: () => '',
|
|
30
|
+
addBreadcrumb: () => {},
|
|
31
|
+
startSpan: (options, callback) => {
|
|
32
|
+
// Return a mock span object with the properties Sentry expects
|
|
33
|
+
const mockSpan = {
|
|
34
|
+
_spanId: 'mock-span-id',
|
|
35
|
+
end: () => {},
|
|
36
|
+
setStatus: () => {},
|
|
37
|
+
setData: () => {},
|
|
38
|
+
setAttribute: () => {},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (callback) {
|
|
42
|
+
return callback(mockSpan)
|
|
43
|
+
}
|
|
44
|
+
return mockSpan
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SyncTelemetry.setInstance(new SyncTelemetry(userId, { Sentry: dummySentry }))
|
|
49
|
+
|
|
50
|
+
const adapterBus = new SyncAdapterEventBus()
|
|
51
|
+
const adapter = syncAdapter(userId, adapterBus)
|
|
52
|
+
const db = syncDatabaseFactory(adapter)
|
|
53
|
+
|
|
54
|
+
const context = new SyncContext({
|
|
55
|
+
session: {
|
|
56
|
+
getClientId: () => 'test-client-id',
|
|
57
|
+
getSessionId: () => null,
|
|
58
|
+
start: () => {},
|
|
59
|
+
stop: () => {},
|
|
60
|
+
},
|
|
61
|
+
connectivity: {
|
|
62
|
+
getValue: () => true,
|
|
63
|
+
subscribe: () => () => {},
|
|
64
|
+
notifyListeners: () => {},
|
|
65
|
+
start: () => {},
|
|
66
|
+
stop: () => {},
|
|
67
|
+
},
|
|
68
|
+
visibility: {
|
|
69
|
+
getValue: () => true,
|
|
70
|
+
subscribe: () => () => {},
|
|
71
|
+
notifyListeners: () => {},
|
|
72
|
+
start: () => {},
|
|
73
|
+
stop: () => {},
|
|
74
|
+
},
|
|
75
|
+
tabs: {
|
|
76
|
+
hasOtherTabs: () => false,
|
|
77
|
+
broadcast: () => {},
|
|
78
|
+
subscribe: () => () => {},
|
|
79
|
+
start: () => {},
|
|
80
|
+
stop: () => {},
|
|
81
|
+
},
|
|
82
|
+
durability: {
|
|
83
|
+
getValue: () => true,
|
|
84
|
+
start: () => {},
|
|
85
|
+
stop: () => {},
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
const manager = new SyncManager(context, db)
|
|
89
|
+
|
|
90
|
+
const initialStrategy = manager.createStrategy(InitialStrategy)
|
|
91
|
+
const aggressivePollingStrategy = manager.createStrategy(PollingStrategy, 3600_000)
|
|
92
|
+
|
|
93
|
+
manager.syncStoresWithStrategies(
|
|
94
|
+
manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
|
|
95
|
+
[initialStrategy, aggressivePollingStrategy]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
manager.protectStores(
|
|
99
|
+
manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
|
|
100
|
+
[]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
SyncManager.assignAndSetupInstance(manager)
|
|
104
|
+
}
|