musora-content-services 2.94.7 → 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.
Files changed (210) hide show
  1. package/.coderabbit.yaml +0 -0
  2. package/.editorconfig +0 -0
  3. package/.github/pull_request_template.md +0 -0
  4. package/.github/workflows/conventional-commits.yaml +0 -0
  5. package/.github/workflows/docs.js.yml +0 -0
  6. package/.github/workflows/node.js.yml +0 -0
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +23 -0
  10. package/CLAUDE.md +408 -0
  11. package/README.md +0 -0
  12. package/babel.config.cjs +10 -0
  13. package/jest.config.js +0 -0
  14. package/jsdoc.json +2 -1
  15. package/package.json +2 -2
  16. package/src/constants/award-assets.js +35 -0
  17. package/src/contentMetaData.js +0 -0
  18. package/src/filterBuilder.js +7 -2
  19. package/src/index.d.ts +26 -5
  20. package/src/index.js +26 -5
  21. package/src/infrastructure/http/HttpClient.ts +0 -0
  22. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  23. package/src/infrastructure/http/index.ts +0 -0
  24. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  25. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  26. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  27. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  28. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  29. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  30. package/src/lib/brands.ts +0 -0
  31. package/src/lib/httpHelper.js +0 -0
  32. package/src/lib/lastUpdated.js +0 -0
  33. package/src/lib/sanity/query.ts +0 -0
  34. package/src/services/api/types.js +0 -0
  35. package/src/services/api/types.ts +0 -0
  36. package/src/services/awards/award-callbacks.js +126 -0
  37. package/src/services/awards/award-query.js +327 -0
  38. package/src/services/awards/internal/.indexignore +1 -0
  39. package/src/services/awards/internal/award-definitions.js +239 -0
  40. package/src/services/awards/internal/award-events.js +102 -0
  41. package/src/services/awards/internal/award-manager.js +162 -0
  42. package/src/services/awards/internal/certificate-builder.js +66 -0
  43. package/src/services/awards/internal/completion-data-generator.js +84 -0
  44. package/src/services/awards/internal/content-progress-observer.js +137 -0
  45. package/src/services/awards/internal/image-utils.js +62 -0
  46. package/src/services/awards/internal/message-generator.js +17 -0
  47. package/src/services/awards/internal/types.js +5 -0
  48. package/src/services/awards/types.d.ts +79 -0
  49. package/src/services/awards/types.js +101 -0
  50. package/src/services/config.js +24 -4
  51. package/src/services/content/artist.ts +0 -0
  52. package/src/services/content/content.ts +0 -0
  53. package/src/services/content/genre.ts +0 -0
  54. package/src/services/content/instructor.ts +0 -0
  55. package/src/services/content-org/content-org.js +0 -0
  56. package/src/services/content-org/guided-courses.ts +0 -0
  57. package/src/services/content-org/learning-paths.ts +19 -15
  58. package/src/services/content-org/playlists-types.js +0 -0
  59. package/src/services/content-org/playlists.js +0 -0
  60. package/src/services/content.js +0 -0
  61. package/src/services/contentAggregator.js +0 -0
  62. package/src/services/contentLikes.js +0 -0
  63. package/src/services/contentProgress.js +2 -1
  64. package/src/services/dataContext.js +0 -0
  65. package/src/services/dateUtils.js +0 -0
  66. package/src/services/eventsAPI.js +0 -0
  67. package/src/services/forums/forums.ts +0 -0
  68. package/src/services/forums/posts.ts +0 -0
  69. package/src/services/forums/threads.ts +0 -0
  70. package/src/services/forums/types.ts +0 -0
  71. package/src/services/gamification/awards.ts +114 -83
  72. package/src/services/gamification/gamification.js +0 -0
  73. package/src/services/imageSRCBuilder.js +0 -0
  74. package/src/services/imageSRCVerify.js +0 -0
  75. package/src/services/liveTesting.ts +0 -0
  76. package/src/services/permissions/PermissionsAdapter.ts +0 -0
  77. package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
  78. package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
  79. package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
  80. package/src/services/permissions/README.md +0 -0
  81. package/src/services/permissions/index.ts +0 -0
  82. package/src/services/progress-events.js +58 -0
  83. package/src/services/progress-row/method-card.js +20 -5
  84. package/src/services/railcontent.js +0 -0
  85. package/src/services/recommendations.js +0 -0
  86. package/src/services/reporting/README.md +0 -0
  87. package/src/services/reporting/reporting.ts +0 -0
  88. package/src/services/reporting/types.ts +0 -0
  89. package/src/services/sanity.js +1 -1
  90. package/src/services/sentry/.indexignore +0 -0
  91. package/src/services/sentry/index.ts +0 -0
  92. package/src/services/sync/.indexignore +0 -0
  93. package/src/services/sync/adapters/factory.ts +0 -0
  94. package/src/services/sync/adapters/lokijs.ts +0 -0
  95. package/src/services/sync/adapters/sqlite.ts +0 -0
  96. package/src/services/sync/concurrency-safety.ts +0 -0
  97. package/src/services/sync/context/index.ts +0 -0
  98. package/src/services/sync/context/providers/base.ts +0 -0
  99. package/src/services/sync/context/providers/connectivity.ts +0 -0
  100. package/src/services/sync/context/providers/durability.ts +0 -0
  101. package/src/services/sync/context/providers/index.ts +0 -0
  102. package/src/services/sync/context/providers/session.ts +0 -0
  103. package/src/services/sync/context/providers/tabs.ts +0 -0
  104. package/src/services/sync/context/providers/visibility.ts +0 -0
  105. package/src/services/sync/database/factory.ts +0 -0
  106. package/src/services/sync/errors/boundary.ts +0 -0
  107. package/src/services/sync/errors/index.ts +0 -0
  108. package/src/services/sync/fetch.ts +10 -2
  109. package/src/services/sync/index.ts +0 -0
  110. package/src/services/sync/manager.ts +6 -0
  111. package/src/services/sync/models/Base.ts +0 -0
  112. package/src/services/sync/models/ContentLike.ts +0 -0
  113. package/src/services/sync/models/ContentProgress.ts +5 -6
  114. package/src/services/sync/models/Practice.ts +0 -0
  115. package/src/services/sync/models/PracticeDayNote.ts +0 -0
  116. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  117. package/src/services/sync/models/index.ts +1 -0
  118. package/src/services/sync/repositories/base.ts +0 -0
  119. package/src/services/sync/repositories/content-likes.ts +0 -0
  120. package/src/services/sync/repositories/content-progress.ts +47 -25
  121. package/src/services/sync/repositories/index.ts +1 -0
  122. package/src/services/sync/repositories/practice-day-notes.ts +0 -0
  123. package/src/services/sync/repositories/practices.ts +16 -1
  124. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  125. package/src/services/sync/repository-proxy.ts +6 -0
  126. package/src/services/sync/resolver.ts +0 -0
  127. package/src/services/sync/retry.ts +12 -11
  128. package/src/services/sync/run-scope.ts +0 -0
  129. package/src/services/sync/schema/index.ts +18 -3
  130. package/src/services/sync/serializers/index.ts +0 -0
  131. package/src/services/sync/serializers/model.ts +0 -0
  132. package/src/services/sync/serializers/raw.ts +0 -0
  133. package/src/services/sync/store/index.ts +53 -8
  134. package/src/services/sync/store/push-coalescer.ts +3 -3
  135. package/src/services/sync/store-configs.ts +7 -1
  136. package/src/services/sync/strategies/base.ts +0 -0
  137. package/src/services/sync/strategies/index.ts +0 -0
  138. package/src/services/sync/strategies/initial.ts +0 -0
  139. package/src/services/sync/strategies/polling.ts +0 -0
  140. package/src/services/sync/telemetry/index.ts +0 -0
  141. package/src/services/sync/telemetry/sampling.ts +0 -0
  142. package/src/services/sync/utils/event-emitter.ts +0 -0
  143. package/src/services/sync/utils/index.ts +0 -0
  144. package/src/services/sync/utils/throttle.ts +0 -0
  145. package/src/services/sync/utils/timers.ts +0 -0
  146. package/src/services/types.js +0 -0
  147. package/src/services/user/account.ts +0 -0
  148. package/src/services/user/chat.js +0 -0
  149. package/src/services/user/interests.js +0 -0
  150. package/src/services/user/management.js +0 -0
  151. package/src/services/user/memberships.ts +0 -0
  152. package/src/services/user/notifications.js +0 -0
  153. package/src/services/user/onboarding.ts +0 -0
  154. package/src/services/user/payments.ts +0 -0
  155. package/src/services/user/permissions.js +0 -0
  156. package/src/services/user/profile.js +0 -0
  157. package/src/services/user/sessions.js +0 -0
  158. package/src/services/user/types.d.ts +0 -0
  159. package/src/services/user/types.js +0 -0
  160. package/src/services/user/user-management-system.js +0 -0
  161. package/src/services/userActivity.js +0 -1
  162. package/test/HttpClient.test.js +6 -6
  163. package/test/awards/award-alacarte-observer.test.js +196 -0
  164. package/test/awards/award-auto-refresh.test.js +83 -0
  165. package/test/awards/award-calculations.test.js +33 -0
  166. package/test/awards/award-certificate-display.test.js +328 -0
  167. package/test/awards/award-collection-edge-cases.test.js +210 -0
  168. package/test/awards/award-collection-filtering.test.js +285 -0
  169. package/test/awards/award-completion-flow.test.js +213 -0
  170. package/test/awards/award-exclusion-handling.test.js +273 -0
  171. package/test/awards/award-multi-lesson.test.js +241 -0
  172. package/test/awards/award-observer-integration.test.js +325 -0
  173. package/test/awards/award-query-messages.test.js +438 -0
  174. package/test/awards/award-user-collection.test.js +412 -0
  175. package/test/awards/duplicate-prevention.test.js +118 -0
  176. package/test/awards/helpers/completion-mock.js +54 -0
  177. package/test/awards/helpers/index.js +3 -0
  178. package/test/awards/helpers/mock-setup.js +69 -0
  179. package/test/awards/helpers/progress-emitter.js +39 -0
  180. package/test/awards/message-generator.test.js +162 -0
  181. package/test/content.test.js +0 -0
  182. package/test/contentLikes.test.js +0 -0
  183. package/test/contentProgress.test.js +0 -0
  184. package/test/dataContext.test.js +0 -0
  185. package/test/forum.test.js +0 -0
  186. package/test/imageSRCBuilder.test.js +0 -0
  187. package/test/imageSRCVerify.test.js +0 -0
  188. package/test/initializeTests.js +6 -0
  189. package/test/learningPaths.test.js +0 -0
  190. package/test/lib/lastUpdated.test.js +0 -0
  191. package/test/live/contentProgressLive.test.js +0 -0
  192. package/test/live/railcontentLive.test.js +0 -0
  193. package/test/localStorageMock.js +0 -0
  194. package/test/log.js +0 -0
  195. package/test/mockData/award-definitions.js +171 -0
  196. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  197. package/test/mockData/mockData_progress_content.json +0 -0
  198. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  199. package/test/mockData/mockData_user_practices.json +0 -0
  200. package/test/notifications.test.js +0 -0
  201. package/test/progressRows.test.js +0 -0
  202. package/test/sanityQueryService.test.js +0 -0
  203. package/test/streakMessage.test.js +0 -0
  204. package/test/sync/models/award-database-integration.test.js +519 -0
  205. package/test/user/permissions.test.js +0 -0
  206. package/test/userActivity.test.js +0 -0
  207. package/tools/generate-index.cjs +9 -0
  208. package/.claude/settings.local.json +0 -14
  209. package/.yarnrc.yml +0 -1
  210. package/test/reporting.test.js +0 -132
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @module Awards
3
+ */
4
+
5
+
6
+ /** @typedef {Map} AwardDefinitionsMap */
7
+ /** @typedef {Map} ContentToAwardsMap */
8
+
9
+ const STORAGE_KEY = 'musora_award_definitions_last_fetch'
10
+
11
+ class AwardDefinitionsService {
12
+ constructor() {
13
+ /** @type {AwardDefinitionsMap} */
14
+ this.definitions = new Map()
15
+
16
+ /** @type {ContentToAwardsMap} */
17
+ this.contentIndex = new Map()
18
+
19
+ /** @type {number} */
20
+ this.lastFetch = 0
21
+
22
+ /** @type {number} */
23
+ this.cacheDuration = 24 * 60 * 60 * 1000
24
+
25
+ /** @type {boolean} */
26
+ this.isFetching = false
27
+
28
+ /** @type {boolean} */
29
+ this.initialized = false
30
+ }
31
+
32
+ /** @returns {Promise<import('./types').AwardDefinition[]>} */
33
+ async getAll(forceRefresh = false) {
34
+ if (this.shouldRefresh() || forceRefresh) {
35
+ await this.fetchFromSanity()
36
+ }
37
+ return Array.from(this.definitions.values())
38
+ }
39
+
40
+ /** @returns {Promise<import('./types').AwardDefinition | null>} */
41
+ async getById(awardId) {
42
+ if (this.shouldRefresh()) {
43
+ await this.fetchFromSanity()
44
+ }
45
+ return this.definitions.get(awardId) || null
46
+ }
47
+
48
+ /** @returns {Promise<import('./types').AwardDefinition[]>} */
49
+ async getByContentId(contentId) {
50
+ if (this.shouldRefresh()) {
51
+ await this.fetchFromSanity()
52
+ }
53
+
54
+ const awardIds = this.contentIndex.get(contentId) || []
55
+ return awardIds
56
+ .map(id => this.definitions.get(id))
57
+ .filter(Boolean)
58
+ }
59
+
60
+ /** @returns {Promise<boolean>} */
61
+ async hasAwards(contentId) {
62
+ if (this.shouldRefresh()) {
63
+ await this.fetchFromSanity()
64
+ }
65
+ return (this.contentIndex.get(contentId)?.length ?? 0) > 0
66
+ }
67
+
68
+ /** @returns {Promise<void>} */
69
+ async fetchFromSanity() {
70
+ if (this.isFetching) {
71
+ return new Promise((resolve) => {
72
+ const checkInterval = setInterval(() => {
73
+ if (!this.isFetching) {
74
+ clearInterval(checkInterval)
75
+ resolve()
76
+ }
77
+ }, 100)
78
+ })
79
+ }
80
+
81
+ this.isFetching = true
82
+
83
+ try {
84
+ const { fetchSanity } = await import('../../sanity')
85
+ const { FilterBuilder } = await import('../../../filterBuilder')
86
+
87
+ const childFilter = await new FilterBuilder('@->exclude_from_awards_calculation != true', {
88
+ isChildrenFilter: true,
89
+ bypassPublishedDateRestriction: true,
90
+ bypassPermissions: true,
91
+ }).buildFilter()
92
+
93
+ const query = `*[_type == 'content-award'] {
94
+ _id,
95
+ is_active,
96
+ name,
97
+ 'logo': logo.asset->url,
98
+ 'badge': badge.asset->url,
99
+ 'award': award.asset->url,
100
+ 'content_id': content->railcontent_id,
101
+ 'content_type': content->_type,
102
+ 'type': _type,
103
+ brand,
104
+ 'content_title': content->title,
105
+ award_custom_text,
106
+ 'instructor_name': content->instructor[0]->name,
107
+ 'child_ids': content->child[${childFilter}]->railcontent_id,
108
+ }`
109
+
110
+ const awards = await fetchSanity(query, true, { processNeedAccess: false })
111
+
112
+ this.definitions.clear()
113
+ this.contentIndex.clear()
114
+
115
+ awards.forEach(award => {
116
+ this.definitions.set(award._id, award)
117
+
118
+ if (award.content_id) {
119
+ const existing = this.contentIndex.get(award.content_id) || []
120
+ this.contentIndex.set(award.content_id, [...existing, award._id])
121
+ }
122
+
123
+ if (award.child_ids && Array.isArray(award.child_ids)) {
124
+ award.child_ids.forEach(childId => {
125
+ if (childId) {
126
+ const existing = this.contentIndex.get(childId) || []
127
+ this.contentIndex.set(childId, [...existing, award._id])
128
+ }
129
+ })
130
+ }
131
+ })
132
+
133
+ this.lastFetch = Date.now()
134
+ await this.saveLastFetchToStorage()
135
+ } catch (error) {
136
+ console.error('Failed to fetch award definitions from Sanity:', error)
137
+ } finally {
138
+ this.isFetching = false
139
+ }
140
+ }
141
+
142
+ /** @returns {boolean} */
143
+ shouldRefresh() {
144
+ return this.definitions.size === 0 ||
145
+ (Date.now() - this.lastFetch) > this.cacheDuration
146
+ }
147
+
148
+ /** @returns {Promise<void>} */
149
+ async refresh() {
150
+ await this.fetchFromSanity()
151
+ }
152
+
153
+ async loadLastFetchFromStorage() {
154
+ try {
155
+ const { globalConfig } = await import('../../config')
156
+ if (!globalConfig.localStorage) {
157
+ return
158
+ }
159
+
160
+ const stored = globalConfig.isMA
161
+ ? await globalConfig.localStorage.getItem(STORAGE_KEY)
162
+ : globalConfig.localStorage.getItem(STORAGE_KEY)
163
+ if (stored) {
164
+ const timestamp = parseInt(stored, 10)
165
+ if (!isNaN(timestamp)) {
166
+ this.lastFetch = timestamp
167
+ }
168
+ }
169
+ } catch (error) {
170
+ console.error('Failed to load lastFetch from storage:', error)
171
+ }
172
+ }
173
+
174
+ async saveLastFetchToStorage() {
175
+ try {
176
+ const { globalConfig } = await import('../../config')
177
+ if (!globalConfig.localStorage) {
178
+ return
179
+ }
180
+
181
+ if (globalConfig.isMA) {
182
+ await globalConfig.localStorage.setItem(STORAGE_KEY, this.lastFetch.toString())
183
+ } else {
184
+ globalConfig.localStorage.setItem(STORAGE_KEY, this.lastFetch.toString())
185
+ }
186
+ } catch (error) {
187
+ console.error('Failed to save lastFetch to storage:', error)
188
+ }
189
+ }
190
+
191
+ async initialize() {
192
+ if (this.initialized) {
193
+ return
194
+ }
195
+
196
+ await this.loadLastFetchFromStorage()
197
+
198
+ if (this.shouldRefresh()) {
199
+ await this.fetchFromSanity()
200
+ }
201
+
202
+ this.initialized = true
203
+ }
204
+
205
+ clear() {
206
+ this.definitions.clear()
207
+ this.contentIndex.clear()
208
+ this.lastFetch = 0
209
+ this.initialized = false
210
+ }
211
+
212
+ getCacheStats() {
213
+ return {
214
+ totalDefinitions: this.definitions.size,
215
+ totalContentMappings: this.contentIndex.size,
216
+ lastFetch: this.lastFetch ? new Date(this.lastFetch).toISOString() : null,
217
+ cacheAge: this.lastFetch ? Date.now() - this.lastFetch : null,
218
+ isFetching: this.isFetching,
219
+ initialized: this.initialized,
220
+ cacheDuration: this.cacheDuration
221
+ }
222
+ }
223
+ }
224
+
225
+
226
+ export const awardDefinitions = new AwardDefinitionsService()
227
+
228
+ /** @returns {Promise<void>} */
229
+ export async function initializeAwardDefinitions() {
230
+ await awardDefinitions.initialize()
231
+ }
232
+
233
+ /**
234
+ * @param {import('./types').AwardDefinition} award
235
+ * @returns {number[]}
236
+ */
237
+ export function getEligibleChildIds(award) {
238
+ return award.child_ids || []
239
+ }
@@ -0,0 +1,102 @@
1
+ /** @typedef {Object} AwardGrantedPayload */
2
+ /** @typedef {Object} AwardProgressPayload */
3
+ /** @callback AwardGrantedListener */
4
+ /** @callback AwardProgressListener */
5
+
6
+
7
+ class AwardEventsService {
8
+ constructor() {
9
+ /** @type {Set<AwardGrantedListener>} */
10
+ this.awardGrantedListeners = new Set()
11
+
12
+ /** @type {Set<AwardProgressListener>} */
13
+ this.awardProgressListeners = new Set()
14
+ }
15
+
16
+ /**
17
+ * @param {'awardGranted' | 'awardProgress'} event - Event name
18
+ * @param {AwardGrantedListener | AwardProgressListener} listener - Listener function
19
+ * @returns {Function} Unsubscribe function
20
+ */
21
+ on(event, listener) {
22
+ if (event === 'awardGranted') {
23
+ this.awardGrantedListeners.add(listener)
24
+ return () => this.awardGrantedListeners.delete(listener)
25
+ } else if (event === 'awardProgress') {
26
+ this.awardProgressListeners.add(listener)
27
+ return () => this.awardProgressListeners.delete(listener)
28
+ }
29
+ return () => {}
30
+ }
31
+
32
+ /**
33
+ * @param {'awardGranted' | 'awardProgress'} event - Event name
34
+ * @param {AwardGrantedListener | AwardProgressListener} listener - Listener function
35
+ * @returns {void}
36
+ */
37
+ once(event, listener) {
38
+ const wrappedListener = (...args) => {
39
+ this.off(event, wrappedListener)
40
+ listener(...args)
41
+ }
42
+ this.on(event, wrappedListener)
43
+ }
44
+
45
+ /**
46
+ * @param {'awardGranted' | 'awardProgress'} event - Event name
47
+ * @param {AwardGrantedListener | AwardProgressListener} listener - Listener function
48
+ * @returns {void}
49
+ */
50
+ off(event, listener) {
51
+ if (event === 'awardGranted') {
52
+ this.awardGrantedListeners.delete(listener)
53
+ } else if (event === 'awardProgress') {
54
+ this.awardProgressListeners.delete(listener)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {AwardGrantedPayload} payload - Event payload
60
+ * @returns {void}
61
+ */
62
+ emitAwardGranted(payload) {
63
+ this.awardGrantedListeners.forEach(listener => {
64
+ try {
65
+ listener(payload)
66
+ } catch (error) {
67
+ console.error('Error in awardGranted listener:', error)
68
+ }
69
+ })
70
+ }
71
+
72
+ /**
73
+ * @param {AwardProgressPayload} payload - Event payload
74
+ * @returns {void}
75
+ */
76
+ emitAwardProgress(payload) {
77
+ this.awardProgressListeners.forEach(listener => {
78
+ try {
79
+ listener(payload)
80
+ } catch (error) {
81
+ console.error('Error in awardProgress listener:', error)
82
+ }
83
+ })
84
+ }
85
+
86
+ /** @returns {void} */
87
+ removeAllListeners() {
88
+ this.awardGrantedListeners.clear()
89
+ this.awardProgressListeners.clear()
90
+ }
91
+
92
+ /** @returns {Object} Listener counts */
93
+ getListenerCounts() {
94
+ return {
95
+ awardGranted: this.awardGrantedListeners.size,
96
+ awardProgress: this.awardProgressListeners.size
97
+ }
98
+ }
99
+ }
100
+
101
+
102
+ export const awardEvents = new AwardEventsService()
@@ -0,0 +1,162 @@
1
+ import { awardDefinitions, getEligibleChildIds } from './award-definitions'
2
+ import { awardEvents } from './award-events'
3
+ import { generateCompletionData } from './completion-data-generator'
4
+ import { AwardMessageGenerator } from './message-generator'
5
+ import db from '../../sync/repository-proxy'
6
+ import { STATE, COLLECTION_TYPE } from '../../sync/models/ContentProgress'
7
+ import {getProgressStateByIds} from "../../contentProgress.js";
8
+
9
+ async function getCompletionStates(contentIds, collection = null) {
10
+ const progress = await getProgressStateByIds(contentIds, collection)
11
+
12
+ return contentIds.map(id => {
13
+ return {
14
+ id,
15
+ completed: progress[id] === STATE.COMPLETED
16
+ }
17
+ })
18
+ }
19
+
20
+ function getCollectionFromAward(award) {
21
+ if (award.content_type === COLLECTION_TYPE.LEARNING_PATH && award.content_id) {
22
+ return { type: COLLECTION_TYPE.LEARNING_PATH, id: award.content_id }
23
+ }
24
+ return null
25
+ }
26
+
27
+
28
+ export class AwardManager {
29
+ async onContentCompleted(contentId) {
30
+ try {
31
+ const awards = await awardDefinitions.getByContentId(contentId)
32
+
33
+ if (awards.length === 0) {
34
+ return
35
+ }
36
+
37
+ for (const award of awards) {
38
+ await this.evaluateAward(award)
39
+ }
40
+ } catch (error) {
41
+ console.error('Error checking awards for completed content:', error)
42
+ }
43
+ }
44
+
45
+ async evaluateAward(award) {
46
+ try {
47
+ const hasCompleted = await db.userAwardProgress.hasCompletedAward(award._id)
48
+ if (hasCompleted) {
49
+ console.log(`Award ${award._id} already completed, skipping evaluation`)
50
+ return
51
+ }
52
+
53
+ const collection = getCollectionFromAward(award)
54
+ const isEligible = await this.checkAwardEligibility(award, collection)
55
+
56
+ if (isEligible) {
57
+ console.log(`Award ${award._id} is now eligible, granting award`)
58
+ await this.grantAward(award, collection)
59
+ } else {
60
+ await this.updateAwardProgress(award, collection)
61
+ }
62
+ } catch (error) {
63
+ console.error(`Error checking award ${award._id}:`, error)
64
+ }
65
+ }
66
+
67
+ async checkAwardEligibility(award, collection) {
68
+ try {
69
+ const childIds = getEligibleChildIds(award)
70
+
71
+ if (childIds.length === 0) {
72
+ return false
73
+ }
74
+
75
+ const completionStates = await getCompletionStates(childIds, collection)
76
+ return completionStates.every(state => state.completed)
77
+ } catch (error) {
78
+ console.error('Error checking award eligibility:', error)
79
+ return false
80
+ }
81
+ }
82
+
83
+ async grantAward(award, collection) {
84
+ const completionData = await generateCompletionData(award, collection)
85
+
86
+ const childIds = getEligibleChildIds(award)
87
+ const completionStates = await getCompletionStates(childIds, collection)
88
+
89
+ const completedLessonIds = completionStates
90
+ .filter(state => state.completed)
91
+ .map(state => state.id)
92
+
93
+ const progressData = {
94
+ completedLessonIds,
95
+ totalLessons: childIds.length,
96
+ completedCount: completedLessonIds.length
97
+ }
98
+
99
+ const popupMessage = AwardMessageGenerator.generatePopupMessage(completionData)
100
+
101
+ await db.userAwardProgress.recordAwardProgress(award._id, 100, {
102
+ completedAt: Date.now(),
103
+ completionData,
104
+ progressData,
105
+ immediate: true
106
+ })
107
+
108
+ awardEvents.emitAwardGranted({
109
+ awardId: award._id,
110
+ definition: award,
111
+ completionData,
112
+ popupMessage,
113
+ timestamp: Date.now()
114
+ })
115
+ }
116
+
117
+ async updateAwardProgress(award, collection) {
118
+ try {
119
+ const childIds = getEligibleChildIds(award)
120
+
121
+ if (childIds.length === 0) return
122
+
123
+ const completionStates = await getCompletionStates(childIds, collection)
124
+
125
+ const completedLessonIds = completionStates
126
+ .filter(state => state.completed)
127
+ .map(state => state.id)
128
+
129
+ const completedCount = completedLessonIds.length
130
+ const progressPercentage = Math.round((completedCount / childIds.length) * 100)
131
+
132
+ const progressData = {
133
+ completedLessonIds,
134
+ totalLessons: childIds.length,
135
+ completedCount
136
+ }
137
+
138
+ await db.userAwardProgress.recordAwardProgress(award._id, progressPercentage, {
139
+ progressData
140
+ })
141
+
142
+ awardEvents.emitAwardProgress({
143
+ awardId: award._id,
144
+ progressPercentage,
145
+ timestamp: Date.now()
146
+ })
147
+ } catch (error) {
148
+ console.error('Error updating award progress:', error)
149
+ }
150
+ }
151
+
152
+ async refreshDefinitions() {
153
+ await awardDefinitions.refresh()
154
+ }
155
+
156
+ clearDefinitionsCache() {
157
+ awardDefinitions.clear()
158
+ }
159
+ }
160
+
161
+
162
+ export const awardManager = new AwardManager()
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @module Awards
3
+ */
4
+
5
+
6
+ import { AWARD_ASSETS } from '../../../constants/award-assets'
7
+ import { AwardMessageGenerator } from './message-generator'
8
+ import { globalConfig } from '../../config'
9
+
10
+ /** @returns {Promise<import('./types').CertificateData>} */
11
+ export async function buildCertificateData(awardId) {
12
+ const { awardDefinitions } = await import('./award-definitions')
13
+ const { getUserData } = await import('../../user/management')
14
+ const db = await import('../../sync/repository-proxy')
15
+
16
+ const awardDef = await awardDefinitions.getById(awardId)
17
+
18
+ if (!awardDef) {
19
+ throw new Error(`Award definition not found: ${awardId}`)
20
+ }
21
+
22
+ const userProgress = await db.default.userAwardProgress.getByAwardId(awardId)
23
+
24
+ if (!userProgress.data || !userProgress.data.completion_data) {
25
+ throw new Error('Completion data not found in local database')
26
+ }
27
+
28
+ const completionData = userProgress.data.completion_data
29
+
30
+ const popupMessage = AwardMessageGenerator.generatePopupMessage(completionData)
31
+
32
+ const certificateMessage = AwardMessageGenerator.generateCertificateMessage(
33
+ completionData,
34
+ awardDef.award_custom_text
35
+ )
36
+
37
+ const userData = await getUserData()
38
+
39
+ return {
40
+ userId: globalConfig.sessionConfig.userId,
41
+ userName: userData?.display_name || userData?.name || 'User',
42
+ completedAt: userProgress.data.completed_at
43
+ ? new Date(userProgress.data.completed_at * 1000).toISOString()
44
+ : new Date().toISOString(),
45
+
46
+ awardId: awardDef._id,
47
+ awardType: awardDef.type || 'content-award',
48
+ awardTitle: awardDef.name,
49
+
50
+ popupMessage,
51
+ certificateMessage,
52
+
53
+ ribbonImage: AWARD_ASSETS.ribbon,
54
+ awardImage: awardDef.award,
55
+ badgeImage: awardDef.badge,
56
+ brandLogo: getBrandLogo(awardDef.brand),
57
+ musoraLogo: AWARD_ASSETS.musoraLogo,
58
+ musoraBgLogo: AWARD_ASSETS.musoraBgLogo,
59
+ instructorName: awardDef.instructor_name
60
+ }
61
+ }
62
+
63
+ function getBrandLogo(brand) {
64
+ const normalizedBrand = brand.toLowerCase()
65
+ return AWARD_ASSETS.brandLogos[normalizedBrand] || AWARD_ASSETS.musoraLogo
66
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module Awards
3
+ */
4
+
5
+
6
+ /**
7
+ * @param {import('./types').AwardDefinition} award
8
+ * @param {{ type: string; id: number } | null} collection
9
+ * @returns {Promise<import('./types').CompletionData>}
10
+ */
11
+ export async function generateCompletionData(award, collection = null) {
12
+ const db = await import('../../sync/repository-proxy')
13
+
14
+ const childIds = award.child_ids || []
15
+
16
+ const daysUserPracticed = await calculateDaysUserPracticed(childIds, db.default, collection)
17
+ const practiceMinutes = await calculatePracticeMinutes(childIds, db.default)
18
+ const contentTitle = award.content_title || generateContentTitle(award.name)
19
+
20
+ return {
21
+ content_title: contentTitle,
22
+ completed_at: new Date().toISOString(),
23
+ days_user_practiced: daysUserPracticed,
24
+ practice_minutes: practiceMinutes
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @param {number[]} contentIds
30
+ * @param {any} db
31
+ * @param {{ type: string; id: number } | null} collection
32
+ * @returns {Promise<number>}
33
+ */
34
+ async function calculateDaysUserPracticed(contentIds, db, collection = null) {
35
+ if (contentIds.length === 0) return 0
36
+
37
+ if (!db.contentProgress || typeof db.contentProgress.getSomeProgressByContentIds !== 'function') {
38
+ console.warn('contentProgress repository not available, returning 1 day')
39
+ return 1
40
+ }
41
+
42
+ const progressResult = await db.contentProgress.getSomeProgressByContentIds(contentIds, collection)
43
+ const progressRecords = progressResult.data || []
44
+
45
+ if (progressRecords.length === 0) return 0
46
+
47
+ const sortedRecords = [...progressRecords].sort((a, b) => a.created_at - b.created_at)
48
+ const earliestRecord = sortedRecords[0]
49
+ const earliestStartDate = earliestRecord.created_at * 1000
50
+
51
+ const now = Date.now()
52
+ const daysDiff = Math.floor((now - earliestStartDate) / (1000 * 60 * 60 * 24))
53
+
54
+ return Math.max(daysDiff, 1)
55
+ }
56
+
57
+ /**
58
+ * @param {number[]} contentIds
59
+ * @param {any} db
60
+ * @returns {Promise<number>}
61
+ */
62
+ async function calculatePracticeMinutes(contentIds, db) {
63
+ if (contentIds.length === 0) return 0
64
+
65
+ if (!db.practices || typeof db.practices.sumPracticeMinutesForContent !== 'function') {
66
+ console.warn('practices repository not available, returning 0 practice minutes')
67
+ return 0
68
+ }
69
+
70
+ const totalMinutes = await db.practices.sumPracticeMinutesForContent(contentIds)
71
+
72
+ return totalMinutes
73
+ }
74
+
75
+ /**
76
+ * @param {string} awardName
77
+ * @returns {string}
78
+ */
79
+ function generateContentTitle(awardName) {
80
+ return awardName
81
+ .replace(/^Complete\s+/i, '')
82
+ .replace(/\s+(Course|Learning Path)$/i, '')
83
+ .trim()
84
+ }