musora-content-services 2.112.1 → 2.113.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 +15 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/services/awards/award-query.js +100 -20
- package/src/services/awards/internal/award-definitions.js +17 -0
- package/src/services/content-org/learning-paths.ts +11 -4
- package/src/services/contentAggregator.js +9 -0
- package/src/services/contentProgress.js +44 -41
- package/src/services/progress-row/rows/method-card.js +2 -1
- package/src/services/sync/models/ContentProgress.ts +5 -4
- package/src/services/sync/repositories/content-progress.ts +17 -5
- package/src/services/sync/repositories/user-award-progress.ts +54 -5
- package/src/services/sync/schema/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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.113.0](https://github.com/railroadmedia/musora-content-services/compare/v2.112.2...v2.113.0) (2026-01-08)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **BEH-1491:** proper card ordering (and hiding) on progress row ([#686](https://github.com/railroadmedia/musora-content-services/issues/686)) ([e519c35](https://github.com/railroadmedia/musora-content-services/commit/e519c352ac5e8d09a910b89fa03baf31490da102))
|
|
11
|
+
|
|
12
|
+
### [2.112.2](https://github.com/railroadmedia/musora-content-services/compare/v2.112.1...v2.112.2) (2026-01-08)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* changes lesson type mapping ([#690](https://github.com/railroadmedia/musora-content-services/issues/690)) ([af4dda9](https://github.com/railroadmedia/musora-content-services/commit/af4dda9e03eecfd49a341d2c1aea8afcf217e233))
|
|
18
|
+
* **T3PS-1187:** award progress optimizations ([#689](https://github.com/railroadmedia/musora-content-services/issues/689)) ([5d063b8](https://github.com/railroadmedia/musora-content-services/commit/5d063b8227e9ee5c70c30bc31263296bfcb4aa63))
|
|
19
|
+
|
|
5
20
|
### [2.112.1](https://github.com/railroadmedia/musora-content-services/compare/v2.112.0...v2.112.1) (2026-01-07)
|
|
6
21
|
|
|
7
22
|
## [2.112.0](https://github.com/railroadmedia/musora-content-services/compare/v2.111.5...v2.112.0) (2026-01-07)
|
package/README.md
CHANGED
|
@@ -54,12 +54,43 @@ the `excludeFromGeneratedIndex` array inside the service file.
|
|
|
54
54
|
|
|
55
55
|
## Publishing Package Updates
|
|
56
56
|
|
|
57
|
-
To publish a new version to NPM run,
|
|
57
|
+
To publish a new version to NPM run,
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
60
|
./publish.sh
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## NPM reauthentication
|
|
64
|
+
|
|
65
|
+
If you see this error displayed when publishing, you need to reauthenticate:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
|
|
69
|
+
npm notice Access token expired or revoked. Please try logging in again.
|
|
70
|
+
npm ERR! code E404
|
|
71
|
+
npm ERR! 404 Not Found - PUT https://registry.npmjs.org/musora-content-services - Not found
|
|
72
|
+
npm ERR! 404
|
|
73
|
+
npm ERR! 404 'musora-content-services@2.99.6' is not in this registry.
|
|
74
|
+
npm ERR! 404
|
|
75
|
+
npm ERR! 404 Note that you can also install from a
|
|
76
|
+
npm ERR! 404 tarball, folder, http url, or git url.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use the shared musora_dev account in 1password ("NPM Access Token For musora_dev")
|
|
80
|
+
and update this value as `npmAccessToken` in the railenvironment credentials file.
|
|
81
|
+
|
|
82
|
+
Alternatively, request your own account or renew your own token.
|
|
83
|
+
Update railenvironment credentials file with your new account details
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npmUserName=
|
|
87
|
+
npmPassword=
|
|
88
|
+
npmAccessToken=
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Either restart the manager container (`docker restart railenvironmentdocker_manager`)
|
|
92
|
+
or run `./usr/local/bin/setup-npm.sh` inside the container to update the `~/.npmrc` file.
|
|
93
|
+
|
|
63
94
|
## Symlinking
|
|
64
95
|
|
|
65
96
|
To link this package to the MWP repo for local development run,
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getAwardStatistics,
|
|
10
10
|
getCompletedAwards,
|
|
11
11
|
getContentAwards,
|
|
12
|
+
getContentAwardsByIds,
|
|
12
13
|
getInProgressAwards,
|
|
13
14
|
resetAllAwards
|
|
14
15
|
} from './services/awards/award-query.js';
|
|
@@ -265,6 +266,7 @@ import {
|
|
|
265
266
|
fetchComingSoon,
|
|
266
267
|
fetchCommentModContentData,
|
|
267
268
|
fetchContentRows,
|
|
269
|
+
fetchContentTypeCounts,
|
|
268
270
|
fetchHierarchy,
|
|
269
271
|
fetchLearningPathHierarchy,
|
|
270
272
|
fetchLeaving,
|
|
@@ -495,6 +497,7 @@ declare module 'musora-content-services' {
|
|
|
495
497
|
fetchCommunityGuidelines,
|
|
496
498
|
fetchContentPageUserData,
|
|
497
499
|
fetchContentRows,
|
|
500
|
+
fetchContentTypeCounts,
|
|
498
501
|
fetchCustomerPayments,
|
|
499
502
|
fetchEnrollmentPageMetadata,
|
|
500
503
|
fetchFollowedThreads,
|
|
@@ -582,6 +585,7 @@ declare module 'musora-content-services' {
|
|
|
582
585
|
getAwardStatistics,
|
|
583
586
|
getCompletedAwards,
|
|
584
587
|
getContentAwards,
|
|
588
|
+
getContentAwardsByIds,
|
|
585
589
|
getContentRows,
|
|
586
590
|
getDailySession,
|
|
587
591
|
getEnrichedLearningPath,
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getAwardStatistics,
|
|
14
14
|
getCompletedAwards,
|
|
15
15
|
getContentAwards,
|
|
16
|
+
getContentAwardsByIds,
|
|
16
17
|
getInProgressAwards,
|
|
17
18
|
resetAllAwards
|
|
18
19
|
} from './services/awards/award-query.js';
|
|
@@ -269,6 +270,7 @@ import {
|
|
|
269
270
|
fetchComingSoon,
|
|
270
271
|
fetchCommentModContentData,
|
|
271
272
|
fetchContentRows,
|
|
273
|
+
fetchContentTypeCounts,
|
|
272
274
|
fetchHierarchy,
|
|
273
275
|
fetchLearningPathHierarchy,
|
|
274
276
|
fetchLeaving,
|
|
@@ -494,6 +496,7 @@ export {
|
|
|
494
496
|
fetchCommunityGuidelines,
|
|
495
497
|
fetchContentPageUserData,
|
|
496
498
|
fetchContentRows,
|
|
499
|
+
fetchContentTypeCounts,
|
|
497
500
|
fetchCustomerPayments,
|
|
498
501
|
fetchEnrollmentPageMetadata,
|
|
499
502
|
fetchFollowedThreads,
|
|
@@ -581,6 +584,7 @@ export {
|
|
|
581
584
|
getAwardStatistics,
|
|
582
585
|
getCompletedAwards,
|
|
583
586
|
getContentAwards,
|
|
587
|
+
getContentAwardsByIds,
|
|
584
588
|
getContentRows,
|
|
585
589
|
getDailySession,
|
|
586
590
|
getEnrichedLearningPath,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* **Query Functions** (read-only):
|
|
8
8
|
* - `getContentAwards(contentId)` - Get awards for a learning path/course
|
|
9
|
+
* - `getContentAwardsByIds(contentIds)` - Get awards for multiple content items (batch optimized)
|
|
9
10
|
* - `getCompletedAwards(brand)` - Get user's earned awards
|
|
10
11
|
* - `getInProgressAwards(brand)` - Get awards user is working toward
|
|
11
12
|
* - `getAwardStatistics(brand)` - Get aggregate award stats
|
|
@@ -121,30 +122,17 @@ export async function getContentAwards(contentId) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const
|
|
125
|
+
const data = await db.userAwardProgress.getAwardsForContent(contentId)
|
|
125
126
|
|
|
126
|
-
const awards = definitions.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
awardId: def._id,
|
|
132
|
-
awardTitle: def.name,
|
|
133
|
-
badge: def.badge,
|
|
134
|
-
award: def.award,
|
|
135
|
-
brand: def.brand,
|
|
136
|
-
instructorName: def.instructor_name,
|
|
137
|
-
progressPercentage: userProgress?.progress_percentage ?? 0,
|
|
138
|
-
isCompleted: userProgress ? UserAwardProgressRepository.isCompleted(userProgress) : false,
|
|
139
|
-
completedAt: userProgress?.completed_at,
|
|
140
|
-
completionData
|
|
141
|
-
}
|
|
142
|
-
})
|
|
127
|
+
const awards = data && data.definitions.length !== 0
|
|
128
|
+
? defineAwards(data)
|
|
129
|
+
: []
|
|
143
130
|
|
|
144
131
|
return {
|
|
145
|
-
hasAwards:
|
|
146
|
-
awards
|
|
132
|
+
hasAwards: awards.length > 0,
|
|
133
|
+
awards,
|
|
147
134
|
}
|
|
135
|
+
|
|
148
136
|
} catch (error) {
|
|
149
137
|
console.error(`Failed to get award status for content ${contentId}:`, error)
|
|
150
138
|
return {
|
|
@@ -154,6 +142,98 @@ export async function getContentAwards(contentId) {
|
|
|
154
142
|
}
|
|
155
143
|
}
|
|
156
144
|
|
|
145
|
+
/**
|
|
146
|
+
* @param {number[]} contentIds - Array of Railcontent IDs to fetch awards for
|
|
147
|
+
* @returns {Promise<Object.<number, ContentAwardsResponse>>} Object mapping content IDs to their award data
|
|
148
|
+
*
|
|
149
|
+
* @description
|
|
150
|
+
* Returns awards for multiple content items at once. More efficient than calling
|
|
151
|
+
* `getContentAwards()` multiple times. Returns an object where keys are content IDs
|
|
152
|
+
* and values are the same structure as `getContentAwards()`.
|
|
153
|
+
*
|
|
154
|
+
* Content IDs without awards will have `{ hasAwards: false, awards: [] }` in the result.
|
|
155
|
+
*
|
|
156
|
+
* Returns empty object `{}` on error (never throws).
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* const learningPathIds = [12345, 67890, 11111]
|
|
160
|
+
* const awardsMap = await getContentAwardsByIds(learningPathIds)
|
|
161
|
+
*
|
|
162
|
+
* learningPathIds.forEach(id => {
|
|
163
|
+
* const { hasAwards, awards } = awardsMap[id] || { hasAwards: false, awards: [] }
|
|
164
|
+
* if (hasAwards) {
|
|
165
|
+
* console.log(`Learning path ${id} has ${awards.length} award(s)`)
|
|
166
|
+
* }
|
|
167
|
+
* })
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* function CourseListWithAwards({ courseIds }) {
|
|
171
|
+
* const [awardsMap, setAwardsMap] = useState({})
|
|
172
|
+
*
|
|
173
|
+
* useEffect(() => {
|
|
174
|
+
* getContentAwardsByIds(courseIds).then(setAwardsMap)
|
|
175
|
+
* }, [courseIds])
|
|
176
|
+
*
|
|
177
|
+
* return courseIds.map(courseId => {
|
|
178
|
+
* const { hasAwards, awards } = awardsMap[courseId] || { hasAwards: false, awards: [] }
|
|
179
|
+
* return (
|
|
180
|
+
* <CourseCard key={courseId} courseId={courseId}>
|
|
181
|
+
* {hasAwards && <AwardBadge award={awards[0]} />}
|
|
182
|
+
* </CourseCard>
|
|
183
|
+
* )
|
|
184
|
+
* })
|
|
185
|
+
* }
|
|
186
|
+
*/
|
|
187
|
+
export async function getContentAwardsByIds(contentIds) {
|
|
188
|
+
try {
|
|
189
|
+
if (!Array.isArray(contentIds) || contentIds.length === 0) {
|
|
190
|
+
return {}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const awardsDataMap = await db.userAwardProgress.getAwardsForContentMany(contentIds)
|
|
194
|
+
|
|
195
|
+
const result = {}
|
|
196
|
+
|
|
197
|
+
contentIds.forEach(contentId => {
|
|
198
|
+
const data = awardsDataMap.get(contentId) // {definitions, progress}
|
|
199
|
+
|
|
200
|
+
const awards = data && data.definitions.length !== 0
|
|
201
|
+
? defineAwards(data)
|
|
202
|
+
: []
|
|
203
|
+
|
|
204
|
+
result[contentId] = {
|
|
205
|
+
hasAwards: awards.length > 0,
|
|
206
|
+
awards,
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return result
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error(`Failed to get award status for content IDs ${contentIds}:`, error)
|
|
213
|
+
return {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function defineAwards(data) {
|
|
218
|
+
return data.definitions.map(def => {
|
|
219
|
+
const userProgress = data.progress.get(def._id)
|
|
220
|
+
const completionData = enhanceCompletionData(userProgress?.completion_data)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
awardId: def._id,
|
|
224
|
+
awardTitle: def.name,
|
|
225
|
+
badge: def.badge,
|
|
226
|
+
award: def.award,
|
|
227
|
+
brand: def.brand,
|
|
228
|
+
instructorName: def.instructor_name,
|
|
229
|
+
progressPercentage: userProgress?.progress_percentage ?? 0,
|
|
230
|
+
isCompleted: userProgress ? UserAwardProgressRepository.isCompleted(userProgress) : false,
|
|
231
|
+
completedAt: userProgress?.completed_at,
|
|
232
|
+
completionData
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
157
237
|
/**
|
|
158
238
|
* @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
|
|
159
239
|
* @param {AwardPaginationOptions} [options={}] - Optional pagination and filtering
|
|
@@ -60,6 +60,23 @@ class AwardDefinitionsService {
|
|
|
60
60
|
.filter(Boolean)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** @returns {Promise<Map<number, import('./types').AwardDefinition[]>>} */
|
|
64
|
+
async getByContentIds(contentIds) {
|
|
65
|
+
if (this.shouldRefresh()) {
|
|
66
|
+
await this.fetchFromSanity()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Map(
|
|
70
|
+
contentIds.map(contentId => {
|
|
71
|
+
const awardIds = this.contentIndex.get(contentId) || []
|
|
72
|
+
const definitions = awardIds
|
|
73
|
+
.map(id => this.definitions.get(id))
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
return [contentId, definitions]
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
/** @returns {Promise<boolean>} */
|
|
64
81
|
async hasAwards(contentId) {
|
|
65
82
|
if (this.shouldRefresh()) {
|
|
@@ -20,6 +20,7 @@ import { getToday } from "../dateUtils.js";
|
|
|
20
20
|
|
|
21
21
|
const BASE_PATH: string = `/api/content-org`
|
|
22
22
|
const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
|
|
23
|
+
const LEARNING_PATH_LESSON = 'learning-path-lesson-v2'
|
|
23
24
|
|
|
24
25
|
interface ActiveLearningPathResponse {
|
|
25
26
|
user_id: number
|
|
@@ -119,7 +120,7 @@ export async function resetAllLearningPaths() {
|
|
|
119
120
|
* @returns {Promise<Object>} Learning path with enriched lesson data
|
|
120
121
|
*/
|
|
121
122
|
export async function getEnrichedLearningPath(learningPathId) {
|
|
122
|
-
|
|
123
|
+
let response = (await addContextToLearningPaths(
|
|
123
124
|
fetchByRailContentId,
|
|
124
125
|
learningPathId,
|
|
125
126
|
COLLECTION_TYPE.LEARNING_PATH,
|
|
@@ -134,11 +135,13 @@ export async function getEnrichedLearningPath(learningPathId) {
|
|
|
134
135
|
addNavigateTo: true,
|
|
135
136
|
}
|
|
136
137
|
)) as any
|
|
138
|
+
// add awards to LP parents only
|
|
139
|
+
response = await addContextToLearningPaths(() => response, {addAwards:true})
|
|
137
140
|
if (!response) return response
|
|
138
141
|
|
|
139
142
|
response.children = mapContentToParent(
|
|
140
143
|
response.children,
|
|
141
|
-
|
|
144
|
+
LEARNING_PATH_LESSON,
|
|
142
145
|
learningPathId
|
|
143
146
|
)
|
|
144
147
|
return response
|
|
@@ -150,7 +153,7 @@ export async function getEnrichedLearningPath(learningPathId) {
|
|
|
150
153
|
* @returns {Promise<Object>} Learning paths with enriched lesson data
|
|
151
154
|
*/
|
|
152
155
|
export async function getEnrichedLearningPaths(learningPathIds: number[]) {
|
|
153
|
-
|
|
156
|
+
let response = (await addContextToLearningPaths(
|
|
154
157
|
fetchByRailContentIds,
|
|
155
158
|
learningPathIds,
|
|
156
159
|
COLLECTION_TYPE.LEARNING_PATH,
|
|
@@ -165,12 +168,15 @@ export async function getEnrichedLearningPaths(learningPathIds: number[]) {
|
|
|
165
168
|
addNavigateTo: true,
|
|
166
169
|
}
|
|
167
170
|
)) as any
|
|
171
|
+
// add awards to LP parents only
|
|
172
|
+
response = await addContextToLearningPaths(() => response, {addAwards:true})
|
|
173
|
+
|
|
168
174
|
if (!response) return response
|
|
169
175
|
|
|
170
176
|
response.forEach((learningPath) => {
|
|
171
177
|
learningPath.children = mapContentToParent(
|
|
172
178
|
learningPath.children,
|
|
173
|
-
|
|
179
|
+
LEARNING_PATH_LESSON,
|
|
174
180
|
learningPath.id
|
|
175
181
|
)
|
|
176
182
|
})
|
|
@@ -197,6 +203,7 @@ export async function getLearningPathLessonsByIds(contentIds, learningPathId) {
|
|
|
197
203
|
* @param parentContentId
|
|
198
204
|
*/
|
|
199
205
|
export function mapContentToParent(lessons, parentContentType, parentContentId) {
|
|
206
|
+
if (!lessons) return lessons
|
|
200
207
|
return lessons.map((lesson: any) => {
|
|
201
208
|
return { ...lesson, type: parentContentType, parent_id: parentContentId }
|
|
202
209
|
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { isContentLikedByIds } from './contentLikes'
|
|
11
11
|
import { fetchLikeCount } from './railcontent'
|
|
12
12
|
import {COLLECTION_TYPE} from "./sync/models/ContentProgress";
|
|
13
|
+
import {getContentAwardsByIds} from "./awards/award-query.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Combine sanity data with BE contextual data.
|
|
@@ -73,6 +74,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
|
|
|
73
74
|
addProgressTimestamp = false,
|
|
74
75
|
addResumeTimeSeconds = false,
|
|
75
76
|
addNavigateTo = false,
|
|
77
|
+
addAwards = false,
|
|
76
78
|
} = options
|
|
77
79
|
|
|
78
80
|
const dataParam = lastArg === options ? dataArgs.slice(0, -1) : dataArgs
|
|
@@ -91,12 +93,14 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
|
|
|
91
93
|
isLikedData,
|
|
92
94
|
resumeTimeData,
|
|
93
95
|
navigateToData,
|
|
96
|
+
awards,
|
|
94
97
|
] = await Promise.all([ //for now assume these all return `collection = {type, id}`. it will be so when watermelon here
|
|
95
98
|
addProgressPercentage || addProgressStatus || addProgressTimestamp
|
|
96
99
|
? getProgressDataByIds(ids, collection) : Promise.resolve(null),
|
|
97
100
|
addIsLiked ? isContentLikedByIds(ids, collection) : Promise.resolve(null),
|
|
98
101
|
addResumeTimeSeconds ? getResumeTimeSecondsByIds(ids, collection) : Promise.resolve(null),
|
|
99
102
|
addNavigateTo ? getNavigateTo(items, collection) : Promise.resolve(null),
|
|
103
|
+
addAwards ? getContentAwardsByIds(ids) : Promise.resolve(null),
|
|
100
104
|
])
|
|
101
105
|
|
|
102
106
|
const addContext = async (item) => ({
|
|
@@ -108,6 +112,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
|
|
|
108
112
|
...(addLikeCount && ids.length === 1 ? { likeCount: await fetchLikeCount(item.id) } : {}),
|
|
109
113
|
...(addResumeTimeSeconds ? { resumeTime: resumeTimeData?.[item.id] } : {}),
|
|
110
114
|
...(addNavigateTo ? { navigateTo: navigateToData?.[item.id] } : {}),
|
|
115
|
+
...(addAwards ? { awards: awards?.[item.id].awards || [] } : {}),
|
|
111
116
|
})
|
|
112
117
|
|
|
113
118
|
return await processItems(data, addContext, dataField, isDataAnArray, dataField_includeParent)
|
|
@@ -163,6 +168,7 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
|
|
|
163
168
|
addLikeCount = false,
|
|
164
169
|
addResumeTimeSeconds = false,
|
|
165
170
|
addNavigateTo = false,
|
|
171
|
+
addAwards = false,
|
|
166
172
|
} = options
|
|
167
173
|
|
|
168
174
|
const dataParam = lastArg === options ? dataArgs.slice(0, -1) : dataArgs
|
|
@@ -189,12 +195,14 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
|
|
|
189
195
|
isLikedData,
|
|
190
196
|
resumeTimeData,
|
|
191
197
|
navigateToData,
|
|
198
|
+
awards,
|
|
192
199
|
] = await Promise.all([
|
|
193
200
|
addProgressPercentage || addProgressStatus || addProgressTimestamp
|
|
194
201
|
? getProgressDataByIdsAndCollections(ids) : Promise.resolve(null),
|
|
195
202
|
addIsLiked ? isContentLikedByIds(justIds) : Promise.resolve(null),
|
|
196
203
|
addResumeTimeSeconds ? getResumeTimeSecondsByIdsAndCollections(ids) : Promise.resolve(null),
|
|
197
204
|
addNavigateTo ? getNavigateToForMethod(items) : Promise.resolve(null),
|
|
205
|
+
addAwards ? getContentAwardsByIds(justIds) : Promise.resolve(null),
|
|
198
206
|
])
|
|
199
207
|
|
|
200
208
|
const addContext = async (item) => {
|
|
@@ -208,6 +216,7 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
|
|
|
208
216
|
...(addLikeCount && ids.length === 1 ? { likeCount: await fetchLikeCount(itemId) } : {}),
|
|
209
217
|
...(addResumeTimeSeconds ? { resumeTime: resumeTimeData?.[itemId] } : {}),
|
|
210
218
|
...(addNavigateTo ? { navigateTo: navigateToData?.[itemId] } : {}),
|
|
219
|
+
...(addAwards ? { awards: awards?.[itemId].awards || [] } : {}),
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
// Enrich intro_video if it exists and flag is set
|
|
@@ -467,7 +467,7 @@ export async function contentStatusReset(contentId, collection = null, {skipPush
|
|
|
467
467
|
return resetStatus(contentId, collection, {skipPush})
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false,
|
|
470
|
+
async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false, fromLearningPath = false} = {}) {
|
|
471
471
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
472
472
|
|
|
473
473
|
// filter out contentIds that are setting progress lower than existing
|
|
@@ -482,12 +482,14 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
482
482
|
collection,
|
|
483
483
|
progress,
|
|
484
484
|
currentSeconds,
|
|
485
|
-
{skipPush: true,
|
|
485
|
+
{skipPush: true, fromLearningPath}
|
|
486
486
|
)
|
|
487
487
|
// note - previous implementation explicitly did not trickle progress to children here
|
|
488
488
|
// (only to siblings/parents via le bubbles)
|
|
489
489
|
|
|
490
|
+
// skip bubbling if progress hasnt changed
|
|
490
491
|
if (progress === currentProgress) {
|
|
492
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
491
493
|
return
|
|
492
494
|
}
|
|
493
495
|
|
|
@@ -561,45 +563,6 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
561
563
|
return response
|
|
562
564
|
}
|
|
563
565
|
|
|
564
|
-
// we cannot simply pass LP id with self collection, because we do not have a-la-carte LP's set up yet,
|
|
565
|
-
// and we need each lesson to bubble to its parent outside of LP
|
|
566
|
-
async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy, {skipPush = false} = {}) {
|
|
567
|
-
// filter out LPs. we dont want to duplicate to LP's while we dont have a-la-cart LP's set up.
|
|
568
|
-
let filteredIds = Object.fromEntries(
|
|
569
|
-
Object.entries(ids).filter(([id]) => {
|
|
570
|
-
return hierarchy.parents[parseInt(id)] !== null
|
|
571
|
-
})
|
|
572
|
-
)
|
|
573
|
-
|
|
574
|
-
const extProgresses = await getProgressDataByIds(Object.keys(filteredIds), null)
|
|
575
|
-
|
|
576
|
-
// overwrite if LP progress greater, unless LP progress was reset to 0
|
|
577
|
-
filteredIds = Object.entries(filteredIds).filter(([id, pct]) => {
|
|
578
|
-
const extPct = extProgresses[id]?.progress
|
|
579
|
-
return (pct !== 0)
|
|
580
|
-
? pct > extPct
|
|
581
|
-
: false
|
|
582
|
-
})
|
|
583
|
-
|
|
584
|
-
// each handles its own bubbling.
|
|
585
|
-
// skipPush on all but last to avoid multiple push requests
|
|
586
|
-
filteredIds.forEach(([id, pct], index) => {
|
|
587
|
-
let skip = true
|
|
588
|
-
if (index === filteredIds.length - 1) {
|
|
589
|
-
skip = skipPush
|
|
590
|
-
}
|
|
591
|
-
saveContentProgress(parseInt(id), null, pct, null, {skipPush: skip, hideFromProgressRow: true})
|
|
592
|
-
})
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async function getHierarchy(contentId, collection) {
|
|
596
|
-
if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
597
|
-
return await fetchLearningPathHierarchy(contentId, collection)
|
|
598
|
-
} else {
|
|
599
|
-
return await fetchHierarchy(contentId)
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
566
|
async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {skipPush = false} = {}) {
|
|
604
567
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
605
568
|
const progress = isCompleted ? 100 : 0
|
|
@@ -651,6 +614,7 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
|
|
|
651
614
|
|
|
652
615
|
const progress = 0
|
|
653
616
|
const response = await db.contentProgress.eraseProgress(contentId, collection, {skipPush: true})
|
|
617
|
+
|
|
654
618
|
const hierarchy = await getHierarchy(contentId, collection)
|
|
655
619
|
|
|
656
620
|
let progresses = {
|
|
@@ -670,6 +634,45 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
|
|
|
670
634
|
return response
|
|
671
635
|
}
|
|
672
636
|
|
|
637
|
+
// we cannot simply pass LP id with self collection, because we do not have a-la-carte LP's set up yet,
|
|
638
|
+
// and we need each lesson to bubble to its parent outside of LP
|
|
639
|
+
async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy, {skipPush = false} = {}) {
|
|
640
|
+
// filter out LPs. we dont want to duplicate to LP's while we dont have a-la-cart LP's set up.
|
|
641
|
+
let filteredIds = Object.fromEntries(
|
|
642
|
+
Object.entries(ids).filter(([id]) => {
|
|
643
|
+
return hierarchy.parents[parseInt(id)] !== null
|
|
644
|
+
})
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
const extProgresses = await getProgressDataByIds(Object.keys(filteredIds), null)
|
|
648
|
+
|
|
649
|
+
// overwrite if LP progress greater, unless LP progress was reset to 0
|
|
650
|
+
filteredIds = Object.entries(filteredIds).filter(([id, pct]) => {
|
|
651
|
+
const extPct = extProgresses[id]?.progress
|
|
652
|
+
return (pct !== 0)
|
|
653
|
+
? pct > extPct
|
|
654
|
+
: false
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// each handles its own bubbling.
|
|
658
|
+
// skipPush on all but last to avoid multiple push requests
|
|
659
|
+
filteredIds.forEach(([id, pct], index) => {
|
|
660
|
+
let skip = true
|
|
661
|
+
if (index === filteredIds.length - 1) {
|
|
662
|
+
skip = skipPush
|
|
663
|
+
}
|
|
664
|
+
saveContentProgress(parseInt(id), null, pct, null, {skipPush: skip, fromLearningPath: true})
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function getHierarchy(contentId, collection) {
|
|
669
|
+
if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
670
|
+
return await fetchLearningPathHierarchy(contentId, collection)
|
|
671
|
+
} else {
|
|
672
|
+
return await fetchHierarchy(contentId)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
673
676
|
// agnostic to collection - makes returned data structure simpler,
|
|
674
677
|
// as long as callers remember to pass collection where needed
|
|
675
678
|
function trickleProgress(hierarchy, contentId, _collection, progress) {
|
|
@@ -107,7 +107,8 @@ export async function getMethodCard(brand) {
|
|
|
107
107
|
)
|
|
108
108
|
|
|
109
109
|
if (!maxProgressTimestamp) {
|
|
110
|
-
|
|
110
|
+
// active LP created_at is stored in seconds, so *1000 to match rest of cards
|
|
111
|
+
maxProgressTimestamp = learningPath.active_learning_path_created_at * 1000
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
return {
|
|
@@ -29,6 +29,7 @@ export default class ContentProgress extends BaseModel<{
|
|
|
29
29
|
state: STATE
|
|
30
30
|
progress_percent: number
|
|
31
31
|
resume_time_seconds: number | null
|
|
32
|
+
last_interacted_a_la_carte: number | null
|
|
32
33
|
}> {
|
|
33
34
|
static table = SYNC_TABLES.CONTENT_PROGRESS
|
|
34
35
|
|
|
@@ -53,8 +54,8 @@ export default class ContentProgress extends BaseModel<{
|
|
|
53
54
|
get resume_time_seconds() {
|
|
54
55
|
return (this._getRaw('resume_time_seconds') as number) || null
|
|
55
56
|
}
|
|
56
|
-
get
|
|
57
|
-
return this._getRaw('
|
|
57
|
+
get last_interacted_a_la_carte() {
|
|
58
|
+
return this._getRaw('last_interacted_a_la_carte') as number
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
set content_id(value: number) {
|
|
@@ -90,8 +91,8 @@ export default class ContentProgress extends BaseModel<{
|
|
|
90
91
|
throwIfNotNullableNumber(value)
|
|
91
92
|
this._setRaw('resume_time_seconds', value !== null ? throwIfOutsideRange(value, 0, 65535) : value)
|
|
92
93
|
}
|
|
93
|
-
set
|
|
94
|
-
this._setRaw('
|
|
94
|
+
set last_interacted_a_la_carte(value: number) {
|
|
95
|
+
this._setRaw('last_interacted_a_la_carte', value)
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
}
|
|
@@ -62,7 +62,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
62
62
|
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
63
63
|
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
64
64
|
|
|
65
|
-
Q.where('
|
|
65
|
+
Q.where('last_interacted_a_la_carte', Q.notEq(null)),
|
|
66
66
|
|
|
67
67
|
Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
|
|
68
68
|
Q.sortBy('updated_at', 'desc'),
|
|
@@ -135,9 +135,13 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false,
|
|
138
|
+
recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false, fromLearningPath = false} = {}) {
|
|
139
139
|
const id = ProgressRepository.generateId(contentId, collection)
|
|
140
140
|
|
|
141
|
+
if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
142
|
+
fromLearningPath = true
|
|
143
|
+
}
|
|
144
|
+
|
|
141
145
|
const result = this.upsertOne(id, (r) => {
|
|
142
146
|
r.content_id = contentId
|
|
143
147
|
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
@@ -149,7 +153,10 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
149
153
|
r.resume_time_seconds = Math.floor(resumeTime)
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
|
|
156
|
+
if (!fromLearningPath) {
|
|
157
|
+
r.last_interacted_a_la_carte = Date.now()
|
|
158
|
+
}
|
|
159
|
+
|
|
153
160
|
}, { skipPush })
|
|
154
161
|
|
|
155
162
|
// Emit event AFTER database write completes
|
|
@@ -180,8 +187,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
180
187
|
recordProgressMany(
|
|
181
188
|
contentProgresses: Record<string, number>, // Accept plain object
|
|
182
189
|
collection: CollectionParameter | null,
|
|
183
|
-
{ tentative = true, skipPush = false,
|
|
190
|
+
{ tentative = true, skipPush = false, fromLearningPath = false }: { tentative?: boolean; skipPush?: boolean; fromLearningPath?: boolean } = {}
|
|
184
191
|
) {
|
|
192
|
+
if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
193
|
+
fromLearningPath = true
|
|
194
|
+
}
|
|
185
195
|
|
|
186
196
|
const data = Object.fromEntries(
|
|
187
197
|
Object.entries(contentProgresses).map(([contentId, progressPct]) => [
|
|
@@ -193,7 +203,9 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
193
203
|
|
|
194
204
|
r.progress_percent = progressPct
|
|
195
205
|
|
|
196
|
-
|
|
206
|
+
if (!fromLearningPath) {
|
|
207
|
+
r.last_interacted_a_la_carte = Date.now()
|
|
208
|
+
}
|
|
197
209
|
},
|
|
198
210
|
])
|
|
199
211
|
)
|
|
@@ -63,6 +63,13 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
|
|
|
63
63
|
return this.readOne(awardId)
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
async getByAwardIds(awardIds: string[]) {
|
|
67
|
+
if (awardIds.length === 0) {
|
|
68
|
+
return { data: [] }
|
|
69
|
+
}
|
|
70
|
+
return this.readSome(awardIds)
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
async hasCompletedAward(awardId: string): Promise<boolean> {
|
|
67
74
|
const result = await this.readOne(awardId)
|
|
68
75
|
if (!result.data) return false
|
|
@@ -121,16 +128,58 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
|
|
|
121
128
|
const awardIds = definitions.map(d => d._id)
|
|
122
129
|
const progressMap = new Map<string, ModelSerialized<UserAwardProgress>>()
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
progressMap.set(awardId, result.data)
|
|
128
|
-
}
|
|
131
|
+
const progressResults = await this.getByAwardIds(awardIds)
|
|
132
|
+
for (const progress of progressResults.data) {
|
|
133
|
+
progressMap.set(progress.award_id, progress)
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
return { definitions, progress: progressMap }
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
async getAwardsForContentMany(contentIds: number[]): Promise<Map<number, {
|
|
140
|
+
definitions: AwardDefinition[]
|
|
141
|
+
progress: Map<string, ModelSerialized<UserAwardProgress>>
|
|
142
|
+
}>> {
|
|
143
|
+
const { awardDefinitions } = await import('../../awards/internal/award-definitions')
|
|
144
|
+
|
|
145
|
+
const contentToDefinitionsMap = await awardDefinitions.getByContentIds(contentIds)
|
|
146
|
+
|
|
147
|
+
const allAwardIds = new Set<string>()
|
|
148
|
+
contentToDefinitionsMap.forEach(definitions => {
|
|
149
|
+
definitions.forEach(d => allAwardIds.add(d._id))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const progressResults = await this.getByAwardIds(Array.from(allAwardIds))
|
|
153
|
+
const globalProgressMap = new Map<string, ModelSerialized<UserAwardProgress>>()
|
|
154
|
+
for (const progress of progressResults.data) {
|
|
155
|
+
globalProgressMap.set(progress.award_id, progress)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const resultMap = new Map<number, {
|
|
159
|
+
definitions: AwardDefinition[]
|
|
160
|
+
progress: Map<string, ModelSerialized<UserAwardProgress>>
|
|
161
|
+
}>()
|
|
162
|
+
|
|
163
|
+
contentIds.forEach(contentId => {
|
|
164
|
+
const definitions = contentToDefinitionsMap.get(contentId) || []
|
|
165
|
+
const contentProgressMap = new Map<string, ModelSerialized<UserAwardProgress>>()
|
|
166
|
+
|
|
167
|
+
definitions.forEach(def => {
|
|
168
|
+
const progress = globalProgressMap.get(def._id)
|
|
169
|
+
if (progress) {
|
|
170
|
+
contentProgressMap.set(def._id, progress)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
resultMap.set(contentId, {
|
|
175
|
+
definitions,
|
|
176
|
+
progress: contentProgressMap
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return resultMap
|
|
181
|
+
}
|
|
182
|
+
|
|
134
183
|
async deleteAllAwards() {
|
|
135
184
|
const allProgress = await this.getAll()
|
|
136
185
|
const ids = allProgress.data.map(p => p.id)
|
|
@@ -26,7 +26,7 @@ const contentProgressTable = tableSchema({
|
|
|
26
26
|
{ name: 'state', type: 'string', isIndexed: true },
|
|
27
27
|
{ name: 'progress_percent', type: 'number' },
|
|
28
28
|
{ name: 'resume_time_seconds', type: 'number', isOptional: true },
|
|
29
|
-
{ name: '
|
|
29
|
+
{ name: 'last_interacted_a_la_carte', type: 'number', isOptional: true },
|
|
30
30
|
{ name: 'created_at', type: 'number' },
|
|
31
31
|
{ name: 'updated_at', type: 'number', isIndexed: true }
|
|
32
32
|
]
|