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.
- package/.coderabbit.yaml +0 -0
- package/.editorconfig +0 -0
- package/.github/pull_request_template.md +0 -0
- package/.github/workflows/conventional-commits.yaml +0 -0
- package/.github/workflows/docs.js.yml +0 -0
- package/.github/workflows/node.js.yml +0 -0
- package/.prettierignore +0 -0
- package/.prettierrc +0 -0
- package/CHANGELOG.md +23 -0
- package/CLAUDE.md +408 -0
- package/README.md +0 -0
- package/babel.config.cjs +10 -0
- package/jest.config.js +0 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/contentMetaData.js +0 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/infrastructure/http/HttpClient.ts +0 -0
- package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
- package/src/infrastructure/http/index.ts +0 -0
- package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
- package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
- package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
- package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
- package/src/lib/brands.ts +0 -0
- package/src/lib/httpHelper.js +0 -0
- package/src/lib/lastUpdated.js +0 -0
- package/src/lib/sanity/query.ts +0 -0
- package/src/services/api/types.js +0 -0
- package/src/services/api/types.ts +0 -0
- 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/artist.ts +0 -0
- package/src/services/content/content.ts +0 -0
- package/src/services/content/genre.ts +0 -0
- package/src/services/content/instructor.ts +0 -0
- package/src/services/content-org/content-org.js +0 -0
- package/src/services/content-org/guided-courses.ts +0 -0
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/content-org/playlists-types.js +0 -0
- package/src/services/content-org/playlists.js +0 -0
- package/src/services/content.js +0 -0
- package/src/services/contentAggregator.js +0 -0
- package/src/services/contentLikes.js +0 -0
- package/src/services/contentProgress.js +2 -1
- package/src/services/dataContext.js +0 -0
- package/src/services/dateUtils.js +0 -0
- package/src/services/eventsAPI.js +0 -0
- package/src/services/forums/forums.ts +0 -0
- package/src/services/forums/posts.ts +0 -0
- package/src/services/forums/threads.ts +0 -0
- package/src/services/forums/types.ts +0 -0
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/gamification/gamification.js +0 -0
- package/src/services/imageSRCBuilder.js +0 -0
- package/src/services/imageSRCVerify.js +0 -0
- package/src/services/liveTesting.ts +0 -0
- package/src/services/permissions/PermissionsAdapter.ts +0 -0
- package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
- package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
- package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
- package/src/services/permissions/README.md +0 -0
- package/src/services/permissions/index.ts +0 -0
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/railcontent.js +0 -0
- package/src/services/recommendations.js +0 -0
- package/src/services/reporting/README.md +0 -0
- package/src/services/reporting/reporting.ts +0 -0
- package/src/services/reporting/types.ts +0 -0
- package/src/services/sanity.js +1 -1
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +0 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +0 -0
- package/src/services/sync/adapters/lokijs.ts +0 -0
- package/src/services/sync/adapters/sqlite.ts +0 -0
- package/src/services/sync/concurrency-safety.ts +0 -0
- package/src/services/sync/context/index.ts +0 -0
- package/src/services/sync/context/providers/base.ts +0 -0
- package/src/services/sync/context/providers/connectivity.ts +0 -0
- package/src/services/sync/context/providers/durability.ts +0 -0
- package/src/services/sync/context/providers/index.ts +0 -0
- package/src/services/sync/context/providers/session.ts +0 -0
- package/src/services/sync/context/providers/tabs.ts +0 -0
- package/src/services/sync/context/providers/visibility.ts +0 -0
- package/src/services/sync/database/factory.ts +0 -0
- package/src/services/sync/errors/boundary.ts +0 -0
- package/src/services/sync/errors/index.ts +0 -0
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/index.ts +0 -0
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/Base.ts +0 -0
- package/src/services/sync/models/ContentLike.ts +0 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/Practice.ts +0 -0
- package/src/services/sync/models/PracticeDayNote.ts +0 -0
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/base.ts +0 -0
- package/src/services/sync/repositories/content-likes.ts +0 -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/practice-day-notes.ts +0 -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/resolver.ts +0 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/run-scope.ts +0 -0
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/serializers/index.ts +0 -0
- package/src/services/sync/serializers/model.ts +0 -0
- package/src/services/sync/serializers/raw.ts +0 -0
- 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/sync/strategies/base.ts +0 -0
- package/src/services/sync/strategies/index.ts +0 -0
- package/src/services/sync/strategies/initial.ts +0 -0
- package/src/services/sync/strategies/polling.ts +0 -0
- package/src/services/sync/telemetry/index.ts +0 -0
- package/src/services/sync/telemetry/sampling.ts +0 -0
- package/src/services/sync/utils/event-emitter.ts +0 -0
- package/src/services/sync/utils/index.ts +0 -0
- package/src/services/sync/utils/throttle.ts +0 -0
- package/src/services/sync/utils/timers.ts +0 -0
- package/src/services/types.js +0 -0
- package/src/services/user/account.ts +0 -0
- package/src/services/user/chat.js +0 -0
- package/src/services/user/interests.js +0 -0
- package/src/services/user/management.js +0 -0
- package/src/services/user/memberships.ts +0 -0
- package/src/services/user/notifications.js +0 -0
- package/src/services/user/onboarding.ts +0 -0
- package/src/services/user/payments.ts +0 -0
- package/src/services/user/permissions.js +0 -0
- package/src/services/user/profile.js +0 -0
- package/src/services/user/sessions.js +0 -0
- package/src/services/user/types.d.ts +0 -0
- package/src/services/user/types.js +0 -0
- package/src/services/user/user-management-system.js +0 -0
- 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/content.test.js +0 -0
- package/test/contentLikes.test.js +0 -0
- package/test/contentProgress.test.js +0 -0
- package/test/dataContext.test.js +0 -0
- package/test/forum.test.js +0 -0
- package/test/imageSRCBuilder.test.js +0 -0
- package/test/imageSRCVerify.test.js +0 -0
- package/test/initializeTests.js +6 -0
- package/test/learningPaths.test.js +0 -0
- package/test/lib/lastUpdated.test.js +0 -0
- package/test/live/contentProgressLive.test.js +0 -0
- package/test/live/railcontentLive.test.js +0 -0
- package/test/localStorageMock.js +0 -0
- package/test/log.js +0 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
- package/test/mockData/mockData_progress_content.json +0 -0
- package/test/mockData/mockData_sanity_progress_content.json +0 -0
- package/test/mockData/mockData_user_practices.json +0 -0
- package/test/notifications.test.js +0 -0
- package/test/progressRows.test.js +0 -0
- package/test/sanityQueryService.test.js +0 -0
- package/test/streakMessage.test.js +0 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- package/test/user/permissions.test.js +0 -0
- package/test/userActivity.test.js +0 -0
- package/tools/generate-index.cjs +9 -0
- package/.claude/settings.local.json +0 -14
- package/.yarnrc.yml +0 -1
- 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
|
+
}
|