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.
Files changed (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +408 -0
  3. package/babel.config.cjs +10 -0
  4. package/jsdoc.json +2 -1
  5. package/package.json +2 -2
  6. package/src/constants/award-assets.js +35 -0
  7. package/src/filterBuilder.js +7 -2
  8. package/src/index.d.ts +26 -5
  9. package/src/index.js +26 -5
  10. package/src/services/awards/award-callbacks.js +126 -0
  11. package/src/services/awards/award-query.js +327 -0
  12. package/src/services/awards/internal/.indexignore +1 -0
  13. package/src/services/awards/internal/award-definitions.js +239 -0
  14. package/src/services/awards/internal/award-events.js +102 -0
  15. package/src/services/awards/internal/award-manager.js +162 -0
  16. package/src/services/awards/internal/certificate-builder.js +66 -0
  17. package/src/services/awards/internal/completion-data-generator.js +84 -0
  18. package/src/services/awards/internal/content-progress-observer.js +137 -0
  19. package/src/services/awards/internal/image-utils.js +62 -0
  20. package/src/services/awards/internal/message-generator.js +17 -0
  21. package/src/services/awards/internal/types.js +5 -0
  22. package/src/services/awards/types.d.ts +79 -0
  23. package/src/services/awards/types.js +101 -0
  24. package/src/services/config.js +24 -4
  25. package/src/services/content-org/learning-paths.ts +19 -15
  26. package/src/services/gamification/awards.ts +114 -83
  27. package/src/services/progress-events.js +58 -0
  28. package/src/services/progress-row/method-card.js +20 -5
  29. package/src/services/sanity.js +1 -1
  30. package/src/services/sync/fetch.ts +10 -2
  31. package/src/services/sync/manager.ts +6 -0
  32. package/src/services/sync/models/ContentProgress.ts +5 -6
  33. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  34. package/src/services/sync/models/index.ts +1 -0
  35. package/src/services/sync/repositories/content-progress.ts +47 -25
  36. package/src/services/sync/repositories/index.ts +1 -0
  37. package/src/services/sync/repositories/practices.ts +16 -1
  38. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  39. package/src/services/sync/repository-proxy.ts +6 -0
  40. package/src/services/sync/retry.ts +12 -11
  41. package/src/services/sync/schema/index.ts +18 -3
  42. package/src/services/sync/store/index.ts +53 -8
  43. package/src/services/sync/store/push-coalescer.ts +3 -3
  44. package/src/services/sync/store-configs.ts +7 -1
  45. package/src/services/userActivity.js +0 -1
  46. package/test/HttpClient.test.js +6 -6
  47. package/test/awards/award-alacarte-observer.test.js +196 -0
  48. package/test/awards/award-auto-refresh.test.js +83 -0
  49. package/test/awards/award-calculations.test.js +33 -0
  50. package/test/awards/award-certificate-display.test.js +328 -0
  51. package/test/awards/award-collection-edge-cases.test.js +210 -0
  52. package/test/awards/award-collection-filtering.test.js +285 -0
  53. package/test/awards/award-completion-flow.test.js +213 -0
  54. package/test/awards/award-exclusion-handling.test.js +273 -0
  55. package/test/awards/award-multi-lesson.test.js +241 -0
  56. package/test/awards/award-observer-integration.test.js +325 -0
  57. package/test/awards/award-query-messages.test.js +438 -0
  58. package/test/awards/award-user-collection.test.js +412 -0
  59. package/test/awards/duplicate-prevention.test.js +118 -0
  60. package/test/awards/helpers/completion-mock.js +54 -0
  61. package/test/awards/helpers/index.js +3 -0
  62. package/test/awards/helpers/mock-setup.js +69 -0
  63. package/test/awards/helpers/progress-emitter.js +39 -0
  64. package/test/awards/message-generator.test.js +162 -0
  65. package/test/initializeTests.js +6 -0
  66. package/test/mockData/award-definitions.js +171 -0
  67. package/test/sync/models/award-database-integration.test.js +519 -0
  68. package/tools/generate-index.cjs +9 -0
@@ -0,0 +1,519 @@
1
+ import { Database } from '@nozbe/watermelondb'
2
+ import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
3
+ import { Q } from '@nozbe/watermelondb'
4
+ import schema, { SYNC_TABLES } from '../../../src/services/sync/schema'
5
+ import ContentProgress from '../../../src/services/sync/models/ContentProgress'
6
+ import Practice from '../../../src/services/sync/models/Practice'
7
+ import ContentLike from '../../../src/services/sync/models/ContentLike'
8
+ import UserAwardProgress from '../../../src/services/sync/models/UserAwardProgress'
9
+
10
+ describe('Award System - Direct Database Integration', () => {
11
+ let database
12
+ let progressCollection
13
+ let practiceCollection
14
+ let awardProgressCollection
15
+
16
+ beforeEach(async () => {
17
+ const adapter = new LokiJSAdapter({
18
+ schema,
19
+ useWebWorker: false,
20
+ useIncrementalIndexedDB: false,
21
+ dbName: `test_${Date.now()}_${Math.random()}`,
22
+ extraLokiOptions: {
23
+ autosave: false
24
+ }
25
+ })
26
+
27
+ database = new Database({
28
+ adapter,
29
+ modelClasses: [ContentProgress, Practice, ContentLike, UserAwardProgress]
30
+ })
31
+
32
+ progressCollection = database.collections.get(SYNC_TABLES.CONTENT_PROGRESS)
33
+ practiceCollection = database.collections.get(SYNC_TABLES.PRACTICES)
34
+ awardProgressCollection = database.collections.get(SYNC_TABLES.USER_AWARD_PROGRESS)
35
+ })
36
+
37
+ afterEach(async () => {
38
+ if (database) {
39
+ try {
40
+ await database.write(async () => {
41
+ await database.unsafeResetDatabase()
42
+ })
43
+ } catch (error) {
44
+ console.error('Error resetting database:', error)
45
+ }
46
+ }
47
+ })
48
+
49
+ describe('ContentProgress CRUD Operations', () => {
50
+ test('creates a progress record with started state', async () => {
51
+ const now = Math.floor(Date.now() / 1000)
52
+
53
+ await database.write(async () => {
54
+ await progressCollection.create(record => {
55
+ record._raw.id = '417045'
56
+ record._raw.content_id = 417045
57
+ record._raw.content_brand = 'drumeo'
58
+ record._raw.state = 'started'
59
+ record._raw.progress_percent = 50
60
+ record._raw.resume_time_seconds = 120
61
+ record._raw.created_at = now
62
+ record._raw.updated_at = now
63
+ record._raw._status = 'synced'
64
+ })
65
+ })
66
+
67
+ const record = await progressCollection.find('417045')
68
+ expect(record.content_id).toBe(417045)
69
+ expect(record.state).toBe('started')
70
+ expect(record.progress_percent).toBe(50)
71
+ })
72
+
73
+ test('updates progress record to completed state', async () => {
74
+ const now = Math.floor(Date.now() / 1000)
75
+
76
+ await database.write(async () => {
77
+ await progressCollection.create(record => {
78
+ record._raw.id = '417045'
79
+ record._raw.content_id = 417045
80
+ record._raw.content_brand = 'drumeo'
81
+ record._raw.state = 'started'
82
+ record._raw.progress_percent = 50
83
+ record._raw.resume_time_seconds = 0
84
+ record._raw.created_at = now
85
+ record._raw.updated_at = now
86
+ record._raw._status = 'synced'
87
+ })
88
+ })
89
+
90
+ await database.write(async () => {
91
+ const record = await progressCollection.find('417045')
92
+ await record.update(r => {
93
+ r._raw.state = 'completed'
94
+ r._raw.progress_percent = 100
95
+ r._raw.updated_at = now + 100
96
+ })
97
+ })
98
+
99
+ const updated = await progressCollection.find('417045')
100
+ expect(updated.state).toBe('completed')
101
+ expect(updated.progress_percent).toBe(100)
102
+ })
103
+
104
+ test('queries multiple completed lessons', async () => {
105
+ const now = Math.floor(Date.now() / 1000)
106
+ const lessonIds = [417045, 417046, 417047]
107
+
108
+ await database.write(async () => {
109
+ for (const lessonId of lessonIds) {
110
+ await progressCollection.create(record => {
111
+ record._raw.id = `${lessonId}`
112
+ record._raw.content_id = lessonId
113
+ record._raw.content_brand = 'drumeo'
114
+ record._raw.state = 'completed'
115
+ record._raw.progress_percent = 100
116
+ record._raw.resume_time_seconds = 0
117
+ record._raw.created_at = now
118
+ record._raw.updated_at = now
119
+ record._raw._status = 'synced'
120
+ })
121
+ }
122
+ })
123
+
124
+ const completed = await progressCollection
125
+ .query(Q.where('state', 'completed'))
126
+ .fetch()
127
+
128
+ expect(completed.length).toBe(3)
129
+ expect(completed.map(r => r.content_id)).toEqual(expect.arrayContaining(lessonIds))
130
+ })
131
+
132
+ test('filters by content_id', async () => {
133
+ const now = Math.floor(Date.now() / 1000)
134
+
135
+ await database.write(async () => {
136
+ await progressCollection.create(record => {
137
+ record._raw.id = '417045'
138
+ record._raw.content_id = 417045
139
+ record._raw.content_brand = 'drumeo'
140
+ record._raw.state = 'completed'
141
+ record._raw.progress_percent = 100
142
+ record._raw.resume_time_seconds = 0
143
+ record._raw.created_at = now
144
+ record._raw.updated_at = now
145
+ record._raw._status = 'synced'
146
+ })
147
+ })
148
+
149
+ const results = await progressCollection
150
+ .query(Q.where('content_id', 417045))
151
+ .fetch()
152
+
153
+ expect(results.length).toBe(1)
154
+ expect(results[0].content_id).toBe(417045)
155
+ })
156
+ })
157
+
158
+ describe('ContentPractice Operations', () => {
159
+ test('creates practice session record', async () => {
160
+ const now = Math.floor(Date.now() / 1000)
161
+
162
+ await database.write(async () => {
163
+ await practiceCollection.create(record => {
164
+ record._raw.id = `practice_417045_${now}`
165
+ record._raw.content_id = 417045
166
+ record._raw.duration_seconds = 1200
167
+ record._raw.created_at = now
168
+ record._raw.updated_at = now
169
+ record._raw._status = 'synced'
170
+ })
171
+ })
172
+
173
+ const records = await practiceCollection
174
+ .query(Q.where('content_id', 417045))
175
+ .fetch()
176
+
177
+ expect(records.length).toBe(1)
178
+ expect(records[0].duration_seconds).toBe(1200)
179
+ })
180
+
181
+ test('queries practice sessions for multiple lessons', async () => {
182
+ const now = Math.floor(Date.now() / 1000)
183
+ const lessonIds = [417045, 417046, 417047]
184
+
185
+ await database.write(async () => {
186
+ for (const lessonId of lessonIds) {
187
+ await practiceCollection.create(record => {
188
+ record._raw.id = `practice_${lessonId}_${now}`
189
+ record._raw.content_id = lessonId
190
+ record._raw.duration_seconds = 600
191
+ record._raw.created_at = now
192
+ record._raw.updated_at = now
193
+ record._raw._status = 'synced'
194
+ })
195
+ }
196
+ })
197
+
198
+ const allPractices = await practiceCollection
199
+ .query(Q.where('content_id', Q.oneOf(lessonIds)))
200
+ .fetch()
201
+
202
+ expect(allPractices.length).toBe(3)
203
+ })
204
+
205
+ test('calculates total practice time for lessons', async () => {
206
+ const now = Math.floor(Date.now() / 1000)
207
+ const lessonId = 417045
208
+
209
+ await database.write(async () => {
210
+ await practiceCollection.create(record => {
211
+ record._raw.id = `practice_${lessonId}_1`
212
+ record._raw.content_id = lessonId
213
+ record._raw.duration_seconds = 600
214
+ record._raw.created_at = now
215
+ record._raw.updated_at = now
216
+ record._raw._status = 'synced'
217
+ })
218
+
219
+ await practiceCollection.create(record => {
220
+ record._raw.id = `practice_${lessonId}_2`
221
+ record._raw.content_id = lessonId
222
+ record._raw.duration_seconds = 900
223
+ record._raw.created_at = now + 3600
224
+ record._raw.updated_at = now + 3600
225
+ record._raw._status = 'synced'
226
+ })
227
+ })
228
+
229
+ const practices = await practiceCollection
230
+ .query(Q.where('content_id', lessonId))
231
+ .fetch()
232
+
233
+ const totalSeconds = practices.reduce((sum, p) => sum + p.duration_seconds, 0)
234
+ const totalMinutes = Math.round(totalSeconds / 60)
235
+
236
+ expect(totalMinutes).toBe(25)
237
+ })
238
+ })
239
+
240
+ describe('UserAwardProgress Operations', () => {
241
+ test('creates award progress record with 0%', async () => {
242
+ const now = Math.floor(Date.now() / 1000)
243
+ const awardId = 'test-award-123'
244
+
245
+ await database.write(async () => {
246
+ await awardProgressCollection.create(record => {
247
+ record._raw.id = awardId
248
+ record._raw.award_id = awardId
249
+ record._raw.progress_percentage = 0
250
+ record._raw.completed_at = null
251
+ record._raw.progress_data = null
252
+ record._raw.completion_data = null
253
+ record._raw.created_at = now
254
+ record._raw.updated_at = now
255
+ record._raw._status = 'synced'
256
+ })
257
+ })
258
+
259
+ const record = await awardProgressCollection.find(awardId)
260
+ expect(record.progress_percentage).toBe(0)
261
+ expect(record.completed_at).toBeNull()
262
+ })
263
+
264
+ test('updates award progress with progress_data', async () => {
265
+ const now = Math.floor(Date.now() / 1000)
266
+ const awardId = 'test-award-123'
267
+
268
+ await database.write(async () => {
269
+ await awardProgressCollection.create(record => {
270
+ record._raw.id = awardId
271
+ record._raw.award_id = awardId
272
+ record._raw.progress_percentage = 0
273
+ record._raw.completed_at = null
274
+ record._raw.progress_data = null
275
+ record._raw.completion_data = null
276
+ record._raw.created_at = now
277
+ record._raw.updated_at = now
278
+ record._raw._status = 'synced'
279
+ })
280
+ })
281
+
282
+ const progressData = {
283
+ completedLessonIds: [417045, 417046],
284
+ totalLessons: 4,
285
+ completedCount: 2
286
+ }
287
+
288
+ await database.write(async () => {
289
+ const record = await awardProgressCollection.find(awardId)
290
+ await record.update(r => {
291
+ r._raw.progress_percentage = 50
292
+ r._raw.progress_data = JSON.stringify(progressData)
293
+ r._raw.updated_at = now + 100
294
+ })
295
+ })
296
+
297
+ const updated = await awardProgressCollection.find(awardId)
298
+ expect(updated.progress_percentage).toBe(50)
299
+ expect(updated.progress_data).toEqual(progressData)
300
+ expect(updated.progress_data.completedLessonIds).toEqual([417045, 417046])
301
+ })
302
+
303
+ test('completes award with completion_data', async () => {
304
+ const now = Math.floor(Date.now() / 1000)
305
+ const awardId = 'test-award-123'
306
+
307
+ await database.write(async () => {
308
+ await awardProgressCollection.create(record => {
309
+ record._raw.id = awardId
310
+ record._raw.award_id = awardId
311
+ record._raw.progress_percentage = 50
312
+ record._raw.completed_at = null
313
+ record._raw.progress_data = JSON.stringify({
314
+ completedLessonIds: [417045, 417046],
315
+ totalLessons: 4,
316
+ completedCount: 2
317
+ })
318
+ record._raw.completion_data = null
319
+ record._raw.created_at = now
320
+ record._raw.updated_at = now
321
+ record._raw._status = 'synced'
322
+ })
323
+ })
324
+
325
+ const completionData = {
326
+ content_title: 'Blues Foundations',
327
+ completed_at: new Date().toISOString(),
328
+ days_user_practiced: 7,
329
+ practice_minutes: 180
330
+ }
331
+
332
+ await database.write(async () => {
333
+ const record = await awardProgressCollection.find(awardId)
334
+ await record.update(r => {
335
+ r._raw.progress_percentage = 100
336
+ r._raw.completed_at = now + 200
337
+ r._raw.completion_data = JSON.stringify(completionData)
338
+ r._raw.updated_at = now + 200
339
+ })
340
+ })
341
+
342
+ const completed = await awardProgressCollection.find(awardId)
343
+ expect(completed.progress_percentage).toBe(100)
344
+ expect(completed.completed_at).toBe(now + 200)
345
+ expect(completed.completion_data).toEqual(completionData)
346
+ expect(completed.isCompleted).toBe(true)
347
+ })
348
+
349
+ test('queries in-progress awards', async () => {
350
+ const now = Math.floor(Date.now() / 1000)
351
+
352
+ await database.write(async () => {
353
+ await awardProgressCollection.create(record => {
354
+ record._raw.id = 'award-1'
355
+ record._raw.award_id = 'award-1'
356
+ record._raw.progress_percentage = 50
357
+ record._raw.completed_at = null
358
+ record._raw.progress_data = null
359
+ record._raw.completion_data = null
360
+ record._raw.created_at = now
361
+ record._raw.updated_at = now
362
+ record._raw._status = 'synced'
363
+ })
364
+
365
+ await awardProgressCollection.create(record => {
366
+ record._raw.id = 'award-2'
367
+ record._raw.award_id = 'award-2'
368
+ record._raw.progress_percentage = 100
369
+ record._raw.completed_at = now
370
+ record._raw.progress_data = null
371
+ record._raw.completion_data = JSON.stringify({})
372
+ record._raw.created_at = now
373
+ record._raw.updated_at = now
374
+ record._raw._status = 'synced'
375
+ })
376
+ })
377
+
378
+ const inProgress = await awardProgressCollection
379
+ .query(
380
+ Q.where('progress_percentage', Q.gt(0)),
381
+ Q.where('completed_at', Q.eq(null))
382
+ )
383
+ .fetch()
384
+
385
+ expect(inProgress.length).toBe(1)
386
+ expect(inProgress[0].award_id).toBe('award-1')
387
+ })
388
+
389
+ test('queries completed awards', async () => {
390
+ const now = Math.floor(Date.now() / 1000)
391
+
392
+ await database.write(async () => {
393
+ await awardProgressCollection.create(record => {
394
+ record._raw.id = 'award-1'
395
+ record._raw.award_id = 'award-1'
396
+ record._raw.progress_percentage = 100
397
+ record._raw.completed_at = now
398
+ record._raw.progress_data = null
399
+ record._raw.completion_data = JSON.stringify({})
400
+ record._raw.created_at = now
401
+ record._raw.updated_at = now
402
+ record._raw._status = 'synced'
403
+ })
404
+
405
+ await awardProgressCollection.create(record => {
406
+ record._raw.id = 'award-2'
407
+ record._raw.award_id = 'award-2'
408
+ record._raw.progress_percentage = 50
409
+ record._raw.completed_at = null
410
+ record._raw.progress_data = null
411
+ record._raw.completion_data = null
412
+ record._raw.created_at = now
413
+ record._raw.updated_at = now
414
+ record._raw._status = 'synced'
415
+ })
416
+ })
417
+
418
+ const completed = await awardProgressCollection
419
+ .query(Q.where('completed_at', Q.notEq(null)))
420
+ .fetch()
421
+
422
+ expect(completed.length).toBe(1)
423
+ expect(completed[0].award_id).toBe('award-1')
424
+ expect(completed[0].isCompleted).toBe(true)
425
+ })
426
+ })
427
+
428
+ describe('Cross-Collection Queries', () => {
429
+ test('finds progress and practice for same lesson', async () => {
430
+ const now = Math.floor(Date.now() / 1000)
431
+ const lessonId = 417045
432
+
433
+ await database.write(async () => {
434
+ await progressCollection.create(record => {
435
+ record._raw.id = `${lessonId}`
436
+ record._raw.content_id = lessonId
437
+ record._raw.content_brand = 'drumeo'
438
+ record._raw.state = 'completed'
439
+ record._raw.progress_percent = 100
440
+ record._raw.resume_time_seconds = 0
441
+ record._raw.created_at = now
442
+ record._raw.updated_at = now
443
+ record._raw._status = 'synced'
444
+ })
445
+
446
+ await practiceCollection.create(record => {
447
+ record._raw.id = `practice_${lessonId}`
448
+ record._raw.content_id = lessonId
449
+ record._raw.duration_seconds = 1200
450
+ record._raw.created_at = now
451
+ record._raw.updated_at = now
452
+ record._raw._status = 'synced'
453
+ })
454
+ })
455
+
456
+ const progress = await progressCollection.find(`${lessonId}`)
457
+ const practices = await practiceCollection
458
+ .query(Q.where('content_id', lessonId))
459
+ .fetch()
460
+
461
+ expect(progress.state).toBe('completed')
462
+ expect(practices.length).toBe(1)
463
+ expect(practices[0].duration_seconds).toBe(1200)
464
+ })
465
+
466
+ test('simulates award progress flow: lessons → award progress', async () => {
467
+ const now = Math.floor(Date.now() / 1000)
468
+ const awardId = 'test-award-123'
469
+ const lessonIds = [417045, 417046, 417047, 417048]
470
+
471
+ await database.write(async () => {
472
+ for (const lessonId of lessonIds.slice(0, 2)) {
473
+ await progressCollection.create(record => {
474
+ record._raw.id = `${lessonId}`
475
+ record._raw.content_id = lessonId
476
+ record._raw.content_brand = 'drumeo'
477
+ record._raw.state = 'completed'
478
+ record._raw.progress_percent = 100
479
+ record._raw.resume_time_seconds = 0
480
+ record._raw.created_at = now
481
+ record._raw.updated_at = now
482
+ record._raw._status = 'synced'
483
+ })
484
+ }
485
+ })
486
+
487
+ const completedLessons = await progressCollection
488
+ .query(
489
+ Q.where('content_id', Q.oneOf(lessonIds)),
490
+ Q.where('state', 'completed')
491
+ )
492
+ .fetch()
493
+
494
+ const progressPercentage = Math.round((completedLessons.length / lessonIds.length) * 100)
495
+
496
+ await database.write(async () => {
497
+ await awardProgressCollection.create(record => {
498
+ record._raw.id = awardId
499
+ record._raw.award_id = awardId
500
+ record._raw.progress_percentage = progressPercentage
501
+ record._raw.completed_at = null
502
+ record._raw.progress_data = JSON.stringify({
503
+ completedLessonIds: completedLessons.map(l => l.content_id),
504
+ totalLessons: lessonIds.length,
505
+ completedCount: completedLessons.length
506
+ })
507
+ record._raw.completion_data = null
508
+ record._raw.created_at = now
509
+ record._raw.updated_at = now
510
+ record._raw._status = 'synced'
511
+ })
512
+ })
513
+
514
+ const award = await awardProgressCollection.find(awardId)
515
+ expect(award.progress_percentage).toBe(50)
516
+ expect(award.progress_data.completedCount).toBe(2)
517
+ })
518
+ })
519
+ })
@@ -87,6 +87,15 @@ treeElements.forEach((treeNode) => {
87
87
  const subDir = fs.readdirSync(filePath)
88
88
  subDir.forEach((subFile) => {
89
89
  const subFilePath = path.join(servicesDir, treeNode, subFile)
90
+
91
+ // Skip directories and check for .indexignore in nested directories
92
+ if (fs.lstatSync(subFilePath).isDirectory()) {
93
+ if (fs.existsSync(path.join(subFilePath, '.indexignore'))) {
94
+ console.log(`Skipping nested directory: ${treeNode}/${subFile} due to .indexignore`)
95
+ }
96
+ return
97
+ }
98
+
90
99
  addFunctionsToFileExports(subFilePath, treeNode + '/' + subFile)
91
100
  })
92
101
  }