musora-content-services 2.94.8 → 2.95.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/CHANGELOG.md +18 -0
  3. package/CLAUDE.md +408 -0
  4. package/babel.config.cjs +10 -0
  5. package/jsdoc.json +2 -1
  6. package/package.json +2 -2
  7. package/src/constants/award-assets.js +35 -0
  8. package/src/filterBuilder.js +7 -2
  9. package/src/index.d.ts +26 -5
  10. package/src/index.js +26 -5
  11. package/src/services/awards/award-callbacks.js +165 -0
  12. package/src/services/awards/award-query.js +495 -0
  13. package/src/services/awards/internal/.indexignore +1 -0
  14. package/src/services/awards/internal/award-definitions.js +239 -0
  15. package/src/services/awards/internal/award-events.js +102 -0
  16. package/src/services/awards/internal/award-manager.js +162 -0
  17. package/src/services/awards/internal/certificate-builder.js +66 -0
  18. package/src/services/awards/internal/completion-data-generator.js +84 -0
  19. package/src/services/awards/internal/content-progress-observer.js +137 -0
  20. package/src/services/awards/internal/image-utils.js +62 -0
  21. package/src/services/awards/internal/message-generator.js +17 -0
  22. package/src/services/awards/internal/types.js +5 -0
  23. package/src/services/awards/types.d.ts +79 -0
  24. package/src/services/awards/types.js +101 -0
  25. package/src/services/config.js +24 -4
  26. package/src/services/content-org/learning-paths.ts +19 -15
  27. package/src/services/gamification/awards.ts +114 -83
  28. package/src/services/progress-events.js +58 -0
  29. package/src/services/progress-row/method-card.js +20 -5
  30. package/src/services/sanity.js +1 -1
  31. package/src/services/sync/fetch.ts +10 -2
  32. package/src/services/sync/manager.ts +6 -0
  33. package/src/services/sync/models/ContentProgress.ts +5 -6
  34. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  35. package/src/services/sync/models/index.ts +1 -0
  36. package/src/services/sync/repositories/content-progress.ts +47 -25
  37. package/src/services/sync/repositories/index.ts +1 -0
  38. package/src/services/sync/repositories/practices.ts +16 -1
  39. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  40. package/src/services/sync/repository-proxy.ts +6 -0
  41. package/src/services/sync/retry.ts +12 -11
  42. package/src/services/sync/schema/index.ts +18 -3
  43. package/src/services/sync/store/index.ts +53 -8
  44. package/src/services/sync/store/push-coalescer.ts +3 -3
  45. package/src/services/sync/store-configs.ts +7 -1
  46. package/src/services/userActivity.js +0 -1
  47. package/test/HttpClient.test.js +6 -6
  48. package/test/awards/award-alacarte-observer.test.js +196 -0
  49. package/test/awards/award-auto-refresh.test.js +83 -0
  50. package/test/awards/award-calculations.test.js +33 -0
  51. package/test/awards/award-certificate-display.test.js +328 -0
  52. package/test/awards/award-collection-edge-cases.test.js +210 -0
  53. package/test/awards/award-collection-filtering.test.js +285 -0
  54. package/test/awards/award-completion-flow.test.js +213 -0
  55. package/test/awards/award-exclusion-handling.test.js +273 -0
  56. package/test/awards/award-multi-lesson.test.js +241 -0
  57. package/test/awards/award-observer-integration.test.js +325 -0
  58. package/test/awards/award-query-messages.test.js +438 -0
  59. package/test/awards/award-user-collection.test.js +412 -0
  60. package/test/awards/duplicate-prevention.test.js +118 -0
  61. package/test/awards/helpers/completion-mock.js +54 -0
  62. package/test/awards/helpers/index.js +3 -0
  63. package/test/awards/helpers/mock-setup.js +69 -0
  64. package/test/awards/helpers/progress-emitter.js +39 -0
  65. package/test/awards/message-generator.test.js +162 -0
  66. package/test/initializeTests.js +6 -0
  67. package/test/mockData/award-definitions.js +171 -0
  68. package/test/sync/models/award-database-integration.test.js +519 -0
  69. package/tools/generate-index.cjs +9 -0
@@ -8,32 +8,12 @@ import { globalConfig } from '../config'
8
8
 
9
9
  const baseUrl = `/api/gamification`
10
10
 
11
- export interface Award {
12
- id: number
13
- user_id: number
14
- completed_at: string // ISO-8601 timestamp
15
- completion_data: {
16
- message?: string
17
- message_certificate?: string
18
- content_title?: string
19
- completed_at?: Date
20
- days_user_practiced?: number
21
- practice_minutes?: number
22
- [key: string]: any
23
- }
24
- award_id: number
25
- type: string
26
- title: string
27
- badge: string
28
- }
29
-
30
11
  export interface Certificate {
31
- id: number
12
+ award_id: string
32
13
  user_name: string
33
14
  user_id: number
34
- completed_at: string // ISO-8601 timestamp
15
+ completed_at: string
35
16
  message: string
36
- award_id: number
37
17
  type: string
38
18
  title: string
39
19
  musora_logo: string
@@ -52,72 +32,123 @@ export interface Certificate {
52
32
  }
53
33
 
54
34
  /**
55
- * Get awards for a specific user.
35
+ * Fetch certificate data for a completed award with all images converted to base64.
36
+ * Returns certificate information ready for rendering in a PDF or image format.
37
+ * All image URLs (logos, signatures, ribbons) are converted to base64 strings for offline use.
56
38
  *
57
- * NOTE: needs error handling for the response from http client
58
- * (Alexandre: I'm doing it in a different branch/PR: https://github.com/railroadmedia/musora-content-services/pull/349)
59
- * NOTE: This function still expects brand because FE passes the argument. It is ignored for now
39
+ * @param {string} awardId - Unique Sanity award ID
60
40
  *
61
- * @param {number|null} [userId] - The user ID. If not provided, the authenticated user is used instead.
62
- * @param {string|null} [_brand] - The brand to fetch the awards for.
63
- * @param {number|null} [page=1] - Page attribute for pagination
64
- * @param {number|null} [limit=5] - Limit how many items to return
65
- * @returns {Promise<PaginatedResponse<Award>>} - The awards for the user.
66
- * @throws {HttpError} - If the HTTP request fails.
67
- */
68
- export async function fetchAwardsForUser(
69
- userId?: number,
70
- _brand?: string,
71
- page: number = 1,
72
- limit: number = 5
73
- ): Promise<PaginatedResponse<Award>> {
74
- if (!userId) {
75
- userId = Number.parseInt(globalConfig.sessionConfig.userId)
76
- }
77
-
78
- const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
79
- const response = await httpClient.get<PaginatedResponse<Award>>(
80
- `${baseUrl}/v1/users/${userId}/awards?limit=${limit}&page=${page}`
81
- )
82
-
83
- return response
84
- }
85
-
86
- /**
87
- * Get award progress for the guided course lesson for the authorized user.
41
+ * @returns {Promise<Certificate>} Certificate object with base64-encoded images:
42
+ * - award_id {string} - Sanity award ID
43
+ * - user_name {string} - User's display name
44
+ * - user_id {number} - User's ID
45
+ * - completed_at {string} - ISO timestamp of completion
46
+ * - message {string} - Certificate message for display
47
+ * - type {string} - Award type (e.g., 'content-award')
48
+ * - title {string} - Award title/name
49
+ * - musora_logo {string} - URL to Musora logo
50
+ * - musora_logo_64 {string} - Base64-encoded Musora logo
51
+ * - musora_bg_logo {string} - URL to Musora background logo
52
+ * - musora_bg_logo_64 {string} - Base64-encoded background logo
53
+ * - brand_logo {string} - URL to brand logo
54
+ * - brand_logo_64 {string} - Base64-encoded brand logo
55
+ * - ribbon_image {string} - URL to ribbon decoration
56
+ * - ribbon_image_64 {string} - Base64-encoded ribbon image
57
+ * - award_image {string} - URL to award image
58
+ * - award_image_64 {string} - Base64-encoded award image
59
+ * - instructor_name {string} - Instructor's name
60
+ * - instructor_signature {string|undefined} - URL to signature (if available)
61
+ * - instructor_signature_64 {string|undefined} - Base64-encoded signature
88
62
  *
89
- * NOTE: needs error handling for the response from http client
90
- * (Alexandre: I'm doing it in a different branch/PR: https://github.com/railroadmedia/musora-content-services/pull/349)
91
- * NOTE: This function still expects brand because FE passes the argument. It is ignored for now
63
+ * @throws {Error} If award is not found or not completed
92
64
  *
93
- * @param {number} guidedCourseLessonId - The guided course lesson Id
94
- * @returns {Promise<Award>} - The award data for a given award and given user.
95
- * @throws {HttpError} - If the HTTP request fails.
96
- */
97
- export async function getAwardDataForGuidedContent(guidedCourseLessonId: number): Promise<Award> {
98
- const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
99
- const response = await httpClient.get<Award>(
100
- `${baseUrl}/v1/users/guided_course_award/${guidedCourseLessonId}`
101
- )
102
-
103
- return response
104
- }
105
-
106
- /**
107
- * Get certificate data for a completed user award
65
+ * @platform Web only - This function uses browser-only APIs (FileReader, Blob).
66
+ * For React Native implementation, see the React Native section below.
67
+ *
68
+ * @example Generate certificate PDF (Web)
69
+ * const cert = await fetchCertificate('abc-123')
70
+ * generatePDF({
71
+ * userName: cert.user_name,
72
+ * awardTitle: cert.title,
73
+ * completedAt: new Date(cert.completed_at).toLocaleDateString(),
74
+ * message: cert.message,
75
+ * brandLogo: `data:image/png;base64,${cert.brand_logo_64}`,
76
+ * signature: cert.instructor_signature_64
77
+ * ? `data:image/png;base64,${cert.instructor_signature_64}`
78
+ * : null
79
+ * })
108
80
  *
109
- * NOTE: needs error handling for the response from http client
110
- * (Alexandre: I'm doing it in a different branch/PR: https://github.com/railroadmedia/musora-content-services/pull/349)
111
- * NOTE: This function still expects brand because FE passes the argument. It is ignored for now
81
+ * @example Display certificate preview (Web)
82
+ * const cert = await fetchCertificate(awardId)
83
+ * return (
84
+ * <CertificatePreview
85
+ * userName={cert.user_name}
86
+ * awardTitle={cert.title}
87
+ * message={cert.message}
88
+ * awardImage={`data:image/png;base64,${cert.award_image_64}`}
89
+ * instructorName={cert.instructor_name}
90
+ * signature={cert.instructor_signature_64}
91
+ * />
92
+ * )
112
93
  *
113
- * @param {number} userAwardId - The user award progress id
114
- * @returns {Promise<Certificate>} - The certificate data for the completed user award.
115
- * @throws {HttpError} - If the HTTP request fails.
94
+ * @example React Native Implementation
95
+ * // This function is NOT compatible with React Native due to FileReader/Blob APIs.
96
+ * // For React Native, implement certificate generation using:
97
+ * //
98
+ * // 1. Use react-native-blob-util for base64 image conversion:
99
+ * // import ReactNativeBlobUtil from 'react-native-blob-util'
100
+ * // const base64 = await ReactNativeBlobUtil.fetch('GET', imageUrl)
101
+ * // .then(res => res.base64())
102
+ * //
103
+ * // 2. Use react-native-html-to-pdf for PDF generation:
104
+ * // import RNHTMLtoPDF from 'react-native-html-to-pdf'
105
+ * // const pdf = await RNHTMLtoPDF.convert({
106
+ * // html: certificateHtmlTemplate,
107
+ * // fileName: `certificate-${awardId}`,
108
+ * // directory: 'Documents',
109
+ * // })
110
+ * //
111
+ * // 3. Build certificate data using getContentAwards() or getCompletedAwards()
112
+ * // which ARE React Native compatible, then handle image conversion
113
+ * // and PDF generation in your RN app layer.
116
114
  */
117
- export async function fetchCertificate(userAwardId: number): Promise<Certificate> {
118
- const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
119
- const response = await httpClient.get<Certificate>(
120
- `${baseUrl}/v1/users/certificate/${userAwardId}`
121
- )
122
- return response
115
+ export async function fetchCertificate(awardId: string): Promise<Certificate> {
116
+ const { buildCertificateData } = await import('../awards/internal/certificate-builder')
117
+ const { urlMapToBase64 } = await import('../awards/internal/image-utils')
118
+
119
+ const certData = await buildCertificateData(awardId)
120
+
121
+ const imageMap = {
122
+ ribbon_image_64: certData.ribbonImage,
123
+ award_image_64: certData.awardImage,
124
+ musora_bg_logo_64: certData.musoraBgLogo,
125
+ brand_logo_64: certData.brandLogo,
126
+ musora_logo_64: certData.musoraLogo,
127
+ ...(certData.instructorSignature && { instructor_signature_64: certData.instructorSignature })
128
+ }
129
+
130
+ const base64Images = await urlMapToBase64(imageMap)
131
+
132
+ return {
133
+ award_id: awardId,
134
+ user_name: certData.userName,
135
+ user_id: certData.userId,
136
+ completed_at: certData.completedAt,
137
+ message: certData.certificateMessage,
138
+ type: certData.awardType,
139
+ title: certData.awardTitle,
140
+ musora_logo: certData.musoraLogo,
141
+ musora_logo_64: base64Images.musora_logo_64,
142
+ musora_bg_logo: certData.musoraBgLogo,
143
+ musora_bg_logo_64: base64Images.musora_bg_logo_64,
144
+ brand_logo: certData.brandLogo,
145
+ brand_logo_64: base64Images.brand_logo_64,
146
+ ribbon_image: certData.ribbonImage,
147
+ ribbon_image_64: base64Images.ribbon_image_64,
148
+ award_image: certData.awardImage,
149
+ award_image_64: base64Images.award_image_64,
150
+ instructor_name: certData.instructorName,
151
+ instructor_signature: certData.instructorSignature,
152
+ instructor_signature_64: base64Images.instructor_signature_64
153
+ }
123
154
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module ProgressEvents
3
+ */
4
+
5
+ /**
6
+ * @typedef {Object} ProgressSavedEvent
7
+ * @property {number} userId - User ID
8
+ * @property {number} contentId - Railcontent ID of the content item
9
+ * @property {number} progressPercent - Completion percentage (0-100)
10
+ * @property {string} progressStatus - Progress state (started, completed)
11
+ * @property {boolean} bubble - Whether to bubble the event up to parent content
12
+ * @property {string|null} collectionType - Collection type (learning-path, guided-course, etc.)
13
+ * @property {number|null} collectionId - Collection ID if within a collection context
14
+ * @property {number|null} resumeTimeSeconds - Resume position in seconds for video content
15
+ * @property {number} timestamp - Unix timestamp when the event occurred
16
+ */
17
+
18
+ /**
19
+ * @callback ProgressSavedListener
20
+ * @param {ProgressSavedEvent} event - The progress event data
21
+ * @returns {void}
22
+ */
23
+
24
+ const listeners = new Set()
25
+
26
+ /**
27
+ * @param {ProgressSavedListener} listener - Function called when progress is saved
28
+ * @returns {function(): void} Cleanup function to unregister the listener
29
+ *
30
+ * @example Listen for progress updates
31
+ * const cleanup = onProgressSaved((event) => {
32
+ * console.log(`Content ${event.contentId}: ${event.progressPercent}%`)
33
+ * if (event.state === 'completed') {
34
+ * showCompletionAnimation()
35
+ * }
36
+ * })
37
+ *
38
+ * // Later, when no longer needed:
39
+ * cleanup()
40
+ */
41
+ export function onProgressSaved(listener) {
42
+ listeners.add(listener)
43
+ return () => listeners.delete(listener)
44
+ }
45
+
46
+ /**
47
+ * @param {ProgressSavedEvent} event - The progress event to emit
48
+ * @returns {void}
49
+ */
50
+ export function emitProgressSaved(event) {
51
+ listeners.forEach(listener => {
52
+ try {
53
+ listener(event)
54
+ } catch (error) {
55
+ console.error('Error in progressSaved listener:', error)
56
+ }
57
+ })
58
+ }
@@ -10,6 +10,11 @@ import { COLLECTION_TYPE } from '../sync/models/ContentProgress'
10
10
 
11
11
  export async function getMethodCard(brand) {
12
12
  const introVideo = await fetchMethodV2IntroVideo(brand)
13
+
14
+ if (!introVideo) {
15
+ return null
16
+ }
17
+
13
18
  const introVideoProgressState = await getProgressState(introVideo?.id)
14
19
 
15
20
  const activeLearningPath = await getActivePath(brand)
@@ -48,9 +53,11 @@ export async function getMethodCard(brand) {
48
53
  const allCompleted = learningPath?.todays_lessons.every(
49
54
  (lesson) => lesson.progressStatus === 'completed'
50
55
  )
56
+
51
57
  const anyCompleted = learningPath?.todays_lessons.some(
52
58
  (lesson) => lesson.progressStatus === 'completed'
53
59
  )
60
+
54
61
  const noneCompleted = learningPath?.todays_lessons.every(
55
62
  (lesson) => lesson.progressStatus !== 'completed'
56
63
  )
@@ -58,6 +65,13 @@ export async function getMethodCard(brand) {
58
65
  const nextIncompleteLesson = learningPath?.todays_lessons.find(
59
66
  (lesson) => lesson.progressStatus !== 'completed'
60
67
  )
68
+
69
+ // get the first incomplete lesson from upcoming and next learning path lessons
70
+ const nextLesson = [
71
+ ...learningPath?.upcoming_lessons,
72
+ ...learningPath?.next_learning_path_lessons,
73
+ ]?.find((lesson) => lesson.progressStatus !== 'completed')
74
+
61
75
  let ctaText, action
62
76
  if (noneCompleted) {
63
77
  ctaText = 'Start Session'
@@ -66,18 +80,19 @@ export async function getMethodCard(brand) {
66
80
  ctaText = 'Continue Session'
67
81
  action = getMethodActionCTA(nextIncompleteLesson)
68
82
  } else if (allCompleted) {
69
- ctaText = learningPath.next_lesson ? 'Start Next Lesson' : 'Browse Lessons'
70
- action = learningPath.next_lesson
71
- ? getMethodActionCTA(learningPath.next_lesson)
83
+ ctaText = nextLesson ? 'Start Next Lesson' : 'Browse Lessons'
84
+ action = nextLesson
85
+ ? getMethodActionCTA(nextLesson)
72
86
  : {
73
- type: 'lessons',
74
- brand: brand,
87
+ type: 'method',
88
+ brand,
75
89
  }
76
90
  }
77
91
 
78
92
  let maxProgressTimestamp = Math.max(
79
93
  ...learningPath?.children.map((lesson) => lesson.progressTimestamp)
80
94
  )
95
+
81
96
  if (!maxProgressTimestamp) {
82
97
  maxProgressTimestamp = learningPath.active_learning_path_created_at
83
98
  }
@@ -838,7 +838,7 @@ export async function fetchAllFilterOptions(
838
838
 
839
839
  return coachId
840
840
  ? `brand == '${brand}' && status == "published" && references(*[_type=='instructor' && railcontent_id == ${coachId}]._id) ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
841
- : `_type == '${contentType}' && brand == "${brand}"${includeStatusFilter ? statusFilter : ''}${style && excludeFilter !== 'style' ? ` && '${style}' in genre[]->name` : ''}${artist && excludeFilter !== 'artist' ? ` && artist->name == '${artist}'` : ''} ${progressFilter} ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
841
+ : `_type == '${contentType}' && brand == "${brand}"${includeStatusFilter ? statusFilter : ''}${style && excludeFilter !== 'style' ? ` && '${style}' in genre[]->name` : ''}${artist && excludeFilter !== 'artist' ? ` && artist->name == "${artist}"` : ''} ${progressFilter} ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
842
842
  }
843
843
 
844
844
  const metaData = processMetadata(brand, contentType, true)
@@ -31,10 +31,12 @@ type SyncPushSuccessResponse = SyncResponseBase & {
31
31
  }
32
32
  type SyncPushFetchFailureResponse = SyncResponseBase & {
33
33
  ok: false,
34
+ failureType: 'fetch'
34
35
  isRetryable: boolean
35
36
  }
36
37
  type SyncPushFailureResponse = SyncResponseBase & {
37
38
  ok: false,
39
+ failureType: 'error'
38
40
  originalError: Error
39
41
  }
40
42
 
@@ -69,12 +71,14 @@ type SyncPullSuccessResponse = SyncResponseBase & {
69
71
  token: SyncToken
70
72
  previousToken: SyncToken | null
71
73
  }
72
- type SyncPullFailureResponse = SyncResponseBase & {
74
+ type SyncPullFetchFailureResponse = SyncResponseBase & {
73
75
  ok: false,
76
+ failureType: 'fetch'
74
77
  isRetryable: boolean
75
78
  }
76
- type SyncPullFetchFailureResponse = SyncResponseBase & {
79
+ type SyncPullFailureResponse = SyncResponseBase & {
77
80
  ok: false,
81
+ failureType: 'error'
78
82
  originalError: Error
79
83
  }
80
84
  export interface SyncResponseBase {
@@ -146,6 +150,7 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
146
150
  } catch (e) {
147
151
  return {
148
152
  ok: false,
153
+ failureType: 'error',
149
154
  originalError: e as Error
150
155
  }
151
156
  }
@@ -153,6 +158,7 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
153
158
  if (response.ok === false) {
154
159
  return {
155
160
  ok: false,
161
+ failureType: 'fetch',
156
162
  isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
157
163
  }
158
164
  }
@@ -192,6 +198,7 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
192
198
  } catch (e) {
193
199
  return {
194
200
  ok: false,
201
+ failureType: 'error',
195
202
  originalError: e as Error
196
203
  }
197
204
  }
@@ -199,6 +206,7 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
199
206
  if (response.ok === false) {
200
207
  return {
201
208
  ok: false,
209
+ failureType: 'fetch',
202
210
  isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
203
211
  }
204
212
  }
@@ -12,6 +12,7 @@ import { SyncConcurrencySafetyMechanism } from './concurrency-safety'
12
12
  import { SyncTelemetry } from './telemetry/index'
13
13
  import { inBoundary } from './errors/boundary'
14
14
  import createStoresFromConfig from './store-configs'
15
+ import { contentProgressObserver } from '../awards/internal/content-progress-observer'
15
16
 
16
17
  export default class SyncManager {
17
18
  private static counter = 0
@@ -120,11 +121,16 @@ export default class SyncManager {
120
121
  })
121
122
  })
122
123
 
124
+ contentProgressObserver.start(this.database).catch(error => {
125
+ this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
126
+ })
127
+
123
128
  const teardown = async () => {
124
129
  this.telemetry.debug('[SyncManager] Tearing down')
125
130
  this.runScope.abort()
126
131
  this.strategyMap.forEach(({ strategies }) => strategies.forEach(strategy => strategy.stop()))
127
132
  this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach(mechanism => mechanism()))
133
+ contentProgressObserver.stop()
128
134
  this.retry.stop()
129
135
  this.context.stop()
130
136
  await this.database.write(() => this.database.unsafeResetDatabase())
@@ -2,7 +2,6 @@ import BaseModel from './Base'
2
2
  import { SYNC_TABLES } from '../schema'
3
3
 
4
4
  export enum COLLECTION_TYPE {
5
- SKILL_PACK = 'skill-pack',
6
5
  LEARNING_PATH = 'learning-path-v2',
7
6
  PLAYLIST = 'playlist',
8
7
  }
@@ -18,7 +17,7 @@ export default class ContentProgress extends BaseModel<{
18
17
  collection_id: number | null
19
18
  state: STATE
20
19
  progress_percent: number
21
- resume_time_seconds: number
20
+ resume_time_seconds: number | null
22
21
  }> {
23
22
  static table = SYNC_TABLES.CONTENT_PROGRESS
24
23
 
@@ -41,7 +40,7 @@ export default class ContentProgress extends BaseModel<{
41
40
  return (this._getRaw('collection_id') as number) || null
42
41
  }
43
42
  get resume_time_seconds() {
44
- return this._getRaw('resume_time_seconds') as number
43
+ return (this._getRaw('resume_time_seconds') as number) || null
45
44
  }
46
45
 
47
46
  set content_id(value: number) {
@@ -54,7 +53,7 @@ export default class ContentProgress extends BaseModel<{
54
53
  this._setRaw('state', value)
55
54
  }
56
55
  set progress_percent(value: number) {
57
- this._setRaw('progress_percent', value)
56
+ this._setRaw('progress_percent', Math.min(100, Math.max(0, value)))
58
57
  }
59
58
  set collection_type(value: COLLECTION_TYPE | null) {
60
59
  this._setRaw('collection_type', value)
@@ -62,8 +61,8 @@ export default class ContentProgress extends BaseModel<{
62
61
  set collection_id(value: number | null) {
63
62
  this._setRaw('collection_id', value)
64
63
  }
65
- set resume_time_seconds(value: number) {
66
- this._setRaw('resume_time_seconds', value)
64
+ set resume_time_seconds(value: number | null) {
65
+ this._setRaw('resume_time_seconds', value !== null ? Math.max(0, value) : null)
67
66
  }
68
67
 
69
68
  }
@@ -0,0 +1,55 @@
1
+ import BaseModel from './Base'
2
+ import { SYNC_TABLES } from '../schema'
3
+ import type { CompletionData } from '../../awards/types'
4
+
5
+ export default class UserAwardProgress extends BaseModel<{
6
+ award_id: string
7
+ progress_percentage: number
8
+ completed_at: number | null
9
+ progress_data: string | null
10
+ completion_data: string | null
11
+ }> {
12
+ static table = SYNC_TABLES.USER_AWARD_PROGRESS
13
+
14
+ get award_id() {
15
+ return this._getRaw('award_id') as string
16
+ }
17
+
18
+ get progress_percentage() {
19
+ return this._getRaw('progress_percentage') as number
20
+ }
21
+
22
+ get completed_at() {
23
+ return this._getRaw('completed_at') as number | null
24
+ }
25
+
26
+ get progress_data() {
27
+ const raw = this._getRaw('progress_data') as string | null
28
+ return raw ? JSON.parse(raw) : null
29
+ }
30
+
31
+ get completion_data(): CompletionData | null {
32
+ const raw = this._getRaw('completion_data') as string | null
33
+ return raw ? JSON.parse(raw) : null
34
+ }
35
+
36
+ set award_id(value: string) {
37
+ this._setRaw('award_id', value)
38
+ }
39
+
40
+ set progress_percentage(value: number) {
41
+ this._setRaw('progress_percentage', value)
42
+ }
43
+
44
+ set completed_at(value: number | null) {
45
+ this._setRaw('completed_at', value)
46
+ }
47
+
48
+ set progress_data(value: any) {
49
+ this._setRaw('progress_data', value ? JSON.stringify(value) : null)
50
+ }
51
+
52
+ set completion_data(value: CompletionData | null) {
53
+ this._setRaw('completion_data', value ? JSON.stringify(value) : null)
54
+ }
55
+ }
@@ -1,4 +1,5 @@
1
1
  export { default as ContentLike } from './ContentLike'
2
2
  export { default as ContentProgress } from './ContentProgress'
3
3
  export { default as Practice } from './Practice'
4
+ export { default as UserAwardProgress } from './UserAwardProgress'
4
5
  export { default as PracticeDayNote } from './PracticeDayNote'
@@ -4,25 +4,31 @@ import ContentProgress, { COLLECTION_TYPE, STATE } from '../models/ContentProgre
4
4
  export default class ProgressRepository extends SyncRepository<ContentProgress> {
5
5
  // null collection only
6
6
  async startedIds(limit?: number) {
7
- return this.queryAll(
7
+ return this.queryAllIds(...[
8
8
  Q.where('collection_type', null),
9
9
  Q.where('collection_id', null),
10
10
 
11
11
  Q.where('state', STATE.STARTED),
12
12
  Q.sortBy('updated_at', 'desc'),
13
- Q.take(limit || Infinity)
14
- )
13
+
14
+ ...(limit ? [Q.take(limit)] : []),
15
+ ])
15
16
  }
16
17
 
17
18
  // null collection only
18
19
  async completedIds(limit?: number) {
19
- return this.queryAllIds(
20
+ return this.queryAllIds(...[
21
+ Q.where('collection_type', null),
22
+ Q.where('collection_id', null),
23
+
20
24
  Q.where('state', STATE.COMPLETED),
21
25
  Q.sortBy('updated_at', 'desc'),
22
- Q.take(limit || Infinity)
23
- )
26
+
27
+ ...(limit ? [Q.take(limit)] : []),
28
+ ])
24
29
  }
25
30
 
31
+ //this _specifically_ needs to get content_ids from ALL collection_types (including null)
26
32
  async completedByContentIds(contentIds: number[]) {
27
33
  return this.queryAll(
28
34
  Q.where('content_id', Q.oneOf(contentIds)),
@@ -85,15 +91,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
85
91
  contentId: number,
86
92
  { collection }: { collection?: { type: COLLECTION_TYPE; id: number } | null } = {}
87
93
  ) {
88
- const clauses = [Q.where('content_id', contentId)]
89
- if (typeof collection != 'undefined') {
90
- clauses.push(
91
- ...[
92
- Q.where('collection_type', collection?.type ?? null),
93
- Q.where('collection_id', collection?.id ?? null),
94
- ]
95
- )
96
- }
94
+ const clauses = [
95
+ Q.where('content_id', contentId),
96
+ Q.where('collection_type', collection?.type ?? null),
97
+ Q.where('collection_id', collection?.id ?? null),
98
+ ]
97
99
 
98
100
  return await this.queryOne(...clauses)
99
101
  }
@@ -102,15 +104,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
102
104
  contentIds: number[],
103
105
  collection: { type: COLLECTION_TYPE; id: number } | null = null
104
106
  ) {
105
- const clauses = [Q.where('content_id', Q.oneOf(contentIds))]
106
- if (typeof collection != 'undefined') {
107
- clauses.push(
108
- ...[
109
- Q.where('collection_type', collection?.type ?? null),
110
- Q.where('collection_id', collection?.id ?? null),
111
- ]
112
- )
113
- }
107
+ const clauses = [
108
+ Q.where('content_id', Q.oneOf(contentIds)),
109
+ Q.where('collection_type', collection?.type ?? null),
110
+ Q.where('collection_id', collection?.id ?? null),
111
+ ]
114
112
 
115
113
  return await this.queryAll(...clauses)
116
114
  }
@@ -118,7 +116,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
118
116
  recordProgress(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null, progressPct: number, resumeTime?: number) {
119
117
  const id = ProgressRepository.generateId(contentId, collection)
120
118
 
121
- return this.upsertOne(id, (r) => {
119
+ const result = this.upsertOne(id, (r) => {
122
120
  r.content_id = contentId
123
121
  r.collection_type = collection?.type ?? null
124
122
  r.collection_id = collection?.id ?? null
@@ -130,6 +128,30 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
130
128
  r.resume_time_seconds = Math.floor(resumeTime)
131
129
  }
132
130
  })
131
+
132
+ // Emit event AFTER database write completes
133
+ result.then(() => {
134
+ return Promise.all([
135
+ import('../../progress-events'),
136
+ import('../../config')
137
+ ])
138
+ }).then(([progressEventsModule, { globalConfig }]) => {
139
+ progressEventsModule.emitProgressSaved({
140
+ userId: Number(globalConfig.railcontentConfig?.userId) || 0,
141
+ contentId,
142
+ progressPercent: progressPct,
143
+ progressStatus: progressPct === 100 ? STATE.COMPLETED : STATE.STARTED,
144
+ bubble: true,
145
+ collectionType: collection?.type ?? null,
146
+ collectionId: collection?.id ?? null,
147
+ resumeTimeSeconds: resumeTime ?? null,
148
+ timestamp: Date.now()
149
+ })
150
+ }).catch(error => {
151
+ console.error('Failed to emit progress saved event:', error)
152
+ })
153
+
154
+ return result
133
155
  }
134
156
 
135
157
  recordProgresses(