musora-content-services 2.160.4 → 2.161.2

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 (40) hide show
  1. package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
  2. package/CHANGELOG.md +19 -0
  3. package/package.json +1 -1
  4. package/src/contentTypeConfig.js +13 -15
  5. package/src/index.d.ts +6 -2
  6. package/src/index.js +6 -2
  7. package/src/infrastructure/sanity/README.md +230 -0
  8. package/src/infrastructure/sanity/SanityClient.ts +105 -0
  9. package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
  10. package/src/infrastructure/sanity/examples/usage.ts +101 -0
  11. package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
  12. package/src/infrastructure/sanity/index.ts +19 -0
  13. package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
  14. package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
  15. package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
  16. package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
  17. package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
  18. package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
  19. package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
  20. package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
  21. package/src/lib/sanity/decorators/base.ts +142 -0
  22. package/src/lib/sanity/decorators/examples.ts +229 -0
  23. package/src/lib/sanity/decorators/navigate-to.ts +139 -0
  24. package/src/lib/sanity/decorators/need-access.ts +40 -0
  25. package/src/lib/sanity/decorators/page-type.ts +35 -0
  26. package/src/services/awards/award-query.js +71 -0
  27. package/src/services/contentAggregator.js +1 -1
  28. package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
  29. package/src/services/user/memberships.ts +46 -34
  30. package/src/services/user/profile.ts +66 -0
  31. package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
  32. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
  33. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
  34. package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
  35. package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
  36. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
  37. package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
  38. package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
  39. package/.claude/settings.local.json +0 -23
  40. package/src/services/user/profile.js +0 -43
@@ -0,0 +1,266 @@
1
+ let mockProgressRecords: any[] = []
2
+ let mockLastInteracted: number | null = null
3
+
4
+ jest.mock('../../../../../src/services/sync/repository-proxy', () => {
5
+ const mockFns = {
6
+ contentProgress: {
7
+ getOneProgressByContentId: jest.fn().mockImplementation((contentId) => {
8
+ const record = mockProgressRecords.find((r) => r.content_id === contentId)
9
+ return Promise.resolve({ data: record || null })
10
+ }),
11
+ getSomeProgressByContentIds: jest.fn().mockImplementation((contentIds) => {
12
+ const records = mockProgressRecords.filter((r) => contentIds.includes(r.content_id))
13
+ return Promise.resolve({ data: records })
14
+ }),
15
+ mostRecentlyUpdatedId: jest.fn().mockImplementation(() => {
16
+ return Promise.resolve({ data: mockLastInteracted })
17
+ }),
18
+ },
19
+ practices: {
20
+ queryAll: jest.fn().mockResolvedValue({ data: [] }),
21
+ getAll: jest.fn().mockResolvedValue({ data: [] }),
22
+ },
23
+ }
24
+ return { default: mockFns, ...mockFns }
25
+ })
26
+
27
+ jest.mock('../../../../../src/services/content-org/learning-paths', () => ({
28
+ getDailySession: jest.fn().mockResolvedValue(null),
29
+ onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
30
+ }))
31
+
32
+ jest.mock('../../../../../src/services/sanity.js', () => ({
33
+ getHierarchy: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
34
+ getHierarchies: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
35
+ getSanityDate: jest.fn((date: Date) => date.toISOString()),
36
+ }))
37
+
38
+ import { initializeTestService } from '../../../../initializeTests.js'
39
+ import {
40
+ NAVIGATE_TO_FIELD,
41
+ decorateNavigateTo,
42
+ navigateToDecorator,
43
+ type NavigateToDecoratable,
44
+ } from '../../../../../src/lib/sanity/decorators/navigate-to'
45
+ import { COLLECTION_TYPE } from '../../../../../src/services/sync/models/ContentProgress'
46
+
47
+ const child = (id: number, type = 'course-lesson'): NavigateToDecoratable => ({
48
+ id,
49
+ type,
50
+ brand: 'drumeo',
51
+ thumbnail: '',
52
+ published_on: null,
53
+ status: 'published',
54
+ })
55
+
56
+ const parent = (
57
+ id: number,
58
+ type: string,
59
+ children: NavigateToDecoratable[]
60
+ ): NavigateToDecoratable => ({
61
+ id,
62
+ type,
63
+ brand: 'drumeo',
64
+ thumbnail: '',
65
+ published_on: null,
66
+ status: 'published',
67
+ children,
68
+ })
69
+
70
+ beforeEach(() => {
71
+ jest.clearAllMocks()
72
+ initializeTestService()
73
+ mockProgressRecords = []
74
+ mockLastInteracted = null
75
+ })
76
+
77
+ describe('navigate-to decorator', () => {
78
+ describe('navigateToDecorator (const)', () => {
79
+ test('field is navigateTo', () => {
80
+ expect(navigateToDecorator.field).toBe('navigateTo')
81
+ expect(NAVIGATE_TO_FIELD).toBe('navigateTo')
82
+ })
83
+ })
84
+
85
+ describe('decorateNavigateTo', () => {
86
+ test('non-navigable type → null', async () => {
87
+ const item = parent(1, 'lesson', [child(101)])
88
+ const result = await decorateNavigateTo(item)
89
+ expect(result.navigateTo).toBeNull()
90
+ })
91
+
92
+ test('empty children → null', async () => {
93
+ const item = parent(1, 'course', [])
94
+ const result = await decorateNavigateTo(item)
95
+ expect(result.navigateTo).toBeNull()
96
+ })
97
+
98
+ test('not-started course → first child', async () => {
99
+ const item = parent(1, 'course', [child(101), child(102)])
100
+ const result = await decorateNavigateTo(item)
101
+ expect(result.navigateTo).toMatchObject({ id: 101, child: null })
102
+ })
103
+
104
+ test('course started, lastInteracted started → lastInteracted', async () => {
105
+ mockProgressRecords = [
106
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
107
+ { content_id: 101, state: 'started', progress_percent: 30, updated_at: 900 },
108
+ { content_id: 102, state: 'started', progress_percent: 10, updated_at: 1000 },
109
+ ]
110
+ mockLastInteracted = 101
111
+ const item = parent(1, 'course', [child(101), child(102)])
112
+ const result = await decorateNavigateTo(item)
113
+ expect(result.navigateTo).toMatchObject({ id: 101 })
114
+ })
115
+
116
+ test('course started, lastInteracted completed → first incomplete after', async () => {
117
+ mockProgressRecords = [
118
+ { content_id: 1, state: 'started', progress_percent: 60, updated_at: 1000 },
119
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
120
+ { content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
121
+ { content_id: 103, state: 'started', progress_percent: 20, updated_at: 800 },
122
+ ]
123
+ mockLastInteracted = 101
124
+ const item = parent(1, 'course', [child(101), child(102), child(103)])
125
+ const result = await decorateNavigateTo(item)
126
+ expect(result.navigateTo).toMatchObject({ id: 103 })
127
+ })
128
+
129
+ test('guided-course started → first incomplete regardless of lastInteracted', async () => {
130
+ mockProgressRecords = [
131
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
132
+ { content_id: 101, state: '', progress_percent: 0, updated_at: 0 },
133
+ { content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
134
+ { content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
135
+ ]
136
+ mockLastInteracted = 102
137
+ const item = parent(1, 'guided-course', [child(101), child(102), child(103)])
138
+ const result = await decorateNavigateTo(item)
139
+ expect(result.navigateTo).toMatchObject({ id: 101 })
140
+ })
141
+
142
+ test('learning-path-v2 started → first incomplete regardless of lastInteracted', async () => {
143
+ mockProgressRecords = [
144
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
145
+ { content_id: 101, state: '', progress_percent: 0, updated_at: 0 },
146
+ { content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
147
+ { content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
148
+ ]
149
+ mockLastInteracted = 102
150
+ const item = parent(1, COLLECTION_TYPE.LEARNING_PATH, [child(101), child(102), child(103)])
151
+ const result = await decorateNavigateTo(item)
152
+ expect(result.navigateTo).toMatchObject({ id: 101 })
153
+ })
154
+
155
+ test('two-depth: not-started course-collection nests first child nav', async () => {
156
+ const courseChild = parent(101, 'course', [child(201), child(202)])
157
+ const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
158
+ const result = await decorateNavigateTo(collection)
159
+ expect(result.navigateTo).toMatchObject({
160
+ id: 101,
161
+ child: { id: 201 },
162
+ })
163
+ })
164
+
165
+ test('two-depth: started course-collection nests lastInteracted child nav', async () => {
166
+ mockProgressRecords = [
167
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
168
+ { content_id: 101, state: 'started', progress_percent: 50, updated_at: 1000 },
169
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
170
+ ]
171
+ mockLastInteracted = 102
172
+ const courseA = parent(101, 'course', [child(201), child(202)])
173
+ const courseB = parent(102, 'course', [child(301), child(302)])
174
+ const collection = parent(1, 'course-collection', [courseA, courseB])
175
+ const result = await decorateNavigateTo(collection)
176
+ expect(result.navigateTo).toMatchObject({
177
+ id: 102,
178
+ child: { id: 301 },
179
+ })
180
+ })
181
+
182
+ test('decorates every item in an array', async () => {
183
+ const items = [parent(1, 'course', [child(101)]), parent(2, 'lesson', [child(201)])]
184
+ const result = await decorateNavigateTo(items)
185
+ expect(result[0].navigateTo).toMatchObject({ id: 101 })
186
+ expect(result[1].navigateTo).toBeNull()
187
+ })
188
+
189
+ test('returns the same reference it was given', async () => {
190
+ const items = [parent(1, 'course', [child(101)])]
191
+ const result = await decorateNavigateTo(items)
192
+ expect(result).toBe(items)
193
+ })
194
+
195
+ test('navigateToDecorator.compute fires once per top-level item, never on descendants', async () => {
196
+ const spy = jest.spyOn(navigateToDecorator, 'compute')
197
+ const courseChild = parent(101, 'course', [child(201), child(202)])
198
+ const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
199
+ const standalone = parent(2, 'course', [child(301)])
200
+ await decorateNavigateTo([collection, standalone])
201
+ expect(spy).toHaveBeenCalledTimes(2)
202
+ expect(spy).toHaveBeenCalledWith(collection)
203
+ expect(spy).toHaveBeenCalledWith(standalone)
204
+ spy.mockRestore()
205
+ })
206
+
207
+ test('descendants of decorated items do not receive navigateTo field', async () => {
208
+ const item = parent(1, 'course', [child(101), child(102)])
209
+ const result = await decorateNavigateTo(item)
210
+ expect(result.navigateTo).not.toBeNull()
211
+ const children = result.children as NavigateToDecoratable[]
212
+ expect((children[0] as Record<string, unknown>).navigateTo).toBeUndefined()
213
+ expect((children[1] as Record<string, unknown>).navigateTo).toBeUndefined()
214
+ })
215
+
216
+ test('skill-pack uses course flow', async () => {
217
+ mockProgressRecords = [
218
+ { content_id: 1, state: 'started', progress_percent: 60, updated_at: 1000 },
219
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
220
+ { content_id: 102, state: 'started', progress_percent: 20, updated_at: 1000 },
221
+ ]
222
+ mockLastInteracted = 102
223
+ const item = parent(1, 'skill-pack', [child(101), child(102)])
224
+ const result = await decorateNavigateTo(item)
225
+ expect(result.navigateTo).toMatchObject({ id: 102 })
226
+ })
227
+
228
+ test('song-tutorial uses course flow', async () => {
229
+ mockProgressRecords = [
230
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
231
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
232
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
233
+ ]
234
+ mockLastInteracted = 101
235
+ const item = parent(1, 'song-tutorial', [child(101), child(102)])
236
+ const result = await decorateNavigateTo(item)
237
+ expect(result.navigateTo).toMatchObject({ id: 102 })
238
+ })
239
+
240
+ test('two-depth started but lastInteracted child not in collection → null', async () => {
241
+ mockProgressRecords = [
242
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
243
+ ]
244
+ mockLastInteracted = 999
245
+ const courseA = parent(101, 'course', [child(201)])
246
+ const collection = parent(1, 'course-collection', [courseA])
247
+ const result = await decorateNavigateTo(collection)
248
+ expect(result.navigateTo).toBeNull()
249
+ })
250
+
251
+ test('output shape matches NavigateTo interface', async () => {
252
+ const item = parent(1, 'course', [child(101)])
253
+ const result = await decorateNavigateTo(item)
254
+ expect(result.navigateTo).toEqual({
255
+ id: 101,
256
+ type: 'course-lesson',
257
+ brand: 'drumeo',
258
+ thumbnail: '',
259
+ published_on: null,
260
+ status: 'published',
261
+ child: null,
262
+ collection: null,
263
+ })
264
+ })
265
+ })
266
+ })
@@ -0,0 +1,89 @@
1
+ const mockDoesUserNeedAccess = jest.fn()
2
+
3
+ jest.mock('../../../../../src/services/permissions', () => ({
4
+ getPermissionsAdapter: () => ({
5
+ doesUserNeedAccess: mockDoesUserNeedAccess,
6
+ }),
7
+ }))
8
+
9
+ import {
10
+ NEED_ACCESS_FIELD,
11
+ accessDecorator,
12
+ decorateAccess,
13
+ type AccessDecoratable,
14
+ } from '../../../../../src/lib/sanity/decorators/need-access'
15
+
16
+ const perms = {
17
+ permissions: [78, 91],
18
+ isAdmin: false,
19
+ isModerator: false,
20
+ isABasicMember: false,
21
+ }
22
+
23
+ describe('need-access decorator', () => {
24
+ beforeEach(() => {
25
+ mockDoesUserNeedAccess.mockReset()
26
+ })
27
+
28
+ describe('accessDecorator (factory)', () => {
29
+ test('field is need_access', () => {
30
+ const dec = accessDecorator(perms)
31
+ expect(dec.field).toBe('need_access')
32
+ expect(NEED_ACCESS_FIELD).toBe('need_access')
33
+ })
34
+
35
+ test('compute delegates to adapter with item + perms', () => {
36
+ mockDoesUserNeedAccess.mockReturnValue(true)
37
+ const dec = accessDecorator(perms)
38
+ const item: AccessDecoratable = { permission_id: [78] }
39
+
40
+ const result = dec.compute(item)
41
+
42
+ expect(result).toBe(true)
43
+ expect(mockDoesUserNeedAccess).toHaveBeenCalledWith(item, perms)
44
+ })
45
+ })
46
+
47
+ describe('decorateAccess', () => {
48
+ test('writes adapter result onto each item', () => {
49
+ mockDoesUserNeedAccess.mockImplementation((item) =>
50
+ item.permission_id?.includes(78) ? false : true
51
+ )
52
+
53
+ const items: AccessDecoratable[] = [
54
+ { permission_id: [78] },
55
+ { permission_id: [999] },
56
+ {},
57
+ ]
58
+ const decorated = decorateAccess(items, perms)
59
+
60
+ expect(decorated.map((i) => i.need_access)).toEqual([false, true, true])
61
+ })
62
+
63
+ test('decorates children recursively', () => {
64
+ mockDoesUserNeedAccess.mockReturnValue(true)
65
+ const tree: AccessDecoratable = {
66
+ permission_id: [],
67
+ children: [
68
+ {
69
+ permission_id: [],
70
+ children: [{ permission_id: [] }],
71
+ },
72
+ ],
73
+ }
74
+
75
+ const decorated = decorateAccess(tree, perms)
76
+
77
+ expect(decorated.need_access).toBe(true)
78
+ expect(decorated.children![0].need_access).toBe(true)
79
+ expect(decorated.children![0].children![0].need_access).toBe(true)
80
+ })
81
+
82
+ test('returns the same reference it was given', () => {
83
+ mockDoesUserNeedAccess.mockReturnValue(false)
84
+ const items: AccessDecoratable[] = [{ permission_id: [78] }]
85
+ const decorated = decorateAccess(items, perms)
86
+ expect(decorated).toBe(items)
87
+ })
88
+ })
89
+ })
@@ -0,0 +1,81 @@
1
+ import {
2
+ PAGE_TYPE_FIELD,
3
+ decoratePageType,
4
+ pageTypeDecorator,
5
+ type PageTypeDecoratable,
6
+ } from '../../../../../src/lib/sanity/decorators/page-type'
7
+
8
+ describe('page-type decorator', () => {
9
+ describe('pageTypeDecorator (const)', () => {
10
+ test('field is page_type', () => {
11
+ expect(pageTypeDecorator.field).toBe('page_type')
12
+ expect(PAGE_TYPE_FIELD).toBe('page_type')
13
+ })
14
+
15
+ test.each([
16
+ ['song', 'song'],
17
+ ['play-along', 'song'],
18
+ ['jam-track', 'song'],
19
+ ['song-tutorial', 'song'],
20
+ ['song-tutorial-lesson', 'song'],
21
+ ['course', 'lesson'],
22
+ ['workout', 'lesson'],
23
+ ['', 'lesson'],
24
+ ])('type=%j -> %s', (type, expected) => {
25
+ expect(pageTypeDecorator.compute({ type })).toBe(expected)
26
+ })
27
+
28
+ test('missing type falls through to lesson', () => {
29
+ expect(pageTypeDecorator.compute({})).toBe('lesson')
30
+ })
31
+ })
32
+
33
+ describe('decoratePageType', () => {
34
+ test('decorates a single object', () => {
35
+ const item: PageTypeDecoratable = { type: 'song' }
36
+ const decorated = decoratePageType(item)
37
+ expect(decorated.page_type).toBe('song')
38
+ })
39
+
40
+ test('decorates every item in an array', () => {
41
+ const items: PageTypeDecoratable[] = [
42
+ { type: 'song' },
43
+ { type: 'course' },
44
+ { type: 'play-along' },
45
+ ]
46
+ const decorated = decoratePageType(items)
47
+ expect(decorated.map((i) => i.page_type)).toEqual(['song', 'lesson', 'song'])
48
+ })
49
+
50
+ test('decorates nested children up to depth 3', () => {
51
+ const tree: PageTypeDecoratable = {
52
+ type: 'course',
53
+ children: [
54
+ {
55
+ type: 'song',
56
+ children: [
57
+ {
58
+ type: 'play-along',
59
+ children: [{ type: 'song' }],
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ }
65
+ const decorated = decoratePageType(tree)
66
+ expect(decorated.page_type).toBe('lesson')
67
+ const lvl1 = decorated.children![0]
68
+ const lvl2 = lvl1.children![0]
69
+ const lvl3 = lvl2.children![0]
70
+ expect(lvl1.page_type).toBe('song')
71
+ expect(lvl2.page_type).toBe('song')
72
+ expect(lvl3.page_type).toBeUndefined()
73
+ })
74
+
75
+ test('preserves the input reference', () => {
76
+ const items: PageTypeDecoratable[] = [{ type: 'song' }]
77
+ const decorated = decoratePageType(items)
78
+ expect(decorated).toBe(items)
79
+ })
80
+ })
81
+ })
@@ -1,23 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npx jest *)",
5
- "Bash(npx tsc *)",
6
- "Skill(counselors)",
7
- "Bash(counselors ls *)",
8
- "Bash(counselors groups *)",
9
- "Bash(counselors run *)",
10
- "Bash(npm test *)",
11
- "Bash(gh pr *)",
12
- "Bash(gh api *)",
13
- "Bash(mkdir -p /tmp/pr-review-v2)",
14
- "Read(//tmp/pr-review-v2/**)",
15
- "Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
16
- "Bash(echo \"no AGENTS.md\")",
17
- "Bash(echo \"exit=$?\")",
18
- "Bash(git checkout *)",
19
- "Skill(pr)",
20
- "Skill(create-decision)"
21
- ]
22
- }
23
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * @module UserProfile
3
- */
4
- import { globalConfig } from '../config.js'
5
- import { GET, DELETE } from '../../infrastructure/http/HttpClient.ts'
6
- import { calculateLongestStreaks } from '../userActivity.js'
7
- import './types.js'
8
-
9
- const baseUrl = `/api/user-management-system`
10
-
11
- /**
12
- * @param {number|null} userId - The user ID to reset permissions for.
13
- * @returns {Promise<OtherStatsDTO>}
14
- */
15
- export async function otherStats(userId = globalConfig.sessionConfig.userId) {
16
- const [stats, longestStreaks] = await Promise.all([
17
- GET(`${baseUrl}/v1/users/${userId}/statistics`),
18
- calculateLongestStreaks(userId),
19
- ])
20
-
21
- return {
22
- ...stats,
23
- longest_day_streak: {
24
- type: 'day',
25
- length: longestStreaks.longestDailyStreak,
26
- },
27
- longest_week_streak: {
28
- type: 'week',
29
- length: longestStreaks.longestWeeklyStreak,
30
- },
31
- total_practice_time: longestStreaks.totalPracticeSeconds + (stats.v1_practice_time ?? 0),
32
- }
33
- }
34
-
35
- /**
36
- * Delete profile picture for the authenticated user
37
- *
38
- * @returns {Promise<void>}
39
- */
40
- export async function deleteProfilePicture() {
41
- const url = `${baseUrl}/v1/users/profile_picture`
42
- await DELETE(url)
43
- }