musora-content-services 2.94.8 → 2.95.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/CHANGELOG.md +16 -0
- package/CLAUDE.md +408 -0
- package/babel.config.cjs +10 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/services/awards/award-callbacks.js +126 -0
- package/src/services/awards/award-query.js +327 -0
- package/src/services/awards/internal/.indexignore +1 -0
- package/src/services/awards/internal/award-definitions.js +239 -0
- package/src/services/awards/internal/award-events.js +102 -0
- package/src/services/awards/internal/award-manager.js +162 -0
- package/src/services/awards/internal/certificate-builder.js +66 -0
- package/src/services/awards/internal/completion-data-generator.js +84 -0
- package/src/services/awards/internal/content-progress-observer.js +137 -0
- package/src/services/awards/internal/image-utils.js +62 -0
- package/src/services/awards/internal/message-generator.js +17 -0
- package/src/services/awards/internal/types.js +5 -0
- package/src/services/awards/types.d.ts +79 -0
- package/src/services/awards/types.js +101 -0
- package/src/services/config.js +24 -4
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/sanity.js +1 -1
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/content-progress.ts +47 -25
- package/src/services/sync/repositories/index.ts +1 -0
- package/src/services/sync/repositories/practices.ts +16 -1
- package/src/services/sync/repositories/user-award-progress.ts +133 -0
- package/src/services/sync/repository-proxy.ts +6 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/store/index.ts +53 -8
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +7 -1
- package/src/services/userActivity.js +0 -1
- package/test/HttpClient.test.js +6 -6
- package/test/awards/award-alacarte-observer.test.js +196 -0
- package/test/awards/award-auto-refresh.test.js +83 -0
- package/test/awards/award-calculations.test.js +33 -0
- package/test/awards/award-certificate-display.test.js +328 -0
- package/test/awards/award-collection-edge-cases.test.js +210 -0
- package/test/awards/award-collection-filtering.test.js +285 -0
- package/test/awards/award-completion-flow.test.js +213 -0
- package/test/awards/award-exclusion-handling.test.js +273 -0
- package/test/awards/award-multi-lesson.test.js +241 -0
- package/test/awards/award-observer-integration.test.js +325 -0
- package/test/awards/award-query-messages.test.js +438 -0
- package/test/awards/award-user-collection.test.js +412 -0
- package/test/awards/duplicate-prevention.test.js +118 -0
- package/test/awards/helpers/completion-mock.js +54 -0
- package/test/awards/helpers/index.js +3 -0
- package/test/awards/helpers/mock-setup.js +69 -0
- package/test/awards/helpers/progress-emitter.js +39 -0
- package/test/awards/message-generator.test.js +162 -0
- package/test/initializeTests.js +6 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- 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
|
-
|
|
12
|
+
award_id: string
|
|
32
13
|
user_name: string
|
|
33
14
|
user_id: number
|
|
34
|
-
completed_at: string
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
* @
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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(
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
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 =
|
|
70
|
-
action =
|
|
71
|
-
? getMethodActionCTA(
|
|
83
|
+
ctaText = nextLesson ? 'Start Next Lesson' : 'Browse Lessons'
|
|
84
|
+
action = nextLesson
|
|
85
|
+
? getMethodActionCTA(nextLesson)
|
|
72
86
|
: {
|
|
73
|
-
type: '
|
|
74
|
-
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
|
}
|
package/src/services/sanity.js
CHANGED
|
@@ -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 ==
|
|
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
|
|
74
|
+
type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
73
75
|
ok: false,
|
|
76
|
+
failureType: 'fetch'
|
|
74
77
|
isRetryable: boolean
|
|
75
78
|
}
|
|
76
|
-
type
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 = [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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(
|