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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)"
6
+ ],
7
+ "deny": []
8
+ }
9
+ }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.112.1",
3
+ "version": "2.113.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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 { definitions, progress } = await db.userAwardProgress.getAwardsForContent(contentId)
125
+ const data = await db.userAwardProgress.getAwardsForContent(contentId)
125
126
 
126
- const awards = definitions.map(def => {
127
- const userProgress = progress.get(def._id)
128
- const completionData = enhanceCompletionData(userProgress?.completion_data)
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: true,
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
- const response = (await addContextToLearningPaths(
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
- COLLECTION_TYPE.LEARNING_PATH,
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
- const response = (await addContextToLearningPaths(
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
- COLLECTION_TYPE.LEARNING_PATH,
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, hideFromProgressRow = 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, hideFromProgressRow}
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
- maxProgressTimestamp = learningPath.active_learning_path_created_at
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 hide_from_progress_row() {
57
- return this._getRaw('hide_from_progress_row') as boolean
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 hide_from_progress_row(value: boolean) {
94
- this._setRaw('hide_from_progress_row', value)
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('hide_from_progress_row', false),
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, hideFromProgressRow = 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
- r.hide_from_progress_row = hideFromProgressRow
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, hideFromProgressRow = false }: { tentative?: boolean; skipPush?: boolean; hideFromProgressRow?: boolean } = {}
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
- r.hide_from_progress_row = hideFromProgressRow
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
- for (const awardId of awardIds) {
125
- const result = await this.getByAwardId(awardId)
126
- if (result.data) {
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: 'hide_from_progress_row', type: 'boolean'},
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
  ]